@stravica-ai/digital-operator-pa 0.1.47 → 0.1.49

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/src/pa/cli.mjs +24 -1
  2. package/dist/engineering-pa-core/docs/commands/onboard.md +81 -7
  3. package/dist/engineering-pa-core/src/distill/cli.mjs +65 -0
  4. package/dist/engineering-pa-core/src/distill/health.mjs +322 -0
  5. package/dist/engineering-pa-scaffold/package.json +3 -2
  6. package/dist/engineering-pa-scaffold/src/commands/init-workspace-extension.mjs +37 -5
  7. package/dist/engineering-pa-scaffold/src/commands/init.mjs +7 -1
  8. package/dist/engineering-pa-scaffold/src/generator.mjs +220 -11
  9. package/dist/engineering-pa-workspace/app/assets/theme.css +3 -2
  10. package/dist/engineering-pa-workspace/app/assets/theme.js +29 -6
  11. package/dist/engineering-pa-workspace/app/banner.js +14 -2
  12. package/dist/engineering-pa-workspace/app/delegations.html +1 -1
  13. package/dist/engineering-pa-workspace/app/home.html +1 -1
  14. package/dist/engineering-pa-workspace/app/index.html +1 -1
  15. package/dist/engineering-pa-workspace/app/knowledge-map.html +1 -1
  16. package/dist/engineering-pa-workspace/app/knowledge.html +1 -1
  17. package/dist/engineering-pa-workspace/app/notes.html +1 -1
  18. package/dist/engineering-pa-workspace/app/onboarding.html +1 -1
  19. package/dist/engineering-pa-workspace/app/profile.html +1 -1
  20. package/dist/engineering-pa-workspace/app/project-detail.html +1 -1
  21. package/dist/engineering-pa-workspace/app/projects.html +1 -1
  22. package/dist/engineering-pa-workspace/app/repos.html +1 -1
  23. package/dist/engineering-pa-workspace/app/schedules.html +1 -1
  24. package/dist/engineering-pa-workspace/app/secrets.html +1 -1
  25. package/dist/engineering-pa-workspace/app/staging.html +1 -1
  26. package/dist/engineering-pa-workspace/app/tasks.html +1 -1
  27. package/dist/engineering-pa-workspace/app/viewer.html +1 -1
  28. package/dist/engineering-pa-workspace/index.mjs +16 -0
  29. package/dist/engineering-pa-workspace/package.json +3 -2
  30. package/dist/engineering-pa-workspace/src/repo-root.mjs +26 -1
  31. package/dist/engineering-pa-workspace/src/server.mjs +4 -3
  32. package/dist/engineering-pa-workspace/src/workspace-page-contract.mjs +98 -0
  33. package/dist/engineering-pa-workspace/src/ws.mjs +2 -0
  34. package/docs/onboarding-solo.md +4 -4
  35. package/package.json +2 -1
@@ -7,13 +7,19 @@
7
7
  // kind: 'dir' (mkdir -p) | 'file' (write file with contents) | 'json'
8
8
  // (stringify + write).
9
9
 
10
- import { existsSync, readFileSync } from 'node:fs';
10
+ import { existsSync, readFileSync, statSync } from 'node:fs';
11
11
  import { readFile, readdir } from 'node:fs/promises';
12
12
  import { createRequire } from 'node:module';
13
- import { dirname, join, resolve } from 'node:path';
13
+ import { hostname, platform } from 'node:os';
14
+ import { delimiter as pathDelimiter, dirname, join, resolve } from 'node:path';
14
15
  import { fileURLToPath } from 'node:url';
15
16
 
16
17
  import { emptyEnvelope, renderEnvelope } from '../../engineering-pa-core/src/autonomy/envelope.mjs';
18
+ import {
19
+ DEFAULT_CADENCE_HOURS,
20
+ distillationScheduleFilename,
21
+ distillationScheduleId,
22
+ } from '../../engineering-pa-core/src/distill/health.mjs';
17
23
 
18
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
25
  const requireFromHere = createRequire(import.meta.url);
@@ -553,6 +559,13 @@ export function buildOnboardingStateJson(config) {
553
559
  profileIntake: 'pending',
554
560
  aliasOffer: 'pending',
555
561
  workspaceOffer: 'pending',
562
+ // Mandatory — no opt-out. The distillation sweep is the only thing
563
+ // that turns staged observations into knowledge docs; without an
564
+ // installed schedule the whole self-learning loop is dead. The
565
+ // /onboard walkthrough runs `scheduler install` on the scaffolded
566
+ // schedule file and triggers one real run before it marks this
567
+ // step ok. See packages/core/docs/commands/onboard.md.
568
+ librarianSchedule: 'pending',
556
569
  atlassianOffer: 'pending',
557
570
  },
