@monster-orange/den 0.2.8 → 0.4.0

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 (158) hide show
  1. package/CHANGELOG.md +1731 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +730 -6
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +12 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/agents/default-pack/be-dev.md +20 -0
  22. package/src/shared/agents/default-pack/devops.md +14 -0
  23. package/src/shared/agents/default-pack/fe-dev.md +20 -0
  24. package/src/shared/agents/default-pack/orchestrator.md +16 -0
  25. package/src/shared/agents/default-pack/planner.md +17 -0
  26. package/src/shared/agents/default-pack/reviewer.md +14 -0
  27. package/src/shared/agents/default-pack/ux-designer.md +15 -0
  28. package/src/shared/agents/den-claude.ts +173 -0
  29. package/src/shared/agents/generic.ts +52 -0
  30. package/src/shared/agents/personas.ts +218 -0
  31. package/src/shared/agents/records.ts +118 -0
  32. package/src/shared/agents/runtime.ts +192 -0
  33. package/src/shared/agents/runtimes.ts +47 -0
  34. package/src/shared/auth/headless-login.ts +1 -1
  35. package/src/shared/auth/sudo-vault.ts +93 -31
  36. package/src/shared/auth/token-providers/index.ts +7 -3
  37. package/src/shared/brand.ts +7 -0
  38. package/src/shared/config-error.ts +33 -3
  39. package/src/shared/constants.ts +3 -24
  40. package/src/shared/den-config.ts +257 -5
  41. package/src/shared/edge/config.ts +147 -21
  42. package/src/shared/edge/exec.ts +31 -0
  43. package/src/shared/edge/included.ts +169 -0
  44. package/src/shared/edge/render.ts +69 -14
  45. package/src/shared/edge/runtime.ts +128 -121
  46. package/src/shared/edge/status.ts +68 -8
  47. package/src/shared/edge/tls.ts +328 -0
  48. package/src/shared/edge/types.ts +25 -9
  49. package/src/shared/engine.ts +23 -0
  50. package/src/shared/environment-manager/default-workspace.ts +16 -1
  51. package/src/shared/environment-manager/health.ts +73 -38
  52. package/src/shared/environment-manager/scope.ts +168 -0
  53. package/src/shared/environment-manager/service-runner.ts +789 -43
  54. package/src/shared/environment-manager/up-targets.ts +132 -0
  55. package/src/shared/environment-manager/workspaces.ts +19 -0
  56. package/src/shared/health/rollup.ts +48 -0
  57. package/src/shared/open-url.ts +21 -2
  58. package/src/shared/org-sources.ts +78 -59
  59. package/src/shared/org.ts +53 -5
  60. package/src/shared/plugins/manifest.ts +406 -0
  61. package/src/shared/plugins/store.ts +358 -0
  62. package/src/shared/process/legacy-adopt.ts +285 -0
  63. package/src/shared/process/process-group.ts +202 -0
  64. package/src/shared/process/spawn-streaming.ts +7 -0
  65. package/src/shared/secret-env.ts +70 -0
  66. package/src/shared/secrets/doppler.ts +1 -1
  67. package/src/shared/secrets/index.ts +279 -50
  68. package/src/shared/secrets/none.ts +56 -0
  69. package/src/shared/secrets/strategies.ts +202 -0
  70. package/src/shared/secrets/types.ts +1 -1
  71. package/src/shared/sessions/client.ts +277 -0
  72. package/src/shared/sessions/den-pty.ts +97 -0
  73. package/src/shared/sessions/env.ts +79 -0
  74. package/src/shared/sessions/host.ts +667 -0
  75. package/src/shared/sessions/names.ts +85 -0
  76. package/src/shared/sessions/paths.ts +27 -0
  77. package/src/shared/sessions/protocol.ts +72 -0
  78. package/src/shared/sessions/pty.ts +141 -0
  79. package/src/shared/sessions/ring-buffer.ts +160 -0
  80. package/src/shared/sessions/types.ts +81 -0
  81. package/src/shared/smoke/git-remote.ts +40 -0
  82. package/src/shared/stacks/manifest-commands.ts +1 -1
  83. package/src/shared/stacks/namespace.ts +178 -0
  84. package/src/shared/stacks/owners.ts +303 -0
  85. package/src/shared/stacks/render.ts +8 -3
  86. package/src/shared/stacks/runtime.ts +100 -16
  87. package/src/shared/stacks/status.ts +63 -12
  88. package/src/shared/strategies/registry.ts +89 -0
  89. package/src/shared/theme/contract.ts +538 -0
  90. package/src/shared/types/index.ts +12 -3
  91. package/src/ui/cli/agents-command.ts +812 -0
  92. package/src/ui/cli/create.ts +24 -7
  93. package/src/ui/cli/doctor-command.ts +260 -0
  94. package/src/ui/cli/edge-command.ts +56 -10
  95. package/src/ui/cli/env-probe.ts +266 -0
  96. package/src/ui/cli/flag-parser.ts +18 -5
  97. package/src/ui/cli/health-display.ts +25 -0
  98. package/src/ui/cli/index.ts +580 -60
  99. package/src/ui/cli/notice.ts +20 -0
  100. package/src/ui/cli/plugin-command.ts +849 -0
  101. package/src/ui/cli/sessions-command.ts +718 -0
  102. package/src/ui/cli/stack-command.ts +63 -2
  103. package/src/ui/cli/test-runner.ts +11 -9
  104. package/src/ui/cli/theme-command.ts +136 -0
  105. package/src/ui/cli/web-command.ts +47 -23
  106. package/src/ui/mcp/api.ts +28 -2
  107. package/src/ui/mcp/scope.ts +145 -20
  108. package/src/ui/mcp/server.ts +27 -2
  109. package/src/ui/mcp/tools.ts +94 -12
  110. package/src/ui/server/appearance-prefs.ts +95 -0
  111. package/src/ui/server/audit.ts +35 -0
  112. package/src/ui/server/daemon-reaper.ts +29 -1
  113. package/src/ui/server/engine-stamp.ts +83 -0
  114. package/src/ui/server/github-service.ts +23 -44
  115. package/src/ui/server/graph-page.ts +20 -0
  116. package/src/ui/server/graph-prefs.ts +44 -1
  117. package/src/ui/server/http-errors.ts +293 -0
  118. package/src/ui/server/http-helpers.ts +22 -3
  119. package/src/ui/server/index.ts +539 -51
  120. package/src/ui/server/public/appearance.js +531 -0
  121. package/src/ui/server/public/edge-page.js +248 -0
  122. package/src/ui/server/public/edge.html +251 -0
  123. package/src/ui/server/public/environments.html +65 -83
  124. package/src/ui/server/public/error-page.js +247 -0
  125. package/src/ui/server/public/error.html +260 -0
  126. package/src/ui/server/public/graph-model.js +332 -14
  127. package/src/ui/server/public/graph.css +141 -47
  128. package/src/ui/server/public/graph.js +223 -44
  129. package/src/ui/server/public/header.js +396 -600
  130. package/src/ui/server/public/health-fit.js +248 -0
  131. package/src/ui/server/public/health-rollup.js +128 -0
  132. package/src/ui/server/public/i18n/es.js +287 -0
  133. package/src/ui/server/public/i18n/ja.js +276 -0
  134. package/src/ui/server/public/index.html +139 -438
  135. package/src/ui/server/public/local-dev.html +3468 -4044
  136. package/src/ui/server/public/logs.html +71 -208
  137. package/src/ui/server/public/panel.js +14 -2
  138. package/src/ui/server/public/release.html +175 -389
  139. package/src/ui/server/public/services.html +596 -770
  140. package/src/ui/server/public/settings-page.js +759 -0
  141. package/src/ui/server/public/settings-registry.js +521 -0
  142. package/src/ui/server/public/settings.html +590 -0
  143. package/src/ui/server/public/shell.js +3869 -0
  144. package/src/ui/server/public/topbar.js +1413 -0
  145. package/src/ui/server/public/version-label.js +12 -0
  146. package/src/ui/server/public/workspace-picker.js +74 -34
  147. package/src/ui/server/readonly-routes.ts +37 -3
  148. package/src/ui/server/services-list.ts +94 -17
  149. package/src/ui/server/settings-distribution.ts +74 -0
  150. package/src/ui/server/shell-prefs.ts +192 -0
  151. package/src/ui/server/theme-routes.ts +292 -0
  152. package/src/ui/server/theme-runtime.ts +407 -0
  153. package/src/ui/server/ui-assets.ts +16 -1
  154. package/src/ui/server/ui-language.ts +50 -0
  155. package/src/ui/server/workspace-routes.ts +48 -18
  156. package/themes/den/den-theme.json +72 -0
  157. package/themes/den/favicon.ico +0 -0
  158. package/themes/den/mark.svg +6 -0
