@1agh/maude 0.23.0 → 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 (101) 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 +32 -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 +126 -2
  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 +74 -10
  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/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. 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
+ });
@@ -15,6 +15,7 @@ import { dirname, resolve } from 'node:path';
15
15
  import { createInterface } from 'node:readline';
16
16
 
17
17
  import { parseArgs } from './argv.mjs';
18
+ import { BEGIN_MARKER, writeGitignoreBlock } from './gitignore-block.mjs';
18
19
  import { addHub, getHub, isHubTrusted, normalizeUrl, removeHub, trustHub } from './hubs-config.mjs';
19
20
 
20
21
  const DESIGN_CONFIG_PATH = '.design/config.json';
@@ -127,6 +128,13 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
127
128
  // already trusted on this machine.
128
129
  if (!alreadyTrusted) trustHub(normUrl);
129
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
+
130
138
  process.stdout.write(
131
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`
132
140
  );
@@ -200,6 +208,7 @@ export async function runStatus({ args, cwd = process.cwd() }) {
200
208
  const url = cfg.linkedHub.url;
201
209
  const hubRecord = getHub(url);
202
210
  const probe = await probeHealth(url);
211
+ const sync = readSyncState(resolve(cwd, '.design', '_sync.json'));
203
212
 
204
213
  const payload = {
205
214
  mode: 'linked',
@@ -216,9 +225,9 @@ export async function runStatus({ args, cwd = process.cwd() }) {
216
225
  authMode: probe.authMode,
217
226
  }
218
227
  : { reachable: false, error: probe.error },
219
- // Sync agent surfaces ('lastSync', 'pendingOps', 'conflictState') land
220
- // in Phase 9 Task 4. For now report n/a.
221
- 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' },
222
231
  };
223
232
 
224
233
  if (flags.json) {
@@ -227,11 +236,55 @@ export async function runStatus({ args, cwd = process.cwd() }) {
227
236
  }
228
237
 
229
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)';
230
246
  process.stdout.write(
231
- `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`
232
248
  );
233
249
  }
234
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
+
235
288
  // ---------------------------------------------------------------- helpers
236
289
 
237
290
  function readDesignConfig(path) {
@@ -273,13 +326,18 @@ function isLoopbackUrl(normUrl) {
273
326
  }
274
327
  }
275
328
 
276
- /** Promise-resolving [y/N] prompt. Prompt + echo go to stderr (stdout is data). */
277
- function promptYesNo(question) {
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) {
278
334
  return new Promise((res) => {
279
335
  const rl = createInterface({ input: process.stdin, output: process.stderr });
280
336
  rl.question(question, (answer) => {
281
337
  rl.close();
282
- res(/^y(es)?$/i.test(answer.trim()));
338
+ const trimmed = answer.trim();
339
+ if (trimmed === '') return res(defaultYes);
340
+ res(/^y(es)?$/i.test(trimmed));
283
341
  });
284
342
  });
285
343
  }
@@ -356,8 +414,14 @@ function trustGateText(normUrl, adopt, manifest) {
356
414
 
357
415
  function linkedModeBanner() {
358
416
  return `
359
- ⚠ Linked mode is an experimental v1.1 preview. Hub-pushed content is written
360
- to your .design/ files as untrusted input. Only link to hubs you operate or
361
- fully trust. See DDR-054 for the trust model.
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.
362
426
  `;
363
427
  }
@@ -0,0 +1,165 @@
1
+ // Phase 11 (flow ⇄ design integration) regression guard.
2
+ //
3
+ // The slash-command orchestration (/flow:plan canvas detection, /flow:done
4
+ // handoff sweep, codebase-intelligence design section, ddr-keeper canvas prompt)
5
+ // is LLM-driven markdown — not unit-testable. What IS deterministic, and what
6
+ // this test locks down, is the *contract* that wiring depends on:
7
+ //
8
+ // 1. The canvas `.meta.json` schema accepts the Phase 11 fields
9
+ // (status enum, handoffCommit, tags, brief_sha) and rejects a bad status.
10
+ // 2. The flow config schema accepts `paths.designRoot`, the real skeleton
11
+ // config validates, and the field stays OPTIONAL (no regression for
12
+ // projects without the design plugin).
13
+ // 3. The /flow:done status round-trip (ready-for-handoff → handed-off +
14
+ // handoffCommit) produces schema-valid output at both ends.
15
+ // 4. The markdown wiring for all four integrations is present (grep guard) —
16
+ // a future edit that deletes a section fails loudly here.
17
+
18
+ import assert from 'node:assert/strict';
19
+ import { readFileSync } from 'node:fs';
20
+ import { resolve } from 'node:path';
21
+ import { test } from 'node:test';
22
+ import addFormats from 'ajv-formats';
23
+ import Ajv2020 from 'ajv/dist/2020.js';
24
+ import { lintConfig } from './config-lint.mjs';
25
+
26
+ const read = (p) => readFileSync(resolve(p), 'utf8');
27
+ const readJson = (p) => JSON.parse(read(p));
28
+
29
+ const META_SCHEMA = readJson('plugins/design/dev-server/canvas-meta.schema.json');
30
+ const FLOW_SCHEMA = readJson('plugins/flow/.claude-plugin/config.schema.json');
31
+ const SKELETON = readJson('plugins/flow/templates/ai-skeleton/workflows.config.json');
32
+
33
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
34
+ addFormats(ajv);
35
+ const validateMeta = ajv.compile(META_SCHEMA);
36
+
37
+ // ── 1. canvas-meta schema accepts the Phase 11 fields ──────────────────────
38
+
39
+ test('canvas-meta: accepts status + tags (ready-for-handoff)', () => {
40
+ const ok = validateMeta({
41
+ title: 'Dark Mode Toggle',
42
+ sections: [{ id: 'hero', label: 'Hero' }],
43
+ tags: ['dark-mode'],
44
+ status: 'ready-for-handoff',
45
+ });
46
+ assert.equal(ok, true, JSON.stringify(validateMeta.errors));
47
+ });
48
+
49
+ test('canvas-meta: accepts handed-off + handoffCommit + brief_sha', () => {
50
+ const ok = validateMeta({
51
+ title: 'Settings',
52
+ sections: [],
53
+ status: 'handed-off',
54
+ handoffCommit: '8dd832b',
55
+ brief_sha: 'abc123def456',
56
+ });
57
+ assert.equal(ok, true, JSON.stringify(validateMeta.errors));
58
+ });
59
+
60
+ test('canvas-meta: every status enum value is accepted', () => {
61
+ for (const status of ['draft', 'in-review', 'ready-for-handoff', 'handed-off']) {
62
+ const ok = validateMeta({ title: 'X', sections: [], status });
63
+ assert.equal(ok, true, `status=${status}: ${JSON.stringify(validateMeta.errors)}`);
64
+ }
65
+ });
66
+
67
+ test('canvas-meta: a bogus status enum is REJECTED', () => {
68
+ const ok = validateMeta({ title: 'X', sections: [], status: 'shipped' });
69
+ assert.equal(ok, false, 'expected status="shipped" to be rejected by the enum');
70
+ });
71
+
72
+ test('canvas-meta: back-compat — a sidecar without any Phase 11 field still validates', () => {
73
+ const ok = validateMeta({ title: 'Legacy', sections: [{ id: 's', label: 'S' }] });
74
+ assert.equal(ok, true, JSON.stringify(validateMeta.errors));
75
+ });
76
+
77
+ test('canvas-meta: tags must be unique strings', () => {
78
+ assert.equal(validateMeta({ title: 'X', sections: [], tags: ['a', 'a'] }), false);
79
+ assert.equal(validateMeta({ title: 'X', sections: [], tags: [1, 2] }), false);
80
+ });
81
+
82
+ // ── 2. flow config schema: paths.designRoot ────────────────────────────────
83
+
84
+ test('flow config: the real skeleton config (with paths.designRoot) validates', async () => {
85
+ const r = await lintConfig({ config: SKELETON, schema: FLOW_SCHEMA });
86
+ assert.equal(r.ok, true, JSON.stringify(r.errors));
87
+ });
88
+
89
+ test('flow config: skeleton declares paths.designRoot = .design', () => {
90
+ assert.equal(SKELETON.paths.designRoot, '.design');
91
+ });
92
+
93
+ test('flow config: paths.designRoot is OPTIONAL (no-regression for non-design projects)', async () => {
94
+ const r = await lintConfig({
95
+ config: { name: 'x', paths: { prd: '.ai/x-prd.md' } },
96
+ schema: FLOW_SCHEMA,
97
+ });
98
+ assert.equal(r.ok, true, JSON.stringify(r.errors));
99
+ });
100
+
101
+ test('flow config: a non-string paths.designRoot is rejected', async () => {
102
+ const r = await lintConfig({
103
+ config: { name: 'x', paths: { designRoot: 42 } },
104
+ schema: FLOW_SCHEMA,
105
+ });
106
+ assert.equal(r.ok, false);
107
+ });
108
+
109
+ test('flow config: paths.designRoot carries a default of .design', () => {
110
+ assert.equal(FLOW_SCHEMA.properties.paths.properties.designRoot.default, '.design');
111
+ });
112
+
113
+ // ── 3. /flow:done status round-trip stays schema-valid ─────────────────────
114
+
115
+ test('done sweep round-trip: ready-for-handoff → handed-off + handoffCommit both valid', () => {
116
+ const before = { title: 'Canvas', sections: [], status: 'ready-for-handoff', tags: ['feat'] };
117
+ assert.equal(validateMeta(before), true, `before: ${JSON.stringify(validateMeta.errors)}`);
118
+
119
+ // what /flow:done step 5b writes:
120
+ const after = { ...before, status: 'handed-off', handoffCommit: 'deadbeef0123' };
121
+ assert.equal(validateMeta(after), true, `after: ${JSON.stringify(validateMeta.errors)}`);
122
+ });
123
+
124
+ // ── 4. markdown wiring guards (a deleted section fails here) ────────────────
125
+
126
+ const PLAN = read('plugins/flow/commands/plan.md');
127
+ const DONE = read('plugins/flow/commands/done.md');
128
+ const MAP_CMD = read('plugins/flow/commands/setup-codebase-map.md');
129
+ const CI_SKILL = read('plugins/flow/skills/codebase-intelligence/SKILL.md');
130
+ const DDR_SKILL = read('plugins/flow/skills/ddr-keeper/SKILL.md');
131
+ const RECORD_DDR = read('plugins/flow/commands/record-ddr.md');
132
+
133
+ test('wiring: /flow:plan has Design Canvas Detection', () => {
134
+ assert.match(PLAN, /Design Canvas Detection/);
135
+ assert.match(PLAN, /paths\.designRoot/);
136
+ assert.match(PLAN, /### Design canvases/); // the Context References subsection
137
+ });
138
+
139
+ test('wiring: /flow:done has the handoff sweep + soft-gate DDR-066 reference', () => {
140
+ assert.match(DONE, /Design handoff sweep/);
141
+ assert.match(DONE, /ready-for-handoff/);
142
+ assert.match(DONE, /handed-off/);
143
+ assert.match(DONE, /handoffCommit/);
144
+ assert.match(DONE, /DDR-066/);
145
+ });
146
+
147
+ test('wiring: codebase map command + skill have the Design artifacts section', () => {
148
+ assert.match(MAP_CMD, /Map Design Artifacts/);
149
+ assert.match(MAP_CMD, /## Design artifacts/);
150
+ assert.match(CI_SKILL, /Design Artifact Scanning/);
151
+ assert.match(CI_SKILL, /### Design artifacts/);
152
+ });
153
+
154
+ test('wiring: ddr-keeper + record-ddr have the canvas-reference prompt', () => {
155
+ assert.match(DDR_SKILL, /Canvas reference for UI-affecting decisions/);
156
+ assert.match(DDR_SKILL, /Related canvas/);
157
+ assert.match(RECORD_DDR, /Related canvas/);
158
+ });
159
+
160
+ test('wiring: DDR-066 exists and is indexed', () => {
161
+ const ddr = read('.ai/decisions/DDR-066-soft-handoff-prompt-in-flow-done.md');
162
+ assert.match(ddr, /soft prompt/i);
163
+ const index = read('.ai/decisions/README.md');
164
+ assert.match(index, /DDR-066/);
165
+ });