@stravica-ai/digital-operator-pa 0.1.4 → 0.1.6

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.
@@ -106,6 +106,59 @@ export function filterProjectsByTeam(projects, opts = {}) {
106
106
  * Absent/blank -> null, which means "do not team-scope nav" (fail open,
107
107
  * backwards compatible: pre-team devices show every nav fragment).
108
108
  */
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
+
109
162
  function normaliseTeamScopeConfig(config) {
110
163
  const c = config && typeof config === 'object' ? config : {};
111
164
  const paName = c.paName || c.agentName || 'pa';
@@ -136,13 +189,11 @@ function normaliseTeamScopeConfig(config) {
136
189
  * @returns {Promise<{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }>}
137
190
  */
138
191
  export async function readTeamScopeConfig(repoRoot) {
139
- let config = null;
140
- try {
141
- config = JSON.parse(await readFile(join(repoRoot, 'operator', 'config', 'preferences.json'), 'utf8'));
142
- } catch {
143
- // Missing/malformed file -> defaults (scoping disabled, no team).
144
- }
145
- return normaliseTeamScopeConfig(config);
192
+ // Base (synced) preferences, then the device-local overlay merged OVER it.
193
+ // 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));
146
197
  }
147
198
 
148
199
  /**
@@ -156,13 +207,11 @@ export async function readTeamScopeConfig(repoRoot) {
156
207
  * @returns {{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }}
157
208
  */
158
209
  export function readTeamScopeConfigSync(repoRoot) {
159
- let config = null;
160
- try {
161
- config = JSON.parse(readFileSync(join(repoRoot, 'operator', 'config', 'preferences.json'), 'utf8'));
162
- } catch {
163
- // Missing/malformed file -> defaults (scoping disabled, no team).
164
- }
165
- return normaliseTeamScopeConfig(config);
210
+ // Base (synced) preferences, then the device-local overlay merged OVER it.
211
+ // 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));
166
215
  }
167
216
 
168
217
  /**
@@ -157,6 +157,11 @@ export function buildGitignore() {
157
157
  '# Workspace runtime',
158
158
  '.ws.pid',
159
159
  '',
160
+ '# Device-local preferences overlay — per-host identity (e.g. `team`)',
161
+ '# shallow-merged OVER the synced preferences.json. Device-local by',
162
+ '# nature, so it is never committed or synced between hosts.',
163
+ 'operator/config/preferences.local.json',
164
+ '',
160
165
  ].join('\n');
161
166
  }
162
167
 
@@ -184,7 +184,7 @@ Stage observations, update journal, update work items, update spawn-log outcomes
184
184
  | Operator knowledge | `operator/knowledge/` |
185
185
  | Platform core docs | `@stravica-ai/engineering-pa-core/docs/` |
186
186
  | Platform core knowledge (bucket scaffolds) | `@stravica-ai/engineering-pa-core/knowledge/` |
187
- | Config / preferences | `operator/config/preferences.json` |
187
+ | Config / preferences | `operator/config/preferences.json` (+ device-local `preferences.local.json` overlay) |
188
188
  | PA run folder | `agents/{PA_NAME}/` |
189
189
  | Autonomy envelope | `agents/{PA_NAME}/autonomy-envelope.md` |
190
190
  | Work items | `work/w-YYYY-MM-DD-NNN.json` |
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Engineering Assistant</title>
6
+ <title>Digital Operator</title>
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%236366f1'/%3E%3Ctext x='8' y='12' font-family='monospace' font-size='11' font-weight='bold' text-anchor='middle' fill='%23121218'%3ED%3C/text%3E%3C/svg%3E">
8
8
  <script src="/banner.js" defer></script>
9
9
  <script>(function(){try{var t=localStorage.getItem('dave.theme');document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
@@ -347,14 +347,14 @@
347
347
  var name = data.paName;
348
348
  var capName = name.charAt(0).toUpperCase() + name.slice(1);
349
349
  brandName.textContent = capName;
350
- document.title = capName + ' — Engineering Assistant';
350
+ document.title = capName + ' — Digital Operator';
351
351
  } else {
352
352
  brandName.textContent = 'Workspace';
353
353
  }
354
- brandSub.textContent = 'Engineering Assistant';
354
+ brandSub.textContent = 'Digital Operator';
355
355
  }).catch(function() {
356
356
  document.getElementById('brand-name').textContent = 'Workspace';
357
- document.getElementById('brand-subtitle').textContent = 'Engineering Assistant';
357
+ document.getElementById('brand-subtitle').textContent = 'Digital Operator';
358
358
  });
359
359
  </script>
360
360
  </body>
@@ -33,9 +33,13 @@
33
33
  *
34
34
  * Team scoping (display)
35
35
  * ----------------------
36
- * The device declares its team via `operator/config/preferences.json:team`
37
- * (resolved by the server through readTeamScopeConfigSync and passed in as
38
- * `opts.team`). The default scoped view keeps entries whose team is the
36
+ * The device declares its team via the `team` key in
37
+ * `operator/config/preferences.json` — or, preferred, the device-local
38
+ * `operator/config/preferences.local.json` overlay shallow-merged over it
39
+ * (device identity is device-local, and preferences.json is synced from a
40
+ * shared canonical source; see readTeamScopeConfig in team-scope.mjs). It is
41
+ * resolved by the server through readTeamScopeConfigSync and passed in as
42
+ * `opts.team`. The default scoped view keeps entries whose team is the
39
43
  * device team OR untagged (null). Untagged-always-shown keeps core nav and
40
44
  * shared/company pages visible everywhere and makes the whole mechanism
41
45
  * backwards compatible: a device with no `team` configured, or fragments
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",