llmnav 0.8.0 → 0.9.1

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,31 @@ The npm package follows Semantic Versioning. The `llmnav/N` source protocol is v
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.1] — 2026-09-07
10
+
11
+ ### Fixed
12
+
13
+ * Explain unsupported or disallowed hard-link lock publication, preserve its filesystem error, and verify installed host show, bounded context, and refresh operations.
14
+ * Prevent inherited npm `allow-scripts` environment policy from breaking isolated package installation smoke checks; installation lifecycle scripts remain disabled.
15
+ * Synchronize the public package-version constant with 0.9.1 and check both npm manifests in the normal test suite.
16
+ * Publish complete generation lock records atomically, clean up failed preparation, and explain repair of unreadable legacy locks.
17
+ * Hold the generation lock throughout search and registry snapshot reads.
18
+ * Apply context edge limits when falling back to semantic relations without a usable graph.
19
+
20
+ ### Performance
21
+
22
+ * Distribute search postings to card-range shards in one pass while preserving byte-identical manifests and artifacts.
23
+ * Prepare card lookups, normalized IDs and aliases, and graph validation once per session snapshot; detach query and show results to protect cached state from caller mutation.
24
+ * Measure fresh-process session startup, graph-aware query and context latency, and refresh separately, with result parity checks against direct navigation APIs.
25
+ * Reuse graph adjacency and traversal ordering within project sessions; refresh rebuilds that state while direct query APIs continue to accept mutable graph inputs.
26
+ * Count removed graph partitions with keyed membership instead of scanning all current partitions for each previous key.
27
+
28
+ ## [0.9.0] — 2026-08-15
29
+
30
+ ### Added
31
+
32
+ * 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.
33
+
9
34
  ## [0.8.0] — 2026-08-15
10
35
 
11
36
  ### Added
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. 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.
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 |
@@ -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. Exact reviewed decisions from `audit.dispositions` are returned separately with `suppressed` or `stale` status; `auditHasFindings` considers only active candidates. 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
 
@@ -299,6 +302,10 @@ await session.refresh(); // after generation or checkout changes
299
302
 
300
303
  `query`, `show`, and `context` reuse the loaded index, postings, graph, lexicon, and registry. `refresh()` replaces the complete snapshot; it never mutates one layer in place. The `check` operation still scans current source and does not use session data.
301
304
 
305
+ Sessions prepare graph adjacency and traversal order once per snapshot for reuse by `query` and `context`. Refreshing prepares a new graph, and obsolete prepared state can be garbage-collected with the previous snapshot. Direct `queryIndex` and `queryPreparedIndex` calls do not cache caller-owned graphs, so in-place changes to supplied edges remain visible on the next call.
306
+
307
+ Session queries also reuse card lookup tables, normalized IDs and aliases, and graph validation. ID substring and phrase matching still scan all documents to preserve ranking. Session `query` and `show` return detached results: caller edits cannot invalidate the private prepared snapshot. These tables are rebuilt on `refresh()` and retained only with that snapshot.
308
+
302
309
  The typed `llmnav/examples/provider-neutral-host.mjs` export composes these APIs into a trusted-root closure. It exposes tool definitions, base and module-selected prompt partitions, one snapshot-backed operation executor, and an explicit refresh method without importing a model SDK.
303
310
 
304
311
  `buildPromptPrefixBundle(input)` constructs ordered package, repository, and module partitions with normalized newlines, SHA-256 content hashes, estimated token counts, and explicit cache-boundary hints. `renderPromptPrefixBundle` serializes it deterministically. `loadPromptPrefixBundle(root)` accepts only a schema-compatible artifact whose exact bytes match `manifest.json`.
@@ -197,6 +197,12 @@ Generation never mutates the live cache file by file. It builds a complete repla
197
197
 
198
198
  The complete source scan and cache commit are serialized by `.llmnav/generation.lock`. The lock records a process and opaque owner ID. A reader waits for a live owner to finish and only then evaluates recovery, while an abandoned lock from a dead process can be removed without granting another process authority over an active journal.
199
199
 
