@1agh/maude 0.47.0 → 0.49.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 (58) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bridge.ts +8 -1
  3. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  4. package/apps/studio/api.ts +18 -2
  5. package/apps/studio/assets-s3.ts +291 -0
  6. package/apps/studio/build.ts +1 -1
  7. package/apps/studio/client/export-center.jsx +7 -6
  8. package/apps/studio/client/github.js +11 -4
  9. package/apps/studio/collab/origins.ts +150 -0
  10. package/apps/studio/collab/protocol.ts +36 -9
  11. package/apps/studio/collab/room.ts +124 -0
  12. package/apps/studio/context.ts +9 -0
  13. package/apps/studio/dist/client.bundle.js +282 -282
  14. package/apps/studio/dist/comment-mount.js +2 -2
  15. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  16. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  17. package/apps/studio/exporters/pdf.ts +35 -1
  18. package/apps/studio/git/service.ts +4 -1
  19. package/apps/studio/http.ts +45 -0
  20. package/apps/studio/paths.ts +37 -1
  21. package/apps/studio/server.ts +75 -2
  22. package/apps/studio/sync/autocommit.ts +299 -0
  23. package/apps/studio/sync/doc-name.ts +228 -0
  24. package/apps/studio/sync/index.ts +95 -1
  25. package/apps/studio/sync/workspace-signin.ts +301 -0
  26. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  27. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  28. package/apps/studio/test/assets-s3.test.ts +249 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  30. package/apps/studio/test/collab-origin-gate.test.ts +323 -0
  31. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  32. package/apps/studio/test/sync-autocommit.test.ts +334 -0
  33. package/apps/studio/test/sync-doc-name.test.ts +281 -0
  34. package/apps/studio/test/workspace-containment.test.ts +258 -0
  35. package/apps/studio/test/workspace-signin.test.ts +270 -0
  36. package/apps/studio/use-collab.tsx +28 -1
  37. package/apps/studio/workspace-mode.ts +210 -0
  38. package/apps/studio/ws.ts +11 -2
  39. package/cli/bin/maude.mjs +1 -0
  40. package/cli/commands/hub-workspace.mjs +341 -0
  41. package/cli/commands/hub.mjs +325 -3
  42. package/cli/commands/hub.test.mjs +14 -1
  43. package/cli/commands/init.mjs +80 -3
  44. package/cli/commands/kg.mjs +368 -0
  45. package/cli/commands/kg.test.mjs +118 -0
  46. package/cli/lib/cell-plan.mjs +302 -0
  47. package/cli/lib/cell-plan.test.mjs +225 -0
  48. package/cli/lib/ddr-to-kgai.mjs +648 -0
  49. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  50. package/cli/lib/flow-design-integration.test.mjs +2 -2
  51. package/cli/lib/gitignore-block.mjs +16 -1
  52. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  53. package/cli/lib/workspace-plan.mjs +422 -0
  54. package/cli/lib/workspace-plan.test.mjs +223 -0
  55. package/package.json +8 -8
  56. package/plugins/design/dependencies.json +17 -0
  57. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  58. package/plugins/flow/dependencies.json +17 -0
@@ -28,11 +28,14 @@ import { Y_TYPES } from '../collab/persistence.ts';
28
28
  import type { Context } from '../context.ts';
29
29
  import { createHistory } from '../history.ts';
30
30
  import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
31
+ import { atomicWrite } from './atomic-write.ts';
32
+ import { createAutoCommit } from './autocommit.ts';
31
33
  import {
32
34
  type ConnectionMonitor,
33
35
  createConnectionMonitor,
34
36
  type ProviderStatus,
35
37
  } from './connection-state.ts';
38
+ import { createDocNameResolver } from './doc-name.ts';
36
39
  import { createEchoGuard } from './echo-guard.ts';
37
40
  import { createFsReader, type FsReader } from './fs-mirror.ts';
38
41
  import { getHubToken } from './hubs-config.ts';
@@ -241,6 +244,42 @@ export function createSyncRuntime(
241
244
  return null;
242
245
  }
243
246
 
