llmnav 0.7.4 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ The npm package follows Semantic Versioning. The `llmnav/N` source protocol is v
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] — 2026-08-15
10
+
11
+ ### Added
12
+
13
+ * Added `llmnav explain <file>` and `explainProjectFile` to report one file's navigation cards, module coverage, audit score evidence, reviewed disposition, and bounded next action without modifying source.
14
+
15
+ ## [0.8.0] — 2026-08-15
16
+
17
+ ### Added
18
+
19
+ * Added exact-path audit dispositions with mandatory review reasons, deterministic suppression, and stale-decision reporting so intentional non-cards do not reappear without becoming hidden permanent ignores.
20
+
9
21
  ## [0.7.4] — 2026-08-14
10
22
 
11
23
  ### Fixed
package/README.md CHANGED
@@ -47,7 +47,7 @@ npx llmnav init --agents all --package-scripts
47
47
 
48
48
  Initialization is explicit. LLMNav never edits a consumer repository from an npm `postinstall` script. Use `--agents none` when only the machine-readable control directory is desired.
49
49
 
50
- Before writing cards, run `npx llmnav audit`. It ranks likely architectural boundaries and explains each score. The command never edits source and remains advisory unless `--fail-on high`, `medium`, or `low` is selected. Review the candidates: a high score is evidence to inspect a file, not permission to generate semantic meaning automatically.
50
+ Before writing cards, run `npx llmnav audit`. It ranks likely architectural boundaries and explains each score. Use `npx llmnav explain <file>` when you need to know why one file is carded, covered by another module card, ranked as a candidate, suppressed by a reviewed disposition, or omitted from the candidate list. Neither command edits source. Review the evidence: a high score is a reason to inspect a file, not permission to generate semantic meaning automatically. For a reviewed false positive or implementation detail, add its exact path and a concrete reason to `audit.dispositions`; stale decisions remain visible instead of becoming permanent hidden ignores.
51
51
 
52
52
  ## Add the first card
53
53
 
@@ -172,6 +172,7 @@ Line-comment cards require an explicit terminator and work with `//`, `#`, and `
172
172
  | --- | --- |
173
173
  | `llmnav init` | Create configuration, registry, schemas, agent instructions, and the initial cache |
174
174
  | `llmnav audit` | Rank unannotated architectural boundary candidates, with compact or file-backed output |
175
+ | `llmnav explain` | Explain one file's card coverage, audit score, disposition, and recommended next action |
175
176
  | `llmnav check` | Validate cards, relations, coverage rules, and registry state |
176
177
  | `llmnav format` | Rewrite safe cards into canonical order and spacing |
177
178
  | `llmnav generate` | Incrementally compile and transactionally commit generated artifacts |
@@ -253,7 +254,7 @@ Do not annotate trivial getters, generated files, obvious wrappers, every test f
253
254
 
254
255
  ## Current implementation boundary
255
256
 
256
- Version 0.6 adds a deterministic coverage audit that prioritizes root and nested-package entrypoints, structural boundaries, persistent artifact filename protocols, versioned schema literals, Rust/Tauri runtime signals, TypeScript Tauri invoke adapters, and high fan-in modules. It suppresses declaration files, dependency caches, test support code, simple barrels, and low-fan-in broad utilities. It suggests narrow coverage rules but never writes cards or invents semantic roles. Use `llmnav audit --summary --json` for a compact automation result or `llmnav audit --json --output .llmnav/audit.json` to keep the full candidate report out of captured stdout.
257
+ The deterministic coverage audit prioritizes root and nested-package entrypoints, structural boundaries, persistent artifact filename protocols, versioned schema literals, Rust/Tauri runtime signals, TypeScript Tauri invoke adapters, and high fan-in modules. It suppresses declaration files, dependency caches, test support code, simple barrels, and low-fan-in broad utilities. It suggests narrow coverage rules but never writes cards or invents semantic roles. Exact reviewed dispositions suppress known non-boundaries while stale dispositions expose obsolete decisions. Use `llmnav audit --summary --json` for a compact automation result or `llmnav audit --json --output .llmnav/audit.json` to keep the full candidate report out of captured stdout.
257
258
 
258
259
  LLMNav does not discover sibling repositories automatically and does not ship an MCP server, embedding database, hosted service, SCIP generator, or complete language-aware call graph. External tools may export the documented compact graph-input schema. Generated structure never writes derived edges into source cards.
259
260
 
@@ -294,7 +295,7 @@ The project uses the Node.js standard library and built-in test runner. There is
294
295
 
295
296
  ## Status
296
297
 
297
- LLMNav is an experimental protocol and a usable v0.7 CLI. The source format remains `llmnav/1`; npm package changes and source-grammar changes are versioned independently.
298
+ LLMNav is an experimental protocol and a usable v0.8 CLI. The source format remains `llmnav/1`; npm package changes and source-grammar changes are versioned independently.
298
299
 
299
300
  ## License
300
301
 
@@ -49,7 +49,7 @@ The generated instruction tells an agent to:
49
49
  10. Run format, check, and generation after semantic changes.
50
50
  11. Fall back to broad search when no credible card is returned.
51
51
 
52
- When a host supports structured tool calls, `llmnav tools --json` returns four stable provider-neutral definitions in fixed order: `llmnav_query`, `llmnav_show`, `llmnav_context`, and `llmnav_check`. The schemas reject unknown fields and omit the repository root so the trusted host binds scope outside model-generated input.
52
+ When a host supports structured tool calls, `llmnav tools --json` returns five stable provider-neutral definitions in fixed order: `llmnav_query`, `llmnav_show`, `llmnav_context`, `llmnav_check`, and `llmnav_explain`. The new definition is appended so the existing stable tool prefix keeps its order. The schemas reject unknown fields and omit the repository root so the trusted host binds scope outside model-generated input.
53
53
 
54
54
  The protocol does not order an agent to trust a card over source code. It uses the card to choose what source to inspect.
55
55
 
@@ -99,7 +99,7 @@ Suggested contract:
99
99
  ```json
