@1agh/maude 0.15.0 → 0.16.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/README.md CHANGED
@@ -1,9 +1,7 @@
1
- # Maude — Claude Code marketplace
1
+ # Maude
2
2
 
3
3
  A personal marketplace of Claude Code plugins. Two plugins today, plus a `maude` CLI for scaffolding and running the bundled dev tooling.
4
4
 
5
- > **Renamed from `md-claude`.** See [`docs/MIGRATING-MD-CLAUDE-TO-MAUDE.md`](./docs/MIGRATING-MD-CLAUDE-TO-MAUDE.md) if you came from the old name.
6
-
7
5
  > **📚 Full docs: https://maude.iagh.cz** (or browse the source under [`site/content/docs/`](./site/content/docs/) until the public URL lands).
8
6
  > Contributing? See [CONTRIBUTING.md](./CONTRIBUTING.md). Security? See [SECURITY.md](./SECURITY.md).
9
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.15.0",
3
+ "version": "0.16.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": {
@@ -39,13 +39,13 @@
39
39
  "prepublishOnly": "bash scripts/check-version-parity.sh"
40
40
  },
41
41
  "optionalDependencies": {
42
- "@1agh/maude-darwin-arm64": "0.15.0",
43
- "@1agh/maude-darwin-x64": "0.15.0",
44
- "@1agh/maude-linux-arm64": "0.15.0",
45
- "@1agh/maude-linux-arm64-musl": "0.15.0",
46
- "@1agh/maude-linux-x64": "0.15.0",
47
- "@1agh/maude-linux-x64-musl": "0.15.0",
48
- "@1agh/maude-win32-x64": "0.15.0"
42
+ "@1agh/maude-darwin-arm64": "0.16.0",
43
+ "@1agh/maude-darwin-x64": "0.16.0",
44
+ "@1agh/maude-linux-arm64": "0.16.0",
45
+ "@1agh/maude-linux-arm64-musl": "0.16.0",
46
+ "@1agh/maude-linux-x64": "0.16.0",
47
+ "@1agh/maude-linux-x64-musl": "0.16.0",
48
+ "@1agh/maude-win32-x64": "0.16.0"
49
49
  },