558
571
  completedAt: null,
@@ -567,10 +580,180 @@ export function buildGitkeep() {
567
580
  return '';
568
581
  }
569
582
 
583
+ /**
584
+ * Pick the OS-native scheduler backend the scaffold writes into the
585
+ * distillation-sweep schedule file. macOS -> launchd, Linux -> cron,
586
+ * Windows -> schtasks. Every other platform lands on `null`, which
587
+ * makes `scheduler install` fall back to detect + refusal (with a
588
+ * clean stderr message).
589
+ *
590
+ * Kept as a switch on `process.platform` rather than a call to
591
+ * scheduler/detect.mjs: the schedule file is written once at scaffold
592
+ * time and shipped in the repo, so the backend choice must be stable
593
+ * (a `detect()` reading brew-installed cron vs launchd is a per-host
594
+ * moment-in-time decision that should not be baked into a tracked
595
+ * file). Every host later runs `scheduler install --force-host` from
596
+ * its own real backend; the schedule file's `backend` field just names
597
+ * the default. Exported for tests.
598
+ *
599
+ * @param {string} [plat] - override for tests; defaults to `process.platform`.
600
+ * @returns {'launchd'|'cron'|'schtasks'|null}
601
+ */
602
+ export function defaultSchedulerBackend(plat = platform()) {
603
+ if (plat === 'darwin') return 'launchd';
604
+ if (plat === 'linux') return 'cron';
605
+ if (plat === 'win32') return 'schtasks';
606
+ return null;
607
+ }
608
+
609
+ // System paths the launchd defaults already carry (see
610
+ // packages/core/src/scheduler/install.mjs withLaunchdDefaults). If
611
+ // `claude` resolves inside any of these, no explicit PATH is needed on
612
+ // the scaffolded schedule — the installer's baked-in PATH covers it.
613
+ const LAUNCHD_DEFAULT_PATHS = [
614
+ '/opt/homebrew/bin',
615
+ '/usr/local/bin',
616
+ '/usr/bin',
617
+ '/bin',
618
+ '/usr/sbin',
619
+ '/sbin',
620
+ ];
621
+
622
+ /**
623
+ * Walk `process.env.PATH` at scaffold time looking for the `claude`
624
+ * executable. Returns the resolved directory, or null if nothing on
625
+ * PATH matches. The scaffold uses this to decide whether to bake an
626
+ * explicit `environment.PATH` into the schedule file (needed when the
627
+ * operator's claude install lives somewhere the launchd default PATH
628
+ * would not find — e.g. ~/.local/bin from a curl-installer). Exported
629
+ * for tests, which drive it with a fake PATH.
630
+ *
631
+ * @param {object} [opts]
632
+ * @param {string} [opts.pathEnv] - override for tests; defaults to process.env.PATH.
633
+ * @param {string} [opts.binName] - override for tests; defaults to `claude`.
634
+ * @returns {string|null}
635
+ */
636
+ export function resolveClaudeBinDir({ pathEnv, binName = 'claude' } = {}) {
637
+ const raw = pathEnv ?? process.env.PATH ?? '';
638
+ const dirs = raw.split(pathDelimiter).filter(Boolean);
639
+ for (const dir of dirs) {
640
+ const candidate = join(dir, binName);
641
+ let st;
642
+ try {
643
+ st = statSync(candidate);
644
+ } catch {
645
+ continue;
646
+ }
647
+ if (!st.isFile()) continue;
648
+ if ((st.mode & 0o111) === 0) continue;
649
+ return dir;
650
+ }
651
+ return null;
652
+ }
653
+
654
+ /**
655
+ * Build the operator repo's scaffolded `schedules/<paName>-distillation-sweep.json`.
656
+ *
657
+ * This is the single load-bearing artefact that makes the whole
658
+ * self-learning loop work in a solo instance. Without a scheduled
659
+ * sweep, `agents/<paName>/staging/` fills up forever and no knowledge
660
+ * doc is ever distilled. The `/onboard` walkthrough runs
661
+ * `scheduler install` on this file (with --force-host to survive the
662
+ * macOS hostname flap) as a mandatory step before onboarding is marked
663
+ * complete.
664
+ *
665
+ * Shape choices, with the estate lore they close:
666
+ * - `command: node node_modules/@stravica-ai/digital-operator-pa/bin/distill run`.
667
+ * Absolute node path is resolved by the scheduler at install time
668
+ * (`resolveLaunchdArgv0('node', ...)` returns `process.execPath`);
669
+ * the bin path goes through the stable `node_modules/@stravica-ai/…`
670
+ * symlink rather than the versioned `.pnpm` store path, so a
671
+ * package upgrade does not silently break the schedule (Dex lore
672
+ * w-2026-07-16-006).
673
+ * - `workingDirectory` is the absolute `targetDir`. No `${DAVE_ROOT}`
674
+ * token — a solo instance is not the estate and does not have that
675
+ * env var; a fresh `os.hostname()`-bound scheduler install with an
676
+ * unset DAVE_ROOT would otherwise fall through to a bogus
677
+ * `node_modules`-rooted path (`validateWorkDir` refuses that at
678
+ * install time — good, but the scaffold should not create that
679
+ * failure mode in the first place).
680
+ * - `host` is the current machine's hostname as `os.hostname()`
681
+ * computes it, so `scheduler install` without `--force-host` picks
682
+ * up the file cleanly on the scaffolded machine, and refuses on any
683
+ * other. The /onboard step passes `--force-host` unconditionally
684
+ * because macOS hostnames flap between the DHCP name and the
685
+ * configured name (w018 hostname-flap precedent), and a well-formed
686
+ * refusal at first-run onboarding is the worst possible operator
687
+ * experience.
688
+ * - `enabled: true`. This is not opt-in.
689
+ * - `schedule: '0 * / 4 * * *'` — every 4 hours, on the hour. Matches
690
+ * the estate's original distillation-sweep cadence and cheap enough
691
+ * that a solo instance never notices the tick.
692
+ * - `environment.PATH` is included ONLY when `claude` resolves at
693
+ * scaffold time OUTSIDE the launchd default PATH set (e.g. ~/.local/bin
694
+ * from the curl installer). In the common brew case it's covered by
695
+ * `withLaunchdDefaults()` and setting an explicit PATH would
696
+ * override — not merge — that default and lose the standard
697
+ * directories (w-2026-08-05 EnvironmentVariables trap).
698
+ *
699
+ * @param {object} config
700
+ * @param {string} config.paName
701
+ * @param {string} config.targetDir - absolute
702
+ * @param {object} [seams]
703
+ * @param {string} [seams.host] - hostname override (for tests)
704
+ * @param {'launchd'|'cron'|'schtasks'|null} [seams.backend] - override (for tests)
705
+ * @param {string|null} [seams.claudeBinDir] - override (for tests)
706
+ * @returns {object}
707
+ */
708
+ export function buildDistillationScheduleJson(config, seams = {}) {
709
+ const backend = seams.backend !== undefined ? seams.backend : defaultSchedulerBackend();
710
+ const host = seams.host !== undefined ? seams.host : hostname();
711
+ const claudeBinDir = seams.claudeBinDir !== undefined
712
+ ? seams.claudeBinDir
713
+ : resolveClaudeBinDir();
714
+
715
+ const out = {
716
+ id: distillationScheduleId(config.paName),
717
+ agent: config.paName,
718
+ name: `Librarian distillation sweep (every ${DEFAULT_CADENCE_HOURS}h)`,
719
+ schedule: `0 */${DEFAULT_CADENCE_HOURS} * * *`,
720
+ command: 'node node_modules/@stravica-ai/digital-operator-pa/bin/distill run',
721
+ workingDirectory: config.targetDir,
722
+ enabled: true,
723
+ backend,
724
+ host,
725
+ description: [
726
+ `Solo-instance librarian distillation sweep for ${config.paName}. Turns the`,
727
+ 'staged observations under agents/<paName>/staging/ into promoted knowledge',
728
+ 'docs under operator/knowledge/, then invalidates the retrieval graph so the',
729
+ 'next `pa knowledge search` sees the new material. Installed by the /onboard',
730
+ 'walkthrough (mandatory step) via `scheduler install --force-host`. Removing',
731
+ 'this schedule silently kills the PA\'s self-learning loop; reinstate it by',
732
+ 're-running /onboard.',
733
+ ].join(' '),
734
+ // DAVE_ROOT MUST be set explicitly — the scheduler's withLaunchdDefaults()
735
+ // falls back to `resolve(__dirname, '..','..','..')` which under a
736
+ // published tarball lands inside the versioned .pnpm store path and
737
+ // `validateLaunchdDaveRoot` refuses (exit 2) at install time. The
738
+ // solo colleague running `scheduler install` from their instance
739
+ // shell also does not typically have DAVE_ROOT set. Baking it into
740
+ // the schedule closes both cases (w-2026-07-15-003 + w-2026-07-16-006
741
+ // trap class). PATH is only added when `claude` lives outside the
742
+ // launchd default PATH set.
743
+ environment: { DAVE_ROOT: config.targetDir },
744
+ };
745
+
746
+ if (claudeBinDir && !LAUNCHD_DEFAULT_PATHS.includes(claudeBinDir)) {
747
+ out.environment.PATH = [claudeBinDir, ...LAUNCHD_DEFAULT_PATHS].join(pathDelimiter);
748
+ }
749
+
750
+ return out;
751
+ }
752
+
570
753
  /**
571
754
  * Build the .claude/settings.json contents.
572
755
  *
573
- * Two things:
756
+ * Three things:
574
757
  * 1. `permissions.deny` for the built-in `deep-research` skill and
575
758
  * workflow. The core operating model (Bucket 4) directs the PA to
576
759
  * the `/research` verb, which persists a session and promotes
@@ -584,12 +767,16 @@ export function buildGitkeep() {
584
767
  * scaffold ships the wiring inert. A per-repo overlay slot is
585
768
  * scaffolded alongside as `.claude/coordinator-overlay.json`.
586
769
  *
587
- * SessionStart hooks (boot-context style injection) are deliberately
588
- * NOT scaffolded — the boot-context tool lives in the HQ estate, not
589
- * the published package. Scaffolding a hook that names a binary the
590
- * colleague does not have would fire on every session boot with a
591
- * non-blocking error. Documented in docs/onboarding-solo.md under
592
- * "what is not included in solo mode".
770
+ * 3. `hooks.SessionStart` wiring `distill health --hook-envelope` so
771
+ * every session boots with a `distillationHealth (live)` block at
772
+ * the top of the context. Reads the local distill ledger + the
773
+ * scaffolded schedule file — never spawns claude, never triggers a
774
+ * sweep, cheap to run. The block warns loudly when the schedule is
775
+ * missing (fresh scaffold pre-/onboard), disabled, or the last run
776
+ * is stale, so the whole self-learning loop cannot die silently
777
+ * the way it did on the WSD-laptop cold install of 0.1.41. Invoked
778
+ * through `pnpm exec` because Claude Code hook subprocesses do not
779
+ * inherit the pnpm-augmented PATH the interactive session enjoys.
593
780
  */