200
+ Readers hold that lock until all snapshot artifacts, including the ID registry for sessions, show, and context, have been read. Lock ownership is published with an exclusive hard link to a fully written, synced, and closed candidate file in the same directory; the filesystem must support hard links. Failed preparation cannot leave an empty authoritative lock. Unused candidate files confer no ownership. An unreadable lock left by an older version is not automatically stolen: stop all LLMNav processes, remove `.llmnav/generation.lock`, then retry. A valid abandoned transaction is still recovered normally.
201
+
202
+ If hard-link publication fails with `ENOSYS`, `ENOTSUP`, `EOPNOTSUPP`, `EPERM`, or `EXDEV`, the error explains the filesystem and permission requirements and retains the original cause. The unpublished candidate is cleaned up; the lock is not downgraded to a non-atomic write. `EPERM` can indicate a permission restriction, not only a filesystem capability gap.
203
+
204
+ When no compatible graph is available, context falls back to semantic relations and still limits traversal with `maxEdges`, including zero. The token budget separately bounds rendered output.
205
+
200
206
  ```text
201
207
  write every staged artifact
202
208
  verify exact staged bytes
@@ -63,6 +63,55 @@ LLMNAV_BENCH_FILES=500 LLMNAV_BENCH_QUERY_RUNS=5 npm run benchmark:v0.2
63
63
 
64
64
  Published repository results use the defaults. The report states that each query starts a fresh Node.js process and that the operating-system filesystem cache is not flushed. That distinction prevents a warm page cache from being mislabeled as cold disk I/O.
65
65
 
66
+ ## Graph-aware session lifecycle
67
+
68
+ `npm run benchmark:navigation` generates a temporary 100-file repository with 1,000 cards and three outgoing semantic relations per card. A fresh worker process measures initial session loading, the first query, 50 repeated queries, 50 bounded graph-context requests, and refresh with unchanged source. Three direct `queryProject` calls provide a filesystem-loading comparison. The report includes medians, p95, memory, operation counters, fixture sizes, and result digests.
69
+
70
+ The operating-system filesystem cache is not flushed. Module import and process launch costs are excluded from `initialSessionMs` but included in the total `workerWallMs`, which also includes reference checks. Fixture generation is outside both measurements. Query scores and reasons must match the direct graph-aware index path; context must include the requested root and graph evidence within its edge and token limits. CI uses a 100-card fixture to validate these contracts without imposing a machine-specific timing threshold.
71
+
72
+
73
+ ### Local baseline before session metadata preparation
74
+
75
+ Measured on 2026-09-07 with runtime source based on `5b0d837`: Windows x64, Node v24.18.0, AMD Ryzen 5 7430U with Radeon Graphics. This is one local run with an unflushed filesystem cache, not a production latency guarantee.
76
+
77
+ | Phase | Median or single sample (ms) | p95 (ms) |
78
+ | --- | ---: | ---: |
79
+ | Initial session load | 141.335 | — |
80
+ | First query | 11.113 | — |
81
+ | Repeated session query (50) | 3.859 | 7.833 |
82
+ | Repeated session context (50) | 2.870 | 5.484 |
83
+ | Refresh unchanged source | 70.303 | — |
84
+ | Direct query with disk loading (3) | 90.443 | 120.859 |
85
+
86
+ All 50 query rankings, scores and reasons matched the direct index path. All 50 contexts met the graph and token bounds. Query work still included 50,000 ID scans and 50,000 phrase-document scans; caching adjacency does not eliminate those passes.
87
+
88
+ Result digests for comparison with subsequent optimization:
89
+
90
+ ```text
91
+ query d7545f3781f705eb2fdb5c7deb14004c7d74f1b9fc81fb21314e00f3f276e272
92
+ context 051db2dc2b802eca7a5b3a04a08c38b447ba95659ebc2a8eef2e9b6fb0e1207b
93
+ ```
94
+
95
+ ### Local session metadata result
96
+
97
+ On the same fixture and environment, one local run after session metadata preparation measured query median/p95 at 2.883/5.796 ms, with identical query and context digests above. Across 50 queries, ID normalizations, lookup-table builds, and graph validations were all zero; ID and phrase scans remain 50,000 each. The optimization does not narrow substring candidates or change ranking.
98
+
99
+ Initial loading was 159.667 ms, first query 21.485 ms, refresh 114.610 ms, context median/p95 3.617/8.979 ms, and direct-query median 102.542 ms. Preparation shifts work to loading/refresh and result detachment adds copying. These single-run timings include local load variation; they establish neither a startup improvement nor a production speed guarantee.
100
+
101
+ ## Card-range shard generation
102
+
103
+ `npm run benchmark:shards` compares one-pass distribution with the pre-optimization per-shard filtering implementation on 2,000 cards and 44,286 postings. It checks exact manifest and artifact bytes. A separate regression instruments posting reads and requires one visit per posting, independent of shard count; the reference visits every posting once per shard.
104
+
105
+ One Windows x64 / Node v24.18.0 run on 2026-09-07, including serialization but excluding index construction:
106
+
107
+ | Shard count | Reference (ms) | One pass (ms) |
108
+ | --- | ---: | ---: |
109
+ | 100 | 503.116 | 169.912 |
110
+ | 20 | 247.831 | 170.843 |
111
+ | 4 | 157.325 | 191.673 |
112
+
113
+ These are single in-process samples, with the reference first, not production guarantees. Few shards can be slower because distribution adds bookkeeping. Posting distribution changes from shard-count multiplied scans to one pass, but serialization still costs the output size. Partitioned posting pairs temporarily occupy memory alongside serialized shards; completed partitions are released as serialization progresses. This benchmark does not measure peak memory. Sharding remains disabled by default.
114
+
66
115
  ## Measurement integrity
67
116
 
68
117
  A benchmark result is accepted only after these checks pass:
@@ -79,6 +128,8 @@ Wall-clock speed is reported exactly as measured. It is not used to invent a spe
79
128
 
80
129
  ## Repository search evaluation
81
130
 
131
+ The [historical navigation replay pilot](navigation-replay.md) compares six fixed task paraphrases with regex file discovery. It reports candidate ranks and explicit unmeasured agent metrics; it is not a substitute for end-to-end trials.
132
+
82
133
  Add real task descriptions to `.llmnav/eval/queries.jsonl`.
83
134
 
84
135
  ```jsonl
package/docs/cli.md CHANGED
@@ -41,6 +41,16 @@ The command never modifies source, configuration, registries, or generated cache
41
41
 
42
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
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.
53
+
44
54
  ## `llmnav check`
45
55
 
