@stravica-ai/digital-operator-pa 0.1.16 → 0.1.18

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.
Files changed (35) hide show
  1. package/dist/engineering-pa-cli/bin/estate-sync +0 -0
  2. package/dist/engineering-pa-cli/bin/launch +0 -0
  3. package/dist/engineering-pa-cli/bin/pa +0 -0
  4. package/dist/engineering-pa-cli/bin/pa-coordinator-hook +0 -0
  5. package/dist/engineering-pa-cli/bin/research +0 -0
  6. package/dist/engineering-pa-cli/bin/scheduler +0 -0
  7. package/dist/engineering-pa-cli/bin/secrets +0 -0
  8. package/dist/engineering-pa-cli/bin/smoke +0 -0
  9. package/dist/engineering-pa-cli/bin/sync +0 -0
  10. package/dist/engineering-pa-cli/docs/gsc.md +14 -0
  11. package/dist/engineering-pa-cli/scripts/migrate-doc-paths.mjs +0 -0
  12. package/dist/engineering-pa-cli/src/agent/cli.mjs +6 -11
  13. package/dist/engineering-pa-cli/src/coordinator-hook/self.mjs +4 -4
  14. package/dist/engineering-pa-cli/src/environment/cli.mjs +4 -6
  15. package/dist/engineering-pa-cli/src/environment/probes.mjs +3 -5
  16. package/dist/engineering-pa-cli/src/gsc/auth.mjs +37 -15
  17. package/dist/engineering-pa-cli/src/gsc/cloudflare.mjs +16 -7
  18. package/dist/engineering-pa-cli/src/pa/cli.mjs +3 -5
  19. package/dist/engineering-pa-cli/src/pa/session.mjs +24 -1
  20. package/dist/engineering-pa-cli/src/pa/work-cli.mjs +5 -7
  21. package/dist/engineering-pa-core/bin/desk +0 -0
  22. package/dist/engineering-pa-core/bin/distill +0 -0
  23. package/dist/engineering-pa-core/bin/memory-sync +0 -0
  24. package/dist/engineering-pa-core/bin/slice +0 -0
  25. package/dist/engineering-pa-core/src/desk/team-scope.mjs +9 -64
  26. package/dist/engineering-pa-core/src/preferences.mjs +133 -0
  27. package/dist/engineering-pa-migrate-inspect/bin/migrate-inspect +0 -0
  28. package/dist/engineering-pa-scaffold/bin/pa-init +0 -0
  29. package/dist/engineering-pa-workspace/src/api.mjs +11 -18
  30. package/dist/engineering-pa-workspace/src/install-daemon.mjs +6 -5
  31. package/dist/engineering-pa-workspace/src/server.mjs +6 -5
  32. package/dist/engineering-pa-workspace/src/status-mon/poller.mjs +4 -11
  33. package/dist/engineering-pa-workspace/src/status-mon/run.mjs +30 -16
  34. package/dist/engineering-pa-workspace/src/ws.mjs +5 -4
  35. package/package.json +6 -5
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -37,10 +37,20 @@ Two npm packages (declared in `@stravica-ai/digital-operator-pa`):
37
37
 
38
38
  5. **Store the SA key in the SOPS vault**:
39
39
  ```bash
40
+ # Legacy single-file vault:
40
41
  pnpm exec secrets set GSC_SERVICE_ACCOUNT_KEY -
42
+ # Scoped-vault estate (workspace or canonical layout):
43
+ pnpm exec secrets set --scope <scope> GSC_SERVICE_ACCOUNT_KEY -
41
44
  # paste the entire JSON contents, then Ctrl-D
42
45
  ```
43
46
 
47
+ On scoped-vault hosts, `pa gsc` reads the key from whichever pot
48
+ `secrets get` would use — i.e. it honours (in order) `SECRETS_DEFAULT_SCOPE`,
49
+ the `defaultScope` in `~/.config/stravica-pa/defaults.json`, or the
50
+ calling PA's `identity.json` — before falling back to the legacy
51
+ single-file vault. Store the key in the pot the host is already
52
+ configured to read; no `pa gsc`-specific configuration is needed.
53
+
44
54
  6. **Store Cloudflare credentials** (also one-time). Two key shapes are
45
55
  supported; pick whichever fits your setup:
46
56
 
@@ -70,6 +80,10 @@ Two npm packages (declared in `@stravica-ai/digital-operator-pa`):
70
80
  no `--account` flag is passed and the `CLOUDFLARE_PAGES_*` unsuffixed
71
81
  pair is absent. Don't rely on it — set the `CLOUDFLARE_PAGES_*` keys.
72
82
 
83
+ Same scoped-vault note as the SA key: on scoped hosts these live in
84
+ whichever pot `secrets get` reads (see step 5); use `secrets set --scope
85
+ <scope> ...` to store them there.
86
+
73
87
  ## Per-domain setup (each time you onboard a new property)
74
88
 
75
89
  7. **Grant the service-account access to the GSC property.** Until the
@@ -29,6 +29,7 @@ import { hostname as osHostname } from 'node:os';
29
29
  import { dirname, join } from 'node:path';
30
30
 
31
31
  import { loadBundledManifestSync } from '../../../engineering-pa-core/src/agent-baseline/manifest.mjs';
32
+ import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
32
33
 
33
34
  import { auditManifest } from './audit.mjs';
34
35
  import { getRepoRoot } from '../pa/repo-root.mjs';
