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

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,83 @@ 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
+ /**
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
+ function normaliseTeamScopeConfig(config) {
163
+ const c = config && typeof config === 'object' ? config : {};
164
+ const paName = c.paName || c.agentName || 'pa';
165
+ const scopeEnabled = c.scopeProjectsToTeam === true;
166
+ const configured = Array.isArray(c.teamIdentities)
167
+ ? c.teamIdentities.filter((x) => typeof x === 'string' && x.length > 0)
168
+ : null;
169
+ const team = typeof c.team === 'string' && c.team.length > 0 ? c.team : null;
170
+ const teamIdentities = [...new Set([paName, ...(configured || [])])];
171
+ return { paName, teamIdentities, scopeEnabled, team };
172
+ }
173
+
96
174
  /**
97
175
  * Read the team-scoping config out of operator/config/preferences.json.
98
176
  * Falls back to disabled scoping + no identity if the file is missing or
@@ -104,26 +182,36 @@ export function filterProjectsByTeam(projects, opts = {}) {
104
182
  * even when the configured list omits it (documented belt-and-braces: the
105
183
  * local agent can never scope out its own directly-assigned projects).
106
184
  *
185
+ * `team` is the device's display team id (or null); see
186
+ * normaliseTeamScopeConfig above.
187
+ *
107
188
  * @param {string} repoRoot
108
- * @returns {Promise<{ paName: string, teamIdentities: string[], scopeEnabled: boolean }>}
189
+ * @returns {Promise<{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }>}
109
190
  */
110
191
  export async function readTeamScopeConfig(repoRoot) {
111
- let paName = 'pa';
112
- let configured = null;
113
- let scopeEnabled = false;
114
- 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
- }
122
- } catch {
123
- // Missing/malformed file -> defaults above (scoping disabled).
124
- }
125
- const teamIdentities = [...new Set([paName, ...(configured || [])])];
126
- return { paName, teamIdentities, scopeEnabled };
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));
197
+ }
198
+
199
+ /**
200
+ * Synchronous mirror of {@link readTeamScopeConfig}. The workspace nav loader
201
+ * (nav.mjs) is a synchronous, mtime-cached read surface, so it resolves the
202
+ * device team through this variant rather than awaiting the async reader.
203
+ * Same fail-open posture: a missing/malformed file yields disabled scoping
204
+ * and a null team.
205
+ *
206
+ * @param {string} repoRoot
207
+ * @returns {{ paName: string, teamIdentities: string[], scopeEnabled: boolean, team: string|null }}
208
+ */
209
+ export function readTeamScopeConfigSync(repoRoot) {
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));
127
215
  }
128
216
 