46
56
  ```sh
@@ -0,0 +1,44 @@
1
+ # Historical navigation replay pilot
2
+
3
+ ## Result
4
+
5
+ On 2026-09-08, six curated historical changes were replayed against runtime commit `d30d80015ea10ea43a0875bc329669fa4019a457`. Both methods included the expected source file in their first five candidates for all six cases. LLMNav ranked four expected files first; the regex baseline placed one first in alphabetical file order. These are different ordering policies, not an agent success-rate comparison.
6
+
7
+ | Historical task | Source change | Expected source | Regex rank / candidates | LLMNav rank / candidates |
8
+ | --- | --- | --- | ---: | ---: |
9
+ | Filesystem capability required by generation locking | `5b0d837` | `src/transaction.js` | 2 / 2 | 2 / 20 |
10
+ | Context still follows edges at a zero edge limit | `34f56a6` | `src/search.js` | 4 / 4 | 2 / 23 |
11
+ | Explain suppressed annotation-audit candidates | `592849f` | `src/audit.js` | 3 / 6 | 1 / 17 |
12
+ | Resolve a Go module import to its package | `fcd40cb` | `src/module-resolution.js` | 3 / 3 | 1 / 23 |
13
+ | Upgrade generated formats without partial cache state | `839ab1c` | `src/migration.js` | 5 / 6 | 1 / 25 |
14
+ | Remove repeated posting scans during sharding | `d30d800` | `src/search-shards.js` | 1 / 1 | 1 / 23 |
15
+
16
+ Candidate counts include the complete positive-result list, not just the first five displayed paths. LLMNav returns more broad matches, while known technical keywords can narrow regex discovery much further. The two non-first LLMNav results put `src/audit.js` ahead of the locking implementation and `src/graph.js` ahead of the context-limit implementation. No query, expected target, annotation, or rank weight was tuned after this run.
17
+
18
+ ## Reproduce
19
+
20
+ From the Git repository, run:
21
+
22
+ ```sh
23
+ node benchmarks/navigation-replay.js
24
+ ```
25
+
26
+ The script performs no model calls, network requests, source edits, or cache generation. It requires an existing generated LLMNav index and the referenced Git history. Expected targets must both exist in current source and occur in the cited historical change. The output includes source, harness, and case hashes so a rerun can distinguish changed inputs from changed ranking. Refresh the index through the normal generation workflow before comparing a later source revision.
27
+
28
+ Both output scopes contain 37 tracked JavaScript files under `src/` and `bin/`. The baseline performs one fixed case-insensitive line-regex search per case and sorts matching paths alphabetically. LLMNav performs one query, preserves score order, deduplicates paths, and restricts output to that same source scope. Its scoring still uses the complete repository index before filtering. Source annotations remain visible to the baseline. The script refuses a truncated 100-card result instead of treating hidden hits as misses.
29
+
30
+ Input identities for the recorded run:
31
+
32
+ ```text
33
+ harness 706834dc4a0faf0b5111f11dc854d929f5c84a4b6757542bd3a98041d0b47f33
34
+ cases c23e467540d03544a68a4ffabc9cb4c91c2ecea87f655f6e3b55257adf404364
35
+ source 2a8e5f8e2bff1f6236bd2117998d134a44cacb728d1667a5f3fb747a7fff8c7b
36
+ ```
37
+
38
+ ## Evidence boundary and decision
39
+
40
+ The cases are author-curated English paraphrases of known changes, not verbatim user requests, representative traffic, Korean-language coverage, or a sealed holdout. They run on already corrected current source, not the pre-fix repository. The author knew the expected paths while constructing both symptom queries and regexes. This is a diagnostic retrieval pilot, not a claim that LLMNav outperforms a developer adaptively using `rg`.
41
+
42
+ Each method performs one search per case, but actual agent tool calls, files opened, uncached tokens, elapsed task time, edit correctness, and task completion are **unmeasured** and reported as `null`, not inferred from candidate rank. No full agent trace was captured. The pilot therefore does not complete the end-to-end evaluation described in [benchmarking](benchmarking.md).
43
+
44
+ The existing changes were delivered and their Windows/Linux CI passed before this pilot. No runtime optimization is added on the strength of these results: they do not establish that `show` or `context` latency blocks development work. Further session lookup caching and shard-memory work remain deferred. The existing ranking and substring-search behavior are unchanged.
@@ -1,18 +1,41 @@
1
1
  # Publishing `llmnav` to npm
2
2
 
3
- The repository is release-ready except for owner-specific metadata and npm account configuration.
3
+ Maintainers publish this repository through the existing tag-triggered GitHub Actions workflow. Initial repository and npm account setup belongs to the separate bootstrap section below; it is not part of every release.
4
4
 
5
- ## 1. Claim the names
5
+ ## Routine release
6
6
 
7
- Create the GitHub repository named `llmnav`, then check the npm registry immediately before the first release:
7
+ 1. Confirm the intended version agrees across `package.json`, `package-lock.json`, `src/spec.js`, and the changelog. Review the exact diff and keep unrelated changes out of the release.
8
+ 2. Check the remote tag and npm version before creating anything. Authentication or network errors mean unknown state, not an unpublished version. Never move an existing release tag or overwrite a published version.
9
+ 3. Validate the exact payload using the commands below. Reuse passing checks only while their relevant inputs remain unchanged.
10
+ 4. Commit and push the release changes to `main`. Require successful Windows/Linux and package CI for that exact commit, not a previous commit.
11
+ 5. Create an annotated version tag on the verified commit and push only that tag. The workflow handles npm publication and GitHub Release creation.
12
+ 6. Verify the tag commit, release workflow, npm version and integrity, provenance, GitHub Release, and an installation of the published package. Publication success and branch CI are separate checks.
8
13
 
9
14
  ```sh
