@stravica-ai/digital-operator-pa 0.1.63 → 0.1.65

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.
@@ -137,11 +137,13 @@ roots, else the clone's own. For every mount whose `to` lies under
137
137
  `operator/knowledge/writing/`. The legacy `<clone>/operator/knowledge/`
138
138
  form is still read during the path migration. On a colliding id the home
139
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.
140
+ the legacy copy. Within one clone that holds both forms of an id, the copy
141
+ whose last commit in that clone is newer wins (`git log -1 --format=%ct`,
142
+ read only on such a collision), until the migration removes the legacy
143
+ copies. On a tie, or when either time is unavailable (the clone is not a
144
+ git repo, the file is untracked, git fails), the legacy copy wins. Commit
145
+ times, not file mtimes, because a clone's mtimes are checkout times. A
146
+ clone with no registry entry is read in the legacy form only.
145
147
 
146
148
  Global flag: `--format <table|json|mermaid>` (default: `table`). The
147
149
  `json` shape is stable-by-convention — `{ "verb": "<name>", ... }` —
@@ -33,6 +33,7 @@
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 { execFileSync } from 'node:child_process';
36
37
  import { existsSync } from 'node:fs';
37
38
  import { readdir } from 'node:fs/promises';
38
39
  import { join, normalize, posix, relative } from 'node:path';
@@ -66,8 +67,9 @@ export const KNOWLEDGE_SCAN_DIRS = ['operator/knowledge', 'core/knowledge'];
66
67
  // composes at `operator/knowledge/` (or `operator/knowledge/wsd/`, and
67
68
  // `<clone>/voice/` at `operator/knowledge/writing/`) exactly as the registry
68
69
  // 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
70
+ // copy with the newer last commit wins during the migration bridge, the
71
+ // legacy `<clone>/operator/knowledge/` copy on a tie or when a commit time is
72
+ // unavailable (`legacySupersedes`); home still beats both. See
71
73
  // `scanSourcesFor` and mounts.mjs.
72
74
  export const EXTRA_SCAN_ROOTS_ENV = 'LIBRARY_KNOWLEDGE_EXTRA_ROOTS';
73
75
 
