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

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.
@@ -10,6 +10,14 @@
10
10
 
11
11
  # Delegation modes
12
12
 
13
+ ## Dispatch-first is the operating posture, not a mode you select
14
+
15
+ Before the three modes below, the posture: **dispatch-first**. The PA's main thread exists for three things only — orchestrating work, keeping the work-trail current (work items, projects, spawn-log, journal, staging), and interacting with the operator. Everything that produces a work product — research, writing, code, analysis, multi-file reads — goes into a dispatch. This is stated unmissably at the top of the PA's CLAUDE.md as the "PA operating mode — dispatch-first" preamble; the modes below are only about HOW to dispatch once you have decided (single subagent vs task group), plus the narrow trivial-inline carve-out.
16
+
17
+ **The produces-a-work-product test.** If the ask produces an artefact the operator or a downstream worker would consume — a draft, a code change, a research finding, a synthesised answer that took reading to build — it is dispatched. Mode 3 (single-threaded) is reserved for work that produces no such artefact: a one-line edit, a single file read, a status answer straight from `pa knowledge`, or a task whose topic sits so close to what you are already doing that a dispatch adds pollution without benefit. "Dispatching costs more than finishing" is a tie-breaker inside that narrow carve-out — never a licence to work inline on anything that produces a work product.
18
+
19
+ ## The three modes
20
+
13
21
  Three modes. Selection is a choice, not a default — each has a distinct trigger shape.
14
22
 
15
23
  ## Mode 1 — Single subagent
@@ -10,6 +10,8 @@ You are a colleague, not a servant. You have opinions and you voice them -- with
10
10
 
11
11
  Your long-term goal is to learn {OPERATOR_NAME}'s judgement so thoroughly that you can operate autonomously on their behalf -- making decisions, managing tasks, running delegated work -- without needing to ask. Every interaction is training data. The measure of progress: how often can you act without escalating? Every question you have to ask that you could have answered is a gap.
12
12
 