100
100
  {
101
101
  "schemaVersion": 1,
102
- "operations": ["llmnav_query", "llmnav_show", "llmnav_context", "llmnav_check"]
102
+ "operations": ["llmnav_query", "llmnav_show", "llmnav_context", "llmnav_check", "llmnav_explain"]
103
103
  }
104
104
  ```
105
105
 
package/docs/api.md CHANGED
@@ -65,7 +65,7 @@ Attached symbol declarations expose generated `language`, `exported`, `visibilit
65
65
  ## Audit annotation coverage
66
66
 
67
67
  ```js
68
- import { auditHasFindings, auditProject } from "llmnav";
68
+ import { auditHasFindings, auditProject, explainProjectFile } from "llmnav";
69
69
 
70
70
  const result = await auditProject(process.cwd());
71
71
  for (const candidate of result.candidates) {
@@ -73,9 +73,12 @@ for (const candidate of result.candidates) {
73
73
  }
74
74
 
75
75
  if (auditHasFindings(result, "high")) process.exitCode = 1;
76
+
77
+ const explanation = await explainProjectFile(process.cwd(), "src/runtime.ts");
78
+ console.log(explanation.status, explanation.candidate?.score, explanation.recommendation.action);
76
79
  ```
77
80
 
78
- `auditProject` is read-only and returns schemaVersion 1 data with repository-relative paths and deterministic ordering. Candidate signals are structural heuristics, not generated semantic meaning. Consumers should review high and medium candidates before adding a card and should never turn the suggested coverage rule into automatic source annotation.
81
+ `auditProject` is read-only and returns schemaVersion 1 data with repository-relative paths and deterministic ordering. Candidate signals are structural heuristics, not generated semantic meaning. Exact reviewed decisions from `audit.dispositions` are returned separately with `suppressed` or `stale` status; `auditHasFindings` considers only active candidates. `explainProjectFile` applies the same analysis to one path, preserving candidate score evidence and package-level coverage without mutating source. Consumers should review high and medium candidates before adding a card and should never turn a suggested coverage rule or recommendation into automatic source annotation.
79
82
 
80
83
  ## Generate incrementally and transactionally
81
84
 
package/docs/cli.md CHANGED
@@ -39,7 +39,17 @@ Reads the selected source set and ranks files that lack file or module cards. Si
39
39
 
40
40
  The command never modifies source, configuration, registries, or generated caches. Its output is advisory and exits with status 0 by default. `--fail-on high` fails only for high candidates; `medium` fails for high or medium; `low` fails for any candidate. Invalid thresholds exit with status 2.
41
41
 
42
- JSON output uses schemaVersion 1, repository-relative paths, deterministic ordering, explainable `reasons` and `signals`, and a path-specific `suggestedCoverageRule` for high and medium candidates. `--summary` omits candidate details. `--output <path>` writes the selected report inside the repository and emits only a compact confirmation envelope to stdout; escaping paths and symbolic-link traversal are rejected. Suggestions require human or agent review: LLMNav cannot infer a durable role, ownership boundary, invariant, or semantic ID from structure alone.
42
+ JSON output uses schemaVersion 1, repository-relative paths, deterministic ordering, explainable `reasons` and `signals`, and a path-specific `suggestedCoverageRule` for high and medium candidates. Reviewed `audit.dispositions` appear separately as `suppressed` or `stale`; only active candidates participate in `--fail-on`. Stale reasons are `file-not-scanned`, `already-carded`, or `not-a-candidate`. `--summary` omits candidate and disposition details but retains their counters. `--output <path>` writes the selected report inside the repository and emits only a compact confirmation envelope to stdout; escaping paths and symbolic-link traversal are rejected. Suggestions require human or agent review: LLMNav cannot infer a durable role, ownership boundary, invariant, or semantic ID from structure alone.
43
+
44
+ ## `llmnav explain`
45
+
46
+ ```sh
47
+ llmnav explain <file> [--json]
48
+ ```
49
+
50
+ Explains one repository-relative or repository-contained absolute file path without modifying the repository. The result distinguishes active candidates, reviewed suppressions, direct cards, package-level module coverage, files with no ranked audit signal, files excluded from the scanned source set, and stale dispositions. Candidate results retain the audit's representative path, priority, score, reasons, signals, and suggested coverage rule; this matters for Go packages, where the requested file and representative candidate can differ.
51
+
52
+ The recommendation is deliberately bounded. It may ask for card review, disposition review, stale-disposition cleanup, or no action, but it never invents a semantic ID or writes a source comment. A file outside the repository or an invalid positional argument exits with status 2. A valid path that is not in the scanned source set returns its explanation and exits with status 1.
43
53
 
44
54
  ## `llmnav check`
45
55
 
@@ -14,6 +14,9 @@
14
14
  "excludeDirectories": ["node_modules", "dist", "target"],
15
15
  "excludeFiles": ["*.generated.*", "*.gen.*"],
16
16
  "coverageRules": [],
17
+ "audit": {
18
+ "dispositions": []
19
+ },
17
20
  "graph": {
18
21
  "indexFiles": []
19
22
  },
@@ -90,6 +93,25 @@ A coverage rule checks files already selected by `sourceRoots` and extension fil
90
93
 
91
94
  Run `llmnav audit` to obtain path-specific candidate rules. A suggestion is not applied automatically and should be accepted only after the file's durable responsibility is confirmed. This separation prevents structural heuristics from creating vague or stale semantic cards.
92
95
 