247
+ // DDR-192 §5 — slug → wire documentName. Only the WIRE name is namespaced;
248
+ // every local map (providers, agents, projections, _history/) stays keyed by
249
+ // the flat slug. Opt-in for now (see createDocNameResolver's rollout rule);
250
+ // a bad MAUDE_HUB_NAMESPACED=1 with no resolvable workspace id throws here
251
+ // rather than silently falling back into a shared namespace.
252
+ let docNameFor: (slug: string) => string;
253
+ try {
254
+ docNameFor = createDocNameResolver({
255
+ repoRoot: ctx.paths.repoRoot,
256
+ explicitWorkspaceId: linkedHub.workspaceId,
257
+ flag: process.env.MAUDE_HUB_NAMESPACED,
258
+ });
259
+ } catch (err) {
260
+ console.error(`[sync] refusing to start: ${(err as Error).message}`);
261
+ return null;
262
+ }
263
+
264
+ // Cloud Phase 3 Task 1 — in a workspace cell, a disk write is only half the
265
+ // save: nobody is at a keyboard to commit, so the cell does it. Off entirely
266
+ // outside workspace mode, where the developer's own git IS the history and
267
+ // committing under them would be an intrusion (DDR-119).
268
+ const autoCommit =
269
+ process.env.MAUDE_WORKSPACE_MODE === '1'
270
+ ? createAutoCommit({
271
+ repoRoot: ctx.paths.repoRoot,
272
+ run: async (args, { cwd }) => {
273
+ const proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
274
+ const [stdout, stderr] = await Promise.all([
275
+ new Response(proc.stdout).text(),
276
+ new Response(proc.stderr).text(),
277
+ ]);
278
+ return { code: await proc.exited, stdout, stderr };
279
+ },
280
+ })
281
+ : null;
282
+
244
283
  const resolvedToken = getHubToken(linkedHub.url);
