Skip to content
This repository was archived by the owner on Jun 16, 2026. It is now read-only.

Commit 8b9e1d0

Browse files
mcp-tool-shopclaude
andcommitted
feat: v1.1.0 — merge semantics, match enrichment, usage schema, SPEC
Add mergeIndexes() for hierarchical loadouts with provenance tracking. Enrich MatchResult with reason and mode fields. Add LoadMode, UsageEvent, MergeConflict, MergedIndex types. Add lazyLoad flag to LoadoutIndex. Write full SPEC.md. 40 tests (8 new for merge). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3537b7c commit 8b9e1d0

9 files changed

Lines changed: 531 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## 1.1.0 — 2026-03-06
4+
5+
- **MatchResult enrichment**: `reason` (human-readable explanation) and `mode` (eager/lazy/manual)
6+
- **Merge semantics**: `mergeIndexes()` for hierarchical loadouts (global → org → project → task)
7+
- **MergedIndex** with provenance tracking and conflict reporting
8+
- **UsageEvent** schema for local-only observability
9+
- **lazyLoad** flag on `LoadoutIndex` for demand-paged context
10+
- **LoadMode** type (`eager | lazy | manual`)
11+
- **SPEC.md**: Full specification document
12+
- 8 new tests (merge), 40 total
13+
314
## 1.0.3 — 2026-03-06
415

516
- Brand logo URL (mcp-tool-shop-org/brand)

SPEC.md

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mcptoolshop/ai-loadout",
3-
"version": "1.0.3",
3+
"version": "1.1.0",
44
"description": "Context-aware knowledge router for AI agents. Dispatch table, frontmatter spec, keyword matcher, token estimator.",
55
"type": "module",
66
"main": "dist/index.js",
@@ -22,6 +22,7 @@
2222
"dist",
2323
"README.md",
2424
"CHANGELOG.md",
25+
"SPEC.md",
2526
"LICENSE",
2627
"SECURITY.md",
2728
"logo.png"

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
export type {
33
Priority,
44
Triggers,
5+
LoadMode,
56
LoadoutEntry,
67
Budget,
78
LoadoutIndex,
89
Frontmatter,
910
MatchResult,
11+
UsageEvent,
12+
MergeConflict,
13+
MergedIndex,
1014
IssueSeverity,
1115
ValidationIssue,
1216
} from "./types.js";
@@ -24,3 +28,6 @@ export { matchLoadout, lookupEntry } from "./match.js";
2428

2529
// ── Validator ──────────────────────────────────────────────────
2630
export { validateIndex } from "./validate.js";
31+
32+
// ── Merge ─────────────────────────────────────────────────────
33+
export { mergeIndexes } from "./merge.js";

src/match.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* - Manual entries are never auto-included (require explicit lookup)
1313
*/
1414

15-
import type { LoadoutIndex, LoadoutEntry, MatchResult } from "./types.js";
15+
import type { LoadoutIndex, LoadoutEntry, MatchResult, LoadMode } from "./types.js";
1616

1717
const MIN_SCORE = 0.1; // minimum score to include a domain entry
1818

@@ -95,7 +95,21 @@ export function matchLoadout(
9595
);
9696

9797
if (score >= MIN_SCORE) {
98-
results.push({ entry, score, matchedKeywords, matchedPatterns });
98+
const mode: LoadMode = entry.priority === "manual"
99+
? "manual"
100+
: entry.priority === "core"
101+
? "eager"
102+
: "lazy";
103+
104+
const reason = entry.priority === "core"
105+
? "core: always loaded"
106+
: matchedKeywords.length > 0 && matchedPatterns.length > 0
107+
? `keywords [${matchedKeywords.join(", ")}] + patterns [${matchedPatterns.join(", ")}]`
108+
: matchedKeywords.length > 0
109+
? `keywords [${matchedKeywords.join(", ")}]`
110+
: `patterns [${matchedPatterns.join(", ")}]`;
111+
112+
results.push({ entry, score, matchedKeywords, matchedPatterns, reason, mode });
99113
}
100114
}
101115

src/merge.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Index merge for hierarchical loadouts.
3+
*
4+
* Deterministic merge: later layers override earlier ones.
5+
* Same-ID entries from a later layer replace the earlier version.
6+
* Conflicts (same keyword, different payload, same priority) are reported.
7+
*/
8+
9+
import type { LoadoutIndex, LoadoutEntry, MergedIndex, MergeConflict, Budget } from "./types.js";
10+
import { estimateTokens } from "./tokens.js";
11+
12+
interface LayeredIndex {
13+
name: string; // e.g. "global", "org", "project", "task"
14+
index: LoadoutIndex;
15+
}
16+
17+
/**
18+
* Merge multiple loadout indexes in order (earlier → later, later wins).
19+
*
20+
* Returns a MergedIndex with provenance tracking and conflict reports.
21+
*/
22+
export function mergeIndexes(layers: LayeredIndex[]): MergedIndex {
23+
const entryMap = new Map<string, { entry: LoadoutEntry; layer: string }>();
24+
const provenance: Record<string, string> = {};
25+
const conflicts: MergeConflict[] = [];
26+
27+
// Track which layers define each ID for conflict detection
28+
const idLayers = new Map<string, string[]>();
29+
30+
for (const { name, index } of layers) {
31+
for (const entry of index.entries) {
32+
// Track all layers that define this ID
33+
const existing = idLayers.get(entry.id) ?? [];
34+
existing.push(name);
35+
idLayers.set(entry.id, existing);
36+
37+
// Later layer overrides earlier
38+
entryMap.set(entry.id, { entry, layer: name });
39+
provenance[entry.id] = name;
40+
}
41+
}
42+
43+
// Report entries defined in multiple layers
44+
for (const [id, layerNames] of idLayers) {
45+
if (layerNames.length > 1) {
46+
conflicts.push({
47+
entryId: id,
48+
layers: layerNames,
49+
resolution: "override",
50+
});
51+
}
52+
}
53+
54+
const entries = [...entryMap.values()].map((v) => v.entry);
55+
56+
// Recalculate budget from merged entries
57+
const coreTokens = entries
58+
.filter((e) => e.priority === "core")
59+
.reduce((sum, e) => sum + e.tokens_est, 0);
60+
const onDemandTokens = entries
61+
.filter((e) => e.priority !== "core")
62+
.reduce((sum, e) => sum + e.tokens_est, 0);
63+
const domainEntries = entries.filter((e) => e.priority === "domain");
64+
const avgTaskLoad = domainEntries.length > 0
65+
? Math.round(onDemandTokens / domainEntries.length)
66+
: 0;
67+
68+
const budget: Budget = {
69+
always_loaded_est: coreTokens,
70+
on_demand_total_est: onDemandTokens,
71+
avg_task_load_est: avgTaskLoad,
72+
avg_task_load_observed: null,
73+
};
74+
75+
return {
76+
version: "1.0.0",
77+
generated: new Date().toISOString(),
78+
entries,
79+
budget,
80+
provenance,
81+
conflicts,
82+
};
83+
}

0 commit comments

Comments
 (0)