96
+ ## Audit dispositions
97
+
98
+ When a candidate has been reviewed and intentionally does not need a semantic card, record that decision instead of repeatedly ignoring it:
99
+
100
+ ```json
101
+ {
102
+ "audit": {
103
+ "dispositions": [
104
+ {
105
+ "path": "src/adapters/local-cache.ts",
106
+ "reason": "This private adapter mirrors its carded module contract and owns no durable policy."
107
+ }
108
+ ]
109
+ }
110
+ }
111
+ ```
112
+
113
+ Each disposition targets one exact normalized repository-relative path. Globs, trailing slashes, backslashes, duplicate paths, and reasons shorter than 12 characters are rejected. A matching candidate is omitted from the active queue and reported as `suppressed`. If the file disappears, gains card coverage, or stops qualifying as a candidate, the disposition is reported as `stale` with a machine-readable reason so obsolete decisions remain visible.
114
+
93
115
  ## Lint profile
94
116
 
95
117
  Byte and field-count limits prevent semantic cards from becoming mini-documents. `maxSemanticRatio` produces a warning after the scanned source exceeds `minimumSourceBytesForRatio`.
@@ -23,7 +23,7 @@ Start with high and medium candidates. The audit explains whether a file is a pa
23
23
 
24
24
  In Go repositories, candidates represent packages. Full imports below a repository-contained `go.mod` contribute package fan-in, `cmd/*/main.go` is treated as a command entrypoint, and `_test.go` files do not create separate production candidates. Put one `module` card on a durable package representative; do not repeat the same package role on every Go file.
25
25
 
26
- After accepting a candidate, write its durable meaning by inspecting the source, add a narrow `coverageRules` entry for that exact boundary, and add a real task-language query to `.llmnav/eval/queries.jsonl`. Use `npx llmnav audit --fail-on high` in CI only after the initial review.
26
+ After accepting a candidate, write its durable meaning by inspecting the source, add a narrow `coverageRules` entry for that exact boundary, and add a real task-language query to `.llmnav/eval/queries.jsonl`. If review confirms that a candidate owns no durable navigation responsibility, add its exact path and a concrete reason to `audit.dispositions`. Never use a glob to dismiss a directory. The audit suppresses the reviewed candidate but reports the entry as stale when the file or its classification changes. Use `npx llmnav audit --fail-on high` in CI only after the initial review.
27
27
 
28
28
  ## Annotate a module boundary
29
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llmnav",
3
- "version": "0.7.4",
3
+ "version": "0.9.0",
4
4
  "description": "A deterministic semantic navigation layer for LLM coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -101,6 +101,34 @@
101
101
  }
102
102
  }
103
103
  },
