@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.
- package/README.md +7 -6
- package/apps/studio/acp/bridge.ts +8 -1
- package/apps/studio/acp/plugin-bootstrap.ts +15 -1
- package/apps/studio/api.ts +18 -2
- package/apps/studio/assets-s3.ts +291 -0
- package/apps/studio/build.ts +1 -1
- package/apps/studio/client/export-center.jsx +7 -6
- package/apps/studio/client/github.js +11 -4
- package/apps/studio/collab/origins.ts +150 -0
- package/apps/studio/collab/protocol.ts +36 -9
- package/apps/studio/collab/room.ts +124 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +282 -282
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
- package/apps/studio/examples/perf-100-artboards.tsx +1 -1
- package/apps/studio/exporters/pdf.ts +35 -1
- package/apps/studio/git/service.ts +4 -1
- package/apps/studio/http.ts +45 -0
- package/apps/studio/paths.ts +37 -1
- package/apps/studio/server.ts +75 -2
- package/apps/studio/sync/autocommit.ts +299 -0
- package/apps/studio/sync/doc-name.ts +228 -0
- package/apps/studio/sync/index.ts +95 -1
- package/apps/studio/sync/workspace-signin.ts +301 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
- package/apps/studio/test/acp-session-plugins.test.ts +6 -0
- package/apps/studio/test/assets-s3.test.ts +249 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/collab-origin-gate.test.ts +323 -0
- package/apps/studio/test/exporters/pdf.test.ts +33 -1
- package/apps/studio/test/sync-autocommit.test.ts +334 -0
- package/apps/studio/test/sync-doc-name.test.ts +281 -0
- package/apps/studio/test/workspace-containment.test.ts +258 -0
- package/apps/studio/test/workspace-signin.test.ts +270 -0
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/workspace-mode.ts +210 -0
- package/apps/studio/ws.ts +11 -2
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/hub-workspace.mjs +341 -0
- package/cli/commands/hub.mjs +325 -3
- package/cli/commands/hub.test.mjs +14 -1
- package/cli/commands/init.mjs +80 -3
- package/cli/commands/kg.mjs +368 -0
- package/cli/commands/kg.test.mjs +118 -0
- package/cli/lib/cell-plan.mjs +302 -0
- package/cli/lib/cell-plan.test.mjs +225 -0
- package/cli/lib/ddr-to-kgai.mjs +648 -0
- package/cli/lib/ddr-to-kgai.test.mjs +99 -0
- package/cli/lib/flow-design-integration.test.mjs +2 -2
- package/cli/lib/gitignore-block.mjs +16 -1
- package/cli/lib/plugin-name-namespace.test.mjs +71 -0
- package/cli/lib/workspace-plan.mjs +422 -0
- package/cli/lib/workspace-plan.test.mjs +223 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +17 -0
- package/plugins/flow/.claude-plugin/config.schema.json +66 -0
- 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
|
|