@@ -201,12 +202,9 @@ function isStale(cache, stalenessMinutes) {
201
202
 
202
203
  function readStalenessMinutes(repoRoot) {
203
204
  try {
204
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
205
- if (existsSync(prefsPath)) {
206
- const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
207
- const value = prefs?.agentAudit?.cacheStalenessMinutes;
208
- if (typeof value === 'number' && value > 0) return value;
209
- }
205
+ const prefs = readPreferencesSync(repoRoot);
206
+ const value = prefs?.agentAudit?.cacheStalenessMinutes;
207
+ if (typeof value === 'number' && value > 0) return value;
210
208
  } catch {
211
209
  // fall through
212
210
  }
@@ -215,11 +213,8 @@ function readStalenessMinutes(repoRoot) {
215
213
 
216
214
  function readPaName(repoRoot) {
217
215
  try {
218
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
219
- if (existsSync(prefsPath)) {
220
- const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
221
- if (typeof prefs.paName === 'string' && prefs.paName.length > 0) return prefs.paName;
222
- }
216
+ const prefs = readPreferencesSync(repoRoot);
217
+ if (prefs && typeof prefs.paName === 'string' && prefs.paName.length > 0) return prefs.paName;
223
218
  } catch {
224
219
  // fall through
225
220
  }
@@ -11,15 +11,15 @@
11
11
  //
12
12
  // Returns a string paName, or null when unresolved.
13
13
 
14
- import { readFile } from 'node:fs/promises';
15
- import { join } from 'node:path';
14
+ import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
16
15
 
17
16
  export async function resolveSelfAgent(repoRoot, env = process.env) {
18
17
  if (env.PA_SELF_AGENT) return env.PA_SELF_AGENT;
19
18
  if (!repoRoot) return null;
20
19
  try {
21
- const raw = await readFile(join(repoRoot, 'operator', 'config', 'preferences.json'), 'utf8');
22
- const config = JSON.parse(raw);
20
+ // Overlay-aware: preferences.local.json shallow-merges OVER the shared
21
+ // preferences.json so a device-local paName wins for the coordinator hook.
22
+ const config = await readPreferences(repoRoot);
23
23
  if (config && typeof config.paName === 'string' && config.paName.trim()) {
24
24
  return config.paName.trim();
25
25
  }
@@ -22,6 +22,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
22
22
  import { join, dirname } from 'node:path';
23
23
  import { pathToFileURL } from 'node:url';
24
24
  import { getRepoRoot } from '../pa/repo-root.mjs';
25
+ import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
25
26
  import {
26
27
  probePlatform,
27
28
  probeRuntimes,
@@ -252,12 +253,9 @@ function isStale(cache, stalenessMinutes) {
252
253
 
253
254
  function readStalenessMinutes(repoRoot) {
254
255
  try {
255
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
256
- if (existsSync(prefsPath)) {
257
- const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
258
- const value = prefs?.environment?.cacheStalenessMinutes;
259
- if (typeof value === 'number' && value > 0) return value;
260
- }
256
+ const prefs = readPreferencesSync(repoRoot);
257
+ const value = prefs?.environment?.cacheStalenessMinutes;
258
+ if (typeof value === 'number' && value > 0) return value;
261
259
  } catch {
262
260
  // fall through
263
261
  }
@@ -10,6 +10,7 @@ import { arch, platform, release, homedir } from 'node:os';
10
10
  import { basename, join, resolve, dirname } from 'node:path';
11
11
  import { resolve4 } from 'node:dns/promises';
12
12
  import { fileURLToPath } from 'node:url';
13
+ import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
13
14
 
14
15
  const execFileP = promisify(execFile);
15
16
 
@@ -234,11 +235,8 @@ export async function probeWorkspace(repoRoot) {
234
235
 
235
236
  function readWorkspacePort(repoRoot) {
236
237
  try {
237
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
238
- if (existsSync(prefsPath)) {
239
- const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
240
- if (typeof prefs.workspacePort === 'number') return prefs.workspacePort;
241
- }
238
+ const prefs = readPreferencesSync(repoRoot);
239
+ if (prefs && typeof prefs.workspacePort === 'number') return prefs.workspacePort;
242
240
  } catch {
243
241
  // fall through
244
242
  }
@@ -8,7 +8,7 @@
8
8
  // override and never touch the network. The SA-key read also accepts an
9
9
  // override so tests can pass in a synthetic key without going through SOPS.
10
10
 
11
- import { defaultPaths, checkVault, secretsFileExists, sopsDecrypt, VaultError } from '../secrets/vault.mjs';
11
+ import { resolveCommandPaths, checkVault, secretsFileExists, sopsDecrypt, VaultError } from '../secrets/vault.mjs';
12
12
 
13
13
  // Read-only scope: searchanalytics.query + sites.list + sitemaps.list.
14
14
  // Read-write scope: sites.add, sitemaps.submit, site verification.
@@ -25,9 +25,26 @@ const DEFAULT_VAULT_KEY = 'GSC_SERVICE_ACCOUNT_KEY';
25
25
  /**
26
26
  * Load the service-account JSON object from the SOPS vault.
27
27
  *
28
+ * Vault path resolution goes through `resolveCommandPaths` — the same
29
+ * resolver `secrets get`/`set` use — so this honours all the same layouts
30
+ * without duplicating logic: an explicit `overrides.paths` (from a
31
+ * pre-resolved `--scope`/`--file`) is a pass-through; otherwise the
32
+ * default-scope resolver runs (SECRETS_DEFAULT_SCOPE env, defaultScope in
33
+ * ~/.config/stravica-pa/defaults.json, or the calling PA's identity.json)
34
+ * and points at a scoped `.vault/scopes/<scope>.enc.json`; if nothing is
35
+ * configured anywhere it falls back to the legacy single-file vault.
36
+ *
37
+ * A stderr `note: writing to '<scope>' pot ...` line is printed by the
38
+ * resolver on the default-scope path — same behaviour as `secrets get`,
39
+ * kept intentionally consistent so operators see the same routing signal
40
+ * from every consumer of the vault.
41
+ *
28
42
  * @param {object} overrides
29
- * @param {object} [overrides.paths] Vault paths (from defaultPaths()).
43
+ * @param {object} [overrides.paths] Vault paths already resolved (e.g. from --scope upstream).
30
44
  * @param {string} [overrides.vaultKey] Vault key name (default GSC_SERVICE_ACCOUNT_KEY).
45
+ * @param {string} [overrides.cwd] Test seam — cwd used for workspace resolution.
46
+ * @param {object} [overrides.env] Test seam — env used for scope-default resolution.
47
+ * @param {string} [overrides.home] Test seam — home used for keyfile + defaults.json lookup.
31
48
  * @param {string} [overrides.serviceAccountKey]
32
49
  * Raw SA JSON string. If provided, skips vault entirely (testing).
33
50
  * @returns {Promise<object>} Parsed service-account JSON.
@@ -39,34 +56,39 @@ export async function loadServiceAccountKey(overrides = {}) {
39
56
  : overrides.serviceAccountKey;
40
57
  }
41
58
 
42
- const paths = overrides.paths || defaultPaths();
59
+ const paths = resolveCommandPaths(overrides);
43
60
  const vaultKey = overrides.vaultKey || DEFAULT_VAULT_KEY;
44
61
 
45
62
  const state = checkVault(paths);
46
63
  if (!state.ok) {
64
+ // Print then throw — main() suppresses messages for errors that carry
65
+ // an exitCode (the throw-site is expected to have reported), so
66
+ // omitting the console.error would exit silently through the pa gsc
67
+ // binary.
68
+ console.error(state.message);
47
69
  throw new VaultError(state.message, state.exitCode ?? 1);
48
70
  }
49
71
  if (!secretsFileExists(paths)) {
50
- throw new VaultError(
51
- `No secrets file at ${paths.secretsFile}. Run 'secrets init' first.`,
52
- 1,
53
- );
72
+ const message = `No secrets file at ${paths.secretsFile}.` + (paths.scoped
73
+ ? ` Store the service-account JSON with:\n secrets set --scope ${paths.scope} ${vaultKey} -\n(then paste the JSON, then Ctrl-D)`
74
+ : " Run 'secrets init' first.");
75
+ console.error(message);
76
+ throw new VaultError(message, 1);
54
77
  }
55
78
  const data = await sopsDecrypt(paths, paths.secretsFile);
56
79
  if (!(vaultKey in data)) {
57
- throw new VaultError(
58
- `Vault key "${vaultKey}" not found. Store the service-account JSON with:\n secrets set ${vaultKey} -\n(then paste the JSON, then Ctrl-D)`,
59
- 1,
60
- );
80
+ const scopeHint = paths.scoped ? ` --scope ${paths.scope}` : '';
81
+ const message = `Vault key "${vaultKey}" not found in ${paths.secretsFile}. Store the service-account JSON with:\n secrets set${scopeHint} ${vaultKey} -\n(then paste the JSON, then Ctrl-D)`;
82
+ console.error(message);
83
+ throw new VaultError(message, 1);
61
84
  }
62
85
  const raw = data[vaultKey];
63
86
  try {
64
87
  return typeof raw === 'string' ? JSON.parse(raw) : raw;
65
88
  } catch (e) {
66
- throw new VaultError(
67
- `Vault key "${vaultKey}" is not valid JSON: ${e.message}`,
68
- 2,
69
- );
89
+ const message = `Vault key "${vaultKey}" is not valid JSON: ${e.message}`;
90
+ console.error(message);
91
+ throw new VaultError(message, 2);
70
92
  }
71
93
  }
72
94
 
@@ -24,7 +24,7 @@
24
24
  //
25
25
  // `fetchImpl` and `now` are injectable for tests.
26
26
 
27
- import { defaultPaths, checkVault, secretsFileExists, sopsDecrypt, VaultError } from '../secrets/vault.mjs';
27
+ import { resolveCommandPaths, checkVault, secretsFileExists, sopsDecrypt, VaultError } from '../secrets/vault.mjs';
28
28
 
29
29
  const CF_API = 'https://api.cloudflare.com/client/v4';
30
30
 
@@ -122,14 +122,23 @@ export async function loadCloudflareCreds(overrides = {}) {
122
122
  if (overrides.vaultData) {
123
123
  data = overrides.vaultData;
124
124
  } else {
125
- const paths = overrides.paths || defaultPaths();
125
+ // resolveCommandPaths honours --scope/--file (via a pre-resolved
126
+ // overrides.paths), the SECRETS_DEFAULT_SCOPE env, defaults.json's
127
+ // defaultScope, and the calling PA's identity.json, before falling
128
+ // back to the legacy single-file vault. Same routing as `secrets get`
129
+ // and `pa gsc`'s SA-key loader — one resolver, one routing signal.
130
+ const paths = resolveCommandPaths(overrides);
126
131
  const state = checkVault(paths);
127
- if (!state.ok) throw new VaultError(state.message, state.exitCode ?? 1);
132
+ if (!state.ok) {
133
+ console.error(state.message);
134
+ throw new VaultError(state.message, state.exitCode ?? 1);
135
+ }
128
136
  if (!secretsFileExists(paths)) {
129
- throw new VaultError(
130
- `No secrets file at ${paths.secretsFile}. Run 'secrets init' first.`,
131
- 1,
132
- );
137
+ const message = `No secrets file at ${paths.secretsFile}.` + (paths.scoped
138
+ ? ` Store Cloudflare credentials with 'secrets set --scope ${paths.scope} <KEY> <value>'.`
139
+ : " Run 'secrets init' first.");
140
+ console.error(message);
141
+ throw new VaultError(message, 1);
133
142
  }
134
143
  data = await sopsDecrypt(paths, paths.secretsFile);
135
144
  }
@@ -10,6 +10,7 @@ import { dirname, join, resolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { run as runDesk } from '../../../engineering-pa-core/src/desk/cli.mjs';
12
12
  import { PA_PACKAGE_NAME } from '../../../engineering-pa-core/src/config/pa-package-name.mjs';
13
+ import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
13
14
  import { main as runMigrateInspect } from '../../../engineering-pa-migrate-inspect/src/cli.mjs';
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -198,11 +199,8 @@ async function doStatus(opts = {}) {
198
199
  function resolveWorkspacePort(repoRoot, opts = {}) {
199
200
  if (opts.workspacePort) return opts.workspacePort;
200
201
  try {
201
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
202
- if (existsSync(prefsPath)) {
203
- const prefs = JSON.parse(readFileSync(prefsPath, 'utf8'));
204
- if (typeof prefs.workspacePort === 'number') return prefs.workspacePort;
205
- }
202
+ const prefs = readPreferencesSync(repoRoot);
203
+ if (prefs && typeof prefs.workspacePort === 'number') return prefs.workspacePort;
206
204
  } catch { /* fall through to default */ }
207
205
  return 4200;
208
206
  }
@@ -21,6 +21,8 @@ import { readFile, stat } from 'node:fs/promises';
21
21
  import { spawn } from 'node:child_process';
22
22
  import { dirname, join, resolve } from 'node:path';
23
23
 
24
+ import { mergePreferences } from '../../../engineering-pa-core/src/preferences.mjs';
25
+
24
26
  /**
25
27
  * Resolve the operator repo root with the priority documented in the brief:
26
28
  * 1. process.env.DAVE_REPO_ROOT (if set and the directory exists)
@@ -93,12 +95,22 @@ export class SessionError extends Error {
93
95
  * and confirm `agents/<paName>/` exists. Returns a plan object describing
94
96
  * what the verb is about to do — `runSession` then either execs `claude`
95
97
  * or hands the plan to a test seam.
98
+ *
99
+ * The optional device-local overlay `operator/config/preferences.local.json`
100
+ * shallow-merges OVER the base so a device-scoped paName can win on this
101
+ * host without editing the shared file (part of w-2026-08-07-002's overlay
102
+ * sweep). An absent/malformed overlay is silently ignored so behaviour
103
+ * without an overlay is byte-identical to the pre-overlay implementation;
104
+ * every error path (missing/malformed/non-object/missing-paName) still
105
+ * points the operator at the SHARED base file, which is the one they need
106
+ * to fix to make this device bootable at all.
96
107
  */
97
108
  export async function buildSessionPlan(repoRoot, seam = {}) {
98
109
  const readFileImpl = seam.readFile ?? readFile;
99
110
  const pathExists = seam.pathExists ?? defaultPathExists;
100
111
 
101
112
  const preferencesPath = join(repoRoot, 'operator', 'config', 'preferences.json');
113
+ const overlayPath = join(repoRoot, 'operator', 'config', 'preferences.local.json');
102
114
 
103
115
  let raw;
104
116
  try {
@@ -133,7 +145,18 @@ export async function buildSessionPlan(repoRoot, seam = {}) {
133
145
  );
134
146
  }
135
147
 
136
- const paName = prefs.paName;
148
+ // Device-local overlay: fail-open. Missing file (ENOENT) OR malformed JSON
149
+ // OR non-object shape leaves the merged view equal to the base.
150
+ let overlay = null;
151
+ try {
152
+ const overlayRaw = await readFileImpl(overlayPath, 'utf8');
153
+ try {
154
+ overlay = JSON.parse(overlayRaw);
155
+ } catch { overlay = null; }
156
+ } catch { overlay = null; }
157
+ const merged = mergePreferences(prefs, overlay) || prefs;
158
+
159
+ const paName = merged.paName;
137
160
  if (typeof paName !== 'string' || paName.trim().length === 0) {
138
161
  throw new SessionError(
139
162
  `preferences.json at ${preferencesPath} is missing required field \`paName\`.`,
@@ -8,8 +8,7 @@
8
8
  // PA name (for --mine resolution) is read from
9
9
  // operator/config/preferences.json. Falls back to 'pa' if missing.
10
10
 
11
- import { readFile } from 'node:fs/promises';
12
- import { join } from 'node:path';
11
+ import { readPreferences } from '../../../engineering-pa-core/src/preferences.mjs';
13
12
 
14
13
  import { createWork, setWorkStatus, listWork } from './work.mjs';
15
14
  import { getRepoRoot } from './repo-root.mjs';
@@ -80,11 +79,10 @@ function parseFlags(args) {
80
79
  async function getPaName() {
81
80
  try {
82
81
  const repoRoot = await getRepoRoot();
83
- const raw = await readFile(
84
- join(repoRoot, 'operator', 'config', 'preferences.json'),
85
- 'utf8',
86
- );
87
- const config = JSON.parse(raw);
82
+ // Overlay-aware: preferences.local.json shallow-merges OVER the shared
83
+ // preferences.json so any device-local paName wins on this device.
84
+ const config = await readPreferences(repoRoot);
85
+ if (!config) return 'pa';
88
86
  return config.paName || config.agentName || 'pa';
89
87
  } catch {
90
88
  return 'pa';
File without changes
File without changes
File without changes
File without changes
@@ -32,9 +32,11 @@
32
32
  // desk/pa (show everything). This is load-bearing — existing installs must
33
33
  // not silently start hiding projects on a package bump.
34
34
 
35
- import { readFile } from 'node:fs/promises';
36
- import { readFileSync } from 'node:fs';
37
- import { join } from 'node:path';
35
+ import { readPreferences, readPreferencesSync, mergePreferences } from '../preferences.mjs';
36
+
37
+ // Re-export the merge helper so callers that still reach for it through
38
+ // team-scope keep working. Preferences.mjs is the single source of truth.
39
+ export { mergePreferences };
38
40
 
39
41
  /**
40
42
  * Resolve the agent identity a project is scoped to, or null if the
@@ -106,59 +108,6 @@ export function filterProjectsByTeam(projects, opts = {}) {
106
108
  * Absent/blank -> null, which means "do not team-scope nav" (fail open,
107
109
  * backwards compatible: pre-team devices show every nav fragment).
108
110
  */
109
- /**
110
- * Read + JSON-parse a file, returning the parsed value or null on any error
111
- * (missing file, unreadable, malformed JSON). Shared by the base + overlay
112
- * reads so both fail-open identically.
113
- */
114
- function readJsonSync(path) {
115
- try {
116
- return JSON.parse(readFileSync(path, 'utf8'));
117
- } catch {
118
- return null;
119
- }
120
- }
121
-
122
- async function readJsonAsync(path) {
123
- try {
124
- return JSON.parse(await readFile(path, 'utf8'));
125
- } catch {
126
- return null;
127
- }
128
- }
129
-
130
- /**
131
- * Shallow-merge the optional device-local overlay
132
- * (operator/config/preferences.local.json) OVER the parsed base
133
- * preferences.json.
134
- *
135
- * The overlay is device-local state that must NOT round-trip through
136
- * library-sync. preferences.json is down-path-materialised from a shared
137
- * canonical source (see the estate's .library-sync manifest — the barry
138
- * `config/` mount), so a device-scoped field (e.g. `team`) written into the
139
- * synced file would be clobbered on the next cycle, and a canonical edit
140
- * would stamp one device's identity onto every device. The overlay lives
141
- * outside every mount source, so down-path reconciliation never tracks or
142
- * deletes it (computeDeletions is manifest-driven — it only removes
143
- * previously-materialised files), and it is gitignored so estate git-sync
144
- * never carries it between hosts (see scaffold buildGitignore).
145
- *
146
- * Precedence: overlay keys win; keys the overlay omits fall through to the
147
- * base. An absent/malformed overlay leaves the base exactly as-is, so a
148
- * device with no overlay behaves identically to pre-overlay releases.
149
- *
150
- * `base`/`overlay` are already-parsed values (a plain object, or null when
151
- * the file was missing/malformed). Returns null only when BOTH are absent,
152
- * preserving the "no config at all -> fail-open defaults" path through
153
- * normaliseTeamScopeConfig.
154
- */
155
- function mergePreferences(base, overlay) {
156
- const b = base && typeof base === 'object' && !Array.isArray(base) ? base : null;
157
- const o = overlay && typeof overlay === 'object' && !Array.isArray(overlay) ? overlay : null;
158
- if (!b && !o) return null;
159
- return { ...(b || {}), ...(o || {}) };
160
- }
161
-
162
111
  function normaliseTeamScopeConfig(config) {
163
112
  const c = config && typeof config === 'object' ? config : {};
164
113
  const paName = c.paName || c.agentName || 'pa';
@@ -189,11 +138,9 @@ function normaliseTeamScopeConfig(config) {
189
138
  * @returns {Promise<{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }>}
190
139
  */
191
140
  export async function readTeamScopeConfig(repoRoot) {
192
- // Base (synced) preferences, then the device-local overlay merged OVER it.
141
+ // Base + device-local overlay via the shared overlay-aware reader.
193
142
  // Missing/malformed either file -> null for that layer -> defaults.
194
- const base = await readJsonAsync(join(repoRoot, 'operator', 'config', 'preferences.json'));
195
- const overlay = await readJsonAsync(join(repoRoot, 'operator', 'config', 'preferences.local.json'));
196
- return normaliseTeamScopeConfig(mergePreferences(base, overlay));
143
+ return normaliseTeamScopeConfig(await readPreferences(repoRoot));
197
144
  }
198
145
 
199
146
  /**
@@ -207,11 +154,9 @@ export async function readTeamScopeConfig(repoRoot) {
207
154
  * @returns {{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }}
208
155
  */
209
156
  export function readTeamScopeConfigSync(repoRoot) {
210
- // Base (synced) preferences, then the device-local overlay merged OVER it.
157
+ // Base + device-local overlay via the shared overlay-aware reader.
211
158
  // Missing/malformed either file -> null for that layer -> defaults.
212
- const base = readJsonSync(join(repoRoot, 'operator', 'config', 'preferences.json'));
213
- const overlay = readJsonSync(join(repoRoot, 'operator', 'config', 'preferences.local.json'));
214
- return normaliseTeamScopeConfig(mergePreferences(base, overlay));
159
+ return normaliseTeamScopeConfig(readPreferencesSync(repoRoot));
215
160
  }
216
161
 
217
162
  /**
@@ -0,0 +1,133 @@
1
+ // preferences.mjs — overlay-aware reader for the operator preferences file.
2
+ //
3
+ // The estate ships a shared `operator/config/preferences.json` (down-path-
4
+ // materialised from a canonical source via library-sync) plus an optional
5
+ // device-local overlay at `operator/config/preferences.local.json` that
6
+ // shallow-merges OVER the base. The overlay lives outside every mount source
7
+ // so down-path reconciliation never tracks it, and it is gitignored so
8
+ // estate git-sync never carries it between hosts.
9
+ //
10
+ // This module is the ONE place every consumer reaches for merged
11
+ // preferences. Before this module existed, ~15 consumer sites (workspace
12
+ // API surfaces, workspace server bind, CLI verbs, environment probes,
13
+ // coordinator-hook) each read `preferences.json` directly with
14
+ // `readFileSync`/`readFile`, which ignored the overlay and made every
15
+ // device-local identity field (paName, pa.*, workspacePort) invisible on
16
+ // every surface except the two `desk/team-scope` readers that had the
17
+ // overlay baked in. See w-2026-08-07-002 and d-2026-08-07-005 for the
18
+ // decision to sweep every reader onto a single overlay-aware helper.
19
+ //
20
+ // Merge semantics: SHALLOW. Overlay keys win at the top level; keys the
21
+ // overlay omits fall through to the base. Deep-merge was considered and
22
+ // rejected at ratification — the existing team-scope reader is shallow,
23
+ // every existing overlay caller already assumes shallow, and deep-merge
24
+ // changes the behaviour of nested-object fields (e.g. `pa: { name, role,
25
+ // roleTitle }`) in ways that would silently reshape callers if we upgraded
26
+ // under them. If a caller needs deep-merge for one field it can compose
27
+ // itself; the shared helper stays predictable.
28
+ //
29
+ // Fail-open posture: an absent or malformed EITHER file yields null for
30
+ // that layer, exactly as before. A device with no overlay reads
31
+ // byte-identically to pre-overlay releases; a device with a malformed
32
+ // overlay falls back to base-only rather than erroring; a device with
33
+ // neither file yields null (the "no config at all" signal every caller
34
+ // already handles).
35
+
36
+ import { readFile } from 'node:fs/promises';
37
+ import { readFileSync } from 'node:fs';
38
+ import { join } from 'node:path';
39
+
40
+ /**
41
+ * Path to the shared preferences file for a given repo root.
42
+ * Exported so callers that still need the raw path (e.g. `pa session`'s
43
+ * error messages) can compute it without duplicating the join.
44
+ *
45
+ * @param {string} repoRoot
46
+ * @returns {string}
47
+ */
48
+ export function preferencesPath(repoRoot) {
49
+ return join(repoRoot, 'operator', 'config', 'preferences.json');
50
+ }
51
+
52
+ /**
53
+ * Path to the device-local overlay file for a given repo root.
54
+ * @param {string} repoRoot
55
+ * @returns {string}
56
+ */
57
+ export function preferencesOverlayPath(repoRoot) {
58
+ return join(repoRoot, 'operator', 'config', 'preferences.local.json');
59
+ }
60
+
61
+ /**
62
+ * Shallow-merge the optional device-local overlay OVER the parsed base
63
+ * preferences. Both inputs are already-parsed values (a plain object, or
64
+ * null when the file was missing/malformed). Non-object / array inputs are
65
+ * treated as null (fail-open). Returns null only when BOTH are absent,
66
+ * preserving the "no config at all" signal every caller already handles.
67
+ *
68
+ * @param {object|null} base
69
+ * @param {object|null} overlay
70
+ * @returns {object|null}
71
+ */
72
+ export function mergePreferences(base, overlay) {
73
+ const b = base && typeof base === 'object' && !Array.isArray(base) ? base : null;
74
+ const o = overlay && typeof overlay === 'object' && !Array.isArray(overlay) ? overlay : null;
75
+ if (!b && !o) return null;
76
+ return { ...(b || {}), ...(o || {}) };
77
+ }
78
+
79
+ /**
80
+ * Read + JSON-parse a file, returning the parsed value or null on any error
81
+ * (missing file, unreadable, malformed JSON). Shared by the base + overlay
82
+ * reads so both fail-open identically.
83
+ */
84
+ function readJsonSync(path) {
85
+ try {
86
+ return JSON.parse(readFileSync(path, 'utf8'));
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ async function readJsonAsync(path) {
93
+ try {
94
+ return JSON.parse(await readFile(path, 'utf8'));
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Read the merged preferences for a repo root: the shared base file
102
+ * (`operator/config/preferences.json`) with the device-local overlay
103
+ * (`operator/config/preferences.local.json`) shallow-merged OVER it.
104
+ *
105
+ * Returns the merged plain object, or null when NEITHER file is
106
+ * present/parseable. A malformed either file is treated as absent for
107
+ * that layer (fail-open). This is deliberately a plain reader — callers
108
+ * that need typed shapes (e.g. `desk/team-scope` normalising for scoping)
109
+ * layer their own extraction on top.
110
+ *
111
+ * @param {string} repoRoot
112
+ * @returns {Promise<object|null>}
113
+ */
114
+ export async function readPreferences(repoRoot) {
115
+ const base = await readJsonAsync(preferencesPath(repoRoot));
116
+ const overlay = await readJsonAsync(preferencesOverlayPath(repoRoot));
117
+ return mergePreferences(base, overlay);
118
+ }
119
+
120
+ /**
121
+ * Synchronous mirror of {@link readPreferences}. Used by hot boot paths
122
+ * (workspace server bind, workspace API handlers, environment probes)
123
+ * where the async surface would force a whole call chain to become async
124
+ * for no functional gain.
125
+ *
126
+ * @param {string} repoRoot
127
+ * @returns {object|null}
128
+ */
129
+ export function readPreferencesSync(repoRoot) {
130
+ const base = readJsonSync(preferencesPath(repoRoot));
131
+ const overlay = readJsonSync(preferencesOverlayPath(repoRoot));
132
+ return mergePreferences(base, overlay);
133
+ }
File without changes
@@ -9,6 +9,7 @@ import { hostname } from 'node:os';
9
9
  import { getSecretsStatus, setSecret, MISSING_TARGET_ERROR } from './secrets-api.mjs';
10
10
  import { detect as schedulerDetect, list as schedulerList } from '../../engineering-pa-core/src/scheduler/index.mjs';
11
11
  import { filterProjectsByTeam, readTeamScopeConfig } from '../../engineering-pa-core/src/desk/team-scope.mjs';
12
+ import { readPreferencesSync } from '../../engineering-pa-core/src/preferences.mjs';
12
13
 
13
14
  /**
14
15
  * handleApi -- route handler for all API endpoints.
@@ -63,11 +64,10 @@ export function handleApi(req, res, url, repoRoot, deps = {}) {
63
64
  // GET /__api/config
64
65
  if (method === 'GET' && pathname === '/__api/config') {
65
66
  try {
66
- const prefsFile = join(repoRoot, 'operator', 'config', 'preferences.json');
67
67
  let operator = { name: 'Operator', role: 'Operator' };
68
68
  let pa = { name: 'PA', role: 'PA' };
69
- if (existsSync(prefsFile)) {
70
- const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
69
+ const prefs = readPreferencesSync(repoRoot);
70
+ if (prefs) {
71
71
  if (prefs.operator && prefs.operator.name) operator = prefs.operator;
72
72
  if (prefs.pa && prefs.pa.name) pa = prefs.pa;
73
73
  }
@@ -855,9 +855,8 @@ function buildOverview(projects, work, repoRoot) {
855
855
  let paName = null;
856
856
  let paRoleTitle = null;
857
857
  try {
858
- const prefsFile = join(repoRoot, 'operator', 'config', 'preferences.json');
859
- if (existsSync(prefsFile)) {
860
- const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
858
+ const prefs = readPreferencesSync(repoRoot);
859
+ if (prefs) {
861
860
  paName = prefs.paName || null;
862
861
  if (prefs.pa && typeof prefs.pa.roleTitle === 'string' && prefs.pa.roleTitle.length) {
863
862
  paRoleTitle = prefs.pa.roleTitle;
@@ -970,12 +969,9 @@ async function buildGitOverview(repoRoot) {
970
969
  // just come back empty and the page renders the user's own repos only.
971
970
  let orgs = [];
972
971
  try {
973
- const prefsFile = join(repoRoot, 'operator', 'config', 'preferences.json');
974
- if (existsSync(prefsFile)) {
975
- const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
976
- if (Array.isArray(prefs.gitOrgs)) {
977
- orgs = prefs.gitOrgs.filter(function(o) { return typeof o === 'string' && o.length > 0; });
978
- }
972
+ const prefs = readPreferencesSync(repoRoot);
973
+ if (prefs && Array.isArray(prefs.gitOrgs)) {
974
+ orgs = prefs.gitOrgs.filter(function(o) { return typeof o === 'string' && o.length > 0; });
979
975
  }
980
976
  } catch { /* non-fatal */ }
981
977
 
@@ -1975,12 +1971,9 @@ export function buildNextSession(repoRoot) {
1975
1971
  // 3. 'dave' (canonical default for legacy deployments)
1976
1972
  let paName = null;
1977
1973
  try {
1978
- const prefsFile = join(repoRoot, 'operator', 'config', 'preferences.json');
1979
- if (existsSync(prefsFile)) {
1980
- const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
1981
- if (prefs && typeof prefs.paName === 'string' && prefs.paName.length) {
1982
- paName = prefs.paName;
1983
- }
1974
+ const prefs = readPreferencesSync(repoRoot);
1975
+ if (prefs && typeof prefs.paName === 'string' && prefs.paName.length) {
1976
+ paName = prefs.paName;
1984
1977
  }
1985
1978
  } catch { /* non-fatal */ }
1986
1979
 
@@ -27,10 +27,11 @@
27
27
  */
28
28
 
29
29
  import { execSync } from 'node:child_process';
30
- import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, realpathSync } from 'node:fs';
30
+ import { writeFileSync, mkdirSync, existsSync, unlinkSync, realpathSync } from 'node:fs';
31
31
  import { homedir, platform } from 'node:os';
32
32
  import { dirname, join, resolve } from 'node:path';
33
33
  import { fileURLToPath, pathToFileURL } from 'node:url';
34
+ import { readPreferencesSync } from '../../engineering-pa-core/src/preferences.mjs';
34
35
 
35
36
  const __filename = fileURLToPath(import.meta.url);
36
37
  const __dirname = dirname(__filename);
@@ -55,7 +56,6 @@ function findRepoRoot(start) {
55
56
  const envRoot = process.env.DAVE_REPO_ROOT || process.env.PA_REPO_ROOT;
56
57
  const REPO_ROOT = envRoot ? resolve(envRoot) : (findRepoRoot(process.cwd()) || process.cwd());
57
58
  const SERVER_SCRIPT = join(__dirname, 'server.mjs');
58
- const PREFS_FILE = join(REPO_ROOT, 'operator', 'config', 'preferences.json');
59
59
 
60
60
  const LABEL = 'com.dave.workspace';
61
61
  const UNIT_NAME = 'dave-workspace.service';
@@ -80,7 +80,8 @@ function defaultLogPaths() {
80
80
  /**
81
81
  * Resolve the workspace port at install time. Precedence:
82
82
  * 1. CLI --port flag
83
- * 2. operator/config/preferences.json workspacePort
83
+ * 2. operator/config/preferences.json workspacePort (with device-local
84
+ * preferences.local.json overlay applied)
84
85
  * 3. default 4200
85
86
  * @param {string|undefined} flagPort
86
87
  * @returns {number}
@@ -94,8 +95,8 @@ export function resolvePort(flagPort) {
94
95
  return parsed;
95
96
  }
96
97
  try {
97
- const prefs = JSON.parse(readFileSync(PREFS_FILE, 'utf-8'));
98
- if (prefs.workspacePort) return prefs.workspacePort;
98
+ const prefs = readPreferencesSync(REPO_ROOT);
99
+ if (prefs && prefs.workspacePort) return prefs.workspacePort;
99
100
  } catch { /* fall through to default */ }
100
101
  return 4200;
101
102
  }
@@ -20,13 +20,14 @@
20
20
 
21
21
  import { createServer } from 'node:http';
22
22
  import { readFile, stat, readdir } from 'node:fs/promises';
23
- import { readFileSync, existsSync, realpathSync } from 'node:fs';
23
+ import { existsSync, realpathSync } from 'node:fs';
24
24
  import { execSync } from 'node:child_process';
25
25
  import { resolve, join, extname } from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { handleApi } from './api.mjs';
28
28
  import { loadNav } from './nav.mjs';
29
29
  import { readTeamScopeConfigSync } from '../../engineering-pa-core/src/desk/team-scope.mjs';
30
+ import { readPreferencesSync } from '../../engineering-pa-core/src/preferences.mjs';
30
31
  import { startPoller as startStatusMonPoller } from './status-mon/poller.mjs';
31
32
 
32
33
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
@@ -89,11 +90,11 @@ let autoOpen = true;
89
90
  let timeoutMinutes = 120;
90
91
  let noTimeout = false;
91
92
 
92
- // Read configured port from preferences if available
93
+ // Read configured port from preferences if available (device-local overlay
94
+ // wins over the shared base — see @stravica-ai/engineering-pa-core/src/preferences.mjs).
93
95
  try {
94
- const prefsPath = join(REPO_ROOT, 'operator', 'config', 'preferences.json');
95
- const prefs = JSON.parse(readFileSync(prefsPath, 'utf-8'));
96
- if (prefs.workspacePort) port = prefs.workspacePort;
96
+ const prefs = readPreferencesSync(REPO_ROOT);
97
+ if (prefs && prefs.workspacePort) port = prefs.workspacePort;
97
98
  } catch { /* no prefs yet — use default */ }
98
99
 
99
100
  // CLI flags override preferences
@@ -12,20 +12,11 @@
12
12
 
13
13
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
15
+ import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
15
16
  import { poll, resolveSources, mergeWithPrevious } from './poll.mjs';
16
17
 
17
18
  const DEFAULT_INTERVAL_MS = 60_000;
18
19
 
19
- function readPreferences(repoRoot) {
20
- try {
21
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
22
- if (!existsSync(prefsPath)) return null;
23
- return JSON.parse(readFileSync(prefsPath, 'utf-8'));
24
- } catch {
25
- return null;
26
- }
27
- }
28
-
29
20
  function readState(statePath) {
30
21
  try {
31
22
  if (!existsSync(statePath)) return null;
@@ -68,7 +59,9 @@ export function startPoller(options) {
68
59
  async function runOnce() {
69
60
  if (stopped) return null;
70
61
  try {
71
- const prefs = readPreferences(repoRoot);
62
+ // Overlay-aware read: any device-scoped statusMonSources override in
63
+ // preferences.local.json shallow-merges OVER the shared base.
64
+ const prefs = readPreferencesSync(repoRoot);
72
65
  const sources = resolveSources(prefs);
73
66
  const previous = readState(statePath);
74
67
  const fresh = await poll({ sources, fetcher, nowMs: Date.now() });
@@ -23,10 +23,11 @@
23
23
  * Exit codes: 0 ok | 1 usage | 2 fetch failure (all sources) | 3 write failure
24
24
  */
25
25
 
26
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
26
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
27
27
  import { dirname, join, resolve } from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
29
 
30
+ import { readPreferencesSync } from '../../../engineering-pa-core/src/preferences.mjs';
30
31
  import { poll, resolveSources, mergeWithPrevious } from './poll.mjs';
31
32
 
32
33
  const __filename = fileURLToPath(import.meta.url);
@@ -81,23 +82,15 @@ function readPreviousState(statePath) {
81
82
  }
82
83
  }
83
84
 
84
- function readPreferences(repoRoot) {
85
- try {
86
- const prefsPath = join(repoRoot, 'operator', 'config', 'preferences.json');
87
- if (!existsSync(prefsPath)) return null;
88
- return JSON.parse(readFileSync(prefsPath, 'utf-8'));
89
- } catch {
90
- return null;
91
- }
92
- }
93
-
94
85
  async function main() {
95
86
  const parsed = parseArgs(process.argv.slice(2));
96
87
  if (!parsed) { printHelp(); process.exit(1); }
97
88
  if (parsed.help) { printHelp(); process.exit(0); }
98
89
 
99
90
  const repoRoot = findRepoRoot();
100
- const prefs = readPreferences(repoRoot);
91
+ // Overlay-aware read: preferences.local.json shallow-merges OVER
92
+ // preferences.json so any device-scoped statusMonSources override wins.
93
+ const prefs = readPreferencesSync(repoRoot);
101
94
  const sources = resolveSources(prefs);
102
95
  const outPath = parsed.outPath || join(repoRoot, '.state', 'status-mon.json');
103
96
 
@@ -127,7 +120,28 @@ async function main() {
127
120
  process.exit(allFailed && !state.lastSuccessfulPollAt ? 2 : 0);
128
121
  }
129
122
 
130
- main().catch((err) => {
131
- process.stderr.write(`unexpected: ${err && err.stack ? err.stack : err}\n`);
132
- process.exit(1);
133
- });
123
+ // Entry-point guard: only run main() when this file is the process entry
124
+ // point. Under the single-bundle rewriter, dist-imports.test.mjs dynamically
125
+ // imports every "rewritten" module (one that crosses into another bundled
126
+ // package) to walk the ESM graph; before this file added its own
127
+ // cross-package import it stayed off that list, so the top-level
128
+ // main().catch() was tolerated. Rebuilding without a guard now runs main()
129
+ // during that dynamic import and the parseArgs sees the outer test process's
130
+ // argv. Matches the isMainModule pattern already used by ws.mjs /
131
+ // server.mjs / install-daemon.mjs (realpath both sides for pnpm-hoisted
132
+ // layouts where argv[1] is a symlink).
133
+ const isMainModule = (() => {
134
+ if (!process.argv[1]) return false;
135
+ try {
136
+ return fileURLToPath(import.meta.url) === realpathSync(resolve(process.argv[1]));
137
+ } catch {
138
+ return false;
139
+ }
140
+ })();
141
+
142
+ if (isMainModule) {
143
+ main().catch((err) => {
144
+ process.stderr.write(`unexpected: ${err && err.stack ? err.stack : err}\n`);
145
+ process.exit(1);
146
+ });
147
+ }
@@ -20,6 +20,7 @@ import { join, resolve, dirname } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { platform } from 'node:os';
22
22
  import { get } from 'node:http';
23
+ import { readPreferencesSync } from '../../engineering-pa-core/src/preferences.mjs';
23
24
  import {
24
25
  detectSupervisor,
25
26
  readSupervisorState,
@@ -36,7 +37,6 @@ const SERVER_SCRIPT = join(__dirname, 'server.mjs');
36
37
  // .ws.pid / .ws.log are never written to during the subprocess test runs.
37
38
  const PID_FILE = process.env.WS_PID_FILE || join(REPO_ROOT, '.ws.pid');
38
39
  const LOG_FILE = process.env.WS_LOG_FILE || join(REPO_ROOT, '.ws.log');
39
- const PREFS_FILE = join(REPO_ROOT, 'operator', 'config', 'preferences.json');
40
40
 
41
41
  // Test-only override: lets e2e tests assert cross-platform parity by forcing
42
42
  // the supervisor probe to behave as if it were running on a different OS.
@@ -75,10 +75,11 @@ function resolvePort() {
75
75
  // 1. Default
76
76
  let port = 4200;
77
77
 
78
- // 2. Preferences override
78
+ // 2. Preferences override — device-local overlay wins over the shared base
79
+ // (see @stravica-ai/engineering-pa-core/src/preferences.mjs).
79
80
  try {
80
- const prefs = JSON.parse(readFileSync(PREFS_FILE, 'utf-8'));
81
- if (prefs.workspacePort) port = prefs.workspacePort;
81
+ const prefs = readPreferencesSync(REPO_ROOT);
82
+ if (prefs && prefs.workspacePort) port = prefs.workspacePort;
82
83
  } catch { /* no prefs — use default */ }
83
84
 
84
85
  // 3. CLI flag override
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.16",
4
- "description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
3
+ "version": "0.1.18",
4
+ "description": "Digital Operator PA \u2014 single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "exports": {
@@ -49,10 +49,11 @@
49
49
  "engines": {
50
50
  "node": ">=24"
51
51
  },
52
- "//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper — see packages/cli/test/secrets. Counts must never lie.",
53
52
  "scripts": {
54
53
  "build": "node build.mjs",
54
+ "prepack": "node build.mjs",
55
55
  "test": "node --test --test-isolation=none test/*.test.mjs",
56
56
  "dry-publish": "node build.mjs && node scripts/dry-publish.mjs"
57
- }
58
- }
57
+ },
58
+ "//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper \u2014 see packages/cli/test/secrets. Counts must never lie."
59
+ }