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

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
+ }
@@ -14,27 +14,54 @@ pre-remote library sweep.
14
14
 
15
15
  ## Verbs
16
16
 
17
- - `library put <path> [--kind <kind>] [--tier auto|<tier>]`
17
+ - `library put <path> [--kind <kind>] [--tier auto|<tier>] [--verbatim-path]`
18
18
  Streams a single event to `POST /events`.
19
19
  **`--kind` is required** for any path outside `agents/*/staging/`; those
20
20
  staging paths default to `knowledge.observation.staged` via path inference.
21
- A canonical doc write is `library put <absolute path> --kind knowledge.doc.upsert`.
21
+ A canonical doc write is `library put <path> --kind knowledge.doc.upsert`.
22
22
  Known kinds (ingest will 422 anything else): `knowledge.observation.staged`,
23
23
  `knowledge.doc.upsert`, `knowledge.doc.delete`, `profile.upsert`,
24
24
  `profile.slice.upsert`, `preferences.upsert`, `role.output`,
25
25
  `system.correction`, `synthesis.upsert`, `synthesis.merge`,
26
26
  `synthesis.retire`, `system.seed`.
27
- **Path resolution.** The `<path>` argument is read as given (cwd-relative or
28
- absolute); the repo-relative payload path is derived only when the input is
29
- absolute. A relative path run from `agents/dave/` therefore encodes a
30
- payload path relative to `agents/dave/`, which also misses the
31
- `agents/*/staging/` kind-inference regex. Safe form: pass an absolute path,
32
- or run from the repo root.
27
+ **Path resolution.** `<path>` is resolved against the current directory, so
28
+ a relative path from any cwd and an absolute path encode the same
29
+ repo-relative path. From the repo root this is byte-identical to the old
30
+ "as given" encoding.
31
+ **Knowledge docs go to the tier path a mount reads.** For
32
+ `knowledge.doc.upsert` and `knowledge.doc.delete` the workspace path
33
+ (`operator/knowledge/...`) is mapped through the registry knowledge mounts
34
+ (`repos.<repo>.mounts[]` with `to` under `operator/knowledge/`) by the
35
+ shared mount map vendored from remote-library
36
+ (`packages/core/src/knowledge/retrieval/mount-map.mjs`). The longest
37
+ matching mount `to` wins:
38
+ | Workspace path | Sent as | Tier |
39
+ |---|---|---|
40
+ | `operator/knowledge/engineering/x.md` | `knowledge/engineering/x.md` | `auto` (hq and company agree) |
41
+ | `operator/knowledge/wsd/memory/y.md` | `knowledge/memory/y.md` | `stravica-library-wsd` |
42
+ | `operator/knowledge/writing/z.md` | `voice/z.md` | `stravica-operator-barry` |
43
+ A unique tier is stamped exactly as an explicit `--tier` is (envelope `tier`
44
+ and `payload.source`); `provenance.sourcePath` keeps the workspace path.
45
+ With an explicit `--tier` (registry key or short name such as `hq`), only
46
+ that tier's mounts are tried, and the registry key is stamped. Put refuses
47
+ with exit 2 and one line naming the mounts tried when no knowledge mount
48
+ reads the path, when the candidate tiers disagree, when `--tier` names an
49
+ unknown tier or one that cannot read the path, or when no registry is found.
50
+ The ingest refuses the same shape with 422 `unmapped-path`.
51
+ Paths the profile tier map owns (`operator/profile/**`) keep their profile
52
+ routing and are sent unmapped.
53
+ **Registry source.** The company clone among `LIBRARY_KNOWLEDGE_EXTRA_ROOTS`
54
+ (the clone whose own registry entry mounts `registry/`), else the workspace
55
+ copy `<repoRoot>/operator/registry/repos.json`, else none (refuse).
33
56
  `--tier auto` sends the spec §2.3 sentinel; the server's routing engine
34
57
  resolves the actual tier. An explicit `--tier <repo>` also stamps
35
58
  `payload.source = <repo>` on the kinds whose ingest schema accepts it
36
59
  (`knowledge.doc.upsert`, `knowledge.doc.delete`, `profile.upsert`,
37
60
  `profile.slice.upsert`).
61
+ `--verbatim-path` (slice 3 removes) sends a `knowledge.doc.delete` at the
62
+ workspace path unmapped, to delete the legacy `operator/knowledge/...`
63
+ copies. It needs `--kind knowledge.doc.delete` and an explicit `--tier`;
64
+ any other use is a usage error (exit 1).
38
65
 
39
66
  - `library flush`
40
67
  Sweeps `<repoRoot>/agents/<paName>/staging/*` and streams one event per file.
@@ -78,7 +78,7 @@ help ('library put --help' etc.) is served the same way.
78
78
  * useful at the point of friction, not a prose essay.
79
79
  */
80
80
  const VERB_USAGE = {
81
- put: `library put <path> [--kind <kind>] [--tier auto|<tier>]
81
+ put: `library put <path> [--kind <kind>] [--tier auto|<tier>] [--verbatim-path]
82
82
 
83
83
  Streams a single event to POST /events (REMOTE mode only).
84
84
 
@@ -91,12 +91,23 @@ const VERB_USAGE = {
91
91
  synthesis.upsert, synthesis.merge, synthesis.retire,
92
92
  system.seed.
93
93
  --tier <name> 'auto' (default) lets the server route; an explicit tier
94
- (repo name) stamps payload.source for routing-rule kinds.
94
+ (repo name, or a short name such as 'hq') stamps
95
+ payload.source for routing-rule kinds.
96
+ --verbatim-path With --kind knowledge.doc.delete and an explicit --tier
97
+ only: send the workspace path unmapped, to delete a legacy
98
+ operator/knowledge/... copy (slice 3 removes).
95
99
 
96
- Path resolution: the argument is read as given (cwd-relative or absolute);
97
- the repo-relative payload path is derived only when the input is absolute.
98
- Run from the repo root with a relative path, or always pass an absolute
99
- path, to keep the staging-inference and payload path in agreement.
100
+ Path resolution: <path> is resolved against the current directory, so the
101
+ payload path is repo-relative from any cwd.
102
+
103
+ Knowledge docs (knowledge.doc.upsert, knowledge.doc.delete) are sent at the
104
+ tier path a registry knowledge mount reads, for example
105
+ operator/knowledge/x.md -> knowledge/x.md (tier auto),
106
+ operator/knowledge/wsd/x.md -> knowledge/x.md (stravica-library-wsd),
107
+ operator/knowledge/writing/x.md -> voice/x.md (stravica-operator-barry).
108
+ No mount reads the path, the tiers disagree, or no registry: exit 2 with
109
+ the mounts tried. Registry: the company clone among
110
+ LIBRARY_KNOWLEDGE_EXTRA_ROOTS, else operator/registry/repos.json.
100
111
  `,
101
112
  flush: `library flush
102
113
 
@@ -276,9 +287,16 @@ function parsePut(argv) {
276
287
  options: {
277
288
  kind: { type: 'string' },
278
289
  tier: { type: 'string', default: 'auto' },
290
+ // slice 3 removes: deletes the legacy operator/knowledge/... copies.
291
+ 'verbatim-path': { type: 'boolean', default: false },
279
292
  },
280
293
  });
281
- return { path: positionals[0], kind: values.kind, tier: values.tier };
294
+ return {
295
+ path: positionals[0],
296
+ kind: values.kind,
297
+ tier: values.tier,
298
+ verbatimPath: values['verbatim-path'],
299
+ };
282
300
  }
283
301
  function parseFlush(argv) {
284
302
  parseArgs({ args: argv, strict: true, allowPositionals: true, options: {} });
@@ -143,6 +143,12 @@ export function guessKindFromPath(sourcePath) {
143
143
  * `--tier` is asserted explicitly (not the `auto` sentinel), the tier
144
144
  * value is used as the source stamp for these kinds automatically, so
145
145
  * callers rarely pass this directly.
146
+ * @param {string} [args.payloadPath] the tier path to send as
147
+ * `payload.path` when it differs from the repo-relative source path
148
+ * (`library put` maps knowledge docs through the registry mounts,
149
+ * w-2026-10-16-dave-019 slice 2). `provenance.sourcePath` keeps the
150
+ * repo-relative source path. Absent: `payload.path` is the source path,
151
+ * unchanged.
146
152
  * @param {() => Date} [args.clock] test seam
147
153
  * @returns {object} EventInput ready for POST /events
148
154
  */
@@ -155,6 +161,7 @@ export function buildEnvelope({
155
161
  tier = 'auto',
156
162
  sourceKind = 'manual',
157
163
  source,
164
+ payloadPath,
158
165
  clock = () => new Date(),
159
166
  }) {
160
167
  // Repo-relative resolution + refusal for anything that would encode as
@@ -173,6 +180,10 @@ export function buildEnvelope({
173
180
  if (relPath !== undefined) {
174
181
  assertRepoRelativeSafe(relPath, sourcePath, repoRoot);
175
182
  }
183
+ if (payloadPath !== undefined) {
184
+ assertRepoRelativeSafe(payloadPath, sourcePath, repoRoot);
185
+ }
186
+ const wirePath = payloadPath ?? relPath;
176
187
 
177
188
  const resolvedKind = kind ?? guessKindFromPath(relPath ?? sourcePath ?? '') ?? null;
178
189
  if (!resolvedKind) {
@@ -218,7 +229,7 @@ export function buildEnvelope({
218
229
  let payload;
219
230
  if (resolvedKind === 'knowledge.doc.delete') {
220
231
  payload = {
221
- path: relPath ?? null,
232
+ path: wirePath ?? null,
222
233
  ...(stampSource ? { source: effectiveSource } : {}),
223
234
  };
224
235
  } else if (resolvedKind === 'profile.slice.upsert') {
@@ -249,7 +260,7 @@ export function buildEnvelope({
249
260
  } else if (KINDS_ACCEPTING_SOURCE.has(resolvedKind)) {
250
261
  // knowledge.doc.upsert, profile.upsert.
251
262
  payload = {
252
- path: relPath ?? null,
263
+ path: wirePath ?? null,
253
264
  content,
254
265
  ...(stampSource ? { source: effectiveSource } : {}),
255
266
  };
@@ -257,7 +268,7 @@ export function buildEnvelope({
257
268
  // Every other kind keeps the pre-cure {path, content} shape
258
269
  // byte-identical.
259
270
  payload = {
260
- path: relPath ?? null,
271
+ path: wirePath ?? null,
261
272
  content,
262
273
  };
263
274
  }
@@ -1,40 +1,72 @@
1
- // verbs/put.mjs — `library put <path> [--kind ...] [--tier auto|<tier>]`
1
+ // verbs/put.mjs: `library put <path> [--kind ...] [--tier auto|<tier>] [--verbatim-path]`
2
2
  //
3
3
  // Streams a single event to POST /events. Reads the file's UTF-8 contents,
4
4
  // builds an envelope (spec §3.1), and posts with retries via http.mjs.
5
5
  // A `deduped` server response counts as SUCCESS (spec §4.2: retrying a
6
6
  // timed-out send must see success).
7
+ //
8
+ // Knowledge docs (`knowledge.doc.upsert`, `knowledge.doc.delete`) are sent at
9
+ // the TIER path a registry mount reads, never at the workspace path
10
+ // (w-2026-10-16-dave-019, slice 2). The input resolves against cwd to a
11
+ // repo-relative workspace path, the shared mount map (vendored from
12
+ // remote-library) maps it, and put refuses with exit 2 when no knowledge
13
+ // mount reads it. The ingest refuses the same shape with 422 `unmapped-path`.
7
14
 
8
15
  import { readFile } from 'node:fs/promises';
9
- import { basename } from 'node:path';
16
+ import { basename, isAbsolute, relative, resolve, sep } from 'node:path';
17
+
18
+ import { resolveExtraScanRoots } from '../../../engineering-pa-core/src/knowledge/retrieval/links.mjs';
19
+ import { loadWriteRegistry } from '../../../engineering-pa-core/src/knowledge/retrieval/mounts.mjs';
20
+ import { createMountMap, resolveWrite } from '../../../engineering-pa-core/src/knowledge/retrieval/mount-map.mjs';
10
21
 
11
- import { buildEnvelope, subkeyIdFromRemoteKey } from '../envelope.mjs';
22
+ import { buildEnvelope, guessKindFromPath, subkeyIdFromRemoteKey } from '../envelope.mjs';
12
23
  import { postEvents } from '../http.mjs';
13
24
  import { parseIngestAck } from '../ack.mjs';
14
25
  import { assertTierMatchesPath, deriveTierFromPath } from '../tier-map.mjs';
15
26
 
27
+ /** Kinds whose payload path must be one a registry knowledge mount reads. */
28
+ export const MAPPED_KINDS = new Set(['knowledge.doc.upsert', 'knowledge.doc.delete']);
29
+
16
30
  /**
17
31
  * @param {object} ctx
18
32
  * @param {object} ctx.config from loadLibraryConfig (mode/remoteEndpoint/remoteKey/preferences)
19
- * @param {object} ctx.args { path, kind?, tier? }
33
+ * @param {object} ctx.args { path, kind?, tier?, verbatimPath? }
20
34
  * @param {typeof fetch} [ctx.fetchImpl]
21
35
  * @param {(...m:unknown[])=>void} [ctx.log]
36
+ * @param {string} [ctx.cwd] test seam; defaults to process.cwd()
37
+ * @param {() => { registry: object|null, looked: string[] }} [ctx.loadRegistry]
38
+ * test seam for the mount registry; defaults to loadWriteRegistry over
39
+ * LIBRARY_KNOWLEDGE_EXTRA_ROOTS and the workspace copy
22
40
  * @returns {Promise<number>} exit code
23
41
  */
24
- export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
25
- const { path, kind, tier = 'auto' } = args;
42
+ export async function runPut({ config, args, fetchImpl, log = defaultLog, cwd = process.cwd(), loadRegistry }) {
43
+ const { path, kind, tier = 'auto', verbatimPath = false } = args;
26
44
  if (!path) {
27
- log('usage: library put <path> [--kind <kind>] [--tier auto|<tier>]');
45
+ log('usage: library put <path> [--kind <kind>] [--tier auto|<tier>] [--verbatim-path]');
46
+ return 1;
47
+ }
48
+
49
+ // `--verbatim-path` (slice 3 removes): send a delete at the workspace path
50
+ // unmapped, so slice 3 can delete the legacy `operator/knowledge/...`
51
+ // copies. Deletes only, and only with an explicit tier.
52
+ if (verbatimPath && (kind !== 'knowledge.doc.delete' || tier === 'auto')) {
53
+ log('usage: --verbatim-path is valid only with --kind knowledge.doc.delete and an explicit --tier <tier>');
28
54
  return 1;
29
55
  }
30
56
 
57
+ // Every kind resolves the input against cwd, so the payload path is
58
+ // repo-relative whether the caller passed a repo-root-relative path, a
59
+ // path relative to some other cwd, or an absolute path. From the repo
60
+ // root this encodes byte-identically to the old "as given" behaviour.
61
+ const absPath = resolve(cwd, path);
62
+
31
63
  // Refuse a caller-forced tier that disagrees with the path-derived
32
64
  // default (w-2026-09-03-dave-017). `auto` never triggers the refusal
33
65
  // (auto means "let derivation choose"); an explicit tier does. The
34
66
  // refusal is a clean exit 2 with a single-line reason so an operator
35
67
  // sees why the CLI declined to encode the event rather than silently
36
68
  // stamping the wrong tier.
37
- const tierRefusal = assertTierMatchesPath(path, config.repoRoot, tier);
69
+ const tierRefusal = assertTierMatchesPath(absPath, config.repoRoot, tier);
38
70
  if (tierRefusal) {
39
71
  log(`[library put] refused: ${tierRefusal}`);
40
72
  return 2;
@@ -43,9 +75,37 @@ export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
43
75
  // When `--tier` is absent the CLI derives it from the path map. Paths
44
76
  // outside the map keep today's `auto` sentinel so unrelated payload
45
77
  // kinds still content-route the way they always did.
46
- const derivedTier = tier === 'auto'
47
- ? (deriveTierFromPath(path, config.repoRoot) ?? 'auto')
48
- : tier;
78
+ const mapTier = deriveTierFromPath(absPath, config.repoRoot);
79
+ let derivedTier = tier === 'auto' ? (mapTier ?? 'auto') : tier;
80
+
81
+ // Knowledge docs go to the tier path a registry mount reads. Paths the
82
+ // profile tier map owns (operator/profile/**) keep their profile routing
83
+ // and path unchanged; profile routing is out of scope for slice 2. A path
84
+ // outside the repo skips mapping so buildEnvelope refuses it as before.
85
+ const workspacePath = relative(config.repoRoot, absPath).split(sep).join('/');
86
+ const insideRepo = workspacePath !== '' && !workspacePath.startsWith('../') && !isAbsolute(workspacePath);
87
+ const effectiveKind = kind ?? guessKindFromPath(workspacePath);
88
+ let payloadPath;
89
+ if (MAPPED_KINDS.has(effectiveKind) && insideRepo && !mapTier && !verbatimPath) {
90
+ const loaded = (loadRegistry ?? (() => loadWriteRegistry({
91
+ extraRoots: resolveExtraScanRoots(process.env),
92
+ repoRoot: config.repoRoot,
93
+ })))();
94
+ if (!loaded?.registry) {
95
+ log(`[library put] refused: no mount registry (looked in: ${(loaded?.looked ?? []).join(', ')})`);
96
+ return 2;
97
+ }
98
+ const asserted = tier === 'auto' ? undefined : tier;
99
+ const res = resolveWrite(createMountMap(loaded.registry), workspacePath, asserted);
100
+ if (!res.ok) {
101
+ log(`[library put] refused: ${describeRefusal(res, workspacePath, asserted)}`);
102
+ return 2;
103
+ }
104
+ payloadPath = res.libraryPath;
105
+ // A unique tier stamps itself exactly as an explicit --tier does; agreeing
106
+ // tiers (hq and company both read `knowledge/`) leave the server to route.
107
+ derivedTier = res.repo ?? 'auto';
108
+ }
49
109
 
50
110
  // Delete events carry no content by spec (§3.1 payload schema); the
51
111
  // operator can and typically will have removed the file locally before
@@ -54,17 +114,18 @@ export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
54
114
  // delete. Every other kind still reads the file to populate
55
115
  // payload.content. Fix Wave E (w-2026-08-24-dave-029) partner cure to
56
116
  // the envelope kind-aware branch.
57
- const content = kind === 'knowledge.doc.delete' ? undefined : await readFile(path, 'utf8');
117
+ const content = kind === 'knowledge.doc.delete' ? undefined : await readFile(absPath, 'utf8');
58
118
 
59
119
  const producer = producerFromPreferences(config);
60
120
  const envelope = buildEnvelope({
61
121
  repoRoot: config.repoRoot,
62
- sourcePath: path,
122
+ sourcePath: absPath,
63
123
  content,
64
124
  producer,
65
125
  kind,
66
126
  tier: derivedTier,
67
127
  sourceKind: 'manual',
128
+ ...(payloadPath !== undefined ? { payloadPath } : {}),
68
129
  });
69
130
 
70
131
  const res = await postEvents({
@@ -77,13 +138,35 @@ export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
77
138
  // RB-5 fix (2026-08-20 rehearsal). See ack.mjs for the wire contract.
78
139
  const ack = parseIngestAck(res);
79
140
  if (ack.kind === 'accepted' || ack.kind === 'deduped') {
80
- log(`[library put] ${ack.kind} ${basename(path)} -> ${ack.eventId} (tier: ${derivedTier})`);
141
+ const pathNote = payloadPath !== undefined ? `, path: ${payloadPath}` : '';
142
+ log(`[library put] ${ack.kind} ${basename(path)} -> ${ack.eventId} (tier: ${derivedTier}${pathNote})`);
81
143
  return 0;
82
144
  }
83
145
  log(`[library put] rejected ${basename(path)}: ${ack.reason}`);
84
146
  return 2;
85
147
  }
86
148
 
149
+ /**
150
+ * One-line reason for a failed resolveWrite, naming the mounts tried.
151
+ *
152
+ * @param {{ reason: string, tried: { repo: string, from: string, to: string }[] }} res
153
+ * @param {string} workspacePath
154
+ * @param {string|undefined} asserted
155
+ */
156
+ export function describeRefusal(res, workspacePath, asserted) {
157
+ const tried = res.tried.length
158
+ ? res.tried.map((t) => `${t.repo} ${t.to} -> ${t.from}`).join(', ')
159
+ : 'no knowledge mounts';
160
+ if (res.reason === 'unknown-tier') {
161
+ return `${workspacePath}: tier ${asserted} is not in the mount registry`;
162
+ }
163
+ if (res.reason === 'ambiguous') {
164
+ return `${workspacePath} is read by knowledge mounts of several tiers that disagree on the tier path; ` +
165
+ `pass --tier to choose; tried: ${tried}`;
166
+ }
167
+ return `${workspacePath} is read by no knowledge mount of ${asserted ?? 'any tier'}; tried: ${tried}`;
168
+ }
169
+
87
170
  export function producerFromPreferences(config) {
88
171
  const paName = config.preferences?.paName;
89
172
  const operator = (config.preferences?.operator?.name ?? '').toString().toLowerCase() || 'unknown';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.63",
3
+ "version": "0.1.64",
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",