@@ -193,9 +195,10 @@ export function scanDirsFor(root) {
193
195
  * exactly the ids this function produced before mounts were read.
194
196
  *
195
197
  * 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
198
+ * legacy copy of the same id. Within ONE clone the copy whose last commit
199
+ * is newer wins (legacy on a tie or an unavailable time); the order cannot
200
+ * say that, so the collision step applies it (`legacySupersedes`,
201
+ * `collectScannedFiles`). A clone with no registry entry
199
202
  * contributes its legacy form only.
200
203
  *
201
204
  * @param {string} root the home workspace root
@@ -258,35 +261,92 @@ export function scanIdFor(scan, abs) {
258
261
  }
259
262
 
260
263
  // 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).
264
+ // it, because "one form first within a clone" and "mapped first across
265
+ // clones" are not one order: with copies in A legacy, A mapped and B mapped
266
+ // (A of higher precedence) the two rules cycle. So the order picks the
267
+ // winning TIER (first occurrence: home, mapped by precedence, legacy), and
268
+ // this rule then decides between that clone's own two copies: the copy whose
269
+ // last commit in the clone is newer wins. On a tie, or when either commit
270
+ // time is unavailable (not a git clone, untracked file, git error), the
271
+ // legacy copy wins, which was the slice 1 rule: every writer took the legacy
272
+ // form between the 2026-08-21 cutover and slice 2, which moved the writers
273
+ // to the mounted form. Commit times, not mtimes: a clone's mtimes are
274
+ // checkout times, while commit times are the same on every host.
275
+
276
+ /**
277
+ * Unix seconds of the last commit that touched `abs` in the clone at
278
+ * `root`, or null when there is none to read: `root` is not a git clone,
279
+ * the file is untracked, or git fails.
280
+ *
281
+ * @param {string} root clone root
282
+ * @param {string} abs absolute path of a file under `root`
283
+ * @returns {number|null}
284
+ */
285
+ export function lastCommitTime(root, abs) {
286
+ // `git -C` would climb into an enclosing repo; only the clone's own
287
+ // history counts.
288
+ if (!existsSync(join(root, '.git'))) return null;
289
+ try {
290
+ const out = execFileSync(
291
+ 'git',
292
+ ['-C', root, 'log', '-1', '--format=%ct', '--', toPosix(relative(root, abs))],
293
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
294
+ ).trim();
295
+ return /^\d+$/.test(out) ? Number(out) : null;
296
+ } catch {
297
+ return null;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * `lastCommitTime` memoised per path, for the life of one scan.
303
+ *
304
+ * @returns {(root: string, abs: string) => number|null}
305
+ */
306
+ export function memoCommitTime(lookup = lastCommitTime) {
307
+ const memo = new Map();
308
+ return (root, abs) => {
309
+ if (!memo.has(abs)) memo.set(abs, lookup(root, abs));
310
+ return memo.get(abs);
311
+ };
312
+ }
313
+
268
314
  /**
269
315
  * True when `candidate` (met later in scan order) should replace `winner`,
270
- * the scan that first claimed the same id.
316
+ * the scan that first claimed the same id. Only a clone's legacy copy can
317
+ * replace that same clone's mapped copy, and it does unless the mapped copy's
318
+ * last commit is strictly newer. Commit times are read only here, so only on
319
+ * a within-clone collision.
271
320
  *
272
321
  * @param {ReturnType<typeof scanSourcesFor>[number]} winner
322
+ * @param {string} winnerAbs the file `winner` holds for the id
273
323
  * @param {ReturnType<typeof scanSourcesFor>[number]} candidate
324
+ * @param {string} candidateAbs the file `candidate` holds for the id
325
+ * @param {(root: string, abs: string) => number|null} commitTime
274
326
  */
275
- export function legacySupersedes(winner, candidate) {
276
- return winner.form === 'mapped' && candidate.form === 'legacy'
277
- && winner.sourceRoot === candidate.sourceRoot;
327
+ export function legacySupersedes(winner, winnerAbs, candidate, candidateAbs, commitTime) {
328
+ if (winner.form !== 'mapped' || candidate.form !== 'legacy'
329
+ || winner.sourceRoot !== candidate.sourceRoot) return false;
330
+ const mappedAt = commitTime(winner.sourceRoot, winnerAbs);
331
+ const legacyAt = commitTime(candidate.sourceRoot, candidateAbs);
332
+ if (mappedAt === null || legacyAt === null) return true;
333
+ return legacyAt >= mappedAt;
278
334
  }
279
335
 
280
336
  /**
281
337
  * 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
338
+ * except that within one clone the copy with the newer last commit wins
283
339
  * (`legacySupersedes`). A replaced entry keeps its position, so the entry
284
340
  * order is still scan order of first occurrence.
285
341
  *
286
342
  * @param {ReturnType<typeof scanSourcesFor>} scans
343
+ * @param {object} [opts]
344
+ * @param {(root: string, abs: string) => number|null} [opts.commitTime]
345
+ * override the memoised git lookup (tests, timing)
287
346
  * @returns {Promise<{ abs: string, id: string, scan: object }[]>}
288
347
  */
289
- export async function collectScannedFiles(scans) {
348
+ export async function collectScannedFiles(scans, opts = {}) {
349
+ const commitTime = opts.commitTime ?? memoCommitTime();
290
350
  const entries = [];
291
351
  const byId = new Map();
292
352
  for (const scan of scans) {
@@ -297,7 +357,7 @@ export async function collectScannedFiles(scans) {
297
357
  const entry = { abs, id, scan };
298
358
  byId.set(id, entry);
299
359
  entries.push(entry);
300
- } else if (legacySupersedes(existing.scan, scan)) {
360
+ } else if (legacySupersedes(existing.scan, existing.abs, scan, abs, commitTime)) {
301
361
  existing.abs = abs;
302
362
  existing.scan = scan;
303
363
  }
@@ -308,14 +368,18 @@ export async function collectScannedFiles(scans) {
308
368
 
309
369
  /**
310
370
  * 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.
371
+ * builders apply (including `legacySupersedes`), or null. Lets body readers
372
+ * (`pa knowledge search --full`) open the copy the graph actually indexed.
313
373
  *
314
374
  * @param {ReturnType<typeof scanSourcesFor>} scans
315
375
  * @param {string} id
376
+ * @param {object} [opts]
377
+ * @param {(root: string, abs: string) => number|null} [opts.commitTime]
378
+ * override the memoised git lookup (tests, timing)
316
379
  * @returns {string|null}
317
380
  */
318
- export function locateScannedFile(scans, id) {
381
+ export function locateScannedFile(scans, id, opts = {}) {
382
+ const commitTime = opts.commitTime ?? memoCommitTime();
319
383
  const posixId = toPosix(id);
320
384
  const fileIn = (scan) => {
321
385
  if (!posixId.startsWith(scan.idPrefix)) return null;
@@ -328,9 +392,9 @@ export function locateScannedFile(scans, id) {
328
392
  const found = fileIn(scan);
329
393
  if (!found) continue;
330
394
  for (const later of scans.slice(i + 1)) {
331
- if (!legacySupersedes(scan, later)) continue;
395
+ if (later.form !== 'legacy' || later.sourceRoot !== scan.sourceRoot) continue;
332
396
  const legacy = fileIn(later);
333
- if (legacy) return legacy;
397
+ if (legacy && legacySupersedes(scan, found, later, legacy, commitTime)) return legacy;
334
398
  }
335
399
  return found;
336
400
  }
@@ -0,0 +1,169 @@
1
+ // Vendored from Stravica/remote-library, src/mounts/mount-map.ts, compiled at 2add6c77a58664981771e71e0cdf0db8def64f66.
2
+ // Do not edit here; change the source and re-vendor.
3
+ // Same fixture in both repos (test/knowledge/fixtures/mount-map-cases.json) proves it is one map.
4
+ // Shared mount map (w-2026-10-16-dave-019, library-sync slice 2).
5
+ //
6
+ // One answer, used by every writer and gate, to "which tier path does a
7
+ // registry mount read for this workspace path, and back". The registry is
8
+ // `registry/repos.json` from the company tier: `repos.<repoKey>.mounts[]`,
9
+ // each `{ from, to }` where `from` is the path inside the tier repo and `to`
10
+ // is the workspace path it composes at.
11
+ //
12
+ // PURE by contract: this file has no imports at all (not even node:path) and
13
+ // works on POSIX path strings only, so its compiled `dist/mounts/mount-map.js`
14
+ // can be vendored unchanged into other repos. Keep it that way.
15
+ export const KNOWLEDGE_TO_PREFIX = "operator/knowledge/";
16
+ function stripDotSlash(p) {
17
+ let out = p;
18
+ while (out.startsWith("./"))
19
+ out = out.slice(2);
20
+ return out;
21
+ }
22
+ function hasUnsafeSegment(p) {
23
+ return p.split("/").some((seg) => seg === "..");
24
+ }
25
+ // Normalise a mount side to `a/b/` form. Null when it cannot be a safe
26
+ // relative directory (empty, absolute, or holding a `..` segment).
27
+ function normaliseMountSide(raw) {
28
+ if (typeof raw !== "string")
29
+ return null;
30
+ let p = stripDotSlash(raw.trim());
31
+ if (p === "" || p.startsWith("/") || p.includes("\\"))
32
+ return null;
33
+ if (hasUnsafeSegment(p))
34
+ return null;
35
+ if (!p.endsWith("/"))
36
+ p += "/";
37
+ return p;
38
+ }
39
+ // Normalise an input file path. Null when it is not a safe relative file
40
+ // path: empty, absolute, a directory (trailing `/`), or holding an empty,
41
+ // `.` or `..` segment.
42
+ function normaliseFilePath(raw) {
43
+ if (typeof raw !== "string")
44
+ return null;
45
+ const p = stripDotSlash(raw);
46
+ if (p === "" || p.startsWith("/") || p.endsWith("/") || p.includes("\\"))
47
+ return null;
48
+ if (p.includes("\0"))
49
+ return null;
50
+ for (const seg of p.split("/")) {
51
+ if (seg === "" || seg === "." || seg === "..")
52
+ return null;
53
+ }
54
+ return p;
55
+ }
56
+ function isObject(v) {
57
+ return typeof v === "object" && v !== null && !Array.isArray(v);
58
+ }
59
+ // Longest-prefix mount for `path` on the given side, with the remainder.
60
+ function longestMatch(mounts, path, side) {
61
+ let best = null;
62
+ for (const m of mounts) {
63
+ if (!path.startsWith(m[side]))
64
+ continue;
65
+ if (best === null || m[side].length > best[side].length)
66
+ best = m;
67
+ }
68
+ if (best === null)
69
+ return null;
70
+ const rest = path.slice(best[side].length);
71
+ if (rest === "")
72
+ return null;
73
+ return { mount: best, rest };
74
+ }
75
+ export function createMountMap(registry, opts) {
76
+ const toPrefix = opts?.toPrefix ?? KNOWLEDGE_TO_PREFIX;
77
+ const byRepo = new Map();
78
+ const repoEntries = isObject(registry) && isObject(registry.repos) ? registry.repos : {};
79
+ for (const [repoKey, entry] of Object.entries(repoEntries)) {
80
+ const kept = [];
81
+ const rawMounts = isObject(entry) && Array.isArray(entry.mounts) ? entry.mounts : [];
82
+ for (const raw of rawMounts) {
83
+ if (!isObject(raw))
84
+ continue;
85
+ const from = normaliseMountSide(raw.from);
86
+ const to = normaliseMountSide(raw.to);
87
+ if (from === null || to === null)
88
+ continue;
89
+ if (!to.startsWith(toPrefix))
90
+ continue;
91
+ kept.push({ from, to });
92
+ }
93
+ byRepo.set(repoKey, kept);
94
+ }
95
+ const mountsFor = (repo) => (byRepo.get(repo) ?? []).map((m) => ({ from: m.from, to: m.to }));
96
+ const toLibraryPath = (repo, workspacePath) => {
97
+ const p = normaliseFilePath(workspacePath);
98
+ if (p === null)
99
+ return null;
100
+ const hit = longestMatch(byRepo.get(repo) ?? [], p, "to");
101
+ return hit === null ? null : hit.mount.from + hit.rest;
102
+ };
103
+ const toWorkspacePath = (repo, libraryPath) => {
104
+ const p = normaliseFilePath(libraryPath);
105
+ if (p === null)
106
+ return null;
107
+ const hit = longestMatch(byRepo.get(repo) ?? [], p, "from");
108
+ return hit === null ? null : hit.mount.to + hit.rest;
109
+ };
110
+ const resolveRepoKey = (tier) => {
111
+ if (typeof tier !== "string" || tier === "")
112
+ return null;
113
+ if (byRepo.has(tier))
114
+ return tier;
115
+ const suffix = `-${tier}`;
116
+ const hits = [...byRepo.keys()].filter((k) => k.endsWith(suffix));
117
+ return hits.length === 1 ? hits[0] : null;
118
+ };
119
+ return {
120
+ repos: () => [...byRepo.keys()],
121
+ mountsFor,
122
+ toLibraryPath,
123
+ toWorkspacePath,
124
+ isReadable: (repo, libraryPath) => toWorkspacePath(repo, libraryPath) !== null,
125
+ resolveRepoKey,
126
+ };
127
+ }
128
+ export function resolveWrite(map, workspacePath, assertedTier) {
129
+ const triedFor = (repo) => map.mountsFor(repo).map((m) => ({ repo, from: m.from, to: m.to }));
130
+ if (assertedTier !== undefined && assertedTier !== "") {
131
+ const repo = map.resolveRepoKey(assertedTier);
132
+ if (repo === null)
133
+ return { ok: false, reason: "unknown-tier", tried: [] };
134
+ const libraryPath = map.toLibraryPath(repo, workspacePath);
135
+ if (libraryPath === null)
136
+ return { ok: false, reason: "unmapped", tried: triedFor(repo) };
137
+ return { ok: true, repo, libraryPath };
138
+ }
139
+ const p = normaliseFilePath(workspacePath);
140
+ const candidates = [];
141
+ const tried = [];
142
+ for (const repo of map.repos()) {
143
+ tried.push(...triedFor(repo));
144
+ if (p === null)
145
+ continue;
146
+ const libraryPath = map.toLibraryPath(repo, p);
147
+ if (libraryPath === null)
148
+ continue;
149
+ const mount = map
150
+ .mountsFor(repo)
151
+ .filter((m) => p.startsWith(m.to))
152
+ .reduce((a, b) => (b.to.length > a.to.length ? b : a));
153
+ candidates.push({ repo, mount, libraryPath });
154
+ }
155
+ if (candidates.length === 0)
156
+ return { ok: false, reason: "unmapped", tried };
157
+ const longest = Math.max(...candidates.map((c) => c.mount.to.length));
158
+ const best = candidates.filter((c) => c.mount.to.length === longest);
159
+ if (best.length === 1)
160
+ return { ok: true, repo: best[0].repo, libraryPath: best[0].libraryPath };
161
+ const paths = new Set(best.map((c) => c.libraryPath));
162
+ if (paths.size === 1)
163
+ return { ok: true, repo: null, libraryPath: best[0].libraryPath };
164
+ return {
165
+ ok: false,
166
+ reason: "ambiguous",
167
+ tried: best.map((c) => ({ repo: c.repo, from: c.mount.from, to: c.mount.to })),
168
+ };
169
+ }
@@ -22,6 +22,9 @@
22
22
  // `registry/`, the registry source of truth);
23
23
  // 2. else the clone's own `registry/repos.json`;
24
24
  // 3. else no registry: the clone is scanned in the legacy form only.
25
+ //
26
+ // `loadWriteRegistry` gives `library put` its registry by the same company
27
+ // rule, falling back to the workspace copy (slice 2).
25
28
 
26
29
  import { execFileSync } from 'node:child_process';
27
30
  import { existsSync, readFileSync } from 'node:fs';
@@ -147,15 +150,7 @@ function mountsRegistry(entry) {
147
150
  export function resolveTierMounts(extraRoots, { remoteOf = gitRemoteOf } = {}) {
148
151
  const own = new Map();
149
152
  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
- }
153
+ const company = findCompanyRegistry(extraRoots, { remoteOf, read: (root) => own.get(root) })?.registry ?? null;
159
154
 
160
155
  return extraRoots.map((root) => {
161
156
  let hit = null;
@@ -182,3 +177,61 @@ export function resolveTierMounts(extraRoots, { remoteOf = gitRemoteOf } = {}) {
182
177
  };
183
178
  });
184
179
  }
180
+
181
+ /**
182
+ * The company clone among `roots`: the clone whose own registry entry mounts
183
+ * `registry/`, the registry's source of truth. Its copy is preferred over
184
+ * every other (possibly drifted) one.
185
+ *
186
+ * @param {string[]} roots
187
+ * @param {object} [opts]
188
+ * @param {(root: string) => string|null} [opts.remoteOf]
189
+ * @param {(root: string) => object|null} [opts.read] registry reader seam
190
+ * @returns {{ root: string, registry: object } | null}
191
+ */
192
+ export function findCompanyRegistry(roots, { remoteOf = gitRemoteOf, read = readRegistry } = {}) {
193
+ for (const root of roots) {
194
+ const registry = read(root);
195
+ const self = resolveRepoEntry(registry, root, { remoteOf });
196
+ if (self && mountsRegistry(self.entry)) return { root, registry };
197
+ }
198
+ return null;
199
+ }
200
+
201
+ /**
202
+ * The registry `library put` maps knowledge paths through
203
+ * (w-2026-10-16-dave-019, slice 2). Source order:
204
+ * 1. the company clone among the extra roots (LIBRARY_KNOWLEDGE_EXTRA_ROOTS);
205
+ * 2. else the workspace copy, `<repoRoot>/operator/registry/repos.json`;
206
+ * 3. else none.
207
+ * `looked` names every place tried, so a refusal can print it.
208
+ *
209
+ * @param {object} opts
210
+ * @param {string[]} opts.extraRoots
211
+ * @param {string} opts.repoRoot
212
+ * @param {(root: string) => string|null} [opts.remoteOf]
213
+ * @returns {{ registry: object|null, source: 'company'|'workspace'|null, path: string|null, looked: string[] }}
214
+ */
215
+ export function loadWriteRegistry({ extraRoots, repoRoot, remoteOf = gitRemoteOf }) {
216
+ const looked = [];
217
+ if (extraRoots.length) {
218
+ looked.push(`company clone among ${extraRoots.length} extra root(s) (${extraRoots.join(':')})`);
219
+ const company = findCompanyRegistry(extraRoots, { remoteOf });
220
+ if (company) {
221
+ return {
222
+ registry: company.registry,
223
+ source: 'company',
224
+ path: join(company.root, ...REGISTRY_RELATIVE_PATH.split('/')),
225
+ looked,
226
+ };
227
+ }
228
+ } else {
229
+ looked.push('company clone (LIBRARY_KNOWLEDGE_EXTRA_ROOTS unset)');
230
+ }
231
+ const operatorDir = join(repoRoot, 'operator');
232
+ const workspacePath = join(operatorDir, ...REGISTRY_RELATIVE_PATH.split('/'));
233
+ looked.push(workspacePath);
234
+ const workspace = readRegistry(operatorDir);
235
+ if (workspace) return { registry: workspace, source: 'workspace', path: workspacePath, looked };
236
+ return { registry: null, source: null, path: null, looked };
237
+ }