@1agh/maude 0.48.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 (33) hide show
  1. package/apps/studio/api.ts +18 -2
  2. package/apps/studio/assets-s3.ts +291 -0
  3. package/apps/studio/collab/origins.ts +150 -0
  4. package/apps/studio/collab/protocol.ts +36 -9
  5. package/apps/studio/collab/room.ts +124 -0
  6. package/apps/studio/context.ts +9 -0
  7. package/apps/studio/dist/client.bundle.js +1 -1
  8. package/apps/studio/dist/comment-mount.js +2 -2
  9. package/apps/studio/http.ts +45 -0
  10. package/apps/studio/server.ts +75 -2
  11. package/apps/studio/sync/autocommit.ts +299 -0
  12. package/apps/studio/sync/doc-name.ts +228 -0
  13. package/apps/studio/sync/index.ts +95 -1
  14. package/apps/studio/sync/workspace-signin.ts +301 -0
  15. package/apps/studio/test/assets-s3.test.ts +249 -0
  16. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  17. package/apps/studio/test/collab-origin-gate.test.ts +323 -0
  18. package/apps/studio/test/sync-autocommit.test.ts +334 -0
  19. package/apps/studio/test/sync-doc-name.test.ts +281 -0
  20. package/apps/studio/test/workspace-containment.test.ts +258 -0
  21. package/apps/studio/test/workspace-signin.test.ts +270 -0
  22. package/apps/studio/use-collab.tsx +28 -1
  23. package/apps/studio/workspace-mode.ts +210 -0
  24. package/apps/studio/ws.ts +11 -2
  25. package/cli/commands/hub-workspace.mjs +341 -0
  26. package/cli/commands/hub.mjs +325 -3
  27. package/cli/commands/hub.test.mjs +14 -1
  28. package/cli/lib/cell-plan.mjs +302 -0
  29. package/cli/lib/cell-plan.test.mjs +225 -0
  30. package/cli/lib/gitignore-block.mjs +12 -1
  31. package/cli/lib/workspace-plan.mjs +422 -0
  32. package/cli/lib/workspace-plan.test.mjs +223 -0
  33. package/package.json +8 -8
