@stravica-ai/digital-operator-pa 0.1.58 → 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.
@@ -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 verbs
110
- for querying the operator's knowledge system. Feeds the third leg of the
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 (6):
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 { getRepoRoot, loadGraph, related, search, searchCascade, domain, orphans, path } from './graph.mjs';
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 rebuilt locally by
77
- library-sync-cycle's graph-rebuild leg. If it is absent, the first query
78
- rebuilds it on demand (one stderr line, then the answer) rather than failing.
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
- export async function loadGraph({ repoRoot, graphPath, autoBuild = true, stderr = process.stderr } = {}) {
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 and normally rebuilt by library-sync-cycle\n',
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 = [
@@ -181,8 +181,20 @@ export function redactBashCommand(cmd) {
181
181
  // user:secret@host URL credential (do this BEFORE the KEY=VALUE pass
182
182
  // so the scheme:// does not accidentally feed the key regex).
183
183
  out = out.replace(/(https?:\/\/[^:/\s@]+):([^@\s'"`]+)@/gi, '$1:<redacted>@');
184
- // Authorization header value (quoted or unquoted, Bearer or raw).
185
- out = out.replace(/(Authorization\s*:\s*)(?:Bearer\s+)?([^\s'"`,;)]+)/gi, '$1<redacted>');
184
+ // Authorization header value (any scheme: Bearer, token, Basic,
185
+ // Digest, or an unknown word; and the raw-value no-scheme form). Three
186
+ // passes handle the single-quoted, double-quoted, and unquoted header
187
+ // forms. The quoted passes consume up to the closing quote so secrets
188
+ // that contain whitespace or inner punctuation (token schemes, Basic
189
+ // base64, Digest comma-separated field lists with inner "quoted"
190
+ // fields) never leak past the scheme word. 0.1.58 only redacted the
191
+ // scheme word for non-Bearer schemes, so `Authorization: token <x>`
192
+ // landed in the log as `Authorization: <redacted> <x>` (Dex's 0.1.58
193
+ // verification). 0.1.59 extends the redaction to the full header
194
+ // value.
195
+ out = out.replace(/'([^'\n]*?Authorization\s*:\s*)[^'\n]+(?=')/gi, (_m, pre) => `'${pre}<redacted>`);
196
+ out = out.replace(/"([^"\n]*?Authorization\s*:\s*)[^"\n]+(?=")/gi, (_m, pre) => `"${pre}<redacted>`);
197
+ out = out.replace(/(Authorization\s*:\s*)[^\s'"`,;)]+(?:\s+[^\s'"`,;)]+)?/gi, '$1<redacted>');
186
198
  // --token / --token= and token=
187
199
  out = out.replace(/(--?token[\s=]+)([^\s'"`;)]+)/gi, '$1<redacted>');
188
200
  // NAME=value (env-style). Match a word boundary that is not preceded
@@ -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, relative } from 'node:path';
28
+ import { join, resolve, dirname } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
- import { buildFileIndex, extractLinks, scanSourcesFor, toPosix, walkFiles } from './links.mjs';
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). Files are relativised against THEIR source root
78
- // so ids are shape-compatible across the flat namespace. Home-wins on
79
- // collision.
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, relative } from 'node:path';
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). Files from an extra root are relativised against THEIR root,
98
- // so their ids are still shaped `operator/knowledge/…` and merge into the
99
- // same flat namespace as home. Home wins on collision (first-in policy).
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 the source root it belongs to,
107
- // so pass 2 can read the file from disk while emitting a source-relative
108
- // id. Ids that collide keep the first occurrence (home-wins).
109
- const fileEntries = [];
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 seenIds = new Set();
112
- for (const { sourceRoot, scanDirs } of sources) {
113
- for (const dir of scanDirs) {
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-source list: the home workspace root first, then every extra
169
- * scan root from the environment. Each entry pairs a source root with the
170
- * scan dirs it contributes, so callers relativise ids against the SOURCE
171
- * root (yielding `operator/knowledge/…` shaped ids regardless of where on
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
- * Home comes first so the first-wins collision policy resolves in favour
175
- * of the home workspace copy.
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
- * @returns {{ sourceRoot: string, scanDirs: string[] }[]}
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 sources = [{ sourceRoot: root, scanDirs: scanDirsFor(root) }];
186
- for (const extra of extras) {
187
- sources.push({ sourceRoot: extra, scanDirs: scanDirsFor(extra) });
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 sources;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
4
4
  "description": "Digital Operator PA: single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",