@1agh/maude 0.26.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.
- package/cli/commands/design-link.test.mjs +46 -0
- package/cli/commands/doctor.mjs +110 -5
- package/cli/commands/doctor.test.mjs +52 -0
- package/cli/lib/design-link.mjs +38 -8
- package/package.json +8 -8
- package/plugins/design/dev-server/activity.ts +256 -0
- package/plugins/design/dev-server/ai-banner.tsx +4 -0
- package/plugins/design/dev-server/artboard-activity-overlay.tsx +67 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +164 -9
- package/plugins/design/dev-server/canvas-lib.tsx +62 -15
- package/plugins/design/dev-server/config.schema.json +2 -1
- package/plugins/design/dev-server/dist/client.bundle.js +18 -18
- package/plugins/design/dev-server/dist/comment-mount.js +82 -4
- package/plugins/design/dev-server/inspect.ts +31 -1
- package/plugins/design/dev-server/participants-chrome.tsx +86 -1
- package/plugins/design/dev-server/server.ts +10 -1
- package/plugins/design/dev-server/sync/agent.ts +23 -8
- package/plugins/design/dev-server/sync/index.ts +24 -19
- package/plugins/design/dev-server/test/activity.test.ts +195 -0
- package/plugins/design/dev-server/test/artboard-activity-overlay.test.tsx +56 -0
- package/plugins/design/dev-server/test/canvas-hmr-runtime.test.tsx +114 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +28 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +34 -6
- package/plugins/design/dev-server/test/use-agent-presence.test.tsx +114 -0
- package/plugins/design/dev-server/test/use-canvas-activity.test.tsx +206 -0
- package/plugins/design/dev-server/use-agent-presence.tsx +244 -0
- package/plugins/design/dev-server/use-canvas-activity.tsx +252 -0
- package/plugins/design/dev-server/ws.ts +21 -2
- 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']);
|
package/cli/commands/doctor.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
+
});
|
package/cli/lib/design-link.mjs
CHANGED
|
@@ -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, {
|
|
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-
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
121
|
+
// DDR-079 — TSX 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
|
-
...(
|
|
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:
|
|
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.
|
|
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.
|
|
45
|
-
"@1agh/maude-darwin-x64": "0.
|
|
46
|
-
"@1agh/maude-linux-arm64": "0.
|
|
47
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
48
|
-
"@1agh/maude-linux-x64": "0.
|
|
49
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
50
|
-
"@1agh/maude-win32-x64": "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;
|