Skip to content

Match rules against observed file content #38

Description

@frap129

Problem Statement

Rule authors can currently select guidance from paths and request context, but they cannot select a Rule from the actual text observed for a file. This prevents precise guidance such as applying Rust safety rules only after OpenCode successfully observes an unsafe { block. Path mentions and unsupported tool arguments can also select glob-scoped Rules even though no successful file-handling event established that the file was part of the Working context.

When a Rule becomes matched after a tool call, users also need its guidance at the earliest provider dispatch where it can affect the work. Today, durable Rule delivery is tied to a user-message turn, so guidance discovered during an assistant tool loop can arrive too late.

Solution

Add a durable fileContains Rule condition that matches case-sensitive literal text from normalized File observations produced only by successful Read, Write, Edit, Apply Patch, and path-associated LSP events. A Rule may use content matching alone or combine it with globs; when both are declared, one File observation must satisfy both the path and content conditions.

Make normalized File observations the sole source for file-based matching. Retain them as live per-session context without scanning the workspace or replaying historical tool events. Route every newly Matched rule, not only content-matched Rules, through one earliest-dispatch delivery contract: initial durable matches keep the existing user-message delivery, mid-session durable matches are persisted through a no-reply synthetic message and are also available to the current provider dispatch, and transient matches remain dispatch-local.

User Stories

  1. As a Rule author, I want to declare one literal with fileContains, so that guidance can depend on text observed in a file.
  2. As a Rule author, I want to declare multiple fileContains literals, so that any one of several relevant constructs can select the Rule.
  3. As a Rule author, I want scalar and array syntax, so that simple Rules stay concise and complex Rules remain readable.
  4. As a Rule author, I want literals matched case-sensitively, so that identifiers whose case carries meaning remain distinct.
  5. As a Rule author, I want metacharacters treated literally, so that content conditions do not unexpectedly behave as regular expressions.
  6. As a Rule author, I want literals to span lines, so that I can match meaningful multi-line source fragments.
  7. As a Rule author, I want exact duplicate literals deduplicated, so that repeated configuration does not cause repeated matching or delivery.
  8. As a Rule author, I want whitespace around configured literals trimmed, so that frontmatter formatting does not accidentally change the intended literal.
  9. As a Rule author, I want an invalid or empty fileContains declaration to fail closed with a warning, so that malformed configuration never makes a conditional Rule unconditional.
  10. As a Rule author, I want fileContains to work without globs, so that content alone can select guidance.
  11. As a Rule author, I want globs and fileContains to apply to the same File observation, so that one file's path cannot be combined with another file's text.
  12. As a Rule author, I want the file path and content conditions to form one condition family, so that match: any and match: all remain predictable.
  13. As a Rule author, I want other condition families to retain their current algebra, so that adding content matching does not redefine keywords, tools, models, agents, commands, projects, branches, operating systems, or CI conditions.
  14. As a Rule author, I want a successful Read to match only the returned text, so that omitted or truncated file content is not guessed.
  15. As a Rule author, I want continuation Reads to contribute only their returned slice, so that matching reflects what OpenCode actually observed.
  16. As a Rule author, I want line-number prefixes and Read wrapper markup excluded, so that only file text can satisfy fileContains.
  17. As a Rule author, I want binary, image, and PDF Reads to contribute path-only observations, so that glob matching can work without inventing text content.
  18. As a Rule author, I want directory Reads to contribute no File observation, so that directories are not mistaken for files.
  19. As a Rule author, I want a successful Write to match its submitted content, so that newly created or replaced text can select guidance immediately.
  20. As a Rule author, I want a successful Edit to match both removed and added text, so that guidance can respond to either side of a replacement.
  21. As a Rule author, I want Apply Patch to produce one File observation per touched file, so that multi-file patches preserve file ownership.
  22. As a Rule author, I want added files to contribute their full patch content, so that new source can select guidance.
  23. As a Rule author, I want updated files to contribute added, removed, and context hunk lines without patch syntax, so that matching uses file text rather than patch metadata.
  24. As a Rule author, I want moved files keyed to their destination path, so that path and content matching reflects the resulting file identity.
  25. As a Rule author, I want deleted files to contribute path-only observations, so that removed text does not create a content match.
  26. As a Rule author, I want a successfully applied but unparseable patch to fall back to path-only summary observations, so that glob matching degrades safely while content matching fails closed.
  27. As a Rule author, I want each LSP operation returning path-associated text to contribute its raw successful result for the queried file, so that language-server findings can select guidance.
  28. As a Rule author, I want paths and URIs merely mentioned by an LSP result to remain text of the queried file's observation, so that one result cannot create unrelated File observations.
  29. As a Rule author, I want failed tool calls to contribute no File observation, so that attempted work does not select guidance.
  30. As a Rule author, I want Grep, Glob, shell, custom, MCP, and other unsupported tools excluded, so that file matching has a narrow and predictable trust boundary.
  31. As a user, I want prose, attachments, metadata, diagnostics, and assistant text excluded from File observations, so that quoted or incidental text cannot select a Rule.
  32. As a user, I want a newly Matched rule available on the immediately following provider dispatch, so that guidance affects the next decision in the same logical turn.
  33. As a user, I want all newly Matched rules to follow the same earliest-dispatch contract, so that delivery timing does not depend on which condition selected the Rule.
  34. As a user, I want durable Rules persisted in conversation history, so that their guidance remains available on later turns and resume.
  35. As a user, I want transient Rules to remain dispatch-local, so that context-sensitive guidance does not persist beyond its applicable dispatch.
  36. As a user, I want multiple matches of one Rule to coalesce, so that matching several literals, files, or concurrent calls does not duplicate guidance.
  37. As a user, I want a later non-matching observation to leave an already Matched rule active, so that durable guidance is not retracted mid-session.
  38. As a user, I want delivery persistence failures isolated from successful tool calls, so that guidance infrastructure cannot turn completed work into a tool failure.
  39. As a user, I want pending guidance exposed to the applicable dispatch when durable persistence fails, so that a storage problem does not unnecessarily delay guidance.
  40. As a user, I want failed persistence retried on later dispatches, so that durable history eventually recovers without duplicate acknowledged delivery.
  41. As a user, I want delivered guidance recovered from its delivery identity after message removal or compaction, so that recovery does not depend on replaying old tool events.
  42. As a user, I want pending but unpersisted activation to remain in-memory only, so that the plugin does not add a new persisted activation format.
  43. As a user, I want a resumed session to trust existing durable delivery rather than scan old tool events, so that resume is deterministic and bounded.
  44. As a user, I want the sidebar's Matched-rule state refreshed when a durable Rule is admitted mid-session, so that the UI reflects current matching promptly.
  45. As a user, I want the sidebar to summarize fileContains alongside existing conditions, so that I can understand why a Rule is scoped.
  46. As an existing Rule author, I want Rules without fileContains to retain their condition algebra, lifetime, identity, framing, and delivery semantics, so that adoption requires no migration.
  47. As an existing Rule author, I want glob matching based on successful File observations rather than path-like prose, so that path scope reflects actual file handling.
  48. As a maintainer, I want File observation capture separate from Working context storage, so that path-and-content records do not leak into the path-only SessionWorkingContext abstraction.
  49. As a maintainer, I want RuleDelivery to remain the only owner of delivery state, so that observation producers cannot create competing ledgers or retry behavior.
  50. As a maintainer, I want concurrent admissions serialized per session and deduplicated by existing Rule identity, so that parallel tool completions remain race-safe.
  51. As a maintainer, I want no proactive or hidden filesystem scan, so that matching cost and data access remain explicit.
  52. As a maintainer, I want the OpenCode v1 mechanism isolated behind existing runtime seams, so that a future v2 migration can replace it without changing the public Rule contract.

Implementation Decisions

  • Add fileContains as a public frontmatter field accepting either one string or an array of strings. A scalar is shorthand for a one-element array.
  • Normalize configured literals by trimming strings, dropping non-string and empty entries, and deduplicating exact values. Preserve case. If a declared field yields no valid literals, mark the condition invalid, log one warning through the existing warning policy, and make the entire Rule never match regardless of other conditions.
  • Match with a case-sensitive literal substring check. Multiple literals have OR semantics. Regular-expression, glob, anchor, and escape semantics are not introduced; newline-containing literals may match across lines.
  • Treat globs and fileContains as one durable File-observation condition family. When both are present, one File observation must satisfy both. The family contributes one check to the existing match: any|all algebra. Therefore globs + fileContains + keywords under match: any means (globs AND fileContains) OR keywords.
  • A content-only File-observation family is valid. The .rs plus unsafe { canonical example matches only when one successful observation has a Rust path and contains that literal.
  • Introduce a runtime-owned, per-session File-observation module separate from SessionWorkingContext and its path-only Working context fields. A File observation is one flat { path, tool, content } record per file from one successful event. The module records observations and exposes current matching context; it does not own delivery bookkeeping.
  • Retain File observations monotonically for the resident session. Later observations do not remove earlier context. Message removal, history invalidation, and compaction do not subtract observations. Session eviction removes them. Do not persist pending activation or rebuild content observations by replaying historical events after restart.
  • Consume paths in the verbatim form supplied to the successful after-hook. Do not resolve, decode, normalize, existence-check, or read them from the filesystem. Existing minimatch behavior, including basename matching for slash-free patterns, remains applicable.
  • Read observations reconstruct only returned file lines: remove response wrapper tags and line-number prefixes, then join lines with newlines. Continuation and truncated Reads contribute only returned lines. Binary, image, PDF, and otherwise unparseable successful file Reads are path-only. Directory Reads yield no observation.
  • Write observations use the submitted content verbatim and the submitted file path.
  • Edit observations concatenate removed oldString and added newString text and associate the result with the submitted file path.
  • Apply Patch observations parse the submitted patch into one observation per touched file. Added files contribute full content; updates contribute added, removed, and context hunk lines without patch control syntax; moves use the destination path; deletes are path-only. If a successful patch cannot be parsed, derive path-only observations from its model-visible result summary.
  • LSP observations use the raw successful result, including a no-results response, and associate it only with the queried file path. Every LSP operation returning path-associated text is eligible. Paths and URIs inside the result remain content and do not create observations.
  • Only successful Read, Write, Edit, Apply Patch, and LSP events contribute observations. Grep, Glob, shell, arbitrary/custom/MCP tools, user and assistant prose, attachments, metadata, diagnostics, and all other sources contribute neither paths nor content.
  • Replace all existing file-based matcher inputs with normalized File observations. This intentionally removes path-like prose and excluded-tool paths as glob sources. Rules without fileContains otherwise preserve their existing algebra and lifetime behavior.
  • Use the existing Rule identity for coalescing. Multiple literals, files, observations, and concurrent calls can admit at most one durable delivery per Rule per session. Deliver Rule content, never triggering file content.
  • Model activation as monotonic unmatched → pending → delivered. A successful matching File observation is the only way content matching enters pending. A later non-match does not retract, re-arm, or alter the Rule.
  • Make the pre-dispatch message transform the single completeness gate. It re-evaluates all Rule lifetimes against the freshest session context on every dispatch. Context-producing hooks record state but do not manipulate the delivery ledger.
  • Preserve existing initial durable delivery by appending the established synthetic Rule framing to the current user message. For a durable Rule first matched mid-session, await persistence of a synthetic no-reply message and also place equivalent pending content in the current in-memory dispatch so it cannot miss the earliest provider call.
  • Keep transient Rule delivery dispatch-local. Re-evaluate it from current context and consume it after one usable dispatch under existing behavior.
  • Keep RuleDelivery authoritative for the single match-submission seam, per-session serialization, identity deduplication, activation transitions, history reconciliation, retry state, and ledger acknowledgement. File-observation and other context producers do not access ledger internals.
  • Acknowledge durable delivery only after persistence succeeds. On failure, leave the Rule pending, insert it into the applicable transformed dispatch when possible, log the failure, and retry at every later dispatch. Delivery failure must not fail a successful tool event, and one Rule's failure must not block another.
  • Suppress recursive handling of the plugin's own no-reply synthetic messages. Preserve existing Rule block framing and bubble-suppression behavior.
  • Concurrent tool completions may produce multiple synthetic messages and have unspecified cross-tool ordering. The guarantees are per-session serialization, per-identity deduplication, and visibility before the next dispatch, not batch grouping.
  • Reconstruct delivered state from the existing history ledger on resume. Message removal and compaction invalidate delivery evidence and cause ledger-driven re-append from current Rule source; they never replay File observations. A compaction summary containing Rule text is not delivery evidence. Pending activation may be lost on process restart and requires a new matching observation.
  • Refresh Matched-rule state when a durable Rule is admitted mid-session, not only on an initial user-message turn.
  • Add fileContains to the existing sidebar condition summary without creating a new UI or diagnostics surface.
  • Update the README condition, matching, and lifetime references; the Rules reference; the crafting-rules skill; the domain glossary; and the documented project structure for any new production module.
  • Continue targeting the supported OpenCode v1.x plugin range. The required composition is the awaited successful tool after-hook, no-reply synthetic prompt persistence, and the experimental pre-dispatch message transform. Re-verify these experimental API signatures and ordering whenever OpenCode dependencies are upgraded.

Testing Decisions

  • Good tests assert externally observable Rule matching and Rule delivery rather than private state, parser helpers, queue shapes, or method-call choreography. Inputs are authored Rule files and OpenCode hook events; outputs are provider-bound messages, persisted synthetic prompts, warnings, Matched-rule state, and visible sidebar condition summaries.
  • Use one primary server acceptance seam: initialize the real server plugin with real temporary Rule files and a mocked OpenCode client, drive the returned OpenCode v1 hook map, and inspect the next transformed dispatch and durable history interactions. This is the highest existing seam and covers frontmatter parsing, File observation normalization, matching, timing, persistence, and lifecycle together.
  • Use the existing sidebar component rendering seam only for the separate TUI entry point. Do not introduce lower-level test-only mutation hooks or a second public observation interface.
  • Prior art is the existing runtime and integration suites that initialize the plugin, invoke tool.execute.before, tool.execute.after, chat.message, message transform, event, and compaction hooks, and inspect synthetic Rule delivery. The existing sidebar tests are prior art for condition-summary rendering.
  • Cover scalar and array syntax; trimming; exact deduplication; case sensitivity; literal metacharacters; multiline literals; OR semantics; standalone content matching; and fail-closed invalid declarations with one warning.
  • Cover the File-observation family under both match: any and match: all, including (globs AND fileContains) OR keywords, same-observation success, cross-file failure, and an earlier retained File observation combining with a later condition.
  • Cover the canonical Rust scenario: **/*.rs plus unsafe { matches one Rust observation containing the literal, but not a Rust observation without it or a non-Rust observation containing it.
  • Cover successful Read, continuation/truncated Read, path-only binary/PDF Read, and directory Read behavior. Prove wrapper markup and line-number prefixes cannot create a content match.
  • Cover Write and Edit with both removed and added text.
  • Cover Apply Patch add, update, move, delete, multiple files, context lines, syntax exclusion, same-observation correlation, and successful malformed-patch fallback.
  • Cover every LSP operation category supported by the host with raw output bound only to the queried path, including no-results output and other-file URIs that must not create observations.
  • Cover failures and excluded sources: failed supported calls, Grep, Glob, shell, custom/MCP tools, prose, attachments, metadata, and diagnostics must not match globs or content.
  • Cover initial durable delivery, mid-session next-dispatch delivery, transient one-dispatch delivery, identity coalescing, concurrent admissions, later non-matches, and unspecified message grouping/order without asserting incidental ordering.
  • Cover durable persistence success, failure fallback into the current dispatch, later retry, ledger acknowledgement only after success, per-rule failure isolation, and synthetic-message recursion suppression.
  • Cover resume from existing delivery history, loss of unpersisted pending activation across restart, message removal, compaction, rule-source deletion, and the prohibition on historical observation replay or filesystem scanning.
  • Cover immediate Matched-rule state refresh after mid-session durable admission and fileContains rendering in the sidebar.
  • Run the repository's lint, TypeScript, and full test commands in the documented order.

Out of Scope

  • Regular-expression, case-insensitive, anchored, or all-literals-must-match content modes.
  • fileContains conditions inside Hooks.
  • Proactive, hidden, startup, or historical filesystem scanning.
  • Replaying historical tool events to reconstruct content activation.
  • Content or paths from Grep, Glob, shell, arbitrary/custom/MCP tools, user or assistant prose, attachments, metadata, diagnostics, or unsupported sources.
  • Persisting the pending activation state or adding a new persisted observation format.
  • Retracting or re-arming a Rule when matching text later disappears.
  • A new diagnostics UI, observation viewer, or sidebar interface.
  • Guaranteed batching or cross-tool ordering of concurrent synthetic messages.
  • Implementing the future OpenCode v2 delivery migration.

Further Notes

  • The prerequisite separation of Working context behind SessionWorkingContext has landed, so File-observation capture can be implemented without using SessionStore's path-only Working context fields.
  • The supported v1 API can meet the next-dispatch contract by composing the awaited successful tool after-hook, session.prompt with noReply, and the mutable pre-dispatch message transform. The transform remains experimental and is an explicit upgrade risk.
  • Existing Rule files require no migration. The intentional compatibility change is limited to file-based matching: path-like prose and excluded-tool inputs/results stop selecting glob Rules because normalized successful File observations become the sole file source.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions