@1agh/maude 0.22.2 → 0.24.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 (125) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +84 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +135 -10
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +222 -11
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -0,0 +1,303 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { test } from 'node:test';
6
+ import {
7
+ check,
8
+ clear,
9
+ getOrCompute,
10
+ list,
11
+ resolveCacheRoot,
12
+ sha8,
13
+ stats,
14
+ write,
15
+ } from './cache.mjs';
16
+
17
+ function tmpCache() {
18
+ const dir = mkdtempSync(join(tmpdir(), 'maude-cache-'));
19
+ return { cacheDir: dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
20
+ }
21
+
22
+ test('write then check returns the same payload', () => {
23
+ const { cacheDir, cleanup } = tmpCache();
24
+ try {
25
+ write('research/domain', 'finance.abc12345', { mood: ['calm'], n: 3 }, {}, { cacheDir });
26
+ const hit = check('research/domain', 'finance.abc12345', { cacheDir });
27
+ assert.ok(hit);
28
+ assert.deepEqual(hit.value, { mood: ['calm'], n: 3 });
29
+ assert.equal(typeof hit.ageMs, 'number');
30
+ assert.ok(hit.ageMs >= 0);
31
+ } finally {
32
+ cleanup();
33
+ }
34
+ });
35
+
36
+ test('first write drops a README documenting the invalidation policy', () => {
37
+ const { cacheDir, cleanup } = tmpCache();
38
+ try {
39
+ write('research/domain', 'k', { x: 1 }, {}, { cacheDir });
40
+ const readme = readFileSync(join(cacheDir, 'README.md'), 'utf8');
41
+ assert.match(readme, /Invalidation policy/);
42
+ assert.match(readme, /research\/domain/);
43
+ } finally {
44
+ cleanup();
45
+ }
46
+ });
47
+
48
+ test('check returns null on miss', () => {
49
+ const { cacheDir, cleanup } = tmpCache();
50
+ try {
51
+ assert.equal(check('research/domain', 'nope', { cacheDir }), null);
52
+ } finally {
53
+ cleanup();
54
+ }
55
+ });
56
+
57
+ test('check returns null on corrupt entry', () => {
58
+ const { cacheDir, cleanup } = tmpCache();
59
+ try {
60
+ const { path } = write('codebase-intelligence', 'deadbeef', { x: 1 }, {}, { cacheDir });
61
+ writeFileSync(path, '{ this is not json', 'utf8');
62
+ assert.equal(check('codebase-intelligence', 'deadbeef', { cacheDir }), null);
63
+ } finally {
64
+ cleanup();
65
+ }
66
+ });
67
+
68
+ test('getOrCompute serves fresh cache without calling compute', async () => {
69
+ const { cacheDir, cleanup } = tmpCache();
70
+ try {
71
+ write('design-context', 'ds/tok1', { tokens: 12 }, {}, { cacheDir });
72
+ let called = false;
73
+ const v = await getOrCompute({
74
+ cacheDir,
75
+ layer: 'design-context',
76
+ key: 'ds/tok1',
77
+ ttlMs: 60_000,
78
+ compute: () => {
79
+ called = true;
80
+ return { tokens: 999 };
81
+ },
82
+ });
83
+ assert.deepEqual(v, { tokens: 12 });
84
+ assert.equal(called, false, 'compute must not run on a fresh hit');
85
+ } finally {
86
+ cleanup();
87
+ }
88
+ });
89
+
90
+ test('getOrCompute runs compute on miss and caches the result', async () => {
91
+ const { cacheDir, cleanup } = tmpCache();
92
+ try {
93
+ let calls = 0;
94
+ const opts = {
95
+ cacheDir,
96
+ layer: 'codebase-intelligence',
97
+ key: 'sha-aaa',
98
+ ttlMs: 60_000,
99
+ compute: () => {
100
+ calls += 1;
101
+ return { files: calls };
102
+ },
103
+ };
104
+ const first = await getOrCompute(opts);
105
+ const second = await getOrCompute(opts);
106
+ assert.deepEqual(first, { files: 1 });
107
+ assert.deepEqual(second, { files: 1 }, 'second call must read cache, not recompute');
108
+ assert.equal(calls, 1);
109
+ } finally {
110
+ cleanup();
111
+ }
112
+ });
113
+
114
+ test('getOrCompute recomputes past the ttl window', async () => {
115
+ const { cacheDir, cleanup } = tmpCache();
116
+ try {
117
+ const { path } = write('security', 'head1', { audit: 'old' }, {}, { cacheDir });
118
+ // Backdate the stored writtenAt to 2 h ago (past a 1 h ttl).
119
+ const stored = JSON.parse(readFileSync(path, 'utf8'));
120
+ stored.writtenAt = Date.now() - 2 * 60 * 60 * 1000;
121
+ writeFileSync(path, JSON.stringify(stored), 'utf8');
122
+ const v = await getOrCompute({
123
+ cacheDir,
124
+ layer: 'security',
125
+ key: 'head1',
126
+ ttlMs: 60 * 60 * 1000,
127
+ compute: () => ({ audit: 'fresh' }),
128
+ });
129
+ assert.deepEqual(v, { audit: 'fresh' });
130
+ } finally {
131
+ cleanup();
132
+ }
133
+ });
134
+
135
+ test('getOrCompute force bypasses a fresh hit', async () => {
136
+ const { cacheDir, cleanup } = tmpCache();
137
+ try {
138
+ write('research/domain', 'x', { v: 'cached' }, {}, { cacheDir });
139
+ const v = await getOrCompute({
140
+ cacheDir,
141
+ layer: 'research/domain',
142
+ key: 'x',
143
+ ttlMs: 60_000,
144
+ force: true,
145
+ compute: () => ({ v: 'recomputed' }),
146
+ });
147
+ assert.deepEqual(v, { v: 'recomputed' });
148
+ } finally {
149
+ cleanup();
150
+ }
151
+ });
152
+
153
+ test('getOrCompute serves a stale entry when compute throws (within maxAge)', async () => {
154
+ const { cacheDir, cleanup } = tmpCache();
155
+ try {
156
+ const { path } = write('research/domain', 'stale', { v: 'old-but-usable' }, {}, { cacheDir });
157
+ const stored = JSON.parse(readFileSync(path, 'utf8'));
158
+ stored.writtenAt = Date.now() - 10 * 24 * 60 * 60 * 1000; // 10 days old
159
+ writeFileSync(path, JSON.stringify(stored), 'utf8');
160
+ const v = await getOrCompute({
161
+ cacheDir,
162
+ layer: 'research/domain',
163
+ key: 'stale',
164
+ ttlMs: 7 * 24 * 60 * 60 * 1000, // 7 day fresh window → stale
165
+ maxAgeMs: 30 * 24 * 60 * 60 * 1000, // 30 day ceiling → still usable
166
+ compute: () => {
167
+ throw new Error('websearch down');
168
+ },
169
+ });
170
+ assert.deepEqual(v, { v: 'old-but-usable' });
171
+ } finally {
172
+ cleanup();
173
+ }
174
+ });
175
+
176
+ test('getOrCompute propagates the error when no usable stale entry exists', async () => {
177
+ const { cacheDir, cleanup } = tmpCache();
178
+ try {
179
+ await assert.rejects(
180
+ getOrCompute({
181
+ cacheDir,
182
+ layer: 'research/domain',
183
+ key: 'missing',
184
+ compute: () => {
185
+ throw new Error('boom');
186
+ },
187
+ }),
188
+ /boom/
189
+ );
190
+ } finally {
191
+ cleanup();
192
+ }
193
+ });
194
+
195
+ test('concurrent writes to the same key never corrupt the file', async () => {
196
+ const { cacheDir, cleanup } = tmpCache();
197
+ try {
198
+ await Promise.all(
199
+ Array.from({ length: 25 }, (_, i) =>
200
+ Promise.resolve().then(() =>
201
+ write('codebase-intelligence', 'race', { i }, {}, { cacheDir })
202
+ )
203
+ )
204
+ );
205
+ const hit = check('codebase-intelligence', 'race', { cacheDir });
206
+ assert.ok(hit, 'entry must be readable after concurrent writes');
207
+ assert.equal(typeof hit.value.i, 'number');
208
+ assert.ok(hit.value.i >= 0 && hit.value.i < 25);
209
+ } finally {
210
+ cleanup();
211
+ }
212
+ });
213
+
214
+ test('path traversal in layer or key is rejected', () => {
215
+ const { cacheDir, cleanup } = tmpCache();
216
+ try {
217
+ assert.throws(() => write('../escape', 'k', {}, {}, { cacheDir }), /escapes cache root/);
218
+ assert.throws(() => check('research', '../../etc/passwd', { cacheDir }), /escapes cache root/);
219
+ } finally {
220
+ cleanup();
221
+ }
222
+ });
223
+
224
+ test('clear(layer) removes only that layer; clear() wipes all but README', () => {
225
+ const { cacheDir, cleanup } = tmpCache();
226
+ try {
227
+ write('research/domain', 'a', { x: 1 }, {}, { cacheDir });
228
+ write('research/domain', 'b', { x: 2 }, {}, { cacheDir });
229
+ write('codebase-intelligence', 'c', { x: 3 }, {}, { cacheDir });
230
+ writeFileSync(join(cacheDir, 'README.md'), '# cache', 'utf8');
231
+
232
+ clear('research/domain', undefined, { cacheDir });
233
+ assert.equal(check('research/domain', 'a', { cacheDir }), null);
234
+ assert.ok(check('codebase-intelligence', 'c', { cacheDir }), 'other layer survives');
235
+
236
+ clear(undefined, undefined, { cacheDir });
237
+ assert.equal(check('codebase-intelligence', 'c', { cacheDir }), null);
238
+ assert.equal(readFileSync(join(cacheDir, 'README.md'), 'utf8'), '# cache', 'README preserved');
239
+ } finally {
240
+ cleanup();
241
+ }
242
+ });
243
+
244
+ test('list reports layers with entry counts and bytes', () => {
245
+ const { cacheDir, cleanup } = tmpCache();
246
+ try {
247
+ write('research/domain', 'a', { x: 1 }, {}, { cacheDir });
248
+ write('research/domain', 'b', { x: 2 }, {}, { cacheDir });
249
+ write('design-context', 'ds/t', { x: 3 }, {}, { cacheDir });
250
+ const layers = list({ cacheDir });
251
+ const byName = Object.fromEntries(layers.map((l) => [l.layer, l]));
252
+ assert.equal(byName.research.entries, 2);
253
+ assert.equal(byName['design-context'].entries, 1);
254
+ assert.ok(byName.research.bytes > 0);
255
+ } finally {
256
+ cleanup();
257
+ }
258
+ });
259
+
260
+ test('stats accumulate hits and misses per layer', async () => {
261
+ const { cacheDir, cleanup } = tmpCache();
262
+ try {
263
+ const base = {
264
+ cacheDir,
265
+ layer: 'research/domain',
266
+ key: 'k',
267
+ ttlMs: 60_000,
268
+ compute: () => ({ v: 1 }),
269
+ };
270
+ await getOrCompute(base); // miss
271
+ await getOrCompute(base); // hit
272
+ await getOrCompute(base); // hit
273
+ const s = stats({ cacheDir });
274
+ assert.equal(s.misses['research/domain'], 1);
275
+ assert.equal(s.hits['research/domain'], 2);
276
+ } finally {
277
+ cleanup();
278
+ }
279
+ });
280
+
281
+ test('resolveCacheRoot honors MAUDE_CACHE_DIR then CLAUDE_PROJECT_DIR', () => {
282
+ const prev = { m: process.env.MAUDE_CACHE_DIR, c: process.env.CLAUDE_PROJECT_DIR };
283
+ try {
284
+ process.env.MAUDE_CACHE_DIR = '/tmp/explicit-cache';
285
+ assert.equal(resolveCacheRoot(), '/tmp/explicit-cache');
286
+ Reflect.deleteProperty(process.env, 'MAUDE_CACHE_DIR');
287
+ process.env.CLAUDE_PROJECT_DIR = '/tmp/proj';
288
+ assert.equal(resolveCacheRoot(), join('/tmp/proj', '.ai/cache'));
289
+ } finally {
290
+ if (prev.m === undefined) Reflect.deleteProperty(process.env, 'MAUDE_CACHE_DIR');
291
+ else process.env.MAUDE_CACHE_DIR = prev.m;
292
+ if (prev.c === undefined) Reflect.deleteProperty(process.env, 'CLAUDE_PROJECT_DIR');
293
+ else process.env.CLAUDE_PROJECT_DIR = prev.c;
294
+ }
295
+ });
296
+
297
+ test('sha8 is stable and 8 hex chars', () => {
298
+ const a = sha8('finance dashboard');
299
+ const b = sha8('finance dashboard');
300
+ assert.equal(a, b);
301
+ assert.match(a, /^[0-9a-f]{8}$/);
302
+ assert.notEqual(sha8('finance dashboard'), sha8('ecommerce checkout'));
303
+ });
@@ -10,19 +10,22 @@
10
10
  //
11
11
  // Token NEVER lands in .design/config.json — that's git-committed.
12
12
 
13
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
14
14
  import { dirname, resolve } from 'node:path';
15
+ import { createInterface } from 'node:readline';
15
16
 
16
17
  import { parseArgs } from './argv.mjs';
17
- import { addHub, getHub, normalizeUrl, removeHub } from './hubs-config.mjs';
18
+ import { BEGIN_MARKER, writeGitignoreBlock } from './gitignore-block.mjs';
19
+ import { addHub, getHub, isHubTrusted, normalizeUrl, removeHub, trustHub } from './hubs-config.mjs';
18
20
 
19
21
  const DESIGN_CONFIG_PATH = '.design/config.json';
22
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
20
23
 
21
24
  // ---------------------------------------------------------------- link
22
25
 
23
26
  export async function runLink({ args, cwd = process.cwd(), forceAdopt = false }) {
24
27
  const tail = args.slice(args.indexOf(forceAdopt ? 'adopt' : 'link') + 1);
25
- const { flags, positional } = parseArgs(tail, { booleans: ['adopt', 'force'] });
28
+ const { flags, positional } = parseArgs(tail, { booleans: ['adopt', 'force', 'yes'] });
26
29
  const url = positional[0];
27
30
  const token = flags.token;
28
31
 
@@ -52,6 +55,46 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
52
55
  process.exit(2);
53
56
  }
54
57
 
58
+ const cfg = readDesignConfig(designConfigPath);
59
+
60
+ // DDR-054 F2/F4: linking to a non-loopback hub grants a remote actor the same
61
+ // write access to .design/ as the local user (hub-pushed content lands on
62
+ // disk verbatim, like `git pull` from a stranger). Require explicit trust for
63
+ // a new remote hub. Trust is checked PER-MACHINE (`isHubTrusted`), never from
64
+ // a committable repo file — a committed allowlist (or the committed
65
+ // `linkedHub.url` itself) would let a malicious PR pre-seed trust and bypass
66
+ // this gate (trust laundering). Loopback dev hubs are exempt.
67
+ const loopback = isLoopbackUrl(normUrl);
68
+ const alreadyTrusted = loopback || isHubTrusted(normUrl);
69
+ const manifest = adopt ? collectAdoptManifest(cwd) : [];
70
+
71
+ if (!alreadyTrusted) {
72
+ process.stderr.write(trustGateText(normUrl, adopt, manifest));
73
+ if (flags.yes) {
74
+ process.stderr.write(' → confirmed via --yes\n');
75
+ } else if (process.stdin.isTTY) {
76
+ const question = adopt
77
+ ? `Link this repo to ${normUrl} AND push local design state up to it? [y/N] `
78
+ : `Link this repo to ${normUrl}? [y/N] `;
79
+ const ok = await promptYesNo(question);
80
+ if (!ok) {
81
+ process.stderr.write('maude design link: aborted — hub not trusted.\n');
82
+ process.exit(1);
83
+ }
84
+ } else {
85
+ process.stderr.write(
86
+ `maude design link: linking to a non-local hub (${normUrl}) requires confirmation.\n Re-run in an interactive terminal, or pass --yes to confirm non-interactively.\n`
87
+ );
88
+ process.exit(1);
89
+ }
90
+ // NOTE: trust is recorded AFTER the link succeeds (below), not here — an
91
+ // aborted link (probe fail without --force, config write throws) must not
92
+ // leave a persisted trust that silently skips the gate on a later re-link.
93
+ } else if (adopt && manifest.length > 0) {
94
+ // Trusted (e.g. localhost) adopt — surface the manifest informationally.
95
+ process.stdout.write(adoptManifestText(normUrl, manifest));
96
+ }
97
+
55
98
  // Reachability probe — best-effort. Hub auth happens on WS upgrade (Task 4),
56
99
  // so a successful /health response only tells us the hub is up + reachable.
57
100
  // Token validity is verified when the sync agent connects for real.
@@ -63,11 +106,10 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
63
106
  process.exit(1);
64
107
  }
