@1agh/maude 0.27.0 → 0.28.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 (27) hide show
  1. package/cli/commands/design-link.test.mjs +46 -0
  2. package/cli/commands/doctor.mjs +110 -5
  3. package/cli/commands/doctor.test.mjs +52 -0
  4. package/cli/lib/design-link.mjs +38 -8
  5. package/package.json +8 -8
  6. package/plugins/design/dev-server/activity.ts +256 -0
  7. package/plugins/design/dev-server/ai-banner.tsx +4 -0
  8. package/plugins/design/dev-server/artboard-activity-overlay.tsx +67 -0
  9. package/plugins/design/dev-server/canvas-comment-mount.tsx +164 -9
  10. package/plugins/design/dev-server/canvas-lib.tsx +62 -15
  11. package/plugins/design/dev-server/config.schema.json +2 -1
  12. package/plugins/design/dev-server/dist/client.bundle.js +18 -18
  13. package/plugins/design/dev-server/dist/comment-mount.js +82 -4
  14. package/plugins/design/dev-server/inspect.ts +31 -1
  15. package/plugins/design/dev-server/participants-chrome.tsx +86 -1
  16. package/plugins/design/dev-server/server.ts +10 -1
  17. package/plugins/design/dev-server/sync/index.ts +24 -19
  18. package/plugins/design/dev-server/test/activity.test.ts +195 -0
  19. package/plugins/design/dev-server/test/artboard-activity-overlay.test.tsx +56 -0
  20. package/plugins/design/dev-server/test/canvas-hmr-runtime.test.tsx +114 -0
  21. package/plugins/design/dev-server/test/sync-runtime.test.ts +34 -6
  22. package/plugins/design/dev-server/test/use-agent-presence.test.tsx +114 -0
  23. package/plugins/design/dev-server/test/use-canvas-activity.test.tsx +206 -0
  24. package/plugins/design/dev-server/use-agent-presence.tsx +244 -0
  25. package/plugins/design/dev-server/use-canvas-activity.tsx +252 -0
  26. package/plugins/design/dev-server/ws.ts +21 -2
  27. package/plugins/design/templates/_shell.html +108 -3
@@ -136,6 +136,52 @@ test('adopt subcommand is an alias of link --adopt', async () => {
136
136
  cleanup();
137
137
  });
138
138
 
