@stravica-ai/digital-operator-pa 0.1.59 → 0.1.60
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/dist/engineering-pa-cli/README.md +27 -3
- package/dist/engineering-pa-cli/src/knowledge/cli.mjs +64 -6
- package/dist/engineering-pa-cli/src/knowledge/graph.mjs +29 -5
- package/dist/engineering-pa-core/knowledge/platform-tooling/pa-cli-reference.md +1 -1
- package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +8 -17
- package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +16 -22
- package/dist/engineering-pa-core/src/knowledge/retrieval/links.mjs +161 -13
- package/dist/engineering-pa-core/src/knowledge/retrieval/mounts.mjs +184 -0
- package/package.json +1 -1
|
@@ -106,8 +106,8 @@ pa work list [--project <id>] [--status ...] [--category ...]
|
|
|
106
106
|
|
|
107
107
|
### `pa knowledge` — query the operator's knowledge graph
|
|
108
108
|
|
|
109
|
-
Reads `<repo-root>/operator/knowledge/.graph.json` and exposes five
|
|
110
|
-
for
|
|
109
|
+
Reads `<repo-root>/operator/knowledge/.graph.json` and exposes five query
|
|
110
|
+
verbs for the operator's knowledge system, plus `rebuild` for the graph itself. Feeds the third leg of the
|
|
111
111
|
PA operating model — knowledge retrieval discipline — so agents can
|
|
112
112
|
extract the right snippet at the right size instead of reading whole
|
|
113
113
|
files.
|
|
@@ -118,8 +118,31 @@ pa knowledge search <term> [--full] # find docs matching a term
|
|
|
118
118
|
pa knowledge domain <name> # all docs in a domain + internal edges
|
|
119
119
|
pa knowledge orphans [--exclude-stubs] # docs with no inbound edges
|
|
120
120
|
pa knowledge path <a> <b> # shortest path between two docs
|
|
121
|
+
pa knowledge rebuild [--format json] # delete and rebuild the graph; prints nodes, edges, ms
|
|
121
122
|
```
|
|
122
123
|
|
|
124
|
+
The graph is per-device and untracked. The first query builds it when it
|
|
125
|
+
is absent; nothing schedules a rebuild, so run `pa knowledge rebuild`
|
|
126
|
+
after the knowledge tree changes.
|
|
127
|
+
|
|
128
|
+
**Scan roots.** The home `operator/knowledge/` and `core/knowledge/`, then
|
|
129
|
+
each clone in `LIBRARY_KNOWLEDGE_EXTRA_ROOTS` (colon-separated clone
|
|
130
|
+
roots). A clone is matched to its registry entry by its `origin` remote
|
|
131
|
+
(normalised against `gitRemote`), else by its directory basename, using
|
|
132
|
+
the company clone's `registry/repos.json` when that clone is among the
|
|
133
|
+
roots, else the clone's own. For every mount whose `to` lies under
|
|
134
|
+
`operator/knowledge/`, `<clone>/<from>` is read as workspace ids under
|
|
135
|
+
`<to>`: hq and company `knowledge/` at `operator/knowledge/`, wsd
|
|
136
|
+
`knowledge/` at `operator/knowledge/wsd/`, operator `voice/` at
|
|
137
|
+
`operator/knowledge/writing/`. The legacy `<clone>/operator/knowledge/`
|
|
138
|
+
form is still read during the path migration. On a colliding id the home
|
|
139
|
+
copy wins, then the mapped copy (higher registry `precedence` first), then
|
|
140
|
+
the legacy copy. Within one clone that holds both forms of an id, the
|
|
141
|
+
legacy copy wins over the mapped one until the migration removes it: every
|
|
142
|
+
writer has written the legacy form since the 2026-08-21 cutover, so it is
|
|
143
|
+
the newer copy. A clone with no registry entry is read in the legacy form
|
|
144
|
+
only.
|
|
145
|
+
|
|
123
146
|
Global flag: `--format <table|json|mermaid>` (default: `table`). The
|
|
124
147
|
`json` shape is stable-by-convention — `{ "verb": "<name>", ... }` —
|
|
125
148
|
so downstream roles (e.g. `dispatch-briefer`) can pipe query output
|
|
@@ -136,7 +159,8 @@ ranking (title exact > title substring > body match, tie-break on
|
|
|
136
159
|
edge count). Zero-result queries exit `0` — empty is a valid answer.
|
|
137
160
|
|
|
138
161
|
**Exit codes**: `0` ok, `1` usage, `2` unknown/ambiguous slug or
|
|
139
|
-
unknown domain, `3` IO (graph missing / unreadable
|
|
162
|
+
unknown domain, `3` IO (graph missing / unreadable, or `rebuild` could
|
|
163
|
+
not write the artefacts).
|
|
140
164
|
|
|
141
165
|
### `pa staging-alias` — canonical staging.<site> alias for CF Workers sites
|
|
142
166
|
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// cli.mjs — argv router for `pa knowledge <verb>`.
|
|
2
2
|
//
|
|
3
|
-
// Verbs (
|
|
3
|
+
// Verbs (7):
|
|
4
4
|
// pa knowledge related <slug> [--in|--out]
|
|
5
5
|
// pa knowledge search <term> [--full] [--strict]
|
|
6
6
|
// pa knowledge domain <name>
|
|
7
7
|
// pa knowledge orphans [--exclude-stubs]
|
|
8
8
|
// pa knowledge path <a> <b>
|
|
9
9
|
// pa knowledge eval [--fixture PATH] [--out DIR] [--full] [--format ...]
|
|
10
|
+
// pa knowledge rebuild [--format table|json]
|
|
10
11
|
//
|
|
11
12
|
// Global flag:
|
|
12
13
|
// --format <table|json|mermaid> default: table
|
|
@@ -17,9 +18,12 @@
|
|
|
17
18
|
//
|
|
18
19
|
// Exit codes (tooling standard §6):
|
|
19
20
|
// 0 ok | 1 usage | 2 unknown-slug / ambiguous / unknown-domain
|
|
20
|
-
// 3 IO (graph missing or unreadable)
|
|
21
|
+
// 3 IO (graph missing or unreadable, or the rebuild failed)
|
|
21
22
|
|
|
22
|
-
import {
|
|
23
|
+
import { rm } from 'node:fs/promises';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { BACKLINKS_RELATIVE_PATH, GRAPH_RELATIVE_PATH, getRepoRoot, loadGraph, related, search, searchCascade, domain, orphans, path } from './graph.mjs';
|
|
23
27
|
import { formatRelated, formatSearch, formatDomain, formatOrphans, formatPath } from './formatters.mjs';
|
|
24
28
|
import { loadOrphanPolicy, applyOrphanPolicy } from './orphan-policy.mjs';
|
|
25
29
|
import { appendCallLog } from './call-log.mjs';
|
|
@@ -58,6 +62,10 @@ Usage:
|
|
|
58
62
|
[--out D] orphan gauge, delta vs last run);
|
|
59
63
|
[--full] persists to
|
|
60
64
|
[--format ...] operator/knowledge/_meta/retrieval-eval-runs/
|
|
65
|
+
pa knowledge rebuild Delete and rebuild the per-device graph
|
|
66
|
+
(.graph.json and .backlinks.json) over
|
|
67
|
+
the home tree and every extra scan root,
|
|
68
|
+
then print nodes, edges and milliseconds
|
|
61
69
|
pa knowledge -h, --help Show this help
|
|
62
70
|
|
|
63
71
|
Global flags:
|
|
@@ -73,9 +81,17 @@ Graph location: <repo-root>/operator/knowledge/.graph.json.
|
|
|
73
81
|
Repo-root resolution: \$DAVE_REPO_ROOT / \$PA_REPO_ROOT, else walk upward
|
|
74
82
|
from cwd looking for a .git entry.
|
|
75
83
|
|
|
76
|
-
The graph is a per-device artefact: untracked, and
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
The graph is a per-device artefact: untracked, and built locally. If it is
|
|
85
|
+
absent, the first query builds it on demand (one stderr line, then the
|
|
86
|
+
answer) rather than failing. Run \`pa knowledge rebuild\` to refresh it after
|
|
87
|
+
the knowledge tree changes; nothing schedules a rebuild.
|
|
88
|
+
|
|
89
|
+
Scan roots: the home operator/knowledge/ and core/knowledge/, then each
|
|
90
|
+
clone in \$LIBRARY_KNOWLEDGE_EXTRA_ROOTS (colon-separated). A clone with a
|
|
91
|
+
registry entry is read through its knowledge mounts (<clone>/<from> composed
|
|
92
|
+
at <to>, for every mount under operator/knowledge/), then in the legacy form
|
|
93
|
+
<clone>/operator/knowledge/. On a colliding id the home copy wins, then the
|
|
94
|
+
mapped copy, then the legacy copy.
|
|
79
95
|
|
|
80
96
|
JSON output shape (stable-by-convention — consumed by dispatch-briefer):
|
|
81
97
|
{ "verb": "<name>", ...verb-specific fields }
|
|
@@ -98,6 +114,7 @@ export async function run(argv) {
|
|
|
98
114
|
case 'domain': return await doDomain(rest);
|
|
99
115
|
case 'orphans': return await doOrphans(rest);
|
|
100
116
|
case 'path': return await doPath(rest);
|
|
117
|
+
case 'rebuild': return await doRebuild(rest);
|
|
101
118
|
case 'context': {
|
|
102
119
|
const { runContext } = await import('./context.mjs');
|
|
103
120
|
return await runContext(rest);
|
|
@@ -261,4 +278,45 @@ async function doPath(args) {
|
|
|
261
278
|
return 0;
|
|
262
279
|
}
|
|
263
280
|
|
|
281
|
+
async function doRebuild(args) {
|
|
282
|
+
const { positional, flags } = parseFlags(args);
|
|
283
|
+
const format = resolveFormat(flags);
|
|
284
|
+
if (positional.length > 0 || format === 'mermaid') {
|
|
285
|
+
process.stderr.write('usage: pa knowledge rebuild [--format table|json]\n');
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
const repoRoot = await getRepoRoot();
|
|
289
|
+
for (const rel of [GRAPH_RELATIVE_PATH, BACKLINKS_RELATIVE_PATH]) {
|
|
290
|
+
try {
|
|
291
|
+
await rm(join(repoRoot, ...rel.split('/')), { force: true });
|
|
292
|
+
} catch (e) {
|
|
293
|
+
process.stderr.write(`[pa knowledge] could not delete ${rel}: ${e.message}\n`);
|
|
294
|
+
return 3;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Same path as a first query on a missing graph: loadGraph builds it.
|
|
298
|
+
let built = null;
|
|
299
|
+
const silent = { write() { return true; } };
|
|
300
|
+
await loadGraph({ repoRoot, stderr: silent, onBuilt: (b) => { built = b; } });
|
|
301
|
+
if (!built) {
|
|
302
|
+
process.stderr.write('[pa knowledge] rebuild did not run: the graph was recreated by another process\n');
|
|
303
|
+
return 2;
|
|
304
|
+
}
|
|
305
|
+
if (format === 'json') {
|
|
306
|
+
process.stdout.write(JSON.stringify({
|
|
307
|
+
verb: 'rebuild',
|
|
308
|
+
graphPath: GRAPH_RELATIVE_PATH,
|
|
309
|
+
nodeCount: built.nodeCount,
|
|
310
|
+
edgeCount: built.edgeCount,
|
|
311
|
+
durationMs: built.durationMs,
|
|
312
|
+
}, null, 2) + '\n');
|
|
313
|
+
} else {
|
|
314
|
+
process.stdout.write(
|
|
315
|
+
`rebuilt ${GRAPH_RELATIVE_PATH}: ${built.nodeCount} nodes, ${built.edgeCount} edges, ${built.durationMs}ms\n`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
await logCall({ verb: 'rebuild', query: null, resultCount: built.nodeCount });
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
264
322
|
export { USAGE };
|
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
import { readFile } from 'node:fs/promises';
|
|
27
27
|
import { existsSync } from 'node:fs';
|
|
28
28
|
import { join, resolve, dirname } from 'node:path';
|
|
29
|
-
import { resolveExtraScanRoots } from '../../../engineering-pa-core/src/knowledge/retrieval/links.mjs';
|
|
29
|
+
import { locateScannedFile, resolveExtraScanRoots, scanSourcesFor } from '../../../engineering-pa-core/src/knowledge/retrieval/links.mjs';
|
|
30
30
|
|
|
31
|
-
const GRAPH_RELATIVE_PATH = 'operator/knowledge/.graph.json';
|
|
31
|
+
export const GRAPH_RELATIVE_PATH = 'operator/knowledge/.graph.json';
|
|
32
|
+
export const BACKLINKS_RELATIVE_PATH = 'operator/knowledge/.backlinks.json';
|
|
32
33
|
|
|
33
34
|
// --- loader ---------------------------------------------------------------
|
|
34
35
|
|
|
@@ -55,8 +56,11 @@ const GRAPH_RELATIVE_PATH = 'operator/knowledge/.graph.json';
|
|
|
55
56
|
// else, so building would silently answer a different question than the one
|
|
56
57
|
// asked. Those callers keep the exit-3 refusal.
|
|
57
58
|
//
|
|
58
|
-
// Set `autoBuild: false` to opt out entirely.
|
|
59
|
-
|
|
59
|
+
// Set `autoBuild: false` to opt out entirely. `onBuilt` receives the build
|
|
60
|
+
// summary (counts and duration) when this call built the artefact;
|
|
61
|
+
// `pa knowledge rebuild` deletes the artefacts and reads its numbers here,
|
|
62
|
+
// so the explicit rebuild and the self-heal are one code path.
|
|
63
|
+
export async function loadGraph({ repoRoot, graphPath, autoBuild = true, stderr = process.stderr, onBuilt = null } = {}) {
|
|
60
64
|
const path = graphPath ?? join(repoRoot, GRAPH_RELATIVE_PATH);
|
|
61
65
|
if (!existsSync(path)) {
|
|
62
66
|
const canBuild = autoBuild && Boolean(repoRoot) && graphPath == null;
|
|
@@ -71,8 +75,9 @@ export async function loadGraph({ repoRoot, graphPath, autoBuild = true, stderr
|
|
|
71
75
|
stderr.write(
|
|
72
76
|
`[pa knowledge] no graph at ${path} -- built on demand ` +
|
|
73
77
|
`(${built.nodeCount} nodes, ${built.edgeCount} edges, ${built.durationMs}ms); ` +
|
|
74
|
-
'retrieval artefacts are per-device
|
|
78
|
+
'retrieval artefacts are per-device; `pa knowledge rebuild` refreshes them\n',
|
|
75
79
|
);
|
|
80
|
+
if (onBuilt) onBuilt(built);
|
|
76
81
|
} catch (e) {
|
|
77
82
|
// Both errors, in one message: "graph missing" alone sends the reader
|
|
78
83
|
// looking for the artefact, when what they need to fix is why it could
|
|
@@ -635,6 +640,15 @@ function guidedMissHint(graph, term) {
|
|
|
635
640
|
return { nearestDomains: ranked.slice(0, 3).map(([d]) => d) };
|
|
636
641
|
}
|
|
637
642
|
|
|
643
|
+
// `search --full` reads one body per node; resolving the scan list asks git
|
|
644
|
+
// for each clone's remote, so resolve it once per root and env value.
|
|
645
|
+
const scanSourcesCache = new Map();
|
|
646
|
+
function cachedScanSources(repoRoot) {
|
|
647
|
+
const key = `${repoRoot}\n${process.env.LIBRARY_KNOWLEDGE_EXTRA_ROOTS ?? ''}`;
|
|
648
|
+
if (!scanSourcesCache.has(key)) scanSourcesCache.set(key, scanSourcesFor(repoRoot));
|
|
649
|
+
return scanSourcesCache.get(key);
|
|
650
|
+
}
|
|
651
|
+
|
|
638
652
|
async function readBody(repoRoot, id) {
|
|
639
653
|
// The id is either a path relative to the repo root, or a bare slug.
|
|
640
654
|
// We try repoRoot/<id> first, then repoRoot/operator/knowledge/<id>.
|
|
@@ -645,6 +659,16 @@ async function readBody(repoRoot, id) {
|
|
|
645
659
|
// under the home repoRoot. The extra scan roots — same env var the
|
|
646
660
|
// builders read — are checked in declared order after the home root, so
|
|
647
661
|
// `--full` body search still finds the file.
|
|
662
|
+
//
|
|
663
|
+
// Registry mounts (w-2026-10-16-dave-019): the builders' own scan list is
|
|
664
|
+
// tried first, so a mapped id (`<clone>/knowledge/x.md` composed at
|
|
665
|
+
// `operator/knowledge/x.md`) opens the same copy the graph indexed.
|
|
666
|
+
const scanned = locateScannedFile(cachedScanSources(repoRoot), id);
|
|
667
|
+
if (scanned) {
|
|
668
|
+
try {
|
|
669
|
+
return await readFile(scanned, 'utf8');
|
|
670
|
+
} catch { return null; }
|
|
671
|
+
}
|
|
648
672
|
const roots = [repoRoot, ...resolveExtraScanRoots()];
|
|
649
673
|
for (const root of roots) {
|
|
650
674
|
const candidates = [
|
|
@@ -17,7 +17,7 @@ The `pa` bin is the operator-facing top-level CLI. Every verb here is a subcomma
|
|
|
17
17
|
| `pa workspace <verb>` | Workspace server lifecycle (start / stop / status / restart / open). |
|
|
18
18
|
| `pa project <verb>` | Project state writes (create / status / doc add/rm/status / list). |
|
|
19
19
|
| `pa work <verb>` | Work-item state writes (create / status / list). |
|
|
20
|
-
| `pa knowledge <verb>` | Query the operator's knowledge graph (related / search / domain / orphans / path). |
|
|
20
|
+
| `pa knowledge <verb>` | Query the operator's knowledge graph (related / search / domain / orphans / path); `rebuild` deletes and rebuilds the per-device graph. |
|
|
21
21
|
| `pa autonomy <verb>` | Autonomy envelope get / set / list / init. |
|
|
22
22
|
| `pa environment [verb]` | Environment cache read / refresh / extended. |
|
|
23
23
|
| `pa lint <verb>` | Lint operator text against voice rules. |
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
|
|
26
26
|
import { readFile } from 'node:fs/promises';
|
|
27
27
|
import { realpathSync } from 'node:fs';
|
|
28
|
-
import { join, resolve, dirname
|
|
28
|
+
import { join, resolve, dirname } from 'node:path';
|
|
29
29
|
import { fileURLToPath } from 'node:url';
|
|
30
|
-
import { buildFileIndex, extractLinks, scanSourcesFor
|
|
30
|
+
import { buildFileIndex, collectScannedFiles, extractLinks, scanSourcesFor } from './links.mjs';
|
|
31
31
|
import { writeJsonAtomic } from './write-atomic.mjs';
|
|
32
32
|
import { findRepoRootSync } from '../../instance-root/find.mjs';
|
|
33
33
|
|
|
@@ -74,22 +74,13 @@ export async function buildBacklinkIndex({ root, coverage = null, extraRoots })
|
|
|
74
74
|
// files exist, so the whole tree is collected before any link is read.
|
|
75
75
|
//
|
|
76
76
|
// Sources: home root first, then env-supplied extra scan roots (Wave H,
|
|
77
|
-
// w-2026-08-24-dave-033)
|
|
78
|
-
//
|
|
79
|
-
//
|
|
77
|
+
// w-2026-08-24-dave-033), each in its registry-mapped form and then its
|
|
78
|
+
// legacy form (w-2026-10-16-dave-019). Each scan names the id prefix its
|
|
79
|
+
// files take, so ids are shape-compatible across the flat namespace. First
|
|
80
|
+
// in wins on collision: home, then mapped, then legacy, except that a
|
|
81
|
+
// clone's legacy copy beats its own mapped copy (`legacySupersedes`).
|
|
80
82
|
const sources = scanSourcesFor(root, { extraRoots });
|
|
81
|
-
const fileEntries =
|
|
82
|
-
const seenIds = new Set();
|
|
83
|
-
for (const { sourceRoot, scanDirs } of sources) {
|
|
84
|
-
for (const dir of scanDirs) {
|
|
85
|
-
for (const abs of await walkFiles(dir)) {
|
|
86
|
-
const id = toPosix(relative(sourceRoot, abs));
|
|
87
|
-
if (seenIds.has(id)) continue;
|
|
88
|
-
seenIds.add(id);
|
|
89
|
-
fileEntries.push({ abs, id });
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
83
|
+
const fileEntries = await collectScannedFiles(sources);
|
|
93
84
|
const fileIndex = buildFileIndex(fileEntries.map((e) => e.id));
|
|
94
85
|
|
|
95
86
|
// Pass 2 — build backlinks map: target -> [sources]
|
|
@@ -16,16 +16,17 @@
|
|
|
16
16
|
|
|
17
17
|
import { readFile } from 'node:fs/promises';
|
|
18
18
|
import { realpathSync } from 'node:fs';
|
|
19
|
-
import { join, resolve, dirname
|
|
19
|
+
import { join, resolve, dirname } from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import {
|
|
22
22
|
buildFileIndex,
|
|
23
23
|
buildIndexHubs,
|
|
24
|
+
collectScannedFiles,
|
|
24
25
|
deriveDomain,
|
|
25
26
|
extractLinks,
|
|
26
27
|
isIndexJson,
|
|
28
|
+
scanIdFor,
|
|
27
29
|
scanSourcesFor,
|
|
28
|
-
toPosix,
|
|
29
30
|
walkFiles,
|
|
30
31
|
} from './links.mjs';
|
|
31
32
|
import { writeJsonAtomic } from './write-atomic.mjs';
|
|
@@ -94,33 +95,26 @@ function reportUnresolved(label, entries) {
|
|
|
94
95
|
*/
|
|
95
96
|
export async function buildKnowledgeGraph({ root, coverage = null, extraRoots }) {
|
|
96
97
|
// Sources: home root first, then each extra scan root (env-supplied by
|
|
97
|
-
// default)
|
|
98
|
-
//
|
|
99
|
-
// same flat namespace as home.
|
|
98
|
+
// default) in its registry-mapped form, then in its legacy form. Each scan
|
|
99
|
+
// names the id prefix its files take, so ids are still shaped
|
|
100
|
+
// `operator/knowledge/...` and merge into the same flat namespace as home.
|
|
101
|
+
// First in wins on collision: home, then mapped, then legacy, except that a
|
|
102
|
+
// clone's legacy copy beats its own mapped copy (`legacySupersedes`).
|
|
100
103
|
const sources = scanSourcesFor(root, { extraRoots });
|
|
101
104
|
|
|
102
105
|
// ── Pass 1: enumerate the tree ────────────────────────────────────────
|
|
103
106
|
// Resolution needs the whole file set before any link is interpreted —
|
|
104
107
|
// `[[slug]]` cannot be resolved without knowing which files exist.
|
|
105
108
|
//
|
|
106
|
-
// Each entry pairs the on-disk path with
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
109
|
+
// Each entry pairs the on-disk path with its workspace id, so pass 2 can
|
|
110
|
+
// read the file from disk while emitting the id. Ids that collide keep the
|
|
111
|
+
// first occurrence (home, then mapped, then legacy), except that a clone's
|
|
112
|
+
// legacy copy replaces its own mapped copy (see collectScannedFiles).
|
|
113
|
+
const fileEntries = await collectScannedFiles(sources);
|
|
110
114
|
const indexEntries = [];
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
for (const abs of await walkFiles(dir)) {
|
|
115
|
-
const id = toPosix(relative(sourceRoot, abs));
|
|
116
|
-
if (seenIds.has(id)) continue;
|
|
117
|
-
seenIds.add(id);
|
|
118
|
-
fileEntries.push({ abs, id });
|
|
119
|
-
}
|
|
120
|
-
for (const abs of await walkFiles(dir, isIndexJson)) {
|
|
121
|
-
const id = toPosix(relative(sourceRoot, abs));
|
|
122
|
-
indexEntries.push({ abs, id });
|
|
123
|
-
}
|
|
115
|
+
for (const scan of sources) {
|
|
116
|
+
for (const abs of await walkFiles(scan.dir, isIndexJson)) {
|
|
117
|
+
indexEntries.push({ abs, id: scanIdFor(scan, abs) });
|
|
124
118
|
}
|
|
125
119
|
}
|
|
126
120
|
const fileIndex = buildFileIndex(fileEntries.map((e) => e.id));
|
|
@@ -33,8 +33,11 @@
|
|
|
33
33
|
// nowhere does not describe the estate — it inflates it, and every orphan
|
|
34
34
|
// statistic computed over it is wrong in both directions.
|
|
35
35
|
|
|
36
|
+
import { existsSync } from 'node:fs';
|
|
36
37
|
import { readdir } from 'node:fs/promises';
|
|
37
|
-
import { join, normalize, posix } from 'node:path';
|
|
38
|
+
import { join, normalize, posix, relative } from 'node:path';
|
|
39
|
+
|
|
40
|
+
import { resolveTierMounts } from './mounts.mjs';
|
|
38
41
|
|
|
39
42
|
// Knowledge trees the builders scan, repo-root-relative.
|
|
40
43
|
export const KNOWLEDGE_SCAN_DIRS = ['operator/knowledge', 'core/knowledge'];
|
|
@@ -57,6 +60,15 @@ export const KNOWLEDGE_SCAN_DIRS = ['operator/knowledge', 'core/knowledge'];
|
|
|
57
60
|
// matches the retirement's repatriation rule ("HOME-REPO copy wins for
|
|
58
61
|
// estate-native classes"); the intent is that canonical docs only ever live
|
|
59
62
|
// on ONE side, and any collision is a symptom that needs resolving upstream.
|
|
63
|
+
//
|
|
64
|
+
// Registry mounts (w-2026-10-16-dave-019): an extra root that is a tier clone
|
|
65
|
+
// is also read through its registry knowledge mounts, so `<clone>/knowledge/`
|
|
66
|
+
// composes at `operator/knowledge/` (or `operator/knowledge/wsd/`, and
|
|
67
|
+
// `<clone>/voice/` at `operator/knowledge/writing/`) exactly as the registry
|
|
68
|
+
// says. Across clones a mapped copy beats a legacy one; within one clone the
|
|
69
|
+
// legacy `<clone>/operator/knowledge/` copy beats the mapped copy during the
|
|
70
|
+
// migration bridge (`legacySupersedes`); home still beats both. See
|
|
71
|
+
// `scanSourcesFor` and mounts.mjs.
|
|
60
72
|
export const EXTRA_SCAN_ROOTS_ENV = 'LIBRARY_KNOWLEDGE_EXTRA_ROOTS';
|
|
61
73
|
|
|
62
74
|
/**
|
|
@@ -165,28 +177,164 @@ export function scanDirsFor(root) {
|
|
|
165
177
|
}
|
|
166
178
|
|
|
167
179
|
/**
|
|
168
|
-
* Full scan
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* disk the file lives).
|
|
180
|
+
* Full scan list, in collision-precedence order. Each entry names one
|
|
181
|
+
* directory to walk and the workspace-id prefix its files take, so every
|
|
182
|
+
* caller derives ids the same way (`scanIdFor`) and they stay shaped
|
|
183
|
+
* `operator/knowledge/...` regardless of where on disk the file lives.
|
|
173
184
|
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
185
|
+
* Order (first wins on a colliding id):
|
|
186
|
+
* 1. the home workspace root (`operator/knowledge/`, `core/knowledge/`);
|
|
187
|
+
* 2. the MAPPED form of every extra root that resolves to a registry entry:
|
|
188
|
+
* `<clone>/<from>` read as `<to>` for each mount whose `to` lies under
|
|
189
|
+
* `operator/knowledge/` (see mounts.mjs). Higher registry `precedence`
|
|
190
|
+
* first, declared order on a tie;
|
|
191
|
+
* 3. the LEGACY form of every extra root, `<clone>/operator/knowledge/` and
|
|
192
|
+
* `<clone>/core/knowledge/`, in declared order. Home and legacy ids are
|
|
193
|
+
* exactly the ids this function produced before mounts were read.
|
|
194
|
+
*
|
|
195
|
+
* So the home copy beats both forms, and a mapped copy beats another clone's
|
|
196
|
+
* legacy copy of the same id. Within ONE clone the legacy copy beats the
|
|
197
|
+
* mapped one; the order cannot say that, so the collision step applies it
|
|
198
|
+
* (`legacySupersedes`, `collectScannedFiles`). A clone with no registry entry
|
|
199
|
+
* contributes its legacy form only.
|
|
176
200
|
*
|
|
177
201
|
* @param {string} root the home workspace root
|
|
178
202
|
* @param {object} [opts]
|
|
179
203
|
* @param {NodeJS.ProcessEnv} [opts.env] defaults to process.env
|
|
180
204
|
* @param {string[]} [opts.extraRoots] override the env-resolved list (tests)
|
|
181
|
-
* @
|
|
205
|
+
* @param {(root: string) => string|null} [opts.remoteOf] override the git
|
|
206
|
+
* remote lookup (tests)
|
|
207
|
+
* @returns {{ sourceRoot: string, dir: string, idPrefix: string,
|
|
208
|
+
* form: 'home'|'mapped'|'legacy', repo: string|null }[]}
|
|
182
209
|
*/
|
|
183
210
|
export function scanSourcesFor(root, opts = {}) {
|
|
184
211
|
const extras = opts.extraRoots ?? resolveExtraScanRoots(opts.env);
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
212
|
+
const legacyScans = (sourceRoot, form, repo = null) => KNOWLEDGE_SCAN_DIRS.map((rel) => ({
|
|
213
|
+
sourceRoot,
|
|
214
|
+
dir: join(sourceRoot, ...rel.split('/')),
|
|
215
|
+
idPrefix: `${rel}/`,
|
|
216
|
+
form,
|
|
217
|
+
repo,
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
const scans = legacyScans(root, 'home');
|
|
221
|
+
const tiers = extras.length ? resolveTierMounts(extras, { remoteOf: opts.remoteOf }) : [];
|
|
222
|
+
|
|
223
|
+
const mapped = tiers
|
|
224
|
+
.map((tier, order) => ({ tier, order }))
|
|
225
|
+
.filter(({ tier }) => tier.mounts.length > 0)
|
|
226
|
+
.sort((a, b) => (b.tier.precedence - a.tier.precedence) || (a.order - b.order));
|
|
227
|
+
for (const { tier } of mapped) {
|
|
228
|
+
for (const { from, to } of tier.mounts) {
|
|
229
|
+
scans.push({
|
|
230
|
+
sourceRoot: tier.root,
|
|
231
|
+
dir: join(tier.root, ...from.split('/').filter(Boolean)),
|
|
232
|
+
idPrefix: to,
|
|
233
|
+
form: 'mapped',
|
|
234
|
+
repo: tier.repo,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// slice 3 removes: the legacy `<clone>/operator/knowledge/` scan is the
|
|
240
|
+
// migration bridge for docs written at the workspace path (811 rows in the
|
|
241
|
+
// w-2026-10-16-dave-019 sizing). Drop it once those rows are re-put.
|
|
242
|
+
for (const tier of tiers) scans.push(...legacyScans(tier.root, 'legacy', tier.repo));
|
|
243
|
+
|
|
244
|
+
// An identity mount `operator/knowledge/ -> operator/knowledge/` would
|
|
245
|
+
// repeat its legacy scan; keep the first (mapped) occurrence only.
|
|
246
|
+
const seen = new Set();
|
|
247
|
+
return scans.filter((s) => {
|
|
248
|
+
const key = `${s.dir}\0${s.idPrefix}`;
|
|
249
|
+
if (seen.has(key)) return false;
|
|
250
|
+
seen.add(key);
|
|
251
|
+
return true;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Workspace id of a file found under `scan.dir`. */
|
|
256
|
+
export function scanIdFor(scan, abs) {
|
|
257
|
+
return scan.idPrefix + toPosix(relative(scan.dir, abs));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// slice 3 removes: the within-tier bridge rule. The scan order cannot carry
|
|
261
|
+
// it, because "legacy first within a clone" and "mapped first across clones"
|
|
262
|
+
// are not one order: with copies in A legacy, A mapped and B mapped (A of
|
|
263
|
+
// higher precedence) the two rules cycle. So the order picks the winning
|
|
264
|
+
// TIER (first occurrence: home, mapped by precedence, legacy), and this rule
|
|
265
|
+
// then swaps a mapped winner for the same clone's legacy copy. Every writer
|
|
266
|
+
// wrote the legacy form after the 2026-08-21 cutover, so where one clone
|
|
267
|
+
// holds both, the legacy copy is the newer (all 25 hq merge rows).
|
|
268
|
+
/**
|
|
269
|
+
* True when `candidate` (met later in scan order) should replace `winner`,
|
|
270
|
+
* the scan that first claimed the same id.
|
|
271
|
+
*
|
|
272
|
+
* @param {ReturnType<typeof scanSourcesFor>[number]} winner
|
|
273
|
+
* @param {ReturnType<typeof scanSourcesFor>[number]} candidate
|
|
274
|
+
*/
|
|
275
|
+
export function legacySupersedes(winner, candidate) {
|
|
276
|
+
return winner.form === 'mapped' && candidate.form === 'legacy'
|
|
277
|
+
&& winner.sourceRoot === candidate.sourceRoot;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Walk every scan and keep one file per workspace id: the first occurrence,
|
|
282
|
+
* except that a clone's legacy copy replaces that clone's mapped copy
|
|
283
|
+
* (`legacySupersedes`). A replaced entry keeps its position, so the entry
|
|
284
|
+
* order is still scan order of first occurrence.
|
|
285
|
+
*
|
|
286
|
+
* @param {ReturnType<typeof scanSourcesFor>} scans
|
|
287
|
+
* @returns {Promise<{ abs: string, id: string, scan: object }[]>}
|
|
288
|
+
*/
|
|
289
|
+
export async function collectScannedFiles(scans) {
|
|
290
|
+
const entries = [];
|
|
291
|
+
const byId = new Map();
|
|
292
|
+
for (const scan of scans) {
|
|
293
|
+
for (const abs of await walkFiles(scan.dir)) {
|
|
294
|
+
const id = scanIdFor(scan, abs);
|
|
295
|
+
const existing = byId.get(id);
|
|
296
|
+
if (!existing) {
|
|
297
|
+
const entry = { abs, id, scan };
|
|
298
|
+
byId.set(id, entry);
|
|
299
|
+
entries.push(entry);
|
|
300
|
+
} else if (legacySupersedes(existing.scan, scan)) {
|
|
301
|
+
existing.abs = abs;
|
|
302
|
+
existing.scan = scan;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return entries;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The on-disk file that backs a workspace id, under the same precedence the
|
|
311
|
+
* builders apply (including `legacySupersedes`), or null. Lets body readers (`pa knowledge search --full`)
|
|
312
|
+
* open the copy the graph actually indexed.
|
|
313
|
+
*
|
|
314
|
+
* @param {ReturnType<typeof scanSourcesFor>} scans
|
|
315
|
+
* @param {string} id
|
|
316
|
+
* @returns {string|null}
|
|
317
|
+
*/
|
|
318
|
+
export function locateScannedFile(scans, id) {
|
|
319
|
+
const posixId = toPosix(id);
|
|
320
|
+
const fileIn = (scan) => {
|
|
321
|
+
if (!posixId.startsWith(scan.idPrefix)) return null;
|
|
322
|
+
const rest = posixId.slice(scan.idPrefix.length);
|
|
323
|
+
if (!rest || escapesRoot(posix.normalize(rest))) return null;
|
|
324
|
+
const candidate = join(scan.dir, ...rest.split('/'));
|
|
325
|
+
return existsSync(candidate) ? candidate : null;
|
|
326
|
+
};
|
|
327
|
+
for (const [i, scan] of scans.entries()) {
|
|
328
|
+
const found = fileIn(scan);
|
|
329
|
+
if (!found) continue;
|
|
330
|
+
for (const later of scans.slice(i + 1)) {
|
|
331
|
+
if (!legacySupersedes(scan, later)) continue;
|
|
332
|
+
const legacy = fileIn(later);
|
|
333
|
+
if (legacy) return legacy;
|
|
334
|
+
}
|
|
335
|
+
return found;
|
|
188
336
|
}
|
|
189
|
-
return
|
|
337
|
+
return null;
|
|
190
338
|
}
|
|
191
339
|
|
|
192
340
|
// ── File index ──────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// mounts.mjs: registry knowledge mounts for the extra scan roots.
|
|
2
|
+
//
|
|
3
|
+
// Each tier clone named in LIBRARY_KNOWLEDGE_EXTRA_ROOTS is a library repo
|
|
4
|
+
// whose registry entry (`registry/repos.json`, `repos.<name>.mounts[]`) says
|
|
5
|
+
// which of its directories compose where in the workspace. The reader used to
|
|
6
|
+
// ignore that and scan only `<clone>/operator/knowledge/`, so the correctly
|
|
7
|
+
// mounted trees (`knowledge/ -> operator/knowledge/`,
|
|
8
|
+
// `knowledge/ -> operator/knowledge/wsd/`, `voice/ -> operator/knowledge/writing/`)
|
|
9
|
+
// were invisible while the mis-pathed legacy copies were visible
|
|
10
|
+
// (w-2026-10-16-dave-019 sizing).
|
|
11
|
+
//
|
|
12
|
+
// This module resolves each clone to its registry entry and returns the
|
|
13
|
+
// knowledge mounts the reader must apply: every mount whose `to` lies under
|
|
14
|
+
// `operator/knowledge/`. Mounts that target anything else are ignored.
|
|
15
|
+
//
|
|
16
|
+
// Resolution order for a clone:
|
|
17
|
+
// 1. its `origin` remote, normalised, against each entry's `gitRemote`;
|
|
18
|
+
// 2. else the clone directory basename against the repo key.
|
|
19
|
+
// Registry source order:
|
|
20
|
+
// 1. the company clone's `registry/repos.json`, when the company clone is
|
|
21
|
+
// among the extra roots (it is the clone whose own entry mounts
|
|
22
|
+
// `registry/`, the registry source of truth);
|
|
23
|
+
// 2. else the clone's own `registry/repos.json`;
|
|
24
|
+
// 3. else no registry: the clone is scanned in the legacy form only.
|
|
25
|
+
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { basename, join, resolve } from 'node:path';
|
|
29
|
+
|
|
30
|
+
export const KNOWLEDGE_MOUNT_PREFIX = 'operator/knowledge/';
|
|
31
|
+
export const REGISTRY_RELATIVE_PATH = 'registry/repos.json';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalise a git remote URL to `host/owner/repo` (lowercase, no `.git`), so
|
|
35
|
+
* `git@github.com:Stravica/x.git`, `ssh://git@github.com/Stravica/x` and
|
|
36
|
+
* `https://github.com/Stravica/x.git` compare equal.
|
|
37
|
+
*
|
|
38
|
+
* @param {string|null|undefined} url
|
|
39
|
+
* @returns {string|null}
|
|
40
|
+
*/
|
|
41
|
+
export function normaliseRemote(url) {
|
|
42
|
+
if (typeof url !== 'string') return null;
|
|
43
|
+
let s = url.trim();
|
|
44
|
+
if (!s) return null;
|
|
45
|
+
s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
|
|
46
|
+
s = s.replace(/^[^@/]+@/, '');
|
|
47
|
+
// scp-like `host:owner/repo`; a Windows drive letter is not a host.
|
|
48
|
+
s = s.replace(/^([^/:]{2,}):(?!\/)/, '$1/');
|
|
49
|
+
s = s.replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
50
|
+
return s.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read the clone's `origin` URL, or null when it is not a git clone root. */
|
|
54
|
+
export function gitRemoteOf(root) {
|
|
55
|
+
// A directory below some other repo would report that repo's remote, so
|
|
56
|
+
// only ask git when the root itself carries the `.git` entry.
|
|
57
|
+
if (!existsSync(join(root, '.git'))) return null;
|
|
58
|
+
try {
|
|
59
|
+
return execFileSync('git', ['-C', root, 'remote', 'get-url', 'origin'], {
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
62
|
+
timeout: 5000,
|
|
63
|
+
}).trim() || null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parse `<root>/registry/repos.json`; null when absent or unreadable. */
|
|
70
|
+
export function readRegistry(root) {
|
|
71
|
+
const path = join(root, ...REGISTRY_RELATIVE_PATH.split('/'));
|
|
72
|
+
if (!existsSync(path)) return null;
|
|
73
|
+
try {
|
|
74
|
+
const json = JSON.parse(readFileSync(path, 'utf8'));
|
|
75
|
+
if (!json || typeof json.repos !== 'object' || json.repos === null) return null;
|
|
76
|
+
return json;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Find the registry entry for a clone: remote first, basename second.
|
|
84
|
+
*
|
|
85
|
+
* @returns {{ name: string, entry: object, via: 'remote'|'basename' } | null}
|
|
86
|
+
*/
|
|
87
|
+
export function resolveRepoEntry(registry, root, { remoteOf = gitRemoteOf } = {}) {
|
|
88
|
+
if (!registry || !registry.repos) return null;
|
|
89
|
+
const remote = normaliseRemote(remoteOf(root));
|
|
90
|
+
if (remote) {
|
|
91
|
+
for (const [name, entry] of Object.entries(registry.repos)) {
|
|
92
|
+
if (normaliseRemote(entry?.gitRemote) === remote) return { name, entry, via: 'remote' };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const base = basename(resolve(root));
|
|
96
|
+
const entry = registry.repos[base];
|
|
97
|
+
if (entry && typeof entry === 'object') return { name: base, entry, via: 'basename' };
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const withSlash = (p) => (p.endsWith('/') ? p : `${p}/`);
|
|
102
|
+
|
|
103
|
+
function isSafeRelative(p) {
|
|
104
|
+
return !p.startsWith('/') && !p.split('/').includes('..');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The mounts the knowledge reader applies: `to` under `operator/knowledge/`,
|
|
109
|
+
* `from` a safe clone-relative directory. Both come back with a trailing `/`.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} entry a `repos.<name>` registry entry
|
|
112
|
+
* @returns {{ from: string, to: string }[]}
|
|
113
|
+
*/
|
|
114
|
+
export function knowledgeMounts(entry) {
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const m of Array.isArray(entry?.mounts) ? entry.mounts : []) {
|
|
117
|
+
if (typeof m?.from !== 'string' || typeof m?.to !== 'string') continue;
|
|
118
|
+
const from = withSlash(m.from.replace(/^\.\//, ''));
|
|
119
|
+
const to = withSlash(m.to.replace(/^\.\//, ''));
|
|
120
|
+
if (!to.startsWith(KNOWLEDGE_MOUNT_PREFIX)) continue;
|
|
121
|
+
if (!isSafeRelative(from) || !isSafeRelative(to)) continue;
|
|
122
|
+
out.push({ from, to });
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function mountsRegistry(entry) {
|
|
128
|
+
return (Array.isArray(entry?.mounts) ? entry.mounts : [])
|
|
129
|
+
.some((m) => typeof m?.from === 'string' && withSlash(m.from) === 'registry/');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve every extra root to its tier and knowledge mounts.
|
|
134
|
+
*
|
|
135
|
+
* @param {string[]} extraRoots
|
|
136
|
+
* @param {object} [opts]
|
|
137
|
+
* @param {(root: string) => string|null} [opts.remoteOf] injectable for tests
|
|
138
|
+
* @returns {{
|
|
139
|
+
* root: string,
|
|
140
|
+
* repo: string|null,
|
|
141
|
+
* via: 'remote'|'basename'|null,
|
|
142
|
+
* registrySource: 'company'|'own'|null,
|
|
143
|
+
* precedence: number,
|
|
144
|
+
* mounts: { from: string, to: string }[],
|
|
145
|
+
* }[]}
|
|
146
|
+
*/
|
|
147
|
+
export function resolveTierMounts(extraRoots, { remoteOf = gitRemoteOf } = {}) {
|
|
148
|
+
const own = new Map();
|
|
149
|
+
for (const root of extraRoots) own.set(root, readRegistry(root));
|
|
150
|
+
|
|
151
|
+
// The company clone is the registry's own source: its entry mounts
|
|
152
|
+
// `registry/`. Prefer its copy over each clone's (possibly drifted) one.
|
|
153
|
+
let company = null;
|
|
154
|
+
for (const root of extraRoots) {
|
|
155
|
+
const registry = own.get(root);
|
|
156
|
+
const self = resolveRepoEntry(registry, root, { remoteOf });
|
|
157
|
+
if (self && mountsRegistry(self.entry)) { company = registry; break; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return extraRoots.map((root) => {
|
|
161
|
+
let hit = null;
|
|
162
|
+
let registrySource = null;
|
|
163
|
+
if (company) {
|
|
164
|
+
hit = resolveRepoEntry(company, root, { remoteOf });
|
|
165
|
+
if (hit) registrySource = 'company';
|
|
166
|
+
}
|
|
167
|
+
if (!hit && own.get(root)) {
|
|
168
|
+
hit = resolveRepoEntry(own.get(root), root, { remoteOf });
|
|
169
|
+
if (hit) registrySource = 'own';
|
|
170
|
+
}
|
|
171
|
+
if (!hit) {
|
|
172
|
+
return { root, repo: null, via: null, registrySource: null, precedence: 0, mounts: [] };
|
|
173
|
+
}
|
|
174
|
+
const precedence = Number.isFinite(hit.entry.precedence) ? hit.entry.precedence : 0;
|
|
175
|
+
return {
|
|
176
|
+
root,
|
|
177
|
+
repo: hit.name,
|
|
178
|
+
via: hit.via,
|
|
179
|
+
registrySource,
|
|
180
|
+
precedence,
|
|
181
|
+
mounts: knowledgeMounts(hit.entry),
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
package/package.json
CHANGED