From b1573325e49dc48ac20f3dfd832844a03a2d6781 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Fri, 10 Jul 2026 14:41:13 -0700 Subject: [PATCH 1/2] docs: plan OpenCode Impeccable plugin --- ...opencode-impeccable-plugin-requirements.md | 123 ++++++++++ ...01-feat-opencode-impeccable-plugin-plan.md | 220 ++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 docs/brainstorms/2026-07-10-opencode-impeccable-plugin-requirements.md create mode 100644 docs/plans/2026-07-10-001-feat-opencode-impeccable-plugin-plan.md diff --git a/docs/brainstorms/2026-07-10-opencode-impeccable-plugin-requirements.md b/docs/brainstorms/2026-07-10-opencode-impeccable-plugin-requirements.md new file mode 100644 index 0000000..a3fd597 --- /dev/null +++ b/docs/brainstorms/2026-07-10-opencode-impeccable-plugin-requirements.md @@ -0,0 +1,123 @@ +--- +date: 2026-07-10 +topic: opencode-impeccable-plugin +--- + +# OpenCode Impeccable Plugin + +## Summary + +Add a repo-local OpenCode plugin that runs Impeccable's design detector after file-mutating tool calls and feeds any findings back to the agent mid-session. It mirrors the existing Copilot hook by wrapping the same shared `hook.mjs` brain, staying advisory and continuing to work across `npx impeccable update`. + +--- + +## Problem Frame + +Impeccable is set up in this repo as a CI gate (`npx impeccable detect`) and an installed skill, but the *interactive* design-feedback loop only exists for harnesses Impeccable natively supports — Claude Code, Codex, Cursor, and GitHub Copilot each get a post-tool-use hook that lints edits as they happen. OpenCode is not one of Impeccable's four supported harnesses, so an operator building UI in OpenCode gets no in-session signal; design-slop findings only surface later at the CI gate or in review, after the work is already assembled. The other harnesses catch it at the moment of the edit. OpenCode should get the same immediacy. + +--- + +## Actors + +- A1. OpenCode agent: performs file-mutating tool calls during a session; the recipient of detector feedback. +- A2. The plugin: a repo-local OpenCode plugin that observes tool calls and bridges them to Impeccable. +- A3. `hook.mjs`: Impeccable's shared, harness-agnostic detection brain (file filtering, config, detection, dedup). + +--- + +## Key Flows + +- F1. Edit-time detection + - **Trigger:** the agent completes a file-mutating tool call. + - **Actors:** A1, A2, A3. + - **Steps:** plugin sees the tool result → if the tool is a file-mutator, translates the event into the stdin shape `hook.mjs` expects → invokes `hook.mjs` → `hook.mjs` filters/detects/dedups → plugin takes any returned findings and attaches them to the tool result the agent sees. + - **Outcome:** the agent sees design findings for the file it just changed, in the same turn; clean or out-of-scope edits produce nothing. + - **Covered by:** R1, R2, R3, R5, R7. + +--- + +## Requirements + +**Plugin behavior & trigger** +- R1. Provide a repo-local OpenCode plugin that runs after file-mutating tool calls in an OpenCode session. +- R2. Trigger detection on the file-mutating tools actually used in this repo: OpenCode built-ins (`edit`, `write`, `apply_patch`) and the AFT MCP equivalents (e.g. `aft_edit`/`aft_write` and `_edit`/`_write`/`apply_patch`-suffixed MCP names). Non-mutating tools must not trigger detection. +- R3. When the detector returns findings for a changed file, surface them into the agent's context so the model sees them in the same turn. +- R4. Stay advisory: never block, deny, or fail a tool call, matching the always-exit-0 contract of the other harnesses. +- R5. When the change is out of the detector's scope (unsupported extension, ignored path, or clean result), add nothing to the agent's context. +- R6. Bound each detector invocation with a timeout (~5s, matching the Copilot hook's `timeoutSec: 5`); on timeout, degrade to a no-op with a logged warning so edits stay responsive. + +**Impeccable integration & resilience** +- R7. Reuse the shared `.agents/skills/impeccable/scripts/hook.mjs` brain rather than reimplementing file-filtering, config loading, detection, or dedup. The plugin is a thin translation layer from OpenCode's tool event to `hook.mjs`'s stdin contract. +- R8. Ensure `hook.mjs` recognizes the bridged OpenCode events as a supported harness shape, so its file extraction and config normalization apply correctly. +- R9. Keep working across `npx impeccable update`: the plugin lives outside Impeccable's four managed harness targets, so current update behavior is not expected to modify it — an expectation to verify (AE4), not a guarantee. The plugin depends only on `hook.mjs`'s stdin/stdout contract, which update maintains. +- R10. Respect the existing `.impeccable/config.json` (ignore rules/values). Reusing `hook.mjs` provides this; the plugin must not bypass it. +- R11. Fail loud, not silent: if the plugin cannot normalize an OpenCode tool event or cannot invoke/parse `hook.mjs` output, emit a one-time non-blocking warning into the session rather than silently doing nothing — so a broken bridge can't masquerade as "no findings." + +**Setup & verification** +- R12. Register the plugin so OpenCode loads it from a repo-local location. The exact mechanism (an `opencode.json` `plugin` entry vs. an auto-discovered plugin directory) is settled in planning. +- R13. Prompt the operator to restart OpenCode to load the new repo-local plugin, then verify. +- R14. Verify end-to-end in a real OpenCode session: an edit introducing a detectable design issue surfaces Impeccable feedback to the agent; a clean edit does not. + +--- + +## Acceptance Examples + +- AE1. **Covers R2, R3.** Given the plugin is loaded, when the agent edits a file introducing a detectable design issue, then Impeccable findings appear in the agent's tool-result context that same turn. +- AE2. **Covers R4, R5.** Given the agent edits a file with no findings, when the tool completes, then the tool result is unchanged and the edit is never blocked. +- AE3. **Covers R2.** Given the agent uses an AFT MCP edit tool rather than the built-in `edit`, when it mutates a file, then detection still runs. +- AE4. **Covers R9.** Given `npx impeccable update` runs and regenerates the four managed harness hooks, when it finishes, then the OpenCode plugin file and its registration remain intact and functional. +- AE5. **Covers R11.** Given the plugin cannot parse a tool event or `hook.mjs` output, when the tool completes, then a one-time non-blocking warning surfaces and the edit is not blocked. +- AE6. **Covers R6.** Given the detector exceeds the timeout, when the tool completes, then the plugin degrades to a no-op with a logged warning and the edit is not stalled. + +--- + +## Success Criteria + +- Operators using OpenCode get the same in-session design feedback that Copilot/Claude/Cursor/Codex users already get. +- A downstream implementer can confirm the plugin fires on the repo's real edit tools, surfaces findings to the agent, and stays advisory. +- The integration continues to work after `npx impeccable update` (verified, not assumed). + +--- + +## Scope Boundaries + +- Upstreaming first-class OpenCode support into Impeccable (a `hook-admin` target + generator) is deferred; this is a repo-local plugin only. +- No hard gate or blocking behavior — advisory only. +- No change to the CI `impeccable detect` gate; this complements the interactive dev loop, it does not replace CI enforcement. +- No new detector rules or `.impeccable/config.json` changes. + +--- + +## Key Decisions + +- Thin adapter over `hook.mjs`, not `impeccable detect` directly and not upstreaming: reuses Impeccable's filtering/config/dedup and survives `impeccable update` via the stable `hook.mjs` contract. +- Match both built-in and AFT-MCP mutation tool names: this repo's edits go through AFT MCP tools, so matching only built-ins would make the hook never fire — the load-bearing correctness point for this repo. +- Advisory, never blocking: matches `hook.mjs`'s always-exit-0 semantics and the other harnesses. + +--- + +## Dependencies / Assumptions + +- Depends on `.agents/skills/impeccable/scripts/hook.mjs` and its stdin-JSON → stdout-ack contract. +- Depends on OpenCode's `tool.execute.after` plugin hook and the `plugin` config key (repo currently has no `opencode.json`; it will be created). +- Assumes `node` and `npx impeccable` are available in the session environment (already true — CI and the Copilot hook rely on them). + +--- + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R3][Technical] For AFT MCP tools, `tool.execute.after` may deliver a raw MCP result (`content[]`) rather than `output.output`. Planning must verify which shape AFT tools deliver and where to attach feedback so the model actually sees it. +- [Affects R8][Technical] Confirm how `hook.mjs` should be told the harness is OpenCode — an `IMPECCABLE_HOOK_HARNESS` override versus emitting one of its recognized event shapes (claude/cursor/github). Pick the mapping `hook.mjs` normalizes cleanly. +- [Affects R2][Needs research] Confirm the exact tool name AFT file edits surface as to the hook (`aft_edit` vs other) by observing a live session or the MCP catalog. +- [Affects R12][Technical] Choose the plugin registration mechanism: an explicit `plugin` entry in a new `opencode.json` (required for a custom path like `.opencode/impeccable/plugin.ts`) vs. an auto-discovered `.opencode/plugin/` file. + +--- + +## Sources / Research + +- Copilot hook `/.github/hooks/impeccable.json`: a `postToolUse` command matching `edit|create|apply_patch` that runs `node .agents/skills/impeccable/scripts/hook.mjs` — the pattern this plugin mirrors. +- `hook.mjs` contract: reads event JSON on **stdin**, writes an ack/reminder payload to **stdout**, always exits 0 (advisory). Does its own file filtering, `.impeccable/config.json` loading, detection, and dedup. Recognizes `claude`/`cursor`/`github` event shapes and an `IMPECCABLE_HOOK_HARNESS` override. +- Impeccable manages hooks for four harnesses only (Claude/Codex/Cursor/Copilot); no OpenCode target exists, so `impeccable update` will not clobber a repo-local `.opencode/` plugin. +- OpenCode plugin API: `tool.execute.after` hook `(input: {tool, sessionID, callID, args}, output: {title, output, metadata})`; feedback reaches the model by appending to `output.output` for native tools; MCP tools surface as `sanitize(server)_sanitize(tool)` (e.g. `aft_edit`) and may need `content[]` mutation instead. A custom-path plugin (`.opencode/impeccable/plugin.ts`) needs an explicit `plugin` entry in `opencode.json`; files in `.opencode/plugin/` are auto-discovered. Docs: opencode.ai/docs/plugins, opencode.ai/docs/config. diff --git a/docs/plans/2026-07-10-001-feat-opencode-impeccable-plugin-plan.md b/docs/plans/2026-07-10-001-feat-opencode-impeccable-plugin-plan.md new file mode 100644 index 0000000..7155416 --- /dev/null +++ b/docs/plans/2026-07-10-001-feat-opencode-impeccable-plugin-plan.md @@ -0,0 +1,220 @@ +--- +title: "feat: OpenCode Impeccable plugin" +type: feat +status: active +date: 2026-07-10 +origin: docs/brainstorms/2026-07-10-opencode-impeccable-plugin-requirements.md +--- + +# feat: OpenCode Impeccable plugin + +## Overview + +Add a repo-local OpenCode plugin that runs Impeccable's design detector after file-mutating tool calls and surfaces any findings back to the agent in the same turn. It mirrors the existing Copilot hook (`.github/hooks/impeccable.json`) by bridging OpenCode's `tool.execute.after` event into the same shared `hook.mjs` brain, so it stays advisory and keeps working across `npx impeccable update`. + +## Problem Frame + +Impeccable ships interactive post-tool-use hooks for four harnesses (Claude Code, Codex, Cursor, GitHub Copilot); OpenCode is not one of them. An operator building UI in OpenCode gets no in-session design signal — findings only appear later at the CI `impeccable detect` gate or in review, after the work is assembled. The other harnesses catch it at the moment of the edit. This plan gives OpenCode the same immediacy without forking Impeccable's detection logic. (See origin: docs/brainstorms/2026-07-10-opencode-impeccable-plugin-requirements.md.) + +## Requirements Trace + +- R1-R5. Repo-local plugin fires after file-mutating tool calls, surfaces findings the same turn, stays advisory, and stays silent when out of scope. +- R6. Bound each detector run with a timeout (~5s) and degrade to no-op. +- R7-R8. Reuse `hook.mjs` as a thin adapter; make `hook.mjs` recognize the bridged events as a supported harness shape. +- R9. Survive `npx impeccable update` (verify, not assume). +- R10. Respect `.impeccable/config.json` via `hook.mjs`. +- R11. Fail loud, not silent, on bridge/parse failure. +- R12-R14. Register the plugin, prompt restart, verify end-to-end in a live session. + +## Scope Boundaries + +- Advisory only — no hard gate, no blocking a tool call. +- No change to the CI `impeccable detect` gate. +- No new detector rules or `.impeccable/config.json` edits. + +### Deferred to Separate Tasks + +- Upstreaming first-class OpenCode support into Impeccable (a `hook-admin` target + generator): separate effort, external repo. + +## Context & Research + +### Relevant Code and Patterns + +- `.github/hooks/impeccable.json` — the Copilot hook this plugin mirrors: `postToolUse`, `matcher: "edit|create|apply_patch"`, runs `node .agents/skills/impeccable/scripts/hook.mjs`, `timeoutSec: 5`. +- `.agents/skills/impeccable/scripts/hook.mjs` — the shared brain. Reads an event JSON on **stdin**, does its own file filtering / `.impeccable/config.json` loading / detection / dedup, writes an ack payload to **stdout**, and **always exits 0** (advisory). Recognizes `claude`/`cursor`/`github` event shapes and an `IMPECCABLE_HOOK_HARNESS` env override; honors `IMPECCABLE_HOOK_DEPTH`. +- `.impeccable/config.json` — detector ignore rules/values; consumed by `hook.mjs`, not by this plugin. +- AFT is loaded as an OpenCode **plugin** (`@cortexkit/aft-opencode`), so this repo's edits surface as tool names to match at runtime — exact name resolved live (see Deferred). + +### Institutional Learnings + +- `docs/solutions/workflow-issues/impeccable-skill-shared-agents-install-2026-06-25.md` — the agent skill's `hook.mjs` is distinct from the CI detector; install/reuse it in place under `.agents/skills/`, do not reimplement detection. + +### External References + +- OpenCode plugin API (opencode.ai/docs/plugins, opencode.ai/docs/config): `tool.execute.after(input: {tool, sessionID, callID, args}, output: {title, output, metadata})`; feedback the model sees is appended to `output.output` for native tools; MCP tools may surface a raw `content[]` result instead. A custom-path plugin needs an explicit `plugin` entry in `opencode.json`; `.opencode/plugin/` files are auto-discovered. Shell via the injected `$` (Bun). + +## Key Technical Decisions + +- Thin adapter over `hook.mjs` (not `impeccable detect` directly, not upstreaming): reuses Impeccable's filtering/config/dedup and rides its update maintenance via the stable stdin/stdout contract. +- Advisory + fail-loud: never block a tool; but if the bridge itself can't run or parse, emit a one-time visible warning so a dead bridge can't masquerade as "clean." +- Match both bare (`edit`/`write`/`apply_patch`) and suffixed (`*_edit`/`*_write`/`*_apply_patch`) tool names, so the hook fires whether edits come from OpenCode built-ins or an AFT-namespaced tool. +- Register via `.opencode/impeccable/plugin.ts` + a new repo `opencode.json` `plugin` entry (per the original brief), keeping the plugin at a self-documenting path. + +## Open Questions + +### Resolved During Planning + +- Run detection via `hook.mjs` or `impeccable detect`? → `hook.mjs` (thin adapter; reuses filtering/config/dedup, survives update). +- Blocking or advisory? → advisory, matching `hook.mjs`'s always-exit-0 contract. + +### Deferred to Implementation + +- Exact feedback attachment field for this repo's edit tools (`output.output` for native vs `content[]` for MCP-shaped results) — resolve by observing a live session; implement native-path first, add the MCP-content path if the live tool delivers that shape. +- Exact tool name AFT file edits surface as (`edit` vs `aft_edit` vs other) — resolve from a live session / the loaded plugin's tool set; the dual bare+suffixed matcher covers both pending confirmation. +- How to signal the OpenCode harness to `hook.mjs` — `IMPECCABLE_HOOK_HARNESS` override vs emitting a recognized (`github`-shaped) event; pick whichever `hook.mjs` normalizes cleanly when exercised. + +## Output Structure + + opencode.json # new: repo config with plugin entry + .opencode/ + impeccable/ + plugin.ts # new: the plugin + plugin.test.ts # new: unit tests for pure helpers + +## Implementation Units + +- [ ] **Unit 1: Registration + pure helpers** + +**Goal:** Register a repo-local plugin OpenCode will load, and implement the pure, side-effect-free helpers the runtime hook depends on. + +**Requirements:** R12, R2 (partial), R5 (partial) + +**Dependencies:** None + +**Files:** +- Create: `opencode.json` +- Create: `.opencode/impeccable/plugin.ts` (export skeleton + helpers) +- Create: `.opencode/impeccable/plugin.test.ts` + +**Approach:** +- Add `opencode.json` with `$schema` and `plugin: ["./.opencode/impeccable/plugin.ts"]`. +- Export a `Plugin` factory returning a `tool.execute.after` hook (body stubbed in Unit 2). +- Implement pure helpers: `isMutatingTool(name)` (matches bare + suffixed edit/write/apply_patch), `extractTouchedPaths(tool, args)` (handles `filePath` and `apply_patch` marker lines), and `buildHookPayload(input)` (shapes the stdin JSON `hook.mjs` expects). + +**Patterns to follow:** +- The Copilot matcher set `edit|create|apply_patch` (`.github/hooks/impeccable.json`), widened for OpenCode's `write` and MCP suffixes. + +**Test scenarios:** +- Happy path: `isMutatingTool` returns true for `edit`, `write`, `apply_patch`, `aft_edit`, `x_write`; false for `read`, `bash`, `grep`. +- Edge case: `extractTouchedPaths` pulls `filePath` from edit/write args; parses Add/Update/Delete/Move marker lines from an `apply_patch` `patchText`; returns `[]` for missing/empty args. +- Happy path: `buildHookPayload` produces the exact field names `hook.mjs` reads (tool name, file path) for a representative edit event. + +**Verification:** +- `opencode.json` validates against the schema; helper unit tests pass. + +--- + +- [ ] **Unit 2: hook.mjs bridge runtime** + +**Goal:** Implement the `tool.execute.after` body — invoke `hook.mjs` on the touched file with a timeout, identify the OpenCode harness, and capture its stdout. + +**Requirements:** R1, R3 (produce), R4, R6, R7, R8, R10 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `.opencode/impeccable/plugin.ts` +- Modify: `.opencode/impeccable/plugin.test.ts` + +**Approach:** +- On a mutating tool, shape the payload (Unit 1) and pipe it to `node .agents/skills/impeccable/scripts/hook.mjs` via the injected `$`, `cwd` at the worktree, `.nothrow().quiet()`. +- Wrap the invocation in a ~5s timeout; on timeout, abandon and continue (no-op, logged). +- Set the harness signal (`IMPECCABLE_HOOK_HARNESS`) and a defensive `IMPECCABLE_HOOK_DEPTH` guard so nested runs can't recurse. +- Return early (no work) for non-mutating tools. + +**Execution note:** Resolve the harness-signal and exact-tool-name questions against a live session here (see Deferred to Implementation) before finalizing the matcher and env. + +**Patterns to follow:** +- `hook.mjs`'s stdin-JSON contract and `IMPECCABLE_HOOK_HARNESS` override. +- The Copilot hook's `timeoutSec: 5`. + +**Test scenarios:** +- Happy path: a mutating tool event triggers one `hook.mjs` invocation with the file path in the piped payload (subprocess stubbed/faked). +- Edge case: a non-mutating tool triggers no invocation. +- Error path: a `hook.mjs` invocation exceeding the timeout is abandoned and the hook returns without throwing. + +**Verification:** +- With the subprocess faked, mutating events invoke the bridge once with the correct payload; non-mutating events don't; timeout degrades to no-op. + +--- + +- [ ] **Unit 3: Surface feedback + fail-loud guard** + +**Goal:** Attach `hook.mjs` findings to what the agent sees, and emit a one-time visible warning when the bridge can't run or parse — never fail silently. + +**Requirements:** R3 (surface), R5, R9 (partial), R11 + +**Dependencies:** Unit 2 + +**Files:** +- Modify: `.opencode/impeccable/plugin.ts` +- Modify: `.opencode/impeccable/plugin.test.ts` + +**Approach:** +- When `hook.mjs` returns findings, append them to the native tool result (`output.output`); if the live tool delivers an MCP `content[]` shape instead, push a text item there (resolve which path applies live, see Deferred). +- When there is no finding (clean / out-of-scope), leave the result untouched. +- Fail-loud: if payload normalization throws, the subprocess errors, or stdout can't be parsed, surface a one-time non-blocking warning (session-scoped guard so it fires once, not per edit) and continue. + +**Test scenarios:** +- Happy path: given a findings payload, the tool output gains the feedback text; the model-visible field is the one mutated. +- Edge case: given a clean/empty payload, the tool output is byte-unchanged. +- Error path: given an unparseable `hook.mjs` output, a warning is emitted once and the edit is not blocked; a second failure in the same session does not re-warn. + +**Verification:** +- Feedback appears only on findings; clean edits are untouched; a simulated bridge failure warns once and never throws. + +--- + +- [ ] **Unit 4: Live verification + update-survival check** + +**Goal:** Prove the assembled plugin works in a real OpenCode session and survives `npx impeccable update`. + +**Requirements:** R13, R14, R9 + +**Dependencies:** Units 1-3 + +**Files:** +- None (verification unit; no new code unless live findings require a fix) + +**Approach:** +- Prompt the operator to restart OpenCode to load the repo-local plugin. +- In a live session: make an edit that introduces a detectable design issue and confirm the finding surfaces to the agent that turn; make a clean edit and confirm nothing surfaces and nothing is blocked. +- Run `npx impeccable update` and confirm `opencode.json` and `.opencode/impeccable/plugin.ts` are unchanged and still functional. +- If the live session reveals the wrong tool name, attachment field, or harness signal, fold the fix back into Units 2/3. + +**Test expectation:** none — live-session verification; the code-level behavior is covered by Units 1-3 unit tests. + +**Verification:** +- Detectable edit → finding surfaces; clean edit → silent, unblocked; post-`update` the plugin file and registration are intact. + +## System-Wide Impact + +- **Interaction graph:** the plugin observes every `tool.execute.after`; only mutating tools do work, everything else returns immediately. +- **Error propagation:** bridge/detector failures never propagate to the tool — they degrade to a one-time warning (R11) or silent no-op on timeout (R6). +- **Re-entrancy:** detection runs as a subprocess, outside the tool system, so it cannot re-trigger `tool.execute.after`; a depth-guard env is set defensively. +- **Unchanged invariants:** the CI `impeccable detect` gate, `.impeccable/config.json`, and the four Impeccable-managed harness hooks are untouched; this only adds an OpenCode-local surface. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Wrong edit-tool name → hook never fires (silent) | Dual bare+suffixed matcher; live-session verification (Unit 4); fail-loud guard surfaces a dead bridge. | +| Feedback attached to a field the model doesn't see | Resolve the native-vs-`content[]` attachment path live before finalizing (Unit 3 / Deferred). | +| Slow detector stalls edits | ~5s timeout with no-op fallback (R6), matching the Copilot hook. | +| Future `impeccable update` changes `hook.mjs`'s contract or starts targeting `.opencode/` | Depend only on the documented stdin/stdout contract; Unit 4 re-checks survival; contract break would surface as a fail-loud warning, not a silent break. | + +## Sources & References + +- **Origin document:** docs/brainstorms/2026-07-10-opencode-impeccable-plugin-requirements.md +- Related code: `.github/hooks/impeccable.json`, `.agents/skills/impeccable/scripts/hook.mjs`, `.impeccable/config.json` +- External docs: opencode.ai/docs/plugins, opencode.ai/docs/config From fb61597b0977eea5a1b18cd6250f7a7ec18d1a59 Mon Sep 17 00:00:00 2001 From: "Marcus R. Brown" Date: Fri, 10 Jul 2026 15:10:31 -0700 Subject: [PATCH 2/2] feat(opencode): add repo-local Impeccable design-hook plugin Bridges OpenCode's tool.execute.after into the shared Impeccable hook.mjs brain so file-mutating edits get in-session design feedback, mirroring the Copilot hook. Advisory-only, ~5s timeout, fail-loud on bridge errors. Pure helpers and an injectable detector runner keep the logic Node-testable; the Bun-$ runner is wired only in the exported plugin factory. --- .gitignore | 2 + .opencode/impeccable/hook-bridge.ts | 260 +++++++++++++++ .opencode/impeccable/plugin.test.ts | 315 ++++++++++++++++++ .opencode/impeccable/plugin.ts | 48 +++ .opencode/tsconfig.json | 11 + ...01-feat-opencode-impeccable-plugin-plan.md | 8 +- eslint.config.ts | 2 +- opencode.json | 4 + package.json | 3 +- pnpm-lock.yaml | 191 +++++++++++ pnpm-workspace.yaml | 3 + tsconfig.json | 2 +- vitest.config.ts | 4 +- 13 files changed, 844 insertions(+), 9 deletions(-) create mode 100644 .opencode/impeccable/hook-bridge.ts create mode 100644 .opencode/impeccable/plugin.test.ts create mode 100644 .opencode/impeccable/plugin.ts create mode 100644 .opencode/tsconfig.json create mode 100644 opencode.json diff --git a/.gitignore b/.gitignore index 6acea64..8ed3e83 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,10 @@ web/dist-fixture # Opencode !.opencode/ .opencode/* +!.opencode/impeccable/ !.opencode/themes/ !.opencode/tui.json +!.opencode/tsconfig.json # impeccable-ignore-start # Ephemeral output, runtime state, and per-dev overrides. diff --git a/.opencode/impeccable/hook-bridge.ts b/.opencode/impeccable/hook-bridge.ts new file mode 100644 index 0000000..4b0c054 --- /dev/null +++ b/.opencode/impeccable/hook-bridge.ts @@ -0,0 +1,260 @@ +/** + * Pure, Node-testable core of the Impeccable OpenCode bridge. + * + * Mirrors `.github/hooks/impeccable.json`: after a file-mutating tool call, + * pipe a hook.mjs-shaped event to `.agents/skills/impeccable/scripts/hook.mjs` + * over stdin and surface any findings back to the agent in the same turn. + * + * Advisory only — never blocks a tool call. Fail-loud: bridge/parse failures + * emit a one-time warning per session instead of silently doing nothing. + * + * This module must NOT import from `@opencode-ai/plugin` and must NOT use Bun + * `$` — the OpenCode plugin loader iterates a plugin module's exports and + * treats each as a candidate plugin factory, so `.opencode/impeccable/plugin.ts` + * must export ONLY the plugin factory. Everything else lives here. + */ + +/** + * The value `hook.mjs`'s `resolveHarness()` reads via the `IMPECCABLE_HOOK_HARNESS` + * env override. Our payload is claude-shaped + * (`tool_name`/`tool_input.file_path`/`tool_input.command`), and + * `normalizeHookEvent(event, cwd, 'claude')` passes claude-harness events + * through UNCHANGED (hook-lib.mjs: `if (harness !== 'cursor') return event`). + * 'github' would instead route through `normalizeGitHubEvent`, which expects + * camelCase `toolName`/`toolArgs` and would only work by incidental spread of + * our pre-set `tool_input` — silently dropping apply_patch multi-file parsing. + */ +export const IMPECCABLE_HOOK_HARNESS = 'claude' + +/** Absolute-worktree-relative path to the shared hook script, matching `.github/hooks/impeccable.json`. */ +export const HOOK_SCRIPT_RELATIVE_PATH = '.agents/skills/impeccable/scripts/hook.mjs' + +/** Matches the Copilot hook's `timeoutSec: 5`. */ +export const DETECTOR_TIMEOUT_MS = 5000 + +const MUTATING_BASE_TOOLS = new Set(['edit', 'write', 'apply_patch']) +const MUTATING_SUFFIX_RE = /_(edit|write|apply_patch)$/ + +/** True for bare (`edit`/`write`/`apply_patch`) and MCP-namespaced-suffixed (`aft_edit`, `x_write`, …) mutating tool names. */ +export function isMutatingTool(tool: string): boolean { + if (typeof tool !== 'string' || !tool) return false + if (MUTATING_BASE_TOOLS.has(tool)) return true + return MUTATING_SUFFIX_RE.test(tool) +} + +const APPLY_PATCH_FILE_LINE_RE = /^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm + +function parseApplyPatchPaths(patchText: string): string[] { + const out: string[] = [] + for (const match of patchText.matchAll(APPLY_PATCH_FILE_LINE_RE)) { + const p = (match[1] ?? '').trim() + if (p && !out.includes(p)) out.push(p) + } + return out +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Pulls the file path(s) a tool call touched from its args: `filePath` for edit/write, patch marker lines for `apply_patch`. Returns `[]` for missing/malformed args. */ +export function extractTouchedPaths(tool: string, args: unknown): string[] { + if (!isRecord(args)) return [] + + if (tool === 'apply_patch' || MUTATING_SUFFIX_RE.exec(tool)?.[1] === 'apply_patch') { + const patchText = args.patchText + if (typeof patchText === 'string' && patchText) return parseApplyPatchPaths(patchText) + return [] + } + + const filePath = args.filePath + if (typeof filePath === 'string' && filePath) return [filePath] + return [] +} + +/** Strips an MCP namespace prefix (`aft_edit`→`edit`) so `tool_name` matches the bare form hook.mjs's `resolveTargetFiles` special-cases (`tool_name === 'apply_patch'` exactly). Passes bare names and non-mutating names through unchanged. */ +export function bareToolName(tool: string): string { + const match = MUTATING_SUFFIX_RE.exec(tool) + return match?.[1] ?? tool +} + +export interface BuildHookPayloadInput { + tool: string + args: unknown + cwd: string + sessionID: string +} + +/** + * Shapes the stdin JSON `hook.mjs` reads. `tool_name` is normalized to its + * bare form. For `apply_patch` (bare or MCP-suffixed), `tool_input.command` + * carries the raw patch text so hook.mjs's own `parseApplyPatchPaths` can + * extract every touched file — hook.mjs only reads `tool_input.command` when + * `tool_name === 'apply_patch'` exactly, so a `file_path`-only payload would + * silently drop all but incidental files. For edit/write, `tool_input.file_path` + * carries the first touched path. + */ +export function buildHookPayload(input: BuildHookPayloadInput): { + tool_name: string + tool_input: { file_path: string } | { command: string } + cwd: string + session_id: string +} { + const bareName = bareToolName(input.tool) + + let toolInput: { file_path: string } | { command: string } + if (bareName === 'apply_patch') { + const args = isRecord(input.args) ? input.args : {} + const patchText = typeof args.patchText === 'string' ? args.patchText : '' + toolInput = { command: patchText } + } else { + const [firstPath] = extractTouchedPaths(input.tool, input.args) + toolInput = { file_path: firstPath ?? '' } + } + + return { + tool_name: bareName, + tool_input: toolInput, + cwd: input.cwd, + session_id: input.sessionID, + } +} + +export interface DetectorRunResult { + stdout: string + stderr: string + exitCode: number +} + +/** Injectable subprocess runner: given the hook payload and the worktree cwd, returns the detector's stdout/stderr/exitCode. */ +export type DetectorRunner = ( + payload: ReturnType, + opts: { worktree: string }, +) => Promise + +export interface CreateHookOptions { + runDetector: DetectorRunner + worktree: string + timeoutMs?: number +} + +const TIMEOUT_SENTINEL = Symbol('impeccable-hook-timeout') + +async function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timer: ReturnType + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs) + }) + try { + return await Promise.race([promise, timeout]) + } finally { + clearTimeout(timer!) + } +} + +const FEEDBACK_OPEN = '\n\n\n' +const FEEDBACK_CLOSE = '\n' + +/** + * Extracts the human-readable feedback text from hook.mjs's stdout. For the + * claude harness, hook.mjs writes a JSON envelope + * `{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"…"}}` + * — this pulls just the inner text so the agent doesn't see raw JSON. + * Defensive: never drops content — falls back to the raw trimmed stdout on + * parse failure or when no recognized field is present. + */ +export function extractFeedbackText(stdout: string): string { + const trimmed = stdout.trim() + if (!trimmed) return '' + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + return trimmed + } + + if (!isRecord(parsed)) return trimmed + + const hookSpecificOutput = isRecord(parsed.hookSpecificOutput) ? parsed.hookSpecificOutput : undefined + const candidates = [ + hookSpecificOutput?.additionalContext, + parsed.additionalContext, + parsed.additional_context, + ] + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate) return candidate + } + + return trimmed +} + +/** + * Builds the `tool.execute.after` handler, with the subprocess runner + * injected so all logic here is testable under Node. + */ +export function createHook(options: CreateHookOptions) { + const timeoutMs = options.timeoutMs ?? DETECTOR_TIMEOUT_MS + let hasWarned = false + + return async function toolExecuteAfter( + input: { tool: string; sessionID: string; callID: string; args: unknown }, + output: { title: string; output: string; metadata: unknown }, + ): Promise { + // Fail-loud: appends a one-time (per session) non-blocking warning to the + // tool's model-visible output. Never throws. + const warnOnce = (message: string) => { + if (hasWarned) return + hasWarned = true + try { + output.output += `${FEEDBACK_OPEN}${message}${FEEDBACK_CLOSE}` + } catch { + // Fail-loud must never itself throw. + } + } + + try { + if (!isMutatingTool(input.tool)) return + + const touchedPaths = extractTouchedPaths(input.tool, input.args) + if (touchedPaths.length === 0) return + + const payload = buildHookPayload({ + tool: input.tool, + args: input.args, + cwd: options.worktree, + sessionID: input.sessionID, + }) + + let result: DetectorRunResult | typeof TIMEOUT_SENTINEL + try { + result = await withTimeout(options.runDetector(payload, { worktree: options.worktree }), timeoutMs) + } catch (err) { + warnOnce(`[impeccable] design hook bridge failed: ${err instanceof Error ? err.message : String(err)}`) + return + } + + if (result === TIMEOUT_SENTINEL) { + // Degrade to no-op on timeout, no warning — this is expected + // backpressure, not a broken bridge. + return + } + + // hook.mjs is contractually always-exit-0; a nonzero exit means the + // invocation itself broke (bad node path, unreadable script, etc.), which + // must fail loud rather than masquerade as a clean scan. + if (result.exitCode !== 0) { + const detail = result.stderr?.trim() || `exit ${result.exitCode}` + warnOnce(`[impeccable] design hook did not run cleanly: ${detail}`) + return + } + + const feedback = extractFeedbackText(result.stdout ?? '') + if (!feedback) return + + output.output += `${FEEDBACK_OPEN}${feedback}${FEEDBACK_CLOSE}` + } catch (err) { + warnOnce(`[impeccable] design hook bridge error: ${err instanceof Error ? err.message : String(err)}`) + } + } +} diff --git a/.opencode/impeccable/plugin.test.ts b/.opencode/impeccable/plugin.test.ts new file mode 100644 index 0000000..2c1b839 --- /dev/null +++ b/.opencode/impeccable/plugin.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from 'vitest' + +import { + bareToolName, + buildHookPayload, + createHook, + extractFeedbackText, + extractTouchedPaths, + isMutatingTool, + type DetectorRunResult, +} from './hook-bridge.ts' + +function makeOutput(initial = 'ok') { + return { title: 't', output: initial, metadata: {} } +} + +function makeInput(overrides: Partial<{ tool: string; sessionID: string; callID: string; args: unknown }> = {}) { + return { + tool: 'edit', + sessionID: 'sess-1', + callID: 'call-1', + args: { filePath: '/repo/web/src/App.tsx' }, + ...overrides, + } +} + +describe('isMutatingTool', () => { + it('is true for bare mutating tool names', () => { + expect(isMutatingTool('edit')).toBe(true) + expect(isMutatingTool('write')).toBe(true) + expect(isMutatingTool('apply_patch')).toBe(true) + }) + + it('is true for MCP-namespaced suffixed tool names', () => { + expect(isMutatingTool('aft_edit')).toBe(true) + expect(isMutatingTool('x_write')).toBe(true) + expect(isMutatingTool('aft_apply_patch')).toBe(true) + }) + + it('is false for non-mutating tools', () => { + expect(isMutatingTool('read')).toBe(false) + expect(isMutatingTool('bash')).toBe(false) + expect(isMutatingTool('grep')).toBe(false) + }) +}) + +describe('extractTouchedPaths', () => { + it('pulls filePath from edit/write args', () => { + expect(extractTouchedPaths('edit', { filePath: '/a/b.ts' })).toEqual(['/a/b.ts']) + expect(extractTouchedPaths('write', { filePath: '/a/c.ts' })).toEqual(['/a/c.ts']) + }) + + it('parses Add/Update/Delete/Move marker lines from apply_patch patchText', () => { + const patchText = [ + '*** Begin Patch', + '*** Add File: a.ts', + '+content', + '*** Update File: b.ts', + '*** Delete File: c.ts', + '*** Move to: d.ts', + '*** End Patch', + ].join('\n') + expect(extractTouchedPaths('apply_patch', { patchText })).toEqual(['a.ts', 'b.ts', 'c.ts', 'd.ts']) + }) + + it('returns [] for missing/empty args', () => { + expect(extractTouchedPaths('edit', undefined)).toEqual([]) + expect(extractTouchedPaths('edit', null)).toEqual([]) + expect(extractTouchedPaths('edit', {})).toEqual([]) + expect(extractTouchedPaths('apply_patch', {})).toEqual([]) + expect(extractTouchedPaths('edit', 'not-an-object')).toEqual([]) + }) + + it('parses marker lines for an MCP-suffixed apply_patch tool name', () => { + const patchText = '*** Add File: a.ts\n*** Update File: b.ts\n' + expect(extractTouchedPaths('aft_apply_patch', { patchText })).toEqual(['a.ts', 'b.ts']) + }) +}) + +describe('bareToolName', () => { + it('passes bare mutating and non-mutating names through unchanged', () => { + expect(bareToolName('edit')).toBe('edit') + expect(bareToolName('write')).toBe('write') + expect(bareToolName('apply_patch')).toBe('apply_patch') + expect(bareToolName('read')).toBe('read') + }) + + it('strips MCP namespace prefixes', () => { + expect(bareToolName('aft_edit')).toBe('edit') + expect(bareToolName('x_apply_patch')).toBe('apply_patch') + }) +}) + +describe('buildHookPayload', () => { + it('produces the exact field names hook.mjs reads for a representative edit event', () => { + const payload = buildHookPayload({ + tool: 'edit', + args: { filePath: '/repo/web/src/App.tsx' }, + cwd: '/repo', + sessionID: 'sess-1', + }) + expect(payload).toEqual({ + tool_name: 'edit', + tool_input: { file_path: '/repo/web/src/App.tsx' }, + cwd: '/repo', + session_id: 'sess-1', + }) + }) + + it('normalizes an MCP-suffixed edit tool_name to bare and keeps file_path', () => { + const payload = buildHookPayload({ + tool: 'aft_edit', + args: { filePath: '/repo/web/src/App.tsx' }, + cwd: '/repo', + sessionID: 'sess-1', + }) + expect(payload.tool_name).toBe('edit') + expect(payload.tool_input).toEqual({ file_path: '/repo/web/src/App.tsx' }) + }) + + it('sets tool_input.command to the patch text for apply_patch (no file_path)', () => { + const patchText = '*** Begin Patch\n*** Add File: a.ts\n+x\n*** End Patch' + const payload = buildHookPayload({ + tool: 'apply_patch', + args: { patchText }, + cwd: '/repo', + sessionID: 'sess-1', + }) + expect(payload.tool_name).toBe('apply_patch') + expect(payload.tool_input).toEqual({ command: patchText }) + }) + + it('sets tool_input.command to the patch text for an MCP-suffixed apply_patch tool_name', () => { + const patchText = '*** Begin Patch\n*** Update File: b.ts\n*** End Patch' + const payload = buildHookPayload({ + tool: 'aft_apply_patch', + args: { patchText }, + cwd: '/repo', + sessionID: 'sess-1', + }) + expect(payload.tool_name).toBe('apply_patch') + expect(payload.tool_input).toEqual({ command: patchText }) + }) + + it('falls back to empty string values when args are empty', () => { + expect(buildHookPayload({ tool: 'edit', args: {}, cwd: '/r', sessionID: 's' }).tool_input).toEqual({ + file_path: '', + }) + expect(buildHookPayload({ tool: 'apply_patch', args: {}, cwd: '/r', sessionID: 's' }).tool_input).toEqual({ + command: '', + }) + }) +}) + +function fakeRunner(result: DetectorRunResult | Error, calls: unknown[][] = []) { + return async (...args: unknown[]) => { + calls.push(args) + if (result instanceof Error) throw result + return result + } +} + +describe('bridge (createHook)', () => { + it('invokes the runner once with the file path in the payload for a mutating tool', async () => { + const calls: unknown[][] = [] + const hook = createHook({ + runDetector: fakeRunner({ stdout: '', stderr: '', exitCode: 0 }, calls), + worktree: '/repo', + }) + await hook(makeInput(), makeOutput()) + expect(calls).toHaveLength(1) + const [payload] = calls[0] as [ReturnType] + expect(payload.tool_input).toEqual({ file_path: '/repo/web/src/App.tsx' }) + }) + + it('invokes the runner zero times for a non-mutating tool', async () => { + const calls: unknown[][] = [] + const hook = createHook({ + runDetector: fakeRunner({ stdout: '', stderr: '', exitCode: 0 }, calls), + worktree: '/repo', + }) + await hook(makeInput({ tool: 'read' }), makeOutput()) + expect(calls).toHaveLength(0) + }) + + it('invokes the runner zero times when no path can be extracted', async () => { + const calls: unknown[][] = [] + const hook = createHook({ + runDetector: fakeRunner({ stdout: '', stderr: '', exitCode: 0 }, calls), + worktree: '/repo', + }) + await hook(makeInput({ args: {} }), makeOutput()) + expect(calls).toHaveLength(0) + }) + + it('degrades to no-op without throwing when the runner exceeds the timeout', async () => { + const hook = createHook({ + runDetector: () => new Promise(() => {}), // never resolves + worktree: '/repo', + timeoutMs: 10, + }) + const output = makeOutput() + await expect(hook(makeInput(), output)).resolves.toBeUndefined() + expect(output.output).toBe('ok') + }) +}) + +describe('extractFeedbackText', () => { + it('extracts additionalContext from the claude hookSpecificOutput envelope', () => { + const stdout = JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'hello' }, + }) + expect(extractFeedbackText(stdout)).toBe('hello') + }) + + it('extracts top-level additionalContext', () => { + expect(extractFeedbackText(JSON.stringify({ additionalContext: 'hi' }))).toBe('hi') + }) + + it('extracts snake_case additional_context', () => { + expect(extractFeedbackText(JSON.stringify({ additional_context: 'fix me' }))).toBe('fix me') + }) + + it('returns non-JSON plain text verbatim (trimmed)', () => { + expect(extractFeedbackText(' found: hardcoded hex color \n')).toBe('found: hardcoded hex color') + }) + + it('returns "" for empty/whitespace input', () => { + expect(extractFeedbackText('')).toBe('') + expect(extractFeedbackText(' \n\t')).toBe('') + }) + + it('falls back to raw trimmed stdout for a bare JSON string', () => { + expect(extractFeedbackText('"x"')).toBe('"x"') + }) + + it('falls back to raw trimmed stdout when JSON has no recognized context field', () => { + const stdout = JSON.stringify({ foo: 'bar' }) + expect(extractFeedbackText(stdout)).toBe(stdout) + }) +}) + +describe('surface', () => { + it('appends only the inner additionalContext text (not raw JSON) when the runner returns findings', async () => { + const stdout = JSON.stringify({ + hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: 'found: gradient-text at L3' }, + }) + const hook = createHook({ + runDetector: fakeRunner({ stdout, stderr: '', exitCode: 0 }), + worktree: '/repo', + }) + const output = makeOutput() + await hook(makeInput(), output) + expect(output.output).toContain('found: gradient-text at L3') + expect(output.output).toContain('') + expect(output.output).not.toContain('hookSpecificOutput') + }) + + it('leaves output.output byte-unchanged when stdout is empty', async () => { + const hook = createHook({ + runDetector: fakeRunner({ stdout: '', stderr: '', exitCode: 0 }), + worktree: '/repo', + }) + const output = makeOutput() + await hook(makeInput(), output) + expect(output.output).toBe('ok') + }) + + it('leaves output.output byte-unchanged when stdout is whitespace-only', async () => { + const hook = createHook({ + runDetector: fakeRunner({ stdout: ' \n', stderr: '', exitCode: 0 }), + worktree: '/repo', + }) + const output = makeOutput() + await hook(makeInput(), output) + expect(output.output).toBe('ok') + }) +}) + +describe('fail-loud', () => { + it('warns when the detector exits nonzero (hook.mjs is contractually always-exit-0)', async () => { + const hook = createHook({ + runDetector: fakeRunner({ stdout: '', stderr: 'node: cannot find module', exitCode: 1 }), + worktree: '/repo', + }) + const output = makeOutput() + await hook(makeInput(), output) + expect(output.output).toContain('[impeccable]') + expect(output.output).toContain('did not run cleanly') + }) + + it('warns once (does not throw) when the runner throws', async () => { + const hook = createHook({ + runDetector: fakeRunner(new Error('boom')), + worktree: '/repo', + }) + const output = makeOutput() + await expect(hook(makeInput(), output)).resolves.toBeUndefined() + expect(output.output).toContain('[impeccable]') + }) + + it('does not re-warn on a second failure in the same session (same hook instance)', async () => { + const hook = createHook({ + runDetector: fakeRunner(new Error('boom')), + worktree: '/repo', + }) + const output1 = makeOutput() + await hook(makeInput(), output1) + expect(output1.output).toContain('[impeccable]') + + const output2 = makeOutput() + await hook(makeInput(), output2) + expect(output2.output).toBe('ok') + }) +}) diff --git a/.opencode/impeccable/plugin.ts b/.opencode/impeccable/plugin.ts new file mode 100644 index 0000000..fb87616 --- /dev/null +++ b/.opencode/impeccable/plugin.ts @@ -0,0 +1,48 @@ +/** + * OpenCode plugin registration for the Impeccable design hook bridge. + * + * The OpenCode plugin loader iterates this module's exports and treats each + * as a candidate plugin factory, so this file exports ONLY the plugin + * function — all pure logic (helpers, `createHook`) lives in + * `./hook-bridge.ts`, which stays Bun-free and Node-testable. This file is + * Bun-only at runtime (uses the injected `$`) and is never imported by tests. + */ + +import type { Plugin, PluginInput } from '@opencode-ai/plugin' + +import { createHook, HOOK_SCRIPT_RELATIVE_PATH, IMPECCABLE_HOOK_HARNESS, type DetectorRunner } from './hook-bridge.ts' + +/** Default subprocess runner: pipes the payload JSON to `hook.mjs` via the injected Bun `$`, cwd at the worktree. Bun-only — never imported/called under Node tests. */ +function createDefaultRunner(input: PluginInput): DetectorRunner { + return async (payload, opts) => { + const scriptPath = `${opts.worktree}/${HOOK_SCRIPT_RELATIVE_PATH}` + const stdinBody = new Response(JSON.stringify(payload)) + const proc = await input.$`node ${scriptPath} < ${stdinBody}` + .cwd(opts.worktree) + // Note: do NOT set IMPECCABLE_HOOK_DEPTH — hook.mjs treats any depth value + // as a re-entrancy signal and no-ops. The bridge runs hook.mjs as a plain + // subprocess (not a tool call), so it cannot re-trigger tool.execute.after; + // hook.mjs manages depth for its own child processes itself. + .env({ + ...process.env, + IMPECCABLE_HOOK_HARNESS, + // Suppress hook.mjs's clean/pending acks — findings still emit (they + // return before the quiet check in hook-lib.mjs). + IMPECCABLE_HOOK_QUIET: '1', + }) + .nothrow() + .quiet() + return { + stdout: proc.stdout.toString(), + stderr: proc.stderr.toString(), + // A null/undefined exitCode means the subprocess was cancelled or never + // ran — fall back to -1 (not 0) so createHook's nonzero-exit fail-loud + // branch fires instead of silently treating it as a clean success. + exitCode: proc.exitCode ?? -1, + } + } +} + +export const ImpeccablePlugin: Plugin = async (input) => ({ + 'tool.execute.after': createHook({ runDetector: createDefaultRunner(input), worktree: input.worktree }), +}) diff --git a/.opencode/tsconfig.json b/.opencode/tsconfig.json new file mode 100644 index 0000000..d2d8e19 --- /dev/null +++ b/.opencode/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@bfra.me/tsconfig", + "compilerOptions": { + "types": ["node"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/docs/plans/2026-07-10-001-feat-opencode-impeccable-plugin-plan.md b/docs/plans/2026-07-10-001-feat-opencode-impeccable-plugin-plan.md index 7155416..538a95f 100644 --- a/docs/plans/2026-07-10-001-feat-opencode-impeccable-plugin-plan.md +++ b/docs/plans/2026-07-10-001-feat-opencode-impeccable-plugin-plan.md @@ -83,7 +83,7 @@ Impeccable ships interactive post-tool-use hooks for four harnesses (Claude Code ## Implementation Units -- [ ] **Unit 1: Registration + pure helpers** +- [x] **Unit 1: Registration + pure helpers** **Goal:** Register a repo-local plugin OpenCode will load, and implement the pure, side-effect-free helpers the runtime hook depends on. @@ -114,7 +114,7 @@ Impeccable ships interactive post-tool-use hooks for four harnesses (Claude Code --- -- [ ] **Unit 2: hook.mjs bridge runtime** +- [x] **Unit 2: hook.mjs bridge runtime** **Goal:** Implement the `tool.execute.after` body — invoke `hook.mjs` on the touched file with a timeout, identify the OpenCode harness, and capture its stdout. @@ -148,7 +148,7 @@ Impeccable ships interactive post-tool-use hooks for four harnesses (Claude Code --- -- [ ] **Unit 3: Surface feedback + fail-loud guard** +- [x] **Unit 3: Surface feedback + fail-loud guard** **Goal:** Attach `hook.mjs` findings to what the agent sees, and emit a one-time visible warning when the bridge can't run or parse — never fail silently. @@ -175,7 +175,7 @@ Impeccable ships interactive post-tool-use hooks for four harnesses (Claude Code --- -- [ ] **Unit 4: Live verification + update-survival check** +- [x] **Unit 4: Live verification + update-survival check** **Goal:** Prove the assembled plugin works in a real OpenCode session and survives `npx impeccable update`. diff --git a/eslint.config.ts b/eslint.config.ts index 20aa8c1..7dd697f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -11,7 +11,7 @@ export default defineConfig( // .agents/ is a vendored shared-skill bundle (e.g. .agents/skills/impeccable) of // third-party .mjs/.md files — not our source; linting it reports tens of thousands // of errors. - ignores: ['docs/plans/**', 'docs/solutions/**', 'docs/brainstorms/**', 'web/**', '.agents/**'], + ignores: ['docs/plans/**', 'docs/solutions/**', 'docs/brainstorms/**', 'web/**', '.agents/**', '.opencode/**'], typescript: { tsconfigPath: './tsconfig.json', // Enforce Node 24 strip-only TypeScript compatibility: rejects parameter properties, diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..95d968c --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["./.opencode/impeccable/plugin.ts"] +} diff --git a/package.json b/package.json index 59979dc..8f994f1 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "start": "node src/server.ts", "build:web": "vite build web", "build:web:fixture": "VITE_FIXTURE_MODE=true vite build web", - "check-types": "tsc --noEmit && tsc --noEmit -p web/tsconfig.json", + "check-types": "tsc --noEmit && tsc --noEmit -p web/tsconfig.json && tsc --noEmit -p .opencode/tsconfig.json", "lint": "eslint", "fix": "eslint --fix", "pretest": "pnpm build:web", @@ -32,6 +32,7 @@ "devDependencies": { "@bfra.me/eslint-config": "0.51.1", "@bfra.me/tsconfig": "0.13.1", + "@opencode-ai/plugin": "1.17.18", "@tailwindcss/vite": "4.3.2", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61cc3b0..1dde5f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@bfra.me/tsconfig': specifier: 0.13.1 version: 0.13.1 + '@opencode-ai/plugin': + specifier: 1.17.18 + version: 1.17.18 '@tailwindcss/vite': specifier: 4.3.2 version: 4.3.2(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) @@ -129,6 +132,10 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@apideck/better-ajv-errors@0.3.7': resolution: {integrity: sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==} engines: {node: '>=10'} @@ -883,6 +890,36 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -955,6 +992,23 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@opencode-ai/plugin@1.17.18': + resolution: {integrity: sha512-tqVBzhTHYUzO0laAmcQeBtT56tXYM5VGUk9V60O+cMx4kkSfac4qEfPVguCGUMJnfcU+u+EPvpME6xmHWoQE8w==} + peerDependencies: + '@opentui/core': '>=0.4.3' + '@opentui/keymap': '>=0.4.3' + '@opentui/solid': '>=0.4.3' + peerDependenciesMeta: + '@opentui/core': + optional: true + '@opentui/keymap': + optional: true + '@opentui/solid': + optional: true + + '@opencode-ai/sdk@1.17.18': + resolution: {integrity: sha512-c/C9PhY8PrbcxDY+JIYtOZsrmMD0KzoVvxq+RGUrZ6LQp57SuVBbT4lfwA2G8Se5RNC1N5JtYjiuaXeECnF2SQ==} + '@oslojs/asn1@1.0.0': resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} @@ -2116,6 +2170,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + effect@4.0.0-beta.83: + resolution: {integrity: sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -2387,6 +2444,10 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -2418,6 +2479,9 @@ packages: filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-up-simple@1.0.1: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} @@ -2585,6 +2649,10 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} + ini@7.0.0: + resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2777,6 +2845,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2806,6 +2877,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -3083,6 +3157,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.4: + resolution: {integrity: sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA==} + + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + nanoid@3.3.14: resolution: {integrity: sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3100,6 +3184,10 @@ packages: resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} engines: {node: '>=18'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-releases@2.0.50: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} @@ -3217,6 +3305,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@8.4.1: + resolution: {integrity: sha512-c58R2+SPFcSIPXoU834QN/KPDDOSd8sXcSrqf6e83Me6Rrp1EYkxukkjXMVrKvKaADs1SOyNkWdfvLf6zY8qLQ==} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -3530,6 +3621,10 @@ packages: resolution: {integrity: sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + toml@4.1.2: + resolution: {integrity: sha512-m0vXfHODcw3gk+KONAOlVQ5yNHc3yS3B1ybM3HS1vqDoS0RWTDDVBVVTYi8hH0k+2OM1vmo9fb1WX9EVqjqfHA==} + engines: {node: '>=20'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -3669,6 +3764,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + vite-plugin-pwa@1.3.0: resolution: {integrity: sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==} engines: {node: '>=16.0.0'} @@ -3889,6 +3988,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.1.8: + resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -3896,6 +3998,10 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)': dependencies: ajv: 8.20.0 @@ -4823,6 +4929,24 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -4935,6 +5059,17 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@opencode-ai/plugin@1.17.18': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@opencode-ai/sdk': 1.17.18 + effect: 4.0.0-beta.83 + zod: 4.1.8 + + '@opencode-ai/sdk@1.17.18': + dependencies: + cross-spawn: 7.0.6 + '@oslojs/asn1@1.0.0': dependencies: '@oslojs/binary': 1.0.0 @@ -5942,6 +6077,19 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + effect@4.0.0-beta.83: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.9.0 + find-my-way-ts: 0.1.6 + ini: 7.0.0 + kubernetes-types: 1.30.0 + msgpackr: 2.0.4 + multipasta: 0.2.8 + toml: 4.1.2 + uuid: 14.0.1 + yaml: 2.9.0 + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -6354,6 +6502,10 @@ snapshots: exsolve@1.0.8: {} + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.1 + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -6378,6 +6530,8 @@ snapshots: dependencies: minimatch: 5.1.9 + find-my-way-ts@0.1.6: {} + find-up-simple@1.0.1: {} find-up@5.0.0: @@ -6537,6 +6691,8 @@ snapshots: indent-string@5.0.0: {} + ini@7.0.0: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -6731,6 +6887,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-with-bigint@3.5.8: {} @@ -6759,6 +6917,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kubernetes-types@1.30.0: {} + leven@3.1.0: {} levn@0.4.1: @@ -7205,6 +7365,24 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.4: + optionalDependencies: + msgpackr-extract: 3.0.4 + + multipasta@0.2.8: {} + nanoid@3.3.14: {} napi-postinstall@0.3.4: {} @@ -7213,6 +7391,11 @@ snapshots: natural-orderby@5.0.0: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-releases@2.0.50: {} object-deep-merge@2.0.1: {} @@ -7322,6 +7505,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@8.4.1: {} + quansync@0.2.11: {} react-dom@19.2.7(react@19.2.7): @@ -7711,6 +7896,8 @@ snapshots: dependencies: eslint-visitor-keys: 5.0.1 + toml@4.1.2: {} + tough-cookie@6.0.1: dependencies: tldts: 7.4.3 @@ -7888,6 +8075,8 @@ snapshots: dependencies: punycode: 2.3.1 + uuid@14.0.1: {} + vite-plugin-pwa@1.3.0(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1): dependencies: debug: 4.4.3 @@ -8145,4 +8334,6 @@ snapshots: yocto-queue@0.1.0: {} + zod@4.1.8: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 72618ef..4a35c9c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,8 @@ allowBuilds: '@swc/core': true + # @opencode-ai/plugin is a type-only devDependency (OpenCode runs its own plugin + # host); its transitive native accelerator never needs to build here. + msgpackr-extract: false unrs-resolver: true shamefullyHoist: true diff --git a/tsconfig.json b/tsconfig.json index 8cb7271..7c615fd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,5 +7,5 @@ "noEmit": true }, "include": ["**/*.ts"], - "exclude": ["node_modules", "dist", "web", "test/operator-runtime.test.ts"] + "exclude": ["node_modules", "dist", "web", ".opencode", "test/operator-runtime.test.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts index 11c5c36..96d8f8c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ // Only run this app's tests. Exclude vendored dependency source under // .slim/clonedeps (those repos carry their own tests + workspace deps that // are unresolvable here). - include: ['test/**/*.test.ts', 'test/**/*.test.js'], - exclude: ['node_modules', 'dist', '.slim'], + include: ['test/**/*.test.ts', 'test/**/*.test.js', '.opencode/impeccable/**/*.test.ts'], + exclude: ['**/node_modules/**', 'dist', '.slim'], }, })