Files
franalgaba__grimoire/AGENTS.md
T
Fran Algaba 001b16258c migrate .spell DSL to brace-delimited syntax (#14)
* migrate .spell DSL from indentation to brace-delimited syntax (SPEC-001)

Replace Python-style 2-space indentation with Rust/Go curly-brace
blocks across the entire codebase. This is a breaking syntax change
that improves agent authorship — LLMs handle braces far better than
whitespace-significant formats.

Tokenizer: remove INDENT/DEDENT tokens, split bracketDepth into
parenDepth (suppresses newlines) and braceDepth (preserves newlines),
remove IndentationError.

Parser: replace all INDENT/DEDENT patterns with LBRACE/RBRACE across
~22 methods. New parseBraceBlock() helper replaces parseStatementBlock().
Two patterns: label blocks (label: { }) and keyword blocks (keyword { }).

AST, transformer, IR generator, and runtime are unchanged — indentation
was purely a lexical/parse concern.

Updated 86 .spell fixture files, 15 test files, 20 documentation files,
README, AGENTS.md, VM spec references, and compiler-pipeline docs.

620 tests pass, 0 fail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add changeset

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 14:04:08 +01:00

16 KiB

description, globs, alwaysApply
description globs alwaysApply
Grimoire - A Portable Execution Language for Onchain Strategies *.ts, *.tsx, *.html, *.css, *.js, *.jsx, *.spell, package.json false

Development Rules

First Message

If the user did not give a concrete task in their first message, read README.md, then ask which area to work on (compiler/runtime, venues, CLI, docs, skills). Based on the answer, read the relevant docs or package readmes.

Agent Portability (LLM-agnostic)

  • Avoid tool-specific assumptions. Use standard shell commands and Bun scripts.
  • Prefer repo-local context. Document workflows in this repo instead of relying on conversation memory.
  • Use relative paths.
  • Provide manual fallbacks if a workflow is automated.
  • Keep secrets out of prompts; use environment variables.
  • If a task needs new dependencies, list them and pin versions.

Code Quality

  • No any unless absolutely necessary.
  • Avoid Bun-only APIs in packages/core, packages/venues, and packages/cli. If you must use Bun APIs, add a Node fallback.
  • Keep protocol SDKs out of @grimoirelabs/core (core stays protocol-agnostic).
  • Prefer deterministic, side-effect-free logic in compiler/runtime.
  • Ask before removing functionality or changing DSL semantics.

Commands

  • For code changes, run bun run validate unless the user says otherwise.
  • Docs-only changes do not require tests.
  • Onchain tests are expensive: only run when requested. Use --dry-run first.
  • Never commit unless the user asks.

Git Rules

  • Do not use destructive commands (git reset --hard, git checkout ., git clean -fd) unless explicitly asked.
  • Stage only files you changed in this session.

GitHub Issues

When reading issues, read all comments. Use:

gh issue view <number> --json title,body,comments,labels,state

PR Workflow

Work in a feature branch if requested. Do not open PRs unless asked.

Tools

  • Bun for install/test/build: bun install, bun test, bun run ...
  • Biome for lint/format: bun run lint, bun run format
  • TypeScript: bun run typecheck
  • Skills validation: bunx skills-ref validate skills/grimoire-vm
  • Changesets: bunx changeset

Style

  • Keep answers concise and technical.
  • No emojis in commits, issues, or code comments.

Changelog

Location: CHANGELOG.md (root). Format follows Keep a Changelog.

  • Add new entries only under ## Unreleased.
  • Do not edit already-released version sections.
  • Use Changesets for normal release notes. Only edit CHANGELOG.md when asked.

Releasing

  • Initial 0.1.0 publish is manual (see docs/how-to/publish.md).
  • After that, use Changesets + CI. Do not bump versions manually.

Project Overview

Grimoire is a DSL for defining and executing onchain DeFi strategies. Strategies are written in .spell files using a brace-delimited syntax ({ ... }).

Project Structure

packages/
  core/                      # Core compiler + runtime (protocol-agnostic)
    src/
      compiler/              # Spell -> IR compilation
        grimoire/            # Tokenizer, parser, transformer
        expression-parser.ts
        ir-generator.ts
        validator.ts
      runtime/               # IR execution engine
        interpreter.ts
        context.ts
        state-store.ts       # StateStore interface + RunRecord types
        sqlite-state-store.ts # SQLite-based state persistence
        steps/               # Step handlers (compute, conditional, loop, etc.)
      venues/                # Adapter registry + types (no SDKs)
      types/                 # TypeScript type definitions
      builders/              # Fluent API for building spells
  venues/                    # Official adapters (SDK integrations)
    src/
      aave-v3.ts
      uniswap-v3.ts
      uniswap-v4.ts
      morpho-blue.ts
      hyperliquid.ts
      across.ts
      yellow.ts
      lifi.ts
      cli/                   # per-venue CLIs
  cli/                       # Grimoire CLI
  sdk/                       # (WIP) SDK for external integrations
spells/                      # Example spell files
skills/                      # Agent skills (per-venue + VM)
docs/                        # Diataxis docs

Grimoire Syntax

Spells use a brace-delimited syntax ({ ... }):

spell YieldOptimizer {
  version: "1.0.0"
  description: "Optimizes yield across lending venues"
  assets: [USDC, USDT, DAI]

  params: {
    min_amount: 100
    amount: 100000
  }

  limits: {
    max_allocation_per_venue: 50%
  }

  venues: {
    aave_v3: @aave_v3
    morpho_blue: @morpho_blue
    uniswap_v3: @uniswap_v3
  }

  skills: {
    dex: {
      type: swap
      adapters: [uniswap_v3]
      default_constraints: {
        max_slippage: 50
      }
    }
  }

  advisors: {
    risk: {
      model: anthropic:sonnet
      timeout: 30
      fallback: true
    }
  }

  state: {
    persistent: {
      counter: 0
    }
    ephemeral: {
      temp: 0
    }
  }

  on hourly: {
    for asset in assets {
      current_balance = balance(asset)

      if current_balance > params.min_amount {
        if **gas costs justify the move** via risk {
          atomic {
            aave_v3.withdraw(asset, params.amount)
            morpho_blue.lend(asset, params.amount)
          }
        }
      }
    }
  }
}

Syntax Reference

Feature Syntax Example
Spell declaration spell Name { spell YieldOptimizer {
Arrays [item1, item2] assets: [USDC, DAI]
Venue refs @name @aave_v3
Venue groups name: [@v1, @v2] lending: [@aave_v3, @morpho_blue]
Skills skills: { skills: { ... }
Advisors advisors: { advisors: { ... }
Percentages N% 50% (converts to 0.5)
Triggers on trigger: { on hourly: {, on daily: {, on manual: {
For loops for x in y { for asset in assets {
Repeat loops repeat N { repeat 3 {
Loop until loop until cond max N { loop until done max 10 {
If/elif/else if cond { if x > 0 {
Advisory (AI) **prompt** if **is this safe** {
Advise x = advise advisor: decision = advise risk: "..."
Atomic blocks atomic { Transaction grouping
Try/catch try { try { ... } catch *: {
Parallel parallel ... { parallel join=all: {
Pipeline `expr map: {`
Block/Do block / do block add(a,b) {
Comments # comment # Calculate rates
Method calls obj.method(args) venue.deposit(asset, amount)
Assignment x = expr rate = get_apy("aave_v3", asset)
Using skill using name swap(...) using dex
Constraints with k=v with max_slippage=50
Logical ops and, or, not if a > 0 and b < 10 {
Emit events emit name(k=v) emit done(value=42)
Halt execution halt "reason" halt "insufficient balance"
Wait wait N wait 3600 (seconds)

Example Spells

  • spells/uniswap-swap-execute.spell
  • spells/aave-supply.spell
  • spells/morpho-blue-lend.spell
  • spells/across-bridge.spell
  • spells/hyperliquid-perps.spell

Compiler Pipeline

Source (.spell) -> Tokenizer -> Parser -> AST -> Transformer -> SpellSource -> IR Generator -> SpellIR

Key files:

  • packages/core/src/compiler/grimoire/tokenizer.ts - Brace-delimited lexer, emits LBRACE/RBRACE tokens
  • packages/core/src/compiler/grimoire/parser.ts - Recursive descent parser
  • packages/core/src/compiler/grimoire/ast.ts - AST node type definitions
  • packages/core/src/compiler/grimoire/transformer.ts - AST -> SpellSource conversion
  • packages/core/src/compiler/ir-generator.ts - SpellSource -> SpellIR (executable format)

Venues and Adapters

Core is SDK-free. Protocol integrations live in @grimoirelabs/venues and are injected at execution time.

Supported adapters:

  • aave_v3 (AaveKit) - lending/borrowing on Ethereum + Base
  • uniswap_v3 - swaps via SwapRouter02
  • uniswap_v4 - swaps via Universal Router + Permit2
  • morpho_blue - isolated lending markets
  • hyperliquid (offchain) - spot + perps via API
  • across (bridge) - cross-chain bridging
  • yellow (offchain) - NitroRPC app-session lifecycle
  • lifi (offchain) - route-based swap and bridging

Adapters can return multi-transaction plans to handle approvals. Offchain venues implement executeAction.

Aave V3 Amount Format

The @aave/client SDK uses human-readable BigDecimal amounts, not raw token units:

  • supply/borrow: value: "0.1" (human-readable)
  • withdraw/repay: value: { exact: "100000" } (raw units in exact wrapper)

The adapter handles this conversion internally using token decimals (USDC=6, WETH=18, etc.).

Morpho Blue Default Markets

The default adapter ships with well-known Base (chain 8453) markets:

  • cbBTC/USDC (86% LLTV)
  • WETH/USDC (86% LLTV)

When no collateral is specified in a spell, the first matching market by loan token is selected. To target a specific market, specify the collateral token in the action.

Across Bridge Minimums

Across enforces minimum bridge amounts per token. For test spells:

  • USDC: minimum about 1.00 (1000000 raw)
  • WETH: minimum about 0.002 ETH

State Persistence

Spell state survives across runs via a SQLite-backed store. The execute() function stays pure; persistence is handled by the CLI or caller.

Architecture

CLI:  state = await store.load(spellId)
CLI:  result = await execute({ ..., persistentState: state })
CLI:  await store.save(spellId, result.finalState)
CLI:  await store.addRun(spellId, createRunRecord(result))
CLI:  await store.saveLedger(spellId, result.runId, result.ledgerEvents)

Key files

  • packages/core/src/runtime/state-store.ts - StateStore interface, RunRecord type, createRunRecord() helper
  • packages/core/src/runtime/sqlite-state-store.ts - SqliteStateStore class using bun:sqlite with a Node fallback
  • packages/cli/src/commands/state-helpers.ts - withStatePersistence() wrapper for CLI commands

StateStore interface

interface StateStore {
  load(spellId: string): Promise<Record<string, unknown> | null>;
  save(spellId: string, state: Record<string, unknown>): Promise<void>;
  addRun(spellId: string, run: RunRecord): Promise<void>;
  getRuns(spellId: string, limit?: number): Promise<RunRecord[]>;
  saveLedger(spellId: string, runId: string, entries: LedgerEntry[]): Promise<void>;
  loadLedger(spellId: string, runId: string): Promise<LedgerEntry[] | null>;
  listSpells(): Promise<string[]>;
}

SQLite schema

Database lives at .grimoire/grimoire.db (configurable via --state-dir). Three tables: spell_state (persistent state), runs (execution history), ledger (event logs per run). Old runs are pruned beyond maxRuns (default 100).

CLI flags

  • --state-dir <dir> - custom directory for grimoire.db (on simulate, cast, history, log)
  • --no-state - disable persistence entirely (on simulate, cast)

Action Constraints (Slippage)

Action constraints are resolved at runtime and attached to Action.constraints for adapter use.

  • max_slippage, min_output, max_input, deadline
  • max_price_impact, min_liquidity, require_quote, require_simulation, max_gas

For swaps, always set both max_slippage and min_output to prevent unexpected losses.

Bridge Actions

bridge actions compile from venue.bridge(asset, amount, to_chain) and are supported by the Across adapter. The IR requires a literal chain ID for to_chain.

CLI

  • grimoire compile <spell> - compile a .spell file to IR
  • grimoire compile-all [dir] - compile all .spell files in a directory
  • grimoire validate <spell> - validate a .spell file
  • grimoire simulate <spell> - simulate execution (dry run), with state persistence
  • grimoire cast <spell> - execute a spell onchain, with state persistence
  • grimoire history [spell] - view execution history (all spells or runs for one spell)
  • grimoire log <spell> <runId> - view ledger events for a specific run
  • grimoire venues - list available venue adapters
  • grimoire init - initialize a new .grimoire directory
  • Per-venue CLIs in @grimoirelabs/venues:
    • grimoire-aave
    • grimoire-uniswap
    • grimoire-morpho-blue
    • grimoire-hyperliquid

Documentation and Skills Maintenance

Docs live in docs/ and follow the Diataxis structure.

Skills live in skills/ and provide LLM-consumable context:

  • skills/grimoire/ - Core CLI commands
  • skills/grimoire-vm/ - In-agent VM spec and conformance references
  • skills/grimoire-aave/ - Aave V3 venue CLI + amount format
  • skills/grimoire-uniswap/ - Uniswap V3/V4 venue CLI
  • skills/grimoire-morpho-blue/ - Morpho Blue venue CLI + default markets
  • skills/grimoire-hyperliquid/ - Hyperliquid venue CLI
  • skills/grimoire-yellow/ - Yellow session lifecycle adapter usage
  • skills/grimoire-lifi/ - LI.FI swap/bridge/compose adapter usage

Keep docs and skills in sync with code changes:

Change Update
New/changed CLI command or flag docs/reference/cli.md, skills/grimoire/SKILL.md, this file
New/changed DSL syntax or feature docs/reference/grimoire-dsl-spec.md
New/changed venue adapter docs/reference/venues.md, matching skills/grimoire-<venue>/SKILL.md
New venue adapter added Create skills/grimoire-<venue>/SKILL.md, update docs/reference/venues.md
Test runner changes docs/how-to/run-tests.md
Wallet/keystore changes docs/reference/cli.md (wallet section)
Bridge/amount thresholds docs/how-to/bridge-with-across.md, docs/reference/venues.md
State persistence changes This file (State Persistence section)
New example spells README.md (Examples section), docs/reference/grimoire-dsl-spec.md

Runtime Notes (Bun-first, Node-supported)

  • Use Bun for local development and tests.
  • Packages in packages/core, packages/venues, and packages/cli must remain Node-compatible.
  • Prefer standard fs APIs over Bun.file in shared packages.
  • If using bun:sqlite, provide a Node fallback (better-sqlite3).

Testing Conventions

import { describe, test, expect } from "bun:test";

describe("Feature", () => {
  test("does something", () => {
    expect(result).toBe(expected);
  });
});

Test files are named *.test.ts and colocated with source files.

Onchain Test Suite

# Simulate only (no wallet needed):
./scripts/run-onchain-tests.sh

# Dry-run (builds txs, estimates gas, does NOT send):
./scripts/run-onchain-tests.sh --dry-run

# Live execution on Base:
CHAIN=8453 ./scripts/run-onchain-tests.sh --execute

# Resume from a specific phase after failure:
./scripts/run-onchain-tests.sh --execute --start-phase 4

# Recovery mode - return stranded funds to Base:
./scripts/run-onchain-tests.sh --recover

Phases: 0 (compile) -> 1 (simulate pure) -> 2 (simulate features) -> 3 (cast features) -> 4 (cast venues) -> 5 (multi-chain). Phase 5 writes checkpoints to .grimoire/test-suite/checkpoint and skips completed sub-steps on resume. Phase 5 is skipped if earlier phases have failures.

Expression Parser

The expression parser (packages/core/src/compiler/expression-parser.ts) handles:

  • Arithmetic: +, -, *, /, %
  • Comparison: ==, !=, <, >, <=, >=
  • Logical: and, or, not (case-insensitive)
  • Ternary: condition ? then : else
  • Function calls: max(a, b), min(x, y), abs(n), sum(arr), avg(arr)
  • Property access: obj.prop, arr[0]

Usage Example

import { compile, execute, SqliteStateStore, createRunRecord } from "@grimoirelabs/core";
import { adapters } from "@grimoirelabs/venues";

// Compile a spell
const result = compile(spellSource);
if (!result.success) {
  console.error(result.errors);
  process.exit(1);
}

// Load persisted state
const store = new SqliteStateStore();
const persistentState = (await store.load(result.ir.id)) ?? {};

// Execute the spell
const execResult = await execute({
  spell: result.ir,
  vault: "0x...",
  chain: 1,
  params: { amount: 1000 },
  persistentState,
  adapters,
});

// Persist results
await store.save(result.ir.id, execResult.finalState);
await store.addRun(result.ir.id, createRunRecord(execResult));
await store.saveLedger(result.ir.id, execResult.runId, execResult.ledgerEvents);
store.close();