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

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.
@@ -33,6 +33,7 @@
33
33
  // not silently start hiding projects on a package bump.
34
34
 
35
35
  import { readFile } from 'node:fs/promises';
36
+ import { readFileSync } from 'node:fs';
36
37
  import { join } from 'node:path';
37
38
 
38
39
  /**
@@ -93,6 +94,30 @@ export function filterProjectsByTeam(projects, opts = {}) {
93
94
  return projects.filter((p) => isOwnProject(p, set));
94
95
  }
95
96
 
97
+ /**
98
+ * Normalise a parsed preferences.json object (or null when the file was
99
+ * missing/malformed) into the effective team-scope config. Shared by the
100
+ * async and sync readers so both derive `teamIdentities`, `scopeEnabled`
101
+ * and `team` identically.
102
+ *
103
+ * `team` is the DEVICE's display team id (e.g. "hq"), a distinct namespace
104
+ * from `teamIdentities` (agent/role identities): teams are the named
105
+ * library-repo teams the workspace nav scopes display by (see nav.mjs).
106
+ * Absent/blank -> null, which means "do not team-scope nav" (fail open,
107
+ * backwards compatible: pre-team devices show every nav fragment).
108
+ */
109
+ function normaliseTeamScopeConfig(config) {
110
+ const c = config && typeof config === 'object' ? config : {};
111
+ const paName = c.paName || c.agentName || 'pa';
112
+ const scopeEnabled = c.scopeProjectsToTeam === true;
113
+ const configured = Array.isArray(c.teamIdentities)
114
+ ? c.teamIdentities.filter((x) => typeof x === 'string' && x.length > 0)
115
+ : null;
116
+ const team = typeof c.team === 'string' && c.team.length > 0 ? c.team : null;
117
+ const teamIdentities = [...new Set([paName, ...(configured || [])])];
118
+ return { paName, teamIdentities, scopeEnabled, team };
119
+ }
120
+
96
121
  /**
97
122
  * Read the team-scoping config out of operator/config/preferences.json.
98
123
  * Falls back to disabled scoping + no identity if the file is missing or
@@ -104,26 +129,40 @@ export function filterProjectsByTeam(projects, opts = {}) {
104
129
  * even when the configured list omits it (documented belt-and-braces: the
105
130
  * local agent can never scope out its own directly-assigned projects).
106
131
  *
132
+ * `team` is the device's display team id (or null); see
133
+ * normaliseTeamScopeConfig above.
134
+ *
107
135
  * @param {string} repoRoot
108
- * @returns {Promise<{ paName: string, teamIdentities: string[], scopeEnabled: boolean }>}
136
+ * @returns {Promise<{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }>}
109
137
  */
110
138
  export async function readTeamScopeConfig(repoRoot) {
111
- let paName = 'pa';
112
- let configured = null;
113
- let scopeEnabled = false;
139
+ let config = null;
114
140
  try {
115
- const raw = await readFile(join(repoRoot, 'operator', 'config', 'preferences.json'), 'utf8');
116
- const config = JSON.parse(raw);
117
- paName = config.paName || config.agentName || 'pa';
118
- scopeEnabled = config.scopeProjectsToTeam === true;
119
- if (Array.isArray(config.teamIdentities)) {
120
- configured = config.teamIdentities.filter((x) => typeof x === 'string' && x.length > 0);
121
- }
141
+ config = JSON.parse(await readFile(join(repoRoot, 'operator', 'config', 'preferences.json'), 'utf8'));
122
142
  } catch {
123
- // Missing/malformed file -> defaults above (scoping disabled).
143
+ // Missing/malformed file -> defaults (scoping disabled, no team).
124
144
  }
125
- const teamIdentities = [...new Set([paName, ...(configured || [])])];
126
- return { paName, teamIdentities, scopeEnabled };
145
+ return normaliseTeamScopeConfig(config);
146
+ }
147
+
148
+ /**
149
+ * Synchronous mirror of {@link readTeamScopeConfig}. The workspace nav loader
150
+ * (nav.mjs) is a synchronous, mtime-cached read surface, so it resolves the
151
+ * device team through this variant rather than awaiting the async reader.
152
+ * Same fail-open posture: a missing/malformed file yields disabled scoping
153
+ * and a null team.
154
+ *
155
+ * @param {string} repoRoot
156
+ * @returns {{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }}
157
+ */
158
+ 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);
127
166
  }
