diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 0ca9292..4643d82 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -63,7 +63,7 @@ body: - Bundled with Qoder Desktop - Host marketplace or plugin manager - Qwen Code extension - - Cursor source-local --plugin-dir + - Cursor session evidence (installation unavailable) - Pi package (pi install or pi -e) - npm package or standalone CLI - Source checkout diff --git a/README.md b/README.md index c5434d1..0038587 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,32 @@ each host, except that Qoder CLI can use the version bundled with Qoder Desktop. After installing or updating a plugin, start a new session or task so the host reloads its plugin inventory. +### Inspect and plan plugin lifecycle changes + +The standalone CLI can inspect local Better Harness installation evidence for +every host without contacting a registry or changing host configuration: + +```bash +better-harness plugin status --host all +better-harness doctor --platform all +``` + +Build a host-specific install, update, or removal plan before using that host's +native UI or CLI. Plans preserve native steps as typed argv data for deliberate +external execution; the human view does not turn them into shell command +strings, and Better Harness does not execute them: + +```bash +better-harness plugin plan install --host qwen --surface cli --scope user +better-harness plugin verify --host qwen --surface cli +``` + +Host differences remain explicit. Qoder Desktop is bundled, Cursor is +session-only while its native command contract is being reconciled, Pi +lifecycle commands without current native evidence remain manual or +unavailable, and WorkBuddy has no managed Better Harness plugin lifecycle +surface. + ### Claude Code Register this repository as a Claude Code marketplace: @@ -268,35 +294,40 @@ session in the repository you want to analyze and run the report prompt: /better-harness analyze this project's AI coding workflow and generate an evidence-backed report ``` -Only when using Qoder CLI without Qoder Desktop, add this repository as a -marketplace and install Better Harness manually: +Only when using Qoder CLI without Qoder Desktop, inspect the current manual +installation disposition before following Qoder's native marketplace flow: ```bash -qodercli plugin marketplace add \ - 'https://github.com/QoderAI/better-harness.git' -qodercli plugin install better-harness@better-harness +better-harness plugin plan install --host qoder --surface cli --scope user ``` -Verify the manual installation: +The planner does not emit the older install syntax because the current native +help and this repository's historical documentation disagree. After a manual +installation, verify only the currently observed inventory command: ```bash qodercli plugin list +better-harness plugin verify --host qoder --surface cli ``` Then start a new Qoder CLI session before using `/better-harness`. ### Cursor -The Cursor plugin is not published to the marketplace yet. Load the -source-local plugin for one Cursor Agent session: +The Cursor plugin is not published to the marketplace. The repository carries +the source-local manifest, but the current local Cursor help does not verify the +historical `--plugin-dir` contract. Better Harness therefore reports the +installation plan as unavailable instead of emitting that command: ```bash git clone https://github.com/QoderAI/better-harness.git -cursor-agent --plugin-dir /path/to/better-harness +better-harness plugin plan install --host cursor --surface agent --scope session ``` Cursor session evidence is supported through workspace-matched transcripts, -metadata, and audit logs. Partial or unavailable coverage remains explicit. +metadata, and audit logs. A session that was loaded through a separately +verified native route can be checked with `better-harness plugin verify --host +cursor --surface agent`; partial or unavailable coverage remains explicit. ### GitHub Copilot @@ -356,8 +387,11 @@ pi -e git:github.com/QoderAI/better-harness ``` Pi discovers the `better-harness` Skill and the `/better-harness` prompt -template through the `pi` manifest in `package.json`. Start a new Pi session -in the repository you want to analyze and run the report prompt: +template through the `pi` manifest in `package.json`. After a persistent +`pi install`, start a new Pi session in the repository you want to analyze. For +the single-run `pi -e` form, launch it from that repository and run the report +prompt in that same Pi session; opening another session drops the temporary +package: ```text /better-harness analyze this project's AI coding workflow and generate an evidence-backed report diff --git a/README.zh-CN.md b/README.zh-CN.md index 599ff2e..faed3be 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -149,6 +149,29 @@ Better Harness 开放了三个相互关联的层次,而不只是一个斜杠 需要为每个宿主单独安装 Better Harness。安装或更新插件后,请启动新的会话或任务, 让宿主重新加载插件清单。 +### 检查并规划插件生命周期变更 + +独立 CLI 可以检查所有宿主的本地 Better Harness 安装证据,不访问远程注册表, +也不修改宿主配置: + +```bash +better-harness plugin status --host all +better-harness doctor --platform all +``` + +在使用宿主原生 UI 或 CLI 前,可以先生成指定宿主的安装、更新或移除计划。 +计划会把原生步骤保留为带类型的 argv 数据,供用户审阅后在外部有意执行; +人类可读视图不会把它们拼成 shell 命令字符串,Better Harness 也不会执行这些步骤: + +```bash +better-harness plugin plan install --host qwen --surface cli --scope user +better-harness plugin verify --host qwen --surface cli +``` + +宿主差异会保持显式:Qoder Desktop 为内置分发;Cursor 在原生命令合同完成核对前 +只保留会话级状态;Pi 缺少当前原生证据的生命周期操作会标记为手工或不可用; +WorkBuddy 没有可管理的 Better Harness 插件生命周期入口。 + ### Claude Code 将本仓库注册为 Claude Code Marketplace: @@ -255,34 +278,37 @@ Better Harness 已内置于 [Qoder](https://qoder.com/) 桌面应用,因此无 /better-harness 分析此项目的 AI 编码工作流并生成基于证据的报告 ``` -只有在未安装 Qoder Desktop、单独使用 Qoder CLI 时,才需要手动添加本仓库作为 -Marketplace 并安装 Better Harness: +只有在未安装 Qoder Desktop、单独使用 Qoder CLI 时,才需要在遵循 Qoder 原生 +Marketplace 流程前检查当前手工安装状态: ```bash -qodercli plugin marketplace add \ - 'https://github.com/QoderAI/better-harness.git' -qodercli plugin install better-harness@better-harness +better-harness plugin plan install --host qoder --surface cli --scope user ``` -验证手动安装: +由于当前原生 help 与本仓库历史文档不一致,计划器不会输出旧版安装语法。 +手工安装后,只使用已经观察到的 inventory 命令验证: ```bash qodercli plugin list +better-harness plugin verify --host qoder --surface cli ``` 然后启动新的 Qoder CLI 会话,再使用 `/better-harness`。 ### Cursor -Cursor 插件尚未发布到 Marketplace。可以在单次 Cursor Agent 会话中从源码加载本地插件: +Cursor 插件尚未发布到 Marketplace。仓库包含源码本地 manifest,但当前本机 +Cursor help 没有验证历史 `--plugin-dir` 合同,因此 Better Harness 会把安装计划 +标记为不可用,而不会输出该命令: ```bash git clone https://github.com/QoderAI/better-harness.git -cursor-agent --plugin-dir /path/to/better-harness +better-harness plugin plan install --host cursor --surface agent --scope session ``` Cursor 会话证据来自与工作区匹配的会话记录、元数据和审计日志。 -覆盖范围不完整或不可用时会被明确标注。 +通过其他已验证原生路径加载的会话可以运行 `better-harness plugin verify --host +cursor --surface agent`;覆盖范围不完整或不可用时会被明确标注。 ### GitHub Copilot @@ -337,7 +363,9 @@ pi -e git:github.com/QoderAI/better-harness ``` Pi 通过 `package.json` 中的 `pi` manifest 发现 `better-harness` Skill 和 -`/better-harness` 提示模板。在需要分析的仓库中启动新的 Pi 会话,运行报告提示词: +`/better-harness` 提示模板。持久化执行 `pi install` 后,请在要分析的仓库中启动 +新的 Pi 会话。使用单次 `pi -e` 时,请从该仓库启动并在同一 Pi 会话中运行报告 +提示词;另开会话会丢失临时加载的包: ```text /better-harness 分析此项目的 AI 编码工作流并生成基于证据的报告 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 66a31a8..befb484 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,6 +32,67 @@ the target journey, contract, evidence, governance, and DX-measurement system. - New analyzers, scoring signals, and hook helpers should own modules when they represent distinct concerns. - Executable behavior belongs in business-named `scripts//`; do not create `scripts/core/`. - Existing `scripts/core-change-watch/` and `scripts/session-analysis/` remain live owners until a tested migration lands. +- `scripts/plugin-lifecycle/` owns read-only Better Harness lifecycle status, + deterministic plans, and verification. It composes only the public + `agent-customize` inventory and never executes a planned host mutation. Its + `read-only-command.mjs` is the shared strict parser/envelope/timeout runtime + for lifecycle and doctor commands; it is capability-scoped, not the + repository-wide authoritative command-contract owner. Identity/digest, + bounded runtime/path discovery, target resolution, status/verification, and + planning live in separate private modules; `target-resolution.mjs` is the + single owner for host, surface, and scope selection used by status and plan. + `model.mjs` owns the shared lifecycle schema version, Better Harness plugin + identity/version, base target, diagnostic, and assertion primitives used by + both status and plan validation; domain modules retain only their own state + vocabularies and invariants. + `status-row.mjs` is the single `PluginLifecycleStatusV1` row factory and + validator for both observed inventory and inventory-failure paths; status + core owns only collection, ordering, aggregation, and command summaries. + `plan-model.mjs` is the corresponding single `PluginLifecyclePlanV1` + transition, step-materialization, digest, and validation owner; plan core + only resolves the target, collects status, and delegates construction. + Planned mutations are typed as external `host-plugin-state` steps, while + post-apply verification is typed as read-only `host-observation` so consumers + never mistake a verification command for a mutation performed by the CLI. + `command-manifest.mjs` is the pure, read-only leaf metadata owner projected + into the root CLI registry; `command-definitions.mjs` binds that manifest to + lifecycle executors and `human-output.mjs` renderers. The plugin CLI performs + generic descriptor dispatch and must not branch on leaf names. + `index.mjs` is the only cross-capability behavioral import surface and + contains exports rather than implementation; the root registry's direct, + metadata-only manifest projection is the sole allowed exception so root help + does not load lifecycle runtime owners. +- `scripts/host-support/` currently provides the validated shadow profiles used + by plugin lifecycle. While ADR-0002 remains proposed, the adapter matrix, + capability providers, manifests, and installation documentation remain + authoritative for their existing slices; the new profiles may detect drift + but do not silently replace those owners. Each host owns one module under + `scripts/host-support/profiles/`; `profile-model.mjs` is the single local + `HostSurfaceProfileV1` vocabulary and validation owner. Profile modules fail + during their own construction and are deeply immutable before registry + composition; the registry facade delegates local validation and owns only + cross-profile host-id and alias conflicts. `profile-builders.mjs` owns typed + construction helpers for evidence, operations, surfaces, and steps rather + than duplicating their invariants, and `profiles.mjs` only composes the + registry. Every provider-specific filesystem root read outside the selected + workspace must have an explicit `inventoryHomeRoutes` entry, including state + files, shared caches, and compatibility roots such as a user-level + `.agents/` directory. Each primary and secondary route declares its provider + option, an isolated path relative to `--host-home`, and a redacted safe + fallback label; providers return the effective resolved roots so doctor can + report them without exposing absolute user paths. Per-surface inventory, + bundled, session-only, or desktop-cache observation semantics are profile + data. Each surface also declares whether host discovery comes from its + executable, a provider diagnostic, or remains unobserved; bundled and + session-only surfaces do not read unrelated persistent inventory. Scope- + artifact ownership and native home binding are profile data as well. A shared + `scopeArtifactPolicy` or `nativeHomeBinding` requires versioned, traceable + native evidence. Without an evidenced binding that can represent every + emitted native step, an isolated-home mutation plan fails closed and omits + unbound native verification steps. Lifecycle status must not branch on + canonical host ids. +- `scripts/harness-doctor/` owns the bounded host/plugin diagnostic view and + reuses plugin-lifecycle status rather than reimplementing host discovery. - `scripts/harness-analysis/canvas-preview/` owns reusable local Canvas serving, runtime discovery, transforms, the Harness preview fixture, and cross-platform browser helpers. Keeping it below `harness-analysis/` preserves the tested diff --git a/docs/adapters/README.md b/docs/adapters/README.md index 3fa06d2..d20c2cb 100644 --- a/docs/adapters/README.md +++ b/docs/adapters/README.md @@ -30,12 +30,50 @@ package through the `pi` manifest in `package.json`. | Claude Code | Analysis-capable source-local host | `.claude-plugin/` | `scripts/agent-customize/providers/claude.mjs` | `scripts/session-analysis/platforms/claude.mjs` | self-contained HTML + Markdown | `.claude` + `CLAUDE.md` + Plugin assets | `claude plugin validate --strict .` -> isolated install/discovery -> configured-asset baseline -> validated `html` render | | Codex | Analysis-capable source-local host | `.codex-plugin/` | `scripts/agent-customize/providers/codex.mjs` | `scripts/session-analysis/platforms/codex.mjs` | self-contained HTML + Markdown | `.codex` + `.agents` + `AGENTS.md` | `harness prepare --platform codex` -> finalize with `html-report` validation | | Qoder | First-class product host | `.qoder-plugin/` | `scripts/agent-customize/providers/qoder.mjs` | `scripts/session-analysis/platforms/qoder.mjs` | `better-harness` | `.qoder/rules` + `AGENTS.md` + output templates | `better-harness harness render --mode qoder-canvas --validate` | -| Cursor | Analysis-capable source-local host | `.cursor-plugin/` | `scripts/agent-customize/providers/cursor.mjs` | `scripts/session-analysis/platforms/cursor.mjs` | self-contained HTML + Markdown | `.cursor` + `.codex` compatibility + `AGENTS.md` | `agent --plugin-dir . --mode ask --print` -> Cursor evidence bundle -> validated `html` render | +| Cursor | Analysis-capable source-local host | `.cursor-plugin/` | `scripts/agent-customize/providers/cursor.mjs` | `scripts/session-analysis/platforms/cursor.mjs` | self-contained HTML + Markdown | `.cursor` + `.codex` compatibility + `AGENTS.md` | native `cursor-agent --help` contract check -> unavailable install plan -> Cursor evidence bundle -> validated `html` render | | Qwen Code | Analysis-capable source-local host | `qwen-extension.json` | `scripts/agent-customize/providers/qwen.mjs` | `scripts/session-analysis/platforms/qwen.mjs` | self-contained HTML + Markdown | `.qwen` + `QWEN.md` + `AGENTS.md` | `harness prepare --platform qwen` -> finalize with `html-report` validation | | GitHub Copilot | Analysis-capable source-local host | `.github/plugin/` | `scripts/agent-customize/providers/copilot.mjs` | `scripts/session-analysis/platforms/copilot.mjs` | self-contained HTML + Markdown | `.github` + `AGENTS.md` + `~/.copilot` | `copilot plugin marketplace add .` -> `copilot plugin install better-harness@better-harness` -> configured-asset baseline -> validated `html` render | | Pi | Analysis-capable source-local host | `pi` manifest in `package.json` | `scripts/agent-customize/providers/pi.mjs` | `scripts/session-analysis/platforms/pi.mjs` | self-contained HTML + Markdown | `.pi` + `.agents` + `AGENTS.md` | `pi install ` or `pi -e ` -> `/better-harness` prompt template -> validated `html` render | | WorkBuddy | Analysis-capable source-local host | none (skills install into `~/.workbuddy/skills`) | `scripts/agent-customize/providers/workbuddy.mjs` | `scripts/session-analysis/platforms/workbuddy.mjs` | self-contained HTML + Markdown | `~/.workbuddy` `AGENTS.md` + identity files + `.agents` + `AGENTS.md` | `session-analysis --platform workbuddy sources` -> validated `html` render | +## Read-only Plugin Lifecycle + +`better-harness plugin status`, `plan`, and `verify` expose a Better Harness-only +view over these adapters. The shadow declarations in `scripts/host-support/` +record lifecycle evidence without replacing this matrix while ADR-0002 is +proposed. Each host declaration lives in `scripts/host-support/profiles/.mjs` +and uses the shared typed constructors rather than copying registry logic. Each +module is locally validated and deeply frozen before registry composition; +aggregate validation adds only cross-host id and alias uniqueness. The +same profile declares its provider home option and each surface's observation +kind, so status collection does not carry a second host lookup table or +host-specific branches. Lifecycle status and plan also share one private target +resolver for aliases, explicit host requirements, surfaces, and scopes, keeping +usage diagnostics consistent as profiles grow. Plugin leaf metadata is declared +once and projected into the root command registry; runtime definitions bind the +same entries to executors and human renderers without leaf-name branches. Every +observed or inventory-failure status instance passes through one validated row +factory, so host additions cannot invent a second status shape. Every lifecycle +plan likewise passes through one transition and validation model: mutation +steps declare external host-plugin-state effects, while follow-up verification +steps declare read-only host-observation effects. The thin plan core does not +copy lifecycle state policy when a host profile is added. +Plans never execute and always preserve native surface differences: + +| Host surface | Lifecycle disposition | +| --- | --- | +| Claude Code CLI | Native install, update, remove, and details verification steps | +| Codex CLI / Desktop | Native CLI argv; manual Desktop UI steps | +| Qoder Desktop / CLI | Bundled Desktop; manual CLI install, verified list/remove, unavailable update | +| Cursor Agent | Session-only evidence; install remains unavailable while the local help contract is stale | +| Qwen Code | Native extension install/list argv; update and remove remain unavailable until safe scope-targeted mutation semantics are evidenced | +| GitHub Copilot CLI | Native marketplace install, list, update, and uninstall argv | +| Pi CLI / CLI session | Persistent user/project install guidance and inventory; separate `pi -e` session-only activation whose update/remove operations are not applicable | +| WorkBuddy | `PLUGIN_LIFECYCLE_UNSUPPORTED`; adapter evidence remains available | + +The lifecycle commands do not read raw session transcripts, contact a registry, +edit host settings, or register an `apply` path. + ## Discovery And Evidence - Claude Code discovers the canonical root `skills/` directory through diff --git a/docs/adapters/contributing-new-coding-agent.md b/docs/adapters/contributing-new-coding-agent.md index 3c2b7c4..83ce6b6 100644 --- a/docs/adapters/contributing-new-coding-agent.md +++ b/docs/adapters/contributing-new-coding-agent.md @@ -28,6 +28,7 @@ claimed slice a stable acceptance id and an evidence route. Use | Configured assets | Available, partial, or unavailable scopes | `scripts/agent-customize/providers/.mjs` | Sanitized fixtures plus bounded real-host inventory smoke | | Session evidence | Available, partial, or unavailable fields/events | `scripts/session-analysis/platforms/.mjs` | Deterministic fixtures plus workspace-qualified source/facts smoke | | Shared registration | Which public commands accept the host | Capability-owned registries and CLIs | Help, unknown-host, delegation, and bundle tests | +| Plugin lifecycle | Better Harness install/status/verify dispositions by native surface | `scripts/host-support/profiles/.mjs` plus `scripts/plugin-lifecycle/` | Profile validation, redacted native-help evidence, deterministic plan, and read-only fixture tests | | Output | Existing Canvas, HTML, Markdown, or a justified new mode | `templates/reporting/` and report routing | Validated render for the claimed mode | | Packaging | npm metadata root, runtime bundle, source-only, or none | `package.json` and `scripts/npm-package/` | `npm run pack:verify` when shipped files change | | Documentation | Positioning, paths, coverage, smoke, limitations | [host adapter matrix](README.md) and capability references | Link checks and commands matching observed behavior | @@ -61,6 +62,10 @@ that affect the proposed support: and version rules; - configuration, runtime, cache, and session roots, including environment and CLI override precedence; +- every primary and secondary configured-asset root the provider reads, such as + a state database, shared client cache, or user-level compatibility directory, + plus a redacted fallback label and its relocation below an isolated + `--host-home`; - workspace identity and path normalization for spaces, Unicode, punctuation, Windows drive letters, case differences, symlinks, and case-insensitive file systems; @@ -140,6 +145,27 @@ rg -n "qoder|codex|claude|cursor|qwen|copilot|pi" scripts test references templa Use the results as an inventory, not a replacement template. Typical registration surfaces include: +- `scripts/host-support/profiles/.mjs` for the lifecycle shadow profile; + use `profile-builders.mjs` constructors, let `profile-model.mjs` validate and + deeply freeze the declaration locally, and keep `profiles.mjs` as an + import-only composition root. Do not defer a malformed profile to aggregate + registry validation. Declare every provider primary and secondary + `inventoryHomeRoutes` entry there with its option, isolated relative path, and + redacted safe fallback; do not let a state file, shared cache, or compatibility + root fall back to the real user home when `--host-home` is supplied. Declare + the surface observation kind and discovery source (`executable`, `diagnostic`, + or `unobserved`) there as well; do not let one surface inherit another + surface's executable or provider-state evidence. Use `scopeArtifactPolicy: shared` + only when versioned native evidence proves that scopes mutate one artifact, + and declare `nativeHomeBinding` only when the cited native contract proves the + environment or config override applies to the emitted steps. Otherwise an + isolated mutation plan must fail closed and must not emit an unbound native + verification step. Do not add a host-id branch to lifecycle status or a second + target resolver to status/plan. Host work must not copy plugin leaf metadata + into either the root registry or lifecycle CLI, or construct status rows + outside the shared status-row factory. Do not construct lifecycle plans, + duplicate transition policy, or label verification as a mutation outside the + shared plan-model factory; - `scripts/agent-customize/providers/index.mjs` and its public inventory CLI; - `scripts/session-analysis/analyzer.mjs` and the platform loader/help contract; - `scripts/harness-analysis/evidence-bundle/` provider validation and routing; @@ -238,6 +264,14 @@ their latest diff and status before citing them. data boundaries explicit. - [ ] Registration matches implemented capabilities; no unrelated host fallback is possible. +- [ ] The host has one independently importable lifecycle profile whose + supported steps use argv arrays and current contract evidence; unavailable + operations remain explicit. Every primary and secondary inventory home route + has an isolated relative path and redacted safe fallback, and every surface + observation kind and discovery source are declared and pass profile + validation. Shared artifact + policy and native home bindings cite versioned native evidence; without that + evidence, isolated mutation and verification steps fail closed. - [ ] Deterministic fixtures cover applicable path, precedence, status, dedupe, foreign-workspace, unknown-event, and secret-boundary risks. - [ ] Focused, full-suite, cross-platform, native-smoke, documentation, and diff --git a/docs/docs/hosts/adapter-matrix.md b/docs/docs/hosts/adapter-matrix.md index 4880b06..7826cbb 100644 --- a/docs/docs/hosts/adapter-matrix.md +++ b/docs/docs/hosts/adapter-matrix.md @@ -38,6 +38,25 @@ not add a seventh filesystem metadata root. The generated Qoder runtime bundle includes only the Qoder shell; non-Qoder generated host artifacts remain source-local. +## Read-only plugin lifecycle + +The standalone CLI can normalize local Better Harness installation evidence +without flattening host capability differences: + +```bash +better-harness plugin status --host all +better-harness plugin verify --host all +better-harness doctor --platform all +``` + +`plugin plan` requires one explicit host and emits typed native argv or manual +steps without executing them. Qoder Desktop remains bundled, Codex Desktop uses +manual UI steps, Cursor installation stays unavailable while its local help +contract is stale, persistent Pi operations without native evidence stay +unavailable, transient Pi update/remove are not applicable, and WorkBuddy +returns `PLUGIN_LIFECYCLE_UNSUPPORTED`. The shadow host profiles do not replace +the canonical adapter matrix while ADR-0002 remains proposed. + ## Output modes - **Qoder Canvas** — renderer-owned `findings.json`, Canvas-only @@ -52,10 +71,13 @@ source-local. ### Pi {#pi} Pi can install the repository through `pi install ` or load it with -`pi -e `. Package discovery, configured assets, workspace-matched -session evidence, and portable HTML routing are implemented. Pi remains outside -the verified Quickstart set until a complete interactive report-loop smoke is -observed. +`pi -e `. Lifecycle status treats persisted user/project package +settings as the `cli` inventory surface and one-run `pi -e` activation as the +separate `cli-session` session-only surface; empty settings do not prove that a +running session omitted the package. Package discovery, configured assets, +workspace-matched session evidence, and portable HTML routing are implemented. +Pi remains outside the verified Quickstart set until a complete interactive +report-loop smoke is observed. ### WorkBuddy {#workbuddy} diff --git a/docs/docs/installation.mdx b/docs/docs/installation.mdx index b8cee2a..3691caa 100644 --- a/docs/docs/installation.mdx +++ b/docs/docs/installation.mdx @@ -36,7 +36,27 @@ node --version npm --version ``` -Cursor's source-local path also requires Git so you can clone this repository. +## Inspect and plan lifecycle changes {#lifecycle-cli} + +The standalone CLI exposes a read-only view of Better Harness installation +evidence. It does not contact a registry, modify host configuration, or execute +the steps in a lifecycle plan: + +```bash +better-harness plugin status --host all +better-harness doctor --platform all +better-harness plugin plan install --host qwen --surface cli --scope user +better-harness plugin verify --host qwen --surface cli +``` + +Plans preserve host differences instead of inventing a common mutation path. +Qoder Desktop is bundled, Codex Desktop returns manual UI steps, Cursor remains +session-only while its native command contract is reconciled, Pi operations +without current native evidence are manual or unavailable, and WorkBuddy has no +managed plugin lifecycle surface. There is no `plugin apply` command. + +Git can clone the Cursor manifest for inspection, but manifest presence alone +does not establish a supported installation route. :::tip Don't see your Coding Agent? @@ -170,14 +190,16 @@ entry point: If Qoder Desktop is installed, Better Harness is already available in Qoder CLI. No marketplace or plugin installation is required. -Only when using Qoder CLI without Qoder Desktop, add this repository as a -marketplace and install Better Harness manually: +Only when using Qoder CLI without Qoder Desktop, inspect the current manual +installation disposition before following Qoder's native marketplace flow: ```bash -qodercli plugin marketplace add "https://github.com/QoderAI/better-harness.git" -qodercli plugin install better-harness@better-harness +better-harness plugin plan install --host qoder --surface cli --scope user ``` +The planner does not emit the older install syntax because the current native +help and this repository's historical documentation disagree. + ### Verify installation For Qoder Desktop, start a new session and run the report prompt shown above. @@ -188,6 +210,7 @@ For a manual Qoder CLI installation, verify discovery with: ```bash qodercli plugin list +better-harness plugin verify --host qoder --surface cli ``` Then start a new Qoder CLI session before using `/better-harness`. @@ -197,25 +220,34 @@ Then start a new Qoder CLI session before using `/better-harness`. ## Cursor {#cursor} -The Cursor plugin is not published to the marketplace yet. Load the -source-local plugin for one Cursor Agent session: +The Cursor plugin is not published to the marketplace. The repository carries +the source-local manifest, but the current local Cursor help does not verify the +historical `--plugin-dir` contract. Better Harness therefore reports the +installation plan as unavailable instead of emitting that command: ```bash git clone https://github.com/QoderAI/better-harness.git -cursor-agent --plugin-dir /path/to/better-harness +better-harness plugin plan install --host cursor --surface agent --scope session ``` ### Verify installation -Keep that Cursor Agent session open and run: +Do not claim that a Cursor Agent session loaded Better Harness while the native +installation contract remains unavailable. Inspect the bounded local evidence: + +```bash +better-harness plugin verify --host cursor --surface agent +``` + +Only if a future or independently verified native route loads the plugin, keep +that same Cursor Agent session open and run: ```text /better-harness analyze this project's AI coding workflow and generate an evidence-backed report ``` -Starting the analysis confirms that the source-local Skill loaded for that -session. Cursor does not install this checkout globally; start future sessions -with the same `--plugin-dir` argument. +The verification result remains partial when runtime activation cannot be +observed. Cursor session evidence is supported through workspace-matched transcripts, metadata, and audit logs. Partial or unavailable coverage remains explicit. @@ -233,7 +265,8 @@ qwen extensions install QoderAI/better-harness ### Verify installation -Start a new Qwen Code session in the repository you want to analyze and run the +Run `qwen extensions list` and confirm that it includes Better Harness. Then +start a new Qwen Code session in the repository you want to analyze and run the report prompt: ```text diff --git a/docs/docs/troubleshooting.md b/docs/docs/troubleshooting.md index d28d4d2..f1def50 100644 --- a/docs/docs/troubleshooting.md +++ b/docs/docs/troubleshooting.md @@ -11,6 +11,18 @@ plugin directories, reports, or user configuration as a first response. Keep credentials, raw transcripts, private prompts, and complete reports out of diagnostic output and public issues. +Start with the read-only lifecycle view when the standalone CLI is available: + +```bash +better-harness plugin status --host all +better-harness doctor --platform all +``` + +Both commands avoid registries, configuration writes, installation commands, +and raw session transcripts. A `partial`, `unobserved`, `manual`, or +`unavailable` result is a preserved evidence boundary, not permission to infer +that a host loaded the plugin. + ## The plugin or Skill is not visible After installing or updating Better Harness, start a new host session or task. @@ -21,25 +33,30 @@ startup. Then use the check supported by that host: | --- | --- | | [Claude Code](./installation?host=claude-code#claude-code) | Run `claude plugin details better-harness@better-harness`; the details should include `Skills (1) better-harness`. | | [Codex](./installation?host=codex#codex) | In Desktop, check **Settings > Plugins**. In the CLI, run `codex plugin list --marketplace better-harness`. | -| [Qoder](./installation?host=qoder#qoder) | The Desktop version is built in. For a manual CLI install, run `qodercli plugin list`. | -| [Cursor](./installation?host=cursor#cursor) | Start the agent with `cursor-agent --plugin-dir /path/to/better-harness`, keep that process open, and run the report prompt in the same session. | -| [Qwen Code](./installation?host=qwen-code#qwen-code) | Start a new session and run the report prompt. This guide does not assume an unverified extension-list command. | +| [Qoder](./installation?host=qoder#qoder) | The Desktop version is built in. For a manual CLI install, run `qodercli plugin list`; lifecycle planning does not emit the stale install syntax. | +| [Cursor](./installation?host=cursor#cursor) | Inspect `better-harness plugin status --host cursor --surface agent`; installation remains unavailable until the local Cursor help contract is reconciled. | +| [Qwen Code](./installation?host=qwen-code#qwen-code) | Run `qwen extensions list` and confirm it includes Better Harness, then start a new session and run the report prompt. | | [GitHub Copilot](./installation?host=github-copilot#github-copilot) | Run `copilot plugin list` and `copilot skill list`; both should include `better-harness`. | If a marketplace command fails, return to the linked host tab and compare the -repository source and command spelling exactly. In particular, current Codex -uses a repository URL with `marketplace add`, then `plugin add`; Qoder CLI uses -`plugin install`. - -## Cursor cannot load the source-local plugin - -The value passed to `--plugin-dir` must be the root of this repository, not its -`.cursor-plugin` or `skills` subdirectory. That root contains both -`.cursor-plugin/plugin.json` and `skills/better-harness/SKILL.md`. - -The plugin applies only to the Cursor Agent process started with that argument. -If the process was closed, start a new one with the same repository path. Do not -copy the checkout into a global plugin directory as a troubleshooting step. +repository source and command spelling exactly. Current Codex uses a repository +URL with `marketplace add`, then `plugin add`. Do not copy the older Qoder or +Cursor install examples when their local native help does not expose the same +contract; the lifecycle planner intentionally returns a manual or unavailable +result instead. + +## Cursor source-local loading is unavailable + +The checked native `cursor-agent` help does not advertise a supported +source-local plugin flag. Do not reuse historical launch commands or infer that +the presence of `.cursor-plugin/plugin.json` makes the source checkout loadable +by the current runtime. + +Use `better-harness plugin status --host cursor --surface agent` to inspect the +bounded session evidence that is available. Until Cursor publishes a matching +native contract, Better Harness has no supported installation command to +troubleshoot; keep the lifecycle result `unavailable` instead of copying the +checkout into a global plugin directory. ## The standalone or source CLI reports an unsupported runtime diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md index 7a9298a..7c3b8d4 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/hosts/adapter-matrix.md @@ -34,6 +34,23 @@ Better Harness 当前声明了八个能力层宿主适配器,其中六个已 运行时 bundle 只包含 Qoder shell。Pi 复用现有 `package.json` 中的安装元数据, 因此不会新增第七个文件系统元数据根目录;非 Qoder 的生成宿主产物保持源码本地。 +## 只读插件生命周期 + +独立 CLI 可以规范化本地 Better Harness 安装证据,同时保留宿主能力差异: + +```bash +better-harness plugin status --host all +better-harness plugin verify --host all +better-harness doctor --platform all +``` + +`plugin plan` 要求显式指定一个宿主,并输出带类型的原生 argv 或手工步骤, +但不会执行。Qoder Desktop 保持内置分发,Codex Desktop 使用手工 UI 步骤; +Cursor 在本机 help 合同过期期间保持安装不可用;Pi 持久化 surface 中缺少原生 +证据的操作保持不可用,临时 session surface 的更新/移除不适用;WorkBuddy 返回 +`PLUGIN_LIFECYCLE_UNSUPPORTED`。在 ADR-0002 仍为 proposed 期间,shadow host +profile 不替代规范适配器矩阵。 + ## 输出模式 - **Qoder Canvas** —— 渲染器负责的 `findings.json`、仅 Canvas 使用的 @@ -48,8 +65,11 @@ Better Harness 当前声明了八个能力层宿主适配器,其中六个已 ### Pi {#pi} Pi 可以通过 `pi install ` 安装本仓库,或使用 `pi -e ` 加载。 -包发现、已配置资产、工作区匹配的会话证据与可移植 HTML 路由均已实现。 -在观察到完整交互式报告闭环冒烟验证前,Pi 仍不进入已验证快速开始集合。 +生命周期状态把持久化的用户/项目包设置作为 `cli` inventory surface,把单次 +`pi -e` 激活作为独立的 `cli-session` session-only surface;空设置不能证明正在 +运行的会话没有加载该包。包发现、已配置资产、工作区匹配的会话证据与可移植 +HTML 路由均已实现。在观察到完整交互式报告闭环冒烟验证前,Pi 仍不进入已验证 +快速开始集合。 ### WorkBuddy {#workbuddy} diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/installation.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/installation.mdx index 4c406ea..bdbb3d9 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/installation.mdx +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/installation.mdx @@ -32,7 +32,25 @@ node --version npm --version ``` -Cursor 使用源码本地插件,还需要 Git 来克隆本仓库。 +## 检查并规划生命周期变更 {#lifecycle-cli} + +独立 CLI 提供 Better Harness 安装证据的只读视图。它不会访问远程注册表、 +修改宿主配置,也不会执行生命周期计划中的步骤: + +```bash +better-harness plugin status --host all +better-harness doctor --platform all +better-harness plugin plan install --host qwen --surface cli --scope user +better-harness plugin verify --host qwen --surface cli +``` + +计划会保留宿主差异,不会伪造统一的变更路径。Qoder Desktop 为内置分发, +Codex Desktop 返回手工 UI 步骤;Cursor 在原生命令合同完成核对前只保留会话级 +状态;Pi 缺少当前原生证据的操作为手工或不可用;WorkBuddy 没有可管理的插件 +生命周期入口。CLI 不提供 `plugin apply` 命令。 + +可以使用 Git 克隆并检查 Cursor manifest,但仅存在 manifest 并不能证明当前 +存在受支持的安装路径。 :::tip 没有找到你的 Coding Agent? @@ -161,14 +179,15 @@ Marketplace 或本地插件安装。两个入口任选其一: 如果已安装 Qoder Desktop,Better Harness 在 Qoder CLI 中开箱即用,无需 marketplace 或插件安装。 -只有在不安装 Qoder Desktop 而单独使用 Qoder CLI 时,才需要手动把本仓库 -添加为 marketplace 并安装: +只有在不安装 Qoder Desktop 而单独使用 Qoder CLI 时,才需要在遵循 Qoder 原生 +marketplace 流程前检查当前手工安装状态: ```bash -qodercli plugin marketplace add "https://github.com/QoderAI/better-harness.git" -qodercli plugin install better-harness@better-harness +better-harness plugin plan install --host qoder --surface cli --scope user ``` +由于当前原生 help 与本仓库历史文档不一致,计划器不会输出旧版安装语法。 + ### 验证安装 对于 Qoder Desktop,请开启新会话并运行上方的报告提示词。使用 Qoder 1.18.0 @@ -179,6 +198,7 @@ qodercli plugin install better-harness@better-harness ```bash qodercli plugin list +better-harness plugin verify --host qoder --surface cli ``` 然后开启新的 Qoder CLI 会话再使用 `/better-harness`。 @@ -188,24 +208,32 @@ qodercli plugin list ## Cursor {#cursor} -Cursor 插件尚未发布到 marketplace。可在单次 Cursor Agent 会话中加载源码 -本地插件: +Cursor 插件尚未发布到 marketplace。仓库包含源码本地 manifest,但当前本机 +Cursor help 没有验证历史 `--plugin-dir` 合同,因此 Better Harness 会把安装计划 +标记为不可用,而不会输出该命令: ```bash git clone https://github.com/QoderAI/better-harness.git -cursor-agent --plugin-dir /path/to/better-harness +better-harness plugin plan install --host cursor --surface agent --scope session ``` ### 验证安装 -保持这个 Cursor Agent 会话处于打开状态,并运行: +原生安装合同仍不可用时,不要声称 Cursor Agent 会话已经加载 Better Harness。 +先检查有界的本地证据: + +```bash +better-harness plugin verify --host cursor --surface agent +``` + +只有未来或其他独立验证的原生路径确实加载插件后,才保持同一个 Cursor Agent +会话处于打开状态并运行: ```text /better-harness 分析此项目的 AI 编码工作流并生成基于证据的报告 ``` -开始分析即表示源码本地 Skill 已在本次会话加载。Cursor 不会把这个检出目录安装 -到全局;后续会话仍需使用相同的 `--plugin-dir` 参数启动。 +无法观察运行时激活状态时,验证结果会保留为 partial。 Cursor 会话证据通过与工作区匹配的转录、元数据和审计日志提供支持。部分或 不可用的覆盖范围会保持显式标注。 @@ -223,7 +251,8 @@ qwen extensions install QoderAI/better-harness ### 验证安装 -在要分析的仓库中启动新的 Qwen Code 会话,然后运行报告提示词: +运行 `qwen extensions list` 并确认其中包含 Better Harness。然后在要分析的 +仓库中启动新的 Qwen Code 会话,并运行报告提示词: ```text /better-harness 分析此项目的 AI 编码工作流并生成基于证据的报告 diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/troubleshooting.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/troubleshooting.md index 1965ee9..04b0900 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/troubleshooting.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/troubleshooting.md @@ -9,6 +9,17 @@ sidebar_position: 4 先对失败步骤做最小检查。不要把删除宿主缓存、插件目录、报告或用户配置作为第一 反应。诊断输出和公开 issue 中不要包含凭据、原始会话记录、私密提示词或完整报告。 +如果独立 CLI 可用,请先运行只读生命周期检查: + +```bash +better-harness plugin status --host all +better-harness doctor --platform all +``` + +这两个命令不访问远程注册表、不写配置、不执行安装命令,也不读取原始会话正文。 +`partial`、`unobserved`、`manual` 或 `unavailable` 表示保留证据边界,不能据此推断 +宿主已经加载插件。 + ## 看不到插件或 Skill 安装或更新 Better Harness 后,请开启新的宿主会话或任务。已有会话可能仍在使用 @@ -18,23 +29,25 @@ sidebar_position: 4 | --- | --- | | [Claude Code](./installation?host=claude-code#claude-code) | 运行 `claude plugin details better-harness@better-harness`;详情中应包含 `Skills (1) better-harness`。 | | [Codex](./installation?host=codex#codex) | Desktop 在 **Settings > Plugins** 中检查;CLI 运行 `codex plugin list --marketplace better-harness`。 | -| [Qoder](./installation?host=qoder#qoder) | Desktop 已内置;手动安装 CLI 插件后运行 `qodercli plugin list`。 | -| [Cursor](./installation?host=cursor#cursor) | 使用 `cursor-agent --plugin-dir /path/to/better-harness` 启动,保持该进程打开,并在同一会话运行报告提示词。 | -| [Qwen Code](./installation?host=qwen-code#qwen-code) | 开启新会话并运行报告提示词;本指南不会假定未经验证的扩展列表命令。 | +| [Qoder](./installation?host=qoder#qoder) | Desktop 已内置;手动安装 CLI 插件后运行 `qodercli plugin list`;生命周期计划不会输出已经漂移的安装语法。 | +| [Cursor](./installation?host=cursor#cursor) | 运行 `better-harness plugin status --host cursor --surface agent`;在本机 Cursor help 合同完成核对前,安装保持不可用。 | +| [Qwen Code](./installation?host=qwen-code#qwen-code) | 运行 `qwen extensions list` 并确认其中包含 Better Harness,然后开启新会话并运行报告提示词。 | | [GitHub Copilot](./installation?host=github-copilot#github-copilot) | 运行 `copilot plugin list` 和 `copilot skill list`;两处都应包含 `better-harness`。 | 如果 marketplace 命令失败,请返回对应宿主的安装标签页,逐字核对仓库源和命令。 -特别是当前 Codex 先对仓库 URL 使用 `marketplace add`,再使用 `plugin add`; -Qoder CLI 使用 `plugin install`。 +特别是当前 Codex 先对仓库 URL 使用 `marketplace add`,再使用 `plugin add`。 +如果本机原生 help 没有公开同样的合同,请不要复制旧版 Qoder 或 Cursor 安装示例; +生命周期计划会刻意返回手工或不可用状态。 -## Cursor 无法加载源码本地插件 +## Cursor 源码本地加载当前不可用 -传给 `--plugin-dir` 的值必须是本仓库根目录,而不是其中的 `.cursor-plugin` 或 -`skills` 子目录。该根目录同时包含 `.cursor-plugin/plugin.json` 和 -`skills/better-harness/SKILL.md`。 +已核对的原生 `cursor-agent` help 没有公开受支持的源码本地插件参数。不要复用旧版 +启动命令,也不要因为仓库包含 `.cursor-plugin/plugin.json` 就推断当前运行时可以 +加载这个源码检出。 -插件只对使用该参数启动的 Cursor Agent 进程生效。如果进程已经关闭,请用同一个 -仓库路径重新启动。不要为了排查问题把检出目录复制到全局插件目录。 +请用 `better-harness plugin status --host cursor --surface agent` 检查有边界的会话 +证据。在 Cursor 发布匹配的原生合同之前,Better Harness 没有可供排查的受支持安装 +命令;应保留生命周期结果 `unavailable`,而不是把检出目录复制到全局插件目录。 ## 独立或源码 CLI 报告运行时版本不受支持 diff --git a/docs/specs/2026-07-31-better-harness-plugin-lifecycle.md b/docs/specs/2026-07-31-better-harness-plugin-lifecycle.md new file mode 100644 index 0000000..c4197f8 --- /dev/null +++ b/docs/specs/2026-07-31-better-harness-plugin-lifecycle.md @@ -0,0 +1,383 @@ +# Manage Better Harness Across Coding Agent Hosts + +## Traceability + +- Spec ID: `better-harness-plugin-lifecycle` +- Status: Draft +- AI involvement: Codex (GPT 5.6 Sol), implementation and review +- Related decision: [ADR-0002 Developer Experience System](../adrs/developer-experience-system.md) +- Related roadmap: `LC-01`, `LC-06`, `LC-12`, and `HA-04` + +## Intent + +Give operators and automation one read-only CLI surface for discovering, +planning, and verifying the Better Harness plugin lifecycle across Claude Code, +Codex, Qoder, Cursor, Qwen Code, GitHub Copilot, and Pi. Keep WorkBuddy visible +as an explicit unsupported lifecycle boundary. Reuse host-native installation +mechanisms and configured-asset evidence without adding MCP, writing host +configuration, contacting remote services, or executing lifecycle mutations. + +The first version must preserve host differences. A missing, stale, manual, +session-only, bundled, or unsupported host contract remains visible instead of +being converted into a generic install command. + +## Acceptance Scenarios + +- **PLG-AC-1 (host profile parity):** A validated host-support registry covers + Claude, Codex, Qoder, Cursor, Qwen, Copilot, Pi, and WorkBuddy, including + surface, scope, distribution, identity, lifecycle disposition, and evidence + metadata. Seven hosts are managed and WorkBuddy reports + `PLUGIN_LIFECYCLE_UNSUPPORTED`. +- **PLG-AC-2 (normalized status):** `better-harness plugin status` uses the + public agent-customize inventory to report every matching Better Harness + installation independently, including host discovery, installation, + enablement, observed-version relation, verification, and bounded evidence. + Display-name-only matches are rejected and multi-scope installs are not + collapsed. Inventory entries outside the selected workspace are excluded, + unknown or unsupported source scopes fail closed instead of falling back to + a default scope, and one surface cannot borrow discovery, version, Skill, or path + evidence from another surface. Pi persistent user/project package inventory + and transient `pi -e` session activation are separate surfaces: empty + persistent settings cannot report the session-only surface as not installed, + and persistent package evidence cannot prove session activation. Provider + inventories preserve native user/project/local scope when the host exposes + that distinction. Qwen scope + comes from the v2 extension-store activation policy rather than UI grouping + preferences; missing, corrupt, unmatched, or foreign activation state stays + visible as unresolved evidence. +- **PLG-AC-3 (deterministic plans):** `plugin plan install|update|remove` + requires an explicit host and produces a stable, content-addressed plan with + preconditions, typed steps, verification, blockers, retention boundaries, + and no side effects. Install is a no-op when already installed, update fails + closed when absent, and remove never removes a marketplace, report, cache, + CLI package, or source checkout. Project/local plans bind their observation, + plan identity, rendered instructions, and external command working directory + to the requested workspace; isolated-host plans retain their host-home + boundary. Inventory failure, an absent required host executable, or an + unrepresentable native scope blocks mutation planning rather than being + treated as not installed. Native steps carry a typed, evidenced home binding; + an actionable isolated-home plan is blocked when every native root cannot be + represented. An unobserved desktop installation remains a manual UI plan for + update or removal rather than being collapsed into an absent-install blocker + or no-op. A host with one shared artifact across activation scopes blocks + cross-scope reinstallation while same-scope install remains a no-op. Blocked + plans are operational failures. Multi-surface Pi plans require `cli` for + persistent user/project packages or `cli-session` for one transient session; + session update and removal are not applicable. Sorting and digests are + locale-independent. Human rendering preserves executable argv as explicitly + non-shell JSON data and neutralizes shell expansion syntax in local paths; + it never degrades typed steps into a copy-paste shell string. +- **PLG-AC-4 (bounded verification and doctor):** `plugin verify` validates + local install evidence, manifest identity, Skill routing, version, and + enablement without claiming runtime activation. `better-harness doctor` + reports product/runtime versions, host discovery, authorized inventory roots, + and stable diagnostics without reading raw sessions or exposing full user-home + paths. Doctor keeps the selected host separate from the runtime operating + system, honors Windows `PATHEXT`, and requires executable permission for + POSIX command discovery. Pi doctor output keeps persistent package inventory + and bounded session-only activation as distinct rows, including when the + persistent inventory collector fails. Each surface declares executable, + diagnostic, or unobserved discovery ownership; Qoder Desktop does not inherit + `qodercli` discovery, while selected bundled/session-only surfaces do not read + unrelated persistent provider state. +- **PLG-AC-5 (command contract):** The new commands provide help-only discovery, + strict parsing, human output, one `command-contract.v1` JSON document, and + portable exits `0` for ok, `2` for partial, `1` for operational failure, and + `64` for invalid usage. The root command inventory and OpenCLI schema describe + each leaf command, including multi-segment `command describe ` + lookup and exact unknown-path diagnostics already present on the target + branch. +- **PLG-AC-6 (read-only privacy boundary):** Status, plan, verify, and doctor do + not write files, spawn lifecycle commands, use shell strings, read transcript + content, send telemetry, or make network calls. Absolute user-home paths are + redacted from diagnostics and evidence views. A supplied `--host-home` + relocates every provider-specific primary and secondary inventory root; no + implicit state file or shared cache may fall back to the real user home, and + doctor reports the effective bounded roots rather than a fixed label. +- **PLG-AC-7 (surface consistency):** English, Chinese, and site installation + or troubleshooting guidance distinguish lifecycle planning from apply and + preserve host-specific unsupported, bundled, manual, or session-only states. + Documentation links, package boundaries, and generated routing remain valid. + No troubleshooting or adapter page recommends a native flag that the current + fail-closed host contract records as unavailable, including Cursor + `--plugin-dir`. +- **PLG-AC-8 (apply remains gated):** No `plugin apply` command, hidden executor, + config writer, or generic third-party plugin input is shipped. Apply remains + gated on accepted readiness, durable runtime, native mutation, journal, + verification receipt, and compensation contracts. +- **PLG-AC-9 (extensible ownership without parser duplication):** Each host + profile is independently owned and composed by one validated registry, while + shared profile constructors enforce typed steps and evidence fields. Plugin + and doctor commands use one strict option parser and one read-only command + runner for help, timeout, JSON envelope, diagnostics, and exit behavior; + adding a lifecycle leaf or doctor option does not require copying a parser or + machine-output control flow. +- **PLG-AC-10 (cohesive lifecycle core):** Plugin identity/digests, bounded + runtime and path discovery, status/verification, and plan generation have + separate capability-private owners. `plugin-lifecycle/index.mjs` remains the + stable public import surface and contains no lifecycle implementation, so + extending one concern does not require editing an unrelated core path. +- **PLG-AC-11 (declarative host observation):** Inventory home routing and + surface observation semantics are validated host-profile data. Status and + verification interpret those declarations without canonical host-id tables + or host-specific branches, so adding a host or a second surface does not + require modifying the lifecycle status core. +- **PLG-AC-12 (single target resolution owner):** Host aliases, all-host + selection, explicit-plan-host requirements, multi-surface ambiguity, surface + lookup, and scope validation have one lifecycle-private owner. Status and plan + cores consume resolved targets without importing host-support lookup APIs or + duplicating target-related diagnostics, while existing error codes, hints, + JSON envelopes, and exit codes remain stable. +- **PLG-AC-13 (declarative plugin leaf dispatch):** Each plugin leaf has one + validated runtime definition for its name, usage synopsis, allowed options, + positional contract, executor, and human renderer. The plugin CLI performs + generic lookup, parsing, preparation, and read-only dispatch without + command-name branches; the root registry projects the import-free command + manifest without loading lifecycle runtime modules, while root help and + OpenCLI output stay unchanged. +- **PLG-AC-14 (single status-row model):** Observed installs and inventory + failures use one lifecycle-private `PluginLifecycleStatusV1` row factory and + validator. Identity, target, discovery, installation, enablement, version, + verification, activation, checks, evidence, and diagnostics are constructed + once and checked against controlled state vocabularies, so a new observation + policy cannot create a structurally divergent row. +- **PLG-AC-15 (single lifecycle-plan model):** Plan transition policy, typed + step materialization, deterministic digests, and complete + `PluginLifecyclePlanV1` validation have one lifecycle-private owner. Mutation + steps declare external host-plugin-state effects, verification steps declare + read-only host-observation effects, and every emitted plan proves that its + target observation, summaries, blockers, status, and digests remain + internally consistent, including target/row distribution parity and exact + blocker/state invariants. The plan core only resolves a target, observes local + status, and delegates construction; it does not own plan schema or lifecycle + state transitions. +- **PLG-AC-16 (fail-fast host profile model):** Each host profile, surface, + lifecycle operation, evidence record, and typed step is validated and deeply + frozen when its module is constructed, before the aggregate registry loads. + One host-support model owns the local `HostSurfaceProfileV1` vocabulary and + invariants; aggregate validation delegates to it and owns only cross-profile + host-id and alias conflicts. Invalid manual/UI/host-command payloads, + undeclared lifecycle keys, mutable nested declarations, and executable steps + without evidence fail at their local module boundary. +- **PLG-AC-17 (shared lifecycle contract primitives):** Status-row and plan + validation share one lifecycle-private owner for base assertions, Better + Harness plugin identity/version fields, host/surface/scope/distribution + targets, expected-source identity, and diagnostic shape/severity. Status and + plan modules retain only their domain-specific state vocabularies and + invariants; they cannot copy or independently weaken the shared contract, and + the stable public lifecycle export surface does not grow. + +## Non-goals + +- Managing arbitrary third-party plugins, publishing a plugin registry, or + defining Loop Packs, signatures, permissions, dependencies, or trust policy. +- Adding an MCP server, MCP tool, MCP configuration, or alternate runtime. +- Executing install, update, remove, marketplace, Git, package-manager, desktop, + slash-command, or session-launch steps. +- Querying a remote latest version or treating the local package version as a + remote freshness claim. +- Deleting user reports, host data, configuration, caches, CLI binaries, or + source checkouts. +- Promoting ADR-0002 target owners without explicit maintainer acceptance and an + activation decision; new declarations remain a shadow parity surface. +- Generalizing the shared lifecycle command runner into the repository-wide + authoritative command-contract owner while ADR-0002 remains proposed. + +## Plan and Tasks + +1. Add capability-owned host-support profiles and validation, with structured + lifecycle steps stored as argv arrays or explicit manual/UI/session steps. +2. Add a plugin-lifecycle public module that composes host profiles with + `agent-customize`'s public inventory, performs strict Better Harness identity + matching, normalizes status, builds deterministic plans, and verifies local + plugin artifacts. +3. Add strict `plugin` and `doctor` CLI entrypoints, register their leaf + contracts in the root CLI, and keep the root facade free of behavior. +4. Add isolated-home fixtures and contract tests for every host, unsupported and + stale capability behavior, cross-platform paths, machine output, no-write + behavior, and the absence of apply. +5. Update curated installation and troubleshooting routes, regenerate the + documentation graph, and verify package/runtime boundaries. +6. Run a Review Readiness Check over Story/Spec/Test/Risk, changed modules, + generated files, AI involvement, and staged/unstaged state before handoff. +7. Split the monolithic host declaration into per-host profile modules backed + by shared typed constructors, and replace the duplicated plugin/doctor CLI + parsing and envelope flow with one lifecycle-owned read-only command runtime. +8. Split the lifecycle implementation into identity, runtime, status, and plan + modules while preserving `index.mjs` exports and public command behavior. +9. Move provider home-option routing and inventory/bundled/session/desktop-cache + observation policy into validated host profiles, then make status collection + a host-neutral interpreter of those policies. +10. Extract host, surface, and scope resolution into one private lifecycle + module, migrate status and plan composition to it, and preserve the public + lifecycle export surface and command behavior. +11. Extract human rendering and declarative plugin leaf definitions, replace + command-specific CLI branches with one descriptor dispatcher, and enforce + parity with the root command registry. +12. Extract status-row construction, local verification, evidence shaping, and + model validation; route both inventory success and failure through the same + factory and leave status core responsible only for collection, ordering, + aggregation, and command-level summaries. +13. Extract lifecycle-plan transition policy, typed step materialization, + deterministic summaries, and complete model validation into one private + plan-model factory; leave plan core responsible only for action validation, + target resolution, local observation, and delegation. +14. Extract the local host-profile vocabulary and validator, make every builder + fail fast and deeply freeze its result, and reduce aggregate registry + validation to delegation plus cross-profile identity uniqueness. +15. Move shared lifecycle plugin, target, diagnostic, and assertion primitives + into the existing private model owner; migrate status-row and plan-model + validators without changing their emitted JSON or public exports. + +## Test and Review Evidence + +- **PLG-AC-1/2/3/4/6/8:** run focused host-support, plugin-lifecycle, doctor, + agent-customize, and no-write fixture tests using isolated homes and injected + host discovery. Include secondary-root isolation, foreign-workspace records, + unsupported scopes, per-surface evidence, provider project scope, inventory + failure, independent static-surface collection and discovery, absent + executable, workspace-bound plans, locale-independent plan + ids, Windows `PATHEXT`, and POSIX executable-permission regressions. +- **PLG-AC-5:** run root CLI tests for help-only behavior, strict arguments, + leaf metadata, JSON bootstrap positions, stdout purity, exit mapping, and + unknown command/surface/scope diagnostics. Rebase the command registry and + fixtures on the target branch's leaf-path contract before regenerating root + inventory hashes. +- **PLG-AC-7:** run support-declaration, plugin-manifest, docs-entrypoint, docs + site, and documentation-link tests; regenerate + `docs/better-harness-doc-links.mmd` with + `node scripts/doc-link-graph/cli.mjs skills/better-harness`. Assert that + installation, troubleshooting, adapter matrix, and Chinese mirrors do not + advertise lifecycle commands marked unavailable by native evidence. +- **PLG-AC-1 through PLG-AC-8:** run `npm run pack:verify`, `npm test`, + `git diff --check`, diff-stat inspection, and separate staged/unstaged checks. +- **PLG-AC-9:** test that each canonical host has exactly one independently + importable profile module, profile construction remains validated, plugin and + doctor parsers share one strict implementation, and all existing help/JSON/ + timeout/exit snapshots remain byte-compatible. +- **PLG-AC-10:** enforce an implementation-free public `index.mjs`, prohibit + cross-capability imports of private lifecycle modules, and rerun identity, + status, verification, plan, doctor, and root CLI contract suites. +- **PLG-AC-11:** assert that every surface declares a supported observation + kind and discovery source, profile home routing is validated, and + `status-core.mjs` contains no + canonical host ids or provider-home lookup table; preserve frozen status, + verify, doctor, JSON, exit, and plan behavior. +- **PLG-AC-12:** cover aliases, all/auto/missing hosts, multi-surface hosts, + unknown surfaces, valid and invalid scopes, and exact usage diagnostics; + statically prevent status/plan cores from importing host-support lookup APIs + or owning target-resolution error codes. +- **PLG-AC-13:** snapshot the definition set against root plugin subcommands, + validate every executor/renderer/positional contract, prohibit command-name + conditionals in `plugin-lifecycle/cli.mjs`, allow only the root registry to + import the metadata-only manifest directly, and rerun help, strict parsing, + human rendering, JSON, OpenCLI, and failure-channel contracts. +- **PLG-AC-14:** validate every emitted row from all hosts, corrupt each + controlled state family in isolation, compare observed and inventory-failure + row shapes, and statically prevent `status-core.mjs` from owning row-level + verification, evidence, or complete schema literals. +- **PLG-AC-15:** validate install plans for every host surface and each + install/update/remove policy lane, corrupt controlled plan states, digests, + summaries, targets, diagnostics, and step effects independently, and + statically prevent `plan-core.mjs` from owning transition policy, step + materialization, or complete plan schema literals. +- **PLG-AC-16:** directly construct invalid host, surface, lifecycle, evidence, + shell, host-command, manual, and desktop-UI declarations and require local + failure; attempt nested mutation in strict modules; independently import all + host modules; corrupt aggregate ids and aliases; and statically prevent the + registry facade from duplicating local profile schema validation. +- **PLG-AC-17:** validate both plugin version-field variants, base and + expected-source targets, diagnostic shape/severity, and controlled values; + rerun status/plan corruption suites and statically require both validators to + import the shared primitives while prohibiting private helper copies and new + public lifecycle exports. +- The redacted native help summary in + `test/fixtures/plugin-lifecycle/native-help-contracts.v1.json` is supporting + macOS contract evidence, not a cross-platform native smoke receipt. Pi native + executable evidence remains unobserved. The fixture separately records the + installed, versioned, redacted `@earendil-works/pi-coding-agent@0.83.0` + package-source contracts for persistent settings and temporary `-e` loading; + that source inspection is not a live native smoke. Cursor/Qoder command drift + stays fail-closed where their complete install contracts are not independently + verified. + +### Observed implementation evidence + +- `node --test` focused lifecycle, doctor, root CLI, support-declaration, and + frozen-contract suites passed after implementation. +- Documentation, bilingual site, manifest, and host-artifact focused suites + passed; the generated Skill documentation graph is current. +- Pi persistent/session surface separation, per-surface discovery ownership, + lifecycle/profile/doctor/CLI architecture, and provider regressions passed + `171/171`; documentation, link, and frozen script contracts passed `32/32`. +- Focused lifecycle, architecture, root CLI, support-declaration, and doc-link + suites passed `213/213` after the final Pi surface, discovery-isolation, + provenance, and shell-neutral rendering regressions landed; the regenerated + documentation graph passed `6/6`. +- `npm test` passed `1163/1163`; `npm run pack:verify` passed with 401 npm + entries and 424 runtime zip entries; `git diff HEAD --check` passed. +- Local read-only smoke checks returned one JSON document for status, verify, + and doctor across eight hosts (`11` surface rows and `8` doctor targets), with + expected partial exit `2` and no absolute `/Users/phodal` output. The Qwen + install-plan smoke separately proved mutation steps use + `external/host-plugin-state`, verification steps use + `read-only/host-observation`, and command-envelope side effects remain + `read-only`. +- Review Readiness found no Story token or external tracker evidence; this spec + is the confirmed local intent/acceptance source. ADR-0002 remains proposed, + so the host-support declarations remain shadow profiles. +- Review Readiness initially found that the staged snapshot predated the + working-tree refactor and failed `git diff --cached --check`. The intended + working tree was explicitly restaged, the staged check passed, and the + delivery branch was rebased onto the current `origin/main` before PR + creation. The upstream leaf-command-path contract remains integrated and + covered by the root CLI tests. +- PLG-AC-9 focused tests prove one importable module per host, fail-fast profile + constructors, and one parser/runner shared by plugin and doctor while frozen + help, command inventory, and OpenCLI outputs remain unchanged. +- PLG-AC-10 focused tests prove the lifecycle public index contains only stable + re-exports, private concern modules stay behind that boundary, and external + capabilities do not couple to lifecycle implementation modules. +- PLG-AC-11 focused tests prove every host home option and surface observation + policy is validated profile data, while status contains no canonical host id, + provider-home table, or provider-specific discovery diagnostic. +- PLG-AC-12 focused tests prove all seven target-related usage codes have one + private owner, aliases/surfaces/scopes preserve exact diagnostics, and invalid + status targets fail before host inventory collection. +- PLG-AC-13 focused tests prove one import-free manifest projects directly into + root discovery without loading the lifecycle runtime and into exact runtime + bindings, the plugin CLI has no leaf-name conditionals, and extracted human + status/plan output remains byte-stable. +- PLG-AC-14 focused tests prove observed and inventory-failure paths share one + complete status-row factory and validator, every controlled state family is + rejected when corrupted, all eleven host-surface rows validate, and the status + core no longer owns row-level schema, evidence, or verification construction. +- PLG-AC-15 focused tests validate all 66 host-surface/action/precondition + combinations, reject controlled state, summary, target, diagnostic, step, + recovery, and digest corruption, enforce manifest/action parity, and prove + the plan core no longer owns transition, step-materialization, or schema + construction. +- PLG-AC-16 focused tests prove malformed evidence and every typed instruction + fail locally, actionable/manual operation evidence is mandatory, lifecycle + keys and scopes are exact, structural fields cannot be overridden, nested + declarations are frozen, all eight host modules validate independently, and + aggregate validation owns only duplicate host-id and alias conflicts. +- PLG-AC-17 focused tests prove both version-field variants and base/expected- + source targets share one validator, diagnostic arrays use one severity + vocabulary, status-row and plan-model contain no private assertion or + diagnostic-severity copies, and the exact public lifecycle exports remain + unchanged. + +## Risks and Review Focus + +- Host inventories use different identifiers and scopes; false-positive + display-name matching could report or plan against the wrong plugin. +- Installed files do not prove a host loaded the Skill. Verification must remain + partial unless activation evidence exists. +- Native argv contracts can become stale as host CLIs evolve. Human output must + preserve them as non-shell JSON data, and a plan step is executable only when + its declared native-contract evidence is current. +- The new host-support surface must not silently replace the canonical adapter + matrix while ADR-0002 remains proposed. +- Paths and command rendering must remain portable across Windows, macOS, and + Linux without shell-string execution. diff --git a/scripts/agent-customize/providers/claude.mjs b/scripts/agent-customize/providers/claude.mjs index 46a753a..e1e1d4c 100644 --- a/scripts/agent-customize/providers/claude.mjs +++ b/scripts/agent-customize/providers/claude.mjs @@ -67,7 +67,8 @@ function pluginKey(value) { function normalizeInstallScope(value) { const scope = String(value ?? "user").trim().toLowerCase(); - return scope === "project" || scope === "local" || scope === "workspace" ? "project" : "user"; + if (scope === "workspace") return "project"; + return scope || "unknown"; } function pluginPathValues(value) { @@ -326,9 +327,11 @@ function pluginSetting(id, settings) { } function pluginApplicable(record, workspace, settings, id) { - if (normalizeInstallScope(record.scope) === "user") return true; + const scope = normalizeInstallScope(record.scope); + if (scope === "user") return true; + if (!settings[scope]) return true; if (record.projectPath) return normalizeWorkspace(record.projectPath) === workspace; - return settings.local.get(id) === true || settings.project.get(id) === true; + return settings[scope].get(id) === true; } function normalizeProvidedRecord(id, record, index) { diff --git a/scripts/agent-customize/providers/codex.mjs b/scripts/agent-customize/providers/codex.mjs index 6ceebe6..630bb44 100644 --- a/scripts/agent-customize/providers/codex.mjs +++ b/scripts/agent-customize/providers/codex.mjs @@ -37,7 +37,7 @@ const ALWAYS_INSTALLED_MARKETPLACES = new Set([ ]); function defaultCodexHome() { - return path.join(os.homedir(), ".codex"); + return process.env.CODEX_HOME || path.join(os.homedir(), ".codex"); } function defaultCodexAppPath() { @@ -299,7 +299,10 @@ function emptyPrimitives() { export async function collectCodexCustomizeInventory(options = {}) { const codexHome = path.resolve(expandHome(options.codexHome ?? options["codex-home"] ?? defaultCodexHome())); - const codexAppPath = options.codexAppPath ?? options["codex-app-path"] ?? defaultCodexAppPath(); + const configuredCodexAppPath = options.codexAppPath ?? options["codex-app-path"] ?? defaultCodexAppPath(); + const codexAppPath = typeof configuredCodexAppPath === "string" && configuredCodexAppPath.length > 0 + ? path.resolve(expandHome(configuredCodexAppPath)) + : configuredCodexAppPath; const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); const includeUserHome = options.includeUserHome !== false; const installState = includeUserHome diff --git a/scripts/agent-customize/providers/copilot.mjs b/scripts/agent-customize/providers/copilot.mjs index 386e84c..17913aa 100644 --- a/scripts/agent-customize/providers/copilot.mjs +++ b/scripts/agent-customize/providers/copilot.mjs @@ -103,6 +103,7 @@ function normalizeProvidedCopilotRecord(record) { return undefined; } const marketplaceName = record.marketplace || "_direct"; + const originSource = record.source?.source ?? (record.marketplace ? "marketplace" : "direct"); return { id: `${marketplaceName}/${name}`, name, @@ -110,8 +111,9 @@ function normalizeProvidedCopilotRecord(record) { installPath: path.resolve(expandHome(installPath)), version: record.version, enabled: record.enabled !== false, - source: record.source?.source ?? (record.marketplace ? "marketplace" : "direct"), - sources: [record.marketplace ? "marketplace" : "direct"], + source: "user", + sources: ["user"], + originSource, installMatch: record.marketplace ? "copilot-marketplace" : "copilot-direct", installedAt: record.installed_at ?? record.installedAt, }; @@ -206,6 +208,7 @@ async function collectCopilotPlugin(record) { installSources: record.sources, installSource: record.source, installMatch: record.installMatch, + originSource: record.originSource, installedAt: record.installedAt, enabled: record.enabled, evidence: evidence(metadataEvidencePath, path.dirname(path.dirname(pluginRoot))), @@ -263,7 +266,7 @@ async function collectCopilotPlugins(records) { return [...byId.values()].sort(sortByName); } -async function collectCopilotUserPrimitives(copilotHome) { +async function collectCopilotUserPrimitives(copilotHome, copilotUserHome) { const settingsPath = path.join(copilotHome, "settings.json"); const settings = (await readCopilotJson(settingsPath)) ?? {}; const mcps = []; @@ -295,10 +298,10 @@ async function collectCopilotUserPrimitives(copilotHome) { const skills = await uniqueAssetsByRealPath([ ...(await collectSkillFiles(path.join(copilotHome, "skills"), "user", "User", copilotHome)), ...(await collectSkillFiles( - path.join(path.dirname(copilotHome), ".agents", "skills"), + path.join(copilotUserHome, ".agents", "skills"), "user", "User", - copilotHome, + copilotUserHome, )), ]); @@ -388,6 +391,9 @@ export async function collectCopilotCustomizeInventory(options = {}) { const copilotHome = path.resolve( expandHome(options.copilotHome ?? options["copilot-home"] ?? defaultCopilotHome()), ); + const copilotUserHome = path.resolve(expandHome( + options.copilotUserHome ?? path.dirname(copilotHome), + )); const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); const includeUserHome = options.includeUserHome !== false; const installState = includeUserHome @@ -395,13 +401,14 @@ export async function collectCopilotCustomizeInventory(options = {}) { : { records: [], source: "not-authorized", installRecordFiles: [] }; const [plugins, user, project] = await Promise.all([ includeUserHome ? collectCopilotPlugins(installState.records ?? []) : [], - includeUserHome ? collectCopilotUserPrimitives(copilotHome) : emptyPrimitives(), + includeUserHome ? collectCopilotUserPrimitives(copilotHome, copilotUserHome) : emptyPrimitives(), collectCopilotWorkspacePrimitives(workspace), ]); return { generatedAt: new Date().toISOString(), provider: "copilot", copilotHome, + copilotUserHome, workspace, tabs: MANAGE_TABS, plugins, diff --git a/scripts/agent-customize/providers/cursor.mjs b/scripts/agent-customize/providers/cursor.mjs index 7d31f72..9a42885 100644 --- a/scripts/agent-customize/providers/cursor.mjs +++ b/scripts/agent-customize/providers/cursor.mjs @@ -29,7 +29,7 @@ import { titleCase, workspaceSourceLabel, } from "../core/items.mjs"; -import { readInstalledPluginState } from "../storage.mjs"; +import { readInstalledPluginState, resolveCursorStateDbPath } from "../storage.mjs"; async function collectRuntimePluginMcpItems(cursorHome) { const projectsRoot = path.join(cursorHome, "projects"); @@ -330,11 +330,14 @@ function emptyPrimitives() { export async function collectCursorCustomizeInventory(options = {}) { const cursorHome = path.resolve(expandHome(options.cursorHome ?? path.join(os.homedir(), ".cursor"))); + const stateDbPath = resolveCursorStateDbPath(options); const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); const includeUserHome = options.includeUserHome !== false; const [allPlugins, installState, user, project] = await Promise.all([ includeUserHome ? collectPlugins(cursorHome) : [], - includeUserHome ? readInstalledPluginState({ ...options, workspace }) : { records: [], source: "not-authorized" }, + includeUserHome + ? readInstalledPluginState({ ...options, stateDbPath, workspace }) + : { records: [], stateDbPath, source: "not-authorized" }, includeUserHome ? collectUserPrimitives(cursorHome) : emptyPrimitives(), collectWorkspacePrimitives(workspace), ]); @@ -344,6 +347,7 @@ export async function collectCursorCustomizeInventory(options = {}) { generatedAt: new Date().toISOString(), provider: "cursor", cursorHome, + stateDbPath, workspace, tabs: MANAGE_TABS, plugins, diff --git a/scripts/agent-customize/providers/pi.mjs b/scripts/agent-customize/providers/pi.mjs index 794264c..c9f7214 100644 --- a/scripts/agent-customize/providers/pi.mjs +++ b/scripts/agent-customize/providers/pi.mjs @@ -511,6 +511,7 @@ export async function collectPiCustomizeInventory(options = {}) { generatedAt: new Date().toISOString(), provider: "pi", piHome, + piUserHome: userHome, workspace, tabs: MANAGE_TABS, plugins, diff --git a/scripts/agent-customize/providers/qoder.mjs b/scripts/agent-customize/providers/qoder.mjs index 51498e2..b781320 100644 --- a/scripts/agent-customize/providers/qoder.mjs +++ b/scripts/agent-customize/providers/qoder.mjs @@ -98,13 +98,11 @@ function splitQoderPluginKey(key) { } function qoderScopeForRecord(record, workspace) { - const rawScope = String(record?.scope ?? "user").trim(); - if (rawScope === "local") { - const projectPath = record?.projectPath ? normalizeWorkspace(record.projectPath) : ""; - return projectPath && projectPath === workspace ? "project" : "other"; - } - if (rawScope === "workspace") { - return "project"; + const rawScope = String(record?.scope ?? "user").trim().toLowerCase(); + if (["project", "local", "workspace"].includes(rawScope)) { + if (!record?.projectPath) return "other"; + if (normalizeWorkspace(record.projectPath) !== workspace) return undefined; + return rawScope === "workspace" ? "project" : rawScope; } return rawScope || "user"; } @@ -151,25 +149,29 @@ function mergeQoderInstalledRecord(existing, next) { } async function readQoderInstalledPluginState(options = {}) { + const qoderHome = path.resolve(expandHome(options.qoderHome ?? defaultQoderHome())); + const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); if (Array.isArray(options.qoderInstalledPluginRecords) || Array.isArray(options.installedPluginRecords)) { const records = (options.qoderInstalledPluginRecords ?? options.installedPluginRecords) .map((record) => { const key = splitQoderPluginKey(record.id); + const sources = (Array.isArray(record.sources) ? record.sources : [record.source ?? "user"]) + .map((source) => qoderScopeForRecord({ ...record, scope: source }, workspace)) + .filter((source) => source != null); + if (sources.length === 0) return undefined; return { ...key, installPath: record.installPath, version: record.version, - sources: Array.isArray(record.sources) ? record.sources : [record.source ?? "user"], - source: record.source ?? "user", + sources: [...new Set(sources)], + source: sources[0], indexSources: record.indexSources ?? ["provided"], }; }) - .filter((record) => record.id && record.installPath); + .filter((record) => record?.id && record.installPath); return { records, source: "provided", indexFiles: [] }; } - const qoderHome = path.resolve(expandHome(options.qoderHome ?? defaultQoderHome())); - const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); const legacyIndexPath = path.join(qoderHome, "plugins", "installed_plugins.json"); const scopedIndexPath = path.join(qoderHome, "plugins", "installed_plugins_v2.json"); const recordsById = new Map(); @@ -181,6 +183,7 @@ async function readQoderInstalledPluginState(options = {}) { for (const [id, value] of Object.entries(legacyIndex.plugins)) { const key = splitQoderPluginKey(id); const source = qoderScopeForRecord(value, workspace); + if (source == null) continue; recordsById.set( key.id, mergeQoderInstalledRecord(recordsById.get(key.id), { @@ -204,6 +207,7 @@ async function readQoderInstalledPluginState(options = {}) { const records = Array.isArray(values) ? values : [values]; for (const value of records) { const source = qoderScopeForRecord(value, workspace); + if (source == null) continue; recordsById.set( key.id, mergeQoderInstalledRecord(recordsById.get(key.id), { @@ -552,6 +556,7 @@ export async function collectQoderCustomizeInventory(options = {}) { generatedAt: new Date().toISOString(), provider: "qoder", qoderHome, + qoderSharedClientCacheRoot: sharedClientCacheRoot, sharedClientCacheRoot, workspace, tabs: MANAGE_TABS, diff --git a/scripts/agent-customize/providers/qwen.mjs b/scripts/agent-customize/providers/qwen.mjs index bf5d09e..7a52f7a 100644 --- a/scripts/agent-customize/providers/qwen.mjs +++ b/scripts/agent-customize/providers/qwen.mjs @@ -30,6 +30,7 @@ import { const QWEN_EXTENSION_MANIFEST = ["qwen-extension.json"]; const QWEN_EXTENSION_INSTALL_FILE = ".qwen-extension-install.json"; const QWEN_EXTENSION_ENABLEMENT_FILE = "extension-enablement.json"; +const QWEN_EXTENSION_STATE_FILE = "state.json"; function defaultQwenHome() { return process.env.QWEN_HOME ?? path.join(os.homedir(), ".qwen"); @@ -55,9 +56,9 @@ function overrideMatchesPath(rule, checkPath) { return new RegExp(`^${regexString}$`).test(checkPath); } -function isExtensionEnabled(enablementConfig, extensionName, workspace) { +function isExtensionEnabled(enablementConfig, extensionName, workspace, initialEnabled = true) { const extensionConfig = enablementConfig?.[extensionName]; - let enabled = true; + let enabled = initialEnabled; const allOverrides = extensionConfig?.overrides ?? []; const lexicalPath = ensureLeadingAndTrailingSlash(workspace); let canonicalPath = lexicalPath; @@ -70,6 +71,87 @@ function isExtensionEnabled(enablementConfig, extensionName, workspace) { return enabled; } +function canonicalizeWorkspacePath(workspace) { + const resolved = path.resolve(workspace); + try { + return realpathSync.native(resolved); + } catch { + return resolved; + } +} + +function validQwenExtensionStoreState(state) { + if ( + !state + || typeof state !== "object" + || state.version !== 2 + || !Number.isSafeInteger(state.generation) + || state.generation < 0 + || !/^[a-f0-9]{64}$/u.test(state.legacyProjectionHash ?? "") + || !state.extensions + || typeof state.extensions !== "object" + || Array.isArray(state.extensions) + ) { + return false; + } + return Object.entries(state.extensions).every(([id, policy]) => ( + /^[a-f0-9]{64}$/u.test(id) + && policy + && typeof policy === "object" + && typeof policy.name === "string" + && /^[a-zA-Z0-9-_.]+$/u.test(policy.name) + && (policy.artifactGeneration == null + || (Number.isSafeInteger(policy.artifactGeneration) && policy.artifactGeneration >= 0)) + && ["enabled", "disabled"].includes(policy.defaultActivation) + && policy.workspaceOverrides + && typeof policy.workspaceOverrides === "object" + && !Array.isArray(policy.workspaceOverrides) + && Object.entries(policy.workspaceOverrides).every(([workspacePath, activation]) => ( + (path.isAbsolute(workspacePath) || path.win32.isAbsolute(workspacePath)) + && ["enabled", "disabled", "inherit"].includes(activation) + )) + && (policy.legacyPathRules == null + || (Array.isArray(policy.legacyPathRules) + && policy.legacyPathRules.every((rule) => typeof rule === "string"))) + )); +} + +function resolveQwenExtensionScope(state, extensionName, workspace) { + if (!validQwenExtensionStoreState(state)) { + return { scope: "unknown", enabled: undefined }; + } + const policies = Object.values(state.extensions).filter((policy) => policy.name === extensionName); + if (policies.length !== 1) { + return { scope: "unknown", enabled: undefined }; + } + const policy = policies[0]; + const canonicalWorkspace = canonicalizeWorkspacePath(workspace); + const exact = policy.workspaceOverrides[canonicalWorkspace]; + let enabled = exact === "enabled" + ? true + : exact === "disabled" + ? false + : policy.defaultActivation === "enabled"; + if (exact == null || exact === "inherit") { + enabled = isExtensionEnabled( + { [extensionName]: { overrides: policy.legacyPathRules ?? [] } }, + extensionName, + workspace, + enabled, + ); + } + if (policy.defaultActivation === "enabled") { + return { scope: "user", enabled }; + } + if (exact === "enabled") { + return { scope: "project", enabled }; + } + if (Object.values(policy.workspaceOverrides).includes("enabled")) { + return { scope: "foreign", enabled }; + } + return { scope: "unknown", enabled }; +} + function flattenSettingsHooks(settingsHooks) { if (!settingsHooks || typeof settingsHooks !== "object" || Array.isArray(settingsHooks)) return []; const items = []; @@ -127,6 +209,7 @@ function normalizeProvidedQwenRecord(record) { version: record.version, sources: Array.isArray(record.sources) ? record.sources : [record.source ?? "user"], source: record.source ?? "user", + originSource: record.originSource, installMatch: record.installMatch ?? "provided", type: record.type ?? "link", enablementConfig: record.enablementConfig ?? null, @@ -138,13 +221,20 @@ async function readQwenInstalledPluginState(options = {}) { const records = (options.qwenInstalledPluginRecords ?? options.installedPluginRecords) .map(normalizeProvidedQwenRecord) .filter(Boolean); - return { records, source: "provided", installRecordFiles: [] }; + return { records, source: "provided", installRecordFiles: [], scopeState: "provided" }; } const qwenHome = path.resolve(expandHome(options.qwenHome ?? options["qwen-home"] ?? defaultQwenHome())); + const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); const extensionsRoot = path.join(qwenHome, "extensions"); const enablementPath = path.join(extensionsRoot, QWEN_EXTENSION_ENABLEMENT_FILE); + const statePath = path.join(qwenHome, "extension-store", QWEN_EXTENSION_STATE_FILE); const enablement = (await readJson(enablementPath)) ?? {}; + const stateExists = await pathExists(statePath); + const extensionStoreState = stateExists ? await readJson(statePath) : undefined; + const scopeState = validQwenExtensionStoreState(extensionStoreState) + ? "extension-store-v2" + : (stateExists ? "invalid" : "missing"); const records = []; const installRecordFiles = []; for (const extensionDir of await listDirectories(extensionsRoot)) { @@ -155,24 +245,31 @@ async function readQwenInstalledPluginState(options = {}) { } installRecordFiles.push(installMarkerPath); const installMarker = (await readJson(installMarkerPath)) ?? {}; + const scopeResolution = resolveQwenExtensionScope(extensionStoreState, name, workspace); + const installType = installMarker.type ?? "unknown"; + const linkedSource = installType === "link" && installMarker.source; records.push({ id: `qwen/${name}`, name, marketplaceName: "qwen", - installPath: installMarker.source ?? extensionDir, + installPath: linkedSource ? installMarker.source : extensionDir, installMarkerPath, version: installMarker.version, - sources: ["user"], - source: installMarker.originSource ?? "user", + sources: [scopeResolution.scope], + source: scopeResolution.scope, + enabled: scopeResolution.enabled, + originSource: installMarker.originSource, installMatch: "qwen-extension-install", - type: installMarker.type ?? "link", + type: installType, enablementConfig: enablement, }); } return { records, source: records.length > 0 ? "qwen-extensions" : "missing", - installRecordFiles, + installRecordFiles: stateExists ? [...installRecordFiles, statePath] : installRecordFiles, + scopeState, + scopeStateFile: statePath, }; } @@ -214,7 +311,9 @@ async function collectQwenPlugin(record, workspace) { heading || titleCase(manifest.name || packageJson.name || record.name); const displayName = normalizePluginDisplayName(rawDisplayName, record.name); - const enabled = isExtensionEnabled(record.enablementConfig, record.name, workspace); + const enabled = typeof record.enabled === "boolean" + ? record.enabled + : isExtensionEnabled(record.enablementConfig, record.name, workspace); const plugin = { id: record.id, qwenExtensionId: record.name, @@ -229,6 +328,7 @@ async function collectQwenPlugin(record, workspace) { version: record.version || manifest.version || packageJson.version, installSources: record.sources, installSource: record.source, + originSource: record.originSource, installMatch: record.installMatch, installType: record.type, installRecordPath: record.installMarkerPath, @@ -347,8 +447,8 @@ export async function collectQwenCustomizeInventory(options = {}) { const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); const includeUserHome = options.includeUserHome !== false; const installState = includeUserHome - ? await readQwenInstalledPluginState({ ...options, qwenHome }) - : { records: [], source: "not-authorized", installRecordFiles: [] }; + ? await readQwenInstalledPluginState({ ...options, qwenHome, workspace }) + : { records: [], source: "not-authorized", installRecordFiles: [], scopeState: "not-authorized" }; const [plugins, user, project] = await Promise.all([ includeUserHome ? collectQwenPlugins(installState.records ?? [], workspace) : [], includeUserHome ? collectQwenUserPrimitives(qwenHome) : emptyPrimitives(), @@ -366,6 +466,8 @@ export async function collectQwenCustomizeInventory(options = {}) { installedPluginState: installState.source, installedPluginRecordCount: plugins.length, installedPluginRecordFiles: installState.installRecordFiles ?? [], + installedPluginScopeState: installState.scopeState, + installedPluginScopeStateFile: installState.scopeStateFile, remotePluginInstallMarkersRequired: true, }, unsupported: [ diff --git a/scripts/agent-customize/providers/workbuddy.mjs b/scripts/agent-customize/providers/workbuddy.mjs index de8828f..f243e40 100644 --- a/scripts/agent-customize/providers/workbuddy.mjs +++ b/scripts/agent-customize/providers/workbuddy.mjs @@ -55,8 +55,9 @@ async function collectWorkbuddyMarketplacePlugin({ pluginRoot, pluginName, marke description: manifest?.description || "", publisher: { displayName: `WorkBuddy Marketplace (${marketplaceName})` }, version: manifest?.version, - installSources: ["marketplace"], - installSource: "marketplace", + installSources: ["user"], + installSource: "user", + originSource: "marketplace", installMatch: "workbuddy-marketplace-dir", installType: "marketplace", enabled: enabledPlugins?.[enabledKey] === true, @@ -99,8 +100,7 @@ async function agentsSkillsDir(base) { return path.join(base, ".agents", "skills"); } -async function collectWorkbuddyUserPrimitives(workbuddyHome) { - const userHomeBase = path.dirname(workbuddyHome); +async function collectWorkbuddyUserPrimitives(workbuddyHome, workbuddyUserHome) { // Global AGENTS.md plus the SOUL/IDENTITY/USER identity files enter every // WorkBuddy conversation as standing context; inventory them as user rules. const globalRules = await collectRuleSources( @@ -119,7 +119,7 @@ async function collectWorkbuddyUserPrimitives(workbuddyHome) { return { skills: [ ...(await collectSkillFiles(path.join(workbuddyHome, "skills"), "user", "User", workbuddyHome)), - ...(await collectSkillFiles(await agentsSkillsDir(userHomeBase), "user", "User", userHomeBase)), + ...(await collectSkillFiles(await agentsSkillsDir(workbuddyUserHome), "user", "User", workbuddyUserHome)), ].sort(sortByName), subagents: [], rules: globalRules, @@ -156,13 +156,16 @@ export async function collectWorkbuddyCustomizeInventory(options = {}) { const workbuddyHome = path.resolve(expandHome( options.workbuddyHome ?? options["workbuddy-home"] ?? defaultWorkbuddyHome(), )); + const workbuddyUserHome = path.resolve(expandHome( + options.workbuddyUserHome ?? path.dirname(workbuddyHome), + )); const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); const includeUserHome = options.includeUserHome !== false; const settingsPath = path.join(workbuddyHome, "settings.json"); const settings = includeUserHome ? (await readJson(settingsPath)) ?? {} : {}; const [marketplacePlugins, user, project] = await Promise.all([ includeUserHome ? collectWorkbuddyMarketplacePlugins(workbuddyHome, settings) : [], - includeUserHome ? collectWorkbuddyUserPrimitives(workbuddyHome) : emptyPrimitives(), + includeUserHome ? collectWorkbuddyUserPrimitives(workbuddyHome, workbuddyUserHome) : emptyPrimitives(), collectWorkbuddyWorkspacePrimitives(workspace), ]); const plugins = [...marketplacePlugins].sort(sortByName); @@ -170,6 +173,7 @@ export async function collectWorkbuddyCustomizeInventory(options = {}) { generatedAt: new Date().toISOString(), provider: "workbuddy", workbuddyHome, + workbuddyUserHome, workspace, tabs: MANAGE_TABS, plugins, diff --git a/scripts/agent-customize/storage.mjs b/scripts/agent-customize/storage.mjs index 020d9e0..5b3e11c 100644 --- a/scripts/agent-customize/storage.mjs +++ b/scripts/agent-customize/storage.mjs @@ -25,6 +25,10 @@ export function defaultCursorStateDbPath() { return path.join(xdgConfig, "Cursor", "User", "globalStorage", "state.vscdb"); } +export function resolveCursorStateDbPath(options = {}) { + return path.resolve(expandHome(options.stateDbPath ?? defaultCursorStateDbPath())); +} + function normalizeInstalledRecord(record) { const id = String(record?.id ?? record?.pluginId ?? "").trim(); if (!id) { @@ -126,7 +130,7 @@ export async function readInstalledPluginState(options = {}) { }; } - const stateDbPath = path.resolve(expandHome(options.stateDbPath ?? defaultCursorStateDbPath())); + const stateDbPath = resolveCursorStateDbPath(options); if (!(await pathExists(stateDbPath))) { return { records: undefined, diff --git a/scripts/better-harness-cli/cli.mjs b/scripts/better-harness-cli/cli.mjs index 84be755..876f8de 100644 --- a/scripts/better-harness-cli/cli.mjs +++ b/scripts/better-harness-cli/cli.mjs @@ -24,7 +24,7 @@ const HELP_GROUPS = [ { title: "Quickstart", audience: "workflow", - commands: ["report"], + commands: ["report", "doctor"], }, { title: "Workflows", @@ -39,7 +39,7 @@ const HELP_GROUPS = [ { title: "Agent Assets", audience: "advanced", - commands: ["agent-customize", "agent-lint", "coding-agent-practices"], + commands: ["agent-customize", "plugin", "agent-lint", "coding-agent-practices"], }, { title: "Maintainer Diagnostics", @@ -50,6 +50,7 @@ const HELP_GROUPS = [ const ROOT_EXAMPLES = [ { audience: "workflow", text: "better-harness report" }, + { audience: "workflow", text: "better-harness doctor --platform all --json" }, { audience: "workflow", text: "better-harness harness analyze --workspace . --language en --format json" }, { audience: "workflow", text: "better-harness harness checkup --phase scan --provider qoder --workspace . --json" }, { audience: "advanced", text: "better-harness harness render --findings /findings.json --mode qoder-canvas --out .qoder/better-harness --target . --validate --json" }, @@ -82,6 +83,11 @@ const GROUP_EXAMPLES = { { audience: "advanced", text: "better-harness coding-agent-practices asset-baseline codex --workspace . --json" }, { audience: "advanced", text: "better-harness coding-agent-practices inventory qoder --workspace ." }, ], + "plugin": [ + { audience: "advanced", text: "better-harness plugin status --host all" }, + { audience: "advanced", text: "better-harness plugin plan install --host qwen --scope user" }, + { audience: "advanced", text: "better-harness plugin verify --host codex --surface cli --json" }, + ], }; function scriptPath(relativePath) { diff --git a/scripts/better-harness-cli/registry.mjs b/scripts/better-harness-cli/registry.mjs index 5414bec..ac0a2f0 100644 --- a/scripts/better-harness-cli/registry.mjs +++ b/scripts/better-harness-cli/registry.mjs @@ -1,3 +1,5 @@ +import { PLUGIN_COMMAND_MANIFEST } from "../plugin-lifecycle/command-manifest.mjs"; + export const FORMAT_VERSION = "1.0"; export const CLI_NAME = "better-harness"; export const COMMAND_AUDIENCES = Object.freeze(["workflow", "advanced", "maintainer"]); @@ -26,6 +28,26 @@ const COMMANDS = [ summary: "Inspect agent-facing Skills, MCPs, hooks, plugins, settings, and memory surfaces.", aliases: [{ name: "customize", hidden: true }], }, + { + name: "plugin", + kind: "group", + audience: "advanced", + summary: "Inspect, plan, and verify the Better Harness plugin lifecycle without applying changes.", + description: "Normalize Better Harness installation evidence across Coding Agent hosts, emit read-only native lifecycle plans, and verify local plugin assets without executing host commands.", + subcommands: PLUGIN_COMMAND_MANIFEST.map(({ name, audience, entryScript: script, summary }) => ({ + name, + audience, + script, + summary, + })), + }, + { + name: "doctor", + kind: "direct", + audience: "workflow", + script: "harness-doctor/cli.mjs", + summary: "Run bounded read-only Better Harness runtime and host diagnostics.", + }, { name: "agent-lint", kind: "direct", diff --git a/scripts/harness-doctor/cli.mjs b/scripts/harness-doctor/cli.mjs new file mode 100644 index 0000000..e0cd033 --- /dev/null +++ b/scripts/harness-doctor/cli.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +import process from "node:process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { runHarnessDoctor } from "./index.mjs"; +import { + LifecycleCliError, + normalizeReadOnlyTimeout, + parseReadOnlyOptions, + runReadOnlyCommand, + validateWorkspace, +} from "../plugin-lifecycle/index.mjs"; + +function usage() { + return [ + "Better Harness doctor", + "", + "Usage:", + " better-harness doctor [--platform ] [options]", + "", + "Options:", + " --platform Host id or alias (default: all)", + " --workspace Workspace used for project-scope inventory", + " --host-home Isolated provider home; requires one platform", + " --timeout Bounded operation timeout metadata (default: 5000)", + " --json Emit one command-contract.v1 JSON document", + " --no-color Disable terminal color", + " -h, --help Print help without probing hosts", + "", + ].join("\n"); +} + +export function parseDoctorArgs(argv) { + const parsed = parseReadOnlyOptions(argv, { + valueOptions: { "--platform": "platform" }, + defaults: { platform: "all" }, + }); + if (!parsed.options.help && parsed.positionals.length > 0) { + throw new LifecycleCliError( + "UNEXPECTED_POSITIONAL", + `Unexpected argument: ${parsed.positionals[0]}`, + { kind: "usage" }, + ); + } + return normalizeReadOnlyTimeout(parsed.options); +} + +export function renderDoctor(result) { + const lines = [ + "Better Harness doctor", + "", + `Package: ${result.runtime.name} ${result.runtime.version}`, + `Node: ${result.runtime.nodeVersion} (${result.runtime.nodeRange})`, + `Platform: ${result.runtime.platform}/${result.runtime.architecture}`, + "Network: none; writes: none; transcripts: not read", + "", + "Hosts:", + ]; + for (const target of result.targets) { + const lifecycle = target.lifecycle + .map((entry) => ( + `${entry.surfaceId}@${entry.scope}[${entry.hostDiscovery}]:${entry.installation}/${entry.verification}` + )) + .join(", "); + lines.push(` ${target.hostId.padEnd(10)} ${target.hostDiscovery.padEnd(10)} ${lifecycle}`); + } + const warnings = result.diagnostics.filter((item) => item.severity !== "info"); + if (warnings.length > 0) lines.push("", "Diagnostics:", ...warnings.map((item) => ` ${item.code}: ${item.message}`)); + lines.push(""); + return lines.join("\n"); +} + +export async function main(argv = process.argv.slice(2)) { + return runReadOnlyCommand({ + argv, + command: "better-harness doctor", + parse: parseDoctorArgs, + usage, + prepare: async (options) => ({ + ...options, + workspace: await validateWorkspace(options.workspace), + }), + execute: runHarnessDoctor, + renderHuman: renderDoctor, + }); +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) { + process.exitCode = await main(); +} diff --git a/scripts/harness-doctor/index.mjs b/scripts/harness-doctor/index.mjs new file mode 100644 index 0000000..926d2cc --- /dev/null +++ b/scripts/harness-doctor/index.mjs @@ -0,0 +1,95 @@ +import { collectAgentCustomizeInventory } from "../agent-customize/index.mjs"; +import { getHostProfile } from "../host-support/index.mjs"; +import { + authorizedRootLabel, + inspectPluginLifecycle, + pluginLifecycleRuntimeInfo, +} from "../plugin-lifecycle/index.mjs"; + +function captureInventoryRoots(capturedRoots, providerOptions, inventory = {}) { + const profile = getHostProfile(providerOptions.provider); + if (!profile) return; + const roots = capturedRoots.get(profile.hostId) ?? new Map(); + for (const route of profile.inventoryHomeRoutes) { + const value = inventory?.[route.option] ?? providerOptions[route.option]; + if (typeof value === "string" && value.trim()) roots.set(route.option, value); + } + capturedRoots.set(profile.hostId, roots); +} + +function effectiveAuthorizedRoots(hostId, capturedRoots, options) { + const profile = getHostProfile(hostId); + if (!profile) return [authorizedRootLabel(hostId)]; + const roots = capturedRoots.get(profile.hostId); + const labels = profile.inventoryHomeRoutes.map((route) => { + const resolvedRoot = roots?.get(route.option); + if (resolvedRoot) { + return authorizedRootLabel(hostId, { + resolvedRoot, + workspace: options.workspace, + platform: options.runtimePlatform, + }); + } + return route.option === profile.inventoryHomeOption + ? authorizedRootLabel(hostId) + : route.fallbackLabel; + }); + return [...new Set(labels.filter(Boolean))]; +} + +export async function runHarnessDoctor(options = {}) { + const { + inventoryCollector: requestedInventoryCollector, + platform: host = "all", + ...lifecycleOptions + } = options; + const capturedRoots = new Map(); + const collectInventory = requestedInventoryCollector ?? collectAgentCustomizeInventory; + const inventoryCollector = async (providerOptions) => { + captureInventoryRoots(capturedRoots, providerOptions); + const inventory = await collectInventory(providerOptions); + captureInventoryRoots(capturedRoots, providerOptions, inventory); + return inventory; + }; + const lifecycle = await inspectPluginLifecycle({ + ...lifecycleOptions, + host, + inventoryCollector, + }); + const runtime = await pluginLifecycleRuntimeInfo(options); + const hostIds = [...new Set(lifecycle.rows.map((row) => row.target.hostId))]; + const targets = hostIds.map((hostId) => { + const rows = lifecycle.rows.filter((row) => row.target.hostId === hostId); + return { + hostId, + hostDiscovery: rows.some((row) => row.hostDiscovery === "present") + ? "present" + : rows.some((row) => row.hostDiscovery === "absent") ? "absent" : "unobserved", + authorizedRoots: effectiveAuthorizedRoots(hostId, capturedRoots, options), + inventory: rows.some((row) => row.diagnostics.some((item) => item.code === "INVENTORY_COLLECTION_FAILED")) + ? "failed" + : "available", + lifecycle: rows.map((row) => ({ + surfaceId: row.target.surfaceId, + scope: row.target.scope, + hostDiscovery: row.hostDiscovery, + installation: row.installation, + verification: row.verification, + })), + }; + }); + return { + kind: "better-harness-doctor", + schemaVersion: "1", + status: lifecycle.status, + runtime, + privacy: { + network: "none", + writes: "none", + transcripts: "not-read", + pathPolicy: "user-home-redacted", + }, + targets, + diagnostics: lifecycle.diagnostics, + }; +} diff --git a/scripts/host-support/index.mjs b/scripts/host-support/index.mjs new file mode 100644 index 0000000..f57cc2a --- /dev/null +++ b/scripts/host-support/index.mjs @@ -0,0 +1,40 @@ +import { HOST_PROFILES, HOST_SUPPORT_SCHEMA_VERSION } from "./profiles.mjs"; +import { validateHostProfileRegistry } from "./profile-model.mjs"; + +export function validateHostProfiles(profiles = HOST_PROFILES) { + return validateHostProfileRegistry(profiles); +} + +validateHostProfiles(); + +export function listHostProfiles() { + return HOST_PROFILES; +} + +export function resolveHostId(value) { + const normalized = String(value ?? "").trim().toLowerCase(); + const profile = HOST_PROFILES.find((entry) => ( + entry.hostId === normalized || entry.aliases.includes(normalized) + )); + return profile?.hostId; +} + +export function getHostProfile(value) { + const hostId = resolveHostId(value); + return hostId ? HOST_PROFILES.find((entry) => entry.hostId === hostId) : undefined; +} + +export function getHostSurface(host, surfaceId) { + const profile = getHostProfile(host); + if (!profile) return undefined; + if (surfaceId) return profile.surfaces.find((surface) => surface.surfaceId === surfaceId); + return profile.surfaces.length === 1 ? profile.surfaces[0] : undefined; +} + +export function supportedHostIds({ managedOnly = false } = {}) { + return HOST_PROFILES + .filter((profile) => !managedOnly || profile.managed) + .map((profile) => profile.hostId); +} + +export { HOST_PROFILES, HOST_SUPPORT_SCHEMA_VERSION } from "./profiles.mjs"; diff --git a/scripts/host-support/profile-builders.mjs b/scripts/host-support/profile-builders.mjs new file mode 100644 index 0000000..6fd3c03 --- /dev/null +++ b/scripts/host-support/profile-builders.mjs @@ -0,0 +1,123 @@ +import { + deepFreeze, + HOST_SUPPORT_SCHEMA_VERSION, + validateContractEvidence, + validateHostProfile, + validateHostSurface, + validateLifecycleOperation, + validateLifecycleStep, +} from "./profile-model.mjs"; + +export const BETTER_HARNESS_REPOSITORY = "https://github.com/QoderAI/better-harness.git"; + +export { + HOST_DISTRIBUTION_KINDS, + HOST_DISCOVERY_SOURCES, + HOST_OBSERVATION_KINDS, + HOST_SCOPE_ARTIFACT_POLICIES, + HOST_SCOPES, + HOST_SUPPORT_SCHEMA_VERSION, + LIFECYCLE_DISPOSITIONS, + LIFECYCLE_OPERATION_NAMES, + LIFECYCLE_STEP_KINDS, + NATIVE_HOME_BINDING_KINDS, +} from "./profile-model.mjs"; + +const DEFAULT_DOCS_PATH = "docs/docs/installation.mdx"; +const CONTRACT_FIXTURE = "test/fixtures/plugin-lifecycle/native-help-contracts.v1.json"; +const DEFAULT_OBSERVATION_KIND = Object.freeze({ + bundled: "bundled", + "source-local": "session-only", +}); + +const BETTER_HARNESS_IDENTITY = Object.freeze({ + names: ["better-harness", "@qoderai/better-harness"], + nativeIds: ["better-harness", "better-harness@better-harness", "better-harness/better-harness"], + repositories: [ + "qoderai/better-harness", + "github.com/qoderai/better-harness", + "git:github.com/qoderai/better-harness", + BETTER_HARNESS_REPOSITORY.toLowerCase(), + ], +}); + +export function evidence(id, kind = "native-cli-help") { + const record = { + id, + kind, + observedAt: "2026-07-31", + fixture: CONTRACT_FIXTURE, + }; + validateContractEvidence(record); + return deepFreeze(record); +} + +function defineStep(kind, payload, expected, extra = {}) { + const step = { ...extra, kind, ...payload, expected }; + validateLifecycleStep(step, `host ${kind} step`); + return deepFreeze(step); +} + +export function shell(argv, expected, extra = {}) { + return defineStep("shell", { argv }, expected, extra); +} + +export function manual(instruction, expected, extra = {}) { + return defineStep("manual", { instruction }, expected, extra); +} + +export function hostCommand(command, expected, extra = {}) { + return defineStep("host-command", { command }, expected, extra); +} + +export function desktopUi(instruction, expected, extra = {}) { + return defineStep("desktop-ui", { instruction }, expected, extra); +} + +export function operation(disposition, steps = [], contractEvidence = undefined) { + const lifecycleOperation = { + disposition, + steps, + ...(contractEvidence ? { contractEvidence } : {}), + }; + validateLifecycleOperation(lifecycleOperation); + return deepFreeze(lifecycleOperation); +} + +export function defineSurface(surface) { + const observation = Object.freeze({ + ...(surface.observation ?? {}), + kind: surface.observation?.kind + ?? DEFAULT_OBSERVATION_KIND[surface.distributionKind] + ?? "inventory", + discoverySource: surface.observation?.discoverySource + ?? (surface.observation?.discoveryDiagnostic ? "diagnostic" : "executable"), + }); + const defined = { + docsPath: DEFAULT_DOCS_PATH, + scopeArtifactPolicy: "independent", + ...surface, + observation, + }; + validateHostSurface(defined); + return deepFreeze(defined); +} + +export function defineHostProfile(profile) { + const provider = profile.provider ?? profile.hostId; + const inventoryHomeOption = profile.inventoryHomeOption ?? `${provider}Home`; + const defined = { + ...profile, + schemaVersion: HOST_SUPPORT_SCHEMA_VERSION, + identity: BETTER_HARNESS_IDENTITY, + provider, + inventoryHomeOption, + inventoryHomeRoutes: profile.inventoryHomeRoutes ?? [{ + option: inventoryHomeOption, + relativePath: "", + fallbackLabel: ``, + }], + }; + validateHostProfile(defined); + return deepFreeze(defined); +} diff --git a/scripts/host-support/profile-model.mjs b/scripts/host-support/profile-model.mjs new file mode 100644 index 0000000..8b76997 --- /dev/null +++ b/scripts/host-support/profile-model.mjs @@ -0,0 +1,259 @@ +export const HOST_SUPPORT_SCHEMA_VERSION = "1"; +export const HOST_DISTRIBUTION_KINDS = Object.freeze(["marketplace", "bundled", "source-local", "extension", "package"]); +export const HOST_SCOPES = Object.freeze(["user", "project", "local", "session", "bundled"]); +export const HOST_OBSERVATION_KINDS = Object.freeze(["inventory", "bundled", "session-only", "desktop-cache"]); +export const HOST_DISCOVERY_SOURCES = Object.freeze(["executable", "diagnostic", "unobserved"]); +export const HOST_SCOPE_ARTIFACT_POLICIES = Object.freeze(["independent", "shared"]); +export const NATIVE_HOME_BINDING_KINDS = Object.freeze(["environment"]); +export const LIFECYCLE_DISPOSITIONS = Object.freeze(["supported", "manual", "not-applicable", "unavailable"]); +export const LIFECYCLE_OPERATION_NAMES = Object.freeze(["install", "update", "remove", "verify"]); +export const LIFECYCLE_STEP_KINDS = Object.freeze(["shell", "manual", "host-command", "desktop-ui"]); + +function assert(condition, message) { + if (!condition) throw new TypeError(message); +} + +function assertNonEmptyString(value, message) { + assert(typeof value === "string" && value.length > 0, message); +} + +function assertIdentifier(value, label) { + assert(/^[a-z][a-z0-9-]*$/u.test(value), `Invalid ${label}: ${value}`); +} + +function assertStringArray(values, message, { nonEmpty = false } = {}) { + assert(Array.isArray(values), message); + if (nonEmpty) assert(values.length > 0, message); + assert(values.every((value) => typeof value === "string" && value.length > 0), message); +} + +function isRedactedInventoryFallback(value) { + if (value === "~" || /^$/u.test(value)) return true; + const match = value.match(/^(?:~|<[a-z][a-z0-9-]*>)\/(.+)$/u); + if (!match) return false; + const segments = match[1].split("/"); + return segments.length > 0 && segments.every((segment) => ( + segment !== "." + && segment !== ".." + && /^[A-Za-z0-9._-]+$/u.test(segment) + )); +} + +function validateInventoryHomeRoutes(profile) { + const routes = profile.inventoryHomeRoutes; + assert(Array.isArray(routes) && routes.length > 0, `Host has no inventory home routes: ${profile.hostId}`); + const options = new Set(); + for (const route of routes) { + assert(route && typeof route === "object" && !Array.isArray(route), `Invalid inventory home route: ${profile.hostId}`); + assert( + typeof route.option === "string" && /^[a-z][A-Za-z0-9]*$/u.test(route.option), + `Invalid inventory home route option: ${profile.hostId}`, + ); + assert(!options.has(route.option), `Duplicate inventory home route option: ${profile.hostId}/${route.option}`); + options.add(route.option); + assert(typeof route.relativePath === "string", `Invalid inventory home route path: ${profile.hostId}/${route.option}`); + assertNonEmptyString(route.fallbackLabel, `Invalid inventory home route fallback: ${profile.hostId}/${route.option}`); + assert( + isRedactedInventoryFallback(route.fallbackLabel), + `Inventory home route fallback must be redacted: ${profile.hostId}/${route.option}`, + ); + const segments = route.relativePath.split(/[\\/]/u); + assert( + !/^(?:[\\/]|[A-Za-z]:)/u.test(route.relativePath) && !segments.includes(".."), + `Inventory home route must stay inside the isolated root: ${profile.hostId}/${route.option}`, + ); + } + assert( + routes.some((route) => route.option === profile.inventoryHomeOption && route.relativePath === ""), + `Primary inventory home route is missing: ${profile.hostId}`, + ); +} + +export function deepFreeze(value, seen = new WeakSet()) { + if (!value || typeof value !== "object" || seen.has(value)) return value; + seen.add(value); + for (const child of Object.values(value)) deepFreeze(child, seen); + return Object.freeze(value); +} + +export function validateContractEvidence(record, label = "lifecycle operation") { + assert(record && typeof record === "object", `${label} lacks evidence; contract evidence is required.`); + assertNonEmptyString(record.id, `${label} has invalid contract evidence id.`); + assertNonEmptyString(record.kind, `${label} has invalid contract evidence kind.`); + assert(/^\d{4}-\d{2}-\d{2}$/u.test(record.observedAt), `${label} has invalid contract evidence date.`); + assertNonEmptyString(record.fixture, `${label} has invalid contract evidence fixture.`); + return true; +} + +export function validateLifecycleStep(step, label = "lifecycle step") { + assert(step && typeof step === "object", `${label} must be an object.`); + assert(LIFECYCLE_STEP_KINDS.includes(step.kind), `Invalid step kind for ${label}.`); + assertNonEmptyString(step.expected, `${label} lacks an expected result.`); + const representations = [step.argv, step.command, step.instruction] + .filter((value) => value != null); + assert(representations.length === 1, `${label} must have exactly one typed instruction.`); + if (step.kind === "shell") { + assert(Array.isArray(step.argv) && step.argv.length > 0, `${label} shell step requires a non-empty argv array.`); + assert(step.argv.every((argument) => typeof argument === "string"), `${label} shell argv must contain only strings.`); + } else if (step.kind === "host-command") { + assertNonEmptyString(step.command, `${label} host command is missing.`); + } else { + assertNonEmptyString(step.instruction, `${label} instruction is missing.`); + } + return true; +} + +export function validateLifecycleOperation(operation, label = "lifecycle operation") { + assert(operation && typeof operation === "object", `Invalid ${label}.`); + assert(LIFECYCLE_DISPOSITIONS.includes(operation.disposition), `Unknown lifecycle disposition: ${operation.disposition}`); + assert(Array.isArray(operation.steps), `${label} steps must be an array.`); + operation.steps.forEach((step, index) => validateLifecycleStep(step, `${label} step ${index + 1}`)); + const actionable = ["supported", "manual"].includes(operation.disposition); + if (actionable) { + assert(operation.steps.length > 0, `${label} ${operation.disposition} disposition must require steps.`); + validateContractEvidence(operation.contractEvidence, label); + } else { + assert(operation.steps.length === 0, `${label} ${operation.disposition} disposition cannot declare steps.`); + if (operation.contractEvidence != null) validateContractEvidence(operation.contractEvidence, label); + } + return true; +} + +export function validateHostSurface(surface, hostId = "") { + assert(surface && typeof surface === "object", `Invalid host surface for ${hostId}.`); + assertIdentifier(surface.surfaceId, "surface id"); + const label = `${hostId}/${surface.surfaceId}`; + assertNonEmptyString(surface.displayName, `Surface has no display name: ${label}`); + assert(HOST_DISTRIBUTION_KINDS.includes(surface.distributionKind), `Invalid distribution for ${label}`); + assertStringArray(surface.scopes, `Surface has no valid scopes: ${label}`, { nonEmpty: true }); + assert(new Set(surface.scopes).size === surface.scopes.length, `Surface repeats scopes: ${label}`); + assert(surface.scopes.every((scope) => HOST_SCOPES.includes(scope)), `Invalid scope for ${label}`); + assert(surface.scopes.includes(surface.defaultScope), `Invalid default scope for ${label}`); + assertNonEmptyString(surface.docsPath, `Surface has no documentation path: ${label}`); + assert(HOST_OBSERVATION_KINDS.includes(surface.observation?.kind), `Invalid observation kind for ${label}`); + assert( + HOST_DISCOVERY_SOURCES.includes(surface.observation?.discoverySource), + `Invalid observation discovery source for ${label}`, + ); + assert( + HOST_SCOPE_ARTIFACT_POLICIES.includes(surface.scopeArtifactPolicy), + `Invalid scope artifact policy for ${label}`, + ); + if (surface.scopeArtifactPolicy === "shared") { + validateContractEvidence(surface.scopeArtifactEvidence, `${label}/scope-artifact-policy`); + } else { + assert(surface.scopeArtifactEvidence == null, `Independent scope artifact policy cannot declare evidence for ${label}`); + } + if (surface.observation.discoveryDiagnostic != null) { + assertNonEmptyString(surface.observation.discoveryDiagnostic, `Invalid discovery diagnostic for ${label}`); + } + if (surface.observation.discoverySource === "diagnostic") { + assertNonEmptyString(surface.observation.discoveryDiagnostic, `Diagnostic discovery source requires a diagnostic for ${label}`); + } else { + assert( + surface.observation.discoveryDiagnostic == null, + `Only diagnostic discovery can declare a discovery diagnostic for ${label}`, + ); + } + if (surface.observation.evidenceRouteOption != null) { + assert( + typeof surface.observation.evidenceRouteOption === "string" + && /^[a-z][A-Za-z0-9]*$/u.test(surface.observation.evidenceRouteOption), + `Invalid observation evidence route for ${label}`, + ); + } + if (surface.distributionKind === "bundled") { + assert(surface.observation.kind === "bundled", `Bundled surface must use bundled observation for ${label}`); + } + if (surface.distributionKind === "source-local") { + assert(surface.observation.kind === "session-only", `Source-local surface must use session-only observation for ${label}`); + } + if (surface.scopeValues != null) { + assert(surface.scopeValues && typeof surface.scopeValues === "object" && !Array.isArray(surface.scopeValues), `Invalid scope values for ${label}`); + for (const [scope, value] of Object.entries(surface.scopeValues)) { + assert(surface.scopes.includes(scope), `Scope value is not declared by ${label}: ${scope}`); + assertNonEmptyString(value, `Invalid scope value for ${label}: ${scope}`); + } + } + if (surface.nativeHomeBinding != null) { + const binding = surface.nativeHomeBinding; + assert(binding && typeof binding === "object" && !Array.isArray(binding), `Invalid native home binding for ${label}`); + assert(NATIVE_HOME_BINDING_KINDS.includes(binding.kind), `Invalid native home binding kind for ${label}`); + assert( + typeof binding.variable === "string" && /^[A-Z_][A-Z0-9_]*$/u.test(binding.variable), + `Invalid native home environment variable for ${label}`, + ); + validateContractEvidence(binding.contractEvidence, `${label}/native-home-binding`); + } + assert(surface.lifecycle && typeof surface.lifecycle === "object", `Surface has no lifecycle: ${label}`); + assert( + JSON.stringify(Object.keys(surface.lifecycle).sort()) === JSON.stringify([...LIFECYCLE_OPERATION_NAMES].sort()), + `Surface lifecycle operations must be exact for ${label}`, + ); + for (const operationName of LIFECYCLE_OPERATION_NAMES) { + validateLifecycleOperation(surface.lifecycle[operationName], `${label}/${operationName}`); + } + return true; +} + +function validateIdentity(identity, hostId) { + assert(identity && typeof identity === "object", `Host has no Better Harness identity: ${hostId}`); + for (const key of ["names", "nativeIds", "repositories"]) { + assertStringArray(identity[key], `Host has invalid identity ${key}: ${hostId}`, { nonEmpty: true }); + } +} + +export function validateHostProfile(profile) { + assert(profile && typeof profile === "object", "Host profile must be an object."); + assert(profile.schemaVersion === HOST_SUPPORT_SCHEMA_VERSION, `Unsupported host profile schema: ${profile.hostId}`); + assertIdentifier(profile.hostId, "host id"); + assertNonEmptyString(profile.displayName, `Host has no display name: ${profile.hostId}`); + assert(typeof profile.managed === "boolean", `Host has invalid managed state: ${profile.hostId}`); + assertStringArray(profile.aliases, `Host has invalid aliases: ${profile.hostId}`); + assert(new Set(profile.aliases).size === profile.aliases.length, `Host repeats aliases: ${profile.hostId}`); + for (const alias of profile.aliases) { + assertIdentifier(alias, "host alias"); + assert(alias !== profile.hostId, `Host alias conflicts with host id: ${alias}`); + } + assertNonEmptyString(profile.provider, `Host has no inventory provider: ${profile.hostId}`); + assert( + typeof profile.inventoryHomeOption === "string" + && /^[a-z][A-Za-z0-9]*$/u.test(profile.inventoryHomeOption), + `Invalid inventory home option: ${profile.hostId}`, + ); + validateInventoryHomeRoutes(profile); + assertStringArray(profile.executables, `Host has invalid executables: ${profile.hostId}`); + if (profile.managed) assert(profile.executables.length > 0, `Managed host has no executable: ${profile.hostId}`); + validateIdentity(profile.identity, profile.hostId); + assert(Array.isArray(profile.surfaces) && profile.surfaces.length > 0, `Host has no surfaces: ${profile.hostId}`); + const surfaceIds = new Set(); + for (const surface of profile.surfaces) { + assert(!surfaceIds.has(surface.surfaceId), `Duplicate surface ${profile.hostId}/${surface.surfaceId}`); + surfaceIds.add(surface.surfaceId); + validateHostSurface(surface, profile.hostId); + if (surface.observation.evidenceRouteOption != null) { + assert( + profile.inventoryHomeRoutes.some((route) => route.option === surface.observation.evidenceRouteOption), + `Unknown observation evidence route for ${profile.hostId}/${surface.surfaceId}: ${surface.observation.evidenceRouteOption}`, + ); + } + } + return true; +} + +export function validateHostProfileRegistry(profiles) { + assert(Array.isArray(profiles) && profiles.length > 0, "Host profile registry must be a non-empty array."); + const ids = new Set(); + const aliases = new Set(); + for (const profile of profiles) { + validateHostProfile(profile); + assert(!ids.has(profile.hostId), `Duplicate host id: ${profile.hostId}`); + ids.add(profile.hostId); + for (const alias of profile.aliases) { + assert(!aliases.has(alias), `Duplicate host alias: ${alias}`); + aliases.add(alias); + } + } + for (const alias of aliases) assert(!ids.has(alias), `Host alias conflicts with host id: ${alias}`); + return true; +} diff --git a/scripts/host-support/profiles.mjs b/scripts/host-support/profiles.mjs new file mode 100644 index 0000000..9a70619 --- /dev/null +++ b/scripts/host-support/profiles.mjs @@ -0,0 +1,21 @@ +import claude from "./profiles/claude.mjs"; +import codex from "./profiles/codex.mjs"; +import qoder from "./profiles/qoder.mjs"; +import cursor from "./profiles/cursor.mjs"; +import qwen from "./profiles/qwen.mjs"; +import copilot from "./profiles/copilot.mjs"; +import pi from "./profiles/pi.mjs"; +import workbuddy from "./profiles/workbuddy.mjs"; + +export { HOST_SUPPORT_SCHEMA_VERSION } from "./profile-builders.mjs"; + +export const HOST_PROFILES = Object.freeze([ + claude, + codex, + qoder, + cursor, + qwen, + copilot, + pi, + workbuddy, +]); diff --git a/scripts/host-support/profiles/claude.mjs b/scripts/host-support/profiles/claude.mjs new file mode 100644 index 0000000..5b9c1e6 --- /dev/null +++ b/scripts/host-support/profiles/claude.mjs @@ -0,0 +1,63 @@ +import { + defineHostProfile, + defineSurface, + evidence, + hostCommand, + operation, + shell, +} from "../profile-builders.mjs"; + +export default defineHostProfile({ + hostId: "claude", + aliases: ["claude-code"], + displayName: "Claude Code", + provider: "claude", + inventoryHomeRoutes: [ + { option: "claudeHome", relativePath: "", fallbackLabel: "~/.claude" }, + { option: "claudeStatePath", relativePath: ".claude.json", fallbackLabel: "~/.claude.json" }, + ], + managed: true, + executables: ["claude"], + surfaces: [defineSurface({ + surfaceId: "cli", + displayName: "Claude Code CLI", + distributionKind: "marketplace", + scopes: ["user", "project", "local"], + defaultScope: "user", + nativeHomeBinding: { + kind: "environment", + variable: "CLAUDE_CONFIG_DIR", + contractEvidence: evidence("claude-config-dir-environment", "native-runtime-contract"), + }, + lifecycle: { + install: operation("supported", [ + hostCommand( + "/plugin marketplace add QoderAI/better-harness", + "The Better Harness marketplace is registered.", + ), + shell( + ["claude", "plugin", "install", "better-harness@better-harness", "--scope", "{scope}"], + "The plugin is installed at the selected scope.", + ), + ], evidence("claude-plugin-install")), + update: operation("supported", [ + shell( + ["claude", "plugin", "update", "better-harness@better-harness", "--scope", "{scope}"], + "The plugin is updated; a new session is required.", + ), + ], evidence("claude-plugin-update")), + remove: operation("supported", [ + shell( + ["claude", "plugin", "uninstall", "better-harness@better-harness", "--scope", "{scope}"], + "Only the selected Better Harness installation is removed.", + ), + ], evidence("claude-plugin-uninstall")), + verify: operation("supported", [ + shell( + ["claude", "plugin", "details", "better-harness@better-harness"], + "Plugin details include the better-harness Skill.", + ), + ], evidence("claude-plugin-details")), + }, + })], +}); diff --git a/scripts/host-support/profiles/codex.mjs b/scripts/host-support/profiles/codex.mjs new file mode 100644 index 0000000..41e5758 --- /dev/null +++ b/scripts/host-support/profiles/codex.mjs @@ -0,0 +1,108 @@ +import { + BETTER_HARNESS_REPOSITORY, + defineHostProfile, + defineSurface, + desktopUi, + evidence, + operation, + shell, +} from "../profile-builders.mjs"; + +export default defineHostProfile({ + hostId: "codex", + aliases: [], + displayName: "Codex", + provider: "codex", + inventoryHomeRoutes: [ + { option: "codexHome", relativePath: "", fallbackLabel: "~/.codex" }, + { + option: "codexAppPath", + relativePath: "Applications/Codex.app", + fallbackLabel: "/Codex.app", + }, + ], + managed: true, + executables: ["codex"], + surfaces: [ + defineSurface({ + surfaceId: "cli", + displayName: "Codex CLI", + distributionKind: "marketplace", + scopes: ["user"], + defaultScope: "user", + nativeHomeBinding: { + kind: "environment", + variable: "CODEX_HOME", + contractEvidence: evidence("codex-home-environment", "native-runtime-contract"), + }, + lifecycle: { + install: operation("supported", [ + shell( + ["codex", "plugin", "marketplace", "add", BETTER_HARNESS_REPOSITORY, "--ref", "main"], + "The Better Harness marketplace snapshot is configured.", + ), + shell( + ["codex", "plugin", "add", "better-harness@better-harness"], + "The plugin is installed from the configured marketplace.", + ), + ], evidence("codex-plugin-add")), + update: operation("supported", [ + shell( + ["codex", "plugin", "marketplace", "upgrade", "better-harness"], + "The configured Better Harness marketplace snapshot is refreshed.", + ), + ], evidence("codex-marketplace-upgrade")), + remove: operation("supported", [ + shell( + ["codex", "plugin", "remove", "better-harness@better-harness"], + "The plugin selection and cached install are removed; the marketplace remains configured.", + ), + ], evidence("codex-plugin-remove")), + verify: operation("supported", [ + shell( + ["codex", "plugin", "list", "--marketplace", "better-harness", "--json"], + "The marketplace inventory reports Better Harness.", + ), + ], evidence("codex-plugin-list")), + }, + }), + defineSurface({ + surfaceId: "desktop", + displayName: "Codex Desktop", + distributionKind: "marketplace", + scopes: ["user"], + defaultScope: "user", + observation: { + kind: "desktop-cache", + discoveryDiagnostic: "appBundleExists", + evidenceRouteOption: "codexAppPath", + }, + lifecycle: { + install: operation("manual", [ + desktopUi( + "Open Settings > Plugins, add the Better Harness Git marketplace at ref main, then install Better Harness.", + "Better Harness appears installed in Settings > Plugins.", + ), + ], evidence("codex-desktop-install", "curated-native-ui")), + update: operation("manual", [ + desktopUi( + "Use Settings > Plugins to refresh the marketplace and update Better Harness.", + "The installed plugin reports the intended version.", + ), + ], evidence("codex-desktop-update", "curated-native-ui")), + remove: operation("manual", [ + desktopUi( + "Use Settings > Plugins to remove Better Harness; retain reports and the marketplace unless explicitly desired.", + "Better Harness no longer appears installed.", + ), + ], evidence("codex-desktop-remove", "curated-native-ui")), + verify: operation("manual", [ + desktopUi( + "Confirm Better Harness is installed in Settings > Plugins, then start a new task.", + "The new task can discover the Better Harness Skill.", + ), + ], evidence("codex-desktop-verify", "curated-native-ui")), + }, + }), + ], +}); diff --git a/scripts/host-support/profiles/copilot.mjs b/scripts/host-support/profiles/copilot.mjs new file mode 100644 index 0000000..9bd75dd --- /dev/null +++ b/scripts/host-support/profiles/copilot.mjs @@ -0,0 +1,55 @@ +import { + defineHostProfile, + defineSurface, + evidence, + operation, + shell, +} from "../profile-builders.mjs"; + +export default defineHostProfile({ + hostId: "copilot", + aliases: ["github-copilot"], + displayName: "GitHub Copilot", + provider: "copilot", + inventoryHomeRoutes: [ + { option: "copilotHome", relativePath: "", fallbackLabel: "~/.copilot" }, + { option: "copilotUserHome", relativePath: "", fallbackLabel: "~" }, + ], + managed: true, + executables: ["copilot"], + surfaces: [defineSurface({ + surfaceId: "cli", + displayName: "GitHub Copilot CLI", + distributionKind: "marketplace", + scopes: ["user"], + defaultScope: "user", + lifecycle: { + install: operation("supported", [ + shell( + ["copilot", "plugin", "marketplace", "add", "QoderAI/better-harness"], + "The Better Harness marketplace is registered.", + ), + shell( + ["copilot", "plugin", "install", "better-harness@better-harness"], + "The Better Harness plugin is installed.", + ), + ], evidence("copilot-plugin-install")), + update: operation("supported", [ + shell( + ["copilot", "plugin", "update", "better-harness@better-harness"], + "The Better Harness plugin is updated.", + ), + ], evidence("copilot-plugin-update")), + remove: operation("supported", [ + shell( + ["copilot", "plugin", "uninstall", "better-harness@better-harness"], + "The Better Harness plugin is uninstalled.", + ), + ], evidence("copilot-plugin-uninstall")), + verify: operation("supported", [ + shell(["copilot", "plugin", "list"], "The plugin inventory reports Better Harness."), + shell(["copilot", "skill", "list", "--json"], "The Skill inventory reports better-harness."), + ], evidence("copilot-plugin-list")), + }, + })], +}); diff --git a/scripts/host-support/profiles/cursor.mjs b/scripts/host-support/profiles/cursor.mjs new file mode 100644 index 0000000..ba439d4 --- /dev/null +++ b/scripts/host-support/profiles/cursor.mjs @@ -0,0 +1,42 @@ +import { + defineHostProfile, + defineSurface, + evidence, + manual, + operation, +} from "../profile-builders.mjs"; + +export default defineHostProfile({ + hostId: "cursor", + aliases: ["cursor-agent"], + displayName: "Cursor", + provider: "cursor", + inventoryHomeRoutes: [ + { option: "cursorHome", relativePath: "", fallbackLabel: "~/.cursor" }, + { + option: "stateDbPath", + relativePath: "state.vscdb", + fallbackLabel: "/Cursor/User/globalStorage/state.vscdb", + }, + ], + managed: true, + executables: ["cursor-agent"], + surfaces: [defineSurface({ + surfaceId: "agent", + displayName: "Cursor Agent", + distributionKind: "source-local", + scopes: ["session"], + defaultScope: "session", + lifecycle: { + install: operation("unavailable", [], evidence("cursor-plugin-dir-help-drift", "contract-drift")), + update: operation("unavailable", [], evidence("cursor-plugin-dir-help-drift", "contract-drift")), + remove: operation("not-applicable"), + verify: operation("manual", [ + manual( + "Verify the source-local plugin in the same Cursor Agent session that loaded it.", + "The session discovers the better-harness Skill.", + ), + ], evidence("cursor-session-plugin", "curated-native-session")), + }, + })], +}); diff --git a/scripts/host-support/profiles/pi.mjs b/scripts/host-support/profiles/pi.mjs new file mode 100644 index 0000000..3569d3b --- /dev/null +++ b/scripts/host-support/profiles/pi.mjs @@ -0,0 +1,70 @@ +import { + defineHostProfile, + defineSurface, + evidence, + manual, + operation, +} from "../profile-builders.mjs"; + +export default defineHostProfile({ + hostId: "pi", + aliases: [], + displayName: "Pi", + provider: "pi", + inventoryHomeRoutes: [ + { option: "piHome", relativePath: "", fallbackLabel: "~/.pi/agent" }, + { option: "piUserHome", relativePath: "", fallbackLabel: "~" }, + ], + managed: true, + executables: ["pi"], + surfaces: [ + defineSurface({ + surfaceId: "cli", + displayName: "Pi CLI", + distributionKind: "package", + scopes: ["user", "project"], + defaultScope: "user", + observation: { kind: "inventory" }, + lifecycle: { + install: operation("manual", [ + manual( + "Use `pi install https://github.com/QoderAI/better-harness` for user scope, or add the package to the workspace `.pi/settings.json` package list for project scope.", + "Pi persists the package at the selected scope and discovers the better-harness Skill and prompt template.", + ), + ], evidence("pi-persistent-package-settings-source", "installed-package-source")), + update: operation("unavailable"), + remove: operation("unavailable"), + verify: operation("manual", [ + manual( + "Inspect the Pi user or project package settings and start a new session to confirm the better-harness Skill.", + "The package settings and enabled resources include Better Harness at the selected persistent scope.", + ), + ], evidence("pi-persistent-package-settings-source", "installed-package-source")), + }, + }), + defineSurface({ + surfaceId: "cli-session", + displayName: "Pi CLI session", + distributionKind: "package", + scopes: ["session"], + defaultScope: "session", + observation: { kind: "session-only" }, + lifecycle: { + install: operation("manual", [ + manual( + "Start Pi with `pi -e git:github.com/QoderAI/better-harness`.", + "That Pi session discovers the better-harness Skill and prompt template without persisting a package setting.", + ), + ], evidence("pi-temporary-extension-source", "installed-package-source")), + update: operation("not-applicable"), + remove: operation("not-applicable"), + verify: operation("manual", [ + manual( + "In the same Pi session started with `pi -e git:github.com/QoderAI/better-harness`, confirm that the better-harness Skill and prompt template are available.", + "The current Pi session exposes Better Harness; persistent package settings remain unchanged.", + ), + ], evidence("pi-temporary-extension-source", "installed-package-source")), + }, + }), + ], +}); diff --git a/scripts/host-support/profiles/qoder.mjs b/scripts/host-support/profiles/qoder.mjs new file mode 100644 index 0000000..b554bc1 --- /dev/null +++ b/scripts/host-support/profiles/qoder.mjs @@ -0,0 +1,79 @@ +import { + defineHostProfile, + defineSurface, + evidence, + manual, + operation, + shell, +} from "../profile-builders.mjs"; + +export default defineHostProfile({ + hostId: "qoder", + aliases: [], + displayName: "Qoder", + provider: "qoder", + inventoryHomeRoutes: [ + { option: "qoderHome", relativePath: "", fallbackLabel: "~/.qoder" }, + { + option: "qoderSharedClientCacheRoot", + relativePath: "SharedClientCache", + fallbackLabel: "/Qoder/SharedClientCache", + }, + ], + managed: true, + executables: ["qodercli"], + surfaces: [ + defineSurface({ + surfaceId: "desktop", + displayName: "Qoder Desktop", + distributionKind: "bundled", + scopes: ["bundled"], + defaultScope: "bundled", + observation: { kind: "bundled", discoverySource: "unobserved" }, + lifecycle: { + install: operation("not-applicable"), + update: operation("manual", [ + manual( + "Update Qoder Desktop through its supported application update route.", + "A new Qoder session exposes the bundled Better Harness version.", + ), + ], evidence("qoder-desktop-bundle", "curated-native-ui")), + remove: operation("not-applicable"), + verify: operation("manual", [ + manual( + "Start a new Qoder session or open Better Harness (Beta) in Quest on Qoder 1.18.0 or newer.", + "The bundled Better Harness entrypoint is visible.", + ), + ], evidence("qoder-desktop-bundle", "curated-native-ui")), + }, + }), + defineSurface({ + surfaceId: "cli", + displayName: "Qoder CLI", + distributionKind: "marketplace", + scopes: ["user", "project", "local"], + defaultScope: "user", + lifecycle: { + install: operation("manual", [ + manual( + "Use the current Qoder marketplace flow, then install better-harness@better-harness at the selected scope.", + "qodercli plugin list --json reports Better Harness.", + ), + ], evidence("qoder-marketplace-command-drift", "contract-drift")), + update: operation("unavailable"), + remove: operation("supported", [ + shell( + ["qodercli", "plugin", "uninstall", "better-harness", "--scope", "{scope}"], + "The selected Better Harness plugin installation is removed.", + ), + ], evidence("qoder-plugin-uninstall")), + verify: operation("supported", [ + shell( + ["qodercli", "plugin", "list", "--json"], + "The installed plugin inventory reports Better Harness.", + ), + ], evidence("qoder-plugin-list")), + }, + }), + ], +}); diff --git a/scripts/host-support/profiles/qwen.mjs b/scripts/host-support/profiles/qwen.mjs new file mode 100644 index 0000000..1787d2d --- /dev/null +++ b/scripts/host-support/profiles/qwen.mjs @@ -0,0 +1,50 @@ +import { + defineHostProfile, + defineSurface, + evidence, + operation, + shell, +} from "../profile-builders.mjs"; + +export default defineHostProfile({ + hostId: "qwen", + aliases: ["qwen-code"], + displayName: "Qwen Code", + provider: "qwen", + inventoryHomeRoutes: [ + { option: "qwenHome", relativePath: "", fallbackLabel: "~/.qwen" }, + ], + managed: true, + executables: ["qwen"], + surfaces: [defineSurface({ + surfaceId: "cli", + displayName: "Qwen Code CLI", + distributionKind: "extension", + scopes: ["user", "project"], + defaultScope: "user", + scopeValues: { user: "user", project: "project" }, + scopeArtifactPolicy: "shared", + scopeArtifactEvidence: evidence("qwen-shared-extension-artifact", "native-runtime-contract"), + nativeHomeBinding: { + kind: "environment", + variable: "QWEN_HOME", + contractEvidence: evidence("qwen-home-environment", "native-runtime-contract"), + }, + lifecycle: { + install: operation("supported", [ + shell( + ["qwen", "extensions", "install", "QoderAI/better-harness", "--scope", "{scopeValue}"], + "The Better Harness extension is installed at the selected scope.", + ), + ], evidence("qwen-extensions-install")), + update: operation("unavailable", [], evidence("qwen-extensions-update")), + remove: operation("unavailable", [], evidence("qwen-extensions-uninstall")), + verify: operation("supported", [ + shell( + ["qwen", "extensions", "list"], + "The extension inventory reports Better Harness.", + ), + ], evidence("qwen-extensions-list")), + }, + })], +}); diff --git a/scripts/host-support/profiles/workbuddy.mjs b/scripts/host-support/profiles/workbuddy.mjs new file mode 100644 index 0000000..355dcb9 --- /dev/null +++ b/scripts/host-support/profiles/workbuddy.mjs @@ -0,0 +1,32 @@ +import { + defineHostProfile, + defineSurface, + operation, +} from "../profile-builders.mjs"; + +export default defineHostProfile({ + hostId: "workbuddy", + aliases: [], + displayName: "WorkBuddy", + provider: "workbuddy", + inventoryHomeRoutes: [ + { option: "workbuddyHome", relativePath: "", fallbackLabel: "~/.workbuddy" }, + { option: "workbuddyUserHome", relativePath: "", fallbackLabel: "~" }, + ], + managed: false, + executables: [], + surfaces: [defineSurface({ + surfaceId: "skills", + displayName: "WorkBuddy Skills", + distributionKind: "source-local", + scopes: ["user"], + defaultScope: "user", + docsPath: "docs/docs/hosts/adapter-matrix.md", + lifecycle: { + install: operation("unavailable"), + update: operation("unavailable"), + remove: operation("unavailable"), + verify: operation("unavailable"), + }, + })], +}); diff --git a/scripts/plugin-lifecycle/cli.mjs b/scripts/plugin-lifecycle/cli.mjs new file mode 100644 index 0000000..ff1408c --- /dev/null +++ b/scripts/plugin-lifecycle/cli.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +import { + LifecycleCliError, + normalizeReadOnlyTimeout, + parseReadOnlyOptions, + runReadOnlyCommand, + validateWorkspace, +} from "./index.mjs"; +import { + PLUGIN_COMMAND_DEFINITIONS, + PLUGIN_VALUE_OPTIONS, + pluginCommandNames, + requirePluginCommandDefinition, +} from "./command-definitions.mjs"; + +function usage(definition) { + return [ + `Better Harness plugin ${definition.name}`, + "", + "Usage:", + ` ${definition.usageLine}`, + "", + "Options:", + " --host Host id or alias", + " --surface Host surface; requires one explicit host", + ...(definition.allowedOptions.includes("scope") + ? [" --scope user, project, local, session, or bundled"] + : []), + " --workspace Workspace used for project-scope inventory", + " --host-home Isolated provider home; requires one explicit host", + " --timeout Bounded operation timeout metadata (default: 5000)", + " --json Emit one command-contract.v1 JSON document", + " --no-color Disable terminal color", + " -h, --help Print help without probing hosts", + "", + "This command is read-only. It never executes lifecycle plan steps.", + "", + ].join("\n"); +} + +function applyPositionalContract(definition, positionals, options) { + if (definition.positionals.kind === "enum") { + if (positionals.length !== 1 || !definition.positionals.values.includes(positionals[0])) { + throw new LifecycleCliError( + definition.positionals.error.code, + definition.positionals.error.message, + { kind: "usage" }, + ); + } + options[definition.positionals.key] = positionals[0]; + return; + } + + if (positionals.length > 0) { + throw new LifecycleCliError( + "UNEXPECTED_POSITIONAL", + `Unexpected argument: ${positionals[0]}`, + { kind: "usage" }, + ); + } +} + +function validateOptionOwnership(definition, options) { + for (const [option, key] of Object.entries(PLUGIN_VALUE_OPTIONS)) { + if (options[key] == null || definition.allowedOptions.includes(key)) continue; + const owners = PLUGIN_COMMAND_DEFINITIONS + .filter((entry) => entry.allowedOptions.includes(key)) + .map((entry) => entry.name); + const ownerLabel = owners.length === 1 + ? `plugin ${owners[0]}` + : `plugin ${owners.join(" or plugin ")}`; + throw new LifecycleCliError( + "OPTION_NOT_ALLOWED", + `${option} is supported only by ${ownerLabel}.`, + { kind: "usage" }, + ); + } +} + +function parsePluginDefinitionArgs(definition, argv) { + const parsed = parseReadOnlyOptions(argv, { + valueOptions: PLUGIN_VALUE_OPTIONS, + }); + const options = normalizeReadOnlyTimeout(parsed.options); + if (options.help) return options; + applyPositionalContract(definition, parsed.positionals, options); + validateOptionOwnership(definition, options); + return options; +} + +export function parsePluginCliArgs(command, argv) { + return parsePluginDefinitionArgs(requirePluginCommandDefinition(command), argv); +} + +function commandName(command) { + return `better-harness plugin ${command}`; +} + +export async function runPluginCommand(command, argv = process.argv.slice(2)) { + const definition = requirePluginCommandDefinition(command); + return runReadOnlyCommand({ + argv, + command: commandName(definition.name), + parse: (args) => parsePluginDefinitionArgs(definition, args), + usage: () => usage(definition), + prepare: async (options) => ({ + ...options, + workspace: await validateWorkspace(options.workspace), + }), + execute: definition.execute, + renderHuman: (result) => definition.renderHuman(result, definition), + }); +} + +export async function main(argv = process.argv.slice(2)) { + const [command, ...rest] = argv; + if (!command || command === "--help" || command === "-h" || command === "help") { + process.stdout.write([ + "Better Harness plugin lifecycle", + "", + "Usage:", + ` better-harness plugin <${pluginCommandNames().join("|")}> [options]`, + "", + "This command is read-only and does not provide apply.", + "", + ].join("\n")); + return 0; + } + return runPluginCommand(command, rest); +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && path.resolve(process.argv[1]) === currentFile) { + process.exitCode = await main(); +} diff --git a/scripts/plugin-lifecycle/command-definitions.mjs b/scripts/plugin-lifecycle/command-definitions.mjs new file mode 100644 index 0000000..8e40a6a --- /dev/null +++ b/scripts/plugin-lifecycle/command-definitions.mjs @@ -0,0 +1,56 @@ +import { LifecycleCliError } from "./contract.mjs"; +import { + PLUGIN_COMMAND_MANIFEST, + PLUGIN_VALUE_OPTIONS, + pluginCommandNames, +} from "./command-manifest.mjs"; +import { renderPlan, renderStatus } from "./human-output.mjs"; +import { buildPluginLifecyclePlan } from "./plan-core.mjs"; +import { PLUGIN_LIFECYCLE_ACTIONS } from "./plan-model.mjs"; +import { inspectPluginLifecycle, verifyPluginLifecycle } from "./status-core.mjs"; + +const COMMAND_RUNTIME = Object.freeze({ + status: Object.freeze({ + execute: inspectPluginLifecycle, + renderHuman: renderStatus, + }), + plan: Object.freeze({ + execute: buildPluginLifecyclePlan, + renderHuman: renderPlan, + }), + verify: Object.freeze({ + execute: verifyPluginLifecycle, + renderHuman: renderStatus, + }), +}); + +const runtimeNames = Object.keys(COMMAND_RUNTIME).sort(); +const manifestNames = pluginCommandNames().sort(); +if (JSON.stringify(runtimeNames) !== JSON.stringify(manifestNames)) { + throw new TypeError("Plugin command manifest and runtime bindings must have exact parity."); +} +const planActions = PLUGIN_COMMAND_MANIFEST.find((entry) => entry.name === "plan")?.positionals.values; +if (JSON.stringify(planActions) !== JSON.stringify(PLUGIN_LIFECYCLE_ACTIONS)) { + throw new TypeError("Plugin plan manifest and lifecycle action model must have exact parity."); +} + +export const PLUGIN_COMMAND_DEFINITIONS = Object.freeze( + PLUGIN_COMMAND_MANIFEST.map((entry) => Object.freeze({ + ...entry, + ...COMMAND_RUNTIME[entry.name], + })), +); + +export { PLUGIN_VALUE_OPTIONS, pluginCommandNames } from "./command-manifest.mjs"; + +export function requirePluginCommandDefinition(command) { + const definition = PLUGIN_COMMAND_DEFINITIONS.find((entry) => entry.name === command); + if (!definition) { + throw new LifecycleCliError( + "UNKNOWN_PLUGIN_COMMAND", + `Unknown plugin command: ${command}`, + { kind: "usage" }, + ); + } + return definition; +} diff --git a/scripts/plugin-lifecycle/command-manifest.mjs b/scripts/plugin-lifecycle/command-manifest.mjs new file mode 100644 index 0000000..98f6414 --- /dev/null +++ b/scripts/plugin-lifecycle/command-manifest.mjs @@ -0,0 +1,103 @@ +export const PLUGIN_VALUE_OPTIONS = Object.freeze({ + "--host": "host", + "--surface": "surface", + "--scope": "scope", +}); + +const OPTION_KEYS = new Set(Object.values(PLUGIN_VALUE_OPTIONS)); + +function defineManifestEntry(entry) { + if (!/^[a-z][a-z0-9-]*$/u.test(entry.name)) { + throw new TypeError(`Invalid plugin command name: ${entry.name}`); + } + if (!/^plugin-lifecycle\/[a-z][a-z0-9-]*\.mjs$/u.test(entry.entryScript)) { + throw new TypeError(`Invalid plugin command entry script: ${entry.name}`); + } + if (entry.audience !== "advanced" || typeof entry.summary !== "string" || entry.summary.length === 0) { + throw new TypeError(`Plugin command requires root registry metadata: ${entry.name}`); + } + if (typeof entry.usageLine !== "string" || !entry.usageLine.startsWith(`better-harness plugin ${entry.name}`)) { + throw new TypeError(`Plugin command has an invalid usage synopsis: ${entry.name}`); + } + if (!Array.isArray(entry.allowedOptions) || entry.allowedOptions.some((key) => !OPTION_KEYS.has(key))) { + throw new TypeError(`Plugin command has invalid option ownership: ${entry.name}`); + } + if (new Set(entry.allowedOptions).size !== entry.allowedOptions.length) { + throw new TypeError(`Plugin command repeats an option owner: ${entry.name}`); + } + if (!["none", "enum"].includes(entry.positionals?.kind)) { + throw new TypeError(`Plugin command has an invalid positional contract: ${entry.name}`); + } + if (entry.positionals.kind === "enum" && ( + !entry.positionals.key + || !Array.isArray(entry.positionals.values) + || entry.positionals.values.length === 0 + || !entry.positionals.error?.code + || !entry.positionals.error?.message + )) { + throw new TypeError(`Plugin command has an incomplete enum contract: ${entry.name}`); + } + return Object.freeze({ + ...entry, + allowedOptions: Object.freeze([...entry.allowedOptions]), + positionals: Object.freeze({ + ...entry.positionals, + ...(entry.positionals.values + ? { values: Object.freeze([...entry.positionals.values]) } + : {}), + ...(entry.positionals.error + ? { error: Object.freeze({ ...entry.positionals.error }) } + : {}), + }), + }); +} + +function defineManifest(entries) { + const names = entries.map((entry) => entry.name); + if (new Set(names).size !== names.length) { + throw new TypeError("Plugin command manifest requires unique names."); + } + return Object.freeze(entries.map(defineManifestEntry)); +} + +export const PLUGIN_COMMAND_MANIFEST = defineManifest([ + { + name: "status", + entryScript: "plugin-lifecycle/status.mjs", + audience: "advanced", + summary: "Inspect Better Harness installation and verification state across hosts.", + usageLine: "better-harness plugin status [--host ] [options]", + allowedOptions: ["host", "surface"], + positionals: { kind: "none" }, + }, + { + name: "plan", + entryScript: "plugin-lifecycle/plan.mjs", + audience: "advanced", + summary: "Build a deterministic install, update, or remove plan without executing it.", + usageLine: "better-harness plugin plan --host [options]", + allowedOptions: ["host", "surface", "scope"], + positionals: { + kind: "enum", + key: "action", + values: ["install", "update", "remove"], + error: { + code: "INVALID_PLAN_ACTION", + message: "Plugin plan requires exactly one action: install, update, or remove.", + }, + }, + }, + { + name: "verify", + entryScript: "plugin-lifecycle/verify.mjs", + audience: "advanced", + summary: "Verify local Better Harness install, manifest, Skill, and enablement evidence.", + usageLine: "better-harness plugin verify [--host ] [options]", + allowedOptions: ["host", "surface"], + positionals: { kind: "none" }, + }, +]); + +export function pluginCommandNames() { + return PLUGIN_COMMAND_MANIFEST.map((entry) => entry.name); +} diff --git a/scripts/plugin-lifecycle/contract.mjs b/scripts/plugin-lifecycle/contract.mjs new file mode 100644 index 0000000..28c5eda --- /dev/null +++ b/scripts/plugin-lifecycle/contract.mjs @@ -0,0 +1,90 @@ +import os from "node:os"; + +export const COMMAND_CONTRACT_VERSION = "1"; + +export class LifecycleCliError extends Error { + constructor(code, message, { hint, kind = "operational", retryable = false } = {}) { + super(message); + this.name = "LifecycleCliError"; + this.code = code; + this.hint = hint; + this.kind = kind; + this.retryable = retryable; + } +} + +export function diagnostic(code, severity, message, hint) { + return { + code, + severity, + message, + ...(hint ? { hint } : {}), + }; +} + +function redactDiagnosticText(value) { + const home = os.homedir(); + if (!home || home === "/") return String(value); + return String(value).replaceAll(home, "~"); +} + +export async function withTimeout(task, timeoutMs) { + let timer; + try { + return await Promise.race([ + Promise.resolve().then(task), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new LifecycleCliError( + "COMMAND_TIMEOUT", + `Read-only inspection exceeded the ${timeoutMs} millisecond timeout.`, + { retryable: true }, + )), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +export function commandEnvelope({ command, status, data = null, diagnostics = [], durationMs = 0 }) { + return { + schemaVersion: COMMAND_CONTRACT_VERSION, + command, + status, + data, + artifacts: [], + diagnostics, + meta: { + durationMs, + sideEffects: "read-only", + }, + }; +} + +export function exitCodeFor(status, { usage = false } = {}) { + if (usage) return 64; + if (status === "ok") return 0; + if (status === "partial") return 2; + return 1; +} + +export function cliErrorEnvelope(command, error, durationMs = 0) { + const usage = error instanceof LifecycleCliError && error.kind === "usage"; + const code = error instanceof LifecycleCliError ? error.code : "PLUGIN_LIFECYCLE_FAILED"; + const message = error instanceof LifecycleCliError + ? redactDiagnosticText(error.message) + : "The read-only lifecycle operation failed."; + const hint = error instanceof LifecycleCliError && error.hint + ? redactDiagnosticText(error.hint) + : undefined; + return { + envelope: commandEnvelope({ + command, + status: "failed", + data: null, + diagnostics: [diagnostic(code, "error", message, hint)], + durationMs, + }), + exitCode: exitCodeFor("failed", { usage }), + }; +} diff --git a/scripts/plugin-lifecycle/human-output.mjs b/scripts/plugin-lifecycle/human-output.mjs new file mode 100644 index 0000000..3af5269 --- /dev/null +++ b/scripts/plugin-lifecycle/human-output.mjs @@ -0,0 +1,124 @@ +function renderJsonData(value) { + return JSON.stringify(value) + .replaceAll("$", "\\u0024") + .replaceAll("`", "\\u0060") + .replaceAll("%", "\\u0025") + .replaceAll("!", "\\u0021"); +} + +function renderArg(value) { + return /^[A-Za-z0-9_./:@=+-]+$/u.test(value) + ? value + : renderJsonData(value); +} + +function renderCell(value) { + const encoded = renderJsonData(String(value)); + return encoded.slice(1, -1); +} + +function rowLabel(row) { + return `${row.target.hostId}/${row.target.surfaceId}@${row.target.scope}`; +} + +function renderRows(rows) { + const header = ["TARGET", "HOST", "INSTALL", "ENABLE", "VERSION", "VERIFY"]; + const values = rows.map((row) => [ + rowLabel(row), + row.hostDiscovery, + row.installation, + row.enablement, + row.version.relation === "unobserved" + ? "unobserved" + : `${renderCell(row.version.observed)} (${row.version.relation})`, + row.verification, + ]); + const widths = header.map((value, index) => Math.max(value.length, ...values.map((row) => row[index].length))); + return [header, ...values] + .map((row) => row.map((value, index) => value.padEnd(widths[index])).join(" ").trimEnd()) + .join("\n"); +} + +export function renderStatus(result, definition) { + const lines = [ + `Better Harness plugin ${definition.name}`, + "", + renderRows(result.rows), + ]; + const warnings = result.diagnostics.filter((item) => item.severity !== "info"); + if (warnings.length > 0) { + lines.push("", "Diagnostics:", ...warnings.map((item) => ` ${item.code}: ${item.message}`)); + } + return `${lines.join("\n")}\n`; +} + +function renderStepInstruction(step) { + return step.argv + ? `argv (JSON, not shell): ${renderJsonData(step.argv)}` + : step.command ?? step.instruction; +} + +function verificationArgv(target) { + const argv = [ + "better-harness", + "plugin", + "verify", + "--host", + target.hostId, + "--surface", + target.surfaceId, + ]; + if (target.workspace) argv.push("--workspace", target.workspace); + if (target.hostHome) argv.push("--host-home", target.hostHome); + return argv; +} + +function renderHomeBinding(binding) { + return renderJsonData({ + variable: binding.variable, + value: binding.value, + }); +} + +export function renderPlan(plan) { + const lines = [ + `Better Harness plugin ${plan.action} plan`, + "", + `Plan: ${plan.planId}`, + `Target: ${plan.target.hostId}/${plan.target.surfaceId}@${plan.target.scope}`, + `State: ${plan.state}`, + `Effects performed now: ${plan.effects}`, + ]; + if (plan.target.workspace) lines.push(`Workspace: ${renderArg(plan.target.workspace)}`); + if (plan.target.hostHome) lines.push(`Host home: ${renderArg(plan.target.hostHome)}`); + if (plan.steps.length > 0) { + lines.push("", "Planned external steps:"); + for (const step of plan.steps) { + lines.push( + ` ${step.id}. [${step.kind}] ${renderStepInstruction(step)}`, + ` Expect: ${step.expected}`, + ); + if (step.cwd) lines.push(` Working directory: ${renderArg(step.cwd)}`); + if (step.homeBinding) { + lines.push(` Host home binding (data): ${renderHomeBinding(step.homeBinding)}`); + lines.push(` Binding evidence: ${step.homeBinding.contractEvidence.id} (${step.homeBinding.contractEvidence.observedAt})`); + } + } + } + if (plan.verificationSteps.length > 0) { + lines.push("", "Verify after applying externally:"); + for (const step of plan.verificationSteps) { + lines.push(` - [${step.kind}] ${renderStepInstruction(step)}`); + if (step.cwd) lines.push(` Working directory: ${renderArg(step.cwd)}`); + if (step.homeBinding) { + lines.push(` Host home binding (data): ${renderHomeBinding(step.homeBinding)}`); + lines.push(` Binding evidence: ${step.homeBinding.contractEvidence.id} (${step.homeBinding.contractEvidence.observedAt})`); + } + } + lines.push(` - Better Harness verify argv (JSON, not shell): ${renderJsonData(verificationArgv(plan.target))}`); + } + if (plan.notes.length > 0) lines.push("", "Notes:", ...plan.notes.map((note) => ` - ${note}`)); + if (plan.blockers.length > 0) lines.push("", "Blockers:", ...plan.blockers.map((item) => ` - ${item.code}: ${item.message}`)); + lines.push("", "No commands were executed and no files were changed.", ""); + return lines.join("\n"); +} diff --git a/scripts/plugin-lifecycle/identity.mjs b/scripts/plugin-lifecycle/identity.mjs new file mode 100644 index 0000000..3ce358b --- /dev/null +++ b/scripts/plugin-lifecycle/identity.mjs @@ -0,0 +1,62 @@ +import { createHash } from "node:crypto"; + +export function normalizeIdentity(value) { + return String(value ?? "") + .trim() + .replace(/\\/gu, "/") + .replace(/^git\+/iu, "") + .replace(/^git@github\.com:/iu, "github.com/") + .replace(/^ssh:\/\/git@github\.com\//iu, "github.com/") + .split(/[?#]/u, 1)[0] + .replace(/\.git$/iu, "") + .replace(/^https?:\/\//iu, "") + .replace(/\/$/u, "") + .toLowerCase(); +} + +function pluginIdentityValues(plugin) { + return [ + plugin.id, + plugin.name, + plugin.claudePluginId, + plugin.codexPluginId, + plugin.qoderPluginId, + plugin.cursorPluginId, + plugin.remotePluginId, + plugin.copilotPluginName, + plugin.piPackageSource, + ].filter((value) => typeof value === "string" && value.trim()); +} + +export function matchesBetterHarnessPlugin(plugin, profile) { + if (!plugin || !profile) return false; + const names = new Set(profile.identity.names.map(normalizeIdentity)); + const nativeIds = new Set(profile.identity.nativeIds.map(normalizeIdentity)); + const repositories = new Set(profile.identity.repositories.map(normalizeIdentity)); + if (names.has(normalizeIdentity(plugin.name))) return true; + for (const value of pluginIdentityValues(plugin)) { + const normalized = normalizeIdentity(value); + if (nativeIds.has(normalized) || repositories.has(normalized)) return true; + } + return false; +} + +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value).sort().map((key) => [key, stableValue(value[key])]), + ); + } + return value; +} + +export function stableSerialize(value) { + return JSON.stringify(stableValue(value)); +} + +export function stableDigest(value) { + return createHash("sha256") + .update(stableSerialize(value)) + .digest("hex"); +} diff --git a/scripts/plugin-lifecycle/index.mjs b/scripts/plugin-lifecycle/index.mjs new file mode 100644 index 0000000..6d9902f --- /dev/null +++ b/scripts/plugin-lifecycle/index.mjs @@ -0,0 +1,23 @@ +export { matchesBetterHarnessPlugin, stableDigest } from "./identity.mjs"; +export { PLUGIN_ID, PLUGIN_LIFECYCLE_SCHEMA_VERSION } from "./model.mjs"; +export { buildPluginLifecyclePlan } from "./plan-core.mjs"; +export { + authorizedRootLabel, + discoverHostExecutable, + pluginLifecycleRuntimeInfo, + validateWorkspace, +} from "./runtime.mjs"; +export { inspectPluginLifecycle, verifyPluginLifecycle } from "./status-core.mjs"; +export { + commandEnvelope, + cliErrorEnvelope, + diagnostic, + exitCodeFor, + LifecycleCliError, + withTimeout, +} from "./contract.mjs"; +export { + normalizeReadOnlyTimeout, + parseReadOnlyOptions, + runReadOnlyCommand, +} from "./read-only-command.mjs"; diff --git a/scripts/plugin-lifecycle/model.mjs b/scripts/plugin-lifecycle/model.mjs new file mode 100644 index 0000000..1aac60e --- /dev/null +++ b/scripts/plugin-lifecycle/model.mjs @@ -0,0 +1,59 @@ +export const PLUGIN_LIFECYCLE_SCHEMA_VERSION = "1"; +export const PLUGIN_ID = "better-harness"; + +const DIAGNOSTIC_SEVERITIES = Object.freeze(["info", "warning", "error"]); + +export function assertLifecycle(condition, message) { + if (!condition) throw new TypeError(message); +} + +export function assertLifecycleString(value, message) { + assertLifecycle(typeof value === "string" && value.length > 0, message); +} + +export function assertLifecycleControlled(values, value, label, family) { + assertLifecycle(values.includes(value), `Invalid ${label} ${family}: ${value}`); +} + +export function validateLifecyclePlugin(plugin, { + label, + versionField, + versionLabel, +}) { + assertLifecycle(plugin && typeof plugin === "object", `${label} plugin must be an object.`); + assertLifecycle(plugin.id === PLUGIN_ID, `Invalid ${label} plugin identity.`); + assertLifecycleString(plugin[versionField], `Missing ${label} ${versionLabel}.`); + return true; +} + +export function validateLifecycleTarget(target, { + label, + expectedSource = false, +} = {}) { + assertLifecycle(target && typeof target === "object", `${label} target must be an object.`); + assertLifecycleString(target.hostId, `Missing ${label} host id.`); + assertLifecycleString(target.surfaceId, `Missing ${label} surface id.`); + assertLifecycleString(target.scope, `Missing ${label} scope.`); + assertLifecycleString(target.distributionKind, `Missing ${label} distribution kind.`); + if (expectedSource) { + assertLifecycleString(target.expectedSource?.pluginId, `Missing ${label} native plugin id.`); + assertLifecycleString(target.expectedSource.repository, `Missing ${label} repository identity.`); + } + return true; +} + +export function validateLifecycleDiagnostics(items, label) { + assertLifecycle(Array.isArray(items), `${label} diagnostics must be an array.`); + for (const item of items) { + assertLifecycle(item && typeof item === "object", `${label} diagnostic must be an object.`); + assertLifecycleString(item.code, `${label} diagnostic code is missing.`); + assertLifecycleControlled( + DIAGNOSTIC_SEVERITIES, + item.severity, + label, + "diagnostic severity", + ); + assertLifecycleString(item.message, `${label} diagnostic message is missing.`); + } + return true; +} diff --git a/scripts/plugin-lifecycle/observation.mjs b/scripts/plugin-lifecycle/observation.mjs new file mode 100644 index 0000000..fb62ddc --- /dev/null +++ b/scripts/plugin-lifecycle/observation.mjs @@ -0,0 +1,70 @@ +function observationKind(surface) { + return surface.observation.kind; +} + +export function observedScopes(plugin, surface) { + if (observationKind(surface) !== "inventory") return [surface.defaultScope]; + const values = Array.isArray(plugin?.installSources) + ? plugin.installSources + : [plugin?.installSource]; + const normalized = values + .map((value) => String(value ?? "").trim().toLowerCase()) + .filter(Boolean); + if (normalized.length === 0 || normalized.some((value) => !surface.scopes.includes(value))) { + return []; + } + return [...new Set(normalized)]; +} + +export function observedInstallation(surface, plugin) { + switch (observationKind(surface)) { + case "bundled": return "bundled"; + case "session-only": return "session-only"; + case "desktop-cache": return "unknown"; + default: return plugin ? "installed" : "not-installed"; + } +} + +export function observedEnablement(surface, plugin) { + switch (observationKind(surface)) { + case "bundled": + case "session-only": + return "not-applicable"; + case "desktop-cache": + return "unknown"; + default: + if (!plugin || plugin.enabled == null) return "unknown"; + return plugin.enabled ? "enabled" : "disabled"; + } +} + +export function observedHostDiscovery(surface, inventory, executableDiscovery) { + switch (surface.observation.discoverySource) { + case "unobserved": return "unobserved"; + case "diagnostic": { + const observed = inventory.diagnostics?.[surface.observation.discoveryDiagnostic]; + return observed === true ? "present" : (observed === false ? "absent" : "unobserved"); + } + default: return executableDiscovery; + } +} + +export function inventoryFailureInstallation(surface) { + return observationKind(surface) === "bundled" ? "bundled" : "unknown"; +} + +export function usesSingleInventoryCandidate(surface) { + return observationKind(surface) !== "inventory"; +} + +export function requiresInventoryCollection(surface) { + return !["bundled", "session-only"].includes(observationKind(surface)); +} + +export function singleInventoryCandidate() { + return undefined; +} + +export function hasUnobservedDesktopEnablement(surface) { + return observationKind(surface) === "desktop-cache"; +} diff --git a/scripts/plugin-lifecycle/plan-core.mjs b/scripts/plugin-lifecycle/plan-core.mjs new file mode 100644 index 0000000..f275c5a --- /dev/null +++ b/scripts/plugin-lifecycle/plan-core.mjs @@ -0,0 +1,27 @@ +import { + createPluginLifecyclePlan, + requirePluginLifecycleAction, +} from "./plan-model.mjs"; +import { inspectPluginLifecycle } from "./status-core.mjs"; +import { resolvePlanTarget } from "./target-resolution.mjs"; +import { normalizeLifecyclePaths } from "./runtime.mjs"; + +export async function buildPluginLifecyclePlan(options = {}) { + const normalizedOptions = normalizeLifecyclePaths(options); + const action = requirePluginLifecycleAction(normalizedOptions.action); + const { profile, surface, scope } = resolvePlanTarget(normalizedOptions); + const observed = await inspectPluginLifecycle({ + ...normalizedOptions, + host: profile.hostId, + surface: surface.surfaceId, + }); + return createPluginLifecyclePlan({ + action, + profile, + surface, + scope, + workspace: normalizedOptions.workspace, + hostHome: normalizedOptions.hostHome, + observed, + }); +} diff --git a/scripts/plugin-lifecycle/plan-model.mjs b/scripts/plugin-lifecycle/plan-model.mjs new file mode 100644 index 0000000..d8733bd --- /dev/null +++ b/scripts/plugin-lifecycle/plan-model.mjs @@ -0,0 +1,466 @@ +import path from "node:path"; + +import { LifecycleCliError, diagnostic } from "./contract.mjs"; +import { stableDigest } from "./identity.mjs"; +import { + assertLifecycle, + assertLifecycleControlled, + assertLifecycleString, + PLUGIN_LIFECYCLE_SCHEMA_VERSION, + validateLifecycleDiagnostics, + validateLifecyclePlugin, + validateLifecycleTarget, +} from "./model.mjs"; +import { validatePluginLifecycleStatusRow } from "./status-row.mjs"; + +export const PLUGIN_LIFECYCLE_ACTIONS = Object.freeze(["install", "update", "remove"]); + +const PLAN_STATES = Object.freeze(["ready", "noop", "manual", "blocked"]); +const PLAN_STATUSES = Object.freeze(["ok", "partial", "failed"]); +const STEP_KINDS = Object.freeze(["shell", "manual", "host-command", "desktop-ui"]); +const RETENTION_BOUNDARIES = Object.freeze([ + "configured marketplace", + "Better Harness reports", + "host caches not owned by plugin removal", + "Better Harness CLI package", + "source checkout", +]); +const RECOVERY_STEPS = Object.freeze([ + "Stop before any remaining external step if the observed preconditions change.", + "Run the read-only verify command to capture the resulting local state.", + "Use only the host's native repair or reinstall route; Better Harness does not roll back host state.", +]); + +function assertControlled(values, value, family) { + assertLifecycleControlled(values, value, "PluginLifecyclePlanV1", family); +} + +function stepRepresentation(step) { + return [step.argv, step.command, step.instruction].filter((value) => value != null); +} + +function validatePlanStep(step, index, phase, target) { + const label = `PluginLifecyclePlanV1 ${phase} step ${index + 1}`; + assertLifecycle(step && typeof step === "object", `${label} must be an object.`); + assertLifecycle(step.id === `step-${index + 1}`, `${label} has an invalid id.`); + assertControlled(STEP_KINDS, step.kind, "step kind"); + assertLifecycle(stepRepresentation(step).length === 1, `${label} must have exactly one typed instruction.`); + if (step.kind === "shell") { + assertLifecycle(Array.isArray(step.argv) && step.argv.length > 0, `${label} requires argv.`); + assertLifecycle(step.argv.every((value) => typeof value === "string"), `${label} argv must contain only strings.`); + } else if (step.kind === "host-command") { + assertLifecycleString(step.command, `${label} requires a host command.`); + } else { + assertLifecycleString(step.instruction, `${label} requires an instruction.`); + } + assertLifecycleString(step.expected, `${label} expected result is missing.`); + const expectedEffects = phase === "mutation" ? "external" : "read-only"; + const expectedImpact = phase === "mutation" ? "host-plugin-state" : "host-observation"; + assertLifecycle(step.effects === expectedEffects, `${label} has invalid effects.`); + assertLifecycle(step.externalImpact === expectedImpact, `${label} has invalid external impact.`); + assertLifecycle(step.ownership === "host-native", `${label} must remain host-native.`); + assertLifecycle(Array.isArray(step.preconditions) && step.preconditions.length > 0, `${label} requires preconditions.`); + assertLifecycle(step.preconditions.every((value) => typeof value === "string" && value.length > 0), `${label} preconditions must be strings.`); + if (step.homeBinding != null) { + assertLifecycle(target.hostHome != null, `${label} cannot bind an unspecified host home.`); + assertLifecycle( + step.homeBinding && typeof step.homeBinding === "object" && !Array.isArray(step.homeBinding), + `${label} has an invalid host home binding.`, + ); + assertLifecycle(step.homeBinding.kind === "environment", `${label} has an invalid host home binding kind.`); + assertLifecycle( + typeof step.homeBinding.variable === "string" && /^[A-Z_][A-Z0-9_]*$/u.test(step.homeBinding.variable), + `${label} has an invalid host home environment variable.`, + ); + assertLifecycle(step.homeBinding.value === target.hostHome, `${label} host home binding differs from its target.`); + assertLifecycle(step.kind === "shell", `${label} cannot bind host home for ${step.kind}.`); + const evidence = step.homeBinding.contractEvidence; + assertLifecycle(evidence && typeof evidence === "object", `${label} host home binding lacks contract evidence.`); + assertLifecycleString(evidence.id, `${label} host home binding evidence id is missing.`); + assertLifecycleString(evidence.kind, `${label} host home binding evidence kind is missing.`); + assertLifecycle(/^\d{4}-\d{2}-\d{2}$/u.test(evidence.observedAt), `${label} host home binding evidence date is invalid.`); + assertLifecycleString(evidence.fixture, `${label} host home binding evidence fixture is missing.`); + } else if (target.hostHome != null) { + assertLifecycle(false, `${label} must bind the isolated host home.`); + } + if (["project", "local"].includes(target.scope)) { + assertLifecycle(step.cwd === target.workspace, `${label} must use the target workspace.`); + } else { + assertLifecycle(step.cwd == null, `${label} cannot declare a workspace for ${target.scope} scope.`); + } +} + +function planCoreForDigest(plan) { + const { planId: _planId, status: _status, diagnostics: _diagnostics, ...core } = plan; + return core; +} + +function preconditionCoreForDigest(plan) { + return { + target: plan.target, + observed: plan.observed, + }; +} + +export function validatePluginLifecyclePlan(plan) { + assertLifecycle(plan && typeof plan === "object", "PluginLifecyclePlanV1 plan must be an object."); + assertLifecycle(plan.kind === "better-harness-plugin-plan", "Invalid PluginLifecyclePlanV1 kind."); + assertLifecycle(plan.schemaVersion === PLUGIN_LIFECYCLE_SCHEMA_VERSION, "Invalid PluginLifecyclePlanV1 schema version."); + validateLifecyclePlugin(plan.plugin, { + label: "PluginLifecyclePlanV1", + versionField: "packageVersion", + versionLabel: "package version", + }); + assertControlled(PLUGIN_LIFECYCLE_ACTIONS, plan.action, "action"); + validateLifecycleTarget(plan.target, { label: "PluginLifecyclePlanV1", expectedSource: true }); + if (["project", "local"].includes(plan.target.scope)) { + assertLifecycleString(plan.target.workspace, "PluginLifecyclePlanV1 target workspace is missing."); + assertLifecycle(path.isAbsolute(plan.target.workspace), "PluginLifecyclePlanV1 target workspace must be absolute."); + } else { + assertLifecycle(plan.target.workspace == null, "PluginLifecyclePlanV1 target workspace is valid only for project or local scope."); + } + if (plan.target.hostHome != null) { + assertLifecycleString(plan.target.hostHome, "PluginLifecyclePlanV1 target host home is invalid."); + assertLifecycle(path.isAbsolute(plan.target.hostHome), "PluginLifecyclePlanV1 target host home must be absolute."); + } + assertControlled(PLAN_STATES, plan.state, "state"); + assertLifecycle(/^[a-f0-9]{64}$/u.test(plan.preconditionDigest), "Invalid PluginLifecyclePlanV1 precondition digest."); + assertLifecycle(Array.isArray(plan.observed), "PluginLifecyclePlanV1 observed rows must be an array."); + for (const row of plan.observed) { + validatePluginLifecycleStatusRow(row); + assertLifecycle(row.target.hostId === plan.target.hostId, "PluginLifecyclePlanV1 observed host differs from its target."); + assertLifecycle(row.target.surfaceId === plan.target.surfaceId, "PluginLifecyclePlanV1 observed surface differs from its target."); + assertLifecycle( + row.target.distributionKind === plan.target.distributionKind, + "PluginLifecyclePlanV1 observed distribution differs from its target.", + ); + assertLifecycle( + row.target.scope === plan.target.scope || plan.target.distributionKind === "bundled", + "PluginLifecyclePlanV1 observed scope differs from its target.", + ); + } + assertLifecycle(plan.preconditionDigest === stableDigest(preconditionCoreForDigest(plan)), "PluginLifecyclePlanV1 precondition digest is stale."); + assertLifecycle(plan.currentObservation?.instances === plan.observed.length, "PluginLifecyclePlanV1 observation count is inconsistent."); + assertLifecycle( + stableDigest(plan.currentObservation.installations) === stableDigest(plan.observed.map((row) => row.installation)), + "PluginLifecyclePlanV1 installation summary is inconsistent.", + ); + assertLifecycle( + stableDigest(plan.currentObservation.verification) === stableDigest(plan.observed.map((row) => row.verification)), + "PluginLifecyclePlanV1 verification summary is inconsistent.", + ); + assertLifecycle(Array.isArray(plan.steps), "PluginLifecyclePlanV1 mutation steps must be an array."); + assertLifecycle(Array.isArray(plan.verificationSteps), "PluginLifecyclePlanV1 verification steps must be an array."); + plan.steps.forEach((step, index) => validatePlanStep(step, index, "mutation", plan.target)); + plan.verificationSteps.forEach((step, index) => validatePlanStep(step, index, "verification", plan.target)); + if (["blocked", "noop"].includes(plan.state)) { + assertLifecycle(plan.steps.length === 0, `PluginLifecyclePlanV1 ${plan.state} plan cannot contain mutation steps.`); + } else { + assertLifecycle(plan.steps.length > 0, `PluginLifecyclePlanV1 ${plan.state} plan requires mutation steps.`); + } + validateLifecycleDiagnostics(plan.blockers, "PluginLifecyclePlanV1 blocker"); + if (plan.state === "blocked") { + assertLifecycle(plan.blockers.length > 0, "PluginLifecyclePlanV1 blocked plan requires a blocker."); + } else { + assertLifecycle(plan.blockers.length === 0, `PluginLifecyclePlanV1 ${plan.state} plan cannot contain blockers.`); + } + assertLifecycle(Array.isArray(plan.notes) && plan.notes.every((value) => typeof value === "string"), "PluginLifecyclePlanV1 notes must be strings."); + assertLifecycle(stableDigest(plan.retention) === stableDigest(RETENTION_BOUNDARIES), "PluginLifecyclePlanV1 retention boundaries changed."); + assertLifecycle(plan.activation?.newSessionRequired === ["ready", "manual"].includes(plan.state), "PluginLifecyclePlanV1 activation requirement is inconsistent."); + assertLifecycleString(plan.activation.instruction, "PluginLifecyclePlanV1 activation instruction is missing."); + assertLifecycle(stableDigest(plan.recovery) === stableDigest(RECOVERY_STEPS), "PluginLifecyclePlanV1 recovery boundaries changed."); + assertLifecycle(plan.effects === "none", "PluginLifecyclePlanV1 generation must remain read-only."); + const expectedStatus = plan.state === "blocked" ? "failed" : (plan.state === "manual" ? "partial" : "ok"); + assertControlled(PLAN_STATUSES, plan.status, "status"); + assertLifecycle(plan.status === expectedStatus, "PluginLifecyclePlanV1 command status is inconsistent."); + validateLifecycleDiagnostics(plan.diagnostics, "PluginLifecyclePlanV1"); + assertLifecycle(stableDigest(plan.diagnostics) === stableDigest(plan.blockers), "PluginLifecyclePlanV1 diagnostics must mirror blockers."); + assertLifecycle(plan.planId === stableDigest(planCoreForDigest(plan)), "PluginLifecyclePlanV1 plan digest is stale."); + return true; +} + +export function requirePluginLifecycleAction(value) { + const action = String(value ?? ""); + if (PLUGIN_LIFECYCLE_ACTIONS.includes(action)) return action; + throw new LifecycleCliError( + "UNKNOWN_LIFECYCLE_ACTION", + `Unknown lifecycle action: ${action || ""}`, + { kind: "usage", hint: `Use ${PLUGIN_LIFECYCLE_ACTIONS.join(", ")}.` }, + ); +} + +function stepCanBindHostHome(step, surface) { + return surface.nativeHomeBinding != null && step.kind === "shell"; +} + +function stepsCanBindHostHome(steps, surface, hostHome) { + return !hostHome || steps.every((step) => stepCanBindHostHome(step, surface)); +} + +function materializeStep(step, surface, scope, workspace, hostHome, index, phase) { + const scopeValue = surface.scopeValues?.[scope] ?? scope; + const replace = (value) => value.replaceAll("{scope}", scope).replaceAll("{scopeValue}", scopeValue); + const mutation = phase === "mutation"; + return { + id: `step-${index + 1}`, + kind: step.kind, + ...(step.argv ? { argv: step.argv.map(replace) } : {}), + ...(step.command ? { command: replace(step.command) } : {}), + ...(step.instruction ? { instruction: replace(step.instruction) } : {}), + expected: replace(step.expected), + externalImpact: mutation ? "host-plugin-state" : "host-observation", + effects: mutation ? "external" : "read-only", + ownership: "host-native", + ...(hostHome ? { + homeBinding: { + kind: surface.nativeHomeBinding.kind, + variable: surface.nativeHomeBinding.variable, + value: hostHome, + contractEvidence: surface.nativeHomeBinding.contractEvidence, + }, + } : {}), + ...(["project", "local"].includes(scope) ? { cwd: workspace } : {}), + preconditions: mutation + ? [ + "The precondition digest still matches current local evidence.", + "The user explicitly chooses to run this host-native step outside Better Harness.", + ] + : [ + "The planned external lifecycle steps were completed or intentionally skipped.", + "The user explicitly chooses to run this host-native observation outside Better Harness.", + ], + }; +} + +function targetRows(observed, surface, scope) { + return observed.rows.filter((row) => ( + row.target.surfaceId === surface.surfaceId + && (row.target.scope === scope || surface.distributionKind === "bundled") + )); +} + +function installedState(rows) { + return rows.some((row) => ["installed", "bundled"].includes(row.installation)); +} + +function installationState(rows) { + if (installedState(rows)) return "installed"; + if (rows.some((row) => ["unknown", "session-only"].includes(row.installation))) { + return "unknown"; + } + return "not-installed"; +} + +function observationBlockers({ action, profile, surface, scope, observed }) { + const surfaceRows = observed.rows.filter((row) => row.target.surfaceId === surface.surfaceId); + const observationFailure = surfaceRows.flatMap((row) => row.diagnostics).find((item) => ( + ["INVENTORY_COLLECTION_FAILED", "INSTALL_SCOPE_UNSUPPORTED"].includes(item.code) + )); + if (observationFailure) return [observationFailure]; + + const selectedRows = targetRows(observed, surface, scope); + if ( + action === "install" + && surface.scopeArtifactPolicy === "shared" + && !installedState(selectedRows) + && installedState(surfaceRows) + ) { + return [diagnostic( + "SHARED_ARTIFACT_SCOPE_CONFLICT", + "error", + `${profile.displayName} already has the shared plugin artifact installed at another activation scope.`, + )]; + } + const operation = surface.lifecycle[action]; + const requiresHost = operation.steps.length > 0 && surface.distributionKind !== "bundled"; + if (requiresHost && surfaceRows.some((row) => row.hostDiscovery === "absent")) { + return [diagnostic( + "HOST_EXECUTABLE_NOT_FOUND", + "error", + `${profile.displayName} requires an available host executable before ${action} can be planned.`, + )]; + } + return []; +} + +function transitionFor({ action, profile, surface, installation, hostHome, preflightBlockers }) { + const observationBlockers = preflightBlockers.filter((item) => item.code !== "HOST_EXECUTABLE_NOT_FOUND"); + if (observationBlockers.length > 0) { + return { + state: "blocked", + blockers: observationBlockers, + }; + } + const operation = surface.lifecycle[action]; + if (!profile.managed) { + return { + state: "blocked", + blockers: [diagnostic( + "PLUGIN_LIFECYCLE_UNSUPPORTED", + "error", + `${profile.displayName} has no managed Better Harness lifecycle surface.`, + )], + }; + } + if (operation.disposition === "unavailable") { + const stale = operation.contractEvidence?.kind === "contract-drift"; + return { + state: "blocked", + blockers: [diagnostic( + stale ? "HOST_CONTRACT_STALE" : "LIFECYCLE_OPERATION_UNAVAILABLE", + "error", + stale + ? `The ${profile.displayName} lifecycle contract conflicts with current local evidence.` + : `${action} is unavailable for ${profile.displayName}/${surface.surfaceId}.`, + )], + }; + } + if (operation.disposition === "not-applicable") { + if (action === "install" && surface.distributionKind === "bundled") { + return { + state: "noop", + notes: ["Better Harness is bundled with this host; no separate install is applicable."], + }; + } + return { + state: "blocked", + blockers: [diagnostic( + "LIFECYCLE_OPERATION_NOT_APPLICABLE", + "warning", + `${action} is not applicable to ${profile.displayName}/${surface.surfaceId}.`, + )], + }; + } + const hostBlockers = preflightBlockers.filter((item) => item.code === "HOST_EXECUTABLE_NOT_FOUND"); + if (hostBlockers.length > 0) { + return { + state: "blocked", + blockers: hostBlockers, + }; + } + const installed = installation === "installed"; + const absent = installation === "not-installed"; + if (action === "install" && installed) { + return { + state: "noop", + notes: ["Better Harness is already installed at the selected target; install does not imply update."], + }; + } + if (action === "update" && absent) { + return { + state: "blocked", + blockers: [diagnostic( + "PLUGIN_NOT_INSTALLED", + "error", + "Better Harness is not installed at the selected target; update will not become a first install.", + )], + }; + } + if (action === "remove" && absent) { + return { + state: "noop", + notes: ["Better Harness is not installed at the selected target; nothing would be removed."], + }; + } + if (installation === "unknown" && operation.disposition !== "manual") { + return { + state: "blocked", + blockers: [diagnostic( + "INSTALLATION_STATE_UNOBSERVED", + "error", + `The available local evidence cannot prove whether Better Harness is installed for ${profile.displayName}/${surface.surfaceId}.`, + )], + }; + } + if ( + !stepsCanBindHostHome(operation.steps, surface, hostHome) + || !stepsCanBindHostHome(surface.lifecycle.verify.steps ?? [], surface, hostHome) + ) { + return { + state: "blocked", + blockers: [diagnostic( + "ISOLATED_HOST_HOME_UNREPRESENTABLE", + "error", + `${profile.displayName}/${surface.surfaceId} cannot bind every native operation and verification step to the isolated host home.`, + )], + }; + } + return { + state: operation.disposition === "manual" ? "manual" : "ready", + steps: operation.steps, + notes: operation.disposition === "manual" + ? ["This host operation requires an explicit native UI, session, or manually verified command path."] + : [], + }; +} + +export function createPluginLifecyclePlan({ action, profile, surface, scope, workspace, hostHome, observed }) { + const rows = targetRows(observed, surface, scope); + const target = { + hostId: profile.hostId, + surfaceId: surface.surfaceId, + scope, + distributionKind: surface.distributionKind, + ...(["project", "local"].includes(scope) ? { workspace } : {}), + ...(hostHome ? { hostHome } : {}), + expectedSource: { + pluginId: profile.identity.nativeIds[0], + repository: "QoderAI/better-harness", + }, + }; + const transition = transitionFor({ + action, + profile, + surface, + installation: installationState(rows), + hostHome, + preflightBlockers: observationBlockers({ action, profile, surface, scope, observed }), + }); + const state = transition.state; + const blockers = transition.blockers ?? []; + const notes = transition.notes ?? []; + const steps = (transition.steps ?? []) + .map((step, index) => materializeStep(step, surface, scope, workspace, hostHome, index, "mutation")); + const rawVerificationSteps = surface.lifecycle.verify.steps ?? []; + const verificationSteps = stepsCanBindHostHome(rawVerificationSteps, surface, hostHome) + ? rawVerificationSteps.map((step, index) => ( + materializeStep(step, surface, scope, workspace, hostHome, index, "verification") + )) + : []; + const planCore = { + kind: "better-harness-plugin-plan", + schemaVersion: PLUGIN_LIFECYCLE_SCHEMA_VERSION, + plugin: observed.plugin, + action, + target, + state, + preconditionDigest: stableDigest({ target, observed: rows }), + currentObservation: { + instances: rows.length, + installations: rows.map((row) => row.installation), + verification: rows.map((row) => row.verification), + }, + observed: rows, + steps, + verificationSteps, + blockers, + notes, + retention: [...RETENTION_BOUNDARIES], + activation: { + newSessionRequired: ["ready", "manual"].includes(state), + instruction: action === "remove" + ? "Start a new host session before confirming that Better Harness is no longer routed." + : "Start a new host session after external steps, then run plugin verify.", + }, + recovery: [...RECOVERY_STEPS], + effects: "none", + }; + const plan = { + ...planCore, + planId: stableDigest(planCore), + status: state === "blocked" ? "failed" : (state === "manual" ? "partial" : "ok"), + diagnostics: blockers, + }; + validatePluginLifecyclePlan(plan); + return plan; +} diff --git a/scripts/plugin-lifecycle/plan.mjs b/scripts/plugin-lifecycle/plan.mjs new file mode 100644 index 0000000..15eb60d --- /dev/null +++ b/scripts/plugin-lifecycle/plan.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { runPluginCommand } from "./cli.mjs"; + +process.exitCode = await runPluginCommand("plan"); diff --git a/scripts/plugin-lifecycle/read-only-command.mjs b/scripts/plugin-lifecycle/read-only-command.mjs new file mode 100644 index 0000000..f7592fd --- /dev/null +++ b/scripts/plugin-lifecycle/read-only-command.mjs @@ -0,0 +1,181 @@ +import process from "node:process"; + +import { + cliErrorEnvelope, + commandEnvelope, + exitCodeFor, + LifecycleCliError, + withTimeout, +} from "./contract.mjs"; + +const COMMON_VALUE_OPTIONS = Object.freeze({ + "--workspace": "workspace", + "--host-home": "hostHome", + "--timeout": "timeout", +}); + +const BOOLEAN_OPTIONS = Object.freeze({ + "--json": "json", + "--no-color": "noColor", + "--help": "help", + "-h": "help", +}); + +function assignOption(options, seen, key, value, name) { + if (seen.has(key)) { + throw new LifecycleCliError( + "DUPLICATE_OPTION", + `Option ${name} may be supplied only once.`, + { kind: "usage" }, + ); + } + if (typeof value === "string" && value.length === 0) { + throw new LifecycleCliError( + "MISSING_OPTION_VALUE", + `Missing value for ${name}.`, + { kind: "usage" }, + ); + } + seen.add(key); + options[key] = value; +} + +function optionValue(argv, index, name) { + const value = argv[index + 1]; + if (value == null || value.startsWith("-")) { + throw new LifecycleCliError( + "MISSING_OPTION_VALUE", + `Missing value for ${name}.`, + { kind: "usage" }, + ); + } + return value; +} + +export function parseReadOnlyOptions(argv, { + valueOptions = {}, + defaults = {}, + helpWord = true, +} = {}) { + const knownValues = { ...COMMON_VALUE_OPTIONS, ...valueOptions }; + const options = { + json: false, + noColor: false, + help: false, + ...defaults, + }; + const positionals = []; + const seen = new Set(); + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + const booleanKey = BOOLEAN_OPTIONS[argument]; + if (booleanKey) { + assignOption(options, seen, booleanKey, true, argument); + continue; + } + if (helpWord && argument === "help") { + assignOption(options, seen, "help", true, "help"); + continue; + } + if (argument.startsWith("--")) { + const equalsAt = argument.indexOf("="); + const name = equalsAt === -1 ? argument : argument.slice(0, equalsAt); + const key = knownValues[name]; + if (!key) { + throw new LifecycleCliError( + "UNKNOWN_OPTION", + `Unknown option: ${name}`, + { kind: "usage" }, + ); + } + const value = equalsAt === -1 + ? optionValue(argv, index, name) + : argument.slice(equalsAt + 1); + assignOption(options, seen, key, value, name); + if (equalsAt === -1) index += 1; + continue; + } + if (argument.startsWith("-")) { + throw new LifecycleCliError( + "UNKNOWN_OPTION", + `Unknown option: ${argument}`, + { kind: "usage" }, + ); + } + positionals.push(argument); + } + + return { options, positionals }; +} + +export function normalizeReadOnlyTimeout(options, { + defaultTimeout = 5000, + maximumTimeout = 60_000, +} = {}) { + if (options.help) return options; + const timeout = options.timeout == null ? defaultTimeout : Number(options.timeout); + if (!Number.isInteger(timeout) || timeout < 1 || timeout > maximumTimeout) { + throw new LifecycleCliError( + "INVALID_TIMEOUT", + `--timeout must be an integer from 1 to ${maximumTimeout} milliseconds.`, + { kind: "usage" }, + ); + } + return { ...options, timeout }; +} + +function defaultEnvelope({ command, result, durationMs }) { + return commandEnvelope({ + command, + status: result.status, + data: result, + diagnostics: result.diagnostics ?? [], + durationMs, + }); +} + +export async function runReadOnlyCommand({ + argv, + command, + parse, + usage, + prepare = async (options) => options, + execute, + envelope = defaultEnvelope, + renderHuman, + stdout = process.stdout, + stderr = process.stderr, +}) { + const startedAt = Date.now(); + const machineRequested = argv.includes("--json"); + let options; + try { + options = parse(argv); + if (options.help) { + stdout.write(usage()); + return 0; + } + options = await prepare(options); + const result = await withTimeout(() => execute(options), options.timeout); + if (options.json) { + stdout.write(`${JSON.stringify(envelope({ + command, + result, + durationMs: Date.now() - startedAt, + }), null, 2)}\n`); + } else { + stdout.write(renderHuman(result)); + } + return exitCodeFor(result.status); + } catch (error) { + const failure = cliErrorEnvelope(command, error, Date.now() - startedAt); + if (machineRequested || options?.json) { + stdout.write(`${JSON.stringify(failure.envelope, null, 2)}\n`); + } else { + const hint = error instanceof LifecycleCliError ? error.hint : undefined; + stderr.write(`${failure.envelope.diagnostics[0].message}${hint ? `\n\n${hint}` : ""}\n`); + } + return failure.exitCode; + } +} diff --git a/scripts/plugin-lifecycle/runtime.mjs b/scripts/plugin-lifecycle/runtime.mjs new file mode 100644 index 0000000..c45d2dc --- /dev/null +++ b/scripts/plugin-lifecycle/runtime.mjs @@ -0,0 +1,155 @@ +import { constants as fsConstants } from "node:fs"; +import { access, readFile, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { getHostProfile } from "../host-support/index.mjs"; +import { LifecycleCliError } from "./contract.mjs"; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(moduleDir, "..", ".."); +const packageJsonPath = path.join(repoRoot, "package.json"); + +function expandLeadingHome(value) { + const raw = String(value); + if (raw === "~") return os.homedir(); + if (/^~[\\/]/u.test(raw)) return path.join(os.homedir(), raw.slice(2)); + return raw; +} + +export function normalizeLifecyclePaths(options = {}) { + const workspace = path.resolve(expandLeadingHome(options.workspace ?? process.cwd())); + return { + ...options, + workspace, + ...(options.hostHome != null + ? { hostHome: path.resolve(expandLeadingHome(options.hostHome)) } + : {}), + }; +} + +function pathRules(value, home, workspace, platform) { + const windowsStyle = platform === "win32" || [value, home, workspace] + .some((entry) => typeof entry === "string" && /^[A-Za-z]:[\\/]/u.test(entry)); + return { + api: windowsStyle ? path.win32 : path, + insensitive: windowsStyle, + }; +} + +export function redactPath(value, { home = os.homedir(), workspace, platform = process.platform } = {}) { + if (typeof value !== "string" || !value) return undefined; + const rules = pathRules(value, home, workspace, platform); + const resolved = rules.api.resolve(value); + const resolvedHome = home ? rules.api.resolve(home) : undefined; + const resolvedWorkspace = workspace ? rules.api.resolve(workspace) : undefined; + const comparable = (entry) => rules.insensitive ? entry?.toLowerCase() : entry; + const contains = (root) => root && ( + comparable(resolved) === comparable(root) + || comparable(resolved).startsWith(`${comparable(root)}${rules.api.sep}`) + ); + if (contains(resolvedWorkspace)) { + const relative = rules.api.relative(resolvedWorkspace, resolved); + return relative ? `/${relative.split(rules.api.sep).join("/")}` : ""; + } + if (contains(resolvedHome)) { + const relative = rules.api.relative(resolvedHome, resolved); + return relative ? `~/${relative.split(rules.api.sep).join("/")}` : "~"; + } + return `/${rules.api.basename(resolved)}`; +} + +function executableCandidates(name, platform, env) { + if (platform !== "win32") return [name]; + if (/\.[A-Za-z0-9]+$/u.test(name)) return [name]; + const pathExt = Object.entries(env) + .find(([key]) => key.toUpperCase() === "PATHEXT")?.[1]; + const extensions = String(pathExt ?? ".EXE;.CMD;.BAT;.COM") + .split(";") + .map((extension) => extension.trim()) + .filter(Boolean) + .map((extension) => extension.startsWith(".") ? extension : `.${extension}`) + .map((extension) => extension.toLowerCase()); + return [...new Set(extensions)].map((extension) => `${name}${extension}`); +} + +async function isExecutableFile(candidate, platform) { + const candidateStat = await stat(candidate); + if (!candidateStat.isFile()) return false; + await access(candidate, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); + return true; +} + +export async function discoverHostExecutable(profile, options = {}) { + if (typeof options.hostDiscovery === "function") { + return options.hostDiscovery(profile); + } + if ((profile.executables ?? []).length === 0) return "unobserved"; + const env = options.env ?? process.env; + const platform = options.runtimePlatform ?? options.platform ?? process.platform; + const pathValue = env.PATH ?? env.Path ?? env.path ?? ""; + const delimiter = platform === "win32" ? path.win32.delimiter : path.delimiter; + const directories = pathValue.split(delimiter).filter(Boolean); + for (const executable of profile.executables) { + for (const directory of directories) { + for (const candidate of executableCandidates(executable, platform, env)) { + try { + if (await isExecutableFile(path.join(directory, candidate), platform)) return "present"; + } catch { + // Continue through the bounded PATH candidates. + } + } + } + } + return "absent"; +} + +export async function packageInfo(options = {}) { + if (options.packageInfo) return options.packageInfo; + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")); + return { + name: packageJson.name, + version: packageJson.version, + nodeRange: packageJson.engines?.node, + npmRange: packageJson.engines?.npm, + }; +} + +export async function validateWorkspace(workspace) { + const resolved = path.resolve(workspace ?? process.cwd()); + const stats = await stat(resolved).catch(() => null); + if (!stats?.isDirectory()) { + throw new LifecycleCliError( + "INVALID_WORKSPACE", + "Workspace is not an existing directory.", + { kind: "usage", hint: "Check the --workspace value." }, + ); + } + return resolved; +} + +export async function pluginLifecycleRuntimeInfo(options = {}) { + const info = await packageInfo(options); + return { + ...info, + nodeVersion: process.version, + platform: process.platform, + architecture: process.arch, + }; +} + +export function authorizedRootLabel(host, { + hostHome, + home, + resolvedRoot, + workspace, + platform, +} = {}) { + if (resolvedRoot) return redactPath(resolvedRoot, { home, workspace, platform }); + if (hostHome) return redactPath(hostHome, { home, workspace, platform }); + const profile = getHostProfile(host); + return profile?.inventoryHomeRoutes + .find((route) => route.option === profile.inventoryHomeOption) + ?.fallbackLabel; +} diff --git a/scripts/plugin-lifecycle/status-core.mjs b/scripts/plugin-lifecycle/status-core.mjs new file mode 100644 index 0000000..7d19447 --- /dev/null +++ b/scripts/plugin-lifecycle/status-core.mjs @@ -0,0 +1,210 @@ +import path from "node:path"; + +import { collectAgentCustomizeInventory } from "../agent-customize/index.mjs"; +import { + matchesBetterHarnessPlugin, + stableSerialize, +} from "./identity.mjs"; +import { PLUGIN_ID, PLUGIN_LIFECYCLE_SCHEMA_VERSION } from "./model.mjs"; +import { + observedHostDiscovery, + observedScopes, + requiresInventoryCollection, + singleInventoryCandidate, + usesSingleInventoryCandidate, +} from "./observation.mjs"; +import { + discoverHostExecutable, + normalizeLifecyclePaths, + packageInfo, +} from "./runtime.mjs"; +import { + buildInventoryFailureStatusRow, + buildObservedStatusRow, + buildUnresolvedScopeStatusRow, +} from "./status-row.mjs"; +import { resolveStatusTarget } from "./target-resolution.mjs"; + +function providerOptions(profile, options) { + const result = { + provider: profile.provider, + workspace: options.workspace, + includeUserHome: options.includeUserHome !== false, + }; + if (options.hostHome) { + for (const route of profile.inventoryHomeRoutes) { + result[route.option] = route.relativePath + ? path.join(options.hostHome, route.relativePath) + : options.hostHome; + } + } + return result; +} + +async function collectHostStatus(profile, expectedVersion, options, selectedSurface) { + const collectInventory = options.inventoryCollector ?? collectAgentCustomizeInventory; + const hostDiscovery = await discoverHostExecutable(profile, options); + const surfaces = selectedSurface ? [selectedSurface] : profile.surfaces; + const inventoryRequired = surfaces.some(requiresInventoryCollection); + let inventory = { plugins: [], diagnostics: {} }; + if (inventoryRequired) { + try { + inventory = await collectInventory(providerOptions(profile, options)); + } catch { + return { + rows: surfaces.map((surface) => { + const surfaceHostDiscovery = observedHostDiscovery(surface, { diagnostics: {} }, hostDiscovery); + return requiresInventoryCollection(surface) + ? buildInventoryFailureStatusRow({ + profile, + surface, + hostDiscovery: surfaceHostDiscovery, + expectedVersion, + options, + }) + : buildObservedStatusRow({ + profile, + surface, + scope: surface.defaultScope, + hostDiscovery: surfaceHostDiscovery, + expectedVersion, + options, + }); + }), + inventoryRequired, + inventoryFailed: true, + }; + } + } + + const matching = (inventory.plugins ?? []).filter((plugin) => ( + plugin?.applicable !== false && matchesBetterHarnessPlugin(plugin, profile) + )); + const rows = []; + for (const surface of surfaces) { + const surfaceHostDiscovery = observedHostDiscovery(surface, inventory, hostDiscovery); + if (usesSingleInventoryCandidate(surface)) { + rows.push(buildObservedStatusRow({ + profile, + surface, + plugin: singleInventoryCandidate(surface, matching), + scope: surface.defaultScope, + hostDiscovery: surfaceHostDiscovery, + expectedVersion, + options, + })); + continue; + } + if (matching.length === 0) { + rows.push(buildObservedStatusRow({ + profile, + surface, + scope: surface.defaultScope, + hostDiscovery: surfaceHostDiscovery, + expectedVersion, + options, + })); + continue; + } + for (const plugin of matching) { + const scopes = observedScopes(plugin, surface); + if (scopes.length === 0) { + rows.push(buildUnresolvedScopeStatusRow({ + profile, + surface, + plugin, + hostDiscovery: surfaceHostDiscovery, + expectedVersion, + options, + })); + continue; + } + for (const scope of scopes) { + rows.push(buildObservedStatusRow({ + profile, + surface, + plugin, + scope, + hostDiscovery: surfaceHostDiscovery, + expectedVersion, + options, + })); + } + } + } + return { rows, inventoryRequired, inventoryFailed: false }; +} + +function commandStatus(rows, { inventoryRequirements = 0, inventoryFailures = 0 } = {}) { + if (rows.length === 0 || (inventoryRequirements > 0 && inventoryFailures === inventoryRequirements)) return "failed"; + if (inventoryFailures > 0) return "partial"; + if (rows.some((row) => row.verification !== "passed" || row.diagnostics.some((item) => item.severity !== "info"))) { + return "partial"; + } + return "ok"; +} + +export async function inspectPluginLifecycle(options = {}) { + const normalizedOptions = normalizeLifecyclePaths(options); + const info = await packageInfo(normalizedOptions); + const { profiles, surface: selectedSurface } = resolveStatusTarget(normalizedOptions); + const collected = await Promise.all( + profiles.map((profile) => collectHostStatus(profile, info.version, normalizedOptions, selectedSurface)), + ); + let rows = collected.flatMap((entry) => entry.rows); + const hostOrder = new Map(profiles.map((profile, index) => [profile.hostId, index])); + const surfaceOrder = new Map(profiles.flatMap((profile) => ( + profile.surfaces.map((surface, index) => [`${profile.hostId}/${surface.surfaceId}`, index]) + ))); + const scopeOrder = new Map(profiles.flatMap((profile) => ( + profile.surfaces.flatMap((surface) => surface.scopes.map((scope, index) => ( + [`${profile.hostId}/${surface.surfaceId}/${scope}`, index] + ))) + ))); + rows.sort((left, right) => { + const hostDifference = hostOrder.get(left.target.hostId) - hostOrder.get(right.target.hostId); + if (hostDifference !== 0) return hostDifference; + const leftSurface = `${left.target.hostId}/${left.target.surfaceId}`; + const rightSurface = `${right.target.hostId}/${right.target.surfaceId}`; + const surfaceDifference = surfaceOrder.get(leftSurface) - surfaceOrder.get(rightSurface); + if (surfaceDifference !== 0) return surfaceDifference; + const scopeDifference = scopeOrder.get(`${leftSurface}/${left.target.scope}`) + - scopeOrder.get(`${rightSurface}/${right.target.scope}`); + if (scopeDifference !== 0) return scopeDifference; + const serializedLeft = stableSerialize(left); + const serializedRight = stableSerialize(right); + return serializedLeft < serializedRight ? -1 : (serializedLeft > serializedRight ? 1 : 0); + }); + const inventoryRequirements = collected.filter((entry) => entry.inventoryRequired).length; + const inventoryFailures = collected.filter((entry) => entry.inventoryFailed).length; + return { + kind: "better-harness-plugin-status", + schemaVersion: PLUGIN_LIFECYCLE_SCHEMA_VERSION, + status: commandStatus(rows, { inventoryRequirements, inventoryFailures }), + plugin: { id: PLUGIN_ID, packageVersion: info.version }, + rows, + diagnostics: rows.flatMap((row) => row.diagnostics), + }; +} + +export async function verifyPluginLifecycle(options = {}) { + const result = await inspectPluginLifecycle(options); + const failed = result.rows.filter((row) => row.verification === "failed"); + const partial = result.rows.filter((row) => ["partial", "unobserved"].includes(row.verification)); + return { + kind: "better-harness-plugin-verification", + schemaVersion: PLUGIN_LIFECYCLE_SCHEMA_VERSION, + status: result.status === "failed" || failed.length === result.rows.length + ? "failed" + : (failed.length > 0 || partial.length > 0 ? "partial" : "ok"), + plugin: result.plugin, + summary: { + targets: result.rows.length, + passed: result.rows.filter((row) => row.verification === "passed").length, + partial: partial.length, + failed: failed.length, + }, + rows: result.rows, + diagnostics: result.diagnostics, + }; +} diff --git a/scripts/plugin-lifecycle/status-row.mjs b/scripts/plugin-lifecycle/status-row.mjs new file mode 100644 index 0000000..33757c1 --- /dev/null +++ b/scripts/plugin-lifecycle/status-row.mjs @@ -0,0 +1,295 @@ +import path from "node:path"; + +import { diagnostic } from "./contract.mjs"; +import { normalizeIdentity } from "./identity.mjs"; +import { + assertLifecycle, + assertLifecycleControlled, + assertLifecycleString, + PLUGIN_ID, + PLUGIN_LIFECYCLE_SCHEMA_VERSION, + validateLifecycleDiagnostics, + validateLifecyclePlugin, + validateLifecycleTarget, +} from "./model.mjs"; +import { + hasUnobservedDesktopEnablement, + inventoryFailureInstallation, + observedEnablement, + observedInstallation, +} from "./observation.mjs"; +import { authorizedRootLabel, redactPath } from "./runtime.mjs"; + +const CONTROLLED_STATES = Object.freeze({ + hostDiscovery: Object.freeze(["present", "absent", "unobserved"]), + installation: Object.freeze(["installed", "not-installed", "bundled", "session-only", "unknown"]), + enablement: Object.freeze(["enabled", "disabled", "unknown", "not-applicable"]), + versionRelation: Object.freeze(["same", "different", "unobserved"]), + verification: Object.freeze(["passed", "partial", "failed", "unobserved"]), + checkStatus: Object.freeze(["passed", "failed", "unobserved"]), +}); + +function assertControlled(family, value) { + assertLifecycleControlled(CONTROLLED_STATES[family], value, "PluginLifecycleStatusV1", family); +} + +export function validatePluginLifecycleStatusRow(row) { + assertLifecycle(row && typeof row === "object", "PluginLifecycleStatusV1 row must be an object."); + assertLifecycle(row.schemaVersion === PLUGIN_LIFECYCLE_SCHEMA_VERSION, "Invalid PluginLifecycleStatusV1 schema version."); + validateLifecyclePlugin(row.plugin, { + label: "PluginLifecycleStatusV1", + versionField: "expectedVersion", + versionLabel: "expected version", + }); + validateLifecycleTarget(row.target, { label: "PluginLifecycleStatusV1" }); + assertControlled("hostDiscovery", row.hostDiscovery); + assertControlled("installation", row.installation); + assertControlled("enablement", row.enablement); + assertControlled("versionRelation", row.version?.relation); + if (row.version.relation === "unobserved") { + assertLifecycle(row.version.observed == null, "Unobserved PluginLifecycleStatusV1 version cannot include an observed value."); + } else { + assertLifecycleString(row.version.observed, "Observed PluginLifecycleStatusV1 version is missing."); + } + assertControlled("verification", row.verification); + assertLifecycle(row.activation === "unobserved", "PluginLifecycleStatusV1 activation must remain unobserved without runtime evidence."); + assertLifecycle(Array.isArray(row.checks), "PluginLifecycleStatusV1 checks must be an array."); + for (const check of row.checks) { + assertLifecycleString(check.id, "PluginLifecycleStatusV1 check id is missing."); + assertControlled("checkStatus", check.status); + } + assertLifecycle(Array.isArray(row.evidence) && row.evidence.length > 0, "PluginLifecycleStatusV1 evidence must be non-empty."); + for (const evidence of row.evidence) { + assertLifecycle(evidence.class === "local-config", "PluginLifecycleStatusV1 evidence must remain local-config."); + assertLifecycleString(evidence.path, "PluginLifecycleStatusV1 evidence path is missing."); + } + validateLifecycleDiagnostics(row.diagnostics, "PluginLifecycleStatusV1"); + return true; +} + +function versionState(observed, expected) { + if (!observed) return { relation: "unobserved" }; + return { + observed: String(observed), + relation: String(observed) === String(expected) ? "same" : "different", + }; +} + +function fallbackEvidencePath(profile, surface, options = {}) { + const routeOption = surface.observation.evidenceRouteOption ?? profile.inventoryHomeOption; + const route = profile.inventoryHomeRoutes.find((candidate) => candidate.option === routeOption); + if (options.hostHome) { + const resolvedRoot = route.relativePath + ? path.join(options.hostHome, route.relativePath) + : options.hostHome; + return authorizedRootLabel(profile.hostId, { ...options, resolvedRoot }); + } + return route.fallbackLabel; +} + +function evidenceFor(plugin, profile, surface, options = {}) { + const evidencePath = plugin?.evidence?.path ?? plugin?.installRecordPath; + const pathLabel = redactPath(evidencePath, options); + return pathLabel + ? [{ class: "local-config", path: pathLabel }] + : [{ class: "local-config", path: fallbackEvidencePath(profile, surface, options) }]; +} + +function skillIsPresent(plugin) { + if (!Array.isArray(plugin?.skills)) return undefined; + return plugin.skills.some((skill) => normalizeIdentity(skill.name) === PLUGIN_ID); +} + +function boundedVerification(verification, code, severity, message, checks = []) { + return { + verification, + activation: "unobserved", + checks, + diagnostics: [diagnostic(code, severity, message)], + }; +} + +function verificationFor({ installation, enabled, plugin, profile, surface }) { + if (!profile.managed) { + return boundedVerification( + "unobserved", + "PLUGIN_LIFECYCLE_UNSUPPORTED", + "warning", + `${profile.displayName} has no managed Better Harness plugin lifecycle surface.`, + ); + } + if (installation === "not-installed") { + return boundedVerification("failed", "PLUGIN_NOT_INSTALLED", "warning", "Better Harness was not found in local install evidence."); + } + if (installation === "unknown") { + return boundedVerification("unobserved", "INSTALLATION_STATE_UNOBSERVED", "warning", "The available local evidence cannot prove installation for this surface."); + } + if (installation === "session-only") { + return boundedVerification( + "unobserved", + "SESSION_ACTIVATION_UNOBSERVED", + "warning", + `${surface.displayName} activation is observable only inside the session that loaded it.`, + ); + } + if (installation === "bundled" && !plugin) { + return boundedVerification("partial", "BUNDLED_RUNTIME_UNOBSERVED", "warning", "Better Harness is declared as bundled, but runtime activation was not probed."); + } + const diagnostics = []; + const checks = []; + checks.push({ id: "plugin-identity", status: "passed" }); + const skillPresent = skillIsPresent(plugin); + if (skillPresent === false) { + checks.push({ id: "skill-route", status: "failed" }); + return boundedVerification( + "failed", + "BETTER_HARNESS_SKILL_MISSING", + "error", + "The installed plugin evidence does not expose the better-harness Skill.", + checks, + ); + } + checks.push({ id: "skill-route", status: skillPresent === true ? "passed" : "unobserved" }); + if (enabled === false) { + diagnostics.push(diagnostic("PLUGIN_DISABLED", "warning", "Better Harness is installed but disabled at the observed scope.")); + } + diagnostics.push(diagnostic("RUNTIME_ACTIVATION_UNOBSERVED", "info", "Local install evidence does not prove that an existing session loaded the Skill.")); + return { + verification: "partial", + activation: "unobserved", + checks, + diagnostics, + }; +} + +function createStatusRow({ + profile, + surface, + scope, + hostDiscovery, + expectedVersion, + installation, + enablement, + version, + verification, + activation, + checks, + evidence, + diagnostics, +}) { + assertLifecycle( + surface.scopes.includes(scope), + `PluginLifecycleStatusV1 scope ${scope} is not declared by ${profile.hostId}/${surface.surfaceId}.`, + ); + const row = { + schemaVersion: PLUGIN_LIFECYCLE_SCHEMA_VERSION, + plugin: { id: PLUGIN_ID, expectedVersion }, + target: { + hostId: profile.hostId, + surfaceId: surface.surfaceId, + scope, + distributionKind: surface.distributionKind, + }, + hostDiscovery, + installation, + enablement, + version, + verification, + activation, + checks, + evidence, + diagnostics, + }; + validatePluginLifecycleStatusRow(row); + return row; +} + +export function buildObservedStatusRow({ + profile, + surface, + plugin, + scope, + hostDiscovery, + expectedVersion, + options, +}) { + let installation = observedInstallation(surface, plugin); + if (!profile.managed) installation = "unknown"; + const verification = verificationFor({ + installation, + enabled: plugin?.enabled, + plugin, + profile, + surface, + }); + if (hasUnobservedDesktopEnablement(surface)) { + verification.diagnostics.push(diagnostic( + "DESKTOP_ENABLEMENT_UNOBSERVED", + "warning", + `${surface.displayName} cache evidence cannot prove that its desktop UI enabled the plugin.`, + )); + } + return createStatusRow({ + profile, + surface, + scope, + hostDiscovery, + expectedVersion, + installation, + enablement: observedEnablement(surface, plugin), + version: versionState(plugin?.version, expectedVersion), + ...verification, + evidence: evidenceFor(plugin, profile, surface, options), + }); +} + +export function buildInventoryFailureStatusRow({ profile, surface, hostDiscovery, expectedVersion, options }) { + return createStatusRow({ + profile, + surface, + scope: surface.defaultScope, + hostDiscovery, + expectedVersion, + installation: inventoryFailureInstallation(surface), + enablement: "unknown", + version: { relation: "unobserved" }, + verification: "unobserved", + activation: "unobserved", + checks: [], + evidence: [{ class: "local-config", path: fallbackEvidencePath(profile, surface, options ?? {}) }], + diagnostics: [diagnostic( + "INVENTORY_COLLECTION_FAILED", + "error", + `Configured-asset inventory failed for ${profile.displayName}.`, + )], + }); +} + +export function buildUnresolvedScopeStatusRow({ + profile, + surface, + plugin, + hostDiscovery, + expectedVersion, + options, +}) { + return createStatusRow({ + profile, + surface, + scope: surface.defaultScope, + hostDiscovery, + expectedVersion, + installation: "unknown", + enablement: "unknown", + version: versionState(plugin?.version, expectedVersion), + verification: "unobserved", + activation: "unobserved", + checks: [{ id: "plugin-identity", status: "passed" }], + evidence: evidenceFor(plugin, profile, surface, options), + diagnostics: [diagnostic( + "INSTALL_SCOPE_UNSUPPORTED", + "error", + `Installed Better Harness evidence could not be assigned to a declared ${surface.displayName} scope.`, + )], + }); +} diff --git a/scripts/plugin-lifecycle/status.mjs b/scripts/plugin-lifecycle/status.mjs new file mode 100644 index 0000000..07f50d1 --- /dev/null +++ b/scripts/plugin-lifecycle/status.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { runPluginCommand } from "./cli.mjs"; + +process.exitCode = await runPluginCommand("status"); diff --git a/scripts/plugin-lifecycle/target-resolution.mjs b/scripts/plugin-lifecycle/target-resolution.mjs new file mode 100644 index 0000000..8a096e4 --- /dev/null +++ b/scripts/plugin-lifecycle/target-resolution.mjs @@ -0,0 +1,98 @@ +import { + getHostProfile, + getHostSurface, + listHostProfiles, + supportedHostIds, +} from "../host-support/index.mjs"; +import { LifecycleCliError } from "./contract.mjs"; + +function usageError(code, message, hint) { + return new LifecycleCliError(code, message, { kind: "usage", hint }); +} + +function unknownHost(host, { includeAll = false } = {}) { + const suffix = includeAll ? ", or all" : ""; + return usageError( + "UNKNOWN_HOST", + `Unknown host: ${host}`, + `Use one of: ${supportedHostIds().join(", ")}${suffix}.`, + ); +} + +function requireSurface(profile, surfaceId, { label = profile.displayName, includeHint = false } = {}) { + const surface = getHostSurface(profile.hostId, surfaceId); + if (surface) return surface; + const hint = includeHint + ? `Use one of: ${profile.surfaces.map((entry) => entry.surfaceId).join(", ")}.` + : undefined; + throw usageError( + "UNKNOWN_HOST_SURFACE", + `Unknown surface for ${label}: ${surfaceId ?? ""}`, + hint, + ); +} + +function selectStatusProfiles(host) { + if (!host || host === "all") return listHostProfiles(); + const profile = getHostProfile(host); + if (!profile) throw unknownHost(host, { includeAll: true }); + return [profile]; +} + +function selectStatusSurface(profiles, surfaceId) { + if (!surfaceId) return undefined; + if (profiles.length !== 1) { + throw usageError( + "AMBIGUOUS_SURFACE", + "--surface requires one explicit host.", + ); + } + const profile = profiles[0]; + return requireSurface(profile, surfaceId, { label: profile.hostId, includeHint: true }); +} + +export function resolveStatusTarget(options = {}) { + const profiles = selectStatusProfiles(options.host); + if (options.hostHome && profiles.length !== 1) { + throw usageError( + "AMBIGUOUS_HOST_HOME", + "--host-home requires one explicit host.", + ); + } + return { + profiles, + surface: selectStatusSurface(profiles, options.surface), + }; +} + +export function resolvePlanTarget(options = {}) { + const { host, surface: surfaceId, scope } = options; + if (!host || host === "all" || host === "auto") { + throw usageError( + "EXPLICIT_HOST_REQUIRED", + "Plugin plans require one explicit host.", + `Use --host ${supportedHostIds({ managedOnly: true }).join(" or --host ")}.`, + ); + } + + const profile = getHostProfile(host); + if (!profile) throw unknownHost(host); + if (!surfaceId && profile.surfaces.length > 1) { + throw usageError( + "AMBIGUOUS_HOST_SURFACE", + `${profile.displayName} has multiple lifecycle surfaces.`, + `Use --surface ${profile.surfaces.map((entry) => entry.surfaceId).join(" or --surface ")}.`, + ); + } + const surface = requireSurface(profile, surfaceId); + + const selectedScope = scope ?? surface.defaultScope; + if (!surface.scopes.includes(selectedScope)) { + throw usageError( + "UNSUPPORTED_SCOPE", + `${profile.displayName}/${surface.surfaceId} does not support scope ${selectedScope}.`, + `Use one of: ${surface.scopes.join(", ")}.`, + ); + } + return { profile, surface, scope: selectedScope }; +} diff --git a/scripts/plugin-lifecycle/verify.mjs b/scripts/plugin-lifecycle/verify.mjs new file mode 100644 index 0000000..9693d74 --- /dev/null +++ b/scripts/plugin-lifecycle/verify.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { runPluginCommand } from "./cli.mjs"; + +process.exitCode = await runPluginCommand("verify"); diff --git a/test/agent-customize.test.mjs b/test/agent-customize.test.mjs index e3257a3..9828756 100644 --- a/test/agent-customize.test.mjs +++ b/test/agent-customize.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, realpath, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -387,7 +387,7 @@ async function makeQoderFixture() { { name: "list_events" }, ); - return { root, qoderHome, sharedClientCacheRoot, workspace }; + return { root, qoderHome, sharedClientCacheRoot, workspace, betterHarnessRoot }; } async function makeCodexFixture() { @@ -687,7 +687,15 @@ async function makeClaudeFixture() { }, }); - return { root, claudeHome, claudeStatePath, workspace, enabledPluginId, disabledPluginId }; + return { + root, + claudeHome, + claudeStatePath, + workspace, + enabledPluginId, + disabledPluginId, + enabledPluginRoot, + }; } async function makeQwenFixture() { @@ -750,6 +758,27 @@ async function makeQwenFixture() { await writeJson(path.join(qwenHome, "extensions", "extension-enablement.json"), { [disabledPluginName]: { overrides: ["!/*"] }, }); + await mkdir(workspace, { recursive: true }); + const canonicalWorkspace = await realpath(workspace); + await writeJson(path.join(qwenHome, "extension-store", "state.json"), { + version: 2, + generation: 1, + legacyProjectionHash: "0".repeat(64), + extensions: { + ["a".repeat(64)]: { + name: deliveryPluginName, + artifactGeneration: 1, + defaultActivation: "enabled", + workspaceOverrides: {}, + }, + ["b".repeat(64)]: { + name: disabledPluginName, + artifactGeneration: 1, + defaultActivation: "enabled", + workspaceOverrides: { [canonicalWorkspace]: "disabled" }, + }, + }, + }); await writeText( path.join(qwenHome, "skills", "local-review", "SKILL.md"), @@ -803,15 +832,17 @@ async function makeQwenFixture() { "[remote \"origin\"]\n\turl = https://github.com/example/better-harness.git\n", ); - return { root, qwenHome, workspace }; + return { root, qwenHome, workspace, canonicalWorkspace }; } test("collectAgentCustomizeInventory returns Cursor-style manage tabs and scoped sources", async () => { const fixture = await makeCursorFixture(); try { + const stateDbPath = path.join(fixture.root, "isolated", "state.vscdb"); const inventory = await collectAgentCustomizeInventory({ cursorHome: fixture.cursorHome, + stateDbPath, workspace: fixture.workspace, installedPluginRecords: [ { id: "hex-id", sources: ["user"] }, @@ -832,6 +863,7 @@ test("collectAgentCustomizeInventory returns Cursor-style manage tabs and scoped inventory.plugins.map((plugin) => plugin.cursorPluginId), ["hex-id", "paper-id"], ); + assert.equal(inventory.stateDbPath, stateDbPath); const hex = inventory.plugins.find((plugin) => plugin.name === "hex"); const paper = inventory.plugins.find((plugin) => plugin.name === "paper-desktop"); assert.ok(hex); @@ -1194,6 +1226,7 @@ test("collectAgentCustomizeInventory returns Qoder installed plugins and scoped assert.equal(inventory.provider, "qoder"); assert.equal(inventory.qoderHome, fixture.qoderHome); + assert.equal(inventory.qoderSharedClientCacheRoot, fixture.sharedClientCacheRoot); assert.equal(inventory.sharedClientCacheRoot, fixture.sharedClientCacheRoot); assert.deepEqual( inventory.plugins.map((plugin) => plugin.displayName), @@ -1206,7 +1239,7 @@ test("collectAgentCustomizeInventory returns Qoder installed plugins and scoped const betterHarness = inventory.plugins.find((plugin) => plugin.name === "better-harness-plugin"); assert.ok(betterHarness); - assert.deepEqual(betterHarness.installSources, ["user", "project"]); + assert.deepEqual(betterHarness.installSources, ["user", "local"]); assert.equal(betterHarness.installMatch, "qoder-installed-index"); assert.equal(betterHarness.installedAt, "2026-06-23T07:15:07.153Z"); assert.equal(betterHarness.enabled, true); @@ -1247,7 +1280,7 @@ test("collectAgentCustomizeInventory returns Qoder installed plugins and scoped filterManageItems(inventory, { tab: "plugins", scopeKind: "project" }).map( (item) => item.displayName, ), - ["Better Harness"], + [], ); assert.deepEqual(inventory.diagnostics.installedPluginRecordCount, 2); assert.equal(inventory.diagnostics.enabledInstalledPluginCount, 2); @@ -1264,6 +1297,33 @@ test("collectAgentCustomizeInventory returns Qoder installed plugins and scoped } }); +test("Qoder inventory preserves current local scope and excludes explicit foreign local records", async () => { + const fixture = await makeQoderFixture(); + + try { + await writeJson(path.join(fixture.qoderHome, "plugins", "installed_plugins.json"), { plugins: {} }); + await writeJson(path.join(fixture.qoderHome, "plugins", "installed_plugins_v2.json"), { + version: 2, + plugins: { + "better-harness-plugin@local": [{ + scope: "local", + installPath: fixture.betterHarnessRoot, + projectPath: path.join(fixture.root, "foreign-workspace"), + }], + }, + }); + const foreign = await collectAgentCustomizeInventory({ + provider: "qoder", + qoderHome: fixture.qoderHome, + qoderSharedClientCacheRoot: fixture.sharedClientCacheRoot, + workspace: fixture.workspace, + }); + assert.equal(foreign.plugins.some((plugin) => plugin.name === "better-harness-plugin"), false); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + test("Qoder provider collects configured MCPs and uses runtime metadata only as enrichment", async () => { const fixture = await makeQoderFixture(); @@ -1376,6 +1436,49 @@ test("collectAgentCustomizeInventory returns Codex installed plugins from instal } }); +test("Codex inventory honors the native CODEX_HOME environment override", () => { + const codexHome = path.join(os.tmpdir(), "better-harness-codex-env-home"); + const source = ` + import { collectAgentCustomizeInventory } from "./scripts/agent-customize/index.mjs"; + const inventory = await collectAgentCustomizeInventory({ + provider: "codex", + includeUserHome: false, + codexAppPath: "", + workspace: process.cwd(), + }); + process.stdout.write(inventory.codexHome); + `; + const result = spawnSync(process.execPath, ["--input-type=module", "--eval", source], { + cwd: process.cwd(), + env: { ...process.env, CODEX_HOME: codexHome }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, codexHome); +}); + +test("Codex inventory returns and probes an explicitly isolated desktop app route", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-codex-app-route-")); + try { + const codexHome = path.join(root, ".codex"); + const codexAppPath = path.join(root, "Applications", "Codex.app"); + await mkdir(codexAppPath, { recursive: true }); + const inventory = await collectAgentCustomizeInventory({ + provider: "codex", + codexHome, + codexAppPath, + workspace: root, + includeUserHome: false, + }); + assert.equal(inventory.codexHome, codexHome); + assert.equal(inventory.codexAppPath, codexAppPath); + assert.equal(inventory.diagnostics.appBundleExists, true); + assert.deepEqual(inventory.plugins, []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("Codex provider collects user and project MCPs, skills, and hooks", async () => { const fixture = await makeCodexFixture(); @@ -1530,6 +1633,40 @@ test("Claude public configured-asset surfaces exclude disabled Plugin children", } }); +test("Claude inventory preserves local scope and marks explicit foreign local records inapplicable", async () => { + const fixture = await makeClaudeFixture(); + + try { + const record = { + id: fixture.enabledPluginId, + installPath: fixture.enabledPluginRoot, + scope: "local", + projectPath: fixture.workspace, + }; + const current = await collectAgentCustomizeInventory({ + provider: "claude", + claudeHome: fixture.claudeHome, + claudeStatePath: fixture.claudeStatePath, + claudeInstalledPluginRecords: [record], + workspace: fixture.workspace, + }); + assert.deepEqual(current.plugins[0].installSources, ["local"]); + assert.equal(current.plugins[0].applicable, true); + + const foreign = await collectAgentCustomizeInventory({ + provider: "claude", + claudeHome: fixture.claudeHome, + claudeStatePath: fixture.claudeStatePath, + claudeInstalledPluginRecords: [{ ...record, projectPath: path.join(fixture.root, "foreign") }], + workspace: fixture.workspace, + }); + assert.equal(foreign.plugins[0].installSource, "local"); + assert.equal(foreign.plugins[0].applicable, false); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + test("tab availability matches Cursor Customize manage scope rules", () => { assert.equal(tabAvailableForScope("plugins", "team"), true); assert.equal(tabAvailableForScope("mcps", "team"), true); @@ -1598,6 +1735,8 @@ test("collectAgentCustomizeInventory returns Qwen installed plugins from extensi const delivery = inventory.plugins.find((plugin) => plugin.name === "delivery"); assert.ok(delivery); assert.equal(delivery.installMatch, "qwen-extension-install"); + assert.equal(delivery.installSource, "user"); + assert.equal(delivery.originSource, "QwenCode"); assert.equal(delivery.skills[0].name, "ship-release"); assert.equal(delivery.mcpServers[0].name, "deliveryMcp"); assert.equal(delivery.hooks[0].command, "node hooks/audit-delivery.mjs"); @@ -1621,12 +1760,110 @@ test("collectAgentCustomizeInventory returns Qwen installed plugins from extensi assert.deepEqual(inventory.diagnostics.installedPluginRecordFiles.map((file) => path.basename(file)), [ ".qwen-extension-install.json", ".qwen-extension-install.json", + "state.json", ]); } finally { await rm(fixture.root, { recursive: true, force: true }); } }); +test("Qwen inventory derives project scope from extension-store activation state, not UI preferences", async () => { + const fixture = await makeQwenFixture(); + + try { + await writeJson(path.join(fixture.qwenHome, "extensions", "extension-preferences.json"), { + favorites: [], + scopes: { delivery: "user" }, + disabledMcpServers: {}, + }); + await writeJson(path.join(fixture.qwenHome, "extension-store", "state.json"), { + version: 2, + generation: 2, + legacyProjectionHash: "0".repeat(64), + extensions: { + ["a".repeat(64)]: { + name: "delivery", + artifactGeneration: 2, + defaultActivation: "disabled", + workspaceOverrides: { [fixture.canonicalWorkspace]: "enabled" }, + }, + ["b".repeat(64)]: { + name: "disabled-ext", + artifactGeneration: 1, + defaultActivation: "enabled", + workspaceOverrides: { [fixture.canonicalWorkspace]: "disabled" }, + }, + }, + }); + const inventory = await collectAgentCustomizeInventory({ + provider: "qwen", + qwenHome: fixture.qwenHome, + workspace: fixture.workspace, + }); + const delivery = inventory.plugins.find((plugin) => plugin.name === "delivery"); + assert.deepEqual(delivery.installSources, ["project"]); + assert.equal(delivery.installSource, "project"); + assert.equal(delivery.originSource, "QwenCode"); + assert.deepEqual( + filterManageItems(inventory, { tab: "plugins", scopeKind: "project" }).map((item) => item.displayName), + ["Delivery"], + ); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test("Qwen inventory fails closed when v2 scope state is missing, corrupt, or foreign", async () => { + for (const stateKind of ["missing", "corrupt", "foreign"]) { + const fixture = await makeQwenFixture(); + try { + const statePath = path.join(fixture.qwenHome, "extension-store", "state.json"); + if (stateKind === "missing") { + await rm(statePath); + } else if (stateKind === "corrupt") { + await writeJson(statePath, { version: 2, extensions: {} }); + } else { + await writeJson(statePath, { + version: 2, + generation: 2, + legacyProjectionHash: "0".repeat(64), + extensions: { + ["a".repeat(64)]: { + name: "delivery", + artifactGeneration: 2, + defaultActivation: "disabled", + workspaceOverrides: { [path.join(fixture.root, "foreign-workspace")]: "enabled" }, + }, + ["b".repeat(64)]: { + name: "disabled-ext", + artifactGeneration: 1, + defaultActivation: "enabled", + workspaceOverrides: { [fixture.canonicalWorkspace]: "disabled" }, + }, + }, + }); + } + await writeJson(path.join(fixture.qwenHome, "extensions", "extension-preferences.json"), { + scopes: { delivery: "user" }, + }); + const inventory = await collectAgentCustomizeInventory({ + provider: "qwen", + qwenHome: fixture.qwenHome, + workspace: fixture.workspace, + }); + const delivery = inventory.plugins.find((plugin) => plugin.name === "delivery"); + assert.deepEqual(delivery.installSources, [stateKind === "foreign" ? "foreign" : "unknown"]); + assert.equal( + inventory.diagnostics.installedPluginScopeState, + stateKind === "foreign" ? "extension-store-v2" : (stateKind === "corrupt" ? "invalid" : stateKind), + ); + assert.equal(inventory.diagnostics.installedPluginScopeStateFile, statePath); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + } +}); + test("Qwen provider collects user and project MCPs, skills, hooks, and rules", async () => { const fixture = await makeQwenFixture(); @@ -1764,12 +2001,15 @@ test("Copilot inventory separates plugin, user, and project assets", async () => assert.equal(inventory.provider, "copilot"); assert.equal(inventory.copilotHome, fixture.copilotHome); + assert.equal(inventory.copilotUserHome, fixture.root); assert.equal(inventory.diagnostics.installedPluginState, "copilot-config"); // The record whose cache path is absent must not become an installed plugin. assert.equal(inventory.plugins.length, 1); assert.equal(inventory.plugins[0].id, "acme/delivery"); assert.equal(inventory.plugins[0].installMatch, "copilot-marketplace"); + assert.deepEqual(inventory.plugins[0].installSources, ["user"]); + assert.equal(inventory.plugins[0].originSource, "marketplace"); assert.equal(inventory.plugins[0].skills.length, 1); const skillNames = inventory.manage.skills.map((skill) => skill.name).sort(); @@ -1924,7 +2164,13 @@ test("collectAgentCustomizeInventory returns Pi packages and extensions as plugi assert.equal(inventory.provider, "pi"); assert.equal(inventory.piHome, fixture.piHome); + assert.equal(inventory.piUserHome, os.homedir()); assert.equal(inventory.plugins.length, 3); + assert.equal( + inventory.plugins.every((plugin) => plugin.installSources.every((scope) => ["user", "project"].includes(scope))), + true, + "persistent Pi inventory must not manufacture session-scoped installation evidence", + ); const delivery = inventory.plugins.find((plugin) => plugin.name === "@scope/delivery-pack"); assert.ok(delivery); @@ -2210,11 +2456,14 @@ test("collectAgentCustomizeInventory returns WorkBuddy marketplace plugins with assert.equal(inventory.provider, "workbuddy"); assert.equal(inventory.workbuddyHome, fixture.workbuddyHome); + assert.equal(inventory.workbuddyUserHome, fixture.root); assert.equal(inventory.plugins.length, 2); const findSkills = inventory.plugins.find((plugin) => plugin.name === "find-skills"); assert.ok(findSkills); assert.equal(findSkills.installMatch, "workbuddy-marketplace-dir"); + assert.deepEqual(findSkills.installSources, ["user"]); + assert.equal(findSkills.originSource, "marketplace"); assert.equal(findSkills.enabled, true); assert.equal(findSkills.version, "1.0.0"); assert.deepEqual(findSkills.skills.map((skill) => skill.name), ["find-skills"]); diff --git a/test/better-harness-cli.test.mjs b/test/better-harness-cli.test.mjs index b1f20c1..d451dc7 100644 --- a/test/better-harness-cli.test.mjs +++ b/test/better-harness-cli.test.mjs @@ -56,6 +56,7 @@ test("better-harness CLI expands help by audience without changing command acces assert.equal(advanced.status, 0, advanced.stderr); assert.match(advanced.stdout, /agent-customize/); assert.match(advanced.stdout, /session-analysis/); + assert.match(advanced.stdout, /plugin/); assert.doesNotMatch(advanced.stdout, /core-change-watch/); const maintainer = runBetterHarness(["--help", "--audience", "maintainer"]); @@ -63,6 +64,12 @@ test("better-harness CLI expands help by audience without changing command acces assert.match(maintainer.stdout, /core-change-watch/); }); +test("better-harness CLI exposes doctor in workflow help", () => { + const result = runBetterHarness(["--help"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /doctor/u); +}); + test("delegated session-analysis and checkup help stay privacy-safe with extra options", () => { const privateRoot = path.join(os.tmpdir(), "harness-help-must-not-be-scanned"); const commands = [ @@ -237,6 +244,185 @@ test("better-harness CLI exposes command inventory as JSON", () => { sessionAnalysis.subcommands.find((subcommand) => subcommand.name === "usage-summary").script, "scripts/session-analysis/usage-summary.mjs", ); + + const plugin = payload.data.commands.find((command) => command.name === "plugin"); + assert.equal(plugin.kind, "group"); + assert.equal(plugin.audience, "advanced"); + assert.deepEqual(plugin.subcommands.map((subcommand) => subcommand.name), ["status", "plan", "verify"]); + assert.equal(plugin.subcommands[0].script, "scripts/plugin-lifecycle/status.mjs"); + + const doctor = payload.data.commands.find((command) => command.name === "doctor"); + assert.equal(doctor.kind, "direct"); + assert.equal(doctor.audience, "workflow"); + assert.equal(doctor.script, "scripts/harness-doctor/cli.mjs"); +}); + +test("plugin help returns before reading host inventory", () => { + const privateRoot = path.join(os.tmpdir(), "plugin-help-must-not-be-scanned"); + for (const subcommand of ["status", "plan", "verify"]) { + const result = runBetterHarness([ + "plugin", + subcommand, + "--host", + "does-not-exist", + "--host-home", + privateRoot, + "--help", + ]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.match(result.stdout, new RegExp(`Better Harness plugin ${subcommand}`, "u")); + assert.doesNotMatch(result.stdout, new RegExp(privateRoot.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u")); + } +}); + +test("plugin status emits command-contract JSON and partial exit for missing local evidence", () => { + const result = runBetterHarness([ + "plugin", + "status", + "--host", + "qwen", + "--host-home", + path.join(os.tmpdir(), "better-harness-empty-qwen-home"), + "--workspace", + process.cwd(), + "--json", + ]); + assert.equal(result.status, 2, result.stderr); + assert.equal(result.stderr, ""); + const payload = JSON.parse(result.stdout); + assert.equal(payload.schemaVersion, "1"); + assert.equal(payload.command, "better-harness plugin status"); + assert.equal(payload.status, "partial"); + assert.equal(payload.meta.sideEffects, "read-only"); + assert.equal(payload.data.rows[0].target.hostId, "qwen"); + assert.equal(payload.data.rows[0].installation, "not-installed"); +}); + +test("plugin plan requires an explicit host and preserves JSON mode after usage failure", () => { + const result = runBetterHarness(["plugin", "plan", "install", "--json"]); + assert.equal(result.status, 64); + assert.equal(result.stderr, ""); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "failed"); + assert.equal(payload.data, null); + assert.equal(payload.diagnostics[0].code, "EXPLICIT_HOST_REQUIRED"); +}); + +test("plugin plan rejects ambiguous multi-surface hosts", () => { + for (const host of ["codex", "pi"]) { + const result = runBetterHarness([ + "plugin", + "plan", + "install", + "--host", + host, + "--host-home", + path.join(os.tmpdir(), `better-harness-empty-${host}-home`), + "--json", + ]); + assert.equal(result.status, 64, host); + const payload = JSON.parse(result.stdout); + assert.equal(payload.diagnostics[0].code, "AMBIGUOUS_HOST_SURFACE", host); + } +}); + +test("plugin plan emits typed argv without executing it", () => { + const result = runBetterHarness([ + "plugin", + "plan", + "--json", + "install", + "--host", + "qwen", + "--surface", + "cli", + "--scope", + "project", + "--host-home", + path.join(os.tmpdir(), "better-harness-empty-qwen-plan-home"), + ]); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "ok"); + assert.equal(payload.data.effects, "none"); + assert.deepEqual(payload.data.steps[0].argv, [ + "qwen", "extensions", "install", "QoderAI/better-harness", "--scope", "project", + ]); + assert.equal(payload.data.steps[0].externalImpact, "host-plugin-state"); + assert.equal(payload.data.steps[0].preconditions.length, 2); +}); + +test("plugin parsers fail closed for duplicate, misplaced, and extra arguments", () => { + const cases = [ + { + args: ["plugin", "status", "--json", "--json"], + code: "DUPLICATE_OPTION", + }, + { + args: ["plugin", "status", "--scope", "user", "--json"], + code: "OPTION_NOT_ALLOWED", + }, + { + args: ["plugin", "verify", "extra", "--json"], + code: "UNEXPECTED_POSITIONAL", + }, + { + args: ["plugin", "status", "--unknown", "--json"], + code: "UNKNOWN_OPTION", + }, + { + args: ["plugin", "status", "--host=", "--json"], + code: "MISSING_OPTION_VALUE", + }, + { + args: ["plugin", "plan", "install", "--host", "codex", "--surface", "desktop", "--scope", "project", "--json"], + code: "UNSUPPORTED_SCOPE", + }, + ]; + for (const entry of cases) { + const result = runBetterHarness(entry.args); + assert.equal(result.status, 64, `${entry.code}: ${result.stderr}`); + assert.equal(result.stderr, ""); + const payload = JSON.parse(result.stdout); + assert.equal(payload.diagnostics[0].code, entry.code); + assert.equal(payload.meta.sideEffects, "read-only"); + } +}); + +test("plugin plans fail closed for unbound isolated and stale native contracts", () => { + const qoder = runBetterHarness([ + "plugin", "plan", "install", + "--host", "qoder", "--surface", "cli", "--scope", "user", + "--host-home", path.join(os.tmpdir(), "better-harness-empty-qoder-plan-home"), + "--json", + ]); + assert.equal(qoder.status, 1, qoder.stderr); + const qoderPayload = JSON.parse(qoder.stdout); + assert.equal(qoderPayload.data.state, "blocked"); + assert.equal(qoderPayload.data.blockers[0].code, "ISOLATED_HOST_HOME_UNREPRESENTABLE"); + assert.deepEqual(qoderPayload.data.steps, []); + + const cursor = runBetterHarness([ + "plugin", "plan", "install", + "--host", "cursor", "--surface", "agent", "--scope", "session", + "--host-home", path.join(os.tmpdir(), "better-harness-empty-cursor-plan-home"), + "--json", + ]); + assert.equal(cursor.status, 1, cursor.stderr); + const cursorPayload = JSON.parse(cursor.stdout); + assert.equal(cursorPayload.data.state, "blocked"); + assert.equal(cursorPayload.data.blockers[0].code, "HOST_CONTRACT_STALE"); + assert.deepEqual(cursorPayload.data.steps, []); +}); + +test("plugin apply is not registered", () => { + const result = runBetterHarness(["plugin", "apply", "--json"]); + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ok, false); + assert.equal(payload.error.code, "UNKNOWN_SUBCOMMAND"); }); test("better-harness CLI filters machine inventory by audience", () => { @@ -244,7 +430,7 @@ test("better-harness CLI filters machine inventory by audience", () => { assert.equal(workflow.status, 0, workflow.stderr); const workflowPayload = JSON.parse(workflow.stdout); assert.equal(workflowPayload.data.audience, "workflow"); - assert.deepEqual(workflowPayload.data.commands.map((command) => command.name), ["harness", "report"]); + assert.deepEqual(workflowPayload.data.commands.map((command) => command.name), ["doctor", "harness", "report"]); assert.deepEqual( workflowPayload.data.commands.find((command) => command.name === "harness").subcommands.map((entry) => entry.name), ["evidence-bundle", "analyze", "checkup", "record-fix-output"], @@ -254,6 +440,7 @@ test("better-harness CLI filters machine inventory by audience", () => { assert.equal(advanced.status, 0, advanced.stderr); const advancedCommands = JSON.parse(advanced.stdout).data.commands; assert.equal(advancedCommands.some((command) => command.name === "agent-customize"), true); + assert.equal(advancedCommands.some((command) => command.name === "plugin"), true); assert.equal(advancedCommands.some((command) => command.name === "core-change-watch"), false); const harness = advancedCommands.find((command) => command.name === "harness"); assert.equal(harness.subcommands.some((subcommand) => subcommand.name === "render"), true); diff --git a/test/docs-dx.test.mjs b/test/docs-dx.test.mjs index dfe42cf..392096c 100644 --- a/test/docs-dx.test.mjs +++ b/test/docs-dx.test.mjs @@ -67,12 +67,25 @@ test("installation prerequisites and verification paths stay aligned across loca assert.match(content, /claude plugin details better-harness@better-harness/u); assert.match(content, /codex plugin list --marketplace better-harness/u); assert.match(content, /qodercli plugin list/u); + assert.match(content, /qwen extensions list/u); assert.match(content, /copilot plugin list/u); assert.match(content, /copilot skill list/u); - assert.doesNotMatch(content, /qwen extensions list/u); } }); +test("Pi single-run README guidance keeps temporary loading in the same session", () => { + const readme = readUtf8("README.md"); + const readmeZh = readUtf8("README.zh-CN.md"); + + for (const content of [readme, readmeZh]) { + assert.match(content, /pi -e git:github\.com\/QoderAI\/better-harness/u); + } + assert.match(readme, /run the report\n?prompt in that same Pi session/u); + assert.match(readme, /opening another session drops the temporary\npackage/u); + assert.match(readmeZh, /在同一 Pi 会话中运行报告\n提示词/u); + assert.match(readmeZh, /另开会话会丢失临时加载的包/u); +}); + test("troubleshooting is bilingual, safe, linked, and routed through Getting Started", () => { const troubleshooting = readUtf8("docs", "docs", "troubleshooting.md"); const troubleshootingZh = readUtf8(...ZH_DOCS_ROOT, "troubleshooting.md"); @@ -91,10 +104,20 @@ test("troubleshooting is bilingual, safe, linked, and routed through Getting Sta assert.match(content, /INVALID_CWD/u); assert.match(content, /--qoder-home/u); assert.match(content, /\.copilot\/better-harness/u); + assert.match(content, /qwen extensions list/u); + assert.doesNotMatch(content, /--plugin-dir/u); assert.doesNotMatch(content, /rm\s+-rf|Remove-Item|del\s+\/s/iu); } }); +test("Cursor adapter guidance does not advertise an unavailable install flag", () => { + const adapters = readUtf8("docs", "adapters", "README.md"); + + assert.match(adapters, /native `cursor-agent --help` contract check/u); + assert.match(adapters, /unavailable install plan/u); + assert.doesNotMatch(adapters, /--plugin-dir/u); +}); + test("first-report guidance no longer claims one invocation works for every host", () => { const firstReport = readUtf8("docs", "docs", "your-first-report.md"); const firstReportZh = readUtf8(...ZH_DOCS_ROOT, "your-first-report.md"); @@ -128,6 +151,7 @@ test("bug report intake does not hard-code a release and covers current host pat assert.match(issueForm, new RegExp(`- ${host.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}`)); } assert.match(issueForm, /Host marketplace or plugin manager/u); - assert.match(issueForm, /Cursor source-local --plugin-dir/u); + assert.match(issueForm, /Cursor session evidence \(installation unavailable\)/u); + assert.doesNotMatch(issueForm, /--plugin-dir/u); assert.match(issueForm, /npm package or standalone CLI/u); }); diff --git a/test/fixtures/plugin-lifecycle/native-help-contracts.v1.json b/test/fixtures/plugin-lifecycle/native-help-contracts.v1.json new file mode 100644 index 0000000..1592c99 --- /dev/null +++ b/test/fixtures/plugin-lifecycle/native-help-contracts.v1.json @@ -0,0 +1,122 @@ +{ + "schemaVersion": "1", + "observedAt": "2026-07-31", + "privacy": "command names, versions, option contracts, and environment variable names only; executable paths and user data omitted", + "hosts": { + "claude": { + "executable": "claude", + "version": "2.1.214", + "observation": "native-help", + "nativeHomeBinding": { + "evidenceId": "claude-config-dir-environment", + "kind": "environment", + "variable": "CLAUDE_CONFIG_DIR", + "observation": "installed provider source and isolated config smoke" + }, + "contracts": [ + "plugin install --scope ", + "plugin update --scope ", + "plugin uninstall --scope ", + "plugin details " + ] + }, + "codex": { + "executable": "codex", + "version": "0.144.5", + "observation": "native-help", + "nativeHomeBinding": { + "evidenceId": "codex-home-environment", + "kind": "environment", + "variable": "CODEX_HOME", + "observation": "codex-rs config loader source and isolated config tests" + }, + "contracts": [ + "plugin marketplace add --ref ", + "plugin add ", + "plugin list --marketplace --json", + "plugin marketplace upgrade ", + "plugin remove " + ] + }, + "qoder": { + "executable": "qodercli", + "version": "1.0.20", + "observation": "native-help-with-drift", + "contracts": [ + "plugin install --scope ", + "plugin list --json", + "plugin uninstall --scope " + ], + "missingContracts": [ + "plugin marketplace add", + "plugin update" + ] + }, + "cursor": { + "executable": "cursor-agent", + "version": "2025.09.18-7ae6800", + "observation": "native-help-with-drift", + "contracts": [], + "missingContracts": [ + "--plugin-dir" + ] + }, + "qwen": { + "executable": "qwen", + "version": "0.21.0", + "observation": "native-help", + "nativeHomeBinding": { + "evidenceId": "qwen-home-environment", + "kind": "environment", + "variable": "QWEN_HOME", + "observation": "installed provider and extension runtime source" + }, + "scopeArtifactPolicy": { + "evidenceId": "qwen-shared-extension-artifact", + "kind": "shared", + "observation": "extension manager source uses one extension directory and activation state store" + }, + "contracts": [ + "extensions install --scope ", + "extensions list", + "extensions update ", + "extensions uninstall " + ] + }, + "copilot": { + "executable": "copilot", + "version": "1.0.74-3", + "observation": "native-help", + "contracts": [ + "plugin marketplace add ", + "plugin install ", + "plugin list", + "plugin update ", + "plugin uninstall ", + "skill list --json" + ] + }, + "pi": { + "executable": "pi", + "version": null, + "observation": "unobserved", + "contracts": [], + "installedPackageSource": { + "package": "@earendil-works/pi-coding-agent", + "version": "0.83.0", + "observation": "installed-package-source", + "nativeExecutableObservation": "unobserved", + "evidence": [ + { + "evidenceId": "pi-persistent-package-settings-source", + "contract": "package manager persists user and project package sources in their respective settings" + }, + { + "evidenceId": "pi-temporary-extension-source", + "contract": "-e/--extension resolves package resources with temporary scope for the current process" + } + ] + } + } + } +} diff --git a/test/fixtures/scripts-refactor-contract/root-help.txt b/test/fixtures/scripts-refactor-contract/root-help.txt index 89dd275..cc8e078 100644 --- a/test/fixtures/scripts-refactor-contract/root-help.txt +++ b/test/fixtures/scripts-refactor-contract/root-help.txt @@ -12,6 +12,7 @@ Commands: Quickstart report Gather static evidence and hand off to the Better Harness skill for a five-minute readiness report + doctor Run bounded read-only Better Harness runtime and host diagnostics Workflows harness Check Better Harness readiness reports and Canvas outputs @@ -30,6 +31,9 @@ Commands: Agent Assets agent-customize Inspect agent-facing Skills, MCPs, hooks, plugins, settings, and memory surfaces + plugin Inspect, plan, and verify the Better Harness plugin lifecycle + without applying changes + subcommands: status, plan, verify agent-lint Parse AGENTS.md and local Markdown references for agent-instruction linting coding-agent-practices Inspect coding-agent assets and practice evidence @@ -43,6 +47,7 @@ Commands: Examples: better-harness report + better-harness doctor --platform all --json better-harness harness analyze --workspace . --language en --format json better-harness harness checkup --phase scan --provider qoder --workspace . --json better-harness harness render --findings /findings.json --mode qoder-canvas --out .qoder/better-harness --target . --validate --json diff --git a/test/harness-doctor.test.mjs b/test/harness-doctor.test.mjs new file mode 100644 index 0000000..ad8cb62 --- /dev/null +++ b/test/harness-doctor.test.mjs @@ -0,0 +1,372 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmod, mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { renderDoctor } from "../scripts/harness-doctor/cli.mjs"; +import { runHarnessDoctor } from "../scripts/harness-doctor/index.mjs"; +import { discoverHostExecutable } from "../scripts/plugin-lifecycle/index.mjs"; + +const cliPath = path.join(process.cwd(), "scripts", "better-harness.mjs"); + +function runCli(args) { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd: process.cwd(), + encoding: "utf8", + }); +} + +const packageInfo = { + name: "@qoderai/better-harness", + version: "0.4.0", + nodeRange: ">=22", + npmRange: ">=10", +}; + +function installedPlugin(overrides = {}) { + return { + id: "better-harness@better-harness", + name: "better-harness", + version: packageInfo.version, + enabled: true, + installSources: ["user"], + skills: [{ name: "better-harness" }], + evidence: { path: "/fixture/better-harness/plugin.json" }, + ...overrides, + }; +} + +test("doctor reports bounded runtime, privacy, and authorized-root facts", async () => { + const result = await runHarnessDoctor({ + platform: "claude", + workspace: process.cwd(), + packageInfo: { + name: "@qoderai/better-harness", + version: "9.9.9", + nodeRange: ">=22", + npmRange: ">=10", + }, + hostDiscovery: async () => "absent", + inventoryCollector: async ({ provider }) => ({ provider, plugins: [], diagnostics: {} }), + }); + assert.equal(result.runtime.version, "9.9.9"); + assert.equal(result.privacy.network, "none"); + assert.equal(result.privacy.writes, "none"); + assert.equal(result.privacy.transcripts, "not-read"); + assert.deepEqual(result.targets[0].authorizedRoots, ["~/.claude", "~/.claude.json"]); + assert.equal(result.targets[0].hostDiscovery, "absent"); +}); + +test("doctor preserves per-surface discovery when a host summary is present", async () => { + const result = await runHarnessDoctor({ + platform: "codex", + workspace: process.cwd(), + packageInfo, + hostDiscovery: async () => "absent", + inventoryCollector: async ({ provider }) => ({ + provider, + plugins: [], + diagnostics: { appBundleExists: true }, + }), + }); + + const target = result.targets[0]; + assert.equal(target.hostDiscovery, "present"); + assert.deepEqual( + target.lifecycle.map(({ surfaceId, scope, hostDiscovery }) => ({ surfaceId, scope, hostDiscovery })), + [ + { surfaceId: "cli", scope: "user", hostDiscovery: "absent" }, + { surfaceId: "desktop", scope: "user", hostDiscovery: "present" }, + ], + ); + + const rendered = renderDoctor(result); + assert.match(rendered, /cli@user\[absent\]:/u); + assert.match(rendered, /desktop@user\[present\]:/u); +}); + +test("doctor preserves independent rows for multiple scopes on one surface", async () => { + const result = await runHarnessDoctor({ + platform: "claude", + workspace: process.cwd(), + packageInfo, + hostDiscovery: async () => "present", + inventoryCollector: async ({ provider }) => ({ + provider, + plugins: [ + installedPlugin({ id: "better-harness-user", installSources: ["user"] }), + installedPlugin({ id: "better-harness-project", installSources: ["project"] }), + ], + diagnostics: {}, + }), + }); + + assert.deepEqual( + result.targets[0].lifecycle.map(({ surfaceId, scope, hostDiscovery }) => ({ + surfaceId, + scope, + hostDiscovery, + })), + [ + { surfaceId: "cli", scope: "user", hostDiscovery: "present" }, + { surfaceId: "cli", scope: "project", hostDiscovery: "present" }, + ], + ); + assert.match(renderDoctor(result), /cli@user\[present\]:.*cli@project\[present\]:/u); +}); + +test("doctor keeps Pi persistent and session-only surfaces distinct", async () => { + const result = await runHarnessDoctor({ + platform: "pi", + workspace: process.cwd(), + packageInfo, + hostDiscovery: async () => "present", + inventoryCollector: async ({ provider }) => ({ provider, plugins: [], diagnostics: {} }), + }); + + assert.deepEqual( + result.targets[0].lifecycle.map(({ surfaceId, scope, installation, verification }) => ({ + surfaceId, + scope, + installation, + verification, + })), + [ + { surfaceId: "cli", scope: "user", installation: "not-installed", verification: "failed" }, + { surfaceId: "cli-session", scope: "session", installation: "session-only", verification: "unobserved" }, + ], + ); + assert.match(renderDoctor(result), /cli@user\[present\]:not-installed\/failed/u); + assert.match(renderDoctor(result), /cli-session@session\[present\]:session-only\/unobserved/u); + + const failedInventory = await runHarnessDoctor({ + platform: "pi", + workspace: process.cwd(), + packageInfo, + hostDiscovery: async () => "present", + inventoryCollector: async () => { + throw new Error("persistent Pi inventory is unreadable"); + }, + }); + assert.equal(failedInventory.targets[0].inventory, "failed"); + assert.deepEqual( + failedInventory.targets[0].lifecycle.map(({ surfaceId, scope, installation, verification }) => ({ + surfaceId, + scope, + installation, + verification, + })), + [ + { surfaceId: "cli", scope: "user", installation: "unknown", verification: "unobserved" }, + { surfaceId: "cli-session", scope: "session", installation: "session-only", verification: "unobserved" }, + ], + ); + assert.match(renderDoctor(failedInventory), /cli@user\[present\]:unknown\/unobserved/u); + assert.match(renderDoctor(failedInventory), /cli-session@session\[present\]:session-only\/unobserved/u); +}); + +test("doctor keeps the host selector separate from Windows executable discovery", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-doctor-win32-")); + try { + const commandPath = path.join(root, "claude.cmd"); + await writeFile(commandPath, "@echo off\r\n"); + const doctorOptions = { + platform: "claude", + runtimePlatform: "win32", + env: { Path: `${path.join(root, "missing")};${root}`, PathExt: " EXE ; CMD ; .cmd " }, + includeUserHome: false, + inventoryCollector: async ({ provider }) => ({ provider, plugins: [], diagnostics: {} }), + }; + const result = await runHarnessDoctor(doctorOptions); + assert.equal(result.targets[0].hostId, "claude"); + assert.equal(result.targets[0].hostDiscovery, "present"); + assert.equal(await discoverHostExecutable( + { executables: ["claude.cmd"] }, + { runtimePlatform: "win32", env: doctorOptions.env }, + ), "present"); + + await unlink(commandPath); + await writeFile(path.join(root, "claude"), "not a Windows command\n"); + const bareResult = await runHarnessDoctor(doctorOptions); + assert.equal(bareResult.targets[0].hostDiscovery, "absent"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("doctor reports the effective redacted host-home override", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "better-harness-doctor-roots-")); + try { + const hostHome = path.join(workspace, "isolated claude home"); + await mkdir(hostHome); + const result = await runHarnessDoctor({ + platform: "claude", + hostHome, + workspace, + env: { PATH: "" }, + includeUserHome: false, + inventoryCollector: async ({ provider }) => ({ provider, plugins: [], diagnostics: {} }), + }); + assert.deepEqual(result.targets[0].authorizedRoots, [ + "/isolated claude home", + "/isolated claude home/.claude.json", + ]); + assert.doesNotMatch(JSON.stringify(result.targets), new RegExp(workspace, "u")); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("doctor reports every collector-resolved root and uses declared fallbacks on failure", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "better-harness-doctor-captured-roots-")); + try { + const claudeHome = path.join(workspace, "custom claude"); + const claudeStatePath = path.join(workspace, "custom state.json"); + const captured = await runHarnessDoctor({ + platform: "claude", + workspace, + hostDiscovery: async () => "absent", + inventoryCollector: async ({ provider }) => ({ + provider, + claudeHome, + claudeStatePath, + plugins: [], + diagnostics: {}, + }), + }); + assert.deepEqual(captured.targets[0].authorizedRoots, [ + "/custom claude", + "/custom state.json", + ]); + + const failed = await runHarnessDoctor({ + platform: "qoder", + workspace, + hostDiscovery: async () => "absent", + inventoryCollector: async () => { + throw new Error("fixture inventory failure"); + }, + }); + assert.equal(failed.targets[0].inventory, "failed"); + assert.deepEqual(failed.targets[0].authorizedRoots, [ + "~/.qoder", + "/Qoder/SharedClientCache", + ]); + + const failedCodex = await runHarnessDoctor({ + platform: "codex", + workspace, + hostDiscovery: async () => "absent", + inventoryCollector: async () => { + throw new Error("fixture inventory failure"); + }, + }); + assert.deepEqual(failedCodex.targets[0].authorizedRoots, [ + "~/.codex", + "/Codex.app", + ]); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("doctor captures and redacts the isolated Codex CLI and desktop roots", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "better-harness-doctor-codex-roots-")); + try { + const hostHome = path.join(workspace, "isolated codex home"); + let capturedOptions; + const result = await runHarnessDoctor({ + platform: "codex", + workspace, + hostHome, + hostDiscovery: async () => "absent", + inventoryCollector: async (providerOptions) => { + capturedOptions = providerOptions; + return { + provider: providerOptions.provider, + codexHome: providerOptions.codexHome, + codexAppPath: providerOptions.codexAppPath, + plugins: [], + diagnostics: { appBundleExists: false }, + }; + }, + }); + assert.equal(capturedOptions.codexHome, hostHome); + assert.equal(capturedOptions.codexAppPath, path.join(hostHome, "Applications", "Codex.app")); + assert.deepEqual(result.targets[0].authorizedRoots, [ + "/isolated codex home", + "/isolated codex home/Applications/Codex.app", + ]); + assert.doesNotMatch(JSON.stringify(result.targets), new RegExp(workspace, "u")); + const desktop = result.targets[0].lifecycle.find((surface) => surface.surfaceId === "desktop"); + assert.equal(result.targets[0].hostDiscovery, "absent"); + assert.equal(desktop.installation, "unknown"); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("host discovery rejects directories named like executables", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-doctor-directory-")); + try { + await mkdir(path.join(root, "claude")); + const result = await discoverHostExecutable( + { executables: ["claude"] }, + { platform: "linux", env: { PATH: root } }, + ); + assert.equal(result, "absent"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("POSIX host discovery requires executable permission", { + skip: process.platform === "win32" ? "POSIX mode bits are unavailable on Windows" : false, +}, async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-doctor-mode-")); + try { + const executable = path.join(root, "claude"); + await writeFile(executable, "#!/bin/sh\nexit 0\n", { mode: 0o644 }); + assert.equal(await discoverHostExecutable( + { executables: ["claude"] }, + { platform: "linux", env: { PATH: root } }, + ), "absent"); + await chmod(executable, 0o755); + assert.equal(await discoverHostExecutable( + { executables: ["claude"] }, + { platform: "linux", env: { PATH: root } }, + ), "present"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +test("doctor help is side-effect-free even with invalid host inputs", () => { + const result = runCli([ + "doctor", + "--platform", + "does-not-exist", + "--host-home", + "/private/path/must-not-be-read", + "--help", + ]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.match(result.stdout, /Better Harness doctor/u); + assert.doesNotMatch(result.stdout, /private\/path/u); +}); + +test("doctor emits one JSON usage envelope and exit 64", () => { + const result = runCli(["doctor", "--platform", "--json"]); + assert.equal(result.status, 64); + assert.equal(result.stderr, ""); + const payload = JSON.parse(result.stdout); + assert.equal(payload.schemaVersion, "1"); + assert.equal(payload.command, "better-harness doctor"); + assert.equal(payload.status, "failed"); + assert.equal(payload.data, null); + assert.equal(payload.artifacts.length, 0); + assert.equal(payload.diagnostics[0].code, "MISSING_OPTION_VALUE"); + assert.equal(payload.meta.sideEffects, "read-only"); +}); diff --git a/test/host-support-profile-model.test.mjs b/test/host-support-profile-model.test.mjs new file mode 100644 index 0000000..1873825 --- /dev/null +++ b/test/host-support-profile-model.test.mjs @@ -0,0 +1,256 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + defineHostProfile, + defineSurface, + desktopUi, + evidence, + hostCommand, + manual, + operation, + shell, +} from "../scripts/host-support/profile-builders.mjs"; +import { + validateContractEvidence, + validateHostProfile, + validateHostProfileRegistry, +} from "../scripts/host-support/profile-model.mjs"; + +const HOSTS = ["claude", "codex", "qoder", "cursor", "qwen", "copilot", "pi", "workbuddy"]; + +function validSurface(overrides = {}) { + return defineSurface({ + surfaceId: "cli", + displayName: "Example CLI", + distributionKind: "package", + scopes: ["user"], + defaultScope: "user", + lifecycle: { + install: operation("supported", [shell(["example", "install"], "installed")], evidence("example-install")), + update: operation("unavailable"), + remove: operation("unavailable"), + verify: operation("supported", [shell(["example", "list"], "listed")], evidence("example-list")), + }, + ...overrides, + }); +} + +function validProfile(overrides = {}) { + return defineHostProfile({ + hostId: "example", + aliases: [], + displayName: "Example", + managed: true, + executables: ["example"], + surfaces: [validSurface()], + ...overrides, + }); +} + +test("typed builders reject malformed evidence and every instruction representation locally", () => { + assert.throws(() => evidence(""), /evidence id/u); + assert.throws( + () => validateContractEvidence({ id: "x", kind: "help", observedAt: "today", fixture: "fixture.json" }), + /evidence date/u, + ); + assert.throws(() => shell("example list", "listed"), /argv array/u); + assert.throws(() => manual("", "done"), /instruction is missing/u); + assert.throws(() => hostCommand("", "done"), /host command is missing/u); + assert.throws(() => desktopUi("", "done"), /instruction is missing/u); + assert.throws( + () => shell(["example", "list"], "listed", { command: "example list" }), + /exactly one typed instruction/u, + ); + const protectedShell = shell(["example", "list"], "listed", { + kind: "manual", + argv: ["other"], + expected: "other", + }); + assert.deepEqual(protectedShell, { + kind: "shell", + argv: ["example", "list"], + expected: "listed", + }); +}); + +test("operation and surface builders fail before aggregate registry loading", () => { + const step = manual("Open the host UI.", "The plugin is visible."); + assert.throws(() => operation("manual", [step]), /lacks evidence/u); + assert.throws(() => operation("manual", [], evidence("manual-empty")), /require steps/u); + assert.throws(() => operation("unavailable", [step]), /cannot declare steps/u); + + const base = validSurface(); + assert.throws( + () => defineSurface({ ...structuredClone(base), lifecycle: { ...structuredClone(base.lifecycle), apply: operation("unavailable") } }), + /operations must be exact/u, + ); + assert.throws( + () => defineSurface({ ...structuredClone(base), scopes: ["user", "user"] }), + /repeats scopes/u, + ); + assert.throws( + () => defineSurface({ ...structuredClone(base), scopeValues: { project: "project" } }), + /not declared/u, + ); + assert.throws( + () => defineSurface({ + ...structuredClone(base), + observation: { kind: "inventory", discoverySource: "diagnostic" }, + }), + /requires a diagnostic/u, + ); + assert.throws( + () => defineSurface({ + ...structuredClone(base), + observation: { kind: "inventory", discoverySource: "unknown" }, + }), + /discovery source/u, + ); +}); + +test("host builders validate local identity and deeply freeze every declaration", async () => { + assert.throws(() => validProfile({ hostId: "Example" }), /host id/u); + assert.throws(() => validProfile({ aliases: ["Bad Alias"] }), /host alias/u); + assert.throws(() => validProfile({ executables: [] }), /Managed host has no executable/u); + + const profile = (await import("../scripts/host-support/profiles/claude.mjs")).default; + const surface = profile.surfaces[0]; + const lifecycleOperation = surface.lifecycle.install; + const step = lifecycleOperation.steps[1]; + for (const value of [ + profile, + profile.aliases, + profile.identity, + profile.identity.nativeIds, + profile.inventoryHomeRoutes, + profile.inventoryHomeRoutes[0], + profile.surfaces, + surface, + surface.scopes, + surface.lifecycle, + lifecycleOperation, + lifecycleOperation.steps, + lifecycleOperation.contractEvidence, + step, + step.argv, + ]) { + assert.equal(Object.isFrozen(value), true); + } + assert.throws(() => surface.scopes.push("session"), TypeError); + assert.throws(() => { step.argv[0] = "other"; }, TypeError); +}); + +test("inventory home routes stay unique and inside the isolated root", () => { + assert.doesNotThrow(() => validProfile({ + inventoryHomeRoutes: [ + { option: "exampleHome", relativePath: "", fallbackLabel: "~/.example" }, + { + option: "exampleAppPath", + relativePath: "Applications/Example.app", + fallbackLabel: "/Example.app", + }, + ], + })); + assert.throws( + () => validProfile({ + inventoryHomeRoutes: [ + { option: "exampleHome", relativePath: "", fallbackLabel: "~/.example" }, + { option: "exampleState", relativePath: "../real-home.json", fallbackLabel: "~/.example-state" }, + ], + }), + /must stay inside/u, + ); + assert.throws( + () => validProfile({ + inventoryHomeRoutes: [ + { option: "exampleHome", relativePath: "", fallbackLabel: "~/.example" }, + { option: "exampleHome", relativePath: "state", fallbackLabel: "~/.example-state" }, + ], + }), + /Duplicate inventory home route/u, + ); + assert.throws( + () => validProfile({ + inventoryHomeRoutes: [{ option: "exampleState", relativePath: "state", fallbackLabel: "~/.example-state" }], + }), + /Primary inventory home route is missing/u, + ); + assert.throws( + () => validProfile({ + inventoryHomeRoutes: [ + { option: "exampleHome", relativePath: "", fallbackLabel: "~/.example" }, + { option: "exampleState", relativePath: "state" }, + ], + }), + /fallback/u, + ); + assert.throws( + () => validProfile({ + inventoryHomeRoutes: [ + { option: "exampleHome", relativePath: "", fallbackLabel: "/Users/private/.example" }, + ], + }), + /must be redacted/u, + ); + for (const fallbackLabel of [ + "../private/.example", + "Users/Alice/.example", + "~/../private/.example", + "/../private", + "/Example.app", + "/Example.app", + ]) { + assert.throws( + () => validProfile({ + inventoryHomeRoutes: [ + { option: "exampleHome", relativePath: "", fallbackLabel }, + ], + }), + /must be redacted/u, + ); + } +}); + +test("every host module is independently valid and aggregate checks own only identity collisions", async () => { + const profiles = []; + for (const host of HOSTS) { + const profile = (await import(`../scripts/host-support/profiles/${host}.mjs`)).default; + assert.equal(validateHostProfile(profile), true); + profiles.push(profile); + } + assert.equal(validateHostProfileRegistry(profiles), true); + + const duplicateId = structuredClone(profiles); + duplicateId[1].hostId = duplicateId[0].hostId; + assert.throws(() => validateHostProfileRegistry(duplicateId), /Duplicate host id/u); + + const aliasConflictsWithId = structuredClone(profiles); + aliasConflictsWithId[1].aliases = [profiles[0].hostId]; + assert.throws(() => validateHostProfileRegistry(aliasConflictsWithId), /alias conflicts with host id/u); + + const duplicateAlias = structuredClone(profiles); + duplicateAlias[0].aliases = ["shared-host"]; + duplicateAlias[1].aliases = ["shared-host"]; + assert.throws(() => validateHostProfileRegistry(duplicateAlias), /Duplicate host alias/u); +}); + +test("host-support registry facade delegates local schema ownership", () => { + const indexSource = readFileSync( + new URL("../scripts/host-support/index.mjs", import.meta.url), + "utf8", + ); + assert.match(indexSource, /validateHostProfileRegistry/u); + assert.doesNotMatch(indexSource, /HOST_DISTRIBUTION_KINDS|HOST_SCOPES|LIFECYCLE_OPERATION_NAMES/u); + assert.doesNotMatch(indexSource, /distributionKind|defaultScope|contractEvidence|step\.kind/u); + + const builderSource = readFileSync( + new URL("../scripts/host-support/profile-builders.mjs", import.meta.url), + "utf8", + ); + assert.match(builderSource, /profile-model\.mjs/u); + assert.match(builderSource, /validateHostSurface/u); + assert.match(builderSource, /validateHostProfile/u); + assert.match(builderSource, /deepFreeze/u); +}); diff --git a/test/host-support.test.mjs b/test/host-support.test.mjs new file mode 100644 index 0000000..7423359 --- /dev/null +++ b/test/host-support.test.mjs @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import test from "node:test"; + +import { + getHostProfile, + getHostSurface, + listHostProfiles, + resolveHostId, + supportedHostIds, + validateHostProfiles, +} from "../scripts/host-support/index.mjs"; +import { evidence, operation, shell } from "../scripts/host-support/profile-builders.mjs"; + +const HOSTS = ["claude", "codex", "qoder", "cursor", "qwen", "copilot", "pi", "workbuddy"]; +const contractFixture = JSON.parse(readFileSync( + new URL("./fixtures/plugin-lifecycle/native-help-contracts.v1.json", import.meta.url), + "utf8", +)); + +test("host-support profiles cover the canonical host set and aliases", () => { + assert.deepEqual(supportedHostIds(), HOSTS); + assert.deepEqual(supportedHostIds({ managedOnly: true }), HOSTS.slice(0, -1)); + assert.equal(resolveHostId("claude-code"), "claude"); + assert.equal(resolveHostId("qwen-code"), "qwen"); + assert.equal(resolveHostId("github-copilot"), "copilot"); + assert.equal(resolveHostId("does-not-exist"), undefined); + assert.equal(validateHostProfiles(listHostProfiles()), true); + for (const profile of listHostProfiles()) { + assert.equal(profile.inventoryHomeOption, `${profile.provider}Home`); + assert.equal(profile.inventoryHomeRoutes[0].option, profile.inventoryHomeOption); + assert.equal(profile.inventoryHomeRoutes[0].relativePath, ""); + assert.equal(profile.inventoryHomeRoutes.every((route) => typeof route.fallbackLabel === "string"), true); + assert.equal(profile.surfaces.every((surface) => typeof surface.observation?.kind === "string"), true); + assert.equal(profile.surfaces.every((surface) => typeof surface.observation?.discoverySource === "string"), true); + } + assert.deepEqual(getHostProfile("claude").inventoryHomeRoutes, [ + { option: "claudeHome", relativePath: "", fallbackLabel: "~/.claude" }, + { option: "claudeStatePath", relativePath: ".claude.json", fallbackLabel: "~/.claude.json" }, + ]); + assert.deepEqual(getHostProfile("codex").inventoryHomeRoutes, [ + { option: "codexHome", relativePath: "", fallbackLabel: "~/.codex" }, + { + option: "codexAppPath", + relativePath: "Applications/Codex.app", + fallbackLabel: "/Codex.app", + }, + ]); + assert.deepEqual(getHostProfile("qoder").inventoryHomeRoutes, [ + { option: "qoderHome", relativePath: "", fallbackLabel: "~/.qoder" }, + { + option: "qoderSharedClientCacheRoot", + relativePath: "SharedClientCache", + fallbackLabel: "/Qoder/SharedClientCache", + }, + ]); + assert.deepEqual(getHostProfile("cursor").inventoryHomeRoutes, [ + { option: "cursorHome", relativePath: "", fallbackLabel: "~/.cursor" }, + { + option: "stateDbPath", + relativePath: "state.vscdb", + fallbackLabel: "/Cursor/User/globalStorage/state.vscdb", + }, + ]); +}); + +test("surface observation policy captures host differences declaratively", () => { + assert.equal(getHostSurface("codex", "cli").observation.kind, "inventory"); + assert.deepEqual(getHostSurface("codex", "desktop").observation, { + kind: "desktop-cache", + discoveryDiagnostic: "appBundleExists", + evidenceRouteOption: "codexAppPath", + discoverySource: "diagnostic", + }); + assert.deepEqual(getHostSurface("qoder", "desktop").observation, { + kind: "bundled", + discoverySource: "unobserved", + }); + assert.equal(getHostSurface("cursor", "agent").observation.kind, "session-only"); + assert.equal(getHostSurface("cursor", "agent").observation.discoverySource, "executable"); + assert.equal(getHostSurface("pi", "cli").observation.kind, "inventory"); + assert.equal(getHostSurface("pi", "cli").observation.discoverySource, "executable"); + assert.equal(getHostSurface("pi", "cli-session").observation.kind, "session-only"); + assert.equal(getHostSurface("pi", "cli-session").observation.discoverySource, "executable"); + assert.equal(getHostSurface("qwen", "cli").scopeArtifactPolicy, "shared"); + assert.equal(getHostSurface("claude", "cli").scopeArtifactPolicy, "independent"); +}); + +test("native home and shared-artifact declarations have matching reviewable evidence", () => { + for (const host of ["claude", "codex", "qwen"]) { + const surface = getHostSurface(host, "cli"); + const fixture = contractFixture.hosts[host].nativeHomeBinding; + assert.equal(surface.nativeHomeBinding.kind, fixture.kind); + assert.equal(surface.nativeHomeBinding.variable, fixture.variable); + assert.equal(surface.nativeHomeBinding.contractEvidence.id, fixture.evidenceId); + } + const qwen = getHostSurface("qwen", "cli"); + assert.equal(qwen.scopeArtifactEvidence.id, contractFixture.hosts.qwen.scopeArtifactPolicy.evidenceId); + assert.equal(qwen.scopeArtifactPolicy, contractFixture.hosts.qwen.scopeArtifactPolicy.kind); +}); + +test("each canonical host owns one independently importable profile module", async () => { + const profilesRoot = new URL("../scripts/host-support/profiles/", import.meta.url); + const modules = readdirSync(profilesRoot) + .filter((name) => name.endsWith(".mjs")) + .map((name) => name.slice(0, -4)) + .sort(); + assert.deepEqual(modules, [...HOSTS].sort()); + + for (const host of HOSTS) { + const module = await import(new URL(`${host}.mjs`, profilesRoot)); + assert.equal(module.default.hostId, host); + assert.equal(module.default.provider, host); + } +}); + +test("profile builders reject untyped shell steps and unsupported executable claims", () => { + assert.throws(() => shell("qwen extensions list", "invalid"), /argv array/u); + assert.throws(() => shell([], "invalid"), /argv array/u); + assert.throws(() => operation("supported", [], evidence("missing-step")), /require steps/u); + assert.throws(() => operation("supported", [shell(["tool", "list"], "listed")]), /contract evidence/u); + assert.throws(() => operation("guessed"), /Unknown lifecycle disposition/u); +}); + +test("multi-surface hosts require an explicit surface", () => { + assert.equal(getHostSurface("codex"), undefined); + assert.equal(getHostSurface("qoder"), undefined); + assert.equal(getHostSurface("pi"), undefined); + assert.equal(getHostSurface("codex", "cli").displayName, "Codex CLI"); + assert.equal(getHostSurface("qoder", "desktop").distributionKind, "bundled"); + assert.equal(getHostSurface("claude").surfaceId, "cli"); +}); + +test("supported lifecycle operations carry typed steps and contract evidence", () => { + for (const profile of listHostProfiles()) { + for (const surface of profile.surfaces) { + for (const [operationName, operation] of Object.entries(surface.lifecycle)) { + if (operation.disposition !== "supported") continue; + assert.ok(operation.contractEvidence, `${profile.hostId}/${surface.surfaceId}/${operationName}`); + assert.ok(operation.steps.length > 0, `${profile.hostId}/${surface.surfaceId}/${operationName}`); + for (const step of operation.steps) { + if (step.kind === "shell") { + assert.ok(Array.isArray(step.argv)); + assert.equal(step.argv.every((arg) => typeof arg === "string"), true); + } + } + } + } + } +}); + +test("known contract gaps remain explicit instead of becoming shell commands", () => { + const cursor = getHostSurface("cursor", "agent"); + assert.equal(cursor.lifecycle.install.disposition, "unavailable"); + assert.equal(cursor.lifecycle.install.steps.length, 0); + assert.equal(cursor.lifecycle.install.contractEvidence.kind, "contract-drift"); + + const qoder = getHostSurface("qoder", "cli"); + assert.equal(qoder.lifecycle.install.disposition, "manual"); + assert.equal(qoder.lifecycle.update.disposition, "unavailable"); + + const pi = getHostSurface("pi", "cli"); + assert.deepEqual(pi.scopes, ["user", "project"]); + assert.equal(pi.defaultScope, "user"); + assert.equal(pi.lifecycle.install.disposition, "manual"); + assert.equal(pi.lifecycle.update.disposition, "unavailable"); + assert.equal(pi.lifecycle.remove.disposition, "unavailable"); + + const piSession = getHostSurface("pi", "cli-session"); + assert.deepEqual(piSession.scopes, ["session"]); + assert.equal(piSession.defaultScope, "session"); + assert.equal(piSession.lifecycle.install.disposition, "manual"); + assert.match(piSession.lifecycle.install.steps[0].instruction, /pi -e git:github\.com\/QoderAI\/better-harness/u); + assert.equal(piSession.lifecycle.update.disposition, "not-applicable"); + assert.equal(piSession.lifecycle.remove.disposition, "not-applicable"); + + const qwen = getHostSurface("qwen", "cli"); + assert.equal(qwen.lifecycle.update.disposition, "unavailable"); + assert.equal(qwen.lifecycle.remove.disposition, "unavailable"); + + assert.equal(getHostProfile("workbuddy").managed, false); +}); + +test("native help summaries bind six observed CLIs and one unobserved Pi contract", () => { + assert.deepEqual(Object.keys(contractFixture.hosts), HOSTS.slice(0, -1)); + assert.deepEqual( + Object.entries(contractFixture.hosts) + .filter(([, value]) => value.observation === "native-help") + .map(([host]) => host), + ["claude", "codex", "qwen", "copilot"], + ); + assert.equal(contractFixture.hosts.qoder.observation, "native-help-with-drift"); + assert.equal(contractFixture.hosts.cursor.observation, "native-help-with-drift"); + assert.equal(contractFixture.hosts.pi.observation, "unobserved"); + assert.equal(contractFixture.hosts.pi.version, null); + assert.equal(contractFixture.hosts.pi.installedPackageSource.package, "@earendil-works/pi-coding-agent"); + assert.equal(contractFixture.hosts.pi.installedPackageSource.version, "0.83.0"); + assert.equal(contractFixture.hosts.pi.installedPackageSource.observation, "installed-package-source"); + assert.equal(contractFixture.hosts.pi.installedPackageSource.nativeExecutableObservation, "unobserved"); + const piEvidenceIds = new Set( + contractFixture.hosts.pi.installedPackageSource.evidence.map((entry) => entry.evidenceId), + ); + const piPersistent = getHostSurface("pi", "cli"); + const piSession = getHostSurface("pi", "cli-session"); + for (const operation of [piPersistent.lifecycle.install, piPersistent.lifecycle.verify]) { + assert.equal(operation.contractEvidence.id, "pi-persistent-package-settings-source"); + assert.equal(operation.contractEvidence.kind, "installed-package-source"); + assert.equal(piEvidenceIds.has(operation.contractEvidence.id), true); + } + for (const operation of [piSession.lifecycle.install, piSession.lifecycle.verify]) { + assert.equal(operation.contractEvidence.id, "pi-temporary-extension-source"); + assert.equal(operation.contractEvidence.kind, "installed-package-source"); + assert.equal(piEvidenceIds.has(operation.contractEvidence.id), true); + } + + for (const profile of listHostProfiles().filter((entry) => entry.managed)) { + for (const surface of profile.surfaces) { + for (const operation of Object.values(surface.lifecycle)) { + if (!operation.contractEvidence) continue; + assert.equal( + operation.contractEvidence.fixture, + "test/fixtures/plugin-lifecycle/native-help-contracts.v1.json", + ); + } + } + } +}); + +test("host profile validation rejects executable steps without contract evidence", () => { + const fixture = structuredClone(listHostProfiles()); + delete fixture[0].surfaces[0].lifecycle.install.contractEvidence; + assert.throws(() => validateHostProfiles(fixture), /lacks evidence/u); +}); + +test("host profile validation rejects invalid inventory and observation routing", () => { + const missingHomeOption = structuredClone(listHostProfiles()); + delete missingHomeOption[0].inventoryHomeOption; + assert.throws(() => validateHostProfiles(missingHomeOption), /inventory home option/u); + + const unknownObservation = structuredClone(listHostProfiles()); + unknownObservation[0].surfaces[0].observation.kind = "host-special-case"; + assert.throws(() => validateHostProfiles(unknownObservation), /observation kind/u); + + const unknownDiscoverySource = structuredClone(listHostProfiles()); + unknownDiscoverySource[0].surfaces[0].observation.discoverySource = "host-special-case"; + assert.throws(() => validateHostProfiles(unknownDiscoverySource), /discovery source/u); + + const missingDiscoveryDiagnostic = structuredClone(listHostProfiles()); + delete missingDiscoveryDiagnostic[1].surfaces[1].observation.discoveryDiagnostic; + assert.throws(() => validateHostProfiles(missingDiscoveryDiagnostic), /requires a diagnostic/u); + + const mismatchedBundledObservation = structuredClone(listHostProfiles()); + mismatchedBundledObservation[2].surfaces[0].observation.kind = "inventory"; + assert.throws(() => validateHostProfiles(mismatchedBundledObservation), /must use bundled observation/u); + + const unknownEvidenceRoute = structuredClone(listHostProfiles()); + unknownEvidenceRoute[1].surfaces[1].observation.evidenceRouteOption = "missingCodexRoute"; + assert.throws(() => validateHostProfiles(unknownEvidenceRoute), /Unknown observation evidence route/u); +}); diff --git a/test/plugin-lifecycle-architecture.test.mjs b/test/plugin-lifecycle-architecture.test.mjs new file mode 100644 index 0000000..856fe21 --- /dev/null +++ b/test/plugin-lifecycle-architecture.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import * as lifecycle from "../scripts/plugin-lifecycle/index.mjs"; + +const root = process.cwd(); +const scriptsRoot = path.join(root, "scripts"); +const lifecycleRoot = path.join(scriptsRoot, "plugin-lifecycle"); + +function moduleFiles(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) return moduleFiles(filePath); + return entry.isFile() && entry.name.endsWith(".mjs") ? [filePath] : []; + }); +} + +test("plugin lifecycle public index is an implementation-free stable surface", () => { + const source = readFileSync(path.join(lifecycleRoot, "index.mjs"), "utf8"); + assert.doesNotMatch(source, /^import\s/mu); + assert.doesNotMatch(source, /\b(?:function|class)\b/u); + assert.doesNotMatch(source, /^(?:const|let|var)\s/mu); + assert.ok(source.split("\n").length < 35, "public index should remain a compact export surface"); + assert.deepEqual(Object.keys(lifecycle).sort(), [ + "LifecycleCliError", + "PLUGIN_ID", + "PLUGIN_LIFECYCLE_SCHEMA_VERSION", + "authorizedRootLabel", + "buildPluginLifecyclePlan", + "cliErrorEnvelope", + "commandEnvelope", + "diagnostic", + "discoverHostExecutable", + "exitCodeFor", + "inspectPluginLifecycle", + "matchesBetterHarnessPlugin", + "normalizeReadOnlyTimeout", + "parseReadOnlyOptions", + "pluginLifecycleRuntimeInfo", + "runReadOnlyCommand", + "stableDigest", + "validateWorkspace", + "verifyPluginLifecycle", + "withTimeout", + ]); +}); + +test("plugin lifecycle concerns have independent private owners", () => { + const modules = new Set(readdirSync(lifecycleRoot)); + for (const name of [ + "identity.mjs", + "command-definitions.mjs", + "command-manifest.mjs", + "human-output.mjs", + "model.mjs", + "observation.mjs", + "plan-core.mjs", + "plan-model.mjs", + "runtime.mjs", + "status-core.mjs", + "status-row.mjs", + "target-resolution.mjs", + ]) { + assert.ok(modules.has(name), `missing lifecycle responsibility owner: ${name}`); + } +}); + +test("status and plan share one target-resolution error owner", () => { + const targetCodes = [ + "AMBIGUOUS_HOST_HOME", + "AMBIGUOUS_HOST_SURFACE", + "AMBIGUOUS_SURFACE", + "EXPLICIT_HOST_REQUIRED", + "UNKNOWN_HOST", + "UNKNOWN_HOST_SURFACE", + "UNSUPPORTED_SCOPE", + ]; + const owners = new Map(targetCodes.map((code) => [code, []])); + for (const filePath of moduleFiles(lifecycleRoot)) { + const source = readFileSync(filePath, "utf8"); + for (const code of targetCodes) { + if (source.includes(`"${code}"`)) owners.get(code).push(path.basename(filePath)); + } + } + for (const [code, modules] of owners) { + assert.deepEqual(modules, ["target-resolution.mjs"], code); + } + + for (const name of ["status-core.mjs", "plan-core.mjs"]) { + const source = readFileSync(path.join(lifecycleRoot, name), "utf8"); + assert.doesNotMatch(source, /host-support\/index\.mjs/u, name); + assert.match(source, /target-resolution\.mjs/u, name); + } +}); + +test("lifecycle status interprets profiles without canonical host branches", () => { + const source = readFileSync(path.join(lifecycleRoot, "status-core.mjs"), "utf8"); + for (const hostId of ["claude", "codex", "qoder", "cursor", "qwen", "copilot", "pi", "workbuddy"]) { + assert.doesNotMatch(source, new RegExp(`["']${hostId}["']`, "u")); + } + assert.doesNotMatch(source, /HOME_OPTION|appBundleExists/u); + assert.match(source, /profile\.inventoryHomeRoutes/u); + assert.match(source, /observedHostDiscovery/u); + assert.match(source, /buildObservedStatusRow/u); + assert.match(source, /buildInventoryFailureStatusRow/u); + assert.match(source, /buildUnresolvedScopeStatusRow/u); + assert.doesNotMatch(source, /function (?:verificationFor|evidenceFor|versionState)/u); + assert.doesNotMatch(source, /\b(?:installation|enablement|activation|checks|evidence):/u); +}); + +test("lifecycle plan core delegates policy, step materialization, and schema ownership", () => { + const source = readFileSync(path.join(lifecycleRoot, "plan-core.mjs"), "utf8"); + assert.match(source, /createPluginLifecyclePlan/u); + assert.match(source, /requirePluginLifecycleAction/u); + assert.match(source, /resolvePlanTarget/u); + assert.match(source, /inspectPluginLifecycle/u); + assert.doesNotMatch(source, /\b(?:planState|blockers|verificationSteps|preconditionDigest|currentObservation|retention|recovery)\b/u); + assert.doesNotMatch(source, /\.(?:disposition|steps)\b/u); + assert.doesNotMatch(source, /function (?:renderStep|targetRows|installedState)/u); +}); + +test("cross-capability lifecycle imports use the public index or pure root metadata", () => { + const importPattern = /(?:\bfrom\s*|\bimport\s*\(\s*)["']([^"']+)["']/gu; + const violations = []; + for (const filePath of moduleFiles(scriptsRoot)) { + if (filePath.startsWith(`${lifecycleRoot}${path.sep}`)) continue; + const source = readFileSync(filePath, "utf8"); + for (const match of source.matchAll(importPattern)) { + const specifier = match[1]; + const metadataProjection = path.relative(root, filePath) === "scripts/better-harness-cli/registry.mjs" + && specifier.endsWith("plugin-lifecycle/command-manifest.mjs"); + if ( + specifier.includes("plugin-lifecycle/") + && !specifier.endsWith("plugin-lifecycle/index.mjs") + && !metadataProjection + ) { + violations.push(`${path.relative(root, filePath)} -> ${specifier}`); + } + } + } + assert.deepEqual(violations, []); +}); diff --git a/test/plugin-lifecycle-command-definitions.test.mjs b/test/plugin-lifecycle-command-definitions.test.mjs new file mode 100644 index 0000000..7163aca --- /dev/null +++ b/test/plugin-lifecycle-command-definitions.test.mjs @@ -0,0 +1,234 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { groupCommand } from "../scripts/better-harness-cli/registry.mjs"; +import { + PLUGIN_COMMAND_DEFINITIONS, + pluginCommandNames, + requirePluginCommandDefinition, +} from "../scripts/plugin-lifecycle/command-definitions.mjs"; +import { PLUGIN_COMMAND_MANIFEST } from "../scripts/plugin-lifecycle/command-manifest.mjs"; +import { renderPlan, renderStatus } from "../scripts/plugin-lifecycle/human-output.mjs"; +import { PLUGIN_LIFECYCLE_ACTIONS } from "../scripts/plugin-lifecycle/plan-model.mjs"; + +test("one plugin command manifest projects into root discovery and runtime bindings", () => { + const manifestSource = readFileSync( + new URL("../scripts/plugin-lifecycle/command-manifest.mjs", import.meta.url), + "utf8", + ); + assert.doesNotMatch(manifestSource, /^import\s/mu); + const rootRegistrySource = readFileSync( + new URL("../scripts/better-harness-cli/registry.mjs", import.meta.url), + "utf8", + ); + assert.match(rootRegistrySource, /plugin-lifecycle\/command-manifest\.mjs/u); + assert.doesNotMatch(rootRegistrySource, /plugin-lifecycle\/index\.mjs/u); + assert.deepEqual(pluginCommandNames(), ["status", "plan", "verify"]); + assert.deepEqual( + PLUGIN_COMMAND_MANIFEST.find((entry) => entry.name === "plan").positionals.values, + PLUGIN_LIFECYCLE_ACTIONS, + ); + assert.deepEqual( + groupCommand("plugin").subcommands, + PLUGIN_COMMAND_MANIFEST.map(({ name, audience, entryScript: script, summary }) => ({ + name, + audience, + script, + summary, + })), + ); + assert.deepEqual( + PLUGIN_COMMAND_DEFINITIONS.map((definition) => definition.name), + pluginCommandNames(), + ); + for (const definition of PLUGIN_COMMAND_DEFINITIONS) { + assert.equal(Object.isFrozen(definition), true); + assert.equal(Object.isFrozen(definition.allowedOptions), true); + assert.equal(Object.isFrozen(definition.positionals), true); + assert.equal(typeof definition.execute, "function"); + assert.equal(typeof definition.renderHuman, "function"); + assert.equal(requirePluginCommandDefinition(definition.name), definition); + } + assert.throws( + () => requirePluginCommandDefinition("apply"), + (error) => error.code === "UNKNOWN_PLUGIN_COMMAND" && error.kind === "usage", + ); +}); + +test("plugin CLI dispatches definitions without leaf-name conditionals", () => { + const source = readFileSync( + new URL("../scripts/plugin-lifecycle/cli.mjs", import.meta.url), + "utf8", + ); + for (const command of pluginCommandNames()) { + assert.doesNotMatch( + source, + new RegExp(`command\\s*[!=]==?\\s*["']${command}["']`, "u"), + command, + ); + } + assert.match(source, /requirePluginCommandDefinition/u); + assert.match(source, /execute: definition\.execute/u); + assert.match(source, /definition\.renderHuman/u); +}); + +test("extracted status human output preserves tables and diagnostic filtering", () => { + const output = renderStatus({ + rows: [{ + target: { hostId: "qwen", surfaceId: "cli", scope: "user" }, + hostDiscovery: "present", + installation: "installed", + enablement: "enabled", + version: { observed: "0.4.0", relation: "same" }, + verification: "partial", + }], + diagnostics: [ + { code: "RUNTIME_ACTIVATION_UNOBSERVED", severity: "info", message: "info" }, + { code: "PLUGIN_DISABLED", severity: "warning", message: "disabled" }, + ], + }, { name: "status" }); + + assert.equal(output, [ + "Better Harness plugin status", + "", + "TARGET HOST INSTALL ENABLE VERSION VERIFY", + "qwen/cli@user present installed enabled 0.4.0 (same) partial", + "", + "Diagnostics:", + " PLUGIN_DISABLED: disabled", + "", + ].join("\n")); +}); + +test("human output escapes control characters from local evidence and paths", () => { + const status = renderStatus({ + rows: [{ + target: { hostId: "qwen", surfaceId: "cli", scope: "user" }, + hostDiscovery: "present", + installation: "installed", + enablement: "enabled", + version: { observed: "0.4.0\n\u001b[31mspoof", relation: "different" }, + verification: "partial", + }], + diagnostics: [], + }, { name: "status" }); + assert.match(status, /0\.4\.0\\n\\u001b\[31mspoof \(different\)/u); + assert.equal(status.includes("\u001b"), false); + assert.equal(status.split("\n").some((line) => line === "spoof (different)"), false); + + const plan = renderPlan({ + action: "install", + planId: "plan-1", + target: { + hostId: "qwen", + surfaceId: "cli", + scope: "project", + workspace: "/workspace/line\nbreak", + }, + state: "ready", + effects: "none", + steps: [], + verificationSteps: [], + notes: [], + blockers: [], + }); + assert.match(plan, /Workspace: "\/workspace\/line\\nbreak"/u); + assert.equal(plan.includes("/workspace/line\nbreak"), false); +}); + +test("extracted plan human output preserves argv as shell-neutral JSON data", () => { + const output = renderPlan({ + action: "install", + planId: "plan-1", + target: { + hostId: "qwen", + surfaceId: "cli", + scope: "project", + workspace: "/workspace/path with space", + hostHome: "/host home/qwen", + }, + state: "ready", + effects: "none", + steps: [{ + id: "step-1", + kind: "shell", + argv: ["qwen", "extensions", "install", "path with space"], + cwd: "/workspace/path with space", + expected: "installed", + }], + verificationSteps: [{ + kind: "shell", + argv: ["qwen", "extensions", "list"], + cwd: "/workspace/path with space", + }], + notes: [], + blockers: [], + }); + + assert.equal(output, [ + "Better Harness plugin install plan", + "", + "Plan: plan-1", + "Target: qwen/cli@project", + "State: ready", + "Effects performed now: none", + "Workspace: \"/workspace/path with space\"", + "Host home: \"/host home/qwen\"", + "", + "Planned external steps:", + " step-1. [shell] argv (JSON, not shell): [\"qwen\",\"extensions\",\"install\",\"path with space\"]", + " Expect: installed", + " Working directory: \"/workspace/path with space\"", + "", + "Verify after applying externally:", + " - [shell] argv (JSON, not shell): [\"qwen\",\"extensions\",\"list\"]", + " Working directory: \"/workspace/path with space\"", + " - Better Harness verify argv (JSON, not shell): [\"better-harness\",\"plugin\",\"verify\",\"--host\",\"qwen\",\"--surface\",\"cli\",\"--workspace\",\"/workspace/path with space\",\"--host-home\",\"/host home/qwen\"]", + "", + "No commands were executed and no files were changed.", + "", + ].join("\n")); +}); + +test("plan human output neutralizes shell expansion syntax in argv and paths", () => { + const hostilePath = "/workspace/$(touch pwn)-`touch pwn2`-%TEMP%-!TEMP!-$env:HOME"; + const output = renderPlan({ + action: "install", + planId: "plan-hostile", + target: { + hostId: "qwen", + surfaceId: "cli", + scope: "project", + workspace: hostilePath, + hostHome: hostilePath, + }, + state: "ready", + effects: "none", + steps: [{ + id: "step-1", + kind: "shell", + argv: ["qwen", "extensions", "install", hostilePath], + cwd: hostilePath, + expected: "installed", + homeBinding: { + variable: "QWEN_HOME", + value: hostilePath, + contractEvidence: { id: "fixture", observedAt: "2026-08-01" }, + }, + }], + verificationSteps: [], + notes: [], + blockers: [], + }); + + assert.match(output, /argv \(JSON, not shell\): \["qwen","extensions","install"/u); + assert.match(output, /Host home binding \(data\): \{"variable":"QWEN_HOME","value":/u); + assert.equal(output.includes("$("), false); + assert.equal(output.includes("`"), false); + assert.equal(output.includes("%TEMP%"), false); + assert.equal(output.includes("!TEMP!"), false); + assert.equal(output.includes("$env:"), false); + assert.doesNotMatch(output, /^\s*(?:qwen|better-harness)\s+/imu); + assert.doesNotMatch(output, /QWEN_HOME=/u); +}); diff --git a/test/plugin-lifecycle-model.test.mjs b/test/plugin-lifecycle-model.test.mjs new file mode 100644 index 0000000..070abcd --- /dev/null +++ b/test/plugin-lifecycle-model.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + assertLifecycleControlled, + validateLifecycleDiagnostics, + validateLifecyclePlugin, + validateLifecycleTarget, +} from "../scripts/plugin-lifecycle/model.mjs"; + +test("shared lifecycle plugin validation owns both version-field variants", () => { + assert.equal(validateLifecyclePlugin( + { id: "better-harness", expectedVersion: "0.4.0" }, + { label: "PluginLifecycleStatusV1", versionField: "expectedVersion", versionLabel: "expected version" }, + ), true); + assert.equal(validateLifecyclePlugin( + { id: "better-harness", packageVersion: "0.4.0" }, + { label: "PluginLifecyclePlanV1", versionField: "packageVersion", versionLabel: "package version" }, + ), true); + assert.throws( + () => validateLifecyclePlugin( + { id: "display-name-only", expectedVersion: "0.4.0" }, + { label: "PluginLifecycleStatusV1", versionField: "expectedVersion", versionLabel: "expected version" }, + ), + /plugin identity/u, + ); + assert.throws( + () => validateLifecyclePlugin( + { id: "better-harness" }, + { label: "PluginLifecyclePlanV1", versionField: "packageVersion", versionLabel: "package version" }, + ), + /package version/u, + ); +}); + +test("shared lifecycle target validation distinguishes base and expected-source contracts", () => { + const target = { + hostId: "qwen", + surfaceId: "cli", + scope: "user", + distributionKind: "extension", + }; + assert.equal(validateLifecycleTarget(target, { label: "PluginLifecycleStatusV1" }), true); + assert.throws( + () => validateLifecycleTarget(target, { label: "PluginLifecyclePlanV1", expectedSource: true }), + /native plugin id/u, + ); + assert.equal(validateLifecycleTarget({ + ...target, + expectedSource: { + pluginId: "better-harness", + repository: "QoderAI/better-harness", + }, + }, { label: "PluginLifecyclePlanV1", expectedSource: true }), true); + assert.throws( + () => validateLifecycleTarget({ ...target, scope: "" }, { label: "PluginLifecycleStatusV1" }), + /scope/u, + ); +}); + +test("shared lifecycle diagnostics enforce one shape and severity vocabulary", () => { + const diagnostics = [ + { code: "INFO", severity: "info", message: "info" }, + { code: "WARN", severity: "warning", message: "warning" }, + { code: "ERROR", severity: "error", message: "error" }, + ]; + assert.equal(validateLifecycleDiagnostics(diagnostics, "ContractV1"), true); + assert.throws( + () => validateLifecycleDiagnostics([{ code: "DEBUG", severity: "debug", message: "debug" }], "ContractV1"), + /diagnostic severity/u, + ); + assert.throws( + () => validateLifecycleDiagnostics([{ code: "", severity: "error", message: "error" }], "ContractV1"), + /diagnostic code/u, + ); + assert.throws(() => validateLifecycleDiagnostics({}, "ContractV1"), /must be an array/u); + assert.throws( + () => assertLifecycleControlled(["ready", "blocked"], "pending", "ContractV1", "state"), + /ContractV1 state/u, + ); +}); + +test("status and plan validators consume shared primitives without private copies", () => { + const modelSource = readFileSync( + new URL("../scripts/plugin-lifecycle/model.mjs", import.meta.url), + "utf8", + ); + const statusSource = readFileSync( + new URL("../scripts/plugin-lifecycle/status-row.mjs", import.meta.url), + "utf8", + ); + const planSource = readFileSync( + new URL("../scripts/plugin-lifecycle/plan-model.mjs", import.meta.url), + "utf8", + ); + for (const source of [statusSource, planSource]) { + assert.match(source, /validateLifecyclePlugin/u); + assert.match(source, /validateLifecycleTarget/u); + assert.match(source, /validateLifecycleDiagnostics/u); + assert.match(source, /assertLifecycleControlled/u); + assert.doesNotMatch(source, /function assert\s*\(/u); + assert.doesNotMatch(source, /DIAGNOSTIC_SEVERITIES|diagnosticSeverity/u); + } + assert.match(modelSource, /const DIAGNOSTIC_SEVERITIES/u); + assert.match(modelSource, /function validateLifecyclePlugin/u); + assert.match(modelSource, /function validateLifecycleTarget/u); + assert.match(modelSource, /function validateLifecycleDiagnostics/u); +}); diff --git a/test/plugin-lifecycle-plan-model.test.mjs b/test/plugin-lifecycle-plan-model.test.mjs new file mode 100644 index 0000000..0fb6df2 --- /dev/null +++ b/test/plugin-lifecycle-plan-model.test.mjs @@ -0,0 +1,381 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { listHostProfiles } from "../scripts/host-support/index.mjs"; +import { buildPluginLifecyclePlan } from "../scripts/plugin-lifecycle/index.mjs"; +import { + PLUGIN_LIFECYCLE_ACTIONS, + validatePluginLifecyclePlan, +} from "../scripts/plugin-lifecycle/plan-model.mjs"; + +const packageInfo = { + name: "@qoderai/better-harness", + version: "0.4.0", + nodeRange: ">=22", + npmRange: ">=10", +}; + +const baseOptions = { + packageInfo, + hostDiscovery: async () => "present", + workspace: process.cwd(), +}; + +function inventoryCollector({ installed = false, scope = "user" } = {}) { + return async ({ provider }) => ({ + provider, + plugins: installed + ? [{ + id: "better-harness", + name: "better-harness", + version: "0.4.0", + enabled: true, + installSources: [scope], + skills: [{ name: "better-harness" }], + evidence: { path: "/fixture/plugin.json" }, + }] + : [], + diagnostics: {}, + }); +} + +async function planFor({ + action = "install", + host = "qwen", + surface = "cli", + scope = "user", + installed = false, + workspace = baseOptions.workspace, + hostHome, + hostDiscovery = baseOptions.hostDiscovery, + collector = inventoryCollector({ installed, scope }), +} = {}) { + return buildPluginLifecyclePlan({ + ...baseOptions, + action, + host, + surface, + scope, + workspace, + hostHome, + hostDiscovery, + inventoryCollector: collector, + }); +} + +test("every host surface and lifecycle lane emits a validated plan model", async () => { + let plans = 0; + for (const profile of listHostProfiles()) { + for (const surface of profile.surfaces) { + for (const action of PLUGIN_LIFECYCLE_ACTIONS) { + for (const installed of [false, true]) { + const plan = await planFor({ + action, + host: profile.hostId, + surface: surface.surfaceId, + scope: surface.defaultScope, + installed, + }); + assert.equal(validatePluginLifecyclePlan(plan), true); + plans += 1; + } + } + } + } + assert.equal(plans, 66); +}); + +test("mutation and verification steps carry distinct effect contracts", async () => { + const plan = await planFor({ action: "install", host: "qwen", surface: "cli" }); + assert.equal(plan.state, "ready"); + assert.equal(plan.effects, "none"); + assert.equal(plan.steps.length, 1); + assert.equal(plan.steps.every((step) => ( + step.effects === "external" + && step.externalImpact === "host-plugin-state" + && step.ownership === "host-native" + )), true); + assert.equal(plan.verificationSteps.length, 1); + assert.equal(plan.verificationSteps.every((step) => ( + step.effects === "read-only" + && step.externalImpact === "host-observation" + && step.ownership === "host-native" + && step.preconditions.every((value) => !value.includes("digest")) + )), true); +}); + +test("project and isolated-home plans bind target, digest, step cwd, and verification context", async () => { + const first = await planFor({ + scope: "project", + workspace: "/fixture/workspace one", + hostHome: "/fixture/qwen home", + }); + const otherWorkspace = await planFor({ + scope: "project", + workspace: "/fixture/workspace two", + hostHome: "/fixture/qwen home", + }); + const otherHome = await planFor({ + scope: "project", + workspace: "/fixture/workspace one", + hostHome: "/fixture/other qwen home", + }); + + assert.equal(first.target.workspace, "/fixture/workspace one"); + assert.equal(first.target.hostHome, "/fixture/qwen home"); + assert.equal(first.steps.every((step) => step.cwd === first.target.workspace), true); + assert.equal(first.verificationSteps.every((step) => step.cwd === first.target.workspace), true); + assert.equal(first.steps.every((step) => ( + step.homeBinding.variable === "QWEN_HOME" + && step.homeBinding.value === first.target.hostHome + && step.homeBinding.contractEvidence.id === "qwen-home-environment" + )), true); + assert.equal(first.verificationSteps.every((step) => ( + step.homeBinding.variable === "QWEN_HOME" && step.homeBinding.value === first.target.hostHome + )), true); + assert.notEqual(first.preconditionDigest, otherWorkspace.preconditionDigest); + assert.notEqual(first.planId, otherWorkspace.planId); + assert.notEqual(first.preconditionDigest, otherHome.preconditionDigest); + assert.notEqual(first.planId, otherHome.planId); + + const user = await planFor({ hostHome: "/fixture/qwen home" }); + assert.equal(Object.hasOwn(user.target, "workspace"), false); + assert.equal(user.steps.every((step) => !Object.hasOwn(step, "cwd")), true); + assert.equal(user.verificationSteps.every((step) => !Object.hasOwn(step, "cwd")), true); +}); + +test("inventory failure and an absent required host executable block plans", async () => { + for (const action of PLUGIN_LIFECYCLE_ACTIONS) { + const inventoryFailure = await planFor({ + action, + installed: action !== "install", + collector: async () => { + throw new Error("fixture inventory failure"); + }, + }); + assert.equal(inventoryFailure.state, "blocked", action); + assert.equal(inventoryFailure.status, "failed", action); + assert.deepEqual(inventoryFailure.steps, [], action); + assert.ok( + inventoryFailure.blockers.some((item) => item.code === "INVENTORY_COLLECTION_FAILED"), + action, + ); + + const absentExecutable = await planFor({ + action, + host: "copilot", + installed: action !== "install", + hostDiscovery: async () => "absent", + }); + assert.equal(absentExecutable.state, "blocked", action); + assert.deepEqual(absentExecutable.steps, [], action); + assert.ok( + absentExecutable.blockers.some((item) => item.code === "HOST_EXECUTABLE_NOT_FOUND"), + action, + ); + } +}); + +test("public plan calls normalize relative workspace and host home before observation", async () => { + let captured; + const plan = await planFor({ + scope: "project", + workspace: ".", + hostHome: "fixture-relative-qwen-home", + collector: async (options) => { + captured = options; + return { provider: options.provider, plugins: [], diagnostics: {} }; + }, + }); + assert.equal(plan.target.workspace, process.cwd()); + assert.equal(plan.target.hostHome, path.resolve("fixture-relative-qwen-home")); + assert.equal(captured.workspace, plan.target.workspace); + assert.equal(captured.qwenHome, plan.target.hostHome); + assert.equal(plan.steps[0].cwd, plan.target.workspace); + assert.equal(plan.steps[0].homeBinding.value, plan.target.hostHome); +}); + +test("unrepresentable scopes and Qwen shared-artifact mutations stay blocked", async () => { + const unresolved = await planFor({ + action: "install", + host: "qoder", + surface: "cli", + collector: inventoryCollector({ installed: true, scope: "other" }), + }); + assert.equal(unresolved.state, "blocked"); + assert.ok(unresolved.blockers.some((item) => item.code === "INSTALL_SCOPE_UNSUPPORTED")); + + const mixed = await planFor({ + collector: async ({ provider }) => ({ + provider, + plugins: [{ + id: "better-harness", + name: "better-harness", + installSources: ["user", "future-scope"], + skills: [{ name: "better-harness" }], + }], + diagnostics: {}, + }), + }); + assert.equal(mixed.state, "blocked"); + assert.ok(mixed.blockers.some((item) => item.code === "INSTALL_SCOPE_UNSUPPORTED")); + + for (const action of ["update", "remove"]) { + const unavailable = await planFor({ action, installed: false }); + assert.equal(unavailable.state, "blocked", action); + assert.ok( + unavailable.blockers.some((item) => item.code === "LIFECYCLE_OPERATION_UNAVAILABLE"), + action, + ); + } +}); + +test("Qwen blocks shared-artifact installs across activation scopes", async () => { + for (const [scope, observedScope] of [["project", "user"], ["user", "project"]]) { + const plan = await planFor({ + scope, + collector: inventoryCollector({ installed: true, scope: observedScope }), + }); + assert.equal(plan.state, "blocked", `${observedScope}->${scope}`); + assert.equal(plan.status, "failed", `${observedScope}->${scope}`); + assert.ok( + plan.blockers.some((item) => item.code === "SHARED_ARTIFACT_SCOPE_CONFLICT"), + `${observedScope}->${scope}`, + ); + } +}); + +test("unsupported CLI scope evidence cannot contaminate a bundled desktop plan", async () => { + const plan = await planFor({ + action: "install", + host: "qoder", + surface: "desktop", + scope: "bundled", + collector: inventoryCollector({ installed: true, scope: "future-cli-scope" }), + }); + assert.equal(plan.state, "noop"); + assert.equal(plan.status, "ok"); + assert.equal(plan.blockers.length, 0); +}); + +test("isolated plans fail closed when native steps cannot bind every host home root", async () => { + const plan = await planFor({ + action: "install", + host: "copilot", + surface: "cli", + hostHome: "relative-copilot-home", + }); + assert.equal(plan.state, "blocked"); + assert.equal(plan.status, "failed"); + assert.deepEqual(plan.steps, []); + assert.deepEqual(plan.verificationSteps, []); + assert.ok(plan.blockers.some((item) => item.code === "ISOLATED_HOST_HOME_UNREPRESENTABLE")); + + const claudeInstall = await planFor({ + action: "install", + host: "claude", + surface: "cli", + hostHome: "relative-claude-home", + }); + assert.equal(claudeInstall.state, "blocked"); + assert.deepEqual(claudeInstall.steps, []); + assert.ok(claudeInstall.blockers.some((item) => item.code === "ISOLATED_HOST_HOME_UNREPRESENTABLE")); + + const claudeUpdate = await planFor({ + action: "update", + host: "claude", + surface: "cli", + installed: true, + hostHome: "relative-claude-home", + }); + assert.equal(claudeUpdate.state, "ready"); + assert.equal(claudeUpdate.steps[0].homeBinding.variable, "CLAUDE_CONFIG_DIR"); +}); + +test("unavailable operation disposition remains explicit when the host is absent", async () => { + for (const action of ["update", "remove"]) { + const plan = await planFor({ + action, + installed: false, + hostDiscovery: async () => "absent", + }); + assert.equal(plan.state, "blocked"); + assert.equal(plan.blockers[0].code, "LIFECYCLE_OPERATION_UNAVAILABLE"); + } +}); + +test("plan validation rejects controlled-state, summary, digest, and step drift", async () => { + const ready = await planFor({ action: "install", host: "qwen", surface: "cli" }); + const corruptions = [ + [(plan) => { plan.action = "apply"; }, /action/u], + [(plan) => { plan.state = "pending"; }, /state/u], + [(plan) => { plan.status = "partial"; }, /command status/u], + [(plan) => { plan.effects = "external"; }, /generation must remain read-only/u], + [(plan) => { plan.planId = "0".repeat(64); }, /plan digest is stale/u], + [(plan) => { plan.preconditionDigest = "0".repeat(64); }, /precondition digest is stale/u], + [(plan) => { plan.currentObservation.instances += 1; }, /observation count/u], + [(plan) => { plan.currentObservation.installations = []; }, /installation summary/u], + [(plan) => { plan.target.hostId = "claude"; }, /observed host/u], + [(plan) => { plan.target.distributionKind = "package"; }, /observed distribution/u], + [(plan) => { plan.target.workspace = "/other/workspace"; }, /target workspace/u], + [(plan) => { plan.steps[0].cwd = "/other/workspace"; }, /cannot declare a workspace/u], + [(plan) => { plan.steps[0].effects = "read-only"; }, /mutation step 1 has invalid effects/u], + [(plan) => { plan.steps[0].command = "qwen extensions install"; }, /exactly one typed instruction/u], + [(plan) => { plan.verificationSteps[0].externalImpact = "host-plugin-state"; }, /verification step 1 has invalid external impact/u], + [(plan) => { plan.steps = []; }, /ready plan requires mutation steps/u], + [(plan) => { plan.retention.pop(); }, /retention boundaries/u], + [(plan) => { plan.activation.newSessionRequired = false; }, /activation requirement/u], + [(plan) => { plan.recovery.pop(); }, /recovery boundaries/u], + [(plan) => { plan.observed[0].installation = "present"; }, /installation/u], + ]; + for (const [mutate, pattern] of corruptions) { + const candidate = structuredClone(ready); + mutate(candidate); + assert.throws(() => validatePluginLifecyclePlan(candidate), pattern); + } + + const blocked = await planFor({ action: "update", host: "qwen", surface: "cli" }); + const invalidSeverity = structuredClone(blocked); + invalidSeverity.blockers[0].severity = "fatal"; + assert.throws(() => validatePluginLifecyclePlan(invalidSeverity), /diagnostic severity/u); + + const mismatchedDiagnostics = structuredClone(blocked); + mismatchedDiagnostics.diagnostics = [ + ...mismatchedDiagnostics.diagnostics, + { code: "EXTRA", severity: "warning", message: "extra" }, + ]; + assert.throws(() => validatePluginLifecyclePlan(mismatchedDiagnostics), /diagnostics must mirror blockers/u); + + const blockerless = structuredClone(blocked); + blockerless.blockers = []; + blockerless.diagnostics = []; + assert.throws(() => validatePluginLifecyclePlan(blockerless), /blocked plan requires a blocker/u); + + const readyWithBlocker = structuredClone(ready); + readyWithBlocker.blockers = [{ code: "EXTRA", severity: "error", message: "extra" }]; + readyWithBlocker.diagnostics = structuredClone(readyWithBlocker.blockers); + assert.throws(() => validatePluginLifecyclePlan(readyWithBlocker), /ready plan cannot contain blockers/u); + + const isolated = await planFor({ hostHome: "/fixture/qwen-home" }); + const mismatchedHome = structuredClone(isolated); + mismatchedHome.steps[0].homeBinding.value = "/fixture/other-home"; + assert.throws(() => validatePluginLifecyclePlan(mismatchedHome), /host home binding differs/u); + + const missingBindingEvidence = structuredClone(isolated); + missingBindingEvidence.steps[0].homeBinding.contractEvidence.fixture = ""; + assert.throws(() => validatePluginLifecyclePlan(missingBindingEvidence), /binding evidence fixture is missing/u); + + const hostCommandBinding = structuredClone(isolated); + hostCommandBinding.steps[0].kind = "host-command"; + hostCommandBinding.steps[0].command = "extensions install better-harness"; + delete hostCommandBinding.steps[0].argv; + assert.throws(() => validatePluginLifecyclePlan(hostCommandBinding), /cannot bind host home for host-command/u); +}); + +test("direct plan callers use the same controlled lifecycle action contract", async () => { + await assert.rejects( + planFor({ action: "apply" }), + (error) => error.code === "UNKNOWN_LIFECYCLE_ACTION" && error.kind === "usage", + ); +}); diff --git a/test/plugin-lifecycle-status-row.test.mjs b/test/plugin-lifecycle-status-row.test.mjs new file mode 100644 index 0000000..d7cfa63 --- /dev/null +++ b/test/plugin-lifecycle-status-row.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { getHostProfile, getHostSurface } from "../scripts/host-support/index.mjs"; +import { inspectPluginLifecycle } from "../scripts/plugin-lifecycle/index.mjs"; +import { + buildInventoryFailureStatusRow, + buildObservedStatusRow, + validatePluginLifecycleStatusRow, +} from "../scripts/plugin-lifecycle/status-row.mjs"; + +const expectedVersion = "0.4.0"; + +function installedPlugin(overrides = {}) { + return { + id: "better-harness@better-harness", + name: "better-harness", + version: expectedVersion, + enabled: true, + installSources: ["user"], + skills: [{ name: "better-harness" }], + evidence: { path: path.join("fixture", "plugin.json") }, + ...overrides, + }; +} + +function observedRow() { + return buildObservedStatusRow({ + profile: getHostProfile("claude"), + surface: getHostSurface("claude", "cli"), + plugin: installedPlugin(), + scope: "user", + hostDiscovery: "present", + expectedVersion, + options: {}, + }); +} + +test("observed and inventory-failure paths share one complete status-row shape", () => { + const observed = observedRow(); + const failure = buildInventoryFailureStatusRow({ + profile: getHostProfile("claude"), + surface: getHostSurface("claude", "cli"), + hostDiscovery: "present", + expectedVersion, + }); + + assert.equal(validatePluginLifecycleStatusRow(observed), true); + assert.equal(validatePluginLifecycleStatusRow(failure), true); + assert.deepEqual(Object.keys(failure), Object.keys(observed)); + assert.deepEqual(Object.keys(failure.plugin), Object.keys(observed.plugin)); + assert.deepEqual(Object.keys(failure.target), Object.keys(observed.target)); + assert.deepEqual(Object.keys(failure.version), ["relation"]); + assert.equal(failure.installation, "unknown"); + assert.equal(failure.diagnostics[0].code, "INVENTORY_COLLECTION_FAILED"); +}); + +test("status-row validation rejects every controlled state family independently", () => { + const mutations = [ + (row) => { row.hostDiscovery = "maybe"; }, + (row) => { row.installation = "cached"; }, + (row) => { row.enablement = "maybe"; }, + (row) => { row.version.relation = "latest"; }, + (row) => { row.verification = "maybe"; }, + (row) => { row.activation = "loaded"; }, + (row) => { row.checks[0].status = "maybe"; }, + (row) => { row.evidence[0].class = "session-body"; }, + (row) => { row.diagnostics[0].severity = "debug"; }, + ]; + + for (const mutate of mutations) { + const row = structuredClone(observedRow()); + mutate(row); + assert.throws(() => validatePluginLifecycleStatusRow(row), /PluginLifecycleStatusV1/u); + } + + assert.throws( + () => buildObservedStatusRow({ + profile: getHostProfile("claude"), + surface: getHostSurface("claude", "cli"), + plugin: installedPlugin(), + scope: "session", + hostDiscovery: "present", + expectedVersion, + options: {}, + }), + /scope session is not declared/u, + ); +}); + +test("all-host status emits only rows accepted by the status model", async () => { + const result = await inspectPluginLifecycle({ + host: "all", + workspace: process.cwd(), + packageInfo: { + name: "@qoderai/better-harness", + version: expectedVersion, + nodeRange: ">=22", + npmRange: ">=10", + }, + hostDiscovery: async () => "present", + inventoryCollector: async ({ provider }) => ({ + provider, + plugins: [installedPlugin()], + diagnostics: {}, + }), + }); + + assert.equal(result.rows.length, 11); + assert.equal(result.rows.every(validatePluginLifecycleStatusRow), true); +}); diff --git a/test/plugin-lifecycle-target-resolution.test.mjs b/test/plugin-lifecycle-target-resolution.test.mjs new file mode 100644 index 0000000..6bfa946 --- /dev/null +++ b/test/plugin-lifecycle-target-resolution.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + resolvePlanTarget, + resolveStatusTarget, +} from "../scripts/plugin-lifecycle/target-resolution.mjs"; + +function rejectsWith(code, message, hint) { + return (error) => { + assert.equal(error.code, code); + assert.equal(error.kind, "usage"); + if (message) assert.equal(error.message, message); + if (hint) assert.equal(error.hint, hint); + return true; + }; +} + +test("status target resolution owns all-host, alias, surface, and home routing", () => { + assert.equal(resolveStatusTarget().profiles.length, 8); + + const aliased = resolveStatusTarget({ host: "qwen-code", surface: "cli" }); + assert.equal(aliased.profiles[0].hostId, "qwen"); + assert.equal(aliased.surface.surfaceId, "cli"); + + assert.throws( + () => resolveStatusTarget({ host: "all", surface: "cli" }), + rejectsWith("AMBIGUOUS_SURFACE", "--surface requires one explicit host."), + ); + assert.throws( + () => resolveStatusTarget({ host: "all", hostHome: "/isolated/home" }), + rejectsWith("AMBIGUOUS_HOST_HOME", "--host-home requires one explicit host."), + ); + assert.throws( + () => resolveStatusTarget({ host: "missing" }), + rejectsWith( + "UNKNOWN_HOST", + "Unknown host: missing", + "Use one of: claude, codex, qoder, cursor, qwen, copilot, pi, workbuddy, or all.", + ), + ); +}); + +test("plan target resolution owns explicit host and multi-surface requirements", () => { + for (const host of [undefined, "all", "auto"]) { + assert.throws( + () => resolvePlanTarget({ host }), + rejectsWith("EXPLICIT_HOST_REQUIRED", "Plugin plans require one explicit host."), + ); + } + + assert.throws( + () => resolvePlanTarget({ host: "codex" }), + rejectsWith( + "AMBIGUOUS_HOST_SURFACE", + "Codex has multiple lifecycle surfaces.", + "Use --surface cli or --surface desktop.", + ), + ); + assert.throws( + () => resolvePlanTarget({ host: "pi" }), + rejectsWith( + "AMBIGUOUS_HOST_SURFACE", + "Pi has multiple lifecycle surfaces.", + "Use --surface cli or --surface cli-session.", + ), + ); + assert.throws( + () => resolvePlanTarget({ host: "claude", surface: "desktop" }), + rejectsWith("UNKNOWN_HOST_SURFACE", "Unknown surface for Claude Code: desktop"), + ); +}); + +test("plan target resolution validates scope after alias and surface resolution", () => { + const target = resolvePlanTarget({ + host: "qwen-code", + surface: "cli", + scope: "project", + }); + assert.equal(target.profile.hostId, "qwen"); + assert.equal(target.surface.surfaceId, "cli"); + assert.equal(target.scope, "project"); + + assert.throws( + () => resolvePlanTarget({ host: "codex", surface: "desktop", scope: "project" }), + rejectsWith( + "UNSUPPORTED_SCOPE", + "Codex/desktop does not support scope project.", + "Use one of: user.", + ), + ); +}); diff --git a/test/plugin-lifecycle.test.mjs b/test/plugin-lifecycle.test.mjs new file mode 100644 index 0000000..0fb9b29 --- /dev/null +++ b/test/plugin-lifecycle.test.mjs @@ -0,0 +1,816 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, readdir, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { getHostProfile } from "../scripts/host-support/index.mjs"; +import { runHarnessDoctor } from "../scripts/harness-doctor/index.mjs"; +import { + buildPluginLifecyclePlan, + inspectPluginLifecycle, + matchesBetterHarnessPlugin, + stableDigest, + verifyPluginLifecycle, + withTimeout, +} from "../scripts/plugin-lifecycle/index.mjs"; + +const packageInfo = { + name: "@qoderai/better-harness", + version: "0.4.0", + nodeRange: ">=22", + npmRange: ">=10", +}; + +function plugin(overrides = {}) { + return { + id: "better-harness@better-harness", + name: "better-harness", + version: "0.4.0", + enabled: true, + installSources: ["user"], + skills: [{ name: "better-harness" }], + evidence: { path: path.join(os.homedir(), ".fixture-host", "plugins", "better-harness", "plugin.json") }, + ...overrides, + }; +} + +function collectorWith(pluginsByProvider = {}) { + return async ({ provider }) => ({ + provider, + plugins: pluginsByProvider[provider] ?? [], + diagnostics: {}, + }); +} + +const baseOptions = { + packageInfo, + hostDiscovery: async () => "present", + workspace: process.cwd(), +}; + +test("Better Harness identity matching rejects display-name-only candidates", () => { + const profile = getHostProfile("codex"); + assert.equal(matchesBetterHarnessPlugin(plugin(), profile), true); + assert.equal(matchesBetterHarnessPlugin({ displayName: "Better Harness", name: "other" }, profile), false); + assert.equal(matchesBetterHarnessPlugin({ name: "@qoderai/better-harness" }, getHostProfile("pi")), true); + assert.equal(matchesBetterHarnessPlugin({ piPackageSource: "git:github.com/QoderAI/better-harness" }, getHostProfile("pi")), true); + assert.equal(matchesBetterHarnessPlugin({ id: "better-harness" }, profile), true); + assert.equal(matchesBetterHarnessPlugin({ remotePluginId: "git+ssh://git@github.com/QoderAI/better-harness.git#main" }, profile), true); +}); + +test("status covers every host and preserves WorkBuddy as unsupported", async () => { + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "all", + inventoryCollector: collectorWith({ + claude: [plugin({ claudePluginId: "better-harness@better-harness" })], + codex: [plugin({ codexPluginId: "better-harness@better-harness" })], + qoder: [plugin({ qoderPluginId: "better-harness@better-harness" })], + cursor: [plugin({ cursorPluginId: "better-harness" })], + qwen: [plugin()], + copilot: [plugin({ copilotPluginName: "better-harness" })], + pi: [plugin({ name: "@qoderai/better-harness", piPackageSource: "git:github.com/QoderAI/better-harness" })], + }), + }); + + assert.deepEqual( + [...new Set(result.rows.map((row) => row.target.hostId))], + ["claude", "codex", "qoder", "cursor", "qwen", "copilot", "pi", "workbuddy"], + ); + const workbuddy = result.rows.find((row) => row.target.hostId === "workbuddy"); + assert.equal(workbuddy.verification, "unobserved"); + assert.ok(workbuddy.diagnostics.some((item) => item.code === "PLUGIN_LIFECYCLE_UNSUPPORTED")); + const desktop = result.rows.find((row) => row.target.hostId === "codex" && row.target.surfaceId === "desktop"); + assert.equal(desktop.installation, "unknown"); + assert.ok(desktop.diagnostics.some((item) => item.code === "DESKTOP_ENABLEMENT_UNOBSERVED")); +}); + +test("status keeps multiple installation scopes as independent rows", async () => { + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "claude", + inventoryCollector: collectorWith({ + claude: [ + plugin({ id: "better-harness-user", installSources: ["user"] }), + plugin({ id: "better-harness-project", installSources: ["project"] }), + ], + }), + }); + assert.deepEqual(result.rows.map((row) => row.target.scope), ["user", "project"]); +}); + +test("status routes isolated homes for inventory surfaces and skips bounded static surfaces", async () => { + for (const host of ["claude", "codex", "qoder", "cursor", "qwen", "copilot", "pi", "workbuddy"]) { + let captured; + const hostHome = path.join(os.tmpdir(), `${host} lifecycle home`); + await inspectPluginLifecycle({ + ...baseOptions, + host, + hostHome, + inventoryCollector: async (options) => { + captured = options; + return { provider: options.provider, plugins: [], diagnostics: {} }; + }, + }); + const profile = getHostProfile(host); + const inventoryRequired = profile.surfaces.some((surface) => ( + !["bundled", "session-only"].includes(surface.observation.kind) + )); + if (!inventoryRequired) { + assert.equal(captured, undefined, host); + continue; + } + for (const route of profile.inventoryHomeRoutes) { + assert.equal( + captured[route.option], + route.relativePath ? path.join(hostHome, route.relativePath) : hostHome, + ); + } + } +}); + +test("direct status calls normalize relative roots and keep fallback evidence inside the isolated boundary", async () => { + let captured; + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "qwen", + workspace: ".", + hostHome: "relative-status-qwen-home", + inventoryCollector: async (options) => { + captured = options; + return { provider: options.provider, plugins: [], diagnostics: {} }; + }, + }); + assert.equal(captured.workspace, process.cwd()); + assert.equal(captured.qwenHome, path.resolve("relative-status-qwen-home")); + assert.equal(result.rows[0].evidence[0].path, "/relative-status-qwen-home"); + + const failed = await inspectPluginLifecycle({ + ...baseOptions, + host: "qwen", + workspace: ".", + hostHome: "relative-status-qwen-home", + inventoryCollector: async () => { throw new Error("fixture failure"); }, + }); + assert.equal(failed.status, "failed"); + assert.equal(failed.rows[0].evidence[0].path, "/relative-status-qwen-home"); +}); + +test("status excludes foreign workspace installs and fails closed on unsupported scopes", async () => { + const foreignClaude = await inspectPluginLifecycle({ + ...baseOptions, + host: "claude", + inventoryCollector: collectorWith({ + claude: [plugin({ applicable: false, installSources: ["project"], projectPath: "/other/workspace" })], + }), + }); + assert.equal(foreignClaude.rows.some((row) => row.installation === "installed"), false); + + const unknownQoderScope = await inspectPluginLifecycle({ + ...baseOptions, + host: "qoder", + surface: "cli", + inventoryCollector: collectorWith({ qoder: [plugin({ installSources: ["other"] })] }), + }); + assert.equal(unknownQoderScope.rows[0].installation, "unknown"); + assert.equal(unknownQoderScope.rows[0].verification, "unobserved"); + assert.ok(unknownQoderScope.rows[0].diagnostics.some((item) => item.code === "INSTALL_SCOPE_UNSUPPORTED")); +}); + +test("Pi keeps persistent inventory separate from session-only activation", async () => { + const empty = await inspectPluginLifecycle({ + ...baseOptions, + host: "pi", + inventoryCollector: collectorWith(), + }); + assert.deepEqual( + empty.rows.map((row) => [row.target.surfaceId, row.target.scope, row.installation]), + [ + ["cli", "user", "not-installed"], + ["cli-session", "session", "session-only"], + ], + ); + const emptySession = empty.rows.find((row) => row.target.surfaceId === "cli-session"); + assert.equal(emptySession.verification, "unobserved"); + assert.ok(emptySession.diagnostics.some((item) => item.code === "SESSION_ACTIVATION_UNOBSERVED")); + assert.equal(emptySession.diagnostics.some((item) => item.code === "PLUGIN_NOT_INSTALLED"), false); + + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "pi", + inventoryCollector: collectorWith({ + pi: [plugin({ + name: "@qoderai/better-harness", + piPackageSource: "git:github.com/QoderAI/better-harness", + installSources: ["project"], + })], + }), + }); + const persistent = result.rows.find((row) => row.target.surfaceId === "cli"); + const session = result.rows.find((row) => row.target.surfaceId === "cli-session"); + assert.equal(persistent.target.scope, "project"); + assert.equal(persistent.installation, "installed"); + assert.equal(session.target.scope, "session"); + assert.equal(session.installation, "session-only"); + assert.equal(session.version.relation, "unobserved"); + assert.equal(session.checks.length, 0); + assert.equal(session.evidence.some((item) => item.path.includes("fixture-host")), false); +}); + +test("Pi session-only status ignores persistent inventory collection failure", async () => { + let inventoryCalls = 0; + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "pi", + surface: "cli-session", + inventoryCollector: async () => { + inventoryCalls += 1; + throw new Error("persistent Pi settings are unreadable"); + }, + }); + assert.equal(inventoryCalls, 0); + assert.equal(result.status, "partial"); + assert.equal(result.rows.length, 1); + assert.equal(result.rows[0].target.scope, "session"); + assert.equal(result.rows[0].installation, "session-only"); + assert.equal(result.rows[0].verification, "unobserved"); + assert.equal(result.diagnostics.some((item) => item.code === "INVENTORY_COLLECTION_FAILED"), false); +}); + +test("bundled Qoder desktop does not borrow CLI plugin evidence", async () => { + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "qoder", + inventoryCollector: collectorWith({ + qoder: [plugin({ version: "9.9.9", skills: [], evidence: { path: "/fixture/qoder-cli/plugin.json" } })], + }), + }); + const desktop = result.rows.find((row) => row.target.surfaceId === "desktop"); + assert.equal(desktop.hostDiscovery, "unobserved"); + assert.equal(desktop.installation, "bundled"); + assert.equal(desktop.version.relation, "unobserved"); + assert.equal(desktop.verification, "partial"); + assert.equal(desktop.evidence.some((item) => item.path.includes("qoder-cli")), false); + assert.equal(desktop.diagnostics.some((item) => item.code === "BETTER_HARNESS_SKILL_MISSING"), false); + + let inventoryCalls = 0; + const selectedDesktop = await inspectPluginLifecycle({ + ...baseOptions, + host: "qoder", + surface: "desktop", + inventoryCollector: async () => { + inventoryCalls += 1; + throw new Error("Qoder CLI inventory is unavailable"); + }, + }); + assert.equal(inventoryCalls, 0); + assert.equal(selectedDesktop.rows.length, 1); + assert.equal(selectedDesktop.rows[0].hostDiscovery, "unobserved"); + assert.equal(selectedDesktop.rows[0].installation, "bundled"); + assert.equal(selectedDesktop.diagnostics.some((item) => item.code === "INVENTORY_COLLECTION_FAILED"), false); +}); + +test("Codex desktop uses only app discovery and bounded desktop evidence", async () => { + const workspace = path.join(os.tmpdir(), "better-harness-codex-desktop-workspace"); + const hostHome = path.join(workspace, "isolated codex home"); + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "codex", + workspace, + hostHome, + hostDiscovery: async () => "absent", + inventoryCollector: collectorWith({ + codex: [plugin({ + version: "9.9.9", + skills: [], + evidence: { path: "/fixture/codex-cli/plugin.json" }, + })], + }), + }); + const desktop = result.rows.find((row) => row.target.surfaceId === "desktop"); + assert.equal(desktop.hostDiscovery, "unobserved"); + assert.equal(desktop.installation, "unknown"); + assert.equal(desktop.enablement, "unknown"); + assert.equal(desktop.version.relation, "unobserved"); + assert.equal(desktop.verification, "unobserved"); + assert.deepEqual(desktop.checks, []); + assert.deepEqual(desktop.evidence, [{ + class: "local-config", + path: "/isolated codex home/Applications/Codex.app", + }]); + assert.equal(desktop.evidence.some((item) => item.path.includes("codex-cli")), false); + assert.equal(desktop.diagnostics.some((item) => item.code === "BETTER_HARNESS_SKILL_MISSING"), false); + + const discovered = await inspectPluginLifecycle({ + ...baseOptions, + host: "codex", + surface: "desktop", + hostDiscovery: async () => "absent", + inventoryCollector: async ({ provider }) => ({ + provider, + plugins: [], + diagnostics: { appBundleExists: true }, + }), + }); + assert.equal(discovered.rows[0].hostDiscovery, "present"); + assert.equal(discovered.rows[0].installation, "unknown"); + assert.equal(discovered.rows[0].version.relation, "unobserved"); + assert.equal(discovered.rows[0].evidence[0].path, "/Codex.app"); + + const absent = await inspectPluginLifecycle({ + ...baseOptions, + host: "codex", + surface: "desktop", + hostDiscovery: async () => "present", + inventoryCollector: async ({ provider }) => ({ + provider, + plugins: [], + diagnostics: { appBundleExists: false }, + }), + }); + assert.equal(absent.rows[0].hostDiscovery, "absent"); + assert.equal(absent.rows[0].installation, "unknown"); +}); + +test("inventory failure does not project CLI discovery onto Codex Desktop", async () => { + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "codex", + hostDiscovery: async () => "present", + inventoryCollector: async () => { throw new Error("fixture inventory failure"); }, + }); + const cli = result.rows.find((row) => row.target.surfaceId === "cli"); + const desktop = result.rows.find((row) => row.target.surfaceId === "desktop"); + assert.equal(cli.hostDiscovery, "present"); + assert.equal(desktop.hostDiscovery, "unobserved"); + assert.ok(desktop.diagnostics.some((item) => item.code === "INVENTORY_COLLECTION_FAILED")); +}); + +test("status ordering and digests do not depend on process locale", () => { + const source = ` + import { inspectPluginLifecycle, stableDigest } from "./scripts/plugin-lifecycle/index.mjs"; + const plugins = ["ä.json", "z.json"].map((name) => ({ + id: "better-harness-" + name, + name: "better-harness", + version: "0.4.0", + enabled: true, + installSources: ["user"], + skills: [{ name: "better-harness" }], + evidence: { path: "/fixture/" + name }, + })); + const result = await inspectPluginLifecycle({ + host: "claude", + workspace: process.cwd(), + packageInfo: ${JSON.stringify(packageInfo)}, + hostDiscovery: async () => "present", + inventoryCollector: async () => ({ provider: "claude", plugins, diagnostics: {} }), + }); + process.stdout.write(stableDigest(result.rows)); + `; + const digestFor = (lang) => { + const result = spawnSync(process.execPath, ["--input-type=module", "--eval", source], { + cwd: process.cwd(), + env: { ...process.env, LANG: lang, LC_ALL: lang }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + return result.stdout; + }; + assert.equal(digestFor("en_US.UTF-8"), digestFor("sv_SE.UTF-8")); +}); + +test("invalid status targets fail before host inventory collection", async () => { + let inventoryCalls = 0; + await assert.rejects( + inspectPluginLifecycle({ + ...baseOptions, + host: "claude", + surface: "desktop", + inventoryCollector: async () => { + inventoryCalls += 1; + return { plugins: [], diagnostics: {} }; + }, + }), + (error) => error.code === "UNKNOWN_HOST_SURFACE", + ); + assert.equal(inventoryCalls, 0); +}); + +test("disabled installs remain installed while verification stays partial", async () => { + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "claude", + inventoryCollector: collectorWith({ claude: [plugin({ enabled: false })] }), + }); + assert.equal(result.rows[0].installation, "installed"); + assert.equal(result.rows[0].enablement, "disabled"); + assert.equal(result.rows[0].verification, "partial"); + assert.ok(result.rows[0].diagnostics.some((item) => item.code === "PLUGIN_DISABLED")); +}); + +test("status redacts evidence paths under the user home", async () => { + const secretHome = path.join(os.homedir(), "private-account-name"); + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "qwen", + inventoryCollector: collectorWith({ qwen: [plugin({ evidence: { path: path.join(secretHome, "extension.json") } })] }), + }); + const serialized = JSON.stringify(result); + assert.doesNotMatch(serialized, new RegExp(secretHome.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u")); + assert.match(serialized, /~\/private-account-name/u); +}); + +test("status redacts Windows drive paths case-insensitively", async () => { + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "qwen", + platform: "win32", + home: "C:\\Users\\Alice", + inventoryCollector: collectorWith({ + qwen: [plugin({ evidence: { path: "c:\\users\\ALICE\\.qwen\\extensions\\better-harness.json" } })], + }), + }); + const serialized = JSON.stringify(result); + assert.doesNotMatch(serialized, /alice/iu); + assert.equal(result.rows[0].evidence[0].path, "~/.qwen/extensions/better-harness.json"); +}); + +test("install plans are deterministic, typed, and perform no implicit update", async () => { + const options = { + ...baseOptions, + action: "install", + host: "qwen", + surface: "cli", + scope: "project", + inventoryCollector: collectorWith(), + }; + const first = await buildPluginLifecyclePlan(options); + const second = await buildPluginLifecyclePlan(options); + assert.equal(first.planId, second.planId); + assert.equal(first.effects, "none"); + assert.equal(first.state, "ready"); + assert.deepEqual(first.steps[0].argv, [ + "qwen", "extensions", "install", "QoderAI/better-harness", "--scope", "project", + ]); + assert.equal(first.steps.every((step) => step.effects === "external"), true); + assert.equal(first.steps.every((step) => step.externalImpact === "host-plugin-state"), true); + assert.equal(first.steps.every((step) => step.preconditions.length === 2), true); + assert.equal(first.activation.newSessionRequired, true); + assert.equal(first.recovery.length, 3); + assert.deepEqual(first.target.expectedSource, { + pluginId: "better-harness", + repository: "QoderAI/better-harness", + }); + + const installed = await buildPluginLifecyclePlan({ + ...options, + inventoryCollector: collectorWith({ qwen: [plugin({ installSources: ["project"] })] }), + }); + assert.equal(installed.state, "noop"); + assert.deepEqual(installed.steps, []); + assert.match(installed.notes[0], /does not imply update/u); +}); + +test("Qwen non-link install markers resolve the copied extension artifact and prevent duplicate install plans", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-qwen-non-link-")); + const qwenHome = path.join(root, ".qwen"); + const workspace = path.join(root, "workspace"); + const extensionRoot = path.join(qwenHome, "extensions", "better-harness"); + const writeJson = async (filePath, value) => { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); + }; + try { + await mkdir(workspace, { recursive: true }); + await writeJson(path.join(extensionRoot, ".qwen-extension-install.json"), { + type: "local", + source: path.join(root, "deleted-source-checkout"), + originSource: "QoderAI/better-harness", + }); + await writeJson(path.join(extensionRoot, "qwen-extension.json"), { + name: "better-harness", + version: "0.4.0", + displayName: "Better Harness", + }); + await mkdir(path.join(extensionRoot, "skills", "better-harness"), { recursive: true }); + await writeFile( + path.join(extensionRoot, "skills", "better-harness", "SKILL.md"), + "---\nname: better-harness\ndescription: Better Harness.\n---\n", + ); + await writeJson(path.join(qwenHome, "extension-store", "state.json"), { + version: 2, + generation: 1, + legacyProjectionHash: "0".repeat(64), + extensions: { + ["a".repeat(64)]: { + name: "better-harness", + artifactGeneration: 1, + defaultActivation: "enabled", + workspaceOverrides: {}, + }, + }, + }); + + const options = { + ...baseOptions, + host: "qwen", + surface: "cli", + scope: "user", + workspace, + hostHome: qwenHome, + }; + const status = await inspectPluginLifecycle(options); + assert.equal(status.rows[0].installation, "installed"); + assert.equal(status.rows[0].target.scope, "user"); + + const plan = await buildPluginLifecyclePlan({ ...options, action: "install" }); + assert.equal(plan.state, "noop"); + assert.deepEqual(plan.steps, []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("update fails closed when absent and remove preserves non-plugin state", async () => { + const update = await buildPluginLifecyclePlan({ + ...baseOptions, + action: "update", + host: "copilot", + surface: "cli", + inventoryCollector: collectorWith(), + }); + assert.equal(update.state, "blocked"); + assert.ok(update.blockers.some((item) => item.code === "PLUGIN_NOT_INSTALLED")); + assert.deepEqual(update.steps, []); + + const remove = await buildPluginLifecyclePlan({ + ...baseOptions, + action: "remove", + host: "copilot", + surface: "cli", + inventoryCollector: collectorWith({ copilot: [plugin()] }), + }); + assert.equal(remove.state, "ready"); + assert.deepEqual(remove.steps[0].argv, ["copilot", "plugin", "uninstall", "better-harness@better-harness"]); + assert.ok(remove.retention.includes("Better Harness reports")); + assert.ok(remove.retention.includes("configured marketplace")); +}); + +test("unverified host contracts and WorkBuddy remain blocked", async () => { + const cursor = await buildPluginLifecyclePlan({ + ...baseOptions, + action: "install", + host: "cursor", + surface: "agent", + inventoryCollector: collectorWith(), + }); + assert.equal(cursor.state, "blocked"); + assert.ok(cursor.blockers.some((item) => item.code === "HOST_CONTRACT_STALE")); + assert.deepEqual(cursor.steps, []); + + const workbuddy = await buildPluginLifecyclePlan({ + ...baseOptions, + action: "install", + host: "workbuddy", + surface: "skills", + inventoryCollector: collectorWith(), + }); + assert.equal(workbuddy.state, "blocked"); + assert.ok(workbuddy.blockers.some((item) => item.code === "PLUGIN_LIFECYCLE_UNSUPPORTED")); + + await assert.rejects( + buildPluginLifecyclePlan({ + ...baseOptions, + action: "install", + host: "workbuddy", + surface: "unknown", + inventoryCollector: collectorWith(), + }), + (error) => error.code === "UNKNOWN_HOST_SURFACE", + ); +}); + +test("Pi session plans preserve the transient -e lifecycle boundary", async () => { + const install = await buildPluginLifecyclePlan({ + ...baseOptions, + action: "install", + host: "pi", + surface: "cli-session", + scope: "session", + inventoryCollector: collectorWith({ pi: [plugin({ + name: "@qoderai/better-harness", + piPackageSource: "git:github.com/QoderAI/better-harness", + installSources: ["user"], + })] }), + }); + assert.equal(install.state, "manual"); + assert.equal(install.target.scope, "session"); + assert.equal(install.observed[0].installation, "session-only"); + assert.equal(install.steps.length, 1); + assert.equal(install.steps[0].kind, "manual"); + assert.match(install.steps[0].instruction, /pi -e git:github\.com\/QoderAI\/better-harness/u); + assert.equal(install.steps[0].cwd, undefined); + + for (const action of ["update", "remove"]) { + const plan = await buildPluginLifecyclePlan({ + ...baseOptions, + action, + host: "pi", + surface: "cli-session", + scope: "session", + inventoryCollector: collectorWith(), + }); + assert.equal(plan.state, "blocked", action); + assert.deepEqual(plan.steps, [], action); + assert.ok(plan.blockers.some((item) => item.code === "LIFECYCLE_OPERATION_NOT_APPLICABLE"), action); + } +}); + +test("Codex Desktop manual plans do not collapse unknown installation into absence", async () => { + for (const action of ["update", "remove"]) { + const plan = await buildPluginLifecyclePlan({ + ...baseOptions, + action, + host: "codex", + surface: "desktop", + scope: "user", + inventoryCollector: async ({ provider }) => ({ + provider, + plugins: [], + diagnostics: { appBundleExists: true }, + }), + }); + assert.equal(plan.observed[0].installation, "unknown", action); + assert.equal(plan.state, "manual", action); + assert.equal(plan.status, "partial", action); + assert.equal(plan.steps.length, 1, action); + assert.equal(plan.steps[0].kind, "desktop-ui", action); + assert.equal(plan.blockers.some((item) => item.code === "PLUGIN_NOT_INSTALLED"), false, action); + } +}); + +test("every host emits an explicit plan and verification result", async () => { + const cases = [ + ["claude", "cli", "user", "ready"], + ["codex", "cli", "user", "ready"], + ["codex", "desktop", "user", "manual"], + ["qoder", "desktop", "bundled", "noop"], + ["qoder", "cli", "user", "manual"], + ["cursor", "agent", "session", "blocked"], + ["qwen", "cli", "user", "ready"], + ["copilot", "cli", "user", "ready"], + ["pi", "cli", "user", "manual"], + ["pi", "cli-session", "session", "manual"], + ["workbuddy", "skills", "user", "blocked"], + ]; + for (const [host, surface, scope, state] of cases) { + const plan = await buildPluginLifecyclePlan({ + ...baseOptions, + action: "install", + host, + surface, + scope, + inventoryCollector: collectorWith(), + }); + assert.equal(plan.state, state, `${host}/${surface}`); + } + + const verification = await verifyPluginLifecycle({ + ...baseOptions, + host: "all", + inventoryCollector: collectorWith(), + }); + assert.deepEqual( + [...new Set(verification.rows.map((row) => row.target.hostId))].sort(), + ["claude", "codex", "copilot", "cursor", "pi", "qoder", "qwen", "workbuddy"], + ); +}); + +test("all-host inventory failure is operational failure, not partial success", async () => { + const result = await inspectPluginLifecycle({ + ...baseOptions, + host: "all", + inventoryCollector: async () => { + throw new Error(`${os.homedir()}/private/provider-state`); + }, + }); + assert.equal(result.status, "failed"); + const inventoryRows = result.rows.filter((row) => { + const surface = getHostProfile(row.target.hostId).surfaces.find( + (candidate) => candidate.surfaceId === row.target.surfaceId, + ); + return !["bundled", "session-only"].includes(surface.observation.kind); + }); + const boundedRows = result.rows.filter((row) => !inventoryRows.includes(row)); + assert.equal( + inventoryRows.every((row) => row.diagnostics.some((item) => item.code === "INVENTORY_COLLECTION_FAILED")), + true, + ); + assert.equal( + boundedRows.every((row) => row.diagnostics.every((item) => item.code !== "INVENTORY_COLLECTION_FAILED")), + true, + ); + assert.doesNotMatch(JSON.stringify(result), new RegExp(os.homedir().replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u")); + + const verification = await verifyPluginLifecycle({ + ...baseOptions, + host: "qwen", + inventoryCollector: async () => { + throw new Error("fixture inventory failure"); + }, + }); + assert.equal(verification.status, "failed"); + assert.equal(verification.diagnostics[0].code, "INVENTORY_COLLECTION_FAILED"); +}); + +test("verification fails when an installed plugin omits the canonical Skill", async () => { + const result = await verifyPluginLifecycle({ + ...baseOptions, + host: "claude", + inventoryCollector: collectorWith({ claude: [plugin({ skills: [] })] }), + }); + assert.equal(result.status, "failed"); + assert.equal(result.summary.failed, 1); + assert.ok(result.diagnostics.some((item) => item.code === "BETTER_HARNESS_SKILL_MISSING")); +}); + +async function snapshotTree(root) { + const files = []; + async function visit(current, relative = "") { + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const nextRelative = relative ? path.join(relative, entry.name) : entry.name; + const next = path.join(current, entry.name); + if (entry.isDirectory()) await visit(next, nextRelative); + else if (entry.isSymbolicLink()) files.push([nextRelative, `symlink:${await readlink(next)}`]); + else files.push([nextRelative, await readFile(next, "utf8")]); + } + } + await visit(root); + return files; +} + +test("status, plan, and verify do not mutate an isolated host home", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-plugin-readonly-")); + const hostHome = path.join(root, "codex home"); + const workspace = path.join(root, "workspace ü"); + try { + await mkdir(hostHome, { recursive: true }); + await mkdir(workspace, { recursive: true }); + const workspaceLink = path.join(root, "workspace link ü"); + await symlink(workspace, workspaceLink, "dir"); + await writeFile(path.join(hostHome, "keep.txt"), "user-owned\n"); + const before = await snapshotTree(root); + await inspectPluginLifecycle({ host: "codex", hostHome, workspace: workspaceLink, env: { PATH: "" } }); + await buildPluginLifecyclePlan({ action: "install", host: "codex", surface: "cli", hostHome, workspace: workspaceLink, env: { PATH: "" } }); + await verifyPluginLifecycle({ host: "codex", hostHome, workspace: workspaceLink, env: { PATH: "" } }); + await runHarnessDoctor({ platform: "codex", hostHome, workspace: workspaceLink, env: { PATH: "" } }); + assert.deepEqual(await snapshotTree(root), before); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("stable plan digests ignore object key ordering", () => { + assert.equal(stableDigest({ b: 2, a: { d: 4, c: 3 } }), stableDigest({ a: { c: 3, d: 4 }, b: 2 })); +}); + +test("plan ids remain stable when inventory instance order changes", async () => { + const firstPlugin = plugin({ + id: "better-harness-first", + evidence: { path: path.join(os.homedir(), ".claude", "plugins", "first.json") }, + }); + const secondPlugin = plugin({ + id: "better-harness-second", + evidence: { path: path.join(os.homedir(), ".claude", "plugins", "second.json") }, + }); + let reverse = false; + const inventoryCollector = async ({ provider }) => { + const plugins = provider === "claude" + ? (reverse ? [secondPlugin, firstPlugin] : [firstPlugin, secondPlugin]) + : []; + reverse = !reverse; + return { provider, plugins, diagnostics: {} }; + }; + const options = { + ...baseOptions, + action: "remove", + host: "claude", + surface: "cli", + scope: "user", + inventoryCollector, + }; + const first = await buildPluginLifecyclePlan(options); + const second = await buildPluginLifecyclePlan(options); + assert.equal(first.planId, second.planId); + assert.deepEqual(first.currentObservation, second.currentObservation); +}); + +test("read-only operations honor their timeout", async () => { + await assert.rejects( + withTimeout(() => new Promise(() => {}), 5), + (error) => error.code === "COMMAND_TIMEOUT" && error.retryable === true, + ); +}); diff --git a/test/read-only-command.test.mjs b/test/read-only-command.test.mjs new file mode 100644 index 0000000..867ee02 --- /dev/null +++ b/test/read-only-command.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + LifecycleCliError, + normalizeReadOnlyTimeout, + parseReadOnlyOptions, + runReadOnlyCommand, +} from "../scripts/plugin-lifecycle/index.mjs"; + +function outputBuffer() { + let value = ""; + return { + stream: { write(chunk) { value += chunk; } }, + read() { return value; }, + }; +} + +test("shared read-only parser owns common flags and command-specific values", () => { + const parsed = parseReadOnlyOptions([ + "install", + "--workspace=space ü", + "--host", "qwen", + "--json", + "--no-color", + "--timeout", "25", + ], { + valueOptions: { "--host": "host" }, + }); + assert.deepEqual(parsed.positionals, ["install"]); + assert.deepEqual(normalizeReadOnlyTimeout(parsed.options), { + json: true, + noColor: true, + help: false, + workspace: "space ü", + host: "qwen", + timeout: 25, + }); +}); + +test("shared read-only parser rejects duplicate, empty, and unknown options", () => { + assert.throws( + () => parseReadOnlyOptions(["--json", "--json"]), + (error) => error.code === "DUPLICATE_OPTION", + ); + assert.throws( + () => parseReadOnlyOptions(["--workspace="]), + (error) => error.code === "MISSING_OPTION_VALUE", + ); + assert.throws( + () => parseReadOnlyOptions(["--json=true"]), + (error) => error.code === "UNKNOWN_OPTION", + ); + assert.throws( + () => normalizeReadOnlyTimeout({ timeout: "0", help: false }), + (error) => error.code === "INVALID_TIMEOUT", + ); +}); + +test("shared runner handles help and JSON failure without executing capability code", async () => { + const helpOut = outputBuffer(); + let executions = 0; + const helpExit = await runReadOnlyCommand({ + argv: ["--help"], + command: "better-harness fixture", + parse: (argv) => normalizeReadOnlyTimeout(parseReadOnlyOptions(argv).options), + usage: () => "fixture help\n", + execute: async () => { executions += 1; }, + envelope: () => { throw new Error("must not envelope help"); }, + renderHuman: () => "must not render", + stdout: helpOut.stream, + stderr: outputBuffer().stream, + }); + assert.equal(helpExit, 0); + assert.equal(helpOut.read(), "fixture help\n"); + assert.equal(executions, 0); + + const failureOut = outputBuffer(); + const failureExit = await runReadOnlyCommand({ + argv: ["--json"], + command: "better-harness fixture", + parse: () => { throw new LifecycleCliError("FIXTURE_INVALID", "Fixture invalid.", { kind: "usage" }); }, + usage: () => "fixture help\n", + execute: async () => { executions += 1; }, + envelope: () => { throw new Error("must not envelope failure"); }, + renderHuman: () => "must not render", + stdout: failureOut.stream, + stderr: outputBuffer().stream, + }); + assert.equal(failureExit, 64); + assert.equal(JSON.parse(failureOut.read()).diagnostics[0].code, "FIXTURE_INVALID"); + assert.equal(executions, 0); +}); + +test("shared runner owns timeout, envelope, human rendering, and exit mapping", async () => { + const machineOut = outputBuffer(); + const machineExit = await runReadOnlyCommand({ + argv: ["--json"], + command: "better-harness fixture", + parse: (argv) => normalizeReadOnlyTimeout(parseReadOnlyOptions(argv).options), + usage: () => "fixture help\n", + execute: async () => ({ status: "partial", diagnostics: [] }), + renderHuman: () => "fixture human\n", + stdout: machineOut.stream, + stderr: outputBuffer().stream, + }); + assert.equal(machineExit, 2); + const payload = JSON.parse(machineOut.read()); + assert.equal(payload.command, "better-harness fixture"); + assert.equal(payload.meta.sideEffects, "read-only"); + + const humanOut = outputBuffer(); + const humanExit = await runReadOnlyCommand({ + argv: [], + command: "better-harness fixture", + parse: (argv) => normalizeReadOnlyTimeout(parseReadOnlyOptions(argv).options), + usage: () => "fixture help\n", + execute: async () => ({ status: "ok", diagnostics: [] }), + envelope: () => { throw new Error("human mode must not create an envelope"); }, + renderHuman: () => "fixture human\n", + stdout: humanOut.stream, + stderr: outputBuffer().stream, + }); + assert.equal(humanExit, 0); + assert.equal(humanOut.read(), "fixture human\n"); +}); + +test("plugin and doctor CLIs compose the shared parser and runner", () => { + for (const relative of [ + "../scripts/plugin-lifecycle/cli.mjs", + "../scripts/harness-doctor/cli.mjs", + ]) { + const source = readFileSync(new URL(relative, import.meta.url), "utf8"); + assert.match(source, /parseReadOnlyOptions/u); + assert.match(source, /runReadOnlyCommand/u); + assert.doesNotMatch(source, /for \(let index = 0; index < argv\.length/u); + } +}); diff --git a/test/scripts-refactor-contract.test.mjs b/test/scripts-refactor-contract.test.mjs index 1dc3155..62eb28f 100644 --- a/test/scripts-refactor-contract.test.mjs +++ b/test/scripts-refactor-contract.test.mjs @@ -107,12 +107,12 @@ test("scripts refactor contract freezes machine-readable CLI output", () => { { label: "command inventory", args: ["commands", "--json"], - sha256: "e86c8b3447f24cf1f6a94f7ca74c48c5d194aa54bad736ff02d7dd64e566dc99", + sha256: "697dc328b47ead7be5b31bf870dfcc3ad431337a3a3d3bf40a7146c708de37b9", }, { label: "OpenCLI schema", args: ["schema"], - sha256: "b6b28fede8f135e598bc2278a36d2c94660edfe1b384c14f6e5da73c394a3347", + sha256: "425f5c3e3fa228bfc7dc2025d685f62166c98ffde3e52be1cf54a40a803a00c3", }, { label: "Harness command description", diff --git a/test/support-declarations.test.mjs b/test/support-declarations.test.mjs index 14136d2..fdda389 100644 --- a/test/support-declarations.test.mjs +++ b/test/support-declarations.test.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import test from "node:test"; import { PROVIDER_COLLECTORS } from "../scripts/agent-customize/providers/index.mjs"; +import { listHostProfiles } from "../scripts/host-support/index.mjs"; import { createAnalyzer, SESSION_ANALYSIS_HELP } from "../scripts/session-analysis/index.mjs"; // Canonical support declaration (roadmap A-06): CLI help, provider registry, @@ -62,6 +63,17 @@ test("agent-customize provider registry declares exactly the supported platforms } }); +test("shadow host-support profiles retain parity with canonical providers", () => { + const profiles = listHostProfiles(); + assertSameSet(profiles.map((profile) => profile.provider), "host-support provider references"); + assertSameSet(profiles.map((profile) => profile.hostId), "host-support host ids"); + + for (const profile of profiles) { + assert.ok(PROVIDER_COLLECTORS.has(profile.provider), `missing public inventory provider for ${profile.hostId}`); + assert.ok(profile.surfaces.length > 0, `host-support profile has no surfaces: ${profile.hostId}`); + } +}); + test("session-analysis platform loader declares exactly the supported platforms", async () => { for (const platform of SUPPORTED_PLATFORMS) { const platformModule = path.join(process.cwd(), "scripts", "session-analysis", "platforms", `${platform}.mjs`);