Schema Evolution
Goal
Capture fast. Structure later. Zero migration pain. Schema = contract, not cage.
5.1 Schemaless Day One
No config. No setup. Drop .md files. Write key:: value.
onde lint checks syntax only. Misspelled enums? Fine. Missing fields? Fine. Files in random folders? Fine.
Graph builds anyway. Queries work anyway.
Rule: Speed first. Law later.
5.2 When to Add Structure
Add schema when chaos costs more than setup. Signals:
- Team writes
status:: IN_PROGRESSbut you expectDOING - Required fields missing. Decisions lack confidence. Tasks lack deadlines.
- Files scatter.
tasks/contains notes.people/contains bugs. - Context window bloat. One file eats 8% of LLM memory.
- You want auto-naming, auto-sorting, or strict edge rules.
Rule: Extract when patterns emerge. Not before.
5.3 Build Your First Schema (Step-by-Step)
Zero manual typing required. onde reads your mess. Writes the law. You refine it.
Step 1: Extract from Existing Files
onde schema extract > .onde/schema.md
What happens:
- Scans all
.mdfiles - Groups nodes by
type:: - Infers property types, enums, edges
- Detects directory layout
- Marks 100%-present fields as
@required - Adds
@softto directory rules (warnings, not errors) - Writes clean
.onde/schema.md
Step 2: Review & Clean
Open .onde/schema.md. You will see inferred sections. Clean them:
- Remove junk types (
type:: scratch,type:: temp) - Fix typos (
stauts→status) - Merge duplicate enums (
enum(A B C)+enum(A,B,C)→ one## Enumsblock) - Drop unused properties
- Keep only what matters
Step 3: Set Defaults & Requirements
Add safety nets so agents/humans skip less:
### task
status: enum(TaskStatus) @required
priority: enum(Priority) @default(C)
deadline: date
@required= lint fails if missing@default(C)= auto-fills when empty- No directive = optional, no auto-fill
Step 4: Map Directories
Tell onde where each type lives:
## Directories
tasks/**: type(task)
people/**: type(person)
decisions/**: type(decision)
drafts/**: ignore
/**= deep (any nesting)/= shallow (top-level only)ignore= skip parsing entirely
Step 5: Enforce Filenames (Optional)
Stop random naming drift:
tasks/**: type(task) @naming("{id}.md")
people/**: type(person) @naming("{title-kebab}.md")
Templates: {id}, {title}, {title-kebab}, {title-snake}, {type}, {date}, {now}.
Step 6: Soft Validate First
Never go hard day one. Test the law:
onde lint --soft
- All schema violations → warnings
- Exit 0. Files untouched.
- Read output. Fix data OR fix schema.
- Repeat until warnings drop to acceptable level.
Step 7: Lock It
Remove --soft. Run strict:
onde lint
- Violations → errors. Exit 1.
- Fix remaining issues.
- Commit schema + cleaned files.
- Workspace now schemaful.
Rule: Extract → Clean → Soft → Lock. Never skip soft pass.
5.4 Schema Anatomy
Four sections. Order matters. Keep it tight.
---
type: schema
version: 6
@max-tokens: 100000
---
## Enums
### TaskStatus
values: TODO DOING DONE BLOCKED CANCELLED
@numeric: 0 1 2 3 4
## Node Types
### task
id: string @id @default(counter(prefix: "task", pad: 3))
status: enum(TaskStatus) @required
priority: enum(Priority) @default(C)
deadline: date
assigned: edge(person) @inverse(owns) @many
depends: edge(task) @inverse(blocks) @many
@sort: priority asc, deadline asc
@group: status(TODO DOING DONE BLOCKED CANCELLED)
## Edge Types
depends: task > task @inverse(blocks)
assigned: task > person @inverse(owns)
## Directories
tasks/**: type(task) @naming("{id}.md")
drafts/**: ignore
| Section | Purpose |
|---|---|
| Frontmatter | Schema type + version + workspace token cap |
## Enums | Reusable value sets. Referenced via enum(Name). |
## Node Types | Field definitions + directives + display defaults. Core of schema. |
## Edge Types | Global edge declarations. Direction + inverse. |
## Directories | Folder → type mapping. Naming + budgets + strictness. |
5.5 Enums
Reusable value sets. Defined once, referenced everywhere. Two forms.
Reusable Enum (## Enums section)
## Enums
### TaskStatus
values: TODO DOING DONE BLOCKED CANCELLED
@numeric: 0 1 2 3 4
Inline Enum (one-off, on field)
### task
status: enum(TODO DOING DONE) @required
No ## Enums entry needed for one-offs. Good for fields unique to one type.
Reusable vs Inline
Reusable (## Enums) | Inline | |
|---|---|---|
| When | Shared across 2+ types | Single type only |
| Reference | enum(TaskStatus) | enum(A B C) |
| Ordering | @numeric enables sort/agg | No ordering |
| Refactor | Change one place | Change per field |
@numeric — Enable Ordering
### Priority
values: A B C
@numeric: 1 2 3
@numeric maps values to numbers. Enables @sort, @avg, @sum, @min, @max on enum fields. Without @numeric, only @count works on enums.
Priority A=1, B=2, C=3 → @sort: priority asc orders A before B before C.
Enum Validation
status: enum(TaskStatus) @required
status:: DOING → valid. status:: IN_PROGRESS → lint error. Full-stop matching. No partial match. No case-insensitive. Exact value or fail.
5.6 Property Types
Every field has a type. Schema enforces. Lint validates.
| Type | Example | Validation |
|---|---|---|
string | title: string | Any text. Use @max-length, @pattern for constraints. |
int | points: int | Integer. 42 ok. 3.14 fails. |
float(N) | confidence: float(0-1) | Decimal with optional range. 0.85 ok. 1.5 fails if range 0-1. |
bool | active: bool | true or false only. |
date | deadline: date | YYYY-MM-DD format. |
datetime | created_at: datetime | YYYY-MM-DDTHH:MM:SS format. |
enum(Name) | status: enum(TaskStatus) | Must be value from ## Enums section. |
enum(A B C) | mode: enum(safe risky) | Inline enum. No ## Enums entry needed. |
edge(Type) | assigned: edge(person) | Link to type:: person node. Creates directed edge. |
Type coercion in @check: date → datetime → numeric → string comparison. Missing fields = check skipped silently.
5.7 ID Formats & Generation
Every node needs a primary key. Declare it. Control how it generates.
Declaration
### task
id: string @id @default(counter(prefix: "task", pad: 3))
@id= marks field as primary key. Unique across workspace. Duplicate = lint error.@default(...)= generation strategy. Runs whenid::missing.- Manual
id:: anything→@defaultskipped. Never overwritten. - No
idfield at all → auto-included asid: string @id @default(counter(prefix: "{type}", pad: 3)).
Generation Strategies
| Strategy | Example | Best For |
|---|---|---|
counter(prefix, pad) | task-001, task-002 | Small graph, human-readable, sequential |
counter(prefix, pad, include_title: true) | task-001-fix-token | Sortable + descriptive |
kebab | task-fix-token-refresh | Deterministic, same title = same ID |
snake | task-fix_token_refresh | Code-friendly |
uuid-short | task-a1b2c3d4 | Medium graph, compact |
uuid-full | task-550e8400-... | Large graph, zero collision |
timestamp | task-20260407T143000Z | Logs, temporal data |
hash | task-3f2a9b | Dedup, content-addressable (6 hex chars) |
bare | fix-token-refresh | No type prefix |
| manual | anything | Migration, existing systems |
Counter Options
id: string @id @default(counter(prefix: "t", pad: 4))
# → t-0001, t-0002
id: string @id @default(counter(prefix: "none"))
# → 001, 002
Collision Handling (kebab/snake/bare)
Same title = collision. Second gets suffix:
task-meeting-notes # first
task-meeting-notes-2 # collision
5.8 Edges & Relationships
Declaring Edges
Two ways. Both valid. Schema picks style.
On node type field (most common):
### task
assigned: edge(person) @inverse(owns) @many
depends: edge(task) @inverse(blocks) @many
Global edge type (explicit direction + inverse):
## Edge Types
depends: task > task @inverse(blocks)
assigned: task > person @inverse(owns)
part_of: any > project @inverse(contains)
task > person means: edge goes FROM task TO person. @inverse(owns) means: person gets owns:: [[task]] auto-generated.
Inverse Edges
Write assigned:: [[sarah-chen]] on task → onde lint auto-adds owns:: [[task-882]] on sarah-chen. Bidirectional. Always consistent.
### task
assigned: edge(person) @inverse(owns) @many
| Side | Field | Direction |
|---|---|---|
| Task writes | assigned:: [[person-001]] | Forward edge |
| Person gets | owns:: [[task-882]] | Auto-generated inverse |
No inverse declared = one-way edge only. Forward works. No back-link created.
Edge Cardinality
| Directive | Meaning | Lint Error |
|---|---|---|
| (none) | Single target. Max one. | More than one = error. |
@many | Multiple targets. Any count. | No limit. |
@many(min:1) | At least one required. | Zero = error. |
@many(max:5) | At most five. | Six = error. |
@many(min:1, max:5) | Between one and five. | Zero or six = error. |
assignees: edge(person) @inverse(owns) @many(min:1, max:5)
reviewers: edge(person) @many(max:3)
lead: edge(person) @inverse(owns) # single only
Edge Targets
edge(person) = target must be type:: person. Lint validates. Link to wrong type = error.
Multi-type targets: edge(task decision) in dir rules. Edge fields on node type accept one type only. Dir rules allow multiple types per folder.
5.9 Node Type Display Directives
Control how onde find and onde organize output nodes. Set per type. Override in files.
@sort — Default Sort Order
### task
@sort: priority asc, deadline asc
Queries without explicit --sort use this. priority asc = A before B before C (if @numeric set). Multiple fields = tiebreaker chain.
@group — Default Grouping
### task
@group: status(TODO DOING DONE BLOCKED CANCELLED) > priority(A B C)
onde find without --group-by uses this. Groups by status first, then priority within each status group. > separates group levels.
@default-display — Output Format
### task
@default-display: dense
| Value | Effect |
|---|---|
dense | onde find task outputs TOON pipe rows. ~15 tokens/node. |
standard | onde find task outputs full ## Heading + key:: value. ~100-500 tokens/node. |
Override per query: onde find task --format md-dense or --format md.
@summary-template — Auto-Generated Body
### decision
@summary-template: "{decided_by} decided on {decided_at}. Confidence: {confidence}."
Node has no body text? Lint generates summary from template into body. Agent sees context without writing prose.
5.10 Field Directives (Data Quality)
Directives live on fields. Enforce rules. Auto-fill values. Catch drift.
Core Directives
| Directive | Effect | Example |
|---|---|---|
@required | Fails lint if missing | status: enum(TaskStatus) @required |
@default(X) | Auto-fills when empty | priority: enum(Priority) @default(C) |
@auto(now) | Sets timestamp on creation | created_at: datetime @auto(now) |
@on-update | Overwrites timestamp every lint | updated_at: datetime @on-update |
@check("A > B") | Compares two fields on same node | @check("deadline > decided_at") |
@pattern("regex") | Validates string format | code: string @pattern("^[A-Z]{3}-\\d{3}$") |
@max-length(N) | Caps string length | title: string @max-length(200) |
@unique | Value distinct across all nodes of type | slug: string @unique |
@deprecated("msg") | Warns on use. Still works. | old_field: string @deprecated("use slug") |
@index | Marks field for query index. Currently same as @unique. | email: string @index |
@many(min, max) | Edge cardinality bounds | assignees: edge(person) @many(min:1, max:5) |
Timestamp Patterns
| Need | Directive | Behavior |
|---|---|---|
| Created timestamp | @auto(now) | Set once on first lint. Never overwrites. |
| Modified timestamp | @on-update | Set on creation + overwrite every lint. |
| Both | created_at: datetime @auto(now) + updated_at: datetime @on-update | Standard pattern. |
@check Rules
- Expression:
field1 operator field2. Operators:>,<,>=,<=,==,!=. - Type coercion: date → datetime → numeric → string. Tries each in order.
- Missing field = check skipped silently. Not an error.
@check("deadline > decided_at")on decision type → lint verifies deadline after decided_at.
@unique Rules
- Cross-node validation. Runs after all files parsed.
- Empty/missing values not tracked. Only non-empty values checked.
slug: string @unique→ two tasks with slug “fix-auth” = lint error.- Lists both violating files:
unique constraint violated — "slug" value "fix-auth" used by: task-001, task-002
5.11 Directory Rules & Naming
Folders enforce type. Type enforces folder. Pick strictness level.
Pattern Types
| Pattern | Depth | Example Match |
|---|---|---|
tasks/ | Shallow (top only) | tasks/fix.md ✓ tasks/auth/fix.md ✗ |
projects/** | Deep (any nesting) | projects/a/b/c.md ✓ |
*-scratch/** | Glob | temp-scratch/notes.md ✓ |
drafts/**: ignore | Skip | Never parsed. Zero overhead. |
Multi-Type Directories
projects/**: type(task decision note)
File in projects/ can be task, decision, or note. Any of the listed types. Agent chooses.
Resolution Order
Most specific wins. Always.
- Exact file path →
tasks/overview.md - Deepest prefix →
projects/alpha/**beatsprojects/** - Glob →
**/q2/**beats** - Shallow →
tasks/ - No match → error (hard) or warning (
@soft)
Naming Templates
tasks/**: type(task) @naming("{id}.md")
# → task-882.md, task-883.md
decisions/**: type(decision) @naming("{date}-{title-kebab}.md")
# → 2026-04-15-use-postgres.md
Variables: {id}, {title}, {title-kebab}, {title-snake}, {type}, {date}, {date:YYYY}, {now}, {now:YYYY-MM}.
Naming Exceptions
tasks/**: type(task) @naming("{id}.md")
tasks/overview.md: @naming-ignore
tasks/archive/**: @naming-ignore
Exact path beats prefix. Overview and archive keep original names.
Naming Inheritance
Child inherits parent @naming unless overridden.
tasks/**: @naming("{id}.md")
tasks/backlog/**: @naming("{id}-backlog.md") # override
tasks/backlog/old/**: @naming-ignore # override: skip
Custom File Extensions (@ext)
Default: scanner parses .md only. Use @ext for double extensions (.task.md, .person.md) or non-standard extensions.
## Directories
# Double extension: parse .task.md files
tasks/**: type(task) @naming("{id}.task.md") @ext(task.md)
# Multiple extensions: parse both .md and .task.md
tasks/**: type(task) @ext(md task.md)
# Workspace default (frontmatter)
@ext: md task.md person.md decision.md
| Directive | Scanner behavior |
|---|---|
No @ext | Parse .md only (default) |
@ext(task.md) | Parse .task.md only |
@ext(md task.md) | Parse both |
@ext(csv) | Parse .csv files |
@ext + @naming must agree. @naming("{id}.task.md") + @ext(md) = mismatch warning. Fix: align both.
Inherited from parent. Child override replaces entirely (not additive).
File Type Restrictions (@reject-ext)
Prevent wrong file types from accumulating in dirs. Agent drops .txt, .csv, .json scratch files. @reject-ext catches them.
## Directories
tasks/**: type(task) @reject-ext(txt csv json log)
people/**: type(person) @reject-ext(txt)
ERR tasks/notes.txt rejected extension — .txt not allowed in tasks/** (reject-ext: txt csv json log)
@soft = warning. No @reject-ext = no restriction. Files matched by @ext never rejected. Child override replaces parent.
@ext + @reject-ext Together
@ext = what to parse. @reject-ext = what to forbid.
tasks/**: type(task) @naming("{id}.task.md") @ext(task.md) @reject-ext(txt csv json)
Effect: scan .task.md only. Validate naming. Reject .txt, .csv, .json. .md = ignored (not scanned, not rejected).
Dir Rules + Path Links
Dir rules make path links typed. tasks/ = always type(task).
depends:: [[/tasks/auth-fix.md]] # path link = guaranteed correct type
assigned:: [[../people/sarah.md]] # relative link = same dir type
Auto-Suggest Rules
onde schema dirs --suggest
# Outputs detected patterns with @soft. Review. Paste into schema.
Rule: Map dirs early. Enforce names later. Use @soft until team adapts.
5.12 Token Budgets (Context Guardrails)
LLM context = finite. One bloated file = blind agent. Budgets prevent silent degradation.
Where to Set
## Directories
tasks/**: type(task) @max-file-tokens(500) @max-dir-tokens(15000)
tasks/active.md: @max-file-tokens(2000)
memory/**: type(memory) @max-file-tokens(200) @max-dir-tokens(10000) @soft
Or workspace total in frontmatter:
---
type: schema
@max-tokens: 100000
---
What Each Does
| Directive | Scope | Effect |
|---|---|---|
@max-file-tokens(N) | Per file | Fails if single .md exceeds N |
@max-dir-tokens(N) | Per pattern | Fails if sum of matched files exceeds N |
@max-tokens(N) | Workspace | Fails if grand total exceeds N |
@soft | Any rule | Downgrades to warning. Exit 0. |
Inheritance & Override
- Child inherits parent
@max-file-tokensif not set. @max-dir-tokensnever inherits. Scoped to pattern only.- Exact file path > deepest prefix > parent rule.
Rule: Set budgets after workspace stabilizes. Start high. Tighten gradually. Use @soft during transition.
5.13 Node Count Limits (Density Guardrails)
Token budgets = file size. Node count limits = density. 200 short rows can fit under token budget while being unscannable. Node limits catch this directly.
| Directive | Scope | Effect |
|---|---|---|
@max-rows(N) | Per dense block | Caps - pipe rows under one heading |
@max-nodes(N) | Per file | Caps all node definitions (Mode 1 + dense) in one file |
@max-dir-nodes(N) | Per dir pattern | Caps total nodes across all matched files |
@auto-split | With any limit | Lint auto-paginates excess into *.batch-NN.md files |
All support @soft. Same resolution order as token budgets (most specific wins). All opt-in.
Example
## Directories
tasks/**: type(task) @max-rows(50) @max-nodes(50) @max-dir-nodes(500)
tasks/active.md: @max-rows(20) @auto-split
decisions/**: type(decision) @max-nodes(10)
memory/**: type(memory) @max-nodes(1000) @soft
Auto-Split vs Manual Split
@auto-split (schema) | onde organize --split | onde organize --split --batch-size N | |
|---|---|---|---|
| Trigger | Auto during lint | Manual | Manual |
| Output | *.batch-NN.md (N per file) | One node per file | *.batch-NN.md (N per file) |
| Format | Dense preserved | Mode 1 | Dense preserved |
@auto-split = auto-paginate. onde organize --split = full decomposition. --batch-size = manual paginate.
5.14 Schema CLI Commands
onde schema extract # generate schema from existing nodes
onde schema extract > schema.md # write to file
onde schema show # print current schema
onde schema diff # compare schema.md to actual node data
onde schema dirs --suggest # detect dir + naming patterns from files
onde migrate --from 3 --to 4 # schema version migration
onde migrate --from 3 --to 4 --dry-run
| Command | When |
|---|---|
schema extract | Starting schema. Day one. |
schema show | Quick check. What’s active? |
schema diff | Drift detection. Schema says X, nodes say Y. |
schema dirs --suggest | Adding dir rules. Auto-detect patterns. |
migrate | Schema version change. Additive only. |
5.15 .onde/config.md — Override Layer
Schema = shared contract. config.md = local override. No schema edit needed.
Place .onde/config.md inside any .onde/ directory. Deepest wins.
# .onde/config.md
@max-file-tokens: 500
@max-file-tokens(active.md): 2000
@max-rows: 30
@max-nodes: 100
@max-dir-nodes: 5000
@max-dir-tokens: 15000
@max-tokens: 100000
@auto-split
@soft
| Use config.md when… | Use schema when… |
|---|---|
| Temporary budget exception | Permanent team-wide budget |
| One person needs more headroom | All files in a dir share same limit |
| Per-file overrides | Budget version-controlled with schema |
Full resolution order: config.md per-file > deepest config.md > schema dir rule > no match.
5.16 Switching Modes
Mode = file presence. Not config flag. Instant switch. Zero data loss.
Schemaless → Schemaful
onde schema extract > .onde/schema.md
onde lint --soft # test law
onde lint # lock law
git add .onde/schema.md
git commit -m "Add schema."
Schemaful → Schemaless
mv .onde/schema.md .onde/schema.md.bak
onde lint # syntax-only instantly
Graph unchanged. Links unchanged. Files untouched. Validation drops. Freedom returns.
Gradual Adoption
- Keep
@softon directory rules. Warnings guide. Errors don’t block. - Run
onde lint --softglobally. All violations → warnings. - Promote rules to hard one by one. Team adapts without friction.
Rule: Schema = opt-in. Remove file = opt-out. Always reversible.
5.17 Schema Migration
Additive only. Never delete properties. Deprecate. Git = rollback.
onde migrate --from 3 --to 4 --dry-run
onde migrate --from 3 --to 4
onde lint
git diff
onde git commit
What happens:
- Adds new fields with
@default→ existing nodes auto-filled - Deprecated fields → warning. Still works. Not deleted.
- Type changes → lint errors. Fix manually.
- No data loss. Git has history. Rollback any time.
Rule: Bump version. Migrate. Lint. Commit. Never destructive.
5.19 Common Traps & Fixes
| Trap | Symptom | Fix |
|---|---|---|
| Extracted schema too noisy | 50+ inferred properties, many junk | Delete unused. Keep core. Rerun lint. |
@required blocks old files | Lint fails on legacy nodes missing field | Add @default(X) or backfill data. |
| Dir rule too strict | tasks/ rejects nested folders | Change tasks/ → tasks/**. |
| Naming breaks path links | [[/tasks/old.md]] fails after rename | onde relink --to id before --rename. |
| Budget fails day one | Every file over limit | Raise limit. Match reality. Tighten later. |
@check silent fail | Comparison not triggering | Check field names. Ensure compatible types. |
| Schema typo causes mass errors | stauts instead of status | Fix schema. Not files. Rerun lint. |
@soft hides real issues | Warnings pile up, never fixed | Schedule cleanup. Promote to hard when ready. |
| Inverse missing | No back-link on target | Add @inverse(owns) to source field. |
| Edge type unknown | depends:: [[X]] fails | Add edge to ## Edge Types section. |
| Wrong edge target type | edge(person) but linked to task | Link to correct type. Lint validates. |
@unique collision | Two nodes share slug | Change one slug or remove @unique. |
| Node count limit surprise | Dense file hit limit unexpectedly | Set @max-rows/@max-nodes. Enable @auto-split. |
@ext vs @naming mismatch | Files never found by scanner | Align both: @ext(task.md) + @naming("{id}.task.md"). |
| Wrong extension files in dir | .txt / .csv junk in tasks/ | Add @reject-ext(txt csv) to dir rule. |
| Custom ext file invisible | .task.md not parsed | Add @ext(task.md) to dir rule. |
5.20 Schema Design Patterns
Pattern A: Single-Type Flat Workspace
## Directories
tasks/: type(task) @naming("{id}.md")
people/: type(person) @naming("{title-kebab}.md")
decisions/: type(decision)
One type per dir. Flat structure. Easy to navigate. Good for small teams, simple domains.
Pattern B: Multi-Type Project Tree
## Directories
projects/**: type(task decision note meeting)
people/**: type(person) @naming("{id}.md")
memory/**: type(memory) @soft
projects/**/archive/**: ignore
Agent nests freely inside projects/. Multiple types allowed. Good for complex domains.
Pattern C: Double Extensions for Type Safety
## Directories
tasks/**: type(task) @naming("{id}.task.md") @ext(task.md) @reject-ext(txt csv json md)
people/**: type(person) @naming("{id}.person.md") @ext(person.md) @reject-ext(txt csv json md)
decisions/**: type(decision) @naming("{date}-{title-kebab}.decision.md") @ext(decision.md)
File extension = type identity. ls tasks/ = all .task.md. Finder/explorer groups by extension. No name collisions with .md files. Strict: reject all other extensions.
Pattern D: Gradual Tightening
## Directories
# Week 1: @soft everything
tasks/**: type(task) @soft @naming("{id}.md") @soft
# Week 3: hard type, soft naming
tasks/**: type(task) @naming("{id}.md") @soft
# Week 6: hard everything
tasks/**: type(task) @naming("{id}.md") @reject-ext(txt csv)
Pattern E: Schema-First Workspace
## Enums
### TaskStatus
values: TODO DOING DONE BLOCKED CANCELLED
@numeric: 0 1 2 3 4
## Node Types
### task
id: string @id @default(counter(prefix: "task", pad: 3))
status: enum(TaskStatus) @required
priority: enum(A B C) @default(C)
deadline: date
@sort: priority asc, deadline asc
@group: status(TODO DOING DONE BLOCKED CANCELLED)
@default-display: dense
Define schema before writing nodes. Lint catches mistakes immediately. Good for teams with clear domain model. Bad for exploratory work.
Pattern F: Mixed Strict/Free Zones
## Directories
# Core domain: strict
tasks/**: type(task) @naming("{id}.md") @reject-ext(txt csv)
people/**: type(person) @naming("{id}.md")
# Exploration: free
research/**: type(note memory assumption) @soft
scratch/**: ignore
Core dirs locked down. Research dirs open. Scratch invisible. Best of both worlds.
Rule: Match pattern to team maturity. New team = Pattern A or D. Mature team = Pattern B or C. Mixed = Pattern F.
Next Step: → Querying & Visualization: Find Anything, Render Anywhere