13
+ **PA operating mode — dispatch-first. This is the defining posture; re-read it every boot.** Your main thread exists for three things only: (1) orchestrating work, (2) keeping the work-trail current (work items, projects, spawn-log, journal, staging), and (3) interacting with {OPERATOR_NAME}. **ALL substantive work goes into a dispatch** — a subagent with a full 8-section brief. Research, writing, code, analysis, multi-file reads, anything that produces a work product: dispatched, not done inline. You lead the team by delegating, not by doing. Keeping the main thread clean is what keeps you available to {OPERATOR_NAME} and stops you burning context on work you could have handed off. The narrow inline exceptions (a one-line edit, a single file read, a status answer) are enumerated in Bucket 3 — treat them as the exception, never the reflex. When unsure: dispatch.
14
+
13
15
  **Autonomy envelope.** Per-task-type approval state lives at `agents/{PA_NAME}/autonomy-envelope.md`. Three states: `fullyApproved` (act, don't ask), `checkBeforeCommitting` (draft, get sign-off before durable action), `needsFullSupervision` (do not act; escalate every step). {OPERATOR_NAME} promotes or demotes at any time via `pa autonomy set`. The envelope is the durable record; your judgement calibrates day-to-day, but the envelope is what you check when the reflex is unclear. See `@stravica-ai/engineering-pa-core/docs/autonomy-envelope-tracker.md`.
14
16
 
15
17
  **This file's discipline.** Seven buckets; always-on rules per bucket sized by need, not by number. New findings land in bucket knowledge docs under `operator/knowledge/<bucket-slug>/`, not here. CLAUDE.md changes only when the operating model shifts. The Librarian never writes to this file.
@@ -58,7 +60,7 @@ Serve {OPERATOR_NAME} as PA. Two-way agreement, not one-way command execution.
58
60
 
59
61
  Stay available for {OPERATOR_NAME}. Preserve main-thread context. Lead the team through delegation, not through doing.
60
62
 
61
- 1. **Delegate by default.** Execute inline only when trivial (one-line edit, single file read), when dispatching costs more than finishing, OR when the topic is close enough to main-thread context that a dispatch adds pollution without benefit. Off-topic tasks always dispatch -- different subjects in one thread degrade both.
63
+ 1. **Dispatch-first (see PA operating mode at the top of this file).** Substantive work is dispatched, full stop. The ONLY inline exceptions: a genuinely trivial one-off (one-line edit, single file read, a status answer straight from `pa knowledge`), or where the topic sits so close to what you are already doing that a dispatch adds pollution without benefit. "Dispatching costs more than finishing" is NOT a licence to work inline on anything that produces a work product. Off-topic tasks always dispatch -- different subjects in one thread degrade both.
62
64
  2. **Every dispatch uses the 8-section briefing contract:** Goal, Trace, Context, Tools and sources, Boundaries, Output format, Done when, Escalation rule. Structured return schemas in the Output-format section are load-bearing -- the parent only sees the worker's final message, no streaming.
63
65
  3. **Trace is load-bearing.** Without it, workers make locally consistent but globally incompatible choices.
64
66
  4. **Preserve main-thread context.** Dispatch when either (a) the reading/exploration would consume significant context, OR (b) the topic differs from the main-thread subject enough that inline work would pollute or fragment the current thread. Size is one signal; subject-continuation is another.
@@ -8,6 +8,7 @@ import { hostname } from 'node:os';
8
8
 
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
+ import { filterProjectsByTeam, readTeamScopeConfig } from '../../engineering-pa-core/src/desk/team-scope.mjs';
11
12
 
12
13
  /**
13
14
  * handleApi -- route handler for all API endpoints.
@@ -78,13 +79,16 @@ export function handleApi(req, res, url, repoRoot, deps = {}) {
78
79
  }
79
80
 
80
81
  // GET /__api/projects
82
+ // Team-scoped through the same core filter desk/pa use: with
83
+ // scopeProjectsToTeam opt-in, other-team projects are hidden from the UI;
84
+ // fail-open (returns everything) when scoping isn't enabled. ?allTeams=1
85
+ // bypasses (mirrors desk --all-teams); ?team=<id> filters to one team.
81
86
  if (method === 'GET' && pathname === '/__api/projects') {
82
- try {
83
- const projects = walkProjects(repoRoot);
87
+ scopedProjects(repoRoot, url).then((projects) => {
84
88
  sendJson(res, 200, { projects });
85
- } catch (err) {
89
+ }).catch((err) => {
86
90
  sendJson(res, 500, { error: err.message });
87
- }
91
+ });
88
92
  return true;
89
93
  }
90
94
 
@@ -100,15 +104,15 @@ export function handleApi(req, res, url, repoRoot, deps = {}) {
100
104
  }
101
105
 
102
106
  // GET /__api/overview
107
+ // Same team-scoping as /__api/projects — the homepage cards read from here.
103
108
  if (method === 'GET' && pathname === '/__api/overview') {
104
- try {
105
- const projects = walkProjects(repoRoot);
109
+ scopedProjects(repoRoot, url).then((projects) => {
106
110
  const work = walkWork(repoRoot);
107
111
  const overview = buildOverview(projects, work, repoRoot);
108
112
  sendJson(res, 200, overview);
109
- } catch (err) {
113
+ }).catch((err) => {
110
114
  sendJson(res, 500, { error: err.message });
111
- }
115
+ });
112
116
  return true;
113
117
  }
114
118
 
@@ -745,6 +749,23 @@ function walkProjects(repoRoot) {
745
749
  return projects;
746
750
  }
747
751
 
752
+ // Read projects filtered through the core team-scope rules (the same helpers
753
+ // desk and `pa` use — packages/core/src/desk/team-scope.mjs). Scoping only
754
+ // activates when operator/config/preferences.json opts in
755
+ // (scopeProjectsToTeam:true); with no config it's a no-op that returns every
756
+ // project (backward-compatible fail-open). Query controls mirror desk's flags:
757
+ // ?allTeams=1 -> bypass scoping entirely (like --all-teams)
758
+ // ?team=<id> -> strict single-team filter (like --team <id>)
759
+ async function scopedProjects(repoRoot, url) {
760
+ const config = await readTeamScopeConfig(repoRoot);
761
+ return filterProjectsByTeam(walkProjects(repoRoot), {
762
+ selfIdentities: config.teamIdentities,
763
+ scopeEnabled: config.scopeEnabled,
764
+ allTeams: url.searchParams.get('allTeams') === '1',
765
+ team: url.searchParams.get('team'),
766
+ });
767
+ }
768
+
748
769
  function walkWork(repoRoot) {
749
770
  const workDir = join(repoRoot, 'work');
750
771
  if (!existsSync(workDir)) return [];
@@ -12,9 +12,26 @@
12
12
  * - A missing or absent operator manifest is not an error.
13
13
  * - A malformed manifest is logged and skipped; the other side still loads.
14
14
  *
15
- * Caching: each load checks both manifest mtimes. The cached merged list is
16
- * returned unchanged if neither file changed since the last load. Missing
17
- * files are tracked with mtime 0 so their appearance triggers a reload.
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.
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.
18
35
  */
19
36
 
20
37
  import { readFileSync, statSync, existsSync } from 'node:fs';
@@ -107,35 +124,50 @@ function mergeItems(coreItems, extItems, warn) {
107
124
  }
108
125
 
109
126
  /**
110
- * Validate that every item's href resolves to an existing page, either under
111
- * APP_DIR or EXT_DIR (for top-level /<name>.html). Non-resolving items are
112
- * kept in the list with a warning so operators can see their work-in-progress.
127
+ * Classify an href by whether its target page is materialised on this device.
128
+ * 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.
113
134
  */
114
- function warnOnMissingHrefs(items, appDir, extDir, warn) {
135
+ function pagePresence(href, appDir, extDir) {
136
+ const corePrefix = '/core/tools/workspace/app/';
137
+ if (href.indexOf(corePrefix) === 0) {
138
+ const name = href.slice(corePrefix.length).split('?')[0].split('#')[0];
139
+ if (!name) return 'unresolvable';
140
+ return existsSync(join(appDir, name)) ? 'present' : 'absent';
141
+ }
142
+ const topMatch = href.match(/^\/([^/?#]+\.html)(?:[?#].*)?$/);
143
+ if (topMatch) {
144
+ const name = topMatch[1];
145
+ const inExt = extDir && existsSync(join(extDir, name));
146
+ const inApp = existsSync(join(appDir, name));
147
+ return (inExt || inApp) ? 'present' : 'absent';
148
+ }
149
+ return 'unresolvable';
150
+ }
151
+
152
+ /**
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.
160
+ */
161
+ function scopeToMaterialised(items, appDir, extDir, warn) {
162
+ const kept = [];
115
163
  for (const item of items) {
116
- const href = item.href;
117
- // Core-shape paths: /core/tools/workspace/app/<file>
118
- const corePrefix = '/core/tools/workspace/app/';
119
- if (href.indexOf(corePrefix) === 0) {
120
- const name = href.slice(corePrefix.length).split('?')[0].split('#')[0];
121
- if (!existsSync(join(appDir, name))) {
122
- warn('[nav] warning: nav item "' + item.id + '" points at missing page ' + href);
123
- }
164
+ if (pagePresence(item.href, appDir, extDir) === 'absent') {
165
+ warn('[nav] hidden: nav item "' + item.id + '" page not materialised on this device (' + item.href + ')');
124
166
  continue;
125
167
  }
126
- // Top-level extension-shape: /<file>.html
127
- const topMatch = href.match(/^\/([^/?#]+\.html)(?:[?#].*)?$/);
128
- if (topMatch) {
129
- const name = topMatch[1];
130
- const inExt = extDir && existsSync(join(extDir, name));
131
- const inApp = existsSync(join(appDir, name));
132
- if (!inExt && !inApp) {
133
- warn('[nav] warning: nav item "' + item.id + '" points at missing page ' + href);
134
- }
135
- continue;
136
- }
137
- // Any other shape we leave alone — might be an external link or a deep core path.
168
+ kept.push(item);
138
169
  }
170
+ return kept;
139
171
  }
140
172
 
141
173
  /**
@@ -172,12 +204,12 @@ export function loadNav(opts) {
172
204
  ? readManifest(extManifest, 'extension nav manifest', warn)
173
205
  : [];
174
206
  const merged = mergeItems(coreItems, extItems, warn);
175
- warnOnMissingHrefs(merged, appDir, extDir, warn);
207
+ const scoped = scopeToMaterialised(merged, appDir, extDir, warn);
176
208
 
177
209
  CACHE.coreMtimeMs = coreMtime;
178
210
  CACHE.extMtimeMs = extMtime;
179
- CACHE.items = merged;
180
- return merged;
211
+ CACHE.items = scoped;
212
+ return scoped;
181
213
  }
182
214
 
183
215
  /**
@@ -60,6 +60,11 @@ const APP_DIR = resolve(__dirname, '..', 'app');
60
60
  const DEBUG = process.env.WS_DEBUG === '1';
61
61
  const log = DEBUG ? console.log.bind(console) : function () {};
62
62
  const warn = DEBUG ? console.warn.bind(console) : function () {};
63
+ // Always-on warning sink. Most diagnostics stay silent unless WS_DEBUG=1, but
64
+ // failures that silently break operator-visible surface area (e.g. a whole
65
+ // extension failing to load, presenting as a vague UI outage) must ALWAYS be
66
+ // visible so the root cause is diagnosable without re-running under WS_DEBUG.
67
+ const warnAlways = console.warn.bind(console);
63
68
 
64
69
  // --- Extension dir ---
65
70
  // Convention: operator/extensions/workspace/ overlays core app pages and
@@ -461,7 +466,11 @@ async function loadExtensions() {
461
466
  });
462
467
  }
463
468
  } catch (e) {
464
- warn('[workspace] Extension api.mjs failed to load: ' + e.message);
469
+ // Always warn — a failed extension import takes out every route the
470
+ // extension registers, which surfaces to the operator as a broken UI with
471
+ // no obvious cause. Name the extension and the error; keep the server
472
+ // alive (a bad extension must not take down the whole workspace).
473
+ warnAlways('[workspace] Extension failed to load: ' + extApiPath + ' — ' + (e && e.stack ? e.stack : e));
465
474
  }
466
475
  }
467
476
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",