594
781
  export function buildClaudeSettingsJson() {
595
782
  return {
@@ -608,6 +795,17 @@ export function buildClaudeSettingsJson() {
608
795
  ],
609
796
  },
610
797
  ],
798
+ SessionStart: [
799
+ {
800
+ matcher: '',
801
+ hooks: [
802
+ {
803
+ type: 'command',
804
+ command: 'pnpm exec distill health --hook-envelope',
805
+ },
806
+ ],
807
+ },
808
+ ],
611
809
  },
612
810
  };
613
811
  }
@@ -810,12 +1008,23 @@ export function buildPlanFromTemplates(config, templates) {
810
1008
  plan.push({ kind: 'file', path: `agents/${paName}/autonomy-envelope.md`, contents: autonomyEnvelope });
811
1009
  plan.push({ kind: 'json', path: `agents/${paName}/spawn-log.json`, contents: spawnLog });
812
1010
 
813
- // Empty-directory markers — git won't track empty dirs.
1011
+ // Load-bearing: the scaffolded distillation-sweep schedule the
1012
+ // /onboard walkthrough installs and fires as a mandatory step. See
1013
+ // buildDistillationScheduleJson for the shape rationale.
1014
+ const distillSched = buildDistillationScheduleJson(config);
1015
+ plan.push({
1016
+ kind: 'json',
1017
+ path: `schedules/${distillationScheduleFilename(paName)}`,
1018
+ contents: distillSched,
1019
+ });
1020
+
1021
+ // Empty-directory markers — git won't track empty dirs. `schedules/`
1022
+ // now ships with a real file (the distillation sweep) so it never
1023
+ // needs a .gitkeep.
814
1024
  for (const d of [
815
1025
  `agents/${paName}/staging`,
816
1026
  'work',
817
1027
  'projects',
818
- 'schedules',
819
1028
  '.vault',
820
1029
  'operator/extensions/roles',
821
1030
  'operator/extensions/tools',
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Two themes live here: dark (default) and light, toggled by the operator
5
5
  * via the banner button. Selection is persisted in localStorage under
6
- * `dave.theme` and applied to `documentElement.dataset.theme` before first
7
- * paint by the inline init script in each page's <head>.
6
+ * `dop.theme` (product-generic; legacy `dave.theme` is read once and
7
+ * migrated forward) and applied to `documentElement.dataset.theme` before
8
+ * first paint by the inline init script in each page's <head>.
8
9
  *
9
10
  * Token set is the union of every page's prior `:root` block. Both themes
10
11
  * MUST define the same set of names — any unilateral token becomes invisible
@@ -4,7 +4,7 @@
4
4
  * Two responsibilities split across two surfaces:
5
5
  *
6
6
  * 1. Pre-paint init. Each page also embeds a tiny inline <script> in <head>
7
- * that reads `localStorage.dave.theme` and sets
7
+ * that reads `localStorage.dop.theme` and sets
8
8
  * `documentElement.dataset.theme` BEFORE first paint. That avoids a
9
9
  * light-flash on a dark-preference user (FOUC). The inline script is the
10
10
  * duplicate of `applyStoredTheme()` below; keeping it inline is the
@@ -18,7 +18,17 @@
18
18
  * context (matching banner.js's behaviour) so a page opened directly
19
19
  * still owns its own toggle.
20
20
  *
21
- * Storage: localStorage key `dave.theme`, values 'dark' | 'light'.
21
+ * Storage: localStorage key `dop.theme`, values 'dark' | 'light'. The
22
+ * canonical token lives in `src/workspace-page-contract.mjs`; this file
23
+ * hand-copies the literal because it runs in the browser and cannot
24
+ * import ESM modules from that path. The theme parity test pins the two
25
+ * copies to the same value.
26
+ *
27
+ * Legacy: `dave.theme` is the pre-rename key. On first boot after the
28
+ * rename we read it once, copy the value to `dop.theme`, and never touch
29
+ * it again — nobody re-picks their theme just because the estate went
30
+ * product-generic. See `src/workspace-page-contract.mjs`.
31
+ *
22
32
  * Default when unset: 'dark' (operator preference — see CLAUDE.md). We do
23
33
  * NOT consult prefers-color-scheme; dark is the chosen default and we don't
24
34
  * want auto-detection overriding it on first load.
@@ -26,15 +36,22 @@
26
36
  (function () {
27
37
  'use strict';
28
38
 
29
- var STORAGE_KEY = 'dave.theme';
39
+ var STORAGE_KEY = 'dop.theme';
40
+ var LEGACY_STORAGE_KEY = 'dave.theme';
30
41
  var DEFAULT_THEME = 'dark';
31
42
  var VALID = { dark: 1, light: 1 };
32
- var MESSAGE_TYPE = 'dave.theme.set';
43
+ var MESSAGE_TYPE = 'dop.theme.set';
33
44
 
34
45
  function readStored() {
35
46
  try {
36
47
  var raw = localStorage.getItem(STORAGE_KEY);
37
48
  if (raw && VALID[raw]) return raw;
49
+ // Legacy fallback: one-time migration from the pre-rename key.
50
+ var legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
51
+ if (legacy && VALID[legacy]) {
52
+ try { localStorage.setItem(STORAGE_KEY, legacy); } catch (_e) { /* no-op */ }
53
+ return legacy;
54
+ }
38
55
  } catch (_e) { /* storage unavailable — fall through to default */ }
39
56
  return DEFAULT_THEME;
40
57
  }
@@ -148,12 +165,18 @@
148
165
  boot();
149
166
  }
150
167
 
151
- // Expose for tests + the inline init helper.
152
- window.__daveTheme = {
168
+ // Expose for tests + the inline init helper. `__daveTheme` is kept as an
169
+ // alias for anything under `operator/extensions/**` that grew a direct
170
+ // reference before the rename; `__dopTheme` is the canonical name and
171
+ // is what new callers should use.
172
+ var api = {
153
173
  read: readStored,
154
174
  write: writeStored,
155
175
  apply: applyTheme,
156
176
  storageKey: STORAGE_KEY,
177
+ legacyStorageKey: LEGACY_STORAGE_KEY,
157
178
  defaultTheme: DEFAULT_THEME,
158
179
  };
180
+ window.__dopTheme = api;
181
+ window.__daveTheme = api;
159
182
  })();
@@ -22,7 +22,11 @@
22
22
 
23
23
  const ENDPOINT = '/__api/status-mon';
24
24
  const POLL_INTERVAL_MS = 30_000;
25
- const DISMISSED_KEY = 'dave.statusmon.dismissed';
25
+ // Product-generic key; the pre-rename store was 'dave.statusmon.dismissed'.
26
+ // Read-once fallback keeps existing dismissals across the rename so a
27
+ // resolved incident someone had already dismissed doesn't reappear.
28
+ const DISMISSED_KEY = 'dop.statusmon.dismissed';
29
+ const LEGACY_DISMISSED_KEY = 'dave.statusmon.dismissed';
26
30
 
27
31
  const SLOT_ID = 'global-banner';
28
32
 
@@ -36,7 +40,15 @@
36
40
 
37
41
  function readDismissed() {
38
42
  try {
39
- const raw = localStorage.getItem(DISMISSED_KEY);
43
+ var raw = localStorage.getItem(DISMISSED_KEY);
44
+ if (!raw) {
45
+ // Legacy fallback: read the pre-rename store once and migrate.
46
+ var legacy = localStorage.getItem(LEGACY_DISMISSED_KEY);
47
+ if (legacy) {
48
+ try { localStorage.setItem(DISMISSED_KEY, legacy); } catch { /* no-op */ }
49
+ raw = legacy;
50
+ }
51
+ }
40
52
  if (!raw) return [];
41
53
  const parsed = JSON.parse(raw);
42
54
  if (!Array.isArray(parsed)) return [];
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Delegations — Workspace</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Home</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -6,7 +6,7 @@
6
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
- <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>
9
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
10
10
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
11
11
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
12
12
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Knowledge Map — Workspace</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Knowledge</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Notes — Dave</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Welcome to your PA</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Profile</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Project Detail</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Projects — Dave</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Git Overview — Workspace</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Schedules — Workspace</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Secrets</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Staging Inbox — Workspace</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Tasks — Dave</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Document Viewer</title>
7
- <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>
7
+ <script>(function(){try{var t=localStorage.getItem('dop.theme');if(!t){var legacy=localStorage.getItem('dave.theme');if(legacy==='light'||legacy==='dark'){t=legacy;try{localStorage.setItem('dop.theme',legacy);}catch(_){}}}document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(_){document.documentElement.dataset.theme='dark';}})();</script>
8
8
  <link rel="stylesheet" href="/core/tools/workspace/app/assets/theme.css">
9
9
  <script src="/core/tools/workspace/app/assets/theme.js" defer></script>
10
10
  <style>
@@ -10,3 +10,19 @@ export const version = '0.1.1';
10
10
  export { loadNav } from './src/nav.mjs';
11
11
  export { startPoller as startStatusMonPoller } from './src/status-mon/poller.mjs';
12
12
  export { handleApi } from './src/api.mjs';
13
+
14
+ // Shared workspace-page contract — every core page and every scaffolded
15
+ // extension page carries the same head + top-of-body markup. Owning the
16
+ // tokens here stops the scaffold and the core pages drifting on the theme
17
+ // storage key.
18
+ export {
19
+ THEME_STORAGE_KEY,
20
+ THEME_LEGACY_STORAGE_KEY,
21
+ THEME_MESSAGE_TYPE,
22
+ THEME_CSS_LINK,
23
+ THEME_JS_TAG,
24
+ BANNER_JS_TAG,
25
+ GLOBAL_BANNER_HTML,
26
+ prepaintScriptTag,
27
+ standardHeadInner,
28
+ } from './src/workspace-page-contract.mjs';
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@stravica-ai/engineering-pa-workspace",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Workspace server + viewer — operator-facing local web UI for the PA platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "exports": {
8
- ".": "./index.mjs"
8
+ ".": "./index.mjs",
9
+ "./index.mjs": "./index.mjs"
9
10
  },
10
11
  "dependencies": {
11
12
  "@stravica-ai/engineering-pa-cli": "workspace:*",