139
+ test('DDR-079: link without a flag omits syncTsx (= default ON, restore-proof)', async () => {
140
+ const res = await runCli(['design', 'link', URL, '--token', 'mau_test']);
141
+ assert.equal(res.status, 0, res.stderr);
142
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
143
+ // We never write syncTsx:true to encode the default — absence means on.
144
+ assert.equal(cfg.linkedHub.syncTsx, undefined);
145
+ assert.match(res.stdout, /TSX sync: on by default/);
146
+ cleanup();
147
+ });
148
+
149
+ test('DDR-079: link --no-sync-tsx records syncTsx:false (project opt-out)', async () => {
150
+ const res = await runCli(['design', 'link', URL, '--token', 'mau_test', '--no-sync-tsx']);
151
+ assert.equal(res.status, 0, res.stderr);
152
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
153
+ assert.equal(cfg.linkedHub.syncTsx, false);
154
+ assert.match(res.stdout, /TSX sync: off \(opted out/);
155
+ cleanup();
156
+ });
157
+
158
+ test('DDR-079: link --sync-tsx pins syncTsx:true explicitly', async () => {
159
+ const res = await runCli(['design', 'link', URL, '--token', 'mau_test', '--sync-tsx']);
160
+ assert.equal(res.status, 0, res.stderr);
161
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
162
+ assert.equal(cfg.linkedHub.syncTsx, true);
163
+ cleanup();
164
+ });
165
+
166
+ test('DDR-079: status surfaces the migration advisory when syncTsx is unset', async () => {
167
+ await runCli(['design', 'link', URL, '--token', 'mau_test']);
168
+ const res = await runCli(['design', 'status']);
169
+ assert.equal(res.status, 0, res.stderr);
170
+ assert.match(res.stdout, /TSX sync:\s+on \(default/);
171
+ assert.match(res.stdout, /syncTsx is not set/);
172
+ cleanup();
173
+ });
174
+
175
+ test('DDR-079: status --json reports effective + explicit syncTsx', async () => {
176
+ await runCli(['design', 'link', URL, '--token', 'mau_test', '--no-sync-tsx']);
177
+ const res = await runCli(['design', 'status', '--json']);
178
+ assert.equal(res.status, 0, res.stderr);
179
+ const payload = JSON.parse(res.stdout);
180
+ assert.equal(payload.syncTsx, false);
181
+ assert.equal(payload.syncTsxExplicit, false);
182
+ cleanup();
183
+ });
184
+
139
185
  test('link against unreachable URL exits 1 unless --force', async () => {
140
186
  const dead = 'http://127.0.0.1:1';
141
187
  const fail = await runCli(['design', 'link', dead, '--token', 'mau_test']);
@@ -1,6 +1,8 @@
1
1
  // `maude doctor` — unified workspace diagnostic.
2
2
  // Combines (1) plugin dependency preflight, (2) workflows.config.json schema
3
- // validation, (3) stack drift, (4) quality-gate additions into one report.
3
+ // validation, (3) stack drift, (4) quality-gate additions, and (5) linked
4
+ // design-hub / TSX-sync health (report-only, local signals — no network) into
5
+ // one report.
4
6
  //
5
7
  // Flags:
6
8
  // --plugin <name> Scope deps section to one plugin. Config section is
@@ -23,6 +25,7 @@ import { stdin, stdout } from 'node:process';
23
25
  import { createInterface } from 'node:readline/promises';
24
26
  import { parseArgs } from '../lib/argv.mjs';
25
27
  import { lintConfig } from '../lib/config-lint.mjs';
28
+ import { getHub } from '../lib/hubs-config.mjs';
26
29
  import { checkAll } from '../lib/preflight.mjs';
27
30
  import { detectQualityGates, detectStack } from '../lib/stack-detect.mjs';
28
31
 
@@ -115,6 +118,60 @@ export async function run({ args, pkgRoot }) {
115
118
  };
116
119
  }
117
120
 
121
+ // ── Design config: linked-hub + TSX-sync health (report-only) ────────────
122
+ // doctor's primary domain is .ai/workflows.config.json, but a linked design
123
+ // project's .design/config.json carries the one knob that has bitten users
124
+ // repeatedly — `linkedHub.syncTsx`. Surface it here so doctor is the one-stop
125
+ // health check. Report-only: under DDR-079 (default ON) every state is valid,
126
+ // so there is nothing to --fix and we NEVER impose an opt-out.
127
+ let designReport = { exists: false };
128
+ const designFsPath = resolve(repoRoot, '.design/config.json');
129
+ if (existsSync(designFsPath)) {
130
+ try {
131
+ const dcfg = JSON.parse(readFileSync(designFsPath, 'utf8'));
132
+ const linkedHub = dcfg.linkedHub;
133
+ if (!linkedHub) {
134
+ designReport = { exists: true, linked: false };
135
+ } else {
136
+ const syncTsx = linkedHub.syncTsx;
137
+ const tsxSync =
138
+ syncTsx === false
139
+ ? 'off (opted out)'
140
+ : syncTsx === true
141
+ ? 'on (explicit)'
142
+ : 'on (default)';
143
+ // Local-only signals — no network. `getHub` reads ~/.config/maude/
144
+ // hubs.json; the sync-agent state comes from the serve-written
145
+ // `_sync.json`. We deliberately DO NOT probe the hub's /health here:
146
+ // doctor must stay fast + offline (it runs in the release pre-flight),
147
+ // so live hub reachability stays in `maude design status`.
148
+ let tokenStored = false;
149
+ try {
150
+ tokenStored = !!getHub(linkedHub.url);
151
+ } catch {
152
+ /* hubs.json absent/unreadable on this machine → no token */
153
+ }
154
+ designReport = {
155
+ exists: true,
156
+ linked: true,
157
+ hubUrl: linkedHub.url,
158
+ adopt: !!linkedHub.adopt,
159
+ tsxSync,
160
+ tokenStored,
161
+ syncAgent: readSyncAgentState(resolve(repoRoot, '.design', '_sync.json')),
162
+ // DDR-079 migration advisory: a linked config with no explicit
163
+ // syncTsx rides the default, which flipped off→on in maude 0.27→0.28.
164
+ advisory:
165
+ syncTsx === undefined
166
+ ? 'TSX sync defaults ON (DDR-079) — every .tsx syncs to this hub. Opt out with linkedHub.syncTsx:false or `maude design link … --no-sync-tsx`.'
167
+ : null,
168
+ };
169
+ }
170
+ } catch (err) {
171
+ designReport = { exists: true, error: `invalid JSON: ${err.message}` };
172
+ }
173
+ }
174
+
118
175
  // ── Summary ─────────────────────────────────────────────────────────────
119
176
  const hardDepsMissing = Object.values(depsByPlugin)
120
177
  .filter((r) => r.summary)
@@ -132,12 +189,12 @@ export async function run({ args, pkgRoot }) {
132
189
 
133
190
  if (jsonMode) {
134
191
  process.stdout.write(
135
- `${JSON.stringify({ deps: depsByPlugin, config: configReport, summary }, null, 2)}\n`
192
+ `${JSON.stringify({ deps: depsByPlugin, config: configReport, design: designReport, summary }, null, 2)}\n`
136
193
  );
137
194
  process.exit(summary.healthy ? 0 : 1);
138
195
  }
139
196
 
140
- printReport({ depsByPlugin, configReport, summary });
197
+ printReport({ depsByPlugin, configReport, designReport, summary });
141
198
 
142
199
  // ── Fix path ────────────────────────────────────────────────────────────
143
200
  if (fix) {
@@ -151,11 +208,33 @@ export async function run({ args, pkgRoot }) {
151
208
  process.exit(summary.healthy ? 0 : 1);
152
209
  }
153
210
 
211
+ // Read the serve-written `.design/_sync.json` and condense it to one line.
212
+ // Local file only — never network. Returns null when the agent hasn't run.
213
+ function readSyncAgentState(p) {
214
+ if (!existsSync(p)) return null;
215
+ try {
216
+ const s = JSON.parse(readFileSync(p, 'utf8'));
217
+ if (s.notSyncable) return `0 syncable — ${s.reason || 'see `maude design status`'}`;
218
+ if (s.state) {
219
+ const bits = [String(s.state), `${s.canvases ?? 0} canvas(es)`];
220
+ if (s.queuedOps) bits.push(`${s.queuedOps} queued`);
221
+ if (s.conflicts?.length) bits.push(`${s.conflicts.length} conflict notice(s)`);
222
+ return bits.join(', ');
223
+ }
224
+ return null;
225
+ } catch {
226
+ return null;
227
+ }
228
+ }
229
+
154
230
  function usage() {
155
231
  return `maude doctor [--plugin <name>] [--fix] [--json]
156
232
 
157
233
  Unified workspace diagnostic. Reports missing dependencies, config schema
158
- errors, stack drift, and missing quality-gate declarations in one shot.
234
+ errors, stack drift, missing quality-gate declarations, and when a
235
+ .design/config.json is linked — design-hub + TSX-sync health, in one shot.
236
+ (The design section is report-only and local; live hub reachability is
237
+ \`maude design status\`.)
159
238
 
160
239
  Flags:
161
240
  --plugin <name> Scope dependency section to one plugin (design | flow).
@@ -170,7 +249,7 @@ function usage() {
170
249
  `;
171
250
  }
172
251
 
173
- function printReport({ depsByPlugin, configReport, summary }) {
252
+ function printReport({ depsByPlugin, configReport, designReport, summary }) {
174
253
  process.stdout.write('maude doctor\n\n');
175
254
  for (const [name, env] of Object.entries(depsByPlugin)) {
176
255
  process.stdout.write(` Dependencies (plugins/${name}):\n`);
@@ -235,6 +314,32 @@ function printReport({ depsByPlugin, configReport, summary }) {
235
314
  }
236
315
  }
237
316
 
317
+ // Design / linked-hub health — report-only, local signals only (no network).
318
+ if (designReport?.exists) {
319
+ process.stdout.write(' Design hub (.design/config.json):\n');
320
+ if (designReport.error) {
321
+ process.stdout.write(` ✗ ${designReport.error}\n`);
322
+ } else if (!designReport.linked) {
323
+ process.stdout.write(' solo — not linked to a hub.\n');
324
+ } else {
325
+ process.stdout.write(` ✓ linked: ${designReport.hubUrl}\n`);
326
+ process.stdout.write(
327
+ ` token stored: ${designReport.tokenStored ? 'yes' : 'NO — run `maude design link`'}\n`
328
+ );
329
+ process.stdout.write(` TSX sync: ${designReport.tsxSync}\n`);
330
+ if (designReport.adopt)
331
+ process.stdout.write(' adopt mode: yes (push-on-first-sync)\n');
332
+ process.stdout.write(
333
+ ` sync agent: ${designReport.syncAgent || 'idle (start `maude design serve`)'}\n`
334
+ );
335
+ if (designReport.advisory) process.stdout.write(` ℹ ${designReport.advisory}\n`);
336
+ process.stdout.write(
337
+ ' (live hub reachability + full sync detail: `maude design status`)\n'
338
+ );
339
+ }
340
+ process.stdout.write('\n');
341
+ }
342
+
238
343
  const parts = [];
239
344
  if (summary.hardDepsMissing) parts.push(`${summary.hardDepsMissing} hard dep missing`);
240
345
  if (summary.schemaErrors)
@@ -183,3 +183,55 @@ test('summary line reflects counts (drift/additions tracked via package.json)',
183
183
  const lintAdd = env.config.qualityAdditions.find((a) => a.gate === 'lint');
184
184
  assert.ok(lintAdd);
185
185
  });
186
+
187
+ // ── DDR-079: design-hub / TSX-sync section (report-only, local) ──────────────
188
+ function writeDesign(root, linkedHub) {
189
+ mkdirSync(join(root, '.design'), { recursive: true });
190
+ writeFileSync(
191
+ join(root, '.design/config.json'),
192
+ JSON.stringify({ name: 't', designRoot: '.design', ...(linkedHub ? { linkedHub } : {}) })
193
+ );
194
+ }
195
+
196
+ test('DDR-079: linked design config with no syncTsx → on (default) + advisory', () => {
197
+ const root = makeTempRepo();
198
+ writeFileSync(join(root, '.ai/workflows.config.json'), `${JSON.stringify({ name: 'x' })}\n`);
199
+ writeDesign(root, { url: 'https://hub.example.com', linkedAt: 1 });
200
+ const env = JSON.parse(spawnDoctor(['--json'], root).stdout);
201
+ assert.equal(env.design.linked, true);
202
+ assert.equal(env.design.hubUrl, 'https://hub.example.com');
203
+ assert.equal(env.design.tsxSync, 'on (default)');
204
+ assert.match(env.design.advisory, /defaults ON/);
205
+ });
206
+
207
+ test('DDR-079: syncTsx:false → off (opted out), no advisory', () => {
208
+ const root = makeTempRepo();
209
+ writeFileSync(join(root, '.ai/workflows.config.json'), `${JSON.stringify({ name: 'x' })}\n`);
210
+ writeDesign(root, { url: 'https://hub.example.com', linkedAt: 1, syncTsx: false });
211
+ const env = JSON.parse(spawnDoctor(['--json'], root).stdout);
212
+ assert.equal(env.design.tsxSync, 'off (opted out)');
213
+ assert.equal(env.design.advisory, null);
214
+ });
215
+
216
+ test('DDR-079: syncTsx:true → on (explicit), no advisory', () => {
217
+ const root = makeTempRepo();
218
+ writeFileSync(join(root, '.ai/workflows.config.json'), `${JSON.stringify({ name: 'x' })}\n`);
219
+ writeDesign(root, { url: 'https://hub.example.com', linkedAt: 1, syncTsx: true });
220
+ const env = JSON.parse(spawnDoctor(['--json'], root).stdout);
221
+ assert.equal(env.design.tsxSync, 'on (explicit)');
222
+ assert.equal(env.design.advisory, null);
223
+ });
224
+
225
+ test('design section reports solo when .design/config.json has no linkedHub', () => {
226
+ const root = makeTempRepo();
227
+ writeDesign(root, null);
228
+ const env = JSON.parse(spawnDoctor(['--json'], root).stdout);
229
+ assert.equal(env.design.exists, true);
230
+ assert.equal(env.design.linked, false);
231
+ });
232
+
233
+ test('design section absent when no .design/config.json', () => {
234
+ const root = makeTempRepo();
235
+ const env = JSON.parse(spawnDoctor(['--json'], root).stdout);
236
+ assert.equal(env.design.exists, false);
237
+ });
@@ -25,7 +25,9 @@ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
25
25
 
26
26
  export async function runLink({ args, cwd = process.cwd(), forceAdopt = false }) {
27
27
  const tail = args.slice(args.indexOf(forceAdopt ? 'adopt' : 'link') + 1);
28
- const { flags, positional } = parseArgs(tail, { booleans: ['adopt', 'force', 'yes'] });
28
+ const { flags, positional } = parseArgs(tail, {
29
+ booleans: ['adopt', 'force', 'yes', 'sync-tsx', 'no-sync-tsx'],
30
+ });
29
31
  const url = positional[0];
30
32
  const token = flags.token;
31
33
 
@@ -116,15 +118,27 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
116
118
  `[design link] note: replacing existing link to ${existing.url} (was added ${new Date(existing.linkedAt).toISOString()}).\n`
117
119
  );
118
120
  }
119
- // DDR-072preserve the project-level TSX opt-in only when re-linking to the
120
- // SAME hub. Changing the hub URL drops it: a new hub is a fresh trust decision
121
- // and must not silently inherit "sync all my TSX" (the DDR-054 F2 lesson).
122
- const keepSyncTsx = existing?.syncTsx === true && existing.url === normUrl;
121
+ // DDR-079TSX sync defaults ON, so we only PERSIST `syncTsx` when it's an
122
+ // explicit choice; absence means "the default", and we never write
123
+ // `syncTsx: true` just to encode the default (that's what bit us a value
124
+ // that git-restore could wipe to silently flip behavior). Precedence:
125
+ // --no-sync-tsx → false (project-wide opt-out)
126
+ // --sync-tsx → true (explicit, == default; useful to pin against a future
127
+ // default change or to self-document)
128
+ // else, re-link to the SAME hub → carry the existing explicit value forward
129
+ // (a NEW hub URL is a fresh trust decision — don't inherit, DDR-054 F2)
130
+ // else → omit (= on by default)
131
+ let syncTsx;
132
+ if (flags['no-sync-tsx']) syncTsx = false;
133
+ else if (flags['sync-tsx']) syncTsx = true;
134
+ else if (existing && existing.url === normUrl && typeof existing.syncTsx === 'boolean') {
135
+ syncTsx = existing.syncTsx;
136
+ }
123
137
  cfg.linkedHub = {
124
138
  url: normUrl,
125
139
  linkedAt: hubRecord.linkedAt,
126
140
  ...(adopt ? { adopt: true } : {}),
127
- ...(keepSyncTsx ? { syncTsx: true } : {}),
141
+ ...(syncTsx !== undefined ? { syncTsx } : {}),
128
142
  };
129
143
  writeDesignConfig(designConfigPath, cfg);
130
144
 
@@ -140,8 +154,12 @@ export async function runLink({ args, cwd = process.cwd(), forceAdopt = false })
140
154
  await maybeWriteGitignoreBlock(cwd, !!flags.yes);
141
155
  }
142
156
 
157
+ const tsxSyncLine =
158
+ syncTsx === false
159
+ ? 'off (opted out — linkedHub.syncTsx: false)'
160
+ : 'on by default (DDR-079) — every .tsx body syncs; opt out with --no-sync-tsx or a canvas .meta.json "syncable": false';
143
161
  process.stdout.write(
144
- `[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`
162
+ `[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' : ''}${syncTsx !== undefined ? `, syncTsx: ${syncTsx}` : ''} }\n TSX sync: ${tsxSyncLine}\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`
145
163
  );
146
164
  if (!loopback) process.stderr.write(linkedModeBanner());
147
165
  }
@@ -220,6 +238,9 @@ export async function runStatus({ args, cwd = process.cwd() }) {
220
238
  url,
221
239
  linkedAt: cfg.linkedHub.linkedAt,
222
240
  adopt: !!cfg.linkedHub.adopt,
241
+ // DDR-079 — effective TSX-sync state: on unless explicitly opted out.
242
+ syncTsx: cfg.linkedHub.syncTsx !== false,
243
+ syncTsxExplicit: typeof cfg.linkedHub.syncTsx === 'boolean' ? cfg.linkedHub.syncTsx : null,
223
244
  tokenStored: !!hubRecord,
224
245
  hub: probe.ok
225
246
  ? {
@@ -249,8 +270,17 @@ export async function runStatus({ args, cwd = process.cwd() }) {
249
270
  }${sync.conflicts?.length ? `, ${sync.conflicts.length} conflict notice(s)` : ''}`
250
271
  : 'idle (start `maude design serve` in linked mode)';
251
272
  process.stdout.write(
252
- `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`
273
+ `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 TSX sync: ${cfg.linkedHub.syncTsx === false ? 'off (opted out — linkedHub.syncTsx: false)' : 'on (default — DDR-079)'}\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`
253
274
  );
275
+ // DDR-079 migration advisory: a config with no explicit `syncTsx` rides the
276
+ // default, which FLIPPED from off→on in maude 0.27. Surface it so an upgrader
277
+ // who relied on the old "TSX never syncs" behavior isn't surprised that every
278
+ // .tsx now goes to the hub.
279
+ if (cfg.linkedHub.syncTsx === undefined) {
280
+ process.stdout.write(
281
+ '\n ℹ syncTsx is not set, so it uses the DEFAULT — which is now ON (DDR-079, maude ≥ 0.27): every .tsx syncs to this hub.\n Upgraded from an older maude and want the pre-0.27 behavior (no TSX sync)? Set .design/config.json → linkedHub.syncTsx: false (or maude design link <url> --token … --no-sync-tsx).\n'
282
+ );
283
+ }
254
284
  }
255
285
 
256
286
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -41,13 +41,13 @@
41
41
  "prepublishOnly": "bash scripts/check-version-parity.sh && bash plugins/design/dev-server/bin/check-runtime-bundles.sh"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@1agh/maude-darwin-arm64": "0.27.0",
45
- "@1agh/maude-darwin-x64": "0.27.0",
46
- "@1agh/maude-linux-arm64": "0.27.0",
47
- "@1agh/maude-linux-arm64-musl": "0.27.0",
48
- "@1agh/maude-linux-x64": "0.27.0",
49
- "@1agh/maude-linux-x64-musl": "0.27.0",
50
- "@1agh/maude-win32-x64": "0.27.0"
44
+ "@1agh/maude-darwin-arm64": "0.28.0",
45
+ "@1agh/maude-darwin-x64": "0.28.0",
46
+ "@1agh/maude-linux-arm64": "0.28.0",
47
+ "@1agh/maude-linux-arm64-musl": "0.28.0",
48
+ "@1agh/maude-linux-x64": "0.28.0",
49
+ "@1agh/maude-linux-x64-musl": "0.28.0",
50
+ "@1agh/maude-win32-x64": "0.28.0"
51
51
  },
52
52
  "files": [
53
53
  "cli",
@@ -0,0 +1,256 @@
1
+ // Canvas activity tracker (Phase 13 / DDR-029).
2
+ //
3
+ // Subscribes to the existing `fs:any` bus event, filters for canvas-shaped
4
+ // files under <designRoot>, and maintains a per-file `active | idle` status with
5
+ // a debounce: rapid saves stay `active`, ACTIVITY_IDLE_MS of fs silence flips a
6
+ // file to `idle`. Every transition emits `activity:change` on the bus, which
7
+ // ws.ts forwards to canvas iframes as `{ type: 'activity', … }`. The injected
8
+ // canvas runtime (use-canvas-activity.tsx) turns that into a live "agent works
9
+ // here" overlay on the affected artboards.
10
+ //
11
+ // Bun-native (DDR-009): setTimeout/clearTimeout + Bun.file for the Task 7 diff;
12
+ // no node:fs reads where Bun.file works. Pure, fs-injected helpers (isCanvasFile
13
+ // / diffArtboardIds) unit-test without touching disk.
14
+
15
+ import path from 'node:path';
16
+
17
+ import type { Context } from './context.ts';
18
+
19
+ /** Idle debounce — fs silence longer than this flips a file `active` → `idle`. */
20
+ export const ACTIVITY_IDLE_MS = 3000;
21
+
22
+ /** LRU cap on the per-file text stash used by the Task 7 region diff. */
23
+ const STASH_CAP = 50;
24
+
25
+ // Mirrors api.ts SKIP_DIRS — directories that never hold user-facing canvases.
26
+ const SKIP_DIRS = new Set([
27
+ 'node_modules',
28
+ '.git',
29
+ '.next',
30
+ '.turbo',
31
+ 'dist',
32
+ 'build',
33
+ '.expo',
34
+ 'coverage',
35
+ 'dev-server',
36
+ '_history',
37
+ ]);
38
+
39
+ /**
40
+ * True when a design-root-relative path is a user-facing canvas the overlay
41
+ * should light up. `.tsx` / `.html` only, minus:
42
+ * - runtime artifacts + any `_`-prefixed file or directory (`_history/`,
43
+ * `_draw/`, `_smoke/`, `_locator.json`, …),
44
+ * - SKIP_DIRS segments (node_modules, dist, …),
45
+ * - DS preview specimens under `system/<ds>/preview/**` — a critic editing a
46
+ * preview emits a real `fs:any`, but those aren't canvases the user is
47
+ * iterating on, so an overlay there is a false positive (plan Task 1 gotcha;
48
+ * extended to `.tsx` specimens, not just the `.html` the plan named, because
49
+ * the rationale is identical).
50
+ *
51
+ * Pure — no disk access. Accepts back-slash or forward-slash input.
52
+ */
53
+ export function isCanvasFile(rel: string): boolean {
54
+ if (typeof rel !== 'string') return false;
55
+ const p = rel.replace(/\\/g, '/').replace(/^\/+/, '');
56
+ if (!p) return false;
57
+ const segs = p.split('/');
58
+ const base = segs[segs.length - 1] ?? '';
59
+ const dot = base.lastIndexOf('.');
60
+ const ext = dot >= 0 ? base.slice(dot).toLowerCase() : '';
61
+ if (ext !== '.tsx' && ext !== '.html') return false;
62
+ for (const s of segs) {
63
+ if (SKIP_DIRS.has(s)) return false;
64
+ if (s.startsWith('_')) return false;
65
+ }
66
+ if (/(^|\/)system\/[^/]+\/preview\//.test(p)) return false;
67
+ return true;
68
+ }
69
+
70
+ export interface ActivityEntry {
71
+ status: 'active' | 'idle';
72
+ /** ISO timestamp of the last transition. */
73
+ ts: string;
74
+ /**
75
+ * Artboard ids the change was scoped to (Task 7), or null when the change is
76
+ * file-level (every artboard in the file lights up). Always null on the first
77
+ * sight of a file (no baseline to diff against).
78
+ */
79
+ artboardIds: string[] | null;
80
+ }
81
+
82
+ export interface ActivityChange {
83
+ file: string;
84
+ status: 'active' | 'idle';
85
+ artboard_ids: string[] | null;
86
+ ts: string;
87
+ }
88
+
89
+ export interface Activity {
90
+ /** WS-open snapshot — keyed by design-root-relative canvas path. */
91
+ state: Record<string, ActivityEntry>;
92
+ /**
93
+ * Test seam — synchronously mark a file `active` (file-level) and schedule its
94
+ * idle flip. The live path calls this from the `fs:any` handler, then refines
95
+ * `artboardIds` asynchronously via the Task 7 diff.
96
+ */
97
+ mark(file: string): void;
98
+ /** Unsubscribe from the bus and clear all pending idle timers. */
99
+ stop(): void;
100
+ }
101
+
102
+ export interface ActivityOptions {
103
+ /** Override the idle debounce (tests pass a small value). */
104
+ idleMs?: number;
105
+ /** Disable the Task 7 per-artboard diff (file-level only). Default: enabled. */
106
+ diff?: boolean;
107
+ }
108
+
109
+ export function createActivity(ctx: Context, opts: ActivityOptions = {}): Activity {
110
+ const idleMs = opts.idleMs ?? ACTIVITY_IDLE_MS;
111
+ const diffEnabled = opts.diff !== false;
112
+
113
+ const state: Record<string, ActivityEntry> = {};
114
+ const idleTimers = new Map<string, ReturnType<typeof setTimeout>>();
115
+ // Last-seen file text, for the Task 7 region diff. Insertion-ordered Map = LRU.
116
+ const lastText = new Map<string, string>();
117
+
118
+ function emit(file: string) {
119
+ const e = state[file];
120
+ if (!e) return;
121
+ const change: ActivityChange = {
122
+ file,
123
+ status: e.status,
124
+ artboard_ids: e.artboardIds,
125
+ ts: e.ts,
126
+ };
127
+ ctx.bus.emit('activity:change', change);
128
+ }
129
+
130
+ function scheduleIdle(file: string) {
131
+ const prev = idleTimers.get(file);
132
+ if (prev) clearTimeout(prev);
133
+ const t = setTimeout(() => {
134
+ idleTimers.delete(file);
135
+ const e = state[file];
136
+ if (!e || e.status === 'idle') return;
137
+ e.status = 'idle';
138
+ e.ts = new Date().toISOString();
139
+ // Idle carries no artboard scope — the whole file cross-fades out.
140
+ e.artboardIds = null;
141
+ emit(file);
142
+ }, idleMs);
143
+ idleTimers.set(file, t);
144
+ }
145
+
146
+ // Public + internal entry. Marks the file `active` file-level (preserving any
147
+ // artboardIds already refined this active window), refreshes the idle timer,
148
+ // and emits. Emits on the idle→active transition AND on every refresh-while-
149
+ // active (plan Task 2) — idempotent for the client, which only fades on idle.
150
+ function mark(file: string) {
151
+ const prev = state[file];
152
+ const carryIds = prev && prev.status === 'active' ? prev.artboardIds : null;
153
+ state[file] = { status: 'active', ts: new Date().toISOString(), artboardIds: carryIds };
154
+ scheduleIdle(file);
155
+ emit(file);
156
+ }
157
+
158
+ // Task 7 — refine the active file's artboardIds via a cheap region diff of the
159
+ // previous vs current file text. Best-effort: any read race / parse ambiguity
160
+ // leaves the file-level highlight in place. Runs at most once per fs:any (which
161
+ // fs-watch already debounces 50 ms), and never blocks the synchronous mark().
162
+ async function refineArtboards(file: string) {
163
+ if (!ctx.paths?.designRoot) return;
164
+ try {
165
+ const abs = path.join(ctx.paths.designRoot, file);
166
+ const next = await Bun.file(abs).text();
167
+ const prev = lastText.get(file);
168
+ stash(file, next);
169
+ if (prev == null) return; // first sight — no baseline, stay file-level
170
+ const ids = diffArtboardIds(prev, next);
171
+ if (!ids || ids.length === 0) return; // ambiguous / outside-region change
172
+ const e = state[file];
173
+ if (e && e.status === 'active') {
174
+ e.artboardIds = ids;
175
+ emit(file);
176
+ }
177
+ } catch {
178
+ /* read race, file gone, or designRoot unset in tests — stay file-level */
179
+ }
180
+ }
181
+
182
+ function stash(file: string, text: string) {
183
+ if (lastText.has(file)) lastText.delete(file);
184
+ lastText.set(file, text);
185
+ while (lastText.size > STASH_CAP) {
186
+ const oldest = lastText.keys().next().value;
187
+ if (oldest === undefined) break;
188
+ lastText.delete(oldest);
189
+ }
190
+ }
191
+
192
+ const off = ctx.bus.on('fs:any', (rel: string) => {
193
+ if (!isCanvasFile(rel)) return;
194
+ mark(rel);
195
+ if (diffEnabled) void refineArtboards(rel);
196
+ });
197
+
198
+ function stop() {
199
+ off();
200
+ for (const t of idleTimers.values()) clearTimeout(t);
201
+ idleTimers.clear();
202
+ lastText.clear();
203
+ }
204
+
205
+ return { state, mark, stop };
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Task 7 helpers — pure, regex-bounded region diff. Exported for unit tests.
210
+
211
+ /**
212
+ * Map each `<DCArtboard id="X">…</DCArtboard>` to its inner body text. Returns
213
+ * `null` when no artboard markers are found (the file can't be region-scoped, so
214
+ * the caller falls back to a file-level highlight). DCArtboards aren't nested in
215
+ * practice, so a non-greedy body match is sufficient — deliberately not AST-
216
+ * based (a TSX parser would mean a new dep, against DDR-009's zero-dep spirit).
217
+ */
218
+ function extractArtboardRegions(src: string): Map<string, string> | null {
219
+ const re = /<DCArtboard\b[^>]*\bid=["']([^"']+)["'][^>]*>([\s\S]*?)<\/DCArtboard>/g;
220
+ const map = new Map<string, string>();
221
+ let m: RegExpExecArray | null;
222
+ // biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec loop.
223
+ while ((m = re.exec(src)) !== null) {
224
+ map.set(m[1] as string, m[2] as string);
225
+ }
226
+ return map.size === 0 ? null : map;
227
+ }
228
+
229
+ /** The file with every artboard BODY stripped — what's left is the "shell". */
230
+ function artboardSkeleton(src: string): string {
231
+ return src.replace(
232
+ /(<DCArtboard\b[^>]*\bid=["'][^"']+["'][^>]*>)[\s\S]*?(<\/DCArtboard>)/g,
233
+ '$1$2'
234
+ );
235
+ }
236
+
237
+ /**
238
+ * Ids of the `<DCArtboard>` blocks whose body changed between two file
239
+ * revisions, or `null` to signal "fall back to file-level" when:
240
+ * - either revision has no parseable artboard markers, OR
241
+ * - anything outside the artboard bodies changed (imports, opening-tag attrs,
242
+ * artboard added/removed → the shell differs), OR
243
+ * - nothing inside any artboard body changed.
244
+ */
245
+ export function diffArtboardIds(prev: string, next: string): string[] | null {
246
+ const prevRegions = extractArtboardRegions(prev);
247
+ const nextRegions = extractArtboardRegions(next);
248
+ if (!prevRegions || !nextRegions) return null;
249
+ if (artboardSkeleton(prev) !== artboardSkeleton(next)) return null;
250
+ const ids = new Set([...prevRegions.keys(), ...nextRegions.keys()]);
251
+ const changed: string[] = [];
252
+ for (const id of ids) {
253
+ if (prevRegions.get(id) !== nextRegions.get(id)) changed.push(id);
254
+ }
255
+ return changed.length ? changed : null;
256
+ }
@@ -125,6 +125,10 @@ export function AiBanner(): JSX.Element | null {
125
125
 
126
126
  // postMessage relay path — parent sends `ai-activity` events.
127
127
  const onMessage = (e: MessageEvent) => {
128
+ // DDR-078 security follow-up: only the trusted embedding parent relays
129
+ // ai-activity. Reject canvas self-posts faking a "Claude is editing"
130
+ // banner. Standalone (no parent) uses the own-WS path below instead.
131
+ if (e.source !== window.parent || window.parent === window) return;
128
132
  const m = e.data as { dgn?: string; file?: string; entry?: AiEntry | null } | null;
129
133
  if (!m || typeof m !== 'object') return;
130
134
  if (m.dgn !== 'ai-activity') return;