@stravica-ai/digital-operator-pa 0.1.62 → 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>", ... }` —
@@ -82,6 +82,8 @@ The runner spawns the command **directly** — no shell, no expansion, no operat
82
82
 
83
83
  If the command contains `'` or `"`, `task-loader` rejects it at load time with exit code 2 (a `--dry-run` also fails). This is a deliberate guard: silently splitting `bash -c "a && b"` on whitespace shreds it to `spawn('bash', ['-c', '"a', '&&', 'b"'])`, bash exits 2 with `unexpected EOF`, and cron mails the operator every interval until someone notices.
84
84
 
85
+ Shell operators are refused the same way, at load time and in the runner: a command with `;`, `&&`, `||`, `|`, `>`, `<`, `$(`, a backtick or `&` exits 2 with a message naming the schedule id and the offending token. Without a shell the operator would reach the program as a literal argument (`pnpm exec estate-sync run; node ...` handed estate-sync the subcommand `run;`).
86
+
85
87
  If you need shell features, you have two options:
86
88
 
87
89
  1. **Wrap the work in a script file** and put the script path in `command`: `command: "/path/to/wrapper.sh"`. The runner spawns the script; the script does whatever it likes.
@@ -43,6 +43,7 @@ function resolveWorkdir(workdir, repoRoot) {
43
43
  * expressions. The string is split on whitespace and passed to spawn()
44
44
  * WITHOUT a shell — no quoting, globbing, expansion, or operators are
45
45
  * honoured. Any single (') or double (") quote character in the command
46
+ * or shell operator (; && || | > < $( ` &)
46
47
  * is a configuration error: rejected via assertNoShellQuoting with
47
48
  * exitCode 2 (task-loader also asserts this at load time so --dry-run
48
49
  * catches it; the check here is the chokepoint guarantee for any caller
@@ -58,8 +59,8 @@ function resolveWorkdir(workdir, repoRoot) {
58
59
  * @returns {{ bin: string, args: string[] }}
59
60
  * @throws {Error & { exitCode: 2 }} on quoted or empty commands
60
61
  */
61
- function parseCommand(command) {
62
- assertNoShellQuoting(command);
62
+ function parseCommand(command, scheduleId) {
63
+ assertNoShellQuoting(command, scheduleId);
63
64
  const parts = command.trim().split(/\s+/);
64
65
  if (!parts[0]) {
65
66
  const e = new Error('command is empty');
@@ -70,7 +71,7 @@ function parseCommand(command) {
70
71
  }
71
72
 
72
73
  async function defaultSpawner(plan) {
73
- const { bin, args } = parseCommand(plan.task.command);
74
+ const { bin, args } = parseCommand(plan.task.command, plan.task.id);
74
75
  const cwd = resolveWorkdir(plan.task.workingDirectory, plan.repoRoot);
75
76
  return new Promise((resolve, reject) => {
76
77
  const child = spawn(bin, args, { cwd, stdio: 'inherit' });
@@ -16,11 +16,17 @@ const REQUIRED_FIELDS = ['id', 'command'];
16
16
  * re-asserted by runner.parseCommand as a chokepoint guarantee for
17
17
  * programmatic callers.
18
18
  *
19
+ * Shell operators (`;`, `&&`, `||`, `|`, `>`, `<`, `$(`, backticks, `&`)
20
+ * are refused for the same reason: without a shell they reach the program
21
+ * as literal args (`pnpm exec estate-sync run; node ...` handed
22
+ * estate-sync the subcommand `run;` and failed 34 runs in a row).
23
+ *
19
24
  * @param {string} command
25
+ * @param {string} [scheduleId] named in the operator refusal message
20
26
  * @throws {Error & { exitCode: 2 }} if command is not a non-empty string,
21
- * or contains a single (') or double (") quote character
27
+ * or contains a single (') or double (") quote character, or a shell operator
22
28
  */
23
- export function assertNoShellQuoting(command) {
29
+ export function assertNoShellQuoting(command, scheduleId) {
24
30
  if (typeof command !== 'string') {
25
31
  const e = new Error('command must be a string');
26
32
  e.exitCode = 2;
@@ -38,6 +44,34 @@ export function assertNoShellQuoting(command) {
38
44
  e.exitCode = 2;
39
45
  throw e;
40
46
  }
47
+ const op = findShellOperator(command);
48
+ if (op) {
49
+ const where = scheduleId ? `schedule "${scheduleId}"` : 'task command';
50
+ const e = new Error(
51
+ `${where} contains shell operator ${JSON.stringify(op.operator)} in token ` +
52
+ `${JSON.stringify(op.token)}: launch runs commands without a shell, so the operator ` +
53
+ 'would reach the program as a literal argument. Wrap the chain in a script file ' +
54
+ 'and invoke the script, or split it into separate schedules.'
55
+ );
56
+ e.exitCode = 2;
57
+ throw e;
58
+ }
59
+ }
60
+
61
+ // Multi-char operators first so `&&` is reported as `&&`, not `&`.
62
+ const SHELL_OPERATORS = ['$(', '&&', '||', ';', '|', '>', '<', '`', '&'];
63
+
64
+ /**
65
+ * @param {string} command
66
+ * @returns {{ operator: string, token: string } | null}
67
+ */
68
+ export function findShellOperator(command) {
69
+ for (const token of command.trim().split(/\s+/)) {
70
+ for (const operator of SHELL_OPERATORS) {
71
+ if (token.includes(operator)) return { operator, token };
72
+ }
73
+ }
74
+ return null;
41
75
  }
42
76
 
43
77
  export async function loadTask(repoRoot, taskId) {
@@ -74,7 +108,7 @@ export async function loadTask(repoRoot, taskId) {
74
108
  }
75
109
 
76
110
  try {
77
- assertNoShellQuoting(data.command);
111
+ assertNoShellQuoting(data.command, data.id ?? taskId);
78
112
  } catch (err) {
79
113
  const e = new Error(`task "${taskId}": ${err.message}`);
80
114
  e.exitCode = err.exitCode ?? 2;
@@ -16,6 +16,7 @@ import { resolve } from 'node:path';
16
16
  // rewrites this string to a relative path inside the single-bundled
17
17
  // tarball.
18
18
  import { detect, install, list, remove, verify } from '../../../engineering-pa-core/src/scheduler/index.mjs';
19
+ import { parseTimeoutSeconds } from '../../../engineering-pa-core/src/scheduler/verify.mjs';
19
20
 
20
21
  const USAGE = `scheduler — operator-facing scheduling primitives
21
22
 
@@ -32,7 +33,9 @@ Usage:
32
33
  field wins (OS-native detect is the fallback).
33
34
  The label form the list verb prints as a
34
35
  secondary line is accepted here directly.
35
- scheduler verify <schedule.json> Verify a schedule fires correctly
36
+ scheduler verify <schedule.json> [--backend N] [--timeout <seconds>]
37
+ Verify a schedule fires correctly.
38
+ --timeout is in seconds (default 120).
36
39
  scheduler -h, --help Show this help
37
40
 
38
41
  Schedule files live at <repoRoot>/schedules/<id>.json. The canonical
@@ -189,7 +192,14 @@ export async function run(argv) {
189
192
  const schedulePath = resolve(positionals[0]);
190
193
  const opts = {};
191
194
  if (values.backend) opts.backend = values.backend;
192
- if (values.timeout) opts.timeoutMs = parseInt(values.timeout, 10);
195
+ if (values.timeout !== undefined) {
196
+ const ms = parseTimeoutSeconds(values.timeout);
197
+ if (ms === null) {
198
+ process.stderr.write(`[scheduler] verify: --timeout requires a positive number of seconds, got: ${values.timeout}\n`);
199
+ return 1;
200
+ }
201
+ opts.timeoutMs = ms;
202
+ }
193
203
  const result = await verify(schedulePath, opts);
194
204
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
195
205
  return result.exitCode ?? (result.ok ? 0 : 2);
@@ -110,9 +110,9 @@ Proven live (w-2026-07-15-003, verified with `env -i`): cron's crond process pro
110
110
 
111
111
  The generated cron line handles this for you automatically: `scheduler install` detects `$HOME/.nvm/nvm.sh` on the target AT INSTALL TIME and, if present, sources it inside the rendered chain. The generated cron line is `bash -c '{ date -u; . $HOME/.nvm/nvm.sh && cd <workDir> && <command>; } >> /tmp/<agent>-<id>.log 2>&1'`; the `{ ...; }` braces are load-bearing: without them the `>> log 2>&1` binds only to `<command>` and any earlier link failing (nvm-source, cd) sends its stderr to cron mail, which hosts with no MTA silently discard (w-2026-09-13-dave-042; 0.1.39 fix, smarthome01 crons fired but wrote no log 2026-09-09 -> 13). The leading `date -u` prints an ISO timestamp so a run that dies mid-chain still leaves dated evidence. If no nvm install is found, the preamble is simply omitted; nothing to do for schedules that only need system binaries. `environment.*` values are emitted as `export VAR='val';` statements ahead of the command (not a bare `VAR='val' cd ...` prefix, which in POSIX shell only scopes to the `cd` builtin and never actually reaches the command after `&&`; a real, silent bug the old generator had).
112
112
 
113
- `scheduler verify` now runs this EXACT generated line (not a bare `sched.command`), under cron's actual minimal environment; never the verifying shell's `process.env`. This is deliberate: the old verify path inherited the operator's full login-shell env, which reliably masked this whole bug class. If `verify` reports `ok` today, that's an honest signal the schedule will actually fire under real cron.
113
+ `scheduler verify` now runs this EXACT generated line (not a bare `sched.command`), under cron's actual minimal environment; never the verifying shell's `process.env`. This is deliberate: the old verify path inherited the operator's full login-shell env, which reliably masked this whole bug class. If `verify` reports `ok` today, that's an honest signal the schedule will actually fire under real cron. The cron verify PATH is cron's base `/usr/bin:/bin`, then `/usr/local/bin`, then the PATH entries of `/etc/environment` when that file exists, because Debian cron reads `/etc/environment` through pam_env (w-2026-10-17-dave-007). A `PATH` in the schedule's own `environment` still replaces it. The result JSON carries the PATH used as `path`.
114
114
 
115
- **Quoting and `%` (w-2026-10-17-dave-006, 0.1.62).** cron hands the command field to `/bin/sh -c`, so the `bash -c` wrapper is single-quoted, with any single quote inside rendered as `'\''` (the same rule the systemd backend uses for `ExecStart`). sh therefore passes the inner script to bash untouched and every `$(...)`, `$1` and `${VAR}` is expanded by bash after the `cd`, not by sh in the cron user's HOME. The old double-quoted wrapper let sh expand them first (smarthome01 workspace-refresh failed 162 of 162 runs with `fatal: ambiguous argument 'origin/'`). cron also turns an unescaped `%` into a newline and feeds the rest of the line to stdin, so every `%` in the rendered line is written as `\%`; cron strips the backslash before sh sees it. `scheduler verify` on the cron backend runs the rendered command the way cron does: it takes the full `bash -c '...'` string, undoes the `\%` escaping, and executes it through `/bin/sh -c` from the working directory, so a quoting or `%` defect shows up in verify. Write schedule `command` strings exactly as you would type them into bash in the working directory: no `\$` or `\%` workarounds. Lines installed by 0.1.61 or earlier keep the double-quoted form until the schedule is re-installed; `list`, `remove` and `install` match both forms by the trailing `# <agent>-<id>` tag. The systemd backend does not escape `%` in `ExecStart`, so systemd still reads `%` as a unit specifier there.
115
+ **Quoting and `%` (w-2026-10-17-dave-006, 0.1.62).** cron hands the command field to `/bin/sh -c`, so the `bash -c` wrapper is single-quoted, with any single quote inside rendered as `'\''` (the same rule the systemd backend uses for `ExecStart`). sh therefore passes the inner script to bash untouched and every `$(...)`, `$1` and `${VAR}` is expanded by bash after the `cd`, not by sh in the cron user's HOME. The old double-quoted wrapper let sh expand them first (smarthome01 workspace-refresh failed 162 of 162 runs with `fatal: ambiguous argument 'origin/'`). cron also turns an unescaped `%` into a newline and feeds the rest of the line to stdin, so every `%` in the rendered line is written as `\%`; cron strips the backslash before sh sees it. `scheduler verify` on the cron backend runs the rendered command the way cron does: it takes the full `bash -c '...'` string, undoes the `\%` escaping, and executes it through `/bin/sh -c` from the working directory, so a quoting or `%` defect shows up in verify. Write schedule `command` strings exactly as you would type them into bash in the working directory: no `\$` or `\%` workarounds. Lines installed by 0.1.61 or earlier keep the double-quoted form until the schedule is re-installed; `list`, `remove` and `install` match both forms by the trailing `# <agent>-<id>` tag. The tag must end the line (w-2026-10-17-dave-007), so an id that is a prefix of another (`dave-foo` against `dave-foo-bar`) matches only its own line. When a schedule's `agent` field changes, `install` also removes the line with the same id under the old agent prefix and prints one line naming both tags. The systemd backend escapes `%` as `%%` and `$` as `$$` in `ExecStart` (w-2026-10-17-dave-007), because systemd resolves `%` specifiers and expands `$VAR` and `${VAR}` itself, inside single quotes too, before bash sees the command; bash then receives the command exactly as written.
116
116
 
117
117
  ### Verify your schedule before walking away
118
118
 
@@ -156,7 +156,7 @@ After installing, run the schedule's command once under the same environment the
156
156
  node core/tools/scheduler/verify.mjs schedules/my-task.json launchd
157
157
  ```
158
158
 
159
- If the backend is omitted, verify auto-detects (the same way `detect.mjs` does). Default timeout is 30 seconds; override with `--timeout`:
159
+ If the backend is omitted, verify auto-detects (the same way `detect.mjs` does). Default timeout is 120 seconds; override with `--timeout`, which is in seconds on both `pa scheduler verify` and `verify.mjs` (w-2026-10-17-dave-007; the `pa` CLI used to read it as milliseconds):
160
160
 
161
161
  ```bash
162
162
  node core/tools/scheduler/verify.mjs schedules/long-job.json launchd --timeout 60
@@ -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
+ }
@@ -494,6 +494,44 @@ export function buildCronLine(sched, workDir, env, opts = {}) {
494
494
  return `${sched.schedule} ${cronField} # ${tag}`;
495
495
  }
496
496
 
497
+ /**
498
+ * True when a crontab line carries `tag` (`# <agent>-<id>`) as its trailing
499
+ * comment. Anchored to the end of the line so `# dave-foo` does not also
500
+ * match `# dave-foo-bar` (w-2026-10-17-dave-007 item 2).
501
+ * @param {string} line
502
+ * @param {string} tag
503
+ * @returns {boolean}
504
+ */
505
+ export function cronLineHasTag(line, tag) {
506
+ return line.trimEnd().endsWith(tag);
507
+ }
508
+
509
+ /**
510
+ * True when a crontab line ends in any `# <agent>-<suffix>` tag for `agent`.
511
+ * @param {string} line
512
+ * @param {string} agent
513
+ * @returns {boolean}
514
+ */
515
+ export function cronLineHasAgentTag(line, agent) {
516
+ return /# (\S+)$/.exec(line.trimEnd())?.[1].startsWith(`${agent}-`) ?? false;
517
+ }
518
+
519
+ /**
520
+ * The tag of a crontab line installed for the same schedule id under a
521
+ * different agent (`# sam-<id>` when installing `# dave-<id>`), or null.
522
+ * The other agent prefix must be a single hyphen-free word so `# dave-foo-bar`
523
+ * is never read as agent `dave-foo` with id `bar` (w-2026-10-17-dave-007 item 5).
524
+ * @param {string} line
525
+ * @param {string} agent
526
+ * @param {string} id
527
+ * @returns {string | null}
528
+ */
529
+ export function otherAgentCronTag(line, agent, id) {
530
+ const m = /# ([A-Za-z0-9_]+)-(\S+)$/.exec(line.trimEnd());
531
+ if (!m || m[2] !== id || m[1] === agent) return null;
532
+ return `${m[1]}-${m[2]}`;
533
+ }
534
+
497
535
  function installCron(sched, workDir) {
498
536
  const agent = schedAgent(sched);
499
537
  const env = resolveEnvironment(sched);
@@ -508,10 +546,17 @@ function installCron(sched, workDir) {
508
546
  // no crontab yet
509
547
  }
510
548
 
511
- // Remove any existing entry for this schedule
549
+ // Remove any existing entry for this schedule, including one left under
550
+ // another agent prefix when the schedule's `agent` field changed.
512
551
  const filtered = existing
513
552
  .split('\n')
514
- .filter(line => !line.includes(tag))
553
+ .filter((line) => {
554
+ if (cronLineHasTag(line, tag)) return false;
555
+ const other = otherAgentCronTag(line, agent, sched.id);
556
+ if (!other) return true;
557
+ console.log(`Removed cron line tagged ${other}; replaced by ${agent}-${sched.id} (agent changed)`);
558
+ return false;
559
+ })
515
560
  .join('\n')
516
561
  .replace(/^\n+|\n+$/g, '');
517
562
 
@@ -843,6 +888,23 @@ function installLaunchd(sched, workDir, cron) {
843
888
 
844
889
  // ── Backend: systemd ─────────────────────────────────────────────────
845
890
 
891
+ /**
892
+ * Escape a command for systemd's ExecStart parser. systemd resolves `%`
893
+ * specifiers when the unit is loaded and expands `$VAR` / `${VAR}` itself,
894
+ * inside single quotes too, before bash ever sees the line. `%%` is a
895
+ * literal percent and `$$` a literal dollar (systemd.unit(5) Specifiers,
896
+ * systemd.service(5) Command lines). The single quote is escaped for the
897
+ * `bash -c '...'` wrapper.
898
+ * @param {string} command
899
+ * @returns {string}
900
+ */
901
+ export function escapeSystemdExecCommand(command) {
902
+ return command
903
+ .replace(/%/g, '%%')
904
+ .replace(/\$/g, '$$$$')
905
+ .replace(/'/g, `'\\''`);
906
+ }
907
+
846
908
  /**
847
909
  * Build the .service unit contents.
848
910
  * @param {object} sched
@@ -862,7 +924,7 @@ Description=${description}
862
924
  [Service]
863
925
  Type=oneshot
864
926
  WorkingDirectory=${workDir}
865
- ${envBlock}ExecStart=/usr/bin/env bash -c '${sched.command.replace(/'/g, `'\\''`)}'
927
+ ${envBlock}ExecStart=/usr/bin/env bash -c '${escapeSystemdExecCommand(sched.command)}'
866
928
 
867
929
  [Install]
868
930
  WantedBy=default.target
@@ -24,7 +24,7 @@ import { resolveHome } from '../home/resolve.mjs';
24
24
  import { execSync } from 'node:child_process';
25
25
  import { fileURLToPath } from 'node:url';
26
26
  import { detect } from './detect.mjs';
27
- import { launchdLabelPrefix } from './install.mjs';
27
+ import { launchdLabelPrefix, cronLineHasAgentTag } from './install.mjs';
28
28
  import { resolveAgent } from './resolve-agent.mjs';
29
29
 
30
30
  /**
@@ -70,12 +70,10 @@ export function list(backend, opts = {}) {
70
70
  function listCron(agent) {
71
71
  const lines = [`${headerName(agent)} schedules (cron):`, '─'.repeat(37)];
72
72
  let found = false;
73
- const tag = `# ${agent}-`;
74
-
75
73
  try {
76
74
  const crontab = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
77
75
  for (const line of crontab.split('\n')) {
78
- if (line.includes(tag)) {
76
+ if (cronLineHasAgentTag(line, agent)) {
79
77
  lines.push(` ${line}`);
80
78
  found = true;
81
79
  }
@@ -23,7 +23,7 @@ import { join, resolve, dirname } from 'node:path';
23
23
  import { resolveHome } from '../home/resolve.mjs';
24
24
  import { execSync } from 'node:child_process';
25
25
  import { fileURLToPath } from 'node:url';
26
- import { launchdLabelPrefix } from './install.mjs';
26
+ import { launchdLabelPrefix, cronLineHasTag } from './install.mjs';
27
27
  import { findRepoRootSync, walkUpForMarker } from '../instance-root/find.mjs';
28
28
  import { resolveAgent } from './resolve-agent.mjs';
29
29
  import { detect } from './detect.mjs';
@@ -44,7 +44,7 @@ function removeCron(schedId, agent) {
44
44
  }
45
45
 
46
46
  const lines = existing.split('\n');
47
- const filtered = lines.filter(line => !line.includes(tag)).join('\n').replace(/^\n+|\n+$/g, '');
47
+ const filtered = lines.filter(line => !cronLineHasTag(line, tag)).join('\n').replace(/^\n+|\n+$/g, '');
48
48
 
49
49
  if (filtered === existing.replace(/^\n+|\n+$/g, '')) {
50
50
  console.log(`No cron entry found for ${agent}-${schedId}`);
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * The verify deliberately does not require the schedule to be installed already —
13
13
  * it derives the env the same way install.mjs does and runs the command once. A
14
- * timeout (default 30s) bounds the run; verify is meant for "does this schedule
14
+ * timeout (default 120s) bounds the run; verify is meant for "does this schedule
15
15
  * even start?" not "does it complete its full work?".
16
16
  *
17
17
  * Backend defaults to the auto-detected backend for the host (launchd on macOS,
@@ -25,7 +25,7 @@
25
25
  * 4 timeout — command did not finish within the timeout
26
26
  */
27
27
 
28
- import { readFileSync, realpathSync } from 'node:fs';
28
+ import { readFileSync, realpathSync, existsSync } from 'node:fs';
29
29
  import { spawn } from 'node:child_process';
30
30
  import { resolve, dirname, join } from 'node:path';
31
31
  import { resolveHome } from '../home/resolve.mjs';
@@ -68,6 +68,54 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
68
68
  };
69
69
  }
70
70
 
71
+ /** Default `scheduler verify --timeout`, in seconds (both CLI paths). */
72
+ export const DEFAULT_VERIFY_TIMEOUT_SECONDS = 120;
73
+
74
+ /**
75
+ * Parse a `--timeout` value given in seconds into milliseconds. Both the
76
+ * `pa scheduler verify` CLI and this module's own CLI read seconds
77
+ * (w-2026-10-17-dave-007 item 3).
78
+ * @param {string | number} value
79
+ * @returns {number | null} milliseconds, or null when not a positive number
80
+ */
81
+ export function parseTimeoutSeconds(value) {
82
+ const n = Number(value);
83
+ if (value === '' || value == null || !Number.isFinite(n) || n <= 0) return null;
84
+ return Math.round(n * 1000);
85
+ }
86
+
87
+ /**
88
+ * Read the PATH entries from an `/etc/environment`-format file (pam_env
89
+ * hands this to cron jobs on Debian). Returns [] when the file is absent
90
+ * or carries no PATH line.
91
+ * @param {string} file
92
+ * @returns {string[]}
93
+ */
94
+ export function readEtcEnvironmentPath(file) {
95
+ if (!existsSync(file)) return [];
96
+ let pathValue = null;
97
+ for (const raw of readFileSync(file, 'utf8').split('\n')) {
98
+ const m = /^\s*(?:export\s+)?PATH\s*=\s*(.*?)\s*$/.exec(raw);
99
+ if (m) pathValue = m[1].replace(/^(["'])(.*)\1$/, '$2');
100
+ }
101
+ return pathValue ? pathValue.split(':').filter(Boolean) : [];
102
+ }
103
+
104
+ /**
105
+ * The PATH verify uses for the cron backend: cron's minimal base, then
106
+ * `/usr/local/bin`, then the PATH entries of `/etc/environment` when it
107
+ * exists, without duplicates (w-2026-10-17-dave-007 item 4). Real cron on
108
+ * Debian gets `/etc/environment` through pam_env, so the bare base alone
109
+ * was stricter than the runtime.
110
+ * @param {string} basePath
111
+ * @param {string} [etcEnvironmentFile]
112
+ * @returns {string}
113
+ */
114
+ export function composeCronVerifyPath(basePath, etcEnvironmentFile = '/etc/environment') {
115
+ const entries = [...basePath.split(':'), '/usr/local/bin', ...readEtcEnvironmentPath(etcEnvironmentFile)];
116
+ return [...new Set(entries.filter(Boolean))].join(':');
117
+ }
118
+
71
119
  /**
72
120
  * Compute the env the named backend would install for this schedule.
73
121
  *
@@ -88,9 +136,11 @@ export function minimalCronEnv({ home = resolveHome(), user = process.env.USER |
88
136
  *
89
137
  * @param {object} sched
90
138
  * @param {string} backend
139
+ * @param {object} [opts]
140
+ * @param {string} [opts.etcEnvironmentFile] - cron only; defaults to /etc/environment
91
141
  * @returns {Record<string,string>}
92
142
  */
93
- export function computeBackendEnv(sched, backend) {
143
+ export function computeBackendEnv(sched, backend, opts = {}) {
94
144
  const explicit = resolveEnvironment(sched);
95
145
  if (backend === 'launchd') {
96
146
  // launchd builds the agent env from EnvironmentVariables alone. Verify
@@ -98,7 +148,9 @@ export function computeBackendEnv(sched, backend) {
98
148
  return withLaunchdDefaults(explicit);
99
149
  }
100
150
  if (backend === 'cron') {
101
- return { ...minimalCronEnv(), ...explicit };
151
+ const base = minimalCronEnv();
152
+ base.PATH = composeCronVerifyPath(base.PATH, opts.etcEnvironmentFile);
153
+ return { ...base, ...explicit };
102
154
  }
103
155
  // systemd/schtasks: "parent env then explicit overrides". Replicate by
104
156
  // layering explicit over process.env, dropping any keys the parent has
@@ -266,7 +318,9 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
266
318
  * @param {string} scheduleFile - Path to the schedule JSON
267
319
  * @param {object} [opts]
268
320
  * @param {string} [opts.backend] - Override backend (default: auto-detect)
269
- * @param {number} [opts.timeoutMs] - Timeout in ms (default: 30000)
321
+ * @param {number} [opts.timeoutMs] - Timeout in ms (default: 120000)
322
+ * @param {string} [opts.etcEnvironmentFile] - Test-only override for the
323
+ * cron backend's `/etc/environment` path.
270
324
  * @param {boolean} [opts.hasNvm] - Test-only override; defaults to a real
271
325
  * filesystem check of $HOME/.nvm/nvm.sh, matching install-time detection.
272
326
  * @param {string} [opts.cronLogPath] - Test-only override for the cron
@@ -274,7 +328,7 @@ export function runVerifyArgv(bin, args, workDir, env, timeoutMs) {
274
328
  * `/tmp/<agent>-<id>-verify.log` — distinct from the INSTALLED
275
329
  * schedule's own `/tmp/<agent>-<id>.log`, so a verify run never
276
330
  * clobbers/interleaves with real cron output).
277
- * @returns {Promise<{ result: 'ok' | 'fail-exit' | 'fail-spawn' | 'fail-timeout', exitCode: number, sched: object, backend: string, env: Record<string,string>, workDir: string, run: object, argv: string[] | null }>}
331
+ * @returns {Promise<{ result: 'ok' | 'fail-exit' | 'fail-spawn' | 'fail-timeout', exitCode: number, sched: object, backend: string, env: Record<string,string>, path: string | null, workDir: string, run: object, argv: string[] | null }>}
278
332
  */
279
333
  export async function verify(scheduleFile, opts = {}) {
280
334
  const sched = readSchedule(scheduleFile);
@@ -286,10 +340,10 @@ export async function verify(scheduleFile, opts = {}) {
286
340
  }
287
341
 
288
342
  const backend = opts.backend || sched.backend || detectDefaultBackend();
289
- const timeoutMs = opts.timeoutMs ?? 30000;
343
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_VERIFY_TIMEOUT_SECONDS * 1000;
290
344
 
291
345
  const workDir = resolveWorkDir(sched.workingDirectory);
292
- const env = computeBackendEnv(sched, backend);
346
+ const env = computeBackendEnv(sched, backend, { etcEnvironmentFile: opts.etcEnvironmentFile });
293
347
 
294
348
  let run;
295
349
  let argv = null;
@@ -320,7 +374,7 @@ export async function verify(scheduleFile, opts = {}) {
320
374
  exitCode = 2;
321
375
  }
322
376
 
323
- return { result, exitCode, sched, backend, env, workDir, run, argv };
377
+ return { result, exitCode, sched, backend, env, path: env.PATH ?? null, workDir, run, argv };
324
378
  }
325
379
 
326
380
  // ── CLI entry point ──────────────────────────────────────────────────
@@ -339,7 +393,7 @@ Arguments:
339
393
  systemd, schtasks). Defaults to auto-detected backend.
340
394
 
341
395
  Flags:
342
- --timeout <seconds> Kill the verify run after this many seconds (default: 30).
396
+ --timeout <seconds> Kill the verify run after this many seconds (default: 120).
343
397
  -h, --help Print this message and exit 0.
344
398
 
345
399
  Exit codes:
@@ -375,7 +429,7 @@ if (isDirectlyInvoked()) {
375
429
  // Parse: positional <schedule.json>, optional positional <backend>, optional --timeout N.
376
430
  let scheduleFile = null;
377
431
  let backend = null;
378
- let timeoutSeconds = 30;
432
+ let timeoutSeconds = DEFAULT_VERIFY_TIMEOUT_SECONDS;
379
433
 
380
434
  for (let i = 0; i < args.length; i++) {
381
435
  const a = args[i];
@@ -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.62",
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",