|
| 1 | +# ai-loadout Specification |
| 2 | + |
| 3 | +> Context-aware knowledge router for AI agents. |
| 4 | +> Version: 1.1.0 |
| 5 | +
|
| 6 | +## Overview |
| 7 | + |
| 8 | +ai-loadout is a zero-dependency kernel for routing context-budgeted knowledge to AI agents. It provides the data model, matching logic, validation, and merge semantics that consumers (like `claude-rules` or `claude-memories`) build on top of. |
| 9 | + |
| 10 | +## Data Model |
| 11 | + |
| 12 | +### LoadoutEntry |
| 13 | + |
| 14 | +A single entry in the dispatch table. |
| 15 | + |
| 16 | +| Field | Type | Constraints | |
| 17 | +|-------|------|-------------| |
| 18 | +| `id` | `string` | Kebab-case, unique, stable once created | |
| 19 | +| `path` | `string` | Relative to repo root, non-empty | |
| 20 | +| `keywords` | `string[]` | Lowercase surface words for matching. Required non-empty for `domain` entries | |
| 21 | +| `patterns` | `string[]` | Named intents (e.g. `ci_pipeline`), not regex | |
| 22 | +| `priority` | `Priority` | `"core"` \| `"domain"` \| `"manual"` | |
| 23 | +| `summary` | `string` | Non-empty, max 120 chars, dense routing signal | |
| 24 | +| `triggers` | `Triggers` | Which agent phases activate this entry | |
| 25 | +| `tokens_est` | `number` | Estimated tokens (chars / 4), non-negative | |
| 26 | +| `lines` | `number` | Line count of the payload file | |
| 27 | + |
| 28 | +### Priority Tiers |
| 29 | + |
| 30 | +| Tier | Behavior | |
| 31 | +|------|----------| |
| 32 | +| `core` | Always loaded. Score 1.0, mode `eager`. Cannot be skipped. | |
| 33 | +| `domain` | Keyword-triggered. Score 0.1–1.0, mode `lazy`. Loaded when task matches. | |
| 34 | +| `manual` | Never auto-loaded. Score 0, mode `manual`. Requires explicit `lookupEntry()`. | |
| 35 | + |
| 36 | +### Triggers |
| 37 | + |
| 38 | +Controls WHEN a payload should be loaded relative to the agent loop. |
| 39 | + |
| 40 | +| Field | Type | Default | Purpose | |
| 41 | +|-------|------|---------|---------| |
| 42 | +| `task` | `boolean` | `true` | Load during task interpretation | |
| 43 | +| `plan` | `boolean` | `true` | Load during plan formation | |
| 44 | +| `edit` | `boolean` | `false` | Load before file edits | |
| 45 | + |
| 46 | +### LoadoutIndex |
| 47 | + |
| 48 | +The dispatch table (`index.json`). |
| 49 | + |
| 50 | +| Field | Type | Required | |
| 51 | +|-------|------|----------| |
| 52 | +| `version` | `string` | Yes | |
| 53 | +| `generated` | `string` | Yes (ISO 8601) | |
| 54 | +| `entries` | `LoadoutEntry[]` | Yes | |
| 55 | +| `budget` | `Budget` | Yes | |
| 56 | +| `lazyLoad` | `boolean` | No. When true, payloads are not pre-loaded into context | |
| 57 | + |
| 58 | +### Budget |
| 59 | + |
| 60 | +| Field | Type | Description | |
| 61 | +|-------|------|-------------| |
| 62 | +| `always_loaded_est` | `number` | Sum of `tokens_est` for all `core` entries | |
| 63 | +| `on_demand_total_est` | `number` | Sum of `tokens_est` for all non-core entries | |
| 64 | +| `avg_task_load_est` | `number` | Estimated average tokens loaded per session | |
| 65 | +| `avg_task_load_observed` | `number \| null` | From usage telemetry (future) | |
| 66 | + |
| 67 | +### LoadMode |
| 68 | + |
| 69 | +Controls HOW a payload is loaded into agent context. |
| 70 | + |
| 71 | +| Mode | When | |
| 72 | +|------|------| |
| 73 | +| `eager` | Loaded immediately (core entries) | |
| 74 | +| `lazy` | Loaded on keyword match (domain entries) | |
| 75 | +| `manual` | Only via explicit lookup | |
| 76 | + |
| 77 | +## Matching |
| 78 | + |
| 79 | +`matchLoadout(task, index)` → `MatchResult[]` |
| 80 | + |
| 81 | +### Algorithm |
| 82 | + |
| 83 | +1. Tokenize task description: lowercase, strip non-alphanumeric, split on whitespace, discard words ≤ 1 char |
| 84 | +2. For each entry: |
| 85 | + - **Core**: score = 1.0, always included |
| 86 | + - **Manual**: score = 0, never included |
| 87 | + - **Domain**: score = (matched keywords / total keywords) + pattern bonus (0.2 if any pattern word matches) |
| 88 | +3. Include entries with score ≥ 0.1 |
| 89 | +4. Sort by score descending, then by `tokens_est` ascending (cheaper first for ties) |
| 90 | + |
| 91 | +### MatchResult |
| 92 | + |
| 93 | +| Field | Type | Description | |
| 94 | +|-------|------|-------------| |
| 95 | +| `entry` | `LoadoutEntry` | The matched entry | |
| 96 | +| `score` | `number` | 0–1, higher = stronger match | |
| 97 | +| `matchedKeywords` | `string[]` | Which keywords matched | |
| 98 | +| `matchedPatterns` | `string[]` | Which patterns matched | |
| 99 | +| `reason` | `string` | Human-readable explanation | |
| 100 | +| `mode` | `LoadMode` | How this entry should be loaded | |
| 101 | + |
| 102 | +### Keyword Matching |
| 103 | + |
| 104 | +- Keywords are split on whitespace/hyphens |
| 105 | +- All words in a multi-word keyword must be present in the task tokens |
| 106 | +- Score contribution: `matchedKeywords.length / totalKeywords.length` |
| 107 | + |
| 108 | +### Pattern Matching |
| 109 | + |
| 110 | +- Patterns are split on underscores |
| 111 | +- Any word match triggers the pattern bonus (+0.2) |
| 112 | +- Patterns are named intents, not regex |
| 113 | + |
| 114 | +## Validation |
| 115 | + |
| 116 | +`validateIndex(index)` → `ValidationIssue[]` |
| 117 | + |
| 118 | +Validates structural integrity only. Does NOT check filesystem (that's the consumer's job). |
| 119 | + |
| 120 | +### Issue Codes |
| 121 | + |
| 122 | +| Code | Severity | Condition | |
| 123 | +|------|----------|-----------| |
| 124 | +| `MISSING_VERSION` | error | Empty version field | |
| 125 | +| `MISSING_GENERATED` | warning | Empty generated timestamp | |
| 126 | +| `INVALID_ENTRIES` | error | Entries is not an array | |
| 127 | +| `MISSING_ID` | error | Entry has no id | |
| 128 | +| `BAD_ID_FORMAT` | warning | ID is not kebab-case | |
| 129 | +| `DUPLICATE_ID` | error | Same ID appears twice | |
| 130 | +| `MISSING_PATH` | error | Entry has no path | |
| 131 | +| `INVALID_PRIORITY` | error | Priority not in `core\|domain\|manual` | |
| 132 | +| `MISSING_SUMMARY` | error | Empty summary | |
| 133 | +| `LONG_SUMMARY` | warning | Summary exceeds 120 chars | |
| 134 | +| `EMPTY_KEYWORDS` | error | Domain entry has no keywords | |
| 135 | +| `BAD_TOKEN_EST` | warning | Negative or non-number token estimate | |
| 136 | +| `NEGATIVE_BUDGET` | warning | Budget field is negative | |
| 137 | + |
| 138 | +## Frontmatter |
| 139 | + |
| 140 | +Payload files carry YAML-like frontmatter: |
| 141 | + |
| 142 | +``` |
| 143 | +--- |
| 144 | +id: my-rule |
| 145 | +keywords: [testing, unit, integration] |
| 146 | +patterns: [test_strategy] |
| 147 | +priority: domain |
| 148 | +triggers: |
| 149 | + task: true |
| 150 | + plan: true |
| 151 | + edit: false |
| 152 | +--- |
| 153 | +``` |
| 154 | + |
| 155 | +- `parseFrontmatter(content)` → `{ frontmatter, body }` |
| 156 | +- `serializeFrontmatter(fm)` → frontmatter string |
| 157 | +- Round-trips are deterministic |
| 158 | +- Missing triggers default to `{ task: true, plan: true, edit: false }` |
| 159 | +- Invalid priority defaults to `domain` |
| 160 | + |
| 161 | +## Merge |
| 162 | + |
| 163 | +`mergeIndexes(layers)` → `MergedIndex` |
| 164 | + |
| 165 | +For hierarchical loadouts: multiple indexes merged deterministically. |
| 166 | + |
| 167 | +### Semantics |
| 168 | + |
| 169 | +- Layers are ordered earlier → later (e.g. global, org, project, task) |
| 170 | +- Later layers override earlier for the same entry ID |
| 171 | +- All overrides are tracked as conflicts with `resolution: "override"` |
| 172 | +- Budget is recalculated from the merged entry set |
| 173 | + |
| 174 | +### MergedIndex |
| 175 | + |
| 176 | +Extends `LoadoutIndex` with: |
| 177 | + |
| 178 | +| Field | Type | Description | |
| 179 | +|-------|------|-------------| |
| 180 | +| `provenance` | `Record<string, string>` | entryId → source layer name | |
| 181 | +| `conflicts` | `MergeConflict[]` | Entries defined in multiple layers | |
| 182 | + |
| 183 | +### MergeConflict |
| 184 | + |
| 185 | +| Field | Type | Description | |
| 186 | +|-------|------|-------------| |
| 187 | +| `entryId` | `string` | Which entry was in conflict | |
| 188 | +| `layers` | `string[]` | All layers that define this entry | |
| 189 | +| `resolution` | `"override" \| "error"` | How it was resolved | |
| 190 | + |
| 191 | +## Token Estimation |
| 192 | + |
| 193 | +`estimateTokens(text)` → `number` |
| 194 | + |
| 195 | +Heuristic: `Math.ceil(text.length / 4)`. Good enough for budget dashboards, not meant for billing. |
| 196 | + |
| 197 | +## Usage Event Schema |
| 198 | + |
| 199 | +For observability (append-only log, local-only, never networked): |
| 200 | + |
| 201 | +| Field | Type | Description | |
| 202 | +|-------|------|-------------| |
| 203 | +| `timestamp` | `string` | ISO 8601 | |
| 204 | +| `taskHash` | `string` | Session-local task identifier | |
| 205 | +| `entryId` | `string` | Which payload was loaded | |
| 206 | +| `trigger` | `string` | Which keyword/pattern caused the load | |
| 207 | +| `mode` | `LoadMode` | eager, lazy, or manual | |
| 208 | +| `tokensEst` | `number` | Estimated token cost | |
| 209 | +| `sourceLayer` | `string?` | Which hierarchy layer (future) | |
| 210 | + |
| 211 | +## Design Constraints |
| 212 | + |
| 213 | +- Zero production dependencies |
| 214 | +- Pure TypeScript ESM |
| 215 | +- Node ≥ 20 |
| 216 | +- Deterministic: same inputs → same outputs (except `generated` timestamps) |
| 217 | +- Kernel only: no CLI, no filesystem access, no I/O |
0 commit comments