@@ -16,6 +16,8 @@ import { fileURLToPath } from "node:url";
1616import type { LoadoutIndex } from "./types.js" ;
1717import { readUsage , summarizeUsage } from "./usage.js" ;
1818import { findDeadEntries , findKeywordOverlaps , analyzeBudget } from "./analysis.js" ;
19+ import { resolveLoadout , explainEntry } from "./resolve.js" ;
20+ import type { ResolveOptions } from "./resolve.js" ;
1921
2022// ── Colors ────────────────────────────────────────────────────
2123const 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+
4972function 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 ──────────────────────────────────────────────────────
238380const args = process . argv . slice ( 2 ) ;
239381
@@ -251,6 +393,12 @@ const cmd = args[0];
251393const cmdArgs = args . slice ( 1 ) ;
252394
253395switch ( 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 ;
0 commit comments