@@ -0,0 +1,299 @@
1
+ // Autosave → append-only git commits — Cloud Phase 3 Task 1.
2
+ //
3
+ // On a laptop, autosave writing a file IS the save: the developer's own git is
4
+ // the history, and they commit when they mean to. In a workspace cell there is
5
+ // no developer at the keyboard, so an unwritten history means the only record
6
+ // of a design is its current bytes — one bad sync away from unrecoverable.
7
+ //
8
+ // So the cell commits. Three rules make that safe rather than merely noisy:
9
+ //
10
+ // 1. APPEND-ONLY. `git add` + `git commit`, ever. No amend, no rebase, no
11
+ // reset, no `checkout --` over a dirty tree, and NEVER a force-push. The
12
+ // history is allowed to be ugly; it is not allowed to lose a state that
13
+ // once existed.
14
+ // 2. AUTHORSHIP IS THE EDITING HUMAN. git separates author from committer
15
+ // precisely for this: the author is the person whose edit this was (from
16
+ // presence), the committer is the workspace bot. `git blame` then answers
17
+ // "who designed this" instead of "the server did", which is the whole
18
+ // reason to keep history at all.
19
+ // 3. QUIESCENCE, NOT KEYSTROKES. Commits fire after edits stop, so a typing
20
+ // session is one commit rather than four hundred.
21
+ //
22
+ // The disk write has ALREADY happened by the time anything here runs. A git
23
+ // failure therefore never loses work — it leaves the change uncommitted and
24
+ // retries on the next quiescence. That ordering is deliberate: making the
25
+ // commit a precondition of the save would turn a transient git error into
26
+ // data loss, which is precisely backwards.
27
+
28
+ import path from 'node:path';
29
+
30
+ /** How long the tree must be quiet before a commit fires. */
31
+ const DEFAULT_DEBOUNCE_MS = 3000;
32
+
33
+ export interface GitRunResult {
34
+ code: number;
35
+ stdout: string;
36
+ stderr: string;
37
+ }
38
+
39
+ /** Injected so tests drive a real repo and callers can swap the runner. */
40
+ export type GitRunner = (args: string[], opts: { cwd: string }) => Promise<GitRunResult>;
41
+
42
+ export interface EditAttribution {
43
+ /** Display name from presence, e.g. "Alice Novak". */
44
+ name: string;
45
+ /** Address from presence, or a synthesized stable one. */
46
+ email: string;
47
+ }
48
+
49
+ export interface AutoCommitOptions {
50
+ repoRoot: string;
51
+ run: GitRunner;
52
+ /** Quiescence window. */
53
+ debounceMs?: number;
54
+ /** Committer identity — the machine, never the human. */
55
+ bot?: EditAttribution;
56
+ log?: Pick<Console, 'warn' | 'error' | 'log'>;
57
+ }
58
+
59
+ export interface AutoCommit {
60
+ /**
61
+ * Record that `relPath` changed, attributed to `who`. Repeated calls within
62
+ * the debounce window coalesce into one commit.
63
+ */
64
+ note(relPath: string, who?: EditAttribution | null): void;
65
+ /** Force the pending commit now (branch switch, shutdown). */
66
+ flush(): Promise<CommitOutcome | null>;
67
+ /** Pending paths, for tests + status surfaces. */
68
+ pending(): string[];
69
+ stop(): void;
70
+ }
71
+
72
+ export type CommitOutcome =
73
+ | { ok: true; sha: string; files: string[]; author: EditAttribution }
74
+ | { ok: false; reason: 'nothing-to-commit' | 'git-failed'; detail?: string; files: string[] };
75
+
76
+ const DEFAULT_BOT: EditAttribution = {
77
+ name: 'Maude Workspace',
78
+ email: 'workspace@maude.local',
79
+ };
80
+
81
+ /**
82
+ * Attribution for an edit whose author we don't know.
83
+ *
84
+ * Deliberately NOT the bot: attributing an anonymous human's work to the server
85
+ * makes `git blame` lie in a way that is hard to notice later. "Unknown" is
86
+ * honest, and it is visibly wrong in a way that prompts a fix.
87
+ */
88
+ export const UNKNOWN_AUTHOR: EditAttribution = {
89
+ name: 'Unknown editor',
90
+ email: 'unknown@maude.local',
91
+ };
92
+
93
+ /**
94
+ * Sanitize a presence-supplied identity before it reaches a git argument.
95
+ *
96
+ * Presence comes from peers over the hub, which is semi-trusted (DDR-054): a
97
+ * name is attacker-influenceable text. Newlines are the specific hazard —
98
+ * `git commit --author` takes `Name <email>`, and an embedded newline could
99
+ * forge trailer lines in the commit message. Everything is passed as argv (no
100
+ * shell), so this is about the git format, not shell quoting.
101
+ */
102
+ export function sanitizeAttribution(who: EditAttribution | null | undefined): EditAttribution {
103
+ if (!who) return UNKNOWN_AUTHOR;
104
+ const clean = (s: string, fallback: string) => {
105
+ const out = String(s ?? '')
106
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping control chars is the point.
107
+ .replace(/[\s\u0000-\u001f\u007f-\u009f]+/g, ' ')
108
+ .replace(/[<>]/g, '')
109
+ .trim()
110
+ .slice(0, 96);
111
+ return out || fallback;
112
+ };
113
+ return {
114
+ name: clean(who.name, UNKNOWN_AUTHOR.name),
115
+ email: clean(who.email, UNKNOWN_AUTHOR.email),
116
+ };
117
+ }
118
+
119
+ /** `Name <email>` as git's `--author` wants it. */
120
+ export function formatAuthor(who: EditAttribution): string {
121
+ const s = sanitizeAttribution(who);
122
+ return `${s.name} <${s.email}>`;
123
+ }
124
+
125
+ /**
126
+ * Commit subject. Names WHAT changed rather than "autosave", because a history
127
+ * of four hundred identical subjects is the same as no history.
128
+ */
129
+ export function commitMessage(files: string[], who: EditAttribution): string {
130
+ // Strip from the FIRST dot, not the last: `Screen.tsx` and `Screen.meta.json`
131
+ // are one canvas, and counting them as two would make every commit subject
132
+ // overstate what changed.
133
+ const canvases = [...new Set(files.map((f) => path.basename(f).replace(/\..*$/, '')))].sort();
134
+ const subject =
135
+ canvases.length === 1
136
+ ? `design: update ${canvases[0]}`
137
+ : `design: update ${canvases.length} canvases`;
138
+ const body = [
139
+ '',
140
+ canvases.length > 1 ? canvases.map((c) => `- ${c}`).join('\n') : '',
141
+ '',
142
+ `Edited by ${who.name} <${who.email}> via the Maude workspace.`,
143
+ 'Autosaved — append-only; this history is never rewritten.',
144
+ ]
145
+ .filter((line, i, all) => !(line === '' && all[i - 1] === ''))
146
+ .join('\n');
147
+ return `${subject}\n${body}`;
148
+ }
149
+
150
+ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
151
+ const {
152
+ repoRoot,
153
+ run,
154
+ debounceMs = DEFAULT_DEBOUNCE_MS,
155
+ bot = DEFAULT_BOT,
156
+ log = console,
157
+ } = opts;
158
+
159
+ const touched = new Set<string>();
160
+ let author: EditAttribution | null = null;
161
+ let timer: ReturnType<typeof setTimeout> | null = null;
162
+ let inFlight: Promise<CommitOutcome | null> | null = null;
163
+ let stopped = false;
164
+
165
+ function note(relPath: string, who?: EditAttribution | null): void {
166
+ if (stopped) return;
167
+ touched.add(relPath);
168
+ // Last writer wins for attribution. A commit that coalesced two people's
169
+ // edits can only name one author; the message body names them, and the
170
+ // alternative (splitting per author) would fight the quiescence batching
171
+ // that keeps this history readable.
172
+ if (who) author = who;
173
+ if (timer) clearTimeout(timer);
174
+ timer = setTimeout(() => {
175
+ timer = null;
176
+ void flush();
177
+ }, debounceMs);
178
+ }
179
+
180
+ async function flush(): Promise<CommitOutcome | null> {
181
+ if (inFlight) return inFlight;
182
+ if (timer) {
183
+ clearTimeout(timer);
184
+ timer = null;
185
+ }
186
+ if (touched.size === 0) return null;
187
+
188
+ const files = [...touched].sort();
189
+ const who = sanitizeAttribution(author);
190
+ touched.clear();
191
+ author = null;
192
+
193
+ inFlight = (async (): Promise<CommitOutcome> => {
194
+ try {
195
+ // Stage ONLY what changed. `git add -A` in a workspace would sweep in
196
+ // whatever else is in the tree — including files a future feature drops
197
+ // there — and the cell must never commit something it wasn't told about.
198
+ const add = await run(['add', '--', ...files], { cwd: repoRoot });
199
+ if (add.code !== 0) {
200
+ log.warn?.(`[autocommit] git add failed: ${add.stderr.trim()}`);
201
+ return { ok: false, reason: 'git-failed', detail: add.stderr.trim(), files };
202
+ }
203
+
204
+ // Nothing staged ⇒ the write was a no-op (an echo, or identical bytes).
205
+ // Not an error, and committing an empty change would be noise.
206
+ const staged = await run(['diff', '--cached', '--name-only'], { cwd: repoRoot });
207
+ if (staged.code === 0 && staged.stdout.trim() === '') {
208
+ return { ok: false, reason: 'nothing-to-commit', files };
209
+ }
210
+
211
+ const commit = await run(
212
+ [
213
+ '-c',
214
+ `user.name=${bot.name}`,
215
+ '-c',
216
+ `user.email=${bot.email}`,
217
+ 'commit',
218
+ '--author',
219
+ formatAuthor(who),
220
+ '--only',
221
+ '--message',
222
+ commitMessage(files, who),
223
+ '--',
224
+ ...files,
225
+ ],
226
+ { cwd: repoRoot }
227
+ );
228
+ if (commit.code !== 0) {
229
+ log.warn?.(`[autocommit] git commit failed: ${commit.stderr.trim()}`);
230
+ // The bytes are on disk. Re-queue so the next quiescence retries
231
+ // rather than silently dropping the change from history.
232
+ for (const f of files) touched.add(f);
233
+ return { ok: false, reason: 'git-failed', detail: commit.stderr.trim(), files };
234
+ }
235
+
236
+ const head = await run(['rev-parse', 'HEAD'], { cwd: repoRoot });
237
+ const sha = head.stdout.trim();
238
+ log.log?.(`[autocommit] ${sha.slice(0, 8)} ${files.length} file(s) by ${who.name}`);
239
+ return { ok: true, sha, files, author: who };
240
+ } finally {
241
+ inFlight = null;
242
+ }
243
+ })();
244
+
245
+ return inFlight;
246
+ }
247
+
248
+ return {
249
+ note,
250
+ flush,
251
+ pending: () => [...touched].sort(),
252
+ stop() {
253
+ stopped = true;
254
+ if (timer) {
255
+ clearTimeout(timer);
256
+ timer = null;
257
+ }
258
+ },
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Push to a mirror remote, refusing anything that would rewrite it.
264
+ *
265
+ * A cell that force-pushes destroys work that exists only on the remote —
266
+ * exactly the hazard DDR-119 was written about, arriving from the other
267
+ * direction. On rejection the correct behaviour is to STOP and surface it, not
268
+ * to "resolve" it: a non-fast-forward means someone else's commits are there,
269
+ * and the cell has no way to know whether merging them is right.
270
+ */
271
+ export async function pushMirror({
272
+ repoRoot,
273
+ run,
274
+ remote = 'origin',
275
+ branch,
276
+ log = console,
277
+ }: {
278
+ repoRoot: string;
279
+ run: GitRunner;
280
+ remote?: string;
281
+ branch: string;
282
+ log?: Pick<Console, 'warn'>;
283
+ }): Promise<{ ok: boolean; rejected: boolean; detail?: string }> {
284
+ // No --force, no --force-with-lease, no +refspec. If this ever needs one,
285
+ // that is a design conversation, not a flag.
286
+ const res = await run(['push', remote, `refs/heads/${branch}:refs/heads/${branch}`], {
287
+ cwd: repoRoot,
288
+ });
289
+ if (res.code === 0) return { ok: true, rejected: false };
290
+ const detail = `${res.stderr}\n${res.stdout}`.trim();
291
+ const rejected = /\brejected\b|non-fast-forward|fetch first/i.test(detail);
292
+ if (rejected) {
293
+ log.warn?.(
294
+ '[autocommit] mirror push REJECTED — someone else saved first. Stopping rather than ' +
295
+ 'rewriting their work; the local history is intact and nothing was lost.'
296
+ );
297
+ }
298
+ return { ok: false, rejected, detail };
299
+ }
@@ -0,0 +1,228 @@
1
+ // Document namespace, client side — `ws/<workspace-id>/<branch>/<slug>` (DDR-192 §5).
2
+ //
3
+ // ⚠ THE GRAMMAR IS MIRRORED in `apps/hub/src/doc-namespace.mjs` and the two are
4
+ // pinned to each other by `test/sync-doc-name.test.ts`, which imports the hub's
5
+ // implementation and asserts both agree over the same corpus. They are separate
6
+ // files on purpose: the hub image installs frozen against its own bun.lock and
7
+ // must not reach into apps/studio. Change one, change the other.
8
+ //
9
+ // WHAT IS NAMESPACED AND WHAT IS NOT: only the WIRE name changes. Every local
10
+ // map — the provider registry, the sync agent, the projection, `_history/`,
11
+ // `_comments/` — stays keyed by the flat slug. The namespace exists to keep two
12
+ // projects (or two branches) from colliding INSIDE A HUB; on disk they already
13
+ // live in different directories.
14
+
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ /** The prefix that marks a namespaced documentName. */
19
+ export const DOC_NAMESPACE_PREFIX = 'ws';
20
+
21
+ /** Max length of one path component (workspace id / branch / slug). */
22
+ export const COMPONENT_MAX = 64;
23
+
24
+ export interface DocNamespace {
25
+ workspaceId: string;
26
+ branch: string;
27
+ slug: string;
28
+ }
29
+
30
+ /**
31
+ * Normalize one path component into the namespace charset. `/` is the
32
+ * separator, so a git branch like `feature/foo` becomes `feature-foo`.
33
+ */
34
+ export function sanitizeComponent(raw: string): string {
35
+ if (typeof raw !== 'string') return '';
36
+ return raw
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9._-]+/g, '-')
39
+ .replace(/-{2,}/g, '-')
40
+ .replace(/^[-.]+|[-.]+$/g, '')
41
+ .slice(0, COMPONENT_MAX)
42
+ .replace(/[-.]+$/g, '');
43
+ }
44
+
45
+ /** Build `ws/<workspace-id>/<branch>/<slug>`. Throws if a component is empty. */
46
+ export function buildDocName({ workspaceId, branch, slug }: DocNamespace): string {
47
+ const w = sanitizeComponent(workspaceId);
48
+ const b = sanitizeComponent(branch);
49
+ const s = sanitizeComponent(slug);
50
+ if (!w) throw new Error('buildDocName: workspaceId is empty after normalization');
51
+ if (!b) throw new Error('buildDocName: branch is empty after normalization');
52
+ if (!s) throw new Error('buildDocName: slug is empty after normalization');
53
+ return `${DOC_NAMESPACE_PREFIX}/${w}/${b}/${s}`;
54
+ }
55
+
56
+ /** Parse a documentName; null means a legacy flat slug (expected, not an error). */
57
+ export function parseDocName(name: string): DocNamespace | null {
58
+ if (typeof name !== 'string' || name.length === 0) return null;
59
+ const parts = name.split('/');
60
+ if (parts.length !== 4) return null;
61
+ const [prefix, workspaceId, branch, slug] = parts;
62
+ if (prefix !== DOC_NAMESPACE_PREFIX) return null;
63
+ if (!workspaceId || !branch || !slug) return null;
64
+ return { workspaceId, branch, slug };
65
+ }
66
+
67
+ /** True when `name` is a namespaced documentName. */
68
+ export function isNamespaced(name: string): boolean {
69
+ return parseDocName(name) !== null;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Resolution — where the workspace id and the branch actually come from
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Current branch, read straight from `.git/HEAD` (no subprocess — the same
78
+ * source `collab/git-lifecycle.ts` watches).
79
+ *
80
+ * A detached HEAD yields `detached-<sha7>`, which is a *stable* name for that
81
+ * commit: two peers detached at the same commit meet, and a peer on a branch
82
+ * never accidentally shares a doc with a detached checkout.
83
+ *
84
+ * Returns null when there is no git repo at all.
85
+ */
86
+ export function readBranch(repoRoot: string): string | null {
87
+ const headPath = path.join(repoRoot, '.git', 'HEAD');
88
+ if (!existsSync(headPath)) return null;
89
+ let head: string;
90
+ try {
91
+ head = readFileSync(headPath, 'utf8').trim();
92
+ } catch {
93
+ return null;
94
+ }
95
+ const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
96
+ if (ref?.[1]) return ref[1];
97
+ if (/^[0-9a-f]{7,40}$/i.test(head)) return `detached-${head.slice(0, 7)}`;
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Read the `origin` remote URL from `.git/config` without shelling out.
103
+ * Returns null when there is no origin (a purely local repo).
104
+ */
105
+ export function readOriginUrl(repoRoot: string): string | null {
106
+ const cfgPath = path.join(repoRoot, '.git', 'config');
107
+ if (!existsSync(cfgPath)) return null;
108
+ let raw: string;
109
+ try {
110
+ raw = readFileSync(cfgPath, 'utf8');
111
+ } catch {
112
+ return null;
113
+ }
114
+ const section = raw.match(/\[remote "origin"\]([\s\S]*?)(?=\n\[|$)/);
115
+ const url = section?.[1]?.match(/^\s*url\s*=\s*(.+)$/m)?.[1]?.trim();
116
+ return url && url.length > 0 ? url : null;
117
+ }
118
+
119
+ /**
120
+ * Reduce a git remote URL to a stable `<owner>-<repo>` identity, so
121
+ * `git@github.com:1aGh/maude.git` and `https://github.com/1aGh/maude` — the
122
+ * same project cloned two different ways — produce the SAME workspace id.
123
+ *
124
+ * Getting this wrong is not a cosmetic bug: two peers of one project that
125
+ * derive different ids would stop meeting, and each would see the other's docs
126
+ * as absent.
127
+ */
128
+ export function workspaceIdFromRemote(url: string): string {
129
+ let s = url.trim().replace(/\.git$/i, '');
130
+ s = s.replace(/^[a-z0-9+.-]+:\/\//i, ''); // scheme
131
+ s = s.replace(/^[^@/]+@/, ''); // user@
132
+ s = s.replace(/^([^/:]+):/, '$1/'); // scp-style host:path → host/path
133
+ const segments = s.split('/').filter(Boolean);
134
+ const tail = segments.slice(-2); // <owner>/<repo>
135
+ return sanitizeComponent(tail.join('-'));
136
+ }
137
+
138
+ export interface ResolveWorkspaceOpts {
139
+ /** Explicit id from config — authoritative when present (the cloud sets it). */
140
+ explicit?: string | undefined;
141
+ repoRoot: string;
142
+ }
143
+
144
+ /**
145
+ * Resolve the workspace id, or null when it cannot be derived in a way that is
146
+ * STABLE ACROSS MACHINES.
147
+ *
148
+ * Order: explicit config → git origin remote. There is deliberately no
149
+ * directory-name fallback: a local path is not the same on two machines, so
150
+ * deriving from it would split peers of the same project into separate
151
+ * documents — the exact failure the namespace exists to prevent, arrived at
152
+ * from the other direction. No stable id ⇒ stay flat (see `createDocNameResolver`).
153
+ */
154
+ export function resolveWorkspaceId({ explicit, repoRoot }: ResolveWorkspaceOpts): string | null {
155
+ const fromConfig = sanitizeComponent(explicit ?? '');
156
+ if (fromConfig) return fromConfig;
157
+ const origin = readOriginUrl(repoRoot);
158
+ if (!origin) return null;
159
+ const derived = workspaceIdFromRemote(origin);
160
+ return derived || null;
161
+ }
162
+
163
+ export interface DocNameResolver {
164
+ /** Map a local canvas slug to the name used on the wire. */
165
+ (slug: string): string;
166
+ }
167
+
168
+ export interface DocNameResolverOpts {
169
+ repoRoot: string;
170
+ /** `linkedHub.workspaceId` when the config carries one. */
171
+ explicitWorkspaceId?: string | undefined;
172
+ /** `MAUDE_HUB_NAMESPACED` — '1' forces on, '0' forces off, absent = auto. */
173
+ flag?: string | undefined;
174
+ /** Test seam: override branch detection. */
175
+ branch?: string | undefined;
176
+ }
177
+
178
+ /**
179
+ * Build the slug → documentName mapping for this process.
180
+ *
181
+ * Rollout rule (DDR-192 §5): namespacing CHANGES DOC IDENTITY, so it is opt-in
182
+ * for now and becomes default-on in workspace mode (Phase 3).
183
+ *
184
+ * MAUDE_HUB_NAMESPACED=0 → always flat, even with an explicit workspace id
185
+ * MAUDE_HUB_NAMESPACED=1 → namespaced, and it is an ERROR to be unable to
186
+ * (an operator who asked for isolation gets a loud
187
+ * failure, never a silent fallback into a shared doc)
188
+ * unset → namespaced only when config declares a workspace
189
+ * id explicitly; otherwise flat
190
+ *
191
+ * The auto case is deliberately NOT "namespace whenever a git origin exists":
192
+ * flipping identity under an existing linked hub would make every doc look
193
+ * freshly empty. DDR-076 keeps that from eating local files, but the hub-side
194
+ * history would be orphaned, and nobody asked for that on upgrade.
195
+ */
196
+ export function createDocNameResolver(opts: DocNameResolverOpts): DocNameResolver {
197
+ const flag = opts.flag;
198
+ if (flag === '0') return (slug) => slug;
199
+
200
+ const workspaceId = resolveWorkspaceId({
201
+ explicit: opts.explicitWorkspaceId,
202
+ repoRoot: opts.repoRoot,
203
+ });
204
+ const branch = sanitizeComponent(opts.branch ?? readBranch(opts.repoRoot) ?? '');
205
+
206
+ if (flag === '1') {
207
+ if (!workspaceId) {
208
+ throw new Error(
209
+ 'MAUDE_HUB_NAMESPACED=1 but no workspace id could be resolved. Set ' +
210
+ '`linkedHub.workspaceId` in .design/config.json, or give the repo an ' +
211
+ '`origin` remote. Refusing to fall back to flat slugs — that would put ' +
212
+ 'this project in a shared document namespace (DDR-192 §5).'
213
+ );
214
+ }
215
+ if (!branch) {
216
+ throw new Error(
217
+ 'MAUDE_HUB_NAMESPACED=1 but the current branch could not be read from ' +
218
+ '.git/HEAD. Refusing to fall back to flat slugs (DDR-192 §5).'
219
+ );
220
+ }
221
+ }
222
+
223
+ const explicitlyDeclared = sanitizeComponent(opts.explicitWorkspaceId ?? '') !== '';
224
+ const on = flag === '1' || (explicitlyDeclared && !!workspaceId && !!branch);
225
+ if (!on || !workspaceId || !branch) return (slug) => slug;
226
+
227
+ return (slug) => buildDocName({ workspaceId, branch, slug });
228
+ }
@@ -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();