129
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` |
@@ -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,114 @@
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 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
43
+ * device team OR untagged (null). Untagged-always-shown keeps core nav and
44
+ * shared/company pages visible everywhere and makes the whole mechanism
45
+ * backwards compatible: a device with no `team` configured, or fragments
46
+ * that carry no team, are shown unfiltered exactly as before. `?allTeams=1`
47
+ * (opts.allTeams) bypasses scoping; `?team=<id>` (opts.requestedTeam) is a
48
+ * strict single-team inspection filter, mirroring the desk/projects
49
+ * team-scope convention.
50
+ *
51
+ * Page presence (materialisation)
52
+ * -------------------------------
53
+ * Independent of team scoping and applied first: an entry whose resolvable
54
+ * target page is not present on disk is dropped (library-sync only writes a
55
+ * page file when the owning repo is mounted here, so page-presence is the
56
+ * per-entry materialisation signal). Hrefs whose shape we can't resolve to a
57
+ * local page (external links, deep paths) are left untouched. Entitlement
58
+ * (which pages materialise) and display (which team's nav shows) are now
59
+ * separate axes: a wsd-entitled HQ device has wsd pages ON DISK but, being an
60
+ * HQ device, does not SHOW wsd nav entries.
61
+ *
62
+ * Caching: the merged + team-tagged + page-scoped list is device-team
63
+ * independent, so it is cached against a signature over every discovered
64
+ * fragment's mtime (a new/removed fragment or a content change bumps it). The
65
+ * per-request team filter is applied on top of the cached list and, when it
66
+ * is a pass-through (allTeams, or no device team and no requested team),
67
+ * returns the cached array by reference.
35
68
  */
36
69
 
37
- import { readFileSync, statSync, existsSync } from 'node:fs';
70
+ import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
38
71
  import { join } from 'node:path';
39
72
 
40
73
  const CACHE = {
41
- coreMtimeMs: -1,
42
- extMtimeMs: -1,
43
- items: null,
74
+ signature: null,
75
+ tagged: null,
44
76
  };
45
77
 
78
+ const FRAGMENT_RE = /^nav(?:\.([A-Za-z0-9_-]+))?\.json$/;
79
+
46
80
  function mtime(path) {
47
81
  try { return statSync(path).mtimeMs; } catch { return 0; }
48
82
  }
49
83
 
50
- function validateItem(raw, source, warn) {
84
+ /**
85
+ * Discover nav fragments in a dir. Returns an ordered list of
86
+ * { path, team, source } — base `nav.json` (team null) first, then
87
+ * `nav.<team>.json` fragments sorted by team. A missing dir yields [].
88
+ */
89
+ function discoverFragments(dir, sourceLabel) {
90
+ if (!dir || !existsSync(dir)) return [];
91
+ let names;
92
+ try {
93
+ names = readdirSync(dir);
94
+ } catch {
95
+ return [];
96
+ }
97
+ const base = [];
98
+ const teamed = [];
99
+ for (const name of names) {
100
+ const m = name.match(FRAGMENT_RE);
101
+ if (!m) continue;
102
+ const team = m[1] || null;
103
+ const entry = { path: join(dir, name), team, source: sourceLabel + ' nav manifest' };
104
+ if (team === null) base.push(entry);
105
+ else teamed.push(entry);
106
+ }
107
+ teamed.sort((a, b) => (a.team < b.team ? -1 : a.team > b.team ? 1 : 0));
108
+ return base.concat(teamed);
109
+ }
110
+
111
+ function validateItem(raw, source, fragmentTeam, warn) {
51
112
  if (!raw || typeof raw !== 'object') {
52
113
  warn('[nav] warning: ' + source + ' contains a non-object entry, skipping');
53
114
  return null;
@@ -69,6 +130,8 @@ function validateItem(raw, source, warn) {
69
130
  label: raw.label,
70
131
  href: raw.href,
71
132
  section: typeof raw.section === 'string' && raw.section ? raw.section : 'Workspace',
133
+ // Resolution order: inline entry.team > fragment-filename team > null.
134
+ team: typeof raw.team === 'string' && raw.team ? raw.team : fragmentTeam,
72
135
  };
73
136
  if (Array.isArray(raw.aliases)) {
74
137
  item.aliases = raw.aliases.filter(function(a) { return typeof a === 'string' && a; });
@@ -78,7 +141,8 @@ function validateItem(raw, source, warn) {
78
141
  return item;
79
142
  }
80
143
 
81
- function readManifest(path, source, warn) {
144
+ function readFragment(fragment, warn) {
145
+ const { path, team, source } = fragment;
82
146
  if (!existsSync(path)) return [];
83
147
  let raw;
84
148
  try {
@@ -93,31 +157,30 @@ function readManifest(path, source, warn) {
93
157
  }
94
158
  const items = [];
95
159
  for (let i = 0; i < raw.length; i++) {
96
- const item = validateItem(raw[i], source, warn);
160
+ const item = validateItem(raw[i], source, team, warn);
97
161
  if (item) items.push(item);
98
162
  }
99
163
  return items;
100
164
  }
101
165
 
102
166
  /**
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.
167
+ * Fold `additions` into a copy of `base`. An addition whose id already exists
168
+ * replaces the existing entry in place (preserving order); a new id is
169
+ * appended. `warn` fires on each collision — pass a no-op to fold silently.
107
170
  */
108
- function mergeItems(coreItems, extItems, warn) {
109
- const merged = coreItems.slice();
171
+ function foldItems(base, additions, warn) {
172
+ const merged = base.slice();
110
173
  const indexById = new Map();
111
174
  for (let i = 0; i < merged.length; i++) {
112
175
  indexById.set(merged[i].id, i);
113
176
  }
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;
177
+ for (const add of additions) {
178
+ if (indexById.has(add.id)) {
179
+ warn('[nav] warning: extension nav id "' + add.id + '" shadows core item');
180
+ merged[indexById.get(add.id)] = add;
118
181
  } else {
119
- indexById.set(ext.id, merged.length);
120
- merged.push(ext);
182
+ indexById.set(add.id, merged.length);
183
+ merged.push(add);
121
184
  }
122
185
  }
123
186
  return merged;
@@ -126,11 +189,6 @@ function mergeItems(coreItems, extItems, warn) {
126
189
  /**
127
190
  * Classify an href by whether its target page is materialised on this device.
128
191
  * 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
192
  */
135
193
  function pagePresence(href, appDir, extDir) {
136
194
  const corePrefix = '/core/tools/workspace/app/';
@@ -150,13 +208,8 @@ function pagePresence(href, appDir, extDir) {
150
208
  }
151
209
 
152
210
  /**
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.
211
+ * Drop any entry whose target page is resolvable-in-shape but absent from
212
+ * disk (its owning library repo is not materialised here), logging why.
160
213
  */
161
214
  function scopeToMaterialised(items, appDir, extDir, warn) {
162
215
  const kept = [];
@@ -171,52 +224,92 @@ function scopeToMaterialised(items, appDir, extDir, warn) {
171
224
  }
172
225
 
173
226
  /**
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.
227
+ * Apply the per-request team filter to the (cached, device-independent)
228
+ * tagged list. Returns the input array BY REFERENCE when the filter is a
229
+ * pass-through, so the loader preserves cache identity for the common case.
230
+ *
231
+ * Precedence (mirrors filterProjectsByTeam):
232
+ * 1. requestedTeam (?team=<id>) — strict: only entries whose team matches
233
+ * exactly. Untagged entries are excluded from this narrow inspection view.
234
+ * 2. allTeams (?allTeams=1) — bypass, return everything.
235
+ * 3. no device team — bypass, return everything (backwards compatible).
236
+ * 4. default — entries whose team is the device team OR untagged (null).
237
+ */
238
+ function applyTeamFilter(items, opts) {
239
+ const requestedTeam = opts.requestedTeam || null;
240
+ if (requestedTeam) {
241
+ return items.filter(function(i) { return i.team === requestedTeam; });
242
+ }
243
+ if (opts.allTeams) return items;
244
+
245
+ const deviceTeam = opts.team;
246
+ const teamSet = deviceTeam == null
247
+ ? null
248
+ : (deviceTeam instanceof Set
249
+ ? deviceTeam
250
+ : new Set(Array.isArray(deviceTeam) ? deviceTeam : [deviceTeam]));
251
+ if (!teamSet || teamSet.size === 0) return items;
252
+
253
+ return items.filter(function(i) { return i.team == null || teamSet.has(i.team); });
254
+ }
255
+
256
+ /**
257
+ * Returns the composed, scoped nav items array.
176
258
  *
177
259
  * @param {object} opts
178
260
  * @param {string} opts.appDir absolute path to core app dir
179
261
  * @param {string} [opts.extDir] absolute path to operator extension dir
262
+ * @param {string|string[]|Set<string>} [opts.team] device display team(s);
263
+ * absent/null => nav shown unfiltered (backwards compatible)
264
+ * @param {boolean} [opts.allTeams] bypass team scoping (?allTeams=1)
265
+ * @param {string} [opts.requestedTeam] strict single-team filter (?team=<id>)
180
266
  * @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[]}>}
267
+ * @returns {Array<{id:string,label:string,href:string,section:string,team:string|null,aliases:string[]}>}
182
268
  */
183
269
  export function loadNav(opts) {
184
270
  const appDir = opts.appDir;
185
271
  const extDir = opts.extDir || null;
186
272
  const warn = opts.warn || function(m) { console.warn(m); };
187
273
 
188
- const coreManifest = join(appDir, 'nav.json');
189
- const extManifest = extDir ? join(extDir, 'nav.json') : null;
274
+ const fragments = discoverFragments(appDir, 'core').concat(discoverFragments(extDir, 'extension'));
190
275
 
191
- const coreMtime = mtime(coreManifest);
192
- const extMtime = extManifest ? mtime(extManifest) : 0;
276
+ // Signature: every fragment path + mtime, sorted. A content change, a new
277
+ // fragment appearing, or a fragment removed all change the signature.
278
+ const signature = fragments
279
+ .map(function(f) { return f.path + ':' + mtime(f.path); })
280
+ .sort()
281
+ .join('|');
193
282
 
194
- if (
195
- CACHE.items !== null &&
196
- CACHE.coreMtimeMs === coreMtime &&
197
- CACHE.extMtimeMs === extMtime
198
- ) {
199
- return CACHE.items;
200
- }
283
+ let tagged;
284
+ if (CACHE.tagged !== null && CACHE.signature === signature) {
285
+ tagged = CACHE.tagged;
286
+ } else {
287
+ const coreFragments = fragments.filter(function(f) { return f.source.indexOf('core') === 0; });
288
+ const extFragments = fragments.filter(function(f) { return f.source.indexOf('core') !== 0; });
201
289
 
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);
290
+ let coreItems = [];
291
+ for (const f of coreFragments) coreItems = foldItems(coreItems, readFragment(f, warn), function() {});
292
+ let extItems = [];
293
+ for (const f of extFragments) extItems = foldItems(extItems, readFragment(f, warn), function() {});
294
+
295
+ const merged = foldItems(coreItems, extItems, warn);
296
+ tagged = scopeToMaterialised(merged, appDir, extDir, warn);
297
+
298
+ CACHE.signature = signature;
299
+ CACHE.tagged = tagged;
300
+ }
208
301
 
209
- CACHE.coreMtimeMs = coreMtime;
210
- CACHE.extMtimeMs = extMtime;
211
- CACHE.items = scoped;
212
- return scoped;
302
+ return applyTeamFilter(tagged, {
303
+ team: opts.team,
304
+ allTeams: opts.allTeams === true,
305
+ requestedTeam: opts.requestedTeam || null,
306
+ });
213
307
  }
214
308
 
215
309
  /**
216
310
  * Test-only helper: clear the in-memory cache so reloads re-read manifests.
217
311
  */
218
312
  export function _resetNavCache() {
219
- CACHE.coreMtimeMs = -1;
220
- CACHE.extMtimeMs = -1;
221
- CACHE.items = null;
313
+ CACHE.signature = null;
314
+ CACHE.tagged = null;
222
315
  }
@@ -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.5",
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",