10
- npm view llmnav
15
+ npm run check
16
+ npm run release:check
17
+ npm run smoke:pack
18
+ npm pack --dry-run
19
+ ```
20
+
21
+ If dependencies are missing or the lockfile changed, install them through the approved dependency workflow before these checks. The package intentionally includes the CLI, source API, type declarations, schema, templates, documentation, the typed provider-neutral host example, README, changelog, roadmap, and license. Tests, benchmarks, and development scripts remain in GitHub rather than the installed package.
22
+
23
+ After choosing the version, replace the placeholder in both tag commands:
24
+
25
+ ```sh
26
+ git tag -a vX.Y.Z -m "llmnav vX.Y.Z"
27
+ git push origin refs/tags/vX.Y.Z
11
28
  ```
12
29
 
13
- An `E404` means no public package is visible through the registry you queried at that moment. It is not a reservation. The name remains claimable by someone else until publication succeeds.
30
+ The workflow rejects a tag that does not match `package.json` or the protected `origin/main` lineage. If the version already exists in npm, it succeeds only when the registry integrity matches the tagged payload. A mismatch fails closed and requires a new version, not a forced tag. GitHub Release creation runs only after publication or matching-integrity verification succeeds.
31
+
32
+ If publication is interrupted, inspect the existing tag, registry artifact, and workflow result before retrying. Preserve the version and tag when they already identify the intended artifact; do not assume a failed workflow means nothing was published.
33
+
34
+ ## One-time bootstrap for a new repository or publisher
35
+
36
+ ### Claim names and set owner metadata
14
37
 
15
- ## 2. Replace release metadata
38
+ Before a first publication, create the GitHub repository and check npm name availability. An `E404` means no public package is visible at that moment, not that the name is reserved. Do not repeat owner replacement for routine releases of this configured repository.
16
39
 
17
40
  Update only the owner-specific URLs in `package.json` and `.github/ISSUE_TEMPLATE/config.yml`.
18
41
  Do not run a repository-wide replacement: the release checker and doctor intentionally keep the literal `OWNER` sentinel in source code.
@@ -29,27 +52,15 @@ Then replace `OWNER` only in `.github/ISSUE_TEMPLATE/config.yml` and run:
29
52
  npm run release:check
30
53
  ```
31
54
 
32
- ## 3. Configure npm authentication
55
+ ### Configure npm authentication
33
56
 
34
57
  The supplied release workflow uses GitHub's OIDC token through npm Trusted Publishers. The public GitHub source repository enables provenance in both `package.json` and the workflow, and the release check fails if either surface disables it. The same tag workflow creates an idempotent GitHub Release only after the npm registry check or publication succeeds.
35
58
 
36
- The workflow references a GitHub environment named `npm`. Create that environment for release protection, or remove the `environment` line when no environment gate is desired.
59
+ The workflow references a GitHub environment named `npm`. Configure that environment and its protection rules as part of bootstrap. Do not remove an existing approval gate merely to unblock a release.
37
60
 
38
61
  A classic or granular access token can be used for a manual first publication. Do not commit `.npmrc` credentials or an npm token. npm may still require browser-backed two-factor authentication.
39
62
 
40
- ## 4. Validate the exact package payload
41
-
42
- ```sh
43
- npm ci
44
- npm run check
45
- npm run release:check
46
- npm run smoke:pack
47
- npm pack --dry-run
48
- ```
49
-
50
- Inspect the tarball list. The package intentionally includes the CLI, source API, type declarations, schema, templates, documentation, the typed provider-neutral host example, README, changelog, roadmap, and license. Tests, benchmark harnesses, and development scripts remain in GitHub but are not installed into consumer projects. `npm run smoke:pack` verifies the exact tarball and example export in a clean temporary project.
51
-
52
- ## 5. Publish the first release
63
+ ### Manual first publication, only when required
53
64
 
54
65
  Manual publication:
55
66
 
@@ -58,22 +69,17 @@ npm login
58
69
  npm publish --provenance --access public
59
70
  ```
60
71
 
61
- Automated publication:
62
-
63
- ```sh
64
- git tag vX.Y.Z
65
- git push origin vX.Y.Z
66
- ```
72
+ ## Verify a published version from a clean directory
67
73
 
68
- The release workflow rejects a tag that does not match `package.json`. If the exact version is already present in npm, the workflow succeeds only when the registry tarball integrity matches the tagged package; a mismatched package fails closed. It then reuses an existing GitHub Release for the tag or creates one with generated release notes. Registry, GitHub API, and Release creation errors other than an expected missing Release fail the workflow.
74
+ Use an explicit published version instead of the moving default tag. The temporary project must be outside the source repository. Do not commit installed files or credentials.
69
75
 
70
- ## 6. Verify from a clean directory
76
+ From a source checkout, `npm run smoke:pack -- --published=X.Y.Z` runs the existing CLI/API/host assertions against that exact npm version in a temporary project with lifecycle scripts disabled. Without the option, it tests a locally packed tarball instead.
71
77
 
72
78
  ```sh
73
79
  mkdir llmnav-smoke
74
80
  cd llmnav-smoke
75
81
  npm init -y
76
- npm install --save-dev llmnav
82
+ npm install --save-dev --ignore-scripts llmnav@X.Y.Z
77
83
  npx llmnav --version
78
84
  npx llmnav init --agents all --package-scripts
79
85
  npx llmnav doctor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llmnav",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "A deterministic semantic navigation layer for LLM coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,6 +48,8 @@
48
48
  "release:tag": "node ./scripts/verify-tag.js",
49
49
  "test:performance": "node --test tests/performance.test.js",
50
50
  "benchmark:v0.2": "node ./benchmarks/run-v0.2.js",
51
+ "benchmark:navigation": "node ./benchmarks/navigation.js",
52
+ "benchmark:shards": "node ./benchmarks/search-shards.js",
51
53
  "smoke:pack": "node ./scripts/pack-smoke.js"
52
54
  },
53
55
  "keywords": [
@@ -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
@@ -32,6 +32,8 @@ Do not add hand-maintained \`calls\`, \`imports\`, \`references\`, \`implements\
32
32
 
33
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
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.
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}`;
37
39
 
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]),
@@ -197,13 +257,41 @@ export async function auditProject(root) {
197
257
  suppressedCandidates: dispositions.filter((disposition) => disposition.status === "suppressed").length,
198
258
  staleDispositions: dispositions.filter((disposition) => disposition.status === "stale").length,
199
259
  };
200
- return {
260
+ const result = {
201
261
  schemaVersion: AUDIT_SCHEMA_VERSION,
202
262
  repositoryId: project.config.repositoryId,
203
263
  summary,
204
264
  candidates: activeCandidates,
205
265
  dispositions,
206
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
+ };
207
295
  }
208
296
 