50
50
  "files": [
51
51
  "cli",
@@ -78,6 +78,19 @@ async function findFiles(absRoot: string, prefix: string, exts: string[]): Promi
78
78
 
79
79
  // ---------- Comments ----------
80
80
 
81
+ /**
82
+ * Phase 6 — single reply on a comment thread. `id` is `r_<hex>`; persists inside
83
+ * the parent `Comment.thread[]`. Bodies are bounded the same way as comment
84
+ * bodies (4000 chars), and `@handle` tokens in `body` flow into the parent's
85
+ * `mentions[]` union.
86
+ */
87
+ export interface Reply {
88
+ id: string;
89
+ author: string;
90
+ body: string;
91
+ created: string;
92
+ }
93
+
81
94
  export interface Comment {
82
95
  id: string;
83
96
  file: string;
@@ -91,6 +104,19 @@ export interface Comment {
91
104
  status: 'open' | 'resolved';
92
105
  created: string;
93
106
  resolved_at: string | null;
107
+ // Phase 6 — author + threading + mentions. Default-filled on read for legacy
108
+ // comments missing these fields (see `loadCommentsForFile`); persisted on next
109
+ // write. `author` defaults to the local `git config user.name` resolved at
110
+ // create time, `thread` to `[]`, `mentions` to `[]`.
111
+ author: string;
112
+ thread: Reply[];
113
+ mentions: string[];
114
+ }
115
+
116
+ export interface GitCommitter {
117
+ name: string;
118
+ email: string;
119
+ commits: number;
94
120
  }
95
121
 
96
122
  export interface Api {
@@ -102,6 +128,9 @@ export interface Api {
102
128
  commentsAdd(payload: Partial<Comment> & { file: string; text: string }): Promise<Comment | null>;
103
129
  commentsPatch(id: string, patch: Partial<Comment>): Promise<Comment | null>;
104
130
  commentsDelete(id: string): Promise<boolean>;
131
+ commentsAddReply(id: string, payload: { body: string; author?: string }): Promise<Comment | null>;
132
+ gitCommitters(): Promise<GitCommitter[]>;
133
+ parseMentions(text: string): string[];
105
134
  // Canvas state
106
135
  loadCanvasState(file: string): Promise<Record<string, unknown> | null>;
107
136
  saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
@@ -147,12 +176,26 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
147
176
  try {
148
177
  const raw = await Bun.file(commentsPath(file)).text();
149
178
  const arr = JSON.parse(raw);
150
- return Array.isArray(arr) ? arr : [];
179
+ if (!Array.isArray(arr)) return [];
180
+ // Phase 6 — default-fill `author` / `thread` / `mentions` for legacy
181
+ // rows. No write-back here; the on-disk shape stays stable until the
182
+ // next mutation persists the upgraded record.
183
+ return arr.map(backfillComment);
151
184
  } catch {
152
185
  return [];
153
186
  }
154
187
  }
155
188
 
189
+ function backfillComment(raw: unknown): Comment {
190
+ const c = (raw ?? {}) as Partial<Comment>;
191
+ return {
192
+ ...(c as Comment),
193
+ author: typeof c.author === 'string' ? c.author : '',
194
+ thread: Array.isArray(c.thread) ? c.thread : [],
195
+ mentions: Array.isArray(c.mentions) ? c.mentions : [],
196
+ };
197
+ }
198
+
156
199
  async function saveCommentsForFile(file: string, list: Comment[]) {
157
200
  await Bun.write(commentsPath(file), JSON.stringify(list, null, 2));
158
201
  }
@@ -172,7 +215,8 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
172
215
  const arr = JSON.parse(raw);
173
216
  if (!Array.isArray(arr) || arr.length === 0) continue;
174
217
  const file = arr[0]?.file as string | undefined;
175
- if (file) out[file] = arr;
218
+ // Backfill legacy rows so callers see the v2 shape uniformly.
219
+ if (file) out[file] = arr.map(backfillComment);
176
220
  } catch {
177
221
  /* ignore */
178
222
  }
@@ -184,10 +228,111 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
184
228
  return `c_${crypto.randomBytes(6).toString('hex')}`;
185
229
  }
186
230
 
231
+ function newReplyId(): string {
232
+ return `r_${crypto.randomBytes(6).toString('hex')}`;
233
+ }
234
+
235
+ // ---------- Git author resolution ----------
236
+ //
237
+ // Author defaults flow from `git config user.name` resolved against the
238
+ // repo root. Cached for the lifetime of the process — the local git
239
+ // identity doesn't shift mid-session and `Bun.spawn` is cheap-but-not-free.
240
+
241
+ let cachedGitUser: string | null = null;
242
+ let cachedGitUserAttempted = false;
243
+ async function gitCurrentUser(): Promise<string> {
244
+ if (cachedGitUserAttempted) return cachedGitUser ?? '';
245
+ cachedGitUserAttempted = true;
246
+ try {
247
+ const proc = Bun.spawn(['git', 'config', 'user.name'], {
248
+ cwd: paths.repoRoot,
249
+ stdout: 'pipe',
250
+ stderr: 'pipe',
251
+ });
252
+ const out = await new Response(proc.stdout).text();
253
+ await proc.exited;
254
+ const name = out.trim();
255
+ cachedGitUser = name || null;
256
+ } catch {
257
+ cachedGitUser = null;
258
+ }
259
+ return cachedGitUser ?? '';
260
+ }
261
+
262
+ // `git shortlog -sne` against the repo head — cached for 60 s so the
263
+ // @mention popup doesn't re-fork git on every keystroke.
264
+ let cachedCommitters: GitCommitter[] | null = null;
265
+ let cachedCommittersAt = 0;
266
+ async function gitCommitters(): Promise<GitCommitter[]> {
267
+ const now = Date.now();
268
+ if (cachedCommitters && now - cachedCommittersAt < 60_000) return cachedCommitters;
269
+ try {
270
+ const proc = Bun.spawn(['git', 'shortlog', '-sne', 'HEAD'], {
271
+ cwd: paths.repoRoot,
272
+ stdout: 'pipe',
273
+ stderr: 'pipe',
274
+ });
275
+ const text = await new Response(proc.stdout).text();
276
+ await proc.exited;
277
+ const lines = text
278
+ .split('\n')
279
+ .map((l) => l.trim())
280
+ .filter(Boolean)
281
+ .slice(0, 20);
282
+ const out: GitCommitter[] = [];
283
+ for (const line of lines) {
284
+ // Format: `<spaces><count>\t<name> <<email>>`
285
+ const m = line.match(/^(\d+)\s+(.+?)\s+<([^>]+)>$/);
286
+ if (!m) continue;
287
+ const commits = Number(m[1]);
288
+ const name = m[2]?.trim() ?? '';
289
+ const email = m[3]?.trim() ?? '';
290
+ if (!name) continue;
291
+ out.push({ name, email, commits });
292
+ }
293
+ cachedCommitters = out;
294
+ cachedCommittersAt = now;
295
+ return out;
296
+ } catch {
297
+ cachedCommitters = cachedCommitters ?? [];
298
+ cachedCommittersAt = now;
299
+ return cachedCommitters;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Extract `@handle` tokens from free text. Deduped, returns the literal
305
+ * `@name` form (matching what the autocomplete inserts), so a comment with
306
+ * `"@ada @lin @ada"` collapses to `["@ada","@lin"]`.
307
+ */
308
+ function parseMentions(text: string): string[] {
309
+ const out: string[] = [];
310
+ const seen = new Set<string>();
311
+ if (typeof text !== 'string' || !text) return out;
312
+ const re = /@[\w][\w.-]*/g;
313
+ for (const m of text.matchAll(re)) {
314
+ const tok = m[0];
315
+ if (!tok || seen.has(tok)) continue;
316
+ seen.add(tok);
317
+ out.push(tok);
318
+ }
319
+ return out;
320
+ }
321
+
322
+ function mentionsUnion(c: Comment): string[] {
323
+ const all = [c.text, ...c.thread.map((r) => r.body)].join('\n');
324
+ return parseMentions(all);
325
+ }
326
+
187
327
  async function commentsAdd(payload: Partial<Comment> & { file: string; text: string }) {
188
328
  if (!payload || typeof payload.file !== 'string' || !payload.file) return null;
189
329
  if (typeof payload.text !== 'string' || !payload.text.trim()) return null;
190
330
  const list = await loadCommentsForFile(payload.file);
331
+ const text = String(payload.text).trim().slice(0, 4000);
332
+ const author =
333
+ typeof payload.author === 'string' && payload.author.trim()
334
+ ? payload.author.trim().slice(0, 120)
335
+ : await gitCurrentUser();
191
336
  const c: Comment = {
192
337
  id: newCommentId(),
193
338
  file: payload.file,
@@ -197,10 +342,13 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
197
342
  classes: String(payload.classes || ''),
198
343
  bounds: payload.bounds ?? null,
199
344
  html_excerpt: String(payload.html_excerpt || '').slice(0, 2000),
200
- text: String(payload.text).trim().slice(0, 4000),
345
+ text,
201
346
  status: 'open',
202
347
  created: new Date().toISOString(),
203
348
  resolved_at: null,
349
+ author,
350
+ thread: [],
351
+ mentions: parseMentions(text),
204
352
  };
205
353
  list.push(c);
206
354
  await saveCommentsForFile(payload.file, list);
@@ -208,6 +356,37 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
208
356
  return c;
209
357
  }
210
358
 
359
+ async function commentsAddReply(
360
+ id: string,
361
+ payload: { body: string; author?: string }
362
+ ): Promise<Comment | null> {
363
+ if (!payload || typeof payload.body !== 'string' || !payload.body.trim()) return null;
364
+ const all = await loadAllComments();
365
+ for (const [file, list] of Object.entries(all)) {
366
+ const i = list.findIndex((c) => c.id === id);
367
+ if (i < 0) continue;
368
+ const entry = list[i];
369
+ if (!entry) continue;
370
+ const body = payload.body.trim().slice(0, 4000);
371
+ const author =
372
+ typeof payload.author === 'string' && payload.author.trim()
373
+ ? payload.author.trim().slice(0, 120)
374
+ : await gitCurrentUser();
375
+ const reply: Reply = {
376
+ id: newReplyId(),
377
+ author,
378
+ body,
379
+ created: new Date().toISOString(),
380
+ };
381
+ entry.thread = [...entry.thread, reply];
382
+ entry.mentions = mentionsUnion(entry);
383
+ await saveCommentsForFile(file, list);
384
+ onCommentsChanged(file);
385
+ return entry;
386
+ }
387
+ return null;
388
+ }
389
+
211
390
  async function commentsPatch(id: string, patch: Partial<Comment>) {
212
391
  const all = await loadAllComments();
213
392
  for (const [file, list] of Object.entries(all)) {
@@ -221,6 +400,7 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
221
400
  }
222
401
  if (typeof patch.text === 'string' && patch.text.trim()) {
223
402
  entry.text = patch.text.trim().slice(0, 4000);
403
+ entry.mentions = mentionsUnion(entry);
224
404
  }
225
405
  await saveCommentsForFile(file, list);
226
406
  onCommentsChanged(file);
@@ -662,6 +842,9 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
662
842
  commentsAdd,
663
843
  commentsPatch,
664
844
  commentsDelete,
845
+ commentsAddReply,
846
+ gitCommitters,
847
+ parseMentions,
665
848
  loadCanvasState,
666
849
  saveCanvasState,
667
850
  loadCanvasMeta,
@@ -42,6 +42,7 @@ import {
42
42
  type ViewportControllerHandle,
43
43
  useViewportControllerContext,
44
44
  } from './canvas-lib.tsx';
45
+ import { CommentsOverlay } from './comments-overlay.tsx';
45
46
  import {
46
47
  ContextMenuProvider,
47
48
  type ContextRegistry,
@@ -469,17 +470,89 @@ function CanvasRouter({
469
470
  setHoverEl(null);
470
471
  },
471
472
  onDropComment: ({ clientX, clientY }) => {
472
- const target = resolveHoverTarget(document, clientX, clientY, { deep: true });
473
- if (!target) return;
473
+ // First try deep mode — preferred when the user clicks exactly on
474
+ // a stamped element. When the deep hit lands on an element with
475
+ // `pointer-events: none` (decorative <svg> children, overlay icons),
476
+ // elementFromPoint propagates past it and `resolveHoverTarget`
477
+ // returns null because the next-closest hit is `.dc-artboard-body`
478
+ // itself.
479
+ let target = resolveHoverTarget(document, clientX, clientY, { deep: true });
480
+ if (!target) target = resolveHoverTarget(document, clientX, clientY, { deep: false });
481
+ // Phase 6 fallback — when both resolveHoverTarget passes bail (the
482
+ // `hit === bodyEl` early-exit triggers on `pointer-events: none`
483
+ // decorations), enumerate every element under the click point and
484
+ // climb the first one that has `data-cd-id`. This is how clicks on
485
+ // SVG logos / icon glyphs land on the actual stamped wrapper.
486
+ if (!target && typeof document.elementsFromPoint === 'function') {
487
+ const stack = document.elementsFromPoint(clientX, clientY);
488
+ for (const candidate of stack) {
489
+ const stamped = (candidate as Element).closest?.('[data-cd-id]') as HTMLElement | null;
490
+ if (!stamped) continue;
491
+ if (!stamped.closest('.dc-artboard-body')) continue;
492
+ const artboardEl = stamped.closest('[data-dc-screen]');
493
+ target = {
494
+ el: stamped,
495
+ cdId: stamped.getAttribute('data-cd-id'),
496
+ artboardId: artboardEl?.getAttribute('data-dc-screen') ?? null,
497
+ };
498
+ break;
499
+ }
500
+ }
501
+ if (!target) {
502
+ // Floating comment fallback — no element anchor, just a click
503
+ // point. The overlay still renders a pin at the stored bounds.
504
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
505
+ const floatingSel: Selection = {
506
+ file: deriveFile(),
507
+ id: undefined,
508
+ selector: '',
509
+ artboardId: null,
510
+ tag: '',
511
+ classes: '',
512
+ text: '',
513
+ dom_path: [],
514
+ bounds: { x: clientX - 12, y: clientY - 12, w: 24, h: 24 },
515
+ html: '',
516
+ };
517
+ try {
518
+ document.dispatchEvent(
519
+ new CustomEvent('cm:open-composer', {
520
+ detail: { selection: floatingSel, clientX, clientY },
521
+ })
522
+ );
523
+ } catch {
524
+ /* ignore */
525
+ }
526
+ try {
527
+ window.parent.postMessage({ dgn: 'comment-compose', selection: floatingSel }, '*');
528
+ } catch {
529
+ /* parent detached */
530
+ }
531
+ return;
532
+ }
474
533
  const sel = hoverTargetToSelection(target);
475
534
  // Commit the target to the selection set so the halo persists while
476
- // the shell-side composer is open. The user clears by:
477
- // - submit / cancel on the composer (shell posts `force-clear`)
535
+ // the composer is open. The user clears by:
536
+ // - submit / cancel on the composer (overlay dispatches force-clear)
478
537
  // - pressing Esc inside the canvas (router's onEscape → clear)
479
538
  // - clicking another element in comment mode (this handler runs
480
539
  // again and replaces)
481
540
  selSet.replace(sel);
482
- if (typeof window === 'undefined') return;
541
+ if (typeof window === 'undefined' || typeof document === 'undefined') return;
542
+ // Phase 6 — open the in-place composer inside the iframe at the click
543
+ // point. Custom event is iframe-local so the overlay can subscribe
544
+ // without round-tripping through the parent shell.
545
+ try {
546
+ document.dispatchEvent(
547
+ new CustomEvent('cm:open-composer', {
548
+ detail: { selection: sel, clientX, clientY },
549
+ })
550
+ );
551
+ } catch {
552
+ /* CustomEvent absent — fall through to legacy parent path */
553
+ }
554
+ // Still post to parent for back-compat with any legacy `.html` mocks
555
+ // whose inspector script consumes `comment-compose`.
483
556
  try {
484
557
  window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
485
558
  } catch {
@@ -492,6 +565,7 @@ function CanvasRouter({
492
565
  return (
493
566
  <>
494
567
  {children}
568
+ <CommentsOverlay />
495
569
  <AnnotationsLayer />
496
570
  <ToolPalette />
497
571
  <HoverHalo el={hoverEl} />