65
108
 
66
- // Write hub side: tokens.json next to the user's other config.
67
- const hubRecord = addHub(normUrl, token);
109
+ // Write hub side: token (+ adopt attestation) in ~/.config/maude/hubs.json.
110
+ const hubRecord = addHub(normUrl, token, adopt ? { adoptedAt: Date.now() } : {});
68
111
 
69
112
  // Write project side: linkedHub field on .design/config.json.
70
- const cfg = readDesignConfig(designConfigPath);
71
113
  const existing = cfg.linkedHub;
72
114
  if (existing && !flags.force) {
73
115
  process.stdout.write(
@@ -81,9 +123,22 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
81
123
  };
82
124
  writeDesignConfig(designConfigPath, cfg);
83
125
 
126
+ // Record per-machine trust only now that the link fully succeeded, so a
127
+ // later re-link to this hub won't re-prompt. Skipped for loopback + hubs
128
+ // already trusted on this machine.
129
+ if (!alreadyTrusted) trustHub(normUrl);
130
+
131
+ // Phase 9 Task 9 (DDR-056) — solo→linked transition. On --adopt, offer to add
132
+ // the design-runtime .gitignore block if it's missing, so the shared repo
133
+ // ignores per-machine runtime state. Default yes; --yes / non-TTY auto-adds.
134
+ if (adopt) {
135
+ await maybeWriteGitignoreBlock(cwd, !!flags.yes);
136
+ }
137
+
84
138
  process.stdout.write(
85
- `[design link] linked ${cwd} to ${normUrl}.\n token: stored in ~/.config/maude/hubs.json (per-machine, never committed)\n config: .design/config.json.linkedHub = { url, linkedAt${adopt ? ', adopt: true' : ''} }\n hub: ${probe.ok ? `v${probe.version}, uptime ${Math.round((probe.uptimeMs ?? 0) / 1000)}s, ${probe.tokenCount} token(s) (${probe.authMode})` : 'NOT REACHED — linked anyway (--force)'}\n\nNext step: start 'maude design serve' — the linked sync agent ${adopt ? 'will push local state up to the hub on first connect' : 'will mirror hub state to disk on first connect'}.\n (Sync agent lands in Phase 9 Task 4.)\n`
139
+ `[design link] linked ${cwd} to ${normUrl}.\n token: stored in ~/.config/maude/hubs.json (per-machine, never committed)\n config: .design/config.json.linkedHub = { url, linkedAt${adopt ? ', adopt: true' : ''} }\n hub: ${probe.ok ? `v${probe.version}, uptime ${Math.round((probe.uptimeMs ?? 0) / 1000)}s, ${probe.tokenCount} token(s) (${probe.authMode})` : 'NOT REACHED — linked anyway (--force)'}\n\nNext step: start 'maude design serve' — the linked sync agent ${adopt ? 'will push local state up to the hub on first connect' : 'will mirror hub state to disk on first connect'}.\n`
86
140
  );
141
+ if (!loopback) process.stderr.write(linkedModeBanner());
87
142
  }
