Skip to content

feat(harness): opencode enforcement parity — port CC hooks via a shared logic core (7h) - #441

Merged
thomasluizon merged 1 commit into
mainfrom
feature/opencode-hook-parity
Jul 10, 2026
Merged

feat(harness): opencode enforcement parity — port CC hooks via a shared logic core (7h)#441
thomasluizon merged 1 commit into
mainfrom
feature/opencode-hook-parity

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

Stage 7h — opencode enforcement parity (paired Orbit deliverable)

Paired with agentic-dev-workflow PR #2 (the generic dual-target hook engine). Gives Orbit opencode enforcement parity on this machine using the same shared-logic-core + adapter pattern the pack standardizes: each rule lives once, both tools enforce it, no twin drift.

Orbit keeps its own project-specific rules (it does not consume the pack); this applies the pack's pattern to Orbit's own hooks.

Shared logic core — .claude/hooks/_lib/ (pure; both tools call it)

module rules
rules-git.mjs git workflow guard (protected-ref push, bypass flags, commit -n) + Expo-SDK-57 pin guard
rules-content.mjs em dashes + brand colors (scan only the added text)
rules-source.mjs TS anti-patterns, workaround markers, csharp authz / timezone / fluent-config (whole-file)
rules-parity.mjs cross-platform parity nudge
io.mjs payload normalizers for both tools

Claude Code side

All 10 hooks refactored into thin adapters over _libbyte-faithful: the exact regexes and stderr messages are preserved, and settings.json wiring is unchanged (filenames unchanged). The parity test runs the real hook files to confirm no behavior regressed.

opencode side — .opencode/plugin/orbit-guardrails.js

One plugin wiring the same _lib:

  • tool.execute.before → git + Expo-pin + em-dash/brand-color on the added text (block by throw).
  • tool.execute.after → whole-file rules + parity nudge (a throw surfaces the violation the way the CC PostToolUse exit-2 does).
  • event/session.idle → best-effort proactivity nudge.

opencode auto-loads it from .opencode/plugin/ (no wiring). Mapping note: opencode has no PostToolUse, so whole-file rules run in tool.execute.after (file written) and added-text rules in tool.execute.before (can block); the proactivity Stop-gate degrades to an idle nudge because opencode cannot rewind a finished turn.

Proof — node .claude/hooks/test-hooks.mjs (42 assertions)

_lib unit checks + the real Claude Code hooks (regression guard) + the real opencode plugin — the same rule yields the same verdict in both tools.

Cross-links agentic-dev-workflow#2.

🤖 Generated with Claude Code

…ed logic core (7h)

Stage 7h of the harness-upgrade plan, paired with agentic-dev-workflow PR #2
(the generic dual-target hook engine). Gives Orbit opencode enforcement parity on
this machine using the same shared-logic-core + adapter pattern the pack
standardizes: every rule lives once, both tools enforce it, no twin drift.

Shared logic core — .claude/hooks/_lib/ (pure, both tools call it):
- rules-git.mjs      checkGitCommand (protected-ref push, bypass flags, commit -n)
                     + checkNpmExpoPin (Expo SDK 57 pin guard)
- rules-content.mjs  checkEmDashes + checkBrandColors (scan only the added text)
- rules-source.mjs   checkTsAntipatterns, checkNewTodos, checkCsharpAuthz,
                     checkCsharpTimezone, checkCsharpFluentConfig (whole-file)
- rules-parity.mjs   classifyScope + parityMessages (cross-platform nudge)
- io.mjs             payload normalizers for both tools

Claude Code side: all 10 hooks refactored into thin adapters over _lib
(byte-faithful — the exact regexes and stderr messages are preserved; settings.json
wiring unchanged since the filenames are unchanged).

opencode side: .opencode/plugin/orbit-guardrails.js — one plugin wiring the SAME
_lib. tool.execute.before runs the git/Expo-pin + added-text content rules (block by
throw); tool.execute.after runs the whole-file rules + parity nudge (a throw surfaces
the violation the way the CC PostToolUse exit-2 does); event/session.idle emits a
best-effort proactivity nudge. opencode auto-loads it from .opencode/plugin/.

Mapping note: opencode has no PostToolUse, so whole-file rules run in
tool.execute.after (file written) and added-text rules in tool.execute.before (can
block); the proactivity Stop-gate degrades to an idle nudge because opencode cannot
rewind a finished turn.

Proof (.claude/hooks/test-hooks.mjs, 42 assertions): _lib unit checks + the REAL
Claude Code hooks (regression guard, confirming the refactor preserved behavior) +
the REAL opencode plugin — the same rule yields the same verdict in both tools.

Cross-links agentic-dev-workflow PR #2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
orbit-ui-mobile-web Ignored Ignored Jul 10, 2026 1:10am

Request Review

@sonarqubecloud

Copy link
Copy Markdown

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #441

Scope: PR #441 in thomasluizon/orbit-ui-mobile
Recommendation: APPROVE

Summary

The diff extracts Orbit's 10 Claude Code hooks into a shared, pure _lib/ core, turns the hooks into thin adapters over it, and ports the same rules to opencode via a new .opencode/plugin/orbit-guardrails.js plugin plus a hand-rolled proof script (test-hooks.mjs). It touches only .claude/hooks/** and .opencode/plugin/** — no apps/*, orbit-api, or packages/shared — so this is pure dev-tooling, not a user-facing change. The extraction itself is byte-faithful: every regex and stderr message diffed against the pre-PR originals matches exactly, with no unused imports or dead code introduced. Two Medium-severity gaps are worth tracking; neither blocks merge.

Findings

Critical / High

None.

Medium

[MEDIUM] The parity proof (test-hooks.mjs) is never run automatically

  • .claude/hooks/test-hooks.mjs:1-137
  • It's a hand-rolled assertion runner (not Vitest), living outside the npm workspaces glob (apps/*, packages/*). npx turbo run test (CI's unit-tests job) never touches it — no husky/pre-commit/CI wiring exists anywhere in the repo for this file.
  • Risk: the PR's "same rule, same verdict, both tools" proof is real only when someone remembers to run it by hand. A future _lib/adapter/plugin edit can silently desync Claude Code and opencode enforcement with nothing in CI catching it — the exact "twin drift" this PR exists to prevent.
  • Fix: add "test:hooks": "node .claude/hooks/test-hooks.mjs" and wire it into the unit-tests CI job (or a dedicated step gated on .claude/hooks/**/.opencode/plugin/**). Ref: CLAUDE.md "Testing: Vitest unit tests only; every feature needs behavior tests."

[MEDIUM] The test never imports the shipped .opencode/plugin/orbit-guardrails.js as-is

  • test-hooks.mjs:1345-1353 does cpSync(pluginSrc, pluginMjs) — copying the plugin to a *.probe.mjs file before import()-ing it. That rename is necessary because plain Node parses a .js as CommonJS unless the nearest package.json declares "type": "module" (root doesn't; none exists under .opencode/), and the shipped file uses ESM syntax that would throw a SyntaxError if Node loaded the .js directly. The test's own workaround is the tell.
  • Risk: if opencode's plugin loader ever resolves modules the way plain Node does, the shipped plugin fails to load entirely and opencode runs with zero Orbit guardrails, silently — untested by this suite since it always tests the renamed copy. Not verifiable in this CI job whether opencode's actual loader is Bun-based (which would sidestep this) — could not execute a live import test.
  • Fix (cheap, removes the ambiguity regardless of runtime): ship as .opencode/plugin/orbit-guardrails.mjs, or add a one-line .opencode/plugin/package.json with {"type":"module"}, and point the test at the real path with no copy step. Ref: CLAUDE.md rule 1 (root cause over workarounds).

Low / Info

None (signal gate).

Subagents

All five gated subagents (parity-checker, i18n-syncer, contract-aligner, security-reviewer, design-reviewer) are N/A — the diff never touches apps/web, apps/mobile, packages/shared, or orbit-api.

Validation

Skipped per CI adaptation (Build / Unit Tests / SonarCloud run as separate required checks). Changed files also sit outside every linted/typed/tested npm workspace regardless.

Deferred

Dimensions covering DESIGN.md, cross-platform parity, i18n, contract drift, orbit-api security/backend rules, FEATURES.md — all N/A, diff never touches their surfaces. The orbit-api side of the backward-compat guard is not verifiable in this job (sibling repo not checked out) but is not implicated since this PR touches no contract/DTO surface.

What's good

  • The _lib extraction is genuinely byte-faithful — regexes and messages match pre-PR originals exactly.
  • No unused imports/dead code in any of the 10 slimmed adapters.
  • test-hooks.mjs's self-avoidance tricks ("TO" + "DO", "--no-" + "verify") so the fixture doesn't trip the hooks it's testing.
  • Sensible, well-reasoned mapping of Claude Code's PreToolUse/PostToolUse split onto opencode's tool.execute.before/after.

Recommendation

Approve as-is. Wiring test-hooks.mjs into CI and removing the .js/.mjs module-format ambiguity in the opencode plugin are cheap, contained fast follows — neither is a functional regression today.

@thomasluizon
thomasluizon merged commit 0a3ef06 into main Jul 10, 2026
19 checks passed
@thomasluizon
thomasluizon deleted the feature/opencode-hook-parity branch July 10, 2026 01:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant