@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.
- package/dist/engineering-pa-cli/README.md +7 -5
- package/dist/engineering-pa-core/src/knowledge/retrieval/links.mjs +88 -24
- package/dist/engineering-pa-core/src/knowledge/retrieval/mount-map.mjs +169 -0
- package/dist/engineering-pa-core/src/knowledge/retrieval/mounts.mjs +62 -9
- package/dist/engineering-pa-library-cli/README.md +35 -8
- package/dist/engineering-pa-library-cli/src/cli.mjs +25 -7
- package/dist/engineering-pa-library-cli/src/envelope.mjs +14 -3
- package/dist/engineering-pa-library-cli/src/verbs/put.mjs +97 -14
- package/package.json +1 -1
|
@@ -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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
70
|
-
//
|
|
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
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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 "
|
|
262
|
-
// are not one order: with copies in A legacy, A mapped and B mapped
|
|
263
|
-
// higher precedence) the two rules cycle. So the order picks the
|
|
264
|
-
// TIER (first occurrence: home, mapped by precedence, legacy), and
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
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
|
-
|
|
277
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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 <
|
|
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.**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
271
|
+
path: wirePath ?? null,
|
|
261
272
|
content,
|
|
262
273
|
};
|
|
263
274
|
}
|
|
@@ -1,40 +1,72 @@
|
|
|
1
|
-
// verbs/put.mjs
|
|
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(
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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