104
+ "audit": {
105
+ "type": "object",
106
+ "additionalProperties": false,
107
+ "properties": {
108
+ "dispositions": {
109
+ "type": "array",
110
+ "items": {
111
+ "type": "object",
112
+ "required": [
113
+ "path",
114
+ "reason"
115
+ ],
116
+ "additionalProperties": false,
117
+ "properties": {
118
+ "path": {
119
+ "type": "string",
120
+ "pattern": "^(?!/|[A-Za-z]:|.*(?:^|/)\\.\\.(?:/|$)|.*[\\\\*?\\[\\]{}])[^/]+(?:/[^/]+)*$"
121
+ },
122
+ "reason": {
123
+ "type": "string",
124
+ "minLength": 12,
125
+ "maxLength": 280
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ },
104
132
  "graph": {
105
133
  "type": "object",
106
134
  "additionalProperties": false,
@@ -6,6 +6,7 @@ excludes=provider SDK transport|repository discovery|source mutation
6
6
  search=agent tool schema|provider neutral tools|tool dispatcher|agent operation protocol
7
7
  rel=workflow>llmnav.search.query
8
8
  rel=workflow>llmnav.rules.validate
9
+ rel=workflow>llmnav.audit.coverage
9
10
  stability=contract
10
11
  */
11
12
 
@@ -14,6 +15,7 @@ import { scanProject } from "./project.js";
14
15
  import { buildContext, queryProject, showProjectCard } from "./search.js";
15
16
  import { countDiagnostics, validateProject } from "./validator.js";
16
17
  import { getAgentToolDefinitions } from "./agent-tools.js";
18
+ import { explainProjectFile } from "./audit.js";
17
19
 
18
20
  export { AGENT_TOOL_SCHEMA_VERSION, getAgentToolDefinitions } from "./agent-tools.js";
19
21
  export const AGENT_OPERATION_SCHEMA_VERSION = 1;
@@ -25,6 +27,7 @@ const OPERATIONS = new Map([
25
27
  ["llmnav_show", "show"],
26
28
  ["llmnav_context", "context"],
27
29
  ["llmnav_check", "check"],
30
+ ["llmnav_explain", "explain"],
28
31
  ]);
29
32
 
30
33
  export async function executeAgentOperation(root, name, input = {}, options = {}) {
@@ -58,6 +61,9 @@ export async function executeAgentOperation(root, name, input = {}, options = {}
58
61
  ? options.session.context(input.id.trim(), contextOptions)
59
62
  : await buildContext(root, input.id.trim(), contextOptions));
60
63
  }
64
+ if (operation === "explain") {
65
+ return success(operation, await explainProjectFile(root, input.file.trim()));
66
+ }
61
67
  const project = await scanProject(root, { paths: input.paths ?? [] });
62
68
  const graphInputs = await loadGraphInputs(root, project.config);
63
69
  const diagnostics = [...validateProject(project), ...graphInputs.diagnostics].sort(compareDiagnostics);
@@ -32,6 +32,9 @@ const DEFINITIONS = [
32
32
  default: [],
33
33
  },
34
34
  }),
35
+ tool("llmnav_explain", "Explain one file's card coverage, audit evidence, disposition, and recommended next action.", {
36
+ file: stringProperty("Repository-relative or repository-contained absolute file path."),
37
+ }, ["file"]),
35
38
  ];
36
39
 
37
40
  export function getAgentToolDefinitions() {
package/src/agents.js CHANGED
@@ -30,7 +30,9 @@ Keep an existing LLMNav ID when a symbol or file is renamed or moved. Change \`r
30
30
 
31
31
  Do not add hand-maintained \`calls\`, \`imports\`, \`references\`, \`implements\`, \`exports\`, or \`overrides\` relations. Do not put paths, line numbers, commit hashes, timestamps, callers, or current signatures in LLMNav source comments.
32
32
 
33
- After initialization and whenever public entrypoints, commands, routes, schemas, migrations, or high fan-in modules change, run \`npm exec -- llmnav audit\`. Review high and medium candidates; never add cards automatically. Add a module card only after confirming a durable responsibility, then encode the accepted boundary in a path-specific \`coverageRules\` entry and add a representative retrieval query.
33
+ After initialization and whenever public entrypoints, commands, routes, schemas, migrations, or high fan-in modules change, run \`npm exec -- llmnav audit\`. Review high and medium candidates; never add cards automatically. Add a module card only after confirming a durable responsibility, then encode the accepted boundary in a path-specific \`coverageRules\` entry and add a representative retrieval query. When a reviewed candidate has no durable navigation responsibility, record its exact path and a concrete reason in \`audit.dispositions\`; never use a glob or broad directory suppression.
34
+
35
+ When deciding whether one specific file needs a card, run \`npm exec -- llmnav explain <file>\`. Use its coverage, score, and disposition evidence to guide review, but never turn its recommendation into automatic source annotation.
34
36
 
35
37
  After semantic changes, run \`npm exec -- llmnav format\`, \`npm exec -- llmnav check\`, and \`npm exec -- llmnav generate\`. Use broad text search only when LLMNav returns no credible candidate.
36
38
  ${END}`;
package/src/audit.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /* llmnav/1 module
2
2
  id=llmnav.audit.coverage
3
3
  role=Identify high-value source modules that lack semantic navigation boundaries without modifying source.
4
- owns=annotation coverage audit|candidate prioritization|coverage rule suggestions
4
+ owns=annotation coverage audit|candidate prioritization|file explanation|coverage rule suggestions
5
5
  excludes=automatic source annotation|semantic role generation
6
- search=llmnav audit|missing module cards|coverage suggestions
6
+ search=llmnav audit|missing module cards|explain missing annotation|coverage suggestions
7
7
  invariant=Audit output is deterministic, repository-relative, and advisory unless an explicit fail threshold is selected.
8
8
  rel=workflow>llmnav.project.scan
9
9
  stability=contract
@@ -17,6 +17,7 @@ import { compareText, readJsonSafe, toPosix } from "./util.js";
17
17
 
18
18
  export const AUDIT_SCHEMA_VERSION = 1;
19
19
  export const AUDIT_PRIORITIES = Object.freeze(["high", "medium", "low"]);
20
+ export const FILE_EXPLANATION_SCHEMA_VERSION = 1;
20
21
 
21
22
  const SOURCE_EXTENSIONS = Object.freeze([
22
23
  ".astro", ".c", ".cc", ".cjs", ".cpp", ".cs", ".cts", ".dart", ".go", ".h", ".hpp", ".java",
@@ -28,6 +29,65 @@ const NON_PRODUCTION_PATH_PATTERN = /(?:^|\/)(?:__tests__|benchmarks?|fixtures?|
28
29
  const LARGE_SOURCE_BYTES = 12_000;
29
30
 
30
31
  export async function auditProject(root) {
32
+ return (await analyzeAuditProject(root)).result;
33
+ }
34
+
35
+ export async function explainProjectFile(root, inputPath) {
36
+ const file = normalizeExplanationPath(root, inputPath);
37
+ const analysis = await analyzeAuditProject(root);
38
+ const { project, fileByPath, candidates, result } = analysis;
39
+ const moduleKey = fileByPath.has(file) ? moduleKeyForFile(file) : null;
40
+ const navigationCards = project.records
41
+ .filter((record) => toPosix(record.relativePath) === file)
42
+ .map((record) => ({ id: record.card.id, scope: record.card.scope, path: file }))
43
+ .sort((left, right) => compareText(left.id, right.id));
44
+ const coverageCards = moduleKey === null ? [] : project.records
45
+ .filter((record) => {
46
+ const cardPath = toPosix(record.relativePath);
47
+ if (record.card.scope === "file") return cardPath === file;
48
+ return record.card.scope === "module" && moduleKeyForFile(cardPath) === moduleKey;
49
+ })
50
+ .map((record) => ({ id: record.card.id, scope: record.card.scope, path: toPosix(record.relativePath) }))
51
+ .sort((left, right) => compareText(left.id, right.id));
52
+ const candidate = moduleKey === null ? null : candidates.find((item) => moduleKeyForFile(item.path) === moduleKey) ?? null;
53
+ const dispositionPath = candidate?.path ?? file;
54
+ const disposition = result.dispositions.find((item) => item.path === dispositionPath) ??
55
+ result.dispositions.find((item) => item.path === file) ?? null;
56
+
57
+ if (disposition?.status === "stale") {
58
+ return buildFileExplanation(result.repositoryId, file, moduleKey, "stale-disposition", navigationCards, coverageCards, candidate, disposition, [
59
+ `stale-disposition:${disposition.staleReason}`,
60
+ ], "remove-or-review-disposition", "Remove or update the stale exact-path disposition after reviewing the current file state.");
61
+ }
62
+ if (!fileByPath.has(file)) {
63
+ return buildFileExplanation(result.repositoryId, file, null, "not-scanned", [], [], null, disposition, ["file-not-scanned"],
64
+ "check-scan-configuration", "Check that the file exists under a source root and is not excluded by extension, directory, or file rules.");
65
+ }
66
+ if (coverageCards.length > 0) {
67
+ const status = coverageCards.some((card) => card.path === file) ? "carded" : "covered";
68
+ return buildFileExplanation(result.repositoryId, file, moduleKey, status, navigationCards, coverageCards, null, disposition,
69
+ [status === "carded" ? "file-has-coverage-card" : "module-covered-by-card"],
70
+ "keep-current-coverage", "No additional file or module card is needed unless this file gains a separate durable responsibility.");
71
+ }
72
+ if (candidate && disposition?.status === "suppressed") {
73
+ return buildFileExplanation(result.repositoryId, file, moduleKey, "suppressed", navigationCards, coverageCards, candidate, disposition,
74
+ ["reviewed-exact-path-disposition", ...candidate.reasons], "keep-or-review-disposition",
75
+ "Keep the disposition while its reason remains true; remove it if the file gains a durable navigation responsibility.");
76
+ }
77
+ if (candidate) {
78
+ const action = candidate.priority === "low" ? "review-or-disposition" : "review-card";
79
+ const message = candidate.priority === "low"
80
+ ? "Review the low-priority signal, but do not add a card unless the file owns a durable navigation responsibility."
81
+ : "Review the candidate and either add one durable card with exact coverage or record an exact-path disposition with a concrete reason.";
82
+ return buildFileExplanation(result.repositoryId, file, moduleKey, "candidate", navigationCards, coverageCards, candidate, null,
83
+ candidate.reasons, action, message);
84
+ }
85
+ const reasons = /\.d\.[cm]?ts$/u.test(file) ? ["declaration-file"] : ["no-ranked-audit-signal"];
86
+ return buildFileExplanation(result.repositoryId, file, moduleKey, "not-candidate", navigationCards, coverageCards, null, disposition, reasons,
87
+ "no-card-needed", "Do not add a card solely for coverage; revisit only if the file gains a durable responsibility or stronger structural signals.");
88
+ }
89
+
90
+ async function analyzeAuditProject(root) {
31
91
  const project = await scanProject(root);
32
92
  const fileByPath = new Map(
33
93
  project.fileRecords.map((record) => [toPosix(record.relativePath), record]),
@@ -162,22 +222,82 @@ export async function auditProject(root) {
162
222
  priorityRank(left.priority) - priorityRank(right.priority) ||
163
223
  right.score - left.score ||
164
224
  compareText(left.path, right.path));
225
+ const candidateByPath = new Map(candidates.map((candidate) => [candidate.path, candidate]));
226
+ const dispositions = [...project.config.audit.dispositions]
227
+ .sort((left, right) => compareText(left.path, right.path))
228
+ .map((disposition) => {
229
+ const candidate = candidateByPath.get(disposition.path);
230
+ if (candidate) {
231
+ candidateByPath.delete(disposition.path);
232
+ return {
233
+ path: disposition.path,
234
+ reason: disposition.reason,
235
+ status: "suppressed",
236
+ priority: candidate.priority,
237
+ score: candidate.score,
238
+ };
239
+ }
240
+ return {
241
+ path: disposition.path,
242
+ reason: disposition.reason,
243
+ status: "stale",
244
+ staleReason: classifyStaleDisposition(disposition.path, fileByPath, exactCardPaths, coveredModules),
245
+ };
246
+ });
247
+ const activeCandidates = candidates.filter((candidate) => candidateByPath.has(candidate.path));
165
248
  const summary = {
166
249
  analyzedFiles: fileByPath.size,
167
250
  cardedFiles: cardedFilePaths.size,
168
251
  filesWithoutModuleCards: fileByPath.size - cardedFilePaths.size,
169
- candidates: candidates.length,
170
- high: candidates.filter((candidate) => candidate.priority === "high").length,
171
- medium: candidates.filter((candidate) => candidate.priority === "medium").length,
172
- low: candidates.filter((candidate) => candidate.priority === "low").length,
173
- coverageSuggestions: candidates.filter((candidate) => candidate.suggestedCoverageRule !== null).length,
252
+ candidates: activeCandidates.length,
253
+ high: activeCandidates.filter((candidate) => candidate.priority === "high").length,
254
+ medium: activeCandidates.filter((candidate) => candidate.priority === "medium").length,
255
+ low: activeCandidates.filter((candidate) => candidate.priority === "low").length,
256
+ coverageSuggestions: activeCandidates.filter((candidate) => candidate.suggestedCoverageRule !== null).length,
257
+ suppressedCandidates: dispositions.filter((disposition) => disposition.status === "suppressed").length,
258
+ staleDispositions: dispositions.filter((disposition) => disposition.status === "stale").length,
174
259
  };
175
- return {
260
+ const result = {
176
261
  schemaVersion: AUDIT_SCHEMA_VERSION,
177
262
  repositoryId: project.config.repositoryId,
178
263
  summary,
179
- candidates,
264
+ candidates: activeCandidates,
265
+ dispositions,
180
266
  };
267
+ return { project, fileByPath, candidates, result };
268
+ }
269
+
270
+ function normalizeExplanationPath(root, inputPath) {
271
+ if (typeof inputPath !== "string" || inputPath.trim() === "") throw new TypeError("explain requires one file path.");
272
+ const rootPath = path.resolve(root);
273
+ const absolutePath = path.resolve(rootPath, inputPath);
274
+ const relativePath = path.relative(rootPath, absolutePath);
275
+ if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
276
+ throw new RangeError("explain path must name one file inside the repository root.");
277
+ }
278
+ return toPosix(relativePath);
279
+ }
280
+
281
+ function buildFileExplanation(repositoryId, file, moduleKey, status, navigationCards, coverageCards, candidate, disposition, reasons, action, message) {
282
+ return {
283
+ schemaVersion: FILE_EXPLANATION_SCHEMA_VERSION,
284
+ repositoryId,
285
+ path: file,
286
+ moduleKey,
287
+ status,
288
+ navigationCards,
289
+ coverageCards,
290
+ candidate,
291
+ disposition,
292
+ reasons,
293
+ recommendation: { action, message },
294
+ };
295
+ }
296
+
297
+ function classifyStaleDisposition(file, fileByPath, exactCardPaths, coveredModules) {
298
+ if (!fileByPath.has(file)) return "file-not-scanned";
299
+ if (exactCardPaths.has(file) || coveredModules.has(moduleKeyForFile(file))) return "already-carded";
300
+ return "not-a-candidate";
181
301
  }
182
302
 
183
303
  function groupFilesByModule(fileByPath) {
package/src/cli.js CHANGED
@@ -38,7 +38,7 @@ import { renderGraphNode } from "./graph.js";
38
38
  import { getAgentToolDefinitions } from "./agent-protocol.js";
39
39
  import { loadPromptPrefixBundle } from "./prompt-bundle.js";
40
40
  import { diagnosticsToEditor, getEditorIntegration } from "./editor.js";
41
- import { AUDIT_PRIORITIES, auditHasFindings, auditProject } from "./audit.js";
41
+ import { AUDIT_PRIORITIES, auditHasFindings, auditProject, explainProjectFile } from "./audit.js";
42
42
  import { migrateProject } from "./migration.js";
43
43
 
44
44
  const VALUE_OPTIONS = new Set(["--root", "--format", "--top", "--depth", "--budget", "--max-edges", "--agents", "--file", "--fail-on", "--output"]);
@@ -56,6 +56,7 @@ const COMMAND_OPTIONS = Object.freeze({
56
56
  doctor: new Set(["--root", "--json"]),
57
57
  migrate: new Set(["--check", "--write", "--root", "--json"]),
58
58
  audit: new Set(["--root", "--json", "--summary", "--fail-on", "--output"]),
59
+ explain: new Set(["--root", "--json"]),
59
60
  spec: new Set(["--root", "--json"]),
60
61
  tools: new Set(["--json"]),
61
62
  bundle: new Set(["--root", "--json"]),
@@ -106,6 +107,8 @@ export async function runCli(argv) {
106
107
  return runMigrate(root, args, json);
107
108
  case "audit":
108
109
  return runAudit(root, args, json);
110
+ case "explain":
111
+ return runExplain(root, args, json);
109
112
  case "spec":
110
113
  return runSpec(json);
111
114
  case "bundle":
@@ -349,17 +352,59 @@ async function runAudit(root, args, json) {
349
352
  const { summary } = result;
350
353
  console.log(
351
354
  `audit files=${summary.analyzedFiles} carded=${summary.cardedFiles} candidates=${summary.candidates} ` +
352
- `high=${summary.high} medium=${summary.medium} low=${summary.low}`,
355
+ `high=${summary.high} medium=${summary.medium} low=${summary.low} ` +
356
+ `suppressed=${summary.suppressedCandidates} stale-dispositions=${summary.staleDispositions}`,
353
357
  );
354
358
  for (const candidate of result.candidates.filter((item) => item.priority !== "low")) {
355
359
  console.log(`${candidate.priority} ${candidate.path} score=${candidate.score} ${candidate.reasons.join(",")}`);
356
360
  }
357
361
  if (summary.low > 0) console.log(`${summary.low} low-priority candidate(s) are available in --json output.`);
362
+ for (const disposition of result.dispositions.filter((item) => item.status === "stale")) {
363
+ console.log(`stale ${disposition.path} ${disposition.staleReason}: ${disposition.reason}`);
364
+ }
358
365
  if (outputPath) console.log(`wrote ${relativePosix(root, outputPath)}`);
359
366
  }
360
367
  return auditHasFindings(result, failOn) ? 1 : 0;
361
368
  }
362
369
 
370
+ async function runExplain(root, args, json) {
371
+ const files = getPositionals(args);
372
+ if (files.length !== 1) throw usageError("explain requires exactly one file path.");
373
+ let result;
374
+ try {
375
+ result = await explainProjectFile(root, files[0]);
376
+ } catch (error) {
377
+ if ((error instanceof TypeError || error instanceof RangeError) && /^explain (?:requires|path)/u.test(error.message)) {
378
+ throw usageError(error.message);
379
+ }
380
+ throw error;
381
+ }
382
+ if (json) {
383
+ console.log(JSON.stringify(result, null, 2));
384
+ } else {
385
+ console.log(`file ${result.path}`);
386
+ console.log(`status ${result.status}`);
387
+ if (result.moduleKey) console.log(`module ${result.moduleKey}`);
388
+ for (const card of result.navigationCards) console.log(`card ${card.scope} @${card.id} ${card.path}`);
389
+ for (const card of result.coverageCards) {
390
+ if (!result.navigationCards.some((item) => item.id === card.id && item.path === card.path)) {
391
+ console.log(`coverage ${card.scope} @${card.id} ${card.path}`);
392
+ }
393
+ }
394
+ if (result.candidate) {
395
+ console.log(`candidate ${result.candidate.priority} ${result.candidate.path} score=${result.candidate.score}`);
396
+ console.log(`why ${result.candidate.reasons.join(",")}`);
397
+ const signals = Object.entries(result.candidate.signals)
398
+ .filter(([, value]) => Array.isArray(value) ? value.length > 0 : Boolean(value))
399
+ .map(([name, value]) => `${name}=${Array.isArray(value) ? value.join("|") : value}`);
400
+ if (signals.length > 0) console.log(`signals ${signals.join(",")}`);
401
+ }
402
+ if (result.disposition) console.log(`disposition ${result.disposition.status}: ${result.disposition.reason}`);
403
+ console.log(`next ${result.recommendation.action}: ${result.recommendation.message}`);
404
+ }
405
+ return result.status === "not-scanned" ? 1 : 0;
406
+ }
407
+
363
408
  function runSpec(json) {
364
409
  const spec = {
365
410
  specVersion: SPEC_VERSION,
@@ -518,6 +563,7 @@ Usage
518
563
  llmnav doctor
519
564
  llmnav migrate [--check|--write]
520
565
  llmnav audit [--summary] [--output path] [--fail-on none|high|medium|low]
566
+ llmnav explain <file>
521
567
  llmnav spec
522
568
  llmnav tools [--json]
523
569
  llmnav bundle [--json]
package/src/config.js CHANGED
@@ -30,7 +30,9 @@ const LINT_KEYS = new Set(Object.keys(DEFAULT_CONFIG.lint));
30
30
  const GENERATION_KEYS = new Set(Object.keys(DEFAULT_CONFIG.generation));
31
31
  const EVALUATION_KEYS = new Set(Object.keys(DEFAULT_CONFIG.evaluation));
32
32
  const GRAPH_KEYS = new Set(Object.keys(DEFAULT_CONFIG.graph));
33
+ const AUDIT_KEYS = new Set(Object.keys(DEFAULT_CONFIG.audit));
33
34
  const COVERAGE_KEYS = new Set(["name", "match", "scope", "requiredFields"]);
35
+ const AUDIT_DISPOSITION_KEYS = new Set(["path", "reason"]);
34
36
 
35
37
  export async function loadConfig(root) {
36
38
  const configPath = path.join(root, ".llmnav", "config.json");
@@ -72,6 +74,7 @@ export function validateConfig(config, configPath = ".llmnav/config.json") {
72
74
  validateStringArray(config.excludeDirectories, "excludeDirectories", problems, { unique: true });
73
75
  validateStringArray(config.excludeFiles, "excludeFiles", problems, { unique: true });
74
76
  validateCoverageRules(config.coverageRules, problems);
77
+ validateAudit(config.audit, problems);
75
78
  validateGraph(config.graph, problems);
76
79
 
77
80
  validateLint(config.lint, problems);
@@ -83,6 +86,44 @@ export function validateConfig(config, configPath = ".llmnav/config.json") {
83
86
  }
84
87
  }
85
88
 
89
+ function validateAudit(audit, problems) {
90
+ if (!isObject(audit)) {
91
+ problems.push("audit must be an object");
92
+ return;
93
+ }
94
+ validateObjectKeys(audit, AUDIT_KEYS, "audit", problems);
95
+ if (!Array.isArray(audit.dispositions)) {
96
+ problems.push("audit.dispositions must be an array");
97
+ return;
98
+ }
99
+ const seenPaths = new Set();
100
+ for (const [index, disposition] of audit.dispositions.entries()) {
101
+ const name = `audit.dispositions[${index}]`;
102
+ if (!isObject(disposition)) {
103
+ problems.push(`${name} must be an object`);
104
+ continue;
105
+ }
106
+ validateObjectKeys(disposition, AUDIT_DISPOSITION_KEYS, name, problems);
107
+ const pathProblem = validateProjectRelativePath(disposition.path);
108
+ if (pathProblem) {
109
+ problems.push(`${name}.path ${pathProblem}`);
110
+ } else {
111
+ const normalizedPath = path.posix.normalize(disposition.path);
112
+ if (normalizedPath !== disposition.path || disposition.path.endsWith("/") || /[*?[\]{}]/u.test(disposition.path)) {
113
+ problems.push(`${name}.path must be one exact normalized repository-relative path without glob syntax`);
114
+ }
115
+ const comparisonPath = normalizedPath.normalize("NFKC").toLocaleLowerCase("en-US");
116
+ if (seenPaths.has(comparisonPath)) problems.push(`${name}.path duplicates an earlier disposition`);
117
+ seenPaths.add(comparisonPath);
118
+ }
119
+ if (typeof disposition.reason !== "string" || disposition.reason.trim().length < 12) {
120
+ problems.push(`${name}.reason must explain the review decision in at least 12 characters`);
121
+ } else if (disposition.reason.length > 280) {
122
+ problems.push(`${name}.reason must not exceed 280 characters`);
123
+ }
124
+ }
125
+ }
126
+
86
127
  function validateGraph(graph, problems) {
87
128
  if (!isObject(graph)) {
88
129
  problems.push("graph must be an object");
package/src/index.d.ts CHANGED
@@ -2,6 +2,8 @@ export type LlmnavScope = "file" | "module" | "symbol";
2
2
  export type LlmnavStability = "architecture" | "contract" | "implementation";
3
3
  export type DiagnosticSeverity = "error" | "warning" | "info";
4
4
  export type AuditPriority = "high" | "medium" | "low";
5
+ export type AuditDispositionStaleReason = "file-not-scanned" | "already-carded" | "not-a-candidate";
6
+ export type FileExplanationStatus = "candidate" | "suppressed" | "carded" | "covered" | "not-candidate" | "not-scanned" | "stale-disposition";
5
7
 
6
8
  export interface LlmnavCard {
7
9
  scope: LlmnavScope;
@@ -108,8 +110,31 @@ export interface AuditResult {
108
110
  medium: number;
109
111
  low: number;
110
112
  coverageSuggestions: number;
113
+ suppressedCandidates: number;
114
+ staleDispositions: number;
111
115
  };
112
116
  candidates: AuditCandidate[];
117
+ dispositions: Array<
118
+ | { path: string; reason: string; status: "suppressed"; priority: AuditPriority; score: number }
119
+ | { path: string; reason: string; status: "stale"; staleReason: AuditDispositionStaleReason }
120
+ >;
121
+ }
122
+
123
+ export interface FileExplanationResult {
124
+ schemaVersion: 1;
125
+ repositoryId: string;
126
+ path: string;
127
+ moduleKey: string | null;
128
+ status: FileExplanationStatus;
129
+ navigationCards: Array<{ id: string; scope: LlmnavScope; path: string }>;
130
+ coverageCards: Array<{ id: string; scope: "file" | "module"; path: string }>;
131
+ candidate: AuditCandidate | null;
132
+ disposition: AuditResult["dispositions"][number] | null;
133
+ reasons: string[];
134
+ recommendation: {
135
+ action: "review-card" | "review-or-disposition" | "keep-or-review-disposition" | "keep-current-coverage" | "no-card-needed" | "check-scan-configuration" | "remove-or-review-disposition";
136
+ message: string;
137
+ };
113
138
  }
114
139
 
115
140
  export interface IndexedLocation {
@@ -247,6 +272,9 @@ export interface LlmnavConfig {
247
272
  excludeDirectories: string[];
248
273
  excludeFiles: string[];
249
274
  coverageRules: Array<Record<string, unknown>>;
275
+ audit: {
276
+ dispositions: Array<{ path: string; reason: string }>;
277
+ };
250
278
  graph: {
251
279
  indexFiles: string[];
252
280
  };
@@ -287,7 +315,7 @@ export interface LlmnavConfig {
287
315
 
288
316
  export interface AgentToolDefinition {
289
317
  schemaVersion: 1;
290
- name: "llmnav_query" | "llmnav_show" | "llmnav_context" | "llmnav_check";
318
+ name: "llmnav_query" | "llmnav_show" | "llmnav_context" | "llmnav_check" | "llmnav_explain";
291
319
  description: string;
292
320
  inputSchema: {
293
321
  type: "object";
@@ -299,7 +327,7 @@ export interface AgentToolDefinition {
299
327
 
300
328
  export interface AgentOperationResult<T = unknown> {
301
329
  schemaVersion: 1;
302
- operation: "query" | "show" | "context" | "check" | "unknown";
330
+ operation: "query" | "show" | "context" | "check" | "explain" | "unknown";
303
331
  ok: boolean;
304
332
  data: T | null;
305
333
  error: { code: string; message: string } | null;
@@ -611,6 +639,7 @@ export interface EvaluationResult {
611
639
  export const AGENT_PROTOCOL: string;
612
640
  export const AUDIT_PRIORITIES: readonly AuditPriority[];
613
641
  export const AUDIT_SCHEMA_VERSION: 1;
642
+ export const FILE_EXPLANATION_SCHEMA_VERSION: 1;
614
643
  export const AGENT_OPERATION_SCHEMA_VERSION: 1;
615
644
  export const AGENT_TOOL_SCHEMA_VERSION: 1;
616
645
  export const BOUNDARY_KINDS: readonly DetectedBoundary["kind"][];
@@ -688,6 +717,7 @@ export function loadConfig(root: string): Promise<{ config: LlmnavConfig; config
688
717
  export function validateConfig(config: LlmnavConfig, configPath?: string): void;
689
718
  export function auditProject(root: string): Promise<AuditResult>;
690
719
  export function auditHasFindings(result: AuditResult, minimumPriority?: AuditPriority | "none"): boolean;
720
+ export function explainProjectFile(root: string, file: string): Promise<FileExplanationResult>;
691
721
  export function findAttachedDeclaration(source: string, block: LlmnavBlock, filePath: string): Declaration | null;
692
722
  export function extractImports(source: string, filePath: string): string[];
693
723
  export function doctorProject(root: string): Promise<{ ok: boolean; checks: Array<{ name: string; ok: boolean; message: string }> }>;
package/src/index.js CHANGED
@@ -14,7 +14,14 @@ export {
14
14
  } from "./prompt-bundle.js";
15
15
  export { compareCardIndexes, describeAffectedBoundaries, describeAffectedCatalogs } from "./changes.js";
16
16
  export { loadConfig, validateConfig } from "./config.js";
17
- export { AUDIT_PRIORITIES, AUDIT_SCHEMA_VERSION, auditHasFindings, auditProject } from "./audit.js";
17
+ export {
18
+ AUDIT_PRIORITIES,
19
+ AUDIT_SCHEMA_VERSION,
20
+ FILE_EXPLANATION_SCHEMA_VERSION,
21
+ auditHasFindings,
22
+ auditProject,
23
+ explainProjectFile,
24
+ } from "./audit.js";
18
25
  export { BOUNDARY_KINDS, detectBoundaries } from "./boundaries.js";
19
26
  export {
20
27
  diagnosticsToEditor,
package/src/spec.js CHANGED
@@ -10,7 +10,7 @@ rel=workflow>llmnav.rules.validate
10
10
  stability=contract
11
11
  */
12
12
 
13
- export const PACKAGE_VERSION = "0.7.4";
13
+ export const PACKAGE_VERSION = "0.9.0";
14
14
  export const SPEC_VERSION = "1";
15
15
 
16
16
  export const SCOPES = Object.freeze(["file", "module", "symbol"]);
@@ -208,6 +208,9 @@ export const DEFAULT_CONFIG = Object.freeze({
208
208
  excludeDirectories: [...DEFAULT_EXCLUDED_DIRECTORIES],
209
209
  excludeFiles: ["*.min.js", "*.bundle.js", "*.generated.*", "*.gen.*"],
210
210
  coverageRules: [],
211
+ audit: {
212
+ dispositions: [],
213
+ },
211
214
  graph: {
212
215
  indexFiles: [],
213
216
  },