209
297
  function classifyStaleDisposition(file, fileByPath, exactCardPaths, coveredModules) {
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":
@@ -364,6 +367,44 @@ async function runAudit(root, args, json) {
364
367
  return auditHasFindings(result, failOn) ? 1 : 0;
365
368
  }
366
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
+
367
408
  function runSpec(json) {
368
409
  const spec = {
369
410
  specVersion: SPEC_VERSION,
@@ -522,6 +563,7 @@ Usage
522
563
  llmnav doctor
523
564
  llmnav migrate [--check|--write]
524
565
  llmnav audit [--summary] [--output path] [--fail-on none|high|medium|low]
566
+ llmnav explain <file>
525
567
  llmnav spec
526
568
  llmnav tools [--json]
527
569
  llmnav bundle [--json]
package/src/graph.js CHANGED
@@ -79,6 +79,7 @@ export function buildRepositoryGraphIncremental(project, index, previousState =
79
79
  }
80
80
 
81
81
  partitions.sort((left, right) => compareText(left.key, right.key));
82
+ const partitionKeys = new Set(partitions.map((partition) => partition.key));
82
83
  const nodes = new Map();
83
84
  const edges = new Map();
84
85
  for (const partition of partitions) {
@@ -115,7 +116,7 @@ export function buildRepositoryGraphIncremental(project, index, previousState =
115
116
  totalPartitions: partitions.length,
116
117
  reusedPartitions,
117
118
  rebuiltPartitions,
118
- removedPartitions: [...previousPartitions.keys()].filter((key) => !partitions.some((item) => item.key === key)).length,
119
+ removedPartitions: [...previousPartitions.keys()].filter((key) => !partitionKeys.has(key)).length,
119
120
  },
120
121
  };
121
122
  }
package/src/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export type LlmnavStability = "architecture" | "contract" | "implementation";
3
3
  export type DiagnosticSeverity = "error" | "warning" | "info";
4
4
  export type AuditPriority = "high" | "medium" | "low";
5
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";
6
7
 
7
8
  export interface LlmnavCard {
8
9
  scope: LlmnavScope;
@@ -119,6 +120,23 @@ export interface AuditResult {
119
120
  >;
120
121
  }
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
+ };
138
+ }
139
+
122
140
  export interface IndexedLocation {
123
141
  path: string;
124
142
  startLine: number;
@@ -213,6 +231,9 @@ export interface SearchMetrics {
213
231
  phraseDocumentsScanned?: number;
214
232
  idDocumentsScanned?: number;
215
233
  graphEdgesVisited?: number;
234
+ idNormalizations?: number;
235
+ lookupBuilds?: number;
236
+ graphValidations?: number;
216
237
  }
217
238
 
218
239
  export interface SearchResult {
@@ -226,7 +247,7 @@ export interface SearchResult {
226
247
 
227
248
  export interface ProjectSession {
228
249
  root: string;
229
- query(query: string, options?: { top?: number }): SearchResult[];
250
+ query(query: string, options?: { top?: number; metrics?: SearchMetrics }): SearchResult[];
230
251
  show(id: string): { card: IndexedCard | null; node: GraphNode | null; resolvedFrom: unknown };
231
252
  context(id: string, options?: { depth?: number; budget?: number; maxEdges?: number }): { id: string; depth: number; budget: number; maxEdges: number; included: string[]; includedEdges: string[]; text: string };
232
253
  refresh(): Promise<ProjectSession>;
@@ -297,7 +318,7 @@ export interface LlmnavConfig {
297
318
 
298
319
  export interface AgentToolDefinition {
299
320
  schemaVersion: 1;
300
- name: "llmnav_query" | "llmnav_show" | "llmnav_context" | "llmnav_check";
321
+ name: "llmnav_query" | "llmnav_show" | "llmnav_context" | "llmnav_check" | "llmnav_explain";
301
322
  description: string;
302
323
  inputSchema: {
303
324
  type: "object";
@@ -309,7 +330,7 @@ export interface AgentToolDefinition {
309
330
 
310
331
  export interface AgentOperationResult<T = unknown> {
311
332
  schemaVersion: 1;
312
- operation: "query" | "show" | "context" | "check" | "unknown";
333
+ operation: "query" | "show" | "context" | "check" | "explain" | "unknown";
313
334
  ok: boolean;
314
335
  data: T | null;
315
336
  error: { code: string; message: string } | null;
@@ -621,6 +642,7 @@ export interface EvaluationResult {
621
642
  export const AGENT_PROTOCOL: string;
622
643
  export const AUDIT_PRIORITIES: readonly AuditPriority[];
623
644
  export const AUDIT_SCHEMA_VERSION: 1;
645
+ export const FILE_EXPLANATION_SCHEMA_VERSION: 1;
624
646
  export const AGENT_OPERATION_SCHEMA_VERSION: 1;
625
647
  export const AGENT_TOOL_SCHEMA_VERSION: 1;
626
648
  export const BOUNDARY_KINDS: readonly DetectedBoundary["kind"][];
@@ -698,6 +720,7 @@ export function loadConfig(root: string): Promise<{ config: LlmnavConfig; config
698
720
  export function validateConfig(config: LlmnavConfig, configPath?: string): void;
699
721
  export function auditProject(root: string): Promise<AuditResult>;
700
722
  export function auditHasFindings(result: AuditResult, minimumPriority?: AuditPriority | "none"): boolean;
723
+ export function explainProjectFile(root: string, file: string): Promise<FileExplanationResult>;
701
724
  export function findAttachedDeclaration(source: string, block: LlmnavBlock, filePath: string): Declaration | null;
702
725
  export function extractImports(source: string, filePath: string): string[];
703
726
  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,
@@ -24,20 +24,26 @@ export function buildSearchShards(index, searchIndex, shardSize) {
24
24
  const cardsById = new Map(index.cards.map((card) => [card.id, card]));
25
25
  const shards = new Map();
26
26
  const records = [];
27
+ const partitions = Array.from({ length: Math.ceil(searchIndex.cardIds.length / shardSize) }, () => ({ tokens: [], postings: [] }));
28
+ for (const [tokenIndex, token] of searchIndex.tokens.entries()) {
29
+ const selectedByShard = new Map();
30
+ for (const [cardIndex, vector] of searchIndex.postings[tokenIndex] ?? []) {
31
+ if (!(cardIndex >= 0 && cardIndex < searchIndex.cardIds.length)) continue;
32
+ const ordinal = Math.floor(cardIndex / shardSize);
33
+ const selected = selectedByShard.get(ordinal) ?? [];
34
+ selected.push([cardIndex - ordinal * shardSize, vector]);
35
+ selectedByShard.set(ordinal, selected);
36
+ }
37
+ for (const [ordinal, selected] of selectedByShard) {
38
+ partitions[ordinal].tokens.push(token);
39
+ partitions[ordinal].postings.push(selected);
40
+ }
41
+ }
27
42
  for (let start = 0, ordinal = 0; start < searchIndex.cardIds.length; start += shardSize, ordinal += 1) {
28
43
  const end = Math.min(start + shardSize, searchIndex.cardIds.length);
29
44
  const cardIds = searchIndex.cardIds.slice(start, end);
30
45
  const cards = cardIds.map((id) => cardsById.get(id)).filter(Boolean);
31
- const tokens = [];
32
- const postings = [];
33
- for (const [tokenIndex, token] of searchIndex.tokens.entries()) {
34
- const selected = (searchIndex.postings[tokenIndex] ?? [])
35
- .filter(([cardIndex]) => cardIndex >= start && cardIndex < end)
36
- .map(([cardIndex, vector]) => [cardIndex - start, vector]);
37
- if (selected.length === 0) continue;
38
- tokens.push(token);
39
- postings.push(selected);
40
- }
46
+ const { tokens, postings } = partitions[ordinal];
41
47
  const shard = {
42
48
  ...searchIndex,
43
49
  cardSetHash: searchCardSetHash(cards),
@@ -49,6 +55,7 @@ export function buildSearchShards(index, searchIndex, shardSize) {
49
55
  };
50
56
  const file = `search-shards/${String(ordinal).padStart(4, "0")}.json`;
51
57
  const content = stableStringify(shard);
58
+ partitions[ordinal] = null;
52
59
  shards.set(file, content);
53
60
  records.push({
54
61
  file,
package/src/search.js CHANGED
@@ -23,15 +23,21 @@ import {
23
23
  verifySearchIndex,
24
24
  } from "./inverted-index.js";
25
25
  import { normalizeSearchText, tokenize } from "./tokenizer.js";
26
- import { recoverGenerationTransaction } from "./transaction.js";
26
+ import { recoverGenerationTransaction, withGenerationLock } from "./transaction.js";
27
27
  import { isCompatibleRepositoryGraph, renderGraphNode, resolveGraphNode } from "./graph.js";
28
28
 
29
29
  const preparedIndexCache = new WeakMap();
30
30
  const preparedSearchIndexCache = new WeakMap();
31
+ const sessionGraphAdjacencyCache = new WeakMap();
32
+ const sessionSearchMetadataCache = new WeakMap();
31
33
 
32
34
  export async function loadSearchData(root) {
35
+ return withGenerationLock(root, (lock) => loadSearchDataLocked(root, lock));
36
+ }
37
+
38
+ async function loadSearchDataLocked(root, lock) {
33
39
  const { config } = await loadConfig(root);
34
- await recoverGenerationTransaction(root, { cacheDirectory: config.generation.cacheDirectory });
40
+ await recoverGenerationTransaction(root, { cacheDirectory: config.generation.cacheDirectory, lockOwnerId: lock.ownerId });
35
41
  const cacheRoot = path.join(root, config.generation.cacheDirectory);
36
42
  await assertNoSymlinkTraversal(root, cacheRoot, config.generation.cacheDirectory);
37
43
  const indexPath = path.join(cacheRoot, "index.json");
@@ -91,34 +97,54 @@ export async function queryProject(root, query, options = {}) {
91
97
  }
92
98
 
93
99
  export async function createProjectSession(root) {
94
- let snapshot = await loadProjectSnapshot(root);
100
+ let snapshot = await loadSessionSnapshot(root);
95
101
  const session = {
96
102
  root,
97
103
  query(query, options = {}) {
98
- return queryPreparedIndex(snapshot.index, snapshot.searchIndex, query, {
104
+ return structuredClone(queryPreparedIndex(snapshot.index, snapshot.searchIndex, query, {
99
105
  ...options,
100
106
  lexicon: snapshot.lexicon,
101
107
  graph: snapshot.graph,
102
- });
108
+ }));
103
109
  },
104
110
  show(id) {
105
- return showSnapshotCard(snapshot, id);
111
+ return structuredClone(showSnapshotCard(snapshot, id));
106
112
  },
107
113
  context(id, options = {}) {
108
114
  return buildSnapshotContext(snapshot, id, options);
109
115
  },
110
116
  async refresh() {
111
- snapshot = await loadProjectSnapshot(root);
117
+ snapshot = await loadSessionSnapshot(root);
112
118
  return session;
113
119
  },
114
120
  };
115
121
  return session;
116
122
  }
117
123
 
124
+ async function loadSessionSnapshot(root) {
125
+ const snapshot = await loadProjectSnapshot(root);
126
+ // Only session-owned graphs are cached: public query inputs may be mutable.
127
+ if (snapshot.graph) sessionGraphAdjacencyCache.set(snapshot.graph, buildGraphAdjacency(snapshot.graph));
128
+ sessionSearchMetadataCache.set(snapshot.index, prepareSearchMetadata(snapshot.index, snapshot.lexicon, snapshot.graph));
129
+ return snapshot;
130
+ }
131
+
132
+ function prepareSearchMetadata(index, lexicon, graph) {
133
+ return {
134
+ byId: new Map(index.cards.map((card) => [card.id, card])),
135
+ cardOrder: new Map(index.cards.map((card, position) => [card.id, position])),
136
+ normalizedIds: index.cards.map((card) => normalizeSearchText(card.id)),
137
+ aliases: Object.entries(lexicon.aliases ?? {}).map(([alias, targets]) => [alias, targets, normalizeSearchText(alias)]),
138
+ graphCompatible: isCompatibleRepositoryGraph(graph, index.repositoryId),
139
+ };
140
+ }
141
+
118
142
  async function loadProjectSnapshot(root) {
119
- const { index, lexicon, searchIndex, graph } = await loadSearchData(root);
120
- const registry = await loadRegistry(root);
121
- return { index, lexicon, searchIndex, graph, registry };
143
+ return withGenerationLock(root, async (lock) => {
144
+ const { index, lexicon, searchIndex, graph } = await loadSearchDataLocked(root, lock);
145
+ const registry = await loadRegistry(root);
146
+ return { index, lexicon, searchIndex, graph, registry };
147
+ });
122
148
  }
123
149
 
124
150
  export function queryIndex(index, query, options = {}) {
@@ -139,11 +165,12 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
139
165
  const metrics = options.metrics ?? null;
140
166
  const normalizedQuery = normalizeSearchText(query);
141
167
  const queryTokens = tokenize(query);
142
- const aliases = Object.entries(lexicon.aliases ?? {});
168
+ const cached = sessionSearchMetadataCache.get(index);
169
+ const prepared = cached ?? prepareSearchMetadata(index, lexicon, options.graph);
170
+ const aliases = prepared.aliases;
143
171
  const aliasTargets = new Set();
144
172
  const aliasReasons = new Map();
145
- const byId = new Map(index.cards.map((card) => [card.id, card]));
146
- const cardOrder = new Map(index.cards.map((card, cardIndex) => [card.id, cardIndex]));
173
+ const { byId, cardOrder } = prepared;
147
174
  const resultsById = new Map();
148
175
 
149
176
  if (metrics) {
@@ -153,10 +180,12 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
153
180
  metrics.phraseDocumentsScanned = 0;
154
181
  metrics.idDocumentsScanned = 0;
155
182
  metrics.graphEdgesVisited = 0;
183
+ metrics.idNormalizations = cached ? 0 : index.cards.length;
184
+ metrics.lookupBuilds = cached ? 0 : 2;
185
+ metrics.graphValidations = cached ? 0 : 1;
156
186
  }
157
187
 
158
- for (const [alias, targetValue] of aliases) {
159
- const normalizedAlias = normalizeSearchText(alias);
188
+ for (const [alias, targetValue, normalizedAlias] of aliases) {
160
189
  if (!normalizedAlias || !normalizedQuery.includes(normalizedAlias)) continue;
161
190
  const targets = Array.isArray(targetValue) ? targetValue : [targetValue];
162
191
  for (const target of targets) {
@@ -167,11 +196,12 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
167
196
  }
168
197
  }
169
198
 
170
- for (const card of index.cards) {
199
+ for (const [position, card] of index.cards.entries()) {
171
200
  if (metrics) metrics.idDocumentsScanned += 1;
172
- if (normalizeSearchText(card.id) === normalizedQuery) {
201
+ const normalizedId = prepared.normalizedIds[position];
202
+ if (normalizedId === normalizedQuery) {
173
203
  addScore(resultsById, card, 1000, "exact semantic ID");
174
- } else if (normalizeSearchText(card.id).includes(normalizedQuery) && normalizedQuery.length > 2) {
204
+ } else if (normalizedId.includes(normalizedQuery) && normalizedQuery.length > 2) {
175
205
  addScore(resultsById, card, 100, "semantic ID phrase");
176
206
  }
177
207
  if (aliasTargets.has(card.id)) {
@@ -215,7 +245,7 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
215
245
  const seeds = [...results]
216
246
  .sort((left, right) => right.score - left.score || (cardOrder.get(left.card.id) ?? 0) - (cardOrder.get(right.card.id) ?? 0))
217
247
  .slice(0, 3);
218
- if (isCompatibleRepositoryGraph(options.graph, index.repositoryId)) {
248
+ if (prepared.graphCompatible) {
219
249
  applyGraphBonuses(index, options.graph, seeds, byId, resultsById, metrics);
220
250
  } else {
221
251
  applyLegacyRelationBonuses(seeds, byId, resultsById);
@@ -491,8 +521,9 @@ function buildLegacyContext(index, rootId, options) {
491
521
  const queue = [{ id: rootId, depth: 0 }];
492
522
  const visited = new Set();
493
523
  const selected = [];
494
- while (queue.length > 0) {
495
- const current = queue.shift();
524
+ let traversedEdges = 0;
525
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
526
+ const current = queue[cursor];
496
527
  if (!current || visited.has(current.id)) continue;
497
528
  visited.add(current.id);
498
529
  const card = byId.get(current.id);
@@ -500,10 +531,16 @@ function buildLegacyContext(index, rootId, options) {
500
531
  selected.push(card);
501
532
  if (current.depth >= depth) continue;
502
533
  for (const relation of card.rel ?? []) {
534
+ if (traversedEdges >= maxEdges) break;
503
535
  const target = relation.slice(relation.indexOf(">") + 1);
504
536
  queue.push({ id: target, depth: current.depth + 1 });
537
+ traversedEdges += 1;
538
+ }
539
+ for (const source of reverse.get(card.id) ?? []) {
540
+ if (traversedEdges >= maxEdges) break;
541
+ queue.push({ id: source, depth: current.depth + 1 });
542
+ traversedEdges += 1;
505
543
  }
506
- for (const source of reverse.get(card.id) ?? []) queue.push({ id: source, depth: current.depth + 1 });
507
544
  }
508
545
 
509
546
  const header = `llmnav-context/1 root=${rootId} depth=${depth}\n`;
@@ -575,6 +612,8 @@ function applyLegacyRelationBonuses(seeds, byId, resultsById) {
575
612
  }
576
613
 
577
614
  function buildGraphAdjacency(graph) {
615
+ const cached = sessionGraphAdjacencyCache.get(graph);
616
+ if (cached) return cached;
578
617
  const adjacency = new Map();
579
618
  for (const edge of [...graph.edges].sort(compareGraphEdgesForTraversal)) {
580
619
  appendGraphNeighbor(adjacency, edge.from, { neighbor: edge.to, direction: "out", edge });
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.8.0";
13
+ export const PACKAGE_VERSION = "0.9.1";
14
14
  export const SPEC_VERSION = "1";
15
15
 
16
16
  export const SCOPES = Object.freeze(["file", "module", "symbol"]);
@@ -9,7 +9,7 @@ stability=architecture
9
9
  */
10
10
 
11
11
  import { randomBytes } from "node:crypto";
12
- import { lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
12
+ import { link, lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import { setTimeout as sleep } from "node:timers/promises";
15
15
  import {
@@ -32,6 +32,7 @@ const DEFAULT_RETRY_DELAYS = Object.freeze([0, 8, 16, 32, 64, 128, 256, 512]);
32
32
  const JOURNAL_PHASES = new Set(["prepared", "old-moved", "new-installed", "committed"]);
33
33
  const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
34
34
  const DEFAULT_LOCK_POLL_MS = 50;
35
+ const LOCK_LINK_CAPABILITY_ERRORS = new Set(["ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV"]);
35
36
  const CONTROL_ARTIFACT_PATHS = new Set([".llmnav/ids.jsonl", ".llmnav/order.lock"]);
36
37
 
37
38
  export async function withGenerationLock(root, callback, options = {}) {
@@ -54,28 +55,50 @@ export async function acquireGenerationLock(root, options = {}) {
54
55
  const pollMs = options.pollMs ?? DEFAULT_LOCK_POLL_MS;
55
56
  const delays = options.delays ?? [0, ...Array.from({ length: Math.ceil(timeoutMs / pollMs) }, () => pollMs)];
56
57
  const openImpl = options.openImpl ?? open;
58
+ const linkImpl = options.linkImpl ?? link;
57
59
  const sleepImpl = options.sleepImpl ?? sleep;
58
-
59
- for (const delay of delays) {
60
- if (delay > 0) await sleepImpl(delay);
60
+ const candidatePath = `${lockPath}.tmp-${process.pid}-${randomBytes(8).toString("hex")}`;
61
+ // Publish only a closed, complete owner record. A crash before publication
62
+ // leaves an unused candidate, never an unreadable authoritative lock.
63
+ let candidateCreated = false;
64
+ try {
65
+ const handle = await openImpl(candidatePath, "wx");
66
+ candidateCreated = true;
61
67
  try {
62
- const handle = await openImpl(lockPath, "wx");
68
+ await handle.writeFile(stableStringify({ schemaVersion: 1, ownerId, pid: process.pid }));
69
+ await handle.sync();
70
+ } finally {
71
+ await handle.close();
72
+ }
73
+
74
+ for (const delay of delays) {
75
+ if (delay > 0) await sleepImpl(delay);
63
76
  try {
64
- await handle.writeFile(stableStringify({ schemaVersion: 1, ownerId, pid: process.pid }));
65
- await handle.sync();
66
- } finally {
67
- await handle.close();
68
- }
69
- return { root, lockPath, ownerId };
70
- } catch (error) {
71
- if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
72
- const existing = await readJsonSafe(lockPath, null);
73
- if (existing && Number.isInteger(existing.pid) && !isProcessAlive(existing.pid)) {
74
- await removeOwnedLock(lockPath, existing.ownerId);
77
+ await linkImpl(candidatePath, lockPath);
78
+ return { root, lockPath, ownerId };
79
+ } catch (error) {
80
+ if (error && typeof error === "object" && LOCK_LINK_CAPABILITY_ERRORS.has(error.code)) {
81
+ throw new Error(
82
+ `Cannot publish the LLMNav generation lock (${error.code}). The repository filesystem must support hard links and permit their creation. Check filesystem permissions or move the repository to a filesystem with hard-link support.`,
83
+ { cause: error },
84
+ );
85
+ }
86
+ if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
87
+ const existing = await readJsonSafe(lockPath, null);
88
+ if (existing && Number.isInteger(existing.pid) && !isProcessAlive(existing.pid)) {
89
+ await removeOwnedLock(lockPath, existing.ownerId);
90
+ }
75
91
  }
76
92
  }
93
+ const existing = await readJsonSafe(lockPath, null);
94
+ if (!existing || !Number.isInteger(existing.pid) || !existing.ownerId) {
95
+ throw new Error("The LLMNav generation lock has no valid owner record. After stopping all LLMNav processes, remove .llmnav/generation.lock and retry.");
96
+ }
97
+ throw new Error("Timed out waiting for the LLMNav generation lock.");
98
+ } finally {
99
+ // Candidate cleanup must not turn successful acquisition into a leaked lock.
100
+ if (candidateCreated) await removeWithRetry(candidatePath).catch(() => {});
77
101
  }
78
- throw new Error("Timed out waiting for the LLMNav generation lock.");
79
102
  }
80
103
 
81
104
  export async function releaseGenerationLock(lock) {