128
167
 
129
168
  /**
@@ -138,6 +138,8 @@ The main sidebar is driven by a declarative manifest rather than hard-coded mark
138
138
 
139
139
  **Extension manifest (optional):** `operator/extensions/workspace/nav.json`. Same shape, merged after core. Missing file is not an error.
140
140
 
141
+ **Team fragments (optional):** alongside `nav.json`, both dirs may carry `nav.<team>.json` files (e.g. `nav.hq.json`, `nav.wsd.json`). These are composed together, base `nav.json` first then team fragments alphabetically. They exist because the extension dir is materialised from multiple library repos merged by file path — a single shared `nav.json` collides (highest-precedence repo wins the whole file, dropping the other teams' entries), whereas distinctly-named fragments don't collide, so every team's entries materialise side by side. Which of them a device *displays* is then decided by team scoping (below).
142
+
141
143
  **Shape:**
142
144
 
143
145
  ```json
@@ -158,10 +160,13 @@ The main sidebar is driven by a declarative manifest rather than hard-coded mark
158
160
  | `href` | yes | Navigation target. Extension pages typically use `/<name>.html`; core pages use `/core/tools/workspace/app/<name>.html`. |
159
161
  | `section` | no | Section header. Defaults to `Navigation`. Any other string creates a new section appended after the core sections (in manifest order). |
160
162
  | `aliases` | no | Additional paths that should highlight this nav item when loaded (e.g. `project-detail.html` highlights `Projects`). |
163
+ | `team` | no | The team this entry belongs to (used by team scoping — see below). Resolution order: this inline field > the `nav.<team>.json` filename it lives in > `null` (untagged). Untagged entries are shown on every device. |
161
164
 
162
165
  **Endpoint:** `GET /__api/nav` returns `{ "items": [...] }` — the merged manifest in final order. The shell fetches this on load and renders the sidebar dynamically. Read-through cached with mtime invalidation; no restart needed after editing a manifest. If the endpoint fails, the shell falls back to a minimal Home-only nav rather than rendering a white page.
163
166
 
164
- **Missing pages:** if an `href` doesn't resolve to a real page (neither a core page nor an extension overlay), a warning is logged at startup but the item stays in the list — useful while building.
167
+ **Missing pages:** if an `href` doesn't resolve to a real page (neither a core page nor an extension overlay), the item is dropped with a `[nav] hidden:` warning — `library-sync` only writes a page file when the owning repo is mounted here, so page-presence is the per-entry materialisation signal (external/deep hrefs whose shape can't be resolved are left untouched).
168
+
169
+ **Team scoping (display):** a device declares its display team in `operator/config/preferences.json:team` (e.g. `"team": "hq"`), reusing the same team-scope config the project surfaces read (`readTeamScopeConfig` in `packages/core/src/desk/team-scope.mjs`). The default view keeps entries whose team matches the device team **or** are untagged (`null`), and hides other teams' — so a device that is *entitled* to another team's tooling (its pages are on disk) still only *shows* its own team's nav. This is the entitlement-vs-display split. Defaults are fail-open and backwards compatible: a device with no `team`, or nav that carries no team tags, renders unfiltered exactly as before. `team` is resolved once at server start (a change is a restart, like the port beside it). Escape hatches mirror the desk/projects convention: `GET /__api/nav?allTeams=1` bypasses scoping entirely, and `?team=<id>` is a strict single-team inspection filter (untagged excluded).
165
170
 
166
171
  **Example:** copy `core/tools/workspace/nav.json.example` to `operator/extensions/workspace/nav.json` and adjust for your pages. A worked demo:
167
172
 
@@ -1,53 +1,110 @@
1
1
  /**
2
2
  * Workspace nav manifest loader.
3
3
  *
4
- * Reads the core manifest at core/tools/workspace/app/nav.json and, if present,
5
- * the operator extension manifest at operator/extensions/workspace/nav.json.
6
- * Merges them into a single ordered list of nav items.
4
+ * Composes the sidebar nav from a set of manifest FRAGMENTS discovered on
5
+ * disk, then scopes the result two ways: by page presence (per-device
6
+ * materialisation) and by team (per-device display).
7
7
  *
8
- * Merge rules:
9
- * - Core items come first, in manifest order.
10
- * - Extension items are appended; on id collision, the extension entry
11
- * replaces the core entry in place (extension wins, warning logged).
12
- * - A missing or absent operator manifest is not an error.
13
- * - A malformed manifest is logged and skipped; the other side still loads.
8
+ * Fragments
9
+ * ---------
10
+ * Each of the two source dirs (core app dir, operator extension dir) may
11
+ * contribute:
12
+ * - `nav.json` — the base fragment, entries team = null (untagged).
13
+ * - `nav.<team>.json` — a team fragment; every entry inherits team
14
+ * `<team>` from the filename unless it carries its
15
+ * own `"team"` field.
16
+ * Fragments are read in a deterministic order (base `nav.json` first, then
17
+ * team fragments alphabetically) so composition is stable across devices.
14
18
  *
15
- * Per-device scoping: after merging, entries whose target page is not
16
- * materialised on THIS device are dropped (not just warned). The estate runs
17
- * one shared nav manifest that lists every page across all library repos, but
18
- * `library-sync` only writes a page file onto a device when the repo that owns
19
- * it is mounted there. So the presence of the href's target page file IS the
20
- * per-entry materialisation signal — an hq-only page (e.g. article-ideas,
21
- * content-calendar, both under operator/extensions/workspace/) is absent on a
22
- * device that doesn't mount stravica-library-hq, so its nav entry is hidden
23
- * rather than rendered as a dangling 404 link. This needs no per-entry "owner"
24
- * field and no coupling to the .library-sync manifest internals: the page file
25
- * library-sync lays down (or omits) is the source of truth. Hrefs whose shape
26
- * we can't resolve to a local page (external links, deep paths) are left
27
- * untouched.
19
+ * Why fragments instead of a single extension nav.json: the operator
20
+ * extension dir is materialised from MULTIPLE library repos (company, hq,
21
+ * wsd) merged by file path. A single shared `nav.json` collides — the
22
+ * highest-precedence repo's copy wins the whole file and silently drops the
23
+ * other teams' entries (this is exactly the HQ regression that motivated
24
+ * this change: wsd's nav.json won and HQ's article-ideas/content-calendar
25
+ * vanished from the sidebar). Distinctly-named `nav.<team>.json` fragments do
26
+ * not collide, so every team's entries materialise side by side; team
27
+ * scoping (below) then decides which to DISPLAY on a given device.
28
28
  *
29
- * Caching: each load checks both manifest mtimes. The cached (merged + scoped)
30
- * list is returned unchanged if neither file changed since the last load.
31
- * Missing files are tracked with mtime 0 so their appearance triggers a reload.
32
- * `library-sync` rewrites the extension nav.json as part of the same mount tree
33
- * whenever it materialises or removes pages, so a materialisation transition
34
- * always bumps a manifest mtime and re-triggers scoping.
29
+ * Team declaration + resolution order for each entry:
30
+ * 1. the entry's own `"team"` field (string), if present;
31
+ * 2. else the team encoded in the fragment filename (`nav.<team>.json`);
32
+ * 3. else `null` (untagged — belongs to no team, always shown).
33
+ *
34
+ * Team scoping (display)
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
39
+ * device team OR untagged (null). Untagged-always-shown keeps core nav and
40
+ * shared/company pages visible everywhere and makes the whole mechanism
41
+ * backwards compatible: a device with no `team` configured, or fragments
42
+ * that carry no team, are shown unfiltered exactly as before. `?allTeams=1`
43
+ * (opts.allTeams) bypasses scoping; `?team=<id>` (opts.requestedTeam) is a
44
+ * strict single-team inspection filter, mirroring the desk/projects
45
+ * team-scope convention.
46
+ *
47
+ * Page presence (materialisation)
48
+ * -------------------------------
49
+ * Independent of team scoping and applied first: an entry whose resolvable
50
+ * target page is not present on disk is dropped (library-sync only writes a
51
+ * page file when the owning repo is mounted here, so page-presence is the
52
+ * per-entry materialisation signal). Hrefs whose shape we can't resolve to a
53
+ * local page (external links, deep paths) are left untouched. Entitlement
54
+ * (which pages materialise) and display (which team's nav shows) are now
55
+ * separate axes: a wsd-entitled HQ device has wsd pages ON DISK but, being an
56
+ * HQ device, does not SHOW wsd nav entries.
57
+ *
58
+ * Caching: the merged + team-tagged + page-scoped list is device-team
59
+ * independent, so it is cached against a signature over every discovered
60
+ * fragment's mtime (a new/removed fragment or a content change bumps it). The
61
+ * per-request team filter is applied on top of the cached list and, when it
62
+ * is a pass-through (allTeams, or no device team and no requested team),
63
+ * returns the cached array by reference.
35
64
  */
