feat(scoped-brains): Brain.scope(domain) + sub-agent inheritance (Phase 2)#78
Conversation
Introduces a domain-scoped proxy class that filters the parent brain's graduated rules by domain while delegating writes (correct, emit) through to the parent. Matches lessons on scope_json.domain, applies_to prefix, or category fallback (OR semantics). Renames the legacy string-returning scope(domain=..., task_type=...) helper to scoped_rules(...) so the name scope() can carry the new object-oriented API. Updates the four existing tests accordingly. Exposes ScopedBrain via gradata.__init__.
Lets any pattern retrieve graduated rules scoped to a given domain via scope["domain"], scope["applies_to"] (equal or "domain:" prefix), or category match. Symmetric with ScopedBrain's lesson filter so both paths agree on domain membership.
When a Claude Code sub-agent is spawned under a ScopedBrain the parent can either pass tool_input.scope_domain explicitly or set the GRADATA_SCOPE_DOMAIN env var. agent_precontext now filters injected rules by that domain before the existing relevance ranking runs, so sub-agents see only rules tagged for the parent's scope. Falls back silently to the prior keyword-inferred behaviour if no scope is declared or the filter module is unavailable.
…b-agent hook
19 new tests spanning the full Phase 2 surface:
* brain.scope(domain) returns ScopedBrain, rejects empty domain
* lesson filter by scope_json.domain, applies_to prefix, category fallback
* inject() returns only scoped rule text; empty when none match
* correct() through a ScopedBrain auto-tags applies_to=domain
* nested scope() rebinds to top-level parent (no intersection)
* RuleContext.query(domain=...) across all three match rules
* agent_precontext hook honours tool_input.scope_domain and
GRADATA_SCOPE_DOMAIN env, filtering rules before relevance ranking
There was a problem hiding this comment.
Gradata has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughPhase 2: Brain.scope(domain) API & Sub-agent Inheritance
WalkthroughThis PR implements domain-scoped access to rules and lessons via a new Changes
Sequence DiagramssequenceDiagram
participant Client
participant Brain
participant ScopedBrain
participant RuleContext
participant FilteredLessons["Filtered Lessons"]
Client->>Brain: scope("domain_x")
activate Brain
Brain->>ScopedBrain: create proxy(domain="domain_x")
deactivate Brain
Client->>ScopedBrain: lessons()
activate ScopedBrain
ScopedBrain->>Brain: get all lessons from parent
Brain-->>ScopedBrain: lessons[]
ScopedBrain->>FilteredLessons: filter by domain_x match
FilteredLessons-->>ScopedBrain: filtered_lessons[]
ScopedBrain-->>Client: filtered_lessons[]
deactivate ScopedBrain
Client->>ScopedBrain: rules()
activate ScopedBrain
ScopedBrain->>Brain: get all rules from parent
Brain-->>ScopedBrain: rules[]
ScopedBrain->>RuleContext: filter by domain_x via _rule_matches_domain()
RuleContext-->>ScopedBrain: domain-scoped_rules[]
ScopedBrain-->>Client: domain-scoped_rules[]
deactivate ScopedBrain
Client->>ScopedBrain: correct(draft, final)
activate ScopedBrain
ScopedBrain->>Brain: correct(...) with domain in context
Brain-->>ScopedBrain: result
ScopedBrain-->>Client: result
deactivate ScopedBrain
sequenceDiagram
participant SubAgent
participant PreContext["agent_precontext Hook"]
participant ScopeResolver["_resolve_scope_domain()"]
participant EnvVar["Environment"]
participant FilterModule["filter_lessons_by_domain()"]
participant FilteredLessons["Scoped Lessons"]
SubAgent->>PreContext: invoke with tool_input
activate PreContext
PreContext->>ScopeResolver: resolve domain
activate ScopeResolver
ScopeResolver->>ScopeResolver: check tool_input.scope_domain
alt scope_domain provided
ScopeResolver-->>PreContext: "domain_x"
else scope_domain empty
ScopeResolver->>EnvVar: read GRADATA_SCOPE_DOMAIN
alt env var set
EnvVar-->>ScopeResolver: "domain_x"
else env var empty
ScopeResolver-->>PreContext: ""
end
end
deactivate ScopeResolver
alt domain is non-empty
PreContext->>FilterModule: filter_lessons_by_domain(lessons, "domain_x")
activate FilterModule
FilterModule->>FilteredLessons: match scope_json.domain
FilteredLessons-->>FilterModule: filtered[]
deactivate FilterModule
FilterModule-->>PreContext: filtered[]
PreContext->>PreContext: use filtered lessons for inference
else domain is empty
PreContext->>PreContext: use all lessons (no scope filter)
end
PreContext-->>SubAgent: augmented context with rules/lessons
deactivate PreContext
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Deploying gradata-dashboard with
|
| Latest commit: |
5d1790f
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://162d997c.gradata-dashboard.pages.dev |
| Branch Preview URL: | https://wt-phase2-scoped-brains.gradata-dashboard.pages.dev |
Council verdict 4/4 STRICT: category is a taxonomy label (STYLE, TONE, SECURITY), not a domain. The OR-fallback where lesson.category.lower() == domain would silently match code-labelled lessons against a "code" domain scope even when no RuleScope was set, making domain filtering non-deterministic. Changes: - lesson_matches_domain (src/gradata/_scoped_brain.py): require scope_json.domain or scope_json.applies_to match; no more category fallback. - ScopedBrain._rule_dict_matches: drop category probe; keep metadata.where_scope (which is a serialization of scope_json.domain, not the category). - _rule_matches_domain (src/gradata/rules/rule_context.py): drop the category fallback branch. - tests/test_scoped_brains.py: rewrite the two fallback-dependent tests to assert legacy category-only lessons are NOT surfaced, plus new round-trip tests showing migration restores the match. Migration path for legacy lessons ships in the follow-up commit (scripts/migrate_legacy_scopes.py). Co-Authored-By: Gradata <noreply@gradata.ai>
Companion migration to the STRICT scoped-brain filter change. Rewrites legacy lessons that relied on the removed category-as-domain fallback so they continue to surface under brain.scope(domain). Behavior: - --dry-run by default (no writes); --apply to persist. - Skips lessons whose scope_json.domain is already set. - For each lesson whose scope_json lacks a domain, if its category is unambiguous (not MIXED/OTHER/GENERAL/UNKNOWN/empty) AND matches a known domain (from --domains-file YAML or inferred from the distinct categories in lessons.md), sets scope_json.domain = category.lower(). - Ambiguous or unknown categories are flagged for manual review, never silently migrated. - Prints a summary (migrated / flagged / skipped) and, with -v, the flagged entries. Tests cover the pure plan_migration function + the CLI in dry-run and apply modes against a 5-lesson fixture brain (2 migratable, 1 ambiguous, 1 already-scoped, 1 unknown-category). scripts/ is gitignored at the repo root; a negation rule pins migrate_legacy_scopes.py into the tree without unmuting other local scripts. Co-Authored-By: Gradata <noreply@gradata.ai>
There was a problem hiding this comment.
Gradata has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.
Two type errors broke main's pyright job after PR #78 landed: 1. src/gradata/hooks/jit_inject.py — `rank_rules_for_draft` returned `list[tuple[object, float]]`, so the main() list comp's `r.state.name` etc. accessed attributes on `object`. Tightened the parameter + return-type annotations to `list[Lesson]` / `list[tuple[Lesson, float]]` and added the `Lesson` import. Tuple invariance forced the propagation. 2. src/gradata/rules/rule_context.py — `_rule_matches_domain` returned `bool | Literal['']` because `applies and applies.startswith(...)` short-circuits to the empty string when `applies == ""`. Wrapped in `bool(applies) and applies.startswith(...)` and split into two statements so pyright sees a clean `bool` return. Both files pass pyright cleanly. No behaviour change. Co-Authored-By: Gradata <noreply@gradata.ai>
Two type errors broke main's pyright job after PR #78 landed: 1. src/gradata/hooks/jit_inject.py — `rank_rules_for_draft` returned `list[tuple[object, float]]`, so the main() list comp's `r.state.name` etc. accessed attributes on `object`. Tightened the parameter + return-type annotations to `list[Lesson]` / `list[tuple[Lesson, float]]` and added the `Lesson` import. Tuple invariance forced the propagation. 2. src/gradata/rules/rule_context.py — `_rule_matches_domain` returned `bool | Literal['']` because `applies and applies.startswith(...)` short-circuits to the empty string when `applies == ""`. Wrapped in `bool(applies) and applies.startswith(...)` and split into two statements so pyright sees a clean `bool` return. Both files pass pyright cleanly. No behaviour change. Co-Authored-By: Gradata <noreply@gradata.ai>
Summary
Ships
brain.scope(domain) -> ScopedBrainAPI: a filtered view where only rules tagged with the domain inject. Sub-agents inherit the scope automatically.API
Sub-agent inheritance: parent passes
tool_input.scope_domain="code"or exportsGRADATA_SCOPE_DOMAIN=code;agent_precontext.main()filters before keyword scoring.RuleContext.query(..., domain="code")filters the singleton rule registry identically.Naming change
Old
Brain.scope()(4-value enum getter) renamed toBrain.scoped_rules()to free the name. 4 legacy tests updated.Council verdict 4/4 STRICT (applied)
Domain match logic is now STRICT. A lesson belongs to domain
Diff:scope_json.domain == D, orscope_json.applies_to == Dorapplies_to.startswith(f"{D}:")The category-as-domain fallback was dropped:
categoryis a taxonomy label (STYLE, TONE, SECURITY), not a domain, and the implicit coupling created non-deterministic scope membership for any lesson whose category label happened to look like a domain.Legacy lessons that previously relied on the fallback are handled by
scripts/migrate_legacy_scopes.py(dry-run by default,--applyto persist). Ambiguous categories (MIXED,OTHER,GENERAL, unknown) are flagged for manual review rather than silently migrated.Files
src/gradata/_scoped_brain.py(new) — strict match logicsrc/gradata/rules/rule_context.py— strict_rule_matches_domainsrc/gradata/brain.py,hooks/agent_precontext.py,__init__.pyscripts/migrate_legacy_scopes.py(new) +.gitignorenegation ruletests/test_scoped_brains.py(18 tests) +test_migrate_legacy_scopes.py(5 tests) +test_scoped_brain.py(4 legacy updated)Tests
2424 pass, 23 skipped. Ruff clean on all touched files.
Commits
0e69b06feat(scoped-brains): addBrain.scope(domain) -> ScopedBrainviewc4d131bfeat(rule-context): adddomain=filter toRuleContext.query()79dedc6feat(hooks): propagate scope domain into sub-agent precontext67a1907test(scoped-brains): cover ScopedBrain + domain filter + hook09a523efix(scoped-brains): drop category-as-domain fallback per council verdict5d1790ffeat(scripts): addmigrate_legacy_scopesfor category-only lessonsCo-Authored-By: Gradata noreply@gradata.ai