feat(opencode): upgrade plugin to full hook-guard port (search + read + strict) - #3660
hybridindie wants to merge 1 commit into
Conversation
… + strict) Replaces the once-per-session bash reminder with a JS port of the Claude Code PreToolUse guard pair (graphify hook-guard <search|read>): - Search guard: fires on the bash command's executed tokens — heredoc bodies and quoted spans dropped, wrappers skipped, git grep / VAR=x grep counted (the Graphify-Labs#3121 contract, mirrored from cli.py _bash_invokes_search). - Read/glob guard: source-extension + in-project gating, output-dir reads exempt, files newer than graph.json (or a .needs_update marker) soften to the stale nudge (the Graphify-Labs#1840 contract). - Strict mode (GRAPHIFY_HOOK_STRICT=1, GRAPHIFY_HOOK_STRICT_TTL): a forceful once-per-session reminder for reads of files the graph indexes, suppressed while cache/last_query_stamp is fresh. Shares the same hook_sessions markers as the Claude hook, so the once-per-session budget spans harnesses. The Claude deny degrades to a strong reminder: opencode plugins cannot block a tool call from the before hook. - Transport: opencode has no PreToolUse additionalContext channel, so bash nudges ride a shell-inert echo '…' ; prepend (no backticks, $ or single quotes — Graphify-Labs#1413) and read/grep/glob nudges are appended to the tool result via tool.execute.after. - Honors GRAPHIFY_OUT like the git hook gates (Graphify-Labs#3546). - New tests/test_opencode_plugin_hook.py drives the extracted plugin in node with the same parametrized cases as test_hook_guard_token_match, plus orientation budget, staleness, strict once-per-session, and shell-inertness pins. Updated the two test_install.py shape tests for the new echo construction.
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.
Formal verification. 1 change(s) alter behavior, breaking input(s) attached.
Behavior changes: \_match\_cited\_file changes behavior, here is the input that shows it.
The verifier found a concrete input on which \_match\_cited\_file behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"cited":"'\./'","doc\_file":"'\.mdx'","source\_files":"\{1, 2, 3\}"\}, the old code produced None but the new code produces raises AttributeError. Paste that input straight into a regression test.
Graphify review — findings
Ports the OpenCode integration from a once-per-session bash reminder to a full JS reimplementation of graphify's Claude Code hook-guard search/read guards, generated by graphify install. The search guard fires on the bash command's executed tokens (heredoc bodies and quoted spans dropped, wrappers skipped, git grep/VAR=x grep counted), the read/glob guard gates on source-extension, in-project, and staleness checks, and opt-in strict mode (GRAPHIFY_HOOK_STRICT) emits a once-per-session reminder sharing the same hook_sessions markers and last_query_stamp as the Claude hook so the budget spans harnesses. Since opencode lacks a PreToolUse additionalContext channel, bash nudges ride a shell-inert echo '…' ; prepend while read/grep/glob nudges append to the tool result, and the Claude deny degrades to a forceful reminder because tool.execute.before cannot block a call; everything fails open.
Worth a look
- Stray top-level name breaks test module import —
tests/test_opencode_plugin_hook.py:309· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Per-session Set grows without eviction —
graphify/install.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Wrapper parsing misses timeout-wrapped searches —
graphify/install.py:1512· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 757 functions depend on the 525 functions this change touches.
Health — this change adds coupling hotspots:
- new:
dispatch_command()— 2 callers, 125 callees - new:
codebuddy_install()— 20 callers, 5 callees - new:
claude_install()— 19 callers, 4 callees - new:
_copy_skill_file()— 12 callers, 6 callees - new:
gemini_install()— 10 callers, 7 callees - new:
claude_uninstall()— 17 callers, 4 callees - new:
_project_uninstall()— 5 callers, 13 callees - new:
dispatch_install_cli()— 2 callers, 31 callees - …and 14 more — each is listed as a finding
Verification — 757 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 676 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
284 of 284 test file(s) selected (100%) via static blast radius.
Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.
tests/test_affected_cli.py— impact, full-run-safetytests/test_affected_member_seed.py— full-run-safetytests/test_agents_platform.py— impact, full-run-safetytests/test_analyze.py— full-run-safetytests/test_anthropic_custom_endpoint.py— full-run-safetytests/test_antigravity_install.py— full-run-safetytests/test_apm_fallback_version.py— full-run-safetytests/test_architecture_doc.py— full-run-safetytests/test_astro_extraction.py— full-run-safetytests/test_astro_import_ids.py— full-run-safetytests/test_atomic_canvas_export.py— full-run-safetytests/test_atomic_version_stamp.py— impact, full-run-safetytests/test_atomic_writes.py— full-run-safetytests/test_backend_env_isolation.py— full-run-safetytests/test_backend_extras.py— full-run-safetytests/test_benchmark.py— full-run-safetytests/test_benchmark_raw_graph.py— full-run-safetytests/test_build.py— full-run-safetytests/test_build_merge_dedup_scope.py— full-run-safetytests/test_build_merge_hyperedges_and_prune.py— full-run-safetytests/test_build_merge_shrink_guard.py— full-run-safetytests/test_builtin_global_type_refs.py— full-run-safetytests/test_cache.py— full-run-safetytests/test_callflow_html.py— full-run-safetytests/test_cargo_introspect.py— full-run-safetytests/test_carried_hyperedge_remap.py— full-run-safetytests/test_case_sensitive_resolution.py— full-run-safetytests/test_charmap_encoding.py— full-run-safetytests/test_chunking.py— full-run-safetytests/test_cjs_module_extension.py— full-run-safetytests/test_claude_cli_backend.py— full-run-safetytests/test_claude_md.py— impact, full-run-safetytests/test_cli_broken_pipe.py— full-run-safetytests/test_cli_export.py— full-run-safetytests/test_cli_help.py— full-run-safetytests/test_cluster.py— full-run-safetytests/test_codebuddy.py— impact, full-run-safetytests/test_community_hub_labels.py— full-run-safetytests/test_community_labels_skill.py— full-run-safetytests/test_confidence.py— full-run-safetytests/test_corrupt_graph_json.py— full-run-safetytests/test_cpp_nested_and_cli.py— full-run-safetytests/test_cpp_objc_cross_file_calls.py— full-run-safetytests/test_cpp_preprocess.py— full-run-safetytests/test_cross_extension_reexport_self_cycle.py— full-run-safetytests/test_cross_language_call_resolution.py— full-run-safetytests/test_cross_repo_external_call_guards.py— full-run-safetytests/test_cross_repo_member_calls.py— full-run-safetytests/test_cross_repo_shared_types.py— full-run-safetytests/test_csharp_call_site_generic_args.py— full-run-safety- … and 234 more
non-code file(s) changed (
CHANGELOG.md) → running the full suite for safety (a code graph can't see config/fixture/data deps)
changed code file(s) with no mapped test (
CHANGELOG.md) — a coverage gap or a missing link — running the full suite rather than only the selected tests
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Formal verification
Behavior changes: \_match\_cited\_file changes behavior, here is the input that shows it.
The verifier found a concrete input on which \_match\_cited\_file behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"cited":"'\./'","doc\_file":"'\.mdx'","source\_files":"\{1, 2, 3\}"\}, the old code produced None but the new code produces raises AttributeError. Paste that input straight into a regression test.
Could not verify: Could not verify build\_from\_json.
The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify prefix\_graph\_for\_global.
The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous
Could not verify: Could not verify dispatch\_command.
The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)
Could not verify: Could not verify extract\_elixir.
The verifier did not have enough to check extract\_elixir, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
No difference found (not proven): No behavior difference found in \_code\_span\_mention (not a proof).
The verifier ran both versions of \_code\_span\_mention on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify extract\_markdown.
The verifier did not have enough to check extract\_markdown, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify extract\_rust.
The verifier did not have enough to check extract\_rust, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
No difference found (not proven): No behavior difference found in resolve\_markdown\_mentions (not a proof).
The verifier ran both versions of resolve\_markdown\_mentions on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify resolve\_ruby\_member\_calls.
The verifier did not have enough to check resolve\_ruby\_member\_calls, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_rebuild\_code.
The verifier did not have enough to check \_rebuild\_code, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: parameter `watch_path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify \_reconcile\_markdown\_links.
The verifier did not have enough to check \_reconcile\_markdown\_links, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: parameter `project_root` is annotated `Path` — outside the synthesizable primitive/collection set
· 22 more finding(s) on lines outside this diff (see the check run).
Summary
The shipped OpenCode plugin (
_OPENCODE_PLUGIN_JS) is a once-per-session bash reminder. The Claude Code harness meanwhile gets the full guard pair (graphify hook-guard <search|read>: executed-token search analysis, source-extension/in-project/staleness gating for reads, opt-in strict mode). This PR closes that gap by porting the Python guards 1:1 to the OpenCode plugin surface, so an agent in OpenCode gets the same graph-first orientation as an agent in Claude Code.Built and verified in a real OpenCode session (
godot-mcp, 180-tool MCP server, graphify-out/ with 164 files) before extraction — the decision logic is line-for-line the same ascli.py:_run_hook_guard.What the plugin now does
tool.execute.before, bash): fires on the command's executed tokens — heredoc bodies and quoted spans stripped, wrapper commands (sudo,xargs,env, …) skipped,VAR=x grepandgit grepcounted — the same hook-guard search substring-matches the whole command string, firing on non-searches #3121 contract as_bash_invokes_search. Prose likegit commit -m "add flag support"never triggers.tool.execute.after): source-extension filter (same_HOOK_SOURCE_EXTSlist), in-project check, output-dir reads exempt, and staleness softening — a file newer thangraph.json(or a.needs_updatemarker) gets the stale nudge instead of the mandatory one (hook-guard: Read/Glob PreToolUse nudge has no project-membership or graph-freshness gate — fires identically on out-of-project files and stale graphs #1840).GRAPHIFY_HOOK_STRICT=1,GRAPHIFY_HOOK_STRICT_TTL, default 1800s): the first read per session of a file the graph indexes gets a forceful once-per-session reminder, suppressed whilecache/last_query_stampis fresh. Session markers reuse the same<out>/cache/hook_sessions/<sid>.deniedfiles the Claude hook writes, so the once-per-session budget is shared across harnesses. The ClaudepermissionDecision: denydegrades to the strong reminder — opencode plugins cannot block a tool call from thebeforehook.GRAPHIFY_OUTlike the git hook gates (▚▚ fix(hooks): resolve the output dir from GRAPHIFY_OUT in the sh gates, not a literal graphify-out/ #3546). Fails open everywhere.Transport adaptation
opencode has no PreToolUse
additionalContextchannel, so the same guard decisions ride two transports:echo '<nudge>' ;prepended to the command — kept free of backticks/$/single quotes (bug: opencode plugin reminder string has unescaped backticks → bash command substitution silently runs 'graphify query "<question>"' on every search #1413, OpenCode Graphify plugin uses &&, which fails in Windows PowerShell 5.1 #1646)tool.execute.afterTest plan
tests/test_opencode_plugin_hook.pyextracts the plugin body, loads it in node, and drivestool.execute.before/tool.execute.after:test_hook_guard_token_match.py(18 fire, 12 quiet, 4 heredoc/quoted cases) so the two harnesses stay in lockstepstripHeredocsoriginally reset the regexlastIndexto 0, re-matching the same opener and eating the line after the terminator (a real search after a heredoc stopped firing); now mirrors cli.py's advancingsearch(text, nl_idx + 1)tests/test_install.py: two shape tests updated for the new echo construction (single-quotedecho '…' ;, and the no-backtick check now scopes to the echoed_ECHOconstants — thetool.execute.afternudges ride the tool result and are never shell-interpreted, so they may contain backticks)pytest -qon v8 @ c7ec108 (shallow clone): 5635 passed; the only failures are pre-existing on a clean checkout (skillgen tests need full git history for pinned refs; ambientOLLAMA_*env vars in the shell affect retry tests; a timing-sensitive TS perf test flaked in the full run and passes in isolation). Affected suites —test_install.py(111),test_install_strings.py(7),test_hook_guard.py,test_hook_strict.py,test_search_hook.py,test_read_hook.py, plus the new 38-case file — all green.Checklist
cli.pyguards with cited issue numbersGRAPHIFY_OUThonored