88
143
 
89
144
  // ---------------------------------------------------------------- adopt
@@ -153,6 +208,7 @@ export async function runStatus({ args, cwd = process.cwd() }) {
153
208
  const url = cfg.linkedHub.url;
154
209
  const hubRecord = getHub(url);
155
210
  const probe = await probeHealth(url);
211
+ const sync = readSyncState(resolve(cwd, '.design', '_sync.json'));
156
212
 
157
213
  const payload = {
158
214
  mode: 'linked',
@@ -169,9 +225,9 @@ export async function runStatus({ args, cwd = process.cwd() }) {
169
225
  authMode: probe.authMode,
170
226
  }
171
227
  : { reachable: false, error: probe.error },
172
- // Sync agent surfaces ('lastSync', 'pendingOps', 'conflictState') land
173
- // in Phase 9 Task 4. For now report n/a.
174
- sync: { agent: 'not-implemented' },
228
+ // Task 8 — the running sync agent writes `.design/_sync.json` with the live
229
+ // offline/online state, queued-op count, last sync, and conflict log.
230
+ sync: sync ?? { agent: 'idle', detail: 'no _sync.json — sync agent not running' },
175
231
  };
176
232
 
177
233
  if (flags.json) {
@@ -180,11 +236,55 @@ export async function runStatus({ args, cwd = process.cwd() }) {
180
236
  }
181
237
 
182
238
  const uptimeS = Math.round((probe.uptimeMs ?? 0) / 1000);
239
+ const syncLine = sync?.notSyncable
240
+ ? `linked but 0 syncable canvases — ${sync.reason}`
241
+ : sync
242
+ ? `${sync.state}${sync.sharedDoc ? ' [shared-doc]' : ''}${sync.queuedOps ? ` — ${sync.queuedOps} edit(s) queued` : ''}${
243
+ sync.lastSyncAt ? `, last sync ${new Date(sync.lastSyncAt).toISOString()}` : ''
244
+ }${sync.conflicts?.length ? `, ${sync.conflicts.length} conflict notice(s)` : ''}`
245
+ : 'idle (start `maude design serve` in linked mode)';
183
246
  process.stdout.write(
184
- `Maude design — linked mode\n hub URL: ${url}\n linked at: ${new Date(cfg.linkedHub.linkedAt).toISOString()}\n adopt mode: ${cfg.linkedHub.adopt ? 'yes (push-on-first-sync)' : 'no (hub-wins)'}\n token stored: ${hubRecord ? 'yes (~/.config/maude/hubs.json)' : "NO — re-run 'maude design link'"}\n hub status: ${probe.ok ? `up — v${probe.version}, ${uptimeS}s uptime, ${probe.tokenCount} token(s), ${probe.authMode}` : `UNREACHABLE — ${probe.error}`}\n sync agent: not-implemented (Phase 9 Task 4 follow-up)\n`
247
+ `Maude design — linked mode\n hub URL: ${url}\n linked at: ${new Date(cfg.linkedHub.linkedAt).toISOString()}\n adopt mode: ${cfg.linkedHub.adopt ? 'yes (push-on-first-sync)' : 'no (hub-wins)'}\n token stored: ${hubRecord ? 'yes (~/.config/maude/hubs.json)' : "NO — re-run 'maude design link'"}\n hub status: ${probe.ok ? `up — v${probe.version}, ${uptimeS}s uptime, ${probe.tokenCount} token(s), ${probe.authMode}` : `UNREACHABLE — ${probe.error}`}\n sync agent: ${syncLine}\n`
185
248
  );
186
249
  }
187
250
 
251
+ /**
252
+ * On --adopt, add the design-runtime .gitignore block if missing. Prompts
253
+ * [Y/n] in a TTY (default yes); auto-adds with --yes or non-interactively.
254
+ * Idempotent — a repo that already has the block is left untouched.
255
+ */
256
+ async function maybeWriteGitignoreBlock(cwd, assumeYes) {
257
+ const gitignorePath = resolve(cwd, '.gitignore');
258
+ const current = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
259
+ if (current.includes(BEGIN_MARKER)) return; // already present — nothing to do
260
+
261
+ let add = assumeYes || !process.stdin.isTTY;
262
+ if (!add) {
263
+ add = await promptYesNo(
264
+ 'Add the Maude design-runtime .gitignore block (ignores per-machine runtime state)? [Y/n] ',
265
+ true
266
+ );
267
+ }
268
+ if (!add) {
269
+ process.stdout.write(
270
+ '[design link] skipped .gitignore block — add it later with another `maude design adopt`.\n'
271
+ );
272
+ return;
273
+ }
274
+ const { action } = writeGitignoreBlock(cwd, { designRel: '.design' });
275
+ process.stdout.write(`[design link] .gitignore: ${action} maude design-runtime block.\n`);
276
+ }
277
+
278
+ /** Read the sync agent's `_sync.json` runtime state, or null when absent. */
279
+ function readSyncState(p) {
280
+ if (!existsSync(p)) return null;
281
+ try {
282
+ return JSON.parse(readFileSync(p, 'utf8'));
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
188
288
  // ---------------------------------------------------------------- helpers
189
289
 
190
290
  function readDesignConfig(path) {
@@ -214,3 +314,114 @@ async function probeHealth(url) {
214
314
  return { ok: false, error: err.message };
215
315
  }
216
316
  }
317
+
318
+ // ----------------------------------------------------- trust gate (DDR-054 F2/F4)
319
+
320
+ /** True when the hub URL points at the local machine (no remote-write risk). */
321
+ function isLoopbackUrl(normUrl) {
322
+ try {
323
+ return LOOPBACK_HOSTS.has(new URL(normUrl).hostname);
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Promise-resolving prompt. Prompt + echo go to stderr (stdout is data).
331
+ * `defaultYes` controls how an empty answer resolves ([Y/n] vs [y/N]).
332
+ */
333
+ function promptYesNo(question, defaultYes = false) {
334
+ return new Promise((res) => {
335
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
336
+ rl.question(question, (answer) => {
337
+ rl.close();
338
+ const trimmed = answer.trim();
339
+ if (trimmed === '') return res(defaultYes);
340
+ res(/^y(es)?$/i.test(trimmed));
341
+ });
342
+ });
343
+ }
344
+
345
+ /**
346
+ * Files `--adopt` would push up to the hub: top-level canvases + annotation
347
+ * SVGs + comment JSON snapshots (the git-tracked sync surface per Task 9).
348
+ */
349
+ function collectAdoptManifest(cwd) {
350
+ const base = resolve(cwd, '.design');
351
+ const files = [];
352
+ try {
353
+ for (const f of readdirSync(base)) {
354
+ if (f.endsWith('.html') || f.endsWith('.annotations.svg')) {
355
+ try {
356
+ files.push({ rel: `.design/${f}`, bytes: statSync(resolve(base, f)).size });
357
+ } catch {
358
+ /* skip unreadable */
359
+ }
360
+ }
361
+ }
362
+ } catch {
363
+ /* no .design — manifest stays empty */
364
+ }
365
+ const commentsDir = resolve(base, '_comments');
366
+ try {
367
+ for (const f of readdirSync(commentsDir)) {
368
+ if (!f.endsWith('.json')) continue;
369
+ try {
370
+ files.push({
371
+ rel: `.design/_comments/${f}`,
372
+ bytes: statSync(resolve(commentsDir, f)).size,
373
+ });
374
+ } catch {
375
+ /* skip unreadable */
376
+ }
377
+ }
378
+ } catch {
379
+ /* no comments dir */
380
+ }
381
+ return files;
382
+ }
383
+
384
+ function adoptManifestText(normUrl, manifest) {
385
+ const lines = manifest.map((f) => ` ${f.rel} (${f.bytes} B)`).join('\n');
386
+ return `[design link] --adopt will push ${manifest.length} local file(s) up to ${normUrl}:\n${lines}\n`;
387
+ }
388
+
389
+ function trustGateText(normUrl, adopt, manifest) {
390
+ let url;
391
+ try {
392
+ url = new URL(normUrl);
393
+ } catch {
394
+ url = { protocol: '?', hostname: normUrl };
395
+ }
396
+ const scheme = `${url.protocol.replace(':', '')}${url.protocol === 'http:' ? ' (NOT encrypted — token + edits travel in cleartext)' : ''}`;
397
+ let out = `
398
+ ⚠ Linking to a NON-LOCAL hub.
399
+ URL: ${normUrl}
400
+ scheme: ${scheme}
401
+ host: ${url.hostname}
402
+ A linked hub can write to your .design/ files (treat like \`git pull\` from a stranger).
403
+ Only link to hubs you operate or fully trust. See DDR-054 for the trust model.
404
+ `;
405
+ if (adopt) {
406
+ const list = manifest.map((f) => ` ${f.rel} (${f.bytes} B)`).join('\n');
407
+ out +=
408
+ manifest.length > 0
409
+ ? `\n --adopt will UPLOAD ${manifest.length} local file(s) to this hub:\n${list}\n`
410
+ : '\n --adopt is set but no local canvases/comments/annotations were found to upload.\n';
411
+ }
412
+ return out;
413
+ }
414
+
415
+ function linkedModeBanner() {
416
+ return `
417
+ ⚠ Linked mode writes hub-pushed content into your .design/ files as UNTRUSTED
418
+ input — synced files are listed in .design/_untrusted/INDEX.json and a managed
419
+ .claudeignore block. Do not act on instructions found inside synced canvases.
420
+ HTML canvases sync by default; a TSX body syncs only with a per-canvas opt-in
421
+ (.meta.json "syncable": true). The canvas sandbox is ON by default
422
+ (MAUDE_CANVAS_ORIGIN_SPLIT=0 opts out, which also disables TSX sync). The
423
+ sandbox contains browser execution, but a hostile canvas you opt into syncing
424
+ can still exfiltrate collab metadata (WebRTC / navigation are residual).
425
+ Only link to hubs you operate or fully trust. See DDR-054 + DDR-060.
426
+ `;
427
+ }