36
65
 
37
- import { readFileSync, statSync, existsSync } from 'node:fs';
66
+ import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
38
67
  import { join } from 'node:path';
39
68
 
40
69
  const CACHE = {
41
- coreMtimeMs: -1,
42
- extMtimeMs: -1,
43
- items: null,
70
+ signature: null,
71
+ tagged: null,
44
72
  };
45
73
 
74
+ const FRAGMENT_RE = /^nav(?:\.([A-Za-z0-9_-]+))?\.json$/;
75
+
46
76
  function mtime(path) {
47
77
  try { return statSync(path).mtimeMs; } catch { return 0; }
48
78
  }
49
79
 
50
- function validateItem(raw, source, warn) {
80
+ /**
81
+ * Discover nav fragments in a dir. Returns an ordered list of
82
+ * { path, team, source } — base `nav.json` (team null) first, then
83
+ * `nav.<team>.json` fragments sorted by team. A missing dir yields [].
84
+ */
85
+ function discoverFragments(dir, sourceLabel) {
86
+ if (!dir || !existsSync(dir)) return [];
87
+ let names;
88
+ try {
89
+ names = readdirSync(dir);
90
+ } catch {
91
+ return [];
92
+ }
93
+ const base = [];
94
+ const teamed = [];
95
+ for (const name of names) {
96
+ const m = name.match(FRAGMENT_RE);
97
+ if (!m) continue;
98
+ const team = m[1] || null;
99
+ const entry = { path: join(dir, name), team, source: sourceLabel + ' nav manifest' };
100
+ if (team === null) base.push(entry);
101
+ else teamed.push(entry);
102
+ }
103
+ teamed.sort((a, b) => (a.team < b.team ? -1 : a.team > b.team ? 1 : 0));
104
+ return base.concat(teamed);
105
+ }
106
+
107
+ function validateItem(raw, source, fragmentTeam, warn) {
51
108
  if (!raw || typeof raw !== 'object') {
52
109
  warn('[nav] warning: ' + source + ' contains a non-object entry, skipping');
53
110
  return null;
@@ -69,6 +126,8 @@ function validateItem(raw, source, warn) {
69
126
  label: raw.label,
70
127
  href: raw.href,
71
128
  section: typeof raw.section === 'string' && raw.section ? raw.section : 'Workspace',
129
+ // Resolution order: inline entry.team > fragment-filename team > null.
130
+ team: typeof raw.team === 'string' && raw.team ? raw.team : fragmentTeam,
72
131
  };
73
132
  if (Array.isArray(raw.aliases)) {
74
133
  item.aliases = raw.aliases.filter(function(a) { return typeof a === 'string' && a; });
@@ -78,7 +137,8 @@ function validateItem(raw, source, warn) {
78
137
  return item;
79
138
  }
80
139
 
81
- function readManifest(path, source, warn) {
140
+ function readFragment(fragment, warn) {
141
+ const { path, team, source } = fragment;
82
142
  if (!existsSync(path)) return [];
83
143
  let raw;
84
144
  try {
@@ -93,31 +153,30 @@ function readManifest(path, source, warn) {
93
153
  }
94
154
  const items = [];
95
155
  for (let i = 0; i < raw.length; i++) {
96
- const item = validateItem(raw[i], source, warn);
156
+ const item = validateItem(raw[i], source, team, warn);
97
157
  if (item) items.push(item);
98
158
  }
99
159
  return items;
100
160
  }
101
161
 
102
162
  /**
103
- * Merge core and extension items. Extension items with a matching id replace
104
- * the corresponding core item in its original slot (preserving core ordering)
105
- * and emit a warning. Extension items with no collision are appended in
106
- * manifest order.
163
+ * Fold `additions` into a copy of `base`. An addition whose id already exists
164
+ * replaces the existing entry in place (preserving order); a new id is
165
+ * appended. `warn` fires on each collision — pass a no-op to fold silently.
107
166
  */
108
- function mergeItems(coreItems, extItems, warn) {
109
- const merged = coreItems.slice();
167
+ function foldItems(base, additions, warn) {
168
+ const merged = base.slice();
110
169
  const indexById = new Map();
111
170
  for (let i = 0; i < merged.length; i++) {
112
171
  indexById.set(merged[i].id, i);
113
172
  }
114
- for (const ext of extItems) {
115
- if (indexById.has(ext.id)) {
116
- warn('[nav] warning: extension nav id "' + ext.id + '" shadows core item');
117
- merged[indexById.get(ext.id)] = ext;
173
+ for (const add of additions) {
174
+ if (indexById.has(add.id)) {
175
+ warn('[nav] warning: extension nav id "' + add.id + '" shadows core item');
176
+ merged[indexById.get(add.id)] = add;
118
177
  } else {
119
- indexById.set(ext.id, merged.length);
120
- merged.push(ext);
178
+ indexById.set(add.id, merged.length);
179
+ merged.push(add);
121
180
  }
122
181
  }
123
182
  return merged;
@@ -126,11 +185,6 @@ function mergeItems(coreItems, extItems, warn) {
126
185
  /**
127
186
  * Classify an href by whether its target page is materialised on this device.
128
187
  * Returns 'present', 'absent', or 'unresolvable'.
129
- *
130
- * - Core-shape paths (/core/tools/workspace/app/<file>) resolve under appDir.
131
- * - Top-level extension-shape paths (/<file>.html) resolve under extDir or appDir.
132
- * - Any other shape (external links, deep paths) is 'unresolvable' — we cannot
133
- * (and should not) judge its local presence, so callers leave it untouched.
134
188
  */
135
189
  function pagePresence(href, appDir, extDir) {
136
190
  const corePrefix = '/core/tools/workspace/app/';
@@ -150,13 +204,8 @@ function pagePresence(href, appDir, extDir) {
150
204
  }
151
205
 
152
206
  /**
153
- * Scope the merged list to this device: drop any entry whose target page is
154
- * resolvable-in-shape but absent from disk (i.e. its owning library repo is not
155
- * materialised here), logging why. Entries whose page is present, or whose href
156
- * shape we can't resolve to a local page, are kept. This is what makes a shared
157
- * estate-wide nav manifest render cleanly per-device without hand-editing:
158
- * hq-only entries vanish on non-HQ devices because their pages were never
159
- * written there.
207
+ * Drop any entry whose target page is resolvable-in-shape but absent from
208
+ * disk (its owning library repo is not materialised here), logging why.
160
209
  */
161
210
  function scopeToMaterialised(items, appDir, extDir, warn) {
162
211
  const kept = [];
@@ -171,52 +220,92 @@ function scopeToMaterialised(items, appDir, extDir, warn) {
171
220
  }
172
221
 
173
222
  /**
174
- * Returns the merged nav items array. Reads from disk when either manifest's
175
- * mtime has changed since the previous call; otherwise returns the cached list.
223
+ * Apply the per-request team filter to the (cached, device-independent)
224
+ * tagged list. Returns the input array BY REFERENCE when the filter is a
225
+ * pass-through, so the loader preserves cache identity for the common case.
226
+ *
227
+ * Precedence (mirrors filterProjectsByTeam):
228
+ * 1. requestedTeam (?team=<id>) — strict: only entries whose team matches
229
+ * exactly. Untagged entries are excluded from this narrow inspection view.
230
+ * 2. allTeams (?allTeams=1) — bypass, return everything.
231
+ * 3. no device team — bypass, return everything (backwards compatible).
232
+ * 4. default — entries whose team is the device team OR untagged (null).
233
+ */
234
+ function applyTeamFilter(items, opts) {
235
+ const requestedTeam = opts.requestedTeam || null;
236
+ if (requestedTeam) {
237
+ return items.filter(function(i) { return i.team === requestedTeam; });
238
+ }
239
+ if (opts.allTeams) return items;
240
+
241
+ const deviceTeam = opts.team;
242
+ const teamSet = deviceTeam == null
243
+ ? null
244
+ : (deviceTeam instanceof Set
245
+ ? deviceTeam
246
+ : new Set(Array.isArray(deviceTeam) ? deviceTeam : [deviceTeam]));
247
+ if (!teamSet || teamSet.size === 0) return items;
248
+
249
+ return items.filter(function(i) { return i.team == null || teamSet.has(i.team); });
250
+ }
251
+
252
+ /**
253
+ * Returns the composed, scoped nav items array.
176
254
  *
177
255
  * @param {object} opts
178
256
  * @param {string} opts.appDir absolute path to core app dir
179
257
  * @param {string} [opts.extDir] absolute path to operator extension dir
258
+ * @param {string|string[]|Set<string>} [opts.team] device display team(s);
259
+ * absent/null => nav shown unfiltered (backwards compatible)
260
+ * @param {boolean} [opts.allTeams] bypass team scoping (?allTeams=1)
261
+ * @param {string} [opts.requestedTeam] strict single-team filter (?team=<id>)
180
262
  * @param {function(string):void} [opts.warn] warning sink (defaults to console.warn)
181
- * @returns {Array<{id:string,label:string,href:string,section:string,aliases:string[]}>}
263
+ * @returns {Array<{id:string,label:string,href:string,section:string,team:string|null,aliases:string[]}>}
182
264
  */
183
265
  export function loadNav(opts) {
184
266
  const appDir = opts.appDir;
185
267
  const extDir = opts.extDir || null;
186
268
  const warn = opts.warn || function(m) { console.warn(m); };
187
269
 
188
- const coreManifest = join(appDir, 'nav.json');
189
- const extManifest = extDir ? join(extDir, 'nav.json') : null;
270
+ const fragments = discoverFragments(appDir, 'core').concat(discoverFragments(extDir, 'extension'));
190
271
 
191
- const coreMtime = mtime(coreManifest);
192
- const extMtime = extManifest ? mtime(extManifest) : 0;
272
+ // Signature: every fragment path + mtime, sorted. A content change, a new
273
+ // fragment appearing, or a fragment removed all change the signature.
274
+ const signature = fragments
275
+ .map(function(f) { return f.path + ':' + mtime(f.path); })
276
+ .sort()
277
+ .join('|');
193
278
 
194
- if (
195
- CACHE.items !== null &&
196
- CACHE.coreMtimeMs === coreMtime &&
197
- CACHE.extMtimeMs === extMtime
198
- ) {
199
- return CACHE.items;
200
- }
279
+ let tagged;
280
+ if (CACHE.tagged !== null && CACHE.signature === signature) {
281
+ tagged = CACHE.tagged;
282
+ } else {
283
+ const coreFragments = fragments.filter(function(f) { return f.source.indexOf('core') === 0; });
284
+ const extFragments = fragments.filter(function(f) { return f.source.indexOf('core') !== 0; });
201
285
 
202
- const coreItems = readManifest(coreManifest, 'core nav manifest', warn);
203
- const extItems = extManifest
204
- ? readManifest(extManifest, 'extension nav manifest', warn)
205
- : [];
206
- const merged = mergeItems(coreItems, extItems, warn);
207
- const scoped = scopeToMaterialised(merged, appDir, extDir, warn);
286
+ let coreItems = [];
287
+ for (const f of coreFragments) coreItems = foldItems(coreItems, readFragment(f, warn), function() {});
288
+ let extItems = [];
289
+ for (const f of extFragments) extItems = foldItems(extItems, readFragment(f, warn), function() {});
290
+
291
+ const merged = foldItems(coreItems, extItems, warn);
292
+ tagged = scopeToMaterialised(merged, appDir, extDir, warn);
293
+
294
+ CACHE.signature = signature;
295
+ CACHE.tagged = tagged;
296
+ }
208
297
 
209
- CACHE.coreMtimeMs = coreMtime;
210
- CACHE.extMtimeMs = extMtime;
211
- CACHE.items = scoped;
212
- return scoped;
298
+ return applyTeamFilter(tagged, {
299
+ team: opts.team,
300
+ allTeams: opts.allTeams === true,
301
+ requestedTeam: opts.requestedTeam || null,
302
+ });
213
303
  }
214
304
 
215
305
  /**
216
306
  * Test-only helper: clear the in-memory cache so reloads re-read manifests.
217
307
  */
218
308
  export function _resetNavCache() {
219
- CACHE.coreMtimeMs = -1;
220
- CACHE.extMtimeMs = -1;
221
- CACHE.items = null;
309
+ CACHE.signature = null;
310
+ CACHE.tagged = null;
222
311
  }
@@ -26,6 +26,7 @@ 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
+ import { readTeamScopeConfigSync } from '../../engineering-pa-core/src/desk/team-scope.mjs';
29
30
  import { startPoller as startStatusMonPoller } from './status-mon/poller.mjs';
30
31
 
31
32
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
@@ -73,6 +74,14 @@ const warnAlways = console.warn.bind(console);
73
74
  const EXT_DIR_PATH = join(REPO_ROOT, 'operator', 'extensions', 'workspace');
74
75
  const EXT_DIR = existsSync(EXT_DIR_PATH) ? EXT_DIR_PATH : null;
75
76
 
77
+ // --- Device display team ---
78
+ // The nav sidebar is scoped to the device's team (operator/config/
79
+ // preferences.json:team), reusing the same team-scope config the project
80
+ // surfaces use (PR #87). null => nav shown unfiltered (backwards compatible).
81
+ // Resolved once at startup; a `team` change is a workspace restart, same as
82
+ // the port it sits beside in preferences.json.
83
+ const DEVICE_TEAM = readTeamScopeConfigSync(REPO_ROOT).team;
84
+
76
85
  // --- Args ---
77
86
  const args = process.argv.slice(2);
78
87
  let port = 4200;
@@ -315,10 +324,19 @@ const server = createServer(async (req, res) => {
315
324
  const extRoute = extRoutes.find(r => r.method === req.method && r.pathname === pathname);
316
325
  if (extRoute) { extRoute.handler(req, res, url, REPO_ROOT); return; }
317
326
 
318
- // GET /__api/nav -- merged core + extension nav manifest
327
+ // GET /__api/nav -- merged core + extension nav manifest, team-scoped to
328
+ // this device. ?allTeams=1 bypasses scoping; ?team=<id> is a strict
329
+ // single-team inspection filter (mirrors the desk/projects convention).
319
330
  if (req.method === 'GET' && pathname === '/__api/nav') {
320
331
  try {
321
- const items = loadNav({ appDir: APP_DIR, extDir: EXT_DIR, warn: warn });
332
+ const items = loadNav({
333
+ appDir: APP_DIR,
334
+ extDir: EXT_DIR,
335
+ team: DEVICE_TEAM,
336
+ allTeams: url.searchParams.get('allTeams') === '1',
337
+ requestedTeam: url.searchParams.get('team'),
338
+ warn: warn,
339
+ });
322
340
  const body = JSON.stringify({ items: items });
323
341
  res.writeHead(200, {
324
342
  'Content-Type': 'application/json',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",