@1agh/maude 0.43.0 → 0.45.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 (126) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/commands/init.mjs +7 -1
  117. package/cli/lib/gitignore-block.mjs +1 -0
  118. package/cli/lib/pkg-root.mjs +107 -0
  119. package/cli/lib/pkg-root.test.mjs +79 -0
  120. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  121. package/cli/lib/update-check.mjs +8 -0
  122. package/package.json +12 -10
  123. package/plugins/design/dependencies.json +18 -0
  124. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  125. package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -0
@@ -42,9 +42,15 @@ export function ChromeVisibilityProvider({
42
42
  children: ReactNode;
43
43
  initial?: Partial<ChromeVisibilityState>;
44
44
  }) {
45
+ // Defaults MUST match the app-shell's own default view-prefs (app.jsx
46
+ // MINIMAP_STORE / ZOOMCTL_STORE both default false) — otherwise a freshly
47
+ // loaded canvas shows the minimap/zoom before (or instead of, when the seed
48
+ // races the early inline-script `dgn:'loaded'`) the shell's OFF state is
49
+ // applied, so a user with both toggled off still sees them. `present` is a
50
+ // transient overlay, never persisted, so it stays false.
45
51
  const [state, setState] = useState<ChromeVisibilityState>({
46
- minimap: initial?.minimap ?? true,
47
- zoom: initial?.zoom ?? true,
52
+ minimap: initial?.minimap ?? false,
53
+ zoom: initial?.zoom ?? false,
48
54
  present: initial?.present ?? false,
49
55
  });
50
56
  const setChrome = useCallback((patch: Partial<ChromeVisibilityState>) => {
@@ -1,6 +1,102 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "bring-your-brand",
6
+ "version": "0.44.0",
7
+ "date": "2026-07-14",
8
+ "kind": "feature",
9
+ "title": "Bring your existing brand into Maude",
10
+ "summary": "Have a logo already? Upload it from the Quick setup checklist and Maude pulls out its color palette (and any recognizable font names) to seed a new design system — nothing is applied automatically, you confirm every choice during setup. Works with SVG logos; safety-checked before anything touches your project.",
11
+ "surface": "design-ui",
12
+ "tour": [
13
+ {
14
+ "target": "[data-testid=\"onboarding-bring-brand\"]",
15
+ "title": "Bring my existing brand",
16
+ "body": "Upload a logo SVG to seed a new design system's colors and fonts from it."
17
+ }
18
+ ]
19
+ },
20
+ {
21
+ "id": "inspector-designer-mode",
22
+ "version": "0.44.0",
23
+ "date": "2026-07-14",
24
+ "kind": "feature",
25
+ "title": "A Figma-friendly view of the CSS panel",
26
+ "summary": "The Inspector now speaks two languages. Advanced keeps the real CSS property names; Designer regroups the exact same controls into Figma-familiar sections — Fill, Stroke, Corner radius, Auto layout, Effects, Opacity, Text — and relabels the rows, so you can style without thinking in CSS. Flip between them from the small toggle in the panel's top-right corner, or set a default in Settings → Appearance. Same live edits underneath, and a value set in one view reads back correctly in the other. New in both views: Blur and Blend controls, plus a single 9-point pad for auto-layout alignment.",
27
+ "surface": "design-ui",
28
+ "tour": [
29
+ {
30
+ "target": "[data-tour=\"cp-mode\"]",
31
+ "title": "Advanced or Designer",
32
+ "body": "Toggle the CSS panel between raw property names and Figma-style vocabulary. Your choice is remembered — you can also set it in Settings → Appearance.",
33
+ "placement": "left",
34
+ "inspector": true,
35
+ "tab": "css",
36
+ "requireSelection": true
37
+ }
38
+ ]
39
+ },
40
+ {
41
+ "id": "watch-the-intro",
42
+ "version": "0.44.0",
43
+ "date": "2026-07-14",
44
+ "kind": "feature",
45
+ "title": "Watch the intro, right inside Maude",
46
+ "summary": "There's now a short product showreel (about a minute and a half) you can watch without leaving the app — Help → Watch the intro, or the same button on the welcome screen the first time you open Maude. It walks through the whole loop: a design system built from a conversation, the infinite canvas, pointing and commenting to direct an AI edit, live multiplayer, and shipping the result. Also lands as its own canvas (Maude Explainer) if you want to open it in the canvas browser like anything else.",
47
+ "surface": "design-ui",
48
+ "tour": [
49
+ {
50
+ "target": "[data-tour=\"help\"]",
51
+ "title": "Watch the intro",
52
+ "body": "Help → Watch the intro plays the full showreel without leaving Maude."
53
+ }
54
+ ]
55
+ },
56
+ {
57
+ "id": "bulk-media-insert",
58
+ "version": "0.44.0",
59
+ "date": "2026-07-14",
60
+ "kind": "feature",
61
+ "title": "Insert several photos at once",
62
+ "summary": "The media picker now supports multi-select — pick several photos (or a mix of photos, videos, and audio) in one go instead of one at a time. Choose whether they land inside your artboard or float on the canvas as annotations; the picker figures out the sensible default (video and audio always go on the canvas, since only images can drop into an artboard) and lets you switch. Dragging several files from Finder onto the canvas at once is also more reliable now — every dropped file lands, even in a large batch.",
63
+ "surface": "design-ui"
64
+ },
65
+ {
66
+ "id": "ai-video-generation",
67
+ "version": "0.44.0",
68
+ "date": "2026-07-14",
69
+ "kind": "feature",
70
+ "title": "Generate video clips for your reels",
71
+ "summary": "The BYOK generator now makes video. Bring your own Google key and generate a clip with Veo — a text prompt (\"slow drone push over an alpine lake at dawn\"), or seed it from a still you already generated so the clip matches your hero's look. The clip lands in your assets like any other footage, and Maude treats it as first-class: when you build a reel it watches the generated clip alongside your real footage and places it as a beat, so you can fill a missing shot without hand-sourcing one. Video takes a few minutes and runs in the background — you'll get the finished clip when it's ready. Add your key under File → Settings — AI generation (⌘,); it stays on your machine.",
72
+ "surface": "design-ui"
73
+ },
74
+ {
75
+ "id": "ai-audio-generation",
76
+ "version": "0.44.0",
77
+ "date": "2026-07-14",
78
+ "kind": "feature",
79
+ "title": "Generate music, voiceover & subtitles",
80
+ "summary": "The BYOK generator now does audio. Bring your own ElevenLabs key and generate a music bed, sound effects, or a text-to-speech voiceover — one key covers the whole stack — and the track lands in your assets ready to drop under a reel. Before you spend credits on a music or SFX track, Maude searches audio you've already made (and your own ElevenLabs history — re-downloading it costs nothing) and offers a match to reuse. Subtitles are free and need no key at all: `maude design transcribe` runs whisper.cpp locally on any clip and writes word-timed SRT/VTT captions. Local setup is one click — Settings → Subtitles → Download model — and Maude auto-converts video containers (no ffmpeg wrangling) and picks the model for you. You pick the subtitle engine — local whisper, ElevenLabs Scribe, or Groq — in Settings; it's an explicit choice, so Maude never silently switches to a paid cloud engine behind your back. In a reel, generated music/voiceover become layered audio tracks (the music ducks under the voice) and the subtitles become a caption track that renders right on the video. Add your keys under File → Settings — AI generation (⌘,); they're stored only on your machine.",
81
+ "surface": "design-ui"
82
+ },
83
+ {
84
+ "id": "ai-media-generation",
85
+ "version": "0.44.0",
86
+ "date": "2026-07-14",
87
+ "kind": "feature",
88
+ "title": "Generate images with your own AI key",
89
+ "summary": "Bring your own Google (Nano Banana) API key and generate images right inside Maude — and the image lands straight on your canvas, not in a dead-end dialog. Use the Generate action (⌘K → \"Generate with AI\"), the command line, or just ask in the chat panel (\"generate a hero image of a mountain lake\"); the finished picture drops onto the artboard you're looking at. Add your key under File → Settings — AI generation (⌘,); it's stored only on your machine, sent straight to the provider, and never touches a canvas or your git history. Want to change a generated image? Ask to edit it (\"make the sky purple\") for a fresh AI-edited version, or fine-tune it in the Photo tab — every result is a normal asset you can place, edit, and export. Audio and video generation are on the way.",
90
+ "surface": "design-ui",
91
+ "tour": [
92
+ {
93
+ "target": "[data-tour=\"menubar-file\"]",
94
+ "title": "AI generation settings",
95
+ "body": "Open File → Settings — AI generation (⌘,) to add your provider key, then Generate with AI from the ⌘K palette.",
96
+ "placement": "bottom"
97
+ }
98
+ ]
99
+ },
4
100
  {
5
101
  "id": "photo-editor",
6
102
  "version": "0.43.0",
package/apps/studio/ws.ts CHANGED
@@ -222,6 +222,11 @@ export function createWs(
222
222
  // clients only — same privileged-data class as the rest of this feed.
223
223
  ctx.bus.on('export:job', (job: unknown) => broadcast({ type: 'export:job', payload: job }));
224
224
 
225
+ // feature-ai-media-generation (DDR-16x) — generation job queue state changes
226
+ // (queued → running → done/failed). Same privileged-data class + snapshot
227
+ // shape as export:job; the notification center reuses the export-center chrome.
228
+ ctx.bus.on('generate:job', (job: unknown) => broadcast({ type: 'generate:job', payload: job }));
229
+
225
230
  // HMR broadcaster — turns fs:any change events into `canvas-hmr` messages.
226
231
  // The iframe-side client (in _shell.html) decides reload strategy from `mode`.
227
232
  // Uses broadcastHmr so the segregated canvas origin's HMR-only sockets get it.
package/cli/bin/maude.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs';
3
- import { dirname, resolve } from 'node:path';
3
+ import { resolve } from 'node:path';
4
+ import { resolvePkgRoot } from '../lib/pkg-root.mjs';
4
5
  // maude — Maude CLI. Scaffold .ai workspace, run dev servers, manage config.
5
- import { fileURLToPath } from 'node:url';
6
6
  import { runUpdateCheck } from '../lib/update-check.mjs';
7
7
 
8
- const __filename = fileURLToPath(import.meta.url);
9
- const __dirname = dirname(__filename);
10
- const CLI_ROOT = resolve(__dirname, '..');
11
- const PKG_ROOT = resolve(CLI_ROOT, '..');
8
+ // DDR-166 T0b — real-disk resolution, safe inside a `bun build --compile`
9
+ // standalone binary (see pkg-root.mjs's own doc comment for why the previous
10
+ // `dirname(fileURLToPath(import.meta.url))` walk-up broke there).
11
+ const PKG_ROOT = resolvePkgRoot();
12
12
 
13
13
  const COMMANDS = {
14
14
  init: () => import('../commands/init.mjs'),
@@ -47,6 +47,23 @@ const BIN_VERBS = new Set([
47
47
  'chat-open',
48
48
  'ensure-browser',
49
49
  'fetch-asset',
50
+ // DDR-167 (Phase 3 / T10). `import-asset` is the LOCAL-file sibling of the
51
+ // URL-only `fetch-asset`: hardened SVG ingestion (allowlist DOM-sanitize +
52
+ // a real-browser execution canary). PDF import is wired but not yet
53
+ // available — see the DDR's addendum on why the planned rasterization
54
+ // mechanism doesn't work under browser automation.
55
+ 'import-asset',
56
+ // DDR-172 (Phase 3 / T11). `import-tokens` is the token-file sibling of
57
+ // `import-asset`: parses W3C design-tokens / Style-Dictionary JSON or raw
58
+ // CSS custom properties, maps recognized tokens onto the DS CSS-variable
59
+ // contract, and patches (theme-block-scoped) or scaffolds the target
60
+ // design system. CLI-only — no HTTP route, no in-app panel.
61
+ 'import-tokens',
62
+ // DDR-173 (Phase 3 / T12). `import-brand` extracts typed palette/font
63
+ // cues + a hardened logo asset from an already-DDR-167-sanitized SVG for
64
+ // `/design:setup-ds --from-brand` — never re-reads or re-sanitizes the
65
+ // original brand file (Decision 2: no parallel, ungated read path).
66
+ 'import-brand',
50
67
  // feature-photo-editor (Stage G). `photo-adjust` is the thin non-browser
51
68
  // parametric verb (curl → /_api/photo-edit). `photo-bg-remove` is the
52
69
  // client-side @imgly ML harness (throwaway proof canvas + agent-browser
@@ -60,6 +77,21 @@ const BIN_VERBS = new Set([
60
77
  // reads the clip as a file:// resource).
61
78
  'ingest-footage',
62
79
  'probe-footage',
80
+ // feature-ai-media-generation (Phase 0, DDR-16x). `generate` is the thin
81
+ // non-browser BYOK verb (curl → /_api/generate-jobs → poll → print the
82
+ // produced /assets/<sha8>.<ext>). The provider call happens server-side (the
83
+ // sidecar resolves the key from the keychain / ~/.config/maude/keys.json).
84
+ 'generate',
85
+ // feature-ai-media-generation (Phase 2, DDR-164). `transcribe` is the local,
86
+ // no-key subtitle verb: spawn whisper.cpp on an audio/video file → SRT/VTT via
87
+ // the shared captions.ts reflow. No dev server; whisper.cpp is a SOFT dep with
88
+ // ElevenLabs Scribe / Groq Whisper as the cloud fallback.
89
+ 'transcribe',
90
+ // feature-ai-media-generation (Task 2.5, DDR-164). `audio-search` is the
91
+ // reuse-before-you-pay verb: search the project's own generated audio + the
92
+ // user's ElevenLabs history before spending credits on a new music/SFX/VO
93
+ // track. Runs server-side (key resolved by the sidecar); needs a dev server.
94
+ 'audio-search',
63
95
  ]);
64
96
 
65
97
  // Bin verbs that boot the dev-server (directly, or by shelling into server-up.sh).
@@ -154,6 +186,7 @@ Dev-tooling (dispatch to the dev-server bash helpers — DDR-062):
154
186
  smoke · canvas-edit · handoff · asset-sweep · visual-sanity · fetch-asset
155
187
  draw-build · draw-proof · svg-optimize · to-lottie · read-annotations · annotate
156
188
  canvas-rects · ingest-footage · probe-footage
189
+ generate · transcribe · audio-search
157
190
  Invoke the bundled helper of the same name. maude resolves it from its
158
191
  own package root and sets CLAUDE_PLUGIN_ROOT for the child; stdout,
159
192
  stderr, and exit code pass straight through (so command-substitution
@@ -6,7 +6,13 @@ import { copyTree } from '../lib/copy-tree.mjs';
6
6
  const PLACEHOLDER = 'PROJECT_NAME';
7
7
  // Files in the skeleton that contain the project-name placeholder and should
8
8
  // be templated on copy.
9
- const TEMPLATED = ['workflows.config.json', 'README.md', 'INDEX.md', 'release-guide.md'];
9
+ const TEMPLATED = [
10
+ 'workflows.config.json',
11
+ 'README.md',
12
+ 'INDEX.md',
13
+ 'release-guide.md',
14
+ 'scenario-guide.md',
15
+ ];
10
16
 
11
17
  // Per-provider command substitutions for release-guide.md. Keys map to the
12
18
  // `# CHANGELOG_PROVIDER_*_CMD` placeholders inside the skeleton's bash blocks.
@@ -38,6 +38,7 @@ export function buildBlock(designRel = '.design') {
38
38
  `${root}/_preflight.json`,
39
39
  `${root}/_locator.json`, // regenerable slug→path index
40
40
  `${root}/_export-history.json`,
41
+ `${root}/_generate-history.json`, // AI-media generation job ledger (regenerable — feature-ai-media-generation, DDR-16x)
41
42
  // Per-machine / per-user dirs.
42
43
  `${root}/_export-jobs/`, // background-export job byte store (regenerable — feature-background-export-notification-center)
43
44
  `${root}/_state/`, // binary CRDT logs (regenerable from hub)
@@ -0,0 +1,107 @@
1
+ // pkg-root.mjs — real-disk `maude` package-root resolution, safe inside a
2
+ // `bun build --compile` standalone binary (DDR-166 T0b).
3
+ //
4
+ // `cli/bin/maude.mjs` originally computed PKG_ROOT via
5
+ // `dirname(fileURLToPath(import.meta.url))` walk-up — correct for the plain
6
+ // npm/node install, but the exact DDR-045 trap: inside a compiled binary,
7
+ // `import.meta.url` resolves to Bun's virtual filesystem (`/$bunfs/root` on
8
+ // POSIX, `B:/~BUN/root` on Windows), not the real on-disk location. Two
9
+ // dev-server releases (v0.18.0/v0.18.1) shipped broken because of this exact
10
+ // bug class before DDR-045 fixed it there. This module mirrors that fix
11
+ // (`apps/studio/paths.ts` `resolveDevServerRoot`) for the CLI, independently
12
+ // — not by importing paths.ts directly, to keep the CLI's compiled binary
13
+ // decoupled from the dev-server's module graph.
14
+
15
+ import { existsSync, realpathSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ function isVirtualBunfsPath(p) {
20
+ return p !== null && (p.startsWith('/$bunfs') || p.startsWith('B:/~BUN'));
21
+ }
22
+
23
+ /** True inside a `bun build --compile` standalone binary. Mirrors `apps/studio/paths.ts`'s `IS_COMPILED_BINARY`. */
24
+ export function isCompiledBinary() {
25
+ return isVirtualBunfsPath(getImportMetaDir());
26
+ }
27
+
28
+ function getImportMetaDir() {
29
+ try {
30
+ return dirname(fileURLToPath(import.meta.url));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Anchor: `apps/studio/bin/screenshot.sh` is present in every real maude
38
+ * package root (dev tree, npm tarball, and the Tauri desktop `Resources/`
39
+ * bundle all ship it) and nowhere else — unique enough to identify the
40
+ * package root without also requiring `package.json` (npm tarballs can drop
41
+ * nested workspace `package.json` files, the exact false-negative DDR-045's
42
+ * own retro already documents for the dev-server's analogous check).
43
+ */
44
+ export function isPkgRoot(dir) {
45
+ // Both anchors, not just one: `apps/studio/bin/screenshot.sh` ALONE is a
46
+ // false-positive inside the Tauri desktop build — `stage-resources.mjs`
47
+ // stages a full working copy of `apps/studio` (dev-server + bin scripts)
48
+ // into `target/debug/apps/studio/` for the sidecar's own runtime
49
+ // resolution, but that staged copy has no `cli/` — walking up from a
50
+ // compiled `maude` binary placed at `target/debug/maude` matched THAT
51
+ // copy first and stopped one level too early (caught by live testing
52
+ // against the real Tauri build, not a synthetic path). `cli/commands/
53
+ // design.mjs` only exists in a genuine package root.
54
+ return (
55
+ existsSync(join(dir, 'apps', 'studio', 'bin', 'screenshot.sh')) &&
56
+ existsSync(join(dir, 'cli', 'commands', 'design.mjs'))
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Resolve maude's own package root — real disk path, safe inside a compiled
62
+ * binary. Priority: (0) explicit override, (1) dev-mode `import.meta.url`
63
+ * (plain `node`/`bun run cli/bin/maude.mjs`), (2) compiled-binary walk-up
64
+ * from `process.execPath` (matches the npm-install layout: binary at
65
+ * `@1agh/maude-<platform>/maude` walks up to `@1agh/maude/`, which ships
66
+ * `apps/studio/` via `package.json` `files`; matches the Tauri desktop layout
67
+ * too, where the bundled `maude` binary and the `Resources/apps/studio`
68
+ * bundle share a common ancestor within the walk-up depth).
69
+ */
70
+ export function resolvePkgRoot() {
71
+ const override = process.env.MAUDE_PKG_ROOT;
72
+ if (override && !isVirtualBunfsPath(override) && isPkgRoot(override)) return override;
73
+
74
+ const importDir = getImportMetaDir();
75
+ if (importDir && !isVirtualBunfsPath(importDir)) {
76
+ // Dev-mode: cli/bin/maude.mjs is 2 levels under the package root.
77
+ const candidate = join(importDir, '..', '..');
78
+ if (isPkgRoot(candidate)) return candidate;
79
+ }
80
+
81
+ // `process.execPath` is the path AS INVOKED — when launched through a
82
+ // symlink (DDR-166 T0b stages exactly this: sidecar.rs's narrow bin-link
83
+ // dir), it's the symlink's own path, not the real target, and walking up
84
+ // from there (e.g. ~/Library/Caches/…/bin-link/) never reaches the repo
85
+ // root. Dereference first. Falls back to the raw path if the file somehow
86
+ // doesn't exist (shouldn't happen — we ARE that running process).
87
+ let cur;
88
+ try {
89
+ cur = dirname(realpathSync(process.execPath));
90
+ } catch {
91
+ cur = dirname(process.execPath);
92
+ }
93
+ for (let i = 0; i < 10; i++) {
94
+ if (isPkgRoot(cur)) return cur;
95
+ const parent = dirname(cur);
96
+ if (parent === cur) break;
97
+ cur = parent;
98
+ }
99
+
100
+ // Final fallback for unanchored test contexts — callers should expect
101
+ // existsSync-based lookups to fail and surface a clear error, same
102
+ // contract as paths.ts's own resolver. Never join a VIRTUAL importDir here
103
+ // (`/$bunfs/root/../..` collapses to `/` via path.join — a real bug an
104
+ // earlier draft of this function had, caught by testing against an
105
+ // unanchored compile output before wiring this into Tauri).
106
+ return dirname(process.execPath);
107
+ }
@@ -0,0 +1,79 @@
1
+ // pkg-root.mjs tests (DDR-166 T0b).
2
+ //
3
+ // The regression this locks down: a naive single-anchor check
4
+ // (`apps/studio/bin/screenshot.sh` alone) false-positives inside the Tauri
5
+ // desktop build, because `stage-resources.mjs` stages a full working copy of
6
+ // `apps/studio` into `target/debug/apps/studio/` for the sidecar's own
7
+ // runtime resolution — that staged copy has no `cli/`, so a compiled `maude`
8
+ // binary walking up from `target/debug/maude` matched it and stopped one
9
+ // level too early, resolving PKG_ROOT to `target/debug` instead of the real
10
+ // repo root. Caught by live testing against the real Tauri build (not a
11
+ // synthetic path), not by static review — worth a regression test.
12
+
13
+ import assert from 'node:assert/strict';
14
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { test } from 'node:test';
18
+
19
+ import { isPkgRoot, resolvePkgRoot } from './pkg-root.mjs';
20
+
21
+ function makeRealRoot(base) {
22
+ mkdirSync(join(base, 'apps', 'studio', 'bin'), { recursive: true });
23
+ writeFileSync(join(base, 'apps', 'studio', 'bin', 'screenshot.sh'), '#!/bin/sh\n');
24
+ mkdirSync(join(base, 'cli', 'commands'), { recursive: true });
25
+ writeFileSync(join(base, 'cli', 'commands', 'design.mjs'), 'export const run = () => {};\n');
26
+ }
27
+
28
+ test('isPkgRoot requires BOTH anchors — the staged-resources false-positive is rejected', () => {
29
+ const tmp = mkdtempSync(join(tmpdir(), 'maude-pkgroot-'));
30
+ try {
31
+ // The exact shape of the bug: apps/studio/bin/screenshot.sh present
32
+ // (mirrors stage-resources.mjs's staged copy), no cli/ at all.
33
+ mkdirSync(join(tmp, 'apps', 'studio', 'bin'), { recursive: true });
34
+ writeFileSync(join(tmp, 'apps', 'studio', 'bin', 'screenshot.sh'), '#!/bin/sh\n');
35
+ assert.equal(isPkgRoot(tmp), false);
36
+
37
+ // cli/ alone, no apps/studio — also not a real root.
38
+ const tmp2 = mkdtempSync(join(tmpdir(), 'maude-pkgroot-'));
39
+ try {
40
+ mkdirSync(join(tmp2, 'cli', 'commands'), { recursive: true });
41
+ writeFileSync(join(tmp2, 'cli', 'commands', 'design.mjs'), '');
42
+ assert.equal(isPkgRoot(tmp2), false);
43
+ } finally {
44
+ rmSync(tmp2, { recursive: true, force: true });
45
+ }
46
+
47
+ // Both anchors together — a genuine package root.
48
+ makeRealRoot(tmp);
49
+ assert.equal(isPkgRoot(tmp), true);
50
+ } finally {
51
+ rmSync(tmp, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ test('resolvePkgRoot honors MAUDE_PKG_ROOT only when it actually satisfies both anchors', () => {
56
+ const real = mkdtempSync(join(tmpdir(), 'maude-pkgroot-real-'));
57
+ const fake = mkdtempSync(join(tmpdir(), 'maude-pkgroot-fake-'));
58
+ const prevOverride = process.env.MAUDE_PKG_ROOT;
59
+ try {
60
+ makeRealRoot(real);
61
+ // Fake: only the staged-resources-shaped anchor, same false-positive shape.
62
+ mkdirSync(join(fake, 'apps', 'studio', 'bin'), { recursive: true });
63
+ writeFileSync(join(fake, 'apps', 'studio', 'bin', 'screenshot.sh'), '#!/bin/sh\n');
64
+
65
+ process.env.MAUDE_PKG_ROOT = real;
66
+ assert.equal(resolvePkgRoot(), real);
67
+
68
+ process.env.MAUDE_PKG_ROOT = fake;
69
+ // Falls through past the (rejected) override to whatever the real
70
+ // walk-up finds in THIS test process — just assert it's NOT the fake
71
+ // dir, since accepting it would be exactly the regression.
72
+ assert.notEqual(resolvePkgRoot(), fake);
73
+ } finally {
74
+ if (prevOverride === undefined) delete process.env.MAUDE_PKG_ROOT;
75
+ else process.env.MAUDE_PKG_ROOT = prevOverride;
76
+ rmSync(real, { recursive: true, force: true });
77
+ rmSync(fake, { recursive: true, force: true });
78
+ }
79
+ });
@@ -0,0 +1,194 @@
1
+ // DDR-174 regression guard (T15).
2
+ //
3
+ // The whole security architecture of `/design:import --reconstruct` rests on
4
+ // ONE fact: every agent turn that ever reads the untrusted source image —
5
+ // `reconstruct-agent` (authoring) AND `reconstruct-critic` (the reality-check
6
+ // comparator) — has `tools: Read, Write, Glob, Grep` and NEVER `Bash`,
7
+ // `WebSearch`, or `WebFetch`. DDR-174's own Consequences section calls for
8
+ // exactly this test: "a targeted grep-based test mirroring
9
+ // plugin-cli-reachability.test.mjs's pattern should assert BOTH the authoring
10
+ // agent's AND the comparator agent's frontmatter never gain
11
+ // Bash/WebSearch/WebFetch, not just this DDR's prose." A future maintainer
12
+ // "helpfully" widening either agent's toolset (e.g. "just give it Bash so it
13
+ // can screenshot itself") would silently reopen DDR-174's entire closure —
14
+ // this fails loudly instead.
15
+
16
+ import assert from 'node:assert/strict';
17
+ import { readFileSync } from 'node:fs';
18
+ import { test } from 'node:test';
19
+
20
+ const AGENTS = [
21
+ 'plugins/design/agents/reconstruct-agent.md',
22
+ 'plugins/design/agents/reconstruct-critic.md',
23
+ ];
24
+
25
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;
26
+
27
+ function readFrontmatter(path) {
28
+ const text = readFileSync(path, 'utf8');
29
+ const fm = FRONTMATTER_RE.exec(text);
30
+ assert.ok(fm, `${path}: no frontmatter block found`);
31
+ return fm[1];
32
+ }
33
+
34
+ function readToolsLine(path) {
35
+ const fm = readFrontmatter(path);
36
+ const toolsLine = fm.split('\n').find((l) => l.startsWith('tools:'));
37
+ assert.ok(toolsLine, `${path}: frontmatter has no tools: line`);
38
+ return toolsLine;
39
+ }
40
+
41
+ for (const path of AGENTS) {
42
+ test(`${path}: tools: line never grants Bash`, () => {
43
+ const line = readToolsLine(path);
44
+ assert.ok(
45
+ !/\bBash\b/.test(line),
46
+ `${path} declares Bash — this reopens DDR-174's core closure (the agent reads an untrusted image and must never hold a shell). Line: ${line}`
47
+ );
48
+ });
49
+
50
+ test(`${path}: tools: line never grants WebSearch or WebFetch`, () => {
51
+ const line = readToolsLine(path);
52
+ assert.ok(
53
+ !/\bWebSearch\b/.test(line) && !/\bWebFetch\b/.test(line),
54
+ `${path} declares WebSearch/WebFetch — DDR-174 requires these agents have no tool call that reaches the network. Line: ${line}`
55
+ );
56
+ });
57
+
58
+ test(`${path}: tools: line is exactly Read, Write, Glob, Grep`, () => {
59
+ const line = readToolsLine(path);
60
+ const tools = line
61
+ .replace(/^tools:\s*/, '')
62
+ .split(',')
63
+ .map((t) => t.trim())
64
+ .filter(Boolean);
65
+ assert.deepEqual(
66
+ [...tools].sort(),
67
+ ['Glob', 'Grep', 'Read', 'Write'],
68
+ `${path}: expected exactly Read/Write/Glob/Grep per DDR-174 Decision 1, got: ${line}`
69
+ );
70
+ });
71
+
72
+ // DDR-174 Addendum (post-implementation adversarial review) — Write has no
73
+ // path restriction (Claude Code has no per-subagent path-scoping mechanism,
74
+ // verified authoritatively, not assumed) and the only real closure for a
75
+ // non-bypassPermissions session is a human approval prompt on any write.
76
+ // `permissionMode: default` is what makes that prompt happen instead of
77
+ // silently inheriting the parent session's (possibly bypassPermissions)
78
+ // mode. A future maintainer removing this line silently re-widens the
79
+ // agent's effective write reach for every downstream consumer NOT running
80
+ // bypassPermissions.
81
+ test(`${path}: frontmatter declares permissionMode: default (DDR-174 Addendum)`, () => {
82
+ const fm = readFrontmatter(path);
83
+ const line = fm.split('\n').find((l) => l.startsWith('permissionMode:'));
84
+ assert.ok(
85
+ line,
86
+ `${path}: missing permissionMode: default — without it this agent silently inherits the parent session's permission mode (e.g. bypassPermissions), losing the one real per-write approval gate available for its unrestricted Write tool (DDR-174 Addendum)`
87
+ );
88
+ assert.equal(
89
+ line.replace(/^permissionMode:\s*/, '').trim(),
90
+ 'default',
91
+ `${path}: permissionMode must be exactly "default" (prompts on every write), not a looser mode. Line: ${line}`
92
+ );
93
+ });
94
+ }
95
+
96
+ test('import.md diff-check is whole-repo scoped, not just $DESIGN_ROOT (DDR-174 Addendum)', () => {
97
+ // The first implementation scoped `git status --porcelain -- "$DESIGN_ROOT"`,
98
+ // which made a write to .claude/settings.json, CLAUDE.md, or plugins/**
99
+ // structurally invisible to the check — an adversarial review built a full
100
+ // exploit chain out of exactly that gap. Assert the pathspec was actually
101
+ // widened, not just documented as widened in prose.
102
+ const importMd = readFileSync('plugins/design/commands/import.md', 'utf8');
103
+ assert.ok(
104
+ !/git -C "\$REPO" status --porcelain -- "\$DESIGN_ROOT"/.test(importMd),
105
+ 'import.md must NOT scope the diff-check to $DESIGN_ROOT only — this was the exact gap the DDR-174 Addendum closes (a write outside $DESIGN_ROOT, e.g. to .claude/settings.json, would be invisible to a $DESIGN_ROOT-scoped check)'
106
+ );
107
+ const wholeRepoOccurrences = (importMd.match(/git -C "\$REPO" status --porcelain\b/g) || [])
108
+ .length;
109
+ assert.ok(
110
+ wholeRepoOccurrences >= 2,
111
+ `import.md must run an UNSCOPED \`git -C "$REPO" status --porcelain\` for both the BEFORE (step 4) and AFTER (step 5b) snapshots — found ${wholeRepoOccurrences} occurrence(s)`
112
+ );
113
+ });
114
+
115
+ test('import.md snapshots the two well-known global Claude Code config paths (DDR-174 Addendum)', () => {
116
+ // A repo-scoped git diff can never see $HOME/.claude/** — these are the two
117
+ // specific files an adversarial review named as attack targets (a planted
118
+ // hook, or a poisoned global CLAUDE.md loaded into every future session).
119
+ const importMd = readFileSync('plugins/design/commands/import.md', 'utf8');
120
+ assert.ok(
121
+ importMd.includes('$HOME/.claude/settings.json') &&
122
+ importMd.includes('$HOME/.claude/CLAUDE.md'),
123
+ 'import.md must snapshot $HOME/.claude/settings.json and $HOME/.claude/CLAUDE.md before/after each round and hard-fail on any change (DDR-174 Addendum) — these are outside any repo-scoped diff check'
124
+ );
125
+ });
126
+
127
+ test('import.md stat calls are portable (GNU-first, BSD-fallback), not BSD-only (DDR-174 Addendum confirmation pass)', () => {
128
+ // The first Addendum revision used BSD-only `stat -f` unconditionally — it
129
+ // silently no-ops on Linux (a platform this project ships a .deb for),
130
+ // because GNU `stat -f` means something else entirely and errors out,
131
+ // swallowed by the trailing `2>/dev/null`. Both empty strings compare
132
+ // equal, so the check trivially "passes" without ever having checked
133
+ // anything. Assert every stat call tries GNU syntax (`stat -c`) first.
134
+ const importMd = readFileSync('plugins/design/commands/import.md', 'utf8');
135
+ // Only scan actual shell inside fenced ```bash blocks — prose elsewhere
136
+ // legitimately mentions `stat -f` by name when explaining the old bug.
137
+ const codeBlocks = [...importMd.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1]).join('\n');
138
+ const bareStatF = codeBlocks.match(/(?<!\|\| )\bstat -f\b/g) || [];
139
+ assert.deepEqual(
140
+ bareStatF,
141
+ [],
142
+ `import.md has a \`stat -f\` call in executable shell with no \`stat -c\` GNU fallback tried first — this silently no-ops on Linux (DDR-174 Addendum confirmation-pass finding). Found ${bareStatF.length} bare occurrence(s).`
143
+ );
144
+ const statCOccurrences = (codeBlocks.match(/stat -c '/g) || []).length;
145
+ assert.ok(
146
+ statCOccurrences >= 4,
147
+ `expected at least 4 GNU-first \`stat -c\` attempts (2 for the non-git repo-wide fallback, 2 for the global-config snapshot) — found ${statCOccurrences}`
148
+ );
149
+ });
150
+
151
+ test('import.md non-git fallback excludes _history/ (DDR-174 Addendum confirmation pass)', () => {
152
+ // The reconstruct loop writes its own screenshots/verdict files under
153
+ // _history/_reconstruct/<slug>/ every round. A git-repo target gets this
154
+ // excluded for free via .gitignore; the non-git `find`-based fallback has
155
+ // no such filter and would false-positive-abort on its own round-1 output
156
+ // the moment round 2's diff check runs, in a non-git target repo.
157
+ const importMd = readFileSync('plugins/design/commands/import.md', 'utf8');
158
+ const findOccurrences = importMd.match(/find "\$REPO" -type f[^\n]*/g) || [];
159
+ assert.ok(
160
+ findOccurrences.length >= 2,
161
+ 'expected the non-git fallback find command in both step 4 and step 5b'
162
+ );
163
+ for (const line of findOccurrences) {
164
+ assert.ok(
165
+ line.includes("-not -path '*/_history/*'"),
166
+ `non-git fallback find command must exclude _history/ (its own round-N artifacts) or every multi-round run in a non-git target repo false-positive-aborts: ${line}`
167
+ );
168
+ }
169
+ });
170
+
171
+ test('import.md uses mktemp for the _active.json patch, not a fixed /tmp filename (DDR-174 Addendum)', () => {
172
+ const importMd = readFileSync('plugins/design/commands/import.md', 'utf8');
173
+ assert.ok(
174
+ !/\/tmp\/active\.json\.tmp/.test(importMd),
175
+ 'import.md must not use a fixed, predictable /tmp filename for the _active.json patch — a symlink race target on a shared machine (DDR-174 Addendum adversarial finding)'
176
+ );
177
+ assert.ok(/mktemp/.test(importMd), 'import.md must use mktemp for its temp file(s)');
178
+ });
179
+
180
+ test('design-critic.md is never repurposed as the reconstruct comparator (DDR-174 Decision 1 Round-2)', () => {
181
+ // The comparator must be reconstruct-critic (Bash-free), never the default
182
+ // Bash-capable design-critic — assert import.md's orchestrator spawns the
183
+ // dedicated agent, not design-critic, for the reality-check step.
184
+ const importMd = readFileSync('plugins/design/commands/import.md', 'utf8');
185
+ const reconstructSection = importMd.slice(importMd.indexOf('## `--reconstruct'));
186
+ assert.ok(
187
+ reconstructSection.includes('design:reconstruct-critic'),
188
+ 'import.md --reconstruct mode must spawn design:reconstruct-critic for the reality-check step'
189
+ );
190
+ assert.ok(
191
+ !/subagent_type:\s*"design:design-critic"/.test(reconstructSection),
192
+ 'import.md --reconstruct mode must NOT spawn the default design-critic as its comparator (DDR-174 Decision 1 Round-2) — it is Bash-capable and reopens the trifecta this DDR closes'
193
+ );
194
+ });
@@ -15,6 +15,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
15
  import { homedir } from 'node:os';
16
16
  import { dirname, join } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
+ import { isCompiledBinary } from './pkg-root.mjs';
18
19
 
19
20
  const PKG_NAME = '@1agh/maude';
20
21
  const REGISTRY_URL = `https://registry.npmjs.org/${PKG_NAME}/latest`;
@@ -31,6 +32,13 @@ function shouldSkip() {
31
32
  if (process.env.NO_UPDATE_NOTIFIER) return true;
32
33
  if (process.env.CI) return true;
33
34
  if (!process.stderr.isTTY) return true;
35
+ // DDR-166 T0b — a Tauri-bundled `maude` binary updates via the app's own
36
+ // auto-updater (DDR-126), not `npm i -g @1agh/maude@latest`; the notice
37
+ // would be actively wrong advice. Also sidesteps spawnDetachedRefresh()'s
38
+ // self-respawn, which re-invokes `import.meta.url` as a script path — a
39
+ // real file:// path in dev/node, but Bun's virtual `/$bunfs/root` inside
40
+ // this exact compiled binary (DDR-045-class trap).
41
+ if (isCompiledBinary()) return true;
34
42
  return false;
35
43
  }
36
44