@@ -5,6 +5,7 @@
5
5
  * server's /api/health endpoint and the CLI's status command.
6
6
  */
7
7
 
8
+ import { rollupRepo, type RepoRollup } from "../health/rollup.js";
8
9
  import { hasRuntime, repoKind } from "../repo-kind.js";
9
10
  import * as https from "https";
10
11
  import * as tls from "tls";
@@ -45,6 +46,7 @@ import type {
45
46
  ResolvedService,
46
47
  ServiceCatalog,
47
48
  ServiceHealthConfig,
49
+ WorkspaceConfig,
48
50
  } from "../types/index.js";
49
51
  import type { Environment } from "../types/index.js";
50
52
 
@@ -80,6 +82,13 @@ export interface ServiceHealthResult {
80
82
  /** Where the service runs for the env checked (R13). */
81
83
  runs?: Placement["runs"];
82
84
  target?: Placement["target"];
85
+ /** A repo row's rollup of every service (rollupRepoHealth). */
86
+ rollup?: RepoRollup;
87
+ /**
88
+ * The workspaces checked that define this service, when more than one
89
+ * was (checkCatalogHealth's `workspaces`): a shared service, listed once.
90
+ */
91
+ workspaces?: string[];
83
92
  }
84
93
 
85
94
  // ── Name resolution ───────────────────────────────────────────────────────────