245
284
  if (!resolvedToken) {
246
285
  console.warn(
@@ -556,6 +595,35 @@ export function createSyncRuntime(
556
595
  * permanent-rejection re-probe (which passes the EXISTING doc so the
557
596
  * agent/projection wiring — doc-scoped — survives the provider swap).
558
597
  */
598
+ /**
599
+ * Who is editing this canvas right now, from hub awareness.
600
+ *
601
+ * Presence carries a display name and no address, so the address is
602
+ * synthesized and clearly marked as derived — inventing a plausible-looking
603
+ * real address would put an unverified identity into permanent git history.
604
+ * Absent a remote peer the answer is null, which `autocommit` turns into
605
+ * "Unknown editor" rather than attributing the work to the server.
606
+ */
607
+ const editorOf = (slug: string): { name: string; email: string } | null => {
608
+ const awareness = providers.get(slug)?.awareness;
609
+ if (!awareness) return null;
610
+ for (const [clientId, state] of awareness.getStates() as Map<
611
+ number,
612
+ { name?: string } | undefined
613
+ >) {
614
+ if (clientId === awareness.clientID) continue; // that's us, the cell
615
+ const name = state?.name?.trim();
616
+ if (name) {
617
+ const slugified = name
618
+ .toLowerCase()
619
+ .replace(/[^a-z0-9]+/g, '-')
620
+ .replace(/^-|-$/g, '');
621
+ return { name, email: `${slugified || 'peer'}@peers.maude.local` };
622
+ }
623
+ }
624
+ return null;
625
+ };
626
+
559
627
  const connectCanvas = async (
560
628
  canvas: CanvasDescriptor,
561
629
  canvasPaths: import('./agent.ts').CanvasSyncPaths,
@@ -565,7 +633,7 @@ export function createSyncRuntime(
565
633
  const provider = await providerFactory({
566
634
  url: linkedHub.url,
567
635
  token,
568
- documentName: canvas.slug,
636
+ documentName: docNameFor(canvas.slug),
569
637
  document,
570
638
  });
571
639
  providers.set(canvas.slug, provider);
@@ -653,6 +721,20 @@ export function createSyncRuntime(
653
721
  echoGuard,
654
722
  adopt: adoptOnce,
655
723
  journal: journal ?? undefined,
724
+ // Wrap the writer rather than adding a new hook: every path the
725
+ // agent materializes to disk goes through it, so a future write
726
+ // surface is committed automatically instead of being forgotten.
727
+ ...(autoCommit
728
+ ? {
729
+ writer: (file: string, bytes: string | Uint8Array) => {
730
+ atomicWrite(file, bytes);
731
+ autoCommit.note(
732
+ path.relative(ctx.paths.repoRoot, file),
733
+ editorOf(canvas.slug)
734
+ );
735
+ },
736
+ }
737
+ : {}),
656
738
  snapshot: async (content, reason) => {
657
739
  try {
658
740
  const snap = await history.writeSnapshot(relBody, content, reason);
@@ -770,6 +852,18 @@ export function createSyncRuntime(
770
852
  async function stop(): Promise<void> {
771
853
  if (stopped) return;
772
854
  stopped = true;
855
+ // Commit whatever is still inside the quiescence window BEFORE tearing
856
+ // anything down. Shutting down mid-window would leave the last edits on
857
+ // disk but out of history — the one state this whole mechanism exists to
858
+ // make impossible.
859
+ if (autoCommit) {
860
+ try {
861
+ await autoCommit.flush();
862
+ } catch (err) {
863
+ console.error('[sync] final autocommit failed:', err);
864
+ }
865
+ autoCommit.stop();
866
+ }
773
867
  for (const detach of awarenessDetaches) {
774
868
  try {
775
869
  detach();
@@ -0,0 +1,301 @@
1
+ // "Sign in to workspace" — Cloud Phase 3 Task 3.
2
+ //
3
+ // The Phase-2 hub can authenticate a PERSON (`POST /auth/login` → a scoped,
4
+ // expiring peer token). This is the client half: the flow that turns "my
5
+ // teammate sent me an address" into a working, synced project without the
6
+ // person ever seeing the word "token".
7
+ //
8
+ // The vocabulary rule is not cosmetic (DDR-193 §5). The persona this product
9
+ // lives or dies by is the invited teammate who has never used git. Every string
10
+ // on this path says "sign in", "workspace", "project" — never token, never
11
+ // repository, never OAuth. A flow that says "paste your bearer token" has
12
+ // already told that person the product is not for them.
13
+ //
14
+ // TOKEN CUSTODY (unchanged from DDR-054 / the phase-29 A2 finding, and the
15
+ // reason this file reuses hub-link.ts rather than writing its own storage):
16
+ //
17
+ // • the health probe is TOKENLESS — a lookalike address must never receive a
18
+ // credential;
19
+ // • the password goes ONLY to the address the user typed, once, and is never
20
+ // stored anywhere, in memory or on disk, after the request;
21
+ // • what IS stored is the minted peer token, in `~/.config/maude/hubs.json`
22
+ // at mode 0600, keyed by normalized URL — the same store `maude design
23
+ // link` writes, so a project opened later just works.
24
+ //
25
+ // The token now EXPIRES (Phase 2), which changes one thing for the client: a
26
+ // sync failure can mean "your session ran out" rather than "something broke",
27
+ // and the honest response is to ask the person to sign in again rather than to
28
+ // silently retry forever.
29
+
30
+ import { saveHubCredential } from './hub-link.ts';
31
+ import { normalizeUrl } from './hubs-config.ts';
32
+
33
+ const PROBE_TIMEOUT_MS = 4000;
34
+ const LOGIN_TIMEOUT_MS = 15_000;
35
+
36
+ export interface SignInResult {
37
+ status: number;
38
+ json:
39
+ | {
40
+ ok: true;
41
+ url: string;
42
+ /** Plain-words identity for the UI header — never a token. */
43
+ user: { email: string; role: string };
44
+ /** ms-epoch. The UI surfaces "signed in until <date>", not a raw number. */
45
+ expiresAt: number | null;
46
+ version: string | null;
47
+ }
48
+ | { ok: false; error: string; reason?: SignInFailure };
49
+ }
50
+
51
+ export type SignInFailure =
52
+ | 'bad-address'
53
+ | 'unreachable'
54
+ | 'not-a-maude-workspace'
55
+ | 'bad-credentials'
56
+ | 'rate-limited'
57
+ | 'save-failed'
58
+ | 'server-error';
59
+
60
+ export interface SignInInput {
61
+ url?: unknown;
62
+ email?: unknown;
63
+ password?: unknown;
64
+ }
65
+
66
+ /** Injected in tests. */
67
+ export interface SignInDeps {
68
+ fetchImpl?: typeof fetch;
69
+ save?: (normUrl: string, token: string) => void;
70
+ }
71
+
72
+ /**
73
+ * Address → reachability → sign in → credential stored. One call, because the
74
+ * person doing this is not debugging a network stack; they were sent a link.
75
+ */
76
+ export async function signInToWorkspace(
77
+ input: SignInInput,
78
+ deps: SignInDeps = {}
79
+ ): Promise<SignInResult> {
80
+ const doFetch = deps.fetchImpl ?? fetch;
81
+ const save = deps.save ?? saveHubCredential;
82
+
83
+ if (typeof input.url !== 'string' || !input.url.trim()) {
84
+ return fail(400, 'Enter the workspace address your team sent you.', 'bad-address');
85
+ }
86
+ if (typeof input.email !== 'string' || !input.email.trim()) {
87
+ return fail(400, 'Enter your email address.', 'bad-credentials');
88
+ }
89
+ if (typeof input.password !== 'string' || input.password === '') {
90
+ return fail(400, 'Enter your password.', 'bad-credentials');
91
+ }
92
+
93
+ let norm: string;
94
+ try {
95
+ norm = normalizeUrl(input.url.trim());
96
+ const parsed = new URL(norm);
97
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
98
+ return fail(400, 'The workspace address must start with http:// or https://.', 'bad-address');
99
+ }
100
+ } catch {
101
+ return fail(400, "That doesn't look like a workspace address.", 'bad-address');
102
+ }
103
+
104
+ // 1. TOKENLESS reachability probe. Also tells us it is a Maude workspace at
105
+ // all, so a typo'd address fails here with a sentence a person can act on
106
+ // instead of failing later as a mysterious auth error.
107
+ const probe = await probe200(doFetch, `${norm}/health`, PROBE_TIMEOUT_MS);
108
+ if (!probe.reached) {
109
+ return fail(
110
+ 502,
111
+ "Couldn't reach that workspace. Check the address, or ask whoever invited you whether it's online.",
112
+ 'unreachable'
113
+ );
114
+ }
115
+ if (!probe.isMaude) {
116
+ return fail(
117
+ 502,
118
+ "That address answered, but it isn't a Maude workspace. Double-check the link you were sent.",
119
+ 'not-a-maude-workspace'
120
+ );
121
+ }
122
+
123
+ // 2. Sign in. The password goes to this address once and is never retained.
124
+ let res: Response;
125
+ try {
126
+ const ctrl = new AbortController();
127
+ const timer = setTimeout(() => ctrl.abort(), LOGIN_TIMEOUT_MS);
128
+ try {
129
+ res = await doFetch(`${norm}/auth/login`, {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json' },
132
+ body: JSON.stringify({ email: input.email.trim(), password: input.password }),
133
+ signal: ctrl.signal,
134
+ });
135
+ } finally {
136
+ clearTimeout(timer);
137
+ }
138
+ } catch {
139
+ return fail(502, "Couldn't reach that workspace to sign in.", 'unreachable');
140
+ }
141
+
142
+ if (res.status === 401) {
143
+ // The hub deliberately cannot tell us WHICH part was wrong (it refuses to be
144
+ // a user-existence oracle), so neither can we. Saying "wrong password" when
145
+ // the account may not exist would be a guess presented as fact.
146
+ return fail(401, 'That email and password combination was not accepted.', 'bad-credentials');
147
+ }
148
+ if (res.status === 429) {
149
+ return fail(429, 'Too many attempts. Wait a minute and try again.', 'rate-limited');
150
+ }
151
+ if (!res.ok) {
152
+ return fail(502, 'The workspace refused the sign-in. Try again shortly.', 'server-error');
153
+ }
154
+
155
+ let body: {
156
+ token?: unknown;
157
+ expiresAt?: unknown;
158
+ user?: { email?: unknown; role?: unknown };
159
+ };
160
+ try {
161
+ body = (await res.json()) as typeof body;
162
+ } catch {
163
+ return fail(502, 'The workspace sent an unexpected reply.', 'server-error');
164
+ }
165
+ if (typeof body.token !== 'string' || !body.token) {
166
+ return fail(502, 'The workspace sent an unexpected reply.', 'server-error');
167
+ }
168
+
169
+ // 3. Persist ONLY the minted token, 0600, in the shared credential store.
170
+ try {
171
+ save(norm, body.token);
172
+ } catch {
173
+ return fail(500, "Couldn't save the workspace connection on this computer.", 'save-failed');
174
+ }
175
+
176
+ return {
177
+ status: 200,
178
+ json: {
179
+ ok: true,
180
+ url: norm,
181
+ user: {
182
+ email: typeof body.user?.email === 'string' ? body.user.email : String(input.email).trim(),
183
+ role: typeof body.user?.role === 'string' ? body.user.role : 'member',
184
+ },
185
+ expiresAt: typeof body.expiresAt === 'number' ? body.expiresAt : null,
186
+ version: probe.version,
187
+ },
188
+ };
189
+ }
190
+
191
+ function fail(status: number, error: string, reason: SignInFailure): SignInResult {
192
+ return { status, json: { ok: false, error, reason } };
193
+ }
194
+
195
+ async function probe200(
196
+ doFetch: typeof fetch,
197
+ url: string,
198
+ timeoutMs: number
199
+ ): Promise<{ reached: boolean; isMaude: boolean; version: string | null }> {
200
+ const ctrl = new AbortController();
201
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
202
+ try {
203
+ const res = await doFetch(url, { signal: ctrl.signal });
204
+ if (!res.ok) return { reached: true, isMaude: false, version: null };
205
+ try {
206
+ const j = (await res.json()) as { ok?: unknown; version?: unknown };
207
+ // A Maude hub's /health always carries `ok`. Anything else answered on
208
+ // that path is some other service.
209
+ const isMaude = j?.ok === true;
210
+ return {
211
+ reached: true,
212
+ isMaude,
213
+ version: typeof j?.version === 'string' ? j.version : null,
214
+ };
215
+ } catch {
216
+ return { reached: true, isMaude: false, version: null };
217
+ }
218
+ } catch {
219
+ return { reached: false, isMaude: false, version: null };
220
+ } finally {
221
+ clearTimeout(timer);
222
+ }
223
+ }
224
+
225
+ // ---------------------------------------------------------------- disclosure
226
+
227
+ /**
228
+ * What the operator of a workspace can see and do — DDR-054's trust model, made
229
+ * visible to the person it concerns.
230
+ *
231
+ * DDR-079 disclosed TSX-sync via a terminal banner. In a workspace the affected
232
+ * human may never open a terminal: invitees run Maude Desktop and nothing else.
233
+ * So the disclosure moves to where they are. The banner stays for terminal
234
+ * users; it is no longer the only disclosure (DDR-192 §6).
235
+ *
236
+ * Written as data rather than JSX so the same sentences appear in the desktop
237
+ * panel, the web onboarding, and the docs without three drifting copies — and
238
+ * so a test can assert the honest ones are still present.
239
+ */
240
+ export interface DisclosureItem {
241
+ /** Short label for the row. */
242
+ title: string;
243
+ /** One plain sentence. No jargon, no hedging. */
244
+ detail: string;
245
+ /** `sees` = the operator can read it · `cannot` = they can't · `you` = you control it. */
246
+ kind: 'sees' | 'cannot' | 'you';
247
+ }
248
+
249
+ export function workspaceDisclosure({
250
+ operator,
251
+ aiAvailable,
252
+ }: {
253
+ /** Who runs this workspace, in words: "your team", "Maude Cloud", a hostname. */
254
+ operator: string;
255
+ /** Whether this machine has a Claude subscription wired up (DDR-123). */
256
+ aiAvailable: boolean;
257
+ }): DisclosureItem[] {
258
+ return [
259
+ {
260
+ kind: 'sees',
261
+ title: 'Your designs',
262
+ detail: `${operator} stores every canvas in this project, including its full edit history.`,
263
+ },
264
+ {
265
+ kind: 'sees',
266
+ title: 'Who is working, and when',
267
+ detail: `${operator} can see which files you open and when you are editing.`,
268
+ },
269
+ {
270
+ kind: 'sees',
271
+ title: 'Comments and annotations',
272
+ detail:
273
+ 'Anything you write on a canvas is stored in the workspace, not just on your machine.',
274
+ },
275
+ {
276
+ kind: 'cannot',
277
+ title: 'Nothing else on this computer',
278
+ detail:
279
+ 'Only this project folder syncs. Other files, other projects, and the rest of your disk are never read.',
280
+ },
281
+ {
282
+ kind: 'cannot',
283
+ title: 'Your designs are never run on their servers',
284
+ detail:
285
+ 'Canvases render here, on your machine. The workspace stores and syncs them — it never executes them.',
286
+ },
287
+ {
288
+ kind: aiAvailable ? 'you' : 'cannot',
289
+ title: 'AI runs on your own subscription',
290
+ detail: aiAvailable
291
+ ? 'Claude runs locally under your own subscription. Your prompts do not pass through the workspace.'
292
+ : 'AI is off because this computer has no Claude subscription connected. Everything else works normally.',
293
+ },
294
+ {
295
+ kind: 'you',
296
+ title: 'You can leave with everything',
297
+ detail:
298
+ 'One click exports the whole project — designs and media — in a form that opens without Maude.',
299
+ },
300
+ ];
301
+ }
@@ -16,6 +16,7 @@ import { computeSessionPlugins } from '../acp/plugin-bootstrap.ts';
16
16
 
17
17
  const DESIGN = '/bundle/plugins/design';
18
18
  const FLOW = '/bundle/plugins/flow';
19
+ const KGAI = '/bundle/plugins/kgai';
19
20
 
20
21
  describe('computeSessionPlugins — gates', () => {
21
22
  // `/flow` auto-load is intentionally OFF for now (2026-07-03) — only `design`
@@ -52,4 +53,37 @@ describe('computeSessionPlugins — gates', () => {
52
53
  const out = computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW });
53
54
  expect(out.every((c) => c.skipMcpDiscovery === true && c.type === 'local')).toBe(true);
54
55
  });
56
+
57
+ // feature-kgai-ecosystem-integration Phase 8 — the third-party kgai plugin
58
+ // carries the `Stop` hook that IS autonomous decision capture. Without
59
+ // injection the packaged app records nothing (settingSources:['user'], and a
60
+ // terminal-less DDR-177 user never marketplace-installs it).
61
+ test('kgai bundled ⇒ injected alongside design (autonomous capture in the .app)', () => {
62
+ const out = computeSessionPlugins({
63
+ native: true,
64
+ designDir: DESIGN,
65
+ flowDir: FLOW,
66
+ kgaiDir: KGAI,
67
+ });
68
+ expect(out).toEqual([
69
+ { type: 'local', path: DESIGN, skipMcpDiscovery: true },
70
+ { type: 'local', path: KGAI, skipMcpDiscovery: true },
71
+ ]);
72
+ });
73
+
74
+ test('kgai absent (dev tree / npm layout) ⇒ design only, no crash', () => {
75
+ expect(
76
+ computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW, kgaiDir: null })
77
+ ).toEqual([{ type: 'local', path: DESIGN, skipMcpDiscovery: true }]);
78
+ // omitted entirely (optional field) behaves the same
79
+ expect(computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW })).toEqual([
80
+ { type: 'local', path: DESIGN, skipMcpDiscovery: true },
81
+ ]);
82
+ });
83
+
84
+ test('not native ⇒ kgai is NOT injected either (web-serve gate holds)', () => {
85
+ expect(
86
+ computeSessionPlugins({ native: false, designDir: DESIGN, flowDir: FLOW, kgaiDir: KGAI })
87
+ ).toEqual([]);
88
+ });
55
89
  });
@@ -94,6 +94,12 @@ describe('newSessionParams — plugin carrier shape', () => {
94
94
  const p = newSessionParams('/repo', undefined, PLUGINS);
95
95
  expect((p._meta as Meta).claudeCode?.options?.settings?.enabledPlugins).toEqual({
96
96
  'design@maude': false,
97
+ // kgai (third-party, injected by the desktop bundle for autonomous capture —
98
+ // feature-kgai-ecosystem-integration Phase 8). Suppressing a user-installed
99
+ // copy matters MORE here than for our own plugins: it would run its own
100
+ // SessionStart install.sh and point at a different engine version than the
101
+ // pinned, signed sidecar we ship.
102
+ kgai: false,
97
103
  });
98
104
  });
99
105