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

Commit 50d12ab

Browse files
mcp-tool-shopclaude
andcommitted
feat: v1.3.0 — hierarchical resolver with layer discovery and entry explanation
Adds the resolver: discovers loadout indexes from canonical locations (global → org → project → session), merges them deterministically, and explains per-entry decision paths. CLI commands: resolve, explain. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 671def8 commit 50d12ab

7 files changed

Lines changed: 811 additions & 4 deletions

File tree

CHANGELOG.md

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

3+
## 1.3.0 — 2026-03-06
4+
5+
- **Hierarchical resolver**: `discoverLayers()`, `resolveLoadout()` for layered indexes (global → org → project → session)
6+
- **Entry explanation**: `explainEntry()` traces an entry's decision path across layers ("why did this rule win?")
7+
- **CLI**: `ai-loadout resolve` and `ai-loadout explain <id>` commands
8+
- **CLI options**: `--project`, `--global`, `--org`, `--session` for resolver configuration
9+
- **Environment variables**: `$AI_LOADOUT_ORG`, `$AI_LOADOUT_SESSION` for layer discovery
10+
- SPEC.md updated with resolver semantics and CLI reference
11+
- 17 new tests (resolver), 80 total
12+
313
## 1.2.0 — 2026-03-06
414

515
- **Usage tracking**: `recordUsage()`, `readUsage()`, `summarizeUsage()` for append-only JSONL logs

SPEC.md

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# ai-loadout Specification
22

33
> Context-aware knowledge router for AI agents.
4-
> Version: 1.1.0
4+
> Version: 1.3.0
55
66
## Overview
77

@@ -208,10 +208,88 @@ For observability (append-only log, local-only, never networked):
208208
| `tokensEst` | `number` | Estimated token cost |
209209
| `sourceLayer` | `string?` | Which hierarchy layer (future) |
210210

211+
## Resolver
212+
213+
`discoverLayers(opts?)``{ layers, searched }`
214+
`resolveLoadout(opts?)``ResolvedLoadout`
215+
`explainEntry(entryId, layers)``EntryExplanation | null`
216+
217+
The resolver discovers, loads, and merges layered loadout indexes. It answers: "what is the merged state?" and "why did this entry win?"
218+
219+
### Canonical Layer Stack
220+
221+
Layers are checked in a fixed order. Later layers override earlier ones for the same entry ID.
222+
223+
| Layer | Location | Override |
224+
|-------|----------|----------|
225+
| `global` | `~/.ai-loadout/index.json` | Lowest priority |
226+
| `org` | Explicit path or `$AI_LOADOUT_ORG` | Overrides global |
227+
| `project` | `<cwd>/.claude/loadout/index.json` | Overrides org |
228+
| `session` | Explicit path or `$AI_LOADOUT_SESSION` | Highest priority |
229+
230+
### Discovery Rules
231+
232+
- Missing layers are normal — most setups only have project-level
233+
- Malformed files are treated as missing (skipped silently)
234+
- The resolver never guesses; it looks in fixed places in a fixed order
235+
- Environment variables: `$AI_LOADOUT_ORG`, `$AI_LOADOUT_SESSION`
236+
237+
### ResolvedLoadout
238+
239+
| Field | Type | Description |
240+
|-------|------|-------------|
241+
| `merged` | `MergedIndex` | The fully merged index with provenance |
242+
| `layers` | `DiscoveredLayer[]` | Layers that were found and loaded |
243+
| `searched` | `SearchedLayer[]` | All locations checked (found or not) |
244+
245+
### EntryExplanation
246+
247+
| Field | Type | Description |
248+
|-------|------|-------------|
249+
| `id` | `string` | Entry ID |
250+
| `finalLayer` | `string` | Which layer the winning version came from |
251+
| `definitions` | `EntryDefinition[]` | Every layer that defined this entry (in order) |
252+
| `overrideChain` | `string[]` | Layer names in override order |
253+
| `isConflict` | `boolean` | True if defined in multiple layers |
254+
255+
### EntryDefinition
256+
257+
| Field | Type | Description |
258+
|-------|------|-------------|
259+
| `layer` | `string` | Layer name |
260+
| `summary` | `string` | Entry summary in this layer |
261+
| `priority` | `Priority` | Entry priority in this layer |
262+
| `tokens` | `number` | Token estimate in this layer |
263+
| `keywords` | `string[]` | Keywords in this layer |
264+
| `path` | `string` | Payload path in this layer |
265+
266+
## CLI
267+
268+
`ai-loadout` provides diagnostic commands. All support `--json` for scripting.
269+
270+
| Command | Description |
271+
|---------|-------------|
272+
| `resolve` | Show merged index from all layers with provenance |
273+
| `explain <id>` | Show decision path for one entry across layers |
274+
| `usage <jsonl>` | Usage summary from event log |
275+
| `dead <index> <jsonl>` | Find entries never loaded |
276+
| `overlaps <index>` | Find keyword routing ambiguities |
277+
| `budget <index> [jsonl]` | Token budget breakdown |
278+
279+
### Resolver CLI Options
280+
281+
| Flag | Description |
282+
|------|-------------|
283+
| `--project <path>` | Project root (default: cwd) |
284+
| `--global <path>` | Global config dir (default: `~/.ai-loadout`) |
285+
| `--org <path>` | Org-level index path |
286+
| `--session <path>` | Session overlay index path |
287+
211288
## Design Constraints
212289