@@ -184,11 +193,13 @@ function extraCa(): string[] {
184
193
  if (_extraCa) return _extraCa;
185
194
  _extraCa = [];
186
195
  try {
187
- // The edge's own CA, and the CA it was pointed at (`edge.caroot`, e.g. the
188
- // laptop's mkcert CA that signs the projects' certificates).
196
+ // The edge's own CA, and the CA it was pointed at (`edge.tls.ca`, formerly
197
+ // `edge.caroot`, e.g. the laptop's mkcert CA that signs the projects'
198
+ // certificates).
189
199
  const cfg = readEdgeConfig();
190
200
  const pems = [path.join(cfg.stateDir, "ca", "rootCA.pem")];
191
- if (cfg.caroot) pems.push(path.join(cfg.caroot, "rootCA.pem"));
201
+ const ca = cfg.tls.strategy === "none" ? null : cfg.tls.ca;
202
+ if (ca) pems.push(path.join(ca, "rootCA.pem"));
192
203
  for (const pem of pems)
193
204
  if (fs.existsSync(pem)) _extraCa.push(fs.readFileSync(pem, "utf-8"));
194
205
  } catch {
@@ -823,32 +834,45 @@ export async function checkServiceHealth(
823
834
  export async function checkCatalogHealth(
824
835
  catalog: ServiceCatalog = readCatalog(),
825
836
  env: Environment = "local",
837
+ /**
838
+ * The workspaces to check (focus first), e.g. the view scope's. Default:
839
+ * the focused one. A service several define is checked once, from the
840
+ * first, with `workspaces` naming every one.
841
+ */
842
+ workspaces?: { name: string; config: WorkspaceConfig }[],
826
843
  ): Promise<ServiceHealthResult[]> {
827
844
  const ws = getActiveWorkspace();
845
+ const list = workspaces ?? (ws ? [ws] : []);
846
+ const byAddress = new Map<string, ResolvedService>();
847
+ const owners = new Map<string, string[]>();
848
+ for (const w of list)
849
+ for (const r of listWorkspaceServices(w.config, catalog, w.name)) {
850
+ // Modules and libraries have no runtime, so no health of their own.
851
+ if (!hasRuntime(repoKind(r.repoPath))) continue;
852
+ if (!byAddress.has(r.address)) byAddress.set(r.address, r);
853
+ const o = owners.get(r.address) ?? [];
854
+ if (!o.includes(w.name)) owners.set(r.address, [...o, w.name]);
855
+ }
828
856
 
829
- if (!ws) return [];
830
- // Modules and libraries have no runtime, so no health of their own.
831
- const resolved = listWorkspaceServices(ws.config, catalog, ws.name).filter(
832
- (r) => hasRuntime(repoKind(r.repoPath)),
857
+ return Promise.all(
858
+ [...byAddress.values()].map(async (r) => {
859
+ const h = await checkServiceHealth(r, env);
860
+ return workspaces ? { ...h, workspaces: owners.get(r.address) } : h;
861
+ }),
833
862
  );
834
-
835
- return Promise.all(resolved.map((r) => checkServiceHealth(r, env)));
836
863
  }
837
864
 
838
865
  /**
839
- * Collapse a multi-service repo's per-subsystem health rows into a single
840
- * repo-level row keyed on the manifest `entrypoint` (the front-door app).
866
+ * One row per repo, its health rolled up from every service's check by the
867
+ * rule the web UI's header row and graph use (src/shared/health/rollup.ts):
868
+ * down when the entry service (the manifest `entrypoint`) is down, degraded
869
+ * when another checked service is, healthy when something is checked and
870
+ * nothing fails, unknown when nothing is checked. Unchecked services never
871
+ * count (0.3.0-dev.10).
841
872
  *
842
- * Stack app-repos declare their shared-infra sidecars (pg/redis/sqs/ddb/s3) as
843
- * manifest `services:`, and those sidecars health-check the shared infra-stack
844
- * stack — so they read "ok" whenever infra-stack is up, making a down app look
845
- * partially running. Rolling up to the entrypoint answers the real question
846
- * ("is this repo's service up?") instead.
847
- *
848
- * A repo is collapsed only when it has multiple rows AND its manifest declares an
849
- * `entrypoint` present among them. Repos with no entrypoint (e.g. the
850
- * infra-stack infra provider, whose subsystems ARE the meaningful services)
851
- * keep their subsystem breakdown. Single-row repos pass through unchanged.
873
+ * The repo row is the entry service's row under the repo name, with
874
+ * `rollup` set; its `health` is "error" for down or degraded. A remote
875
+ * service keeps its own row too (R13). Single-service repos keep their row.
852
876
  */
853
877
  export function rollupRepoHealth(
854
878
  results: ServiceHealthResult[],
@@ -867,23 +891,34 @@ export function rollupRepoHealth(
867
891
 
868
892
  const out: ServiceHealthResult[] = [];
869
893
  for (const [repoName, rows] of groups) {
870
- if (rows.length <= 1) {
871
- out.push(...rows);
872
- continue;
873
- }
874
- const entrypoint = getEntrypoint(repoName, rows[0].repoPath);
875
- const epRow = entrypoint
876
- ? rows.find((r) => r.subsystem === entrypoint)
877
- : undefined;
878
- if (epRow) {
879
- // Show the entrypoint's health under the clean repo-name address. A
880
- // remote service is not a sidecar: it keeps its own row (R13).
881
- out.push({ ...epRow, address: repoName });
882
- out.push(...rows.filter((r) => r !== epRow && r.runs === "remote"));
883
- } else {
884
- // No entrypoint → infra provider; keep the subsystem breakdown.
885
- out.push(...rows);
886
- }
894
+ const entrypoint =
895
+ rows.length > 1 ? getEntrypoint(repoName, rows[0].repoPath) : undefined;
896
+ const rollup = rollupRepo(
897
+ repoName,
898
+ rows.map((r) => ({
899
+ service: r.subsystem,
900
+ ok: r.health === "ok" ? true : r.health === "error" ? false : null,
901
+ isEntrypoint: !!entrypoint && r.subsystem === entrypoint,
902
+ })),
903
+ );
904
+ const lead =
905
+ rows.find((r) => r.subsystem === rollup.entry) ??
906
+ rows.find((r) => r.health === "error") ??
907
+ rows[0];
908
+ out.push({
909
+ ...lead,
910
+ address: rows.length > 1 ? repoName : lead.address,
911
+ health:
912
+ rollup.state === "ok"
913
+ ? "ok"
914
+ : rollup.state === "unknown"
915
+ ? "unknown"
916
+ : "error",
917
+ rollup,
918
+ });
919
+ // A remote service is not a sidecar: it keeps its own row (R13).
920
+ if (rows.length > 1)
921
+ out.push(...rows.filter((r) => r !== lead && r.runs === "remote"));
887
922
  }
888
923
  return out;
889
924
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The view scope (0.3.0-dev.16): which workspaces' repos are in view, the
3
+ * one source for the header's health row, the pages and `<cli> status`.
4
+ *
5
+ * - **Scope** is a view filter: "all" (every active workspace) or one
6
+ * active workspace. It is a user preference (user.yml `view.scope`) and
7
+ * changes nothing else: no edge route, container or service.
8
+ * - **Focus** is the default target for commands (`up`, `restart`, the
9
+ * Local Dev actions). Scope never changes it.
10
+ *
11
+ * "all" is the default, also with several active workspaces: the health
12
+ * row already covered every active workspace, so a page that showed only
13
+ * the focused one disagreed with the row above it. With one active
14
+ * workspace "all" and that workspace are the same view.
15
+ */
16
+ import { readUserConfig, type UserConfig } from "../den-config.js";
17
+ import type { WorkspaceConfig } from "../types/index.js";
18
+ import { readWorkspacesFile } from "./workspaces.js";
19
+
20
+ /** "all", or one workspace's name. */
21
+ export type ViewScope = string;
22
+ export const ALL_ACTIVE = "all";
23
+
24
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
25
+
26
+ /** The stored scope, normalised: "all" unless a plausible workspace name. */
27
+ export function storedScope(user: Pick<UserConfig, "view">): ViewScope {
28
+ const s = user.view?.scope;
29
+ return typeof s === "string" && s !== ALL_ACTIVE && NAME.test(s)
30
+ ? s
31
+ : ALL_ACTIVE;
32
+ }
33
+
34
+ /**
35
+ * The workspaces in view, focused first, then the other active ones in
36
+ * their file order. A named scope that is no longer active falls back to
37
+ * all active (so a deactivated workspace never empties the view). Pure.
38
+ */
39
+ export function scopeWorkspaceNames(
40
+ scope: ViewScope,
41
+ ws: { active: string[]; focus: string | null; known: string[] },
42
+ ): string[] {
43
+ const active = ws.active.filter((n) => ws.known.includes(n));
44
+ if (scope !== ALL_ACTIVE && active.includes(scope)) return [scope];
45
+ const focus = ws.focus && active.includes(ws.focus) ? ws.focus : null;
46
+ return [...(focus ? [focus] : []), ...active.filter((n) => n !== focus)];
47
+ }
48
+
49
+ /** What a view resolves to: the scope asked for, and what is in it. */
50
+ export interface ScopeView {
51
+ /** The stored scope ("all" or a workspace). */
52
+ scope: ViewScope;
53
+ /** True when a named scope is not active any more: showing all. */
54
+ fellBack: boolean;
55
+ /** The workspaces in view, focused first. */
56
+ workspaces: { name: string; config: WorkspaceConfig }[];
57
+ /** The focused workspace: the default target for commands. */
58
+ focus: string | null;
59
+ }
60
+
61
+ /** The view for `scope` (default: the user's stored one). */
62
+ export function scopeView(scope?: ViewScope): ScopeView {
63
+ const want = scope ?? storedScope(readUserConfig());
64
+ const file = readWorkspacesFile();
65
+ const known = Object.keys(file.workspaces);
66
+ const focusName =
67
+ process.env.DEN_WORKSPACE_OVERRIDE ?? file.focus ?? file.active[0] ?? null;
68
+ const focus = focusName && file.workspaces[focusName] ? focusName : null;
69
+ const names = scopeWorkspaceNames(want, {
70
+ active: file.active,
71
+ focus,
72
+ known,
73
+ });
74
+ return {
75
+ scope: want,
76
+ fellBack: want !== ALL_ACTIVE && !names.includes(want),
77
+ workspaces: names.map((name) => ({
78
+ name,
79
+ config: file.workspaces[name],
80
+ })),
81
+ focus,
82
+ };
83
+ }
84
+
85
+ /** A cache key for what is in view (the scope's workspaces, in order). */
86
+ export function scopeKey(view: ScopeView): string {
87
+ return view.workspaces.map((w) => w.name).join(",");
88
+ }
89
+
90
+ /**
91
+ * Validate a scope a client asks for: "all", or a workspace that is active
92
+ * now. Pure; the caller stores the result in user.yml `view`.
93
+ */
94
+ export function parseScope(
95
+ raw: unknown,
96
+ active: string[],
97
+ ): { ok: true; value: ViewScope } | { ok: false; error: string } {
98
+ if (raw === ALL_ACTIVE) return { ok: true, value: ALL_ACTIVE };
99
+ if (typeof raw !== "string" || !NAME.test(raw))
100
+ return { ok: false, error: `scope must be "all" or a workspace name` };
101
+ if (!active.includes(raw))
102
+ return { ok: false, error: `workspace "${raw}" is not active` };
103
+ return { ok: true, value: raw };
104
+ }
105
+
106
+ /** The `view` block to store: none for the default, so user.yml stays lean. */
107
+ export function storedView(scope: ViewScope): UserConfig["view"] | undefined {
108
+ return scope === ALL_ACTIVE ? undefined : { scope };
109
+ }
110
+
111
+ /** What `/api/scope` returns: the scope, what it shows, and the focus. */
112
+ export function scopePayload(view: ScopeView): {
113
+ scope: ViewScope;
114
+ fellBack: boolean;
115
+ inView: string[];
116
+ focus: string | null;
117
+ } {
118
+ return {
119
+ scope: view.fellBack ? ALL_ACTIVE : view.scope,
120
+ fellBack: view.fellBack,
121
+ inView: view.workspaces.map((w) => w.name),
122
+ focus: view.focus,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Take `--scope <all|workspace>` (or `--scope=…`) out of argv. `--all` is
128
+ * not it: on `status` that already means verbose (one row per service).
129
+ */
130
+ export function takeScopeFlag(argv: string[]): {
131
+ scope: string | null;
132
+ rest: string[];
133
+ error?: string;
134
+ } {
135
+ const rest: string[] = [];
136
+ let scope: string | null = null;
137
+ for (let i = 0; i < argv.length; i++) {
138
+ const a = argv[i];
139
+ if (a === "--scope") {
140
+ const v = argv[i + 1];
141
+ if (v === undefined || v.startsWith("-"))
142
+ return { scope: null, rest, error: "--scope needs all or a workspace" };
143
+ scope = v;
144
+ i++;
145
+ } else if (a.startsWith("--scope=")) scope = a.slice("--scope=".length);
146
+ else rest.push(a);
147
+ }
148
+ return { scope, rest };
149
+ }
150
+
151
+ /**
152
+ * The scope line `status` prints, so a reader sees what is in view and
153
+ * what commands target: "Scope: all active (a, b, c) · focus: a".
154
+ */
155
+ export function scopeLine(view: ScopeView): string {
156
+ const names = view.workspaces.map((w) => w.name);
157
+ const what =
158
+ view.scope !== ALL_ACTIVE && !view.fellBack
159
+ ? view.scope
160
+ : `all active (${names.join(", ") || "none"})`;
161
+ const fell = view.fellBack ? ` (${view.scope} is not active)` : "";
162
+ return `Scope: ${what}${fell} · focus: ${view.focus ?? "none"}`;
163
+ }
164
+
165
+ /** " · a, b" after a shared service's name; "" when one workspace owns it. */
166
+ export function ownersSuffix(owners: string[] | undefined): string {
167
+ return owners && owners.length > 1 ? ` · ${owners.join(", ")}` : "";
168
+ }