213290
- Zero production dependencies
214291
- Pure TypeScript ESM
215292
- Node ≥ 20
216293
- Deterministic: same inputs → same outputs (except `generated` timestamps)
217-
- Kernel only: no CLI, no filesystem access, no I/O
294+
- Core types, matcher, validator, and merge are pure functions (no I/O)
295+
- Resolver and usage modules perform filesystem I/O for practical use

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@mcptoolshop/ai-loadout",
3-
"version": "1.2.0",
4-
"description": "Context-aware knowledge router for AI agents. Dispatch table, matcher, usage tracking, dead/overlap analysis, budget CLI.",
3+
"version": "1.3.0",
4+
"description": "Context-aware knowledge router for AI agents. Dispatch table, matcher, hierarchical resolver, usage tracking, analysis CLI.",
55
"type": "module",
66
"bin": {
77
"ai-loadout": "./dist/cli.js"

src/cli.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { fileURLToPath } from "node:url";
1616
import type { LoadoutIndex } from "./types.js";
1717
import { readUsage, summarizeUsage } from "./usage.js";
1818
import { findDeadEntries, findKeywordOverlaps, analyzeBudget } from "./analysis.js";
19+
import { resolveLoadout, explainEntry } from "./resolve.js";
20+
import type { ResolveOptions } from "./resolve.js";
1921

2022
// ── Colors ────────────────────────────────────────────────────
2123
const BOLD = "\x1b[1m";
@@ -46,6 +48,27 @@ function positionalArgs(args: string[]): string[] {
4648
return args.filter((a) => !a.startsWith("--"));
4749
}
4850

51+
function getFlagValue(args: string[], flag: string): string | undefined {
52+
const prefix = `--${flag}=`;
53+
for (const a of args) {
54+
if (a.startsWith(prefix)) return a.slice(prefix.length);
55+
}
56+
const idx = args.indexOf(`--${flag}`);
57+
if (idx !== -1 && idx + 1 < args.length && !args[idx + 1].startsWith("--")) {
58+
return args[idx + 1];
59+
}
60+
return undefined;
61+
}
62+
63+
function getResolveOpts(args: string[]): ResolveOptions {
64+
return {
65+
projectRoot: getFlagValue(args, "project"),
66+
globalDir: getFlagValue(args, "global"),
67+
orgPath: getFlagValue(args, "org"),
68+
sessionPath: getFlagValue(args, "session"),
69+
};
70+
}
71+
4972
function loadIndex(path: string): LoadoutIndex {
5073
if (!existsSync(path)) {
5174
fail("FILE_NOT_FOUND", `Index not found: ${path}`);
@@ -69,6 +92,8 @@ function printHelp() {
6992
${BOLD}ai-loadout${RESET} v${getVersion()} — Knowledge router for AI agents
7093
7194
${BOLD}Usage:${RESET}
95+
ai-loadout resolve Resolve layered loadouts (global → org → project → session)
96+
ai-loadout explain <entry-id> Explain why an entry resolved to its current state
7297
ai-loadout usage <jsonl> Usage summary from event log
7398
ai-loadout dead <index> <jsonl> Find entries never loaded
7499
ai-loadout overlaps <index> Find keyword routing ambiguities
@@ -78,8 +103,15 @@ ${BOLD}Options:${RESET}
78103
--json Output as JSON
79104
--help Show this help
80105
--version Show version
106+
--project Project root for resolve/explain (default: cwd)
107+
--global Global config dir for resolve/explain (default: ~/.ai-loadout)
108+
--org Org-level index path for resolve/explain (or $AI_LOADOUT_ORG)
109+
--session Session overlay index path for resolve/explain (or $AI_LOADOUT_SESSION)
81110
82111
${BOLD}Examples:${RESET}
112+
ai-loadout resolve
113+
ai-loadout resolve --json
114+
ai-loadout explain github-actions
83115
ai-loadout usage .claude/loadout-usage.jsonl
84116
ai-loadout dead .claude/rules/index.json .claude/loadout-usage.jsonl
85117
ai-loadout overlaps .claude/rules/index.json
@@ -234,6 +266,116 @@ function cmdBudget(args: string[]) {
234266
log("");
235267
}
236268

269+
function cmdResolve(args: string[]) {
270+
const opts = getResolveOpts(args);
271+
const json = hasFlag(args, "json");
272+
const result = resolveLoadout(opts);
273+
274+
if (json) {
275+
log(JSON.stringify({
276+
layers: result.searched,
277+
entries: result.merged.entries.map((e) => ({
278+
id: e.id,
279+
priority: e.priority,
280+
tokens: e.tokens_est,
281+
source: result.merged.provenance[e.id],
282+
})),
283+
conflicts: result.merged.conflicts,
284+
budget: result.merged.budget,
285+
}, null, 2));
286+
return;
287+
}
288+
289+
// Show searched layers
290+
log(`\n${BOLD}Layer Discovery${RESET}\n`);
291+
for (const s of result.searched) {
292+
if (s.found) {
293+
ok(`${s.name.padEnd(10)} ${DIM}${s.path}${RESET}`);
294+
} else {
295+
log(` ${DIM}${RESET} ${s.name.padEnd(10)} ${DIM}${s.path} (not found)${RESET}`);
296+
}
297+
}
298+
299+
if (result.layers.length === 0) {
300+
log(`\n ${YELLOW}No loadout indexes found.${RESET}`);
301+
log(` ${DIM}Create .claude/loadout/index.json or ~/.ai-loadout/index.json${RESET}\n`);
302+
return;
303+
}
304+
305+
// Show merged entries with provenance
306+
log(`\n${BOLD}Resolved Entries${RESET} (${result.merged.entries.length} entries from ${result.layers.length} layer${result.layers.length === 1 ? "" : "s"})\n`);
307+
log(` ${"Entry".padEnd(30)} ${"Priority".padEnd(10)} ${"Tokens".padStart(8)} Source`);
308+
log(` ${"─".repeat(30)} ${"─".repeat(10)} ${"─".repeat(8)} ${"─".repeat(10)}`);
309+
310+
for (const entry of result.merged.entries) {
311+
const source = result.merged.provenance[entry.id] ?? "?";
312+
const priorityColor = entry.priority === "core" ? RED : entry.priority === "domain" ? CYAN : DIM;
313+
log(` ${entry.id.padEnd(30)} ${priorityColor}${entry.priority.padEnd(10)}${RESET} ${String(entry.tokens_est).padStart(8)} ${source}`);
314+
}
315+
316+
// Show conflicts
317+
if (result.merged.conflicts.length > 0) {
318+
log(`\n${BOLD}Overrides${RESET} (${result.merged.conflicts.length} entries defined in multiple layers)\n`);
319+
for (const c of result.merged.conflicts) {
320+
log(` ${YELLOW}${c.entryId}${RESET}${c.layers.join(" → ")} ${DIM}(${c.resolution})${RESET}`);
321+
}
322+
}
323+
324+
// Budget summary
325+
log(`\n Total: ${result.merged.entries.length} entries, ${result.merged.budget.always_loaded_est + result.merged.budget.on_demand_total_est} tokens`);
326+
log(` Core: ${result.merged.budget.always_loaded_est} tokens (always loaded), On-demand: ${result.merged.budget.on_demand_total_est} tokens\n`);
327+
}
328+
329+
function cmdExplain(args: string[]) {
330+
const positional = positionalArgs(args);
331+
if (positional.length < 1) {
332+
fail("MISSING_ARG", "Usage: ai-loadout explain <entry-id>", "Run 'ai-loadout resolve' to see available entries");
333+
}
334+
335+
const entryId = positional[0];
336+
const opts = getResolveOpts(args);
337+
const json = hasFlag(args, "json");
338+
339+
const { layers } = resolveLoadout(opts);
340+
const explanation = explainEntry(entryId, layers);
341+
342+
if (!explanation) {
343+
if (json) {
344+
log(JSON.stringify({ error: "NOT_FOUND", entryId }, null, 2));
345+
process.exit(1);
346+
}
347+
fail("NOT_FOUND", `Entry "${entryId}" not found in any layer`, "Run 'ai-loadout resolve' to see available entries");
348+
}
349+
350+
if (json) {
351+
log(JSON.stringify(explanation, null, 2));
352+
return;
353+
}
354+
355+
log(`\n${BOLD}Entry Explanation: ${CYAN}${explanation.id}${RESET}\n`);
356+
357+
if (explanation.isConflict) {
358+
log(` ${YELLOW}Defined in ${explanation.definitions.length} layers${RESET} — final version from ${GREEN}${explanation.finalLayer}${RESET}`);
359+
log(` Override chain: ${explanation.overrideChain.join(" → ")}\n`);
360+
} else {
361+
log(` Defined in: ${GREEN}${explanation.finalLayer}${RESET} (no overrides)\n`);
362+
}
363+
364+
for (const def of explanation.definitions) {
365+
const isWinner = def.layer === explanation.finalLayer;
366+
const marker = isWinner ? `${GREEN}${RESET}` : `${DIM}${RESET}`;
367+
const layerLabel = isWinner ? `${GREEN}${def.layer}${RESET}` : `${DIM}${def.layer}${RESET}`;
368+
369+
log(` ${marker} ${layerLabel}`);
370+
log(` Summary: ${isWinner ? def.summary : `${DIM}${def.summary}${RESET}`}`);
371+
log(` Priority: ${def.priority}`);
372+
log(` Tokens: ${def.tokens}`);
373+
log(` Keywords: ${def.keywords.join(", ") || "(none)"}`);
374+
log(` Path: ${def.path}`);
375+
log("");
376+
}
377+
}
378+
237379
// ── Main ──────────────────────────────────────────────────────
238380
const args = process.argv.slice(2);
239381

@@ -251,6 +393,12 @@ const cmd = args[0];
251393
const cmdArgs = args.slice(1);
252394

253395
switch (cmd) {
396+
case "resolve":
397+
cmdResolve(cmdArgs);
398+
break;
399+
case "explain":
400+
cmdExplain(cmdArgs);
401+
break;
254402
case "usage":
255403
cmdUsage(cmdArgs);
256404
break;

src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,14 @@ export type { UsageSummary } from "./usage.js";
3939
// ── Analysis ──────────────────────────────────────────────────
4040
export { findDeadEntries, findKeywordOverlaps, analyzeBudget } from "./analysis.js";
4141
export type { DeadEntry, KeywordOverlap, BudgetBreakdown } from "./analysis.js";
42+
43+
// ── Resolver ─────────────────────────────────────────────────
44+
export { discoverLayers, resolveLoadout, explainEntry } from "./resolve.js";
45+
export type {
46+
DiscoveredLayer,
47+
SearchedLayer,
48+
ResolvedLoadout,
49+
EntryDefinition,
50+
EntryExplanation,
51+
ResolveOptions,
52+
} from "./resolve.js";

0 commit comments

Comments
 (0)