@1agh/maude 0.22.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +84 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +135 -10
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +222 -11
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
package/README.md CHANGED
@@ -11,7 +11,7 @@ A personal marketplace of Claude Code plugins. Two plugins today, plus a `maude`
11
11
 
12
12
  | Plugin | What it does |
13
13
  | ------ | ------------ |
14
- | **`design`** | Canvas-first iteration on HTML/JSX mocks under `.design/` — element selection via Cmd+Click, auto-managed dev server, chained UX/DS critique. |
14
+ | **`design`** | Canvas-first iteration on TSX/JSX mocks under `.design/` — element selection via Cmd+Click, auto-managed dev server, chained UX/DS critique. |
15
15
  | **`flow`** | Generic agentic workflow loop with a second-brain `.ai/` workspace. `/flow:plan`, `/flow:execute`, `/flow:utils-verify`, `/flow:validate`, `/flow:done`, `/flow:init`, `/flow:record-ddr`, `/flow:scenario`, …. Project-agnostic via `<project>` placeholders + per-repo `.ai/workflows.config.json`. |
16
16
 
17
17
  Plus the **`maude`** CLI — `maude init` scaffolds a fresh `.ai/` workspace from the flow plugin skeleton; `maude design serve` boots the design dev server. The legacy `mdcc` alias still works (prints a deprecation warning) and will be removed in v0.17.x.
@@ -66,6 +66,35 @@ Then inside Claude Code (with `flow@maude` installed):
66
66
 
67
67
  `/init` writes the `CLAUDE.md` Claude auto-loads every session (conventions, build commands, gotchas). `/flow:init` handles the structured workspace config — they're complementary, not duplicates.
68
68
 
69
+ ### Health check — `maude doctor`
70
+
71
+ `maude doctor` reports missing dependencies, config schema errors, stack drift, and missing quality-gate declarations in one shot. `--fix` applies safe auto-fixes (prompts per dependency install; never overwrites existing user config values). `--json` for programmatic consumers. Run it after stack changes or before opening a PR.
72
+
73
+ ```sh
74
+ maude doctor # full health check (deps + config + quality)
75
+ maude doctor --fix # install missing deps (per-item prompt) + apply safe config fixes
76
+ maude doctor --json # machine-readable envelope
77
+ ```
78
+
79
+ **Quality gates** are declared in `.ai/workflows.config.json` under the top-level `quality` map — a flat `gate → shell command` mapping (e.g. `{ "lint": "pnpm lint", "tests": "pnpm test" }`). Flow commands (`/flow:validate`, `/flow:utils-verify`, `/flow:quick`) read it directly and run each via `eval`. There is **no `maude quality run` wrapper** — `pnpm <script>` is already the runner. Hook it into your own pre-commit tool with a one-liner: `eval $(jq -r '.quality.lint' .ai/workflows.config.json)`. Populate the block by running `maude doctor --fix` once your `package.json` scripts exist (additive — it never overwrites a command you tuned).
80
+
81
+ ### Sidecar cache — `maude cache`
82
+
83
+ Flow + design commands reuse expensive cross-session work through a small sidecar cache at `.ai/cache/` ([DDR-061](.ai/decisions/DDR-061-sidecar-cache-monitor-background-orchestration.md)): domain research (skips 30–90 s of WebSearch on a same-domain brief), codebase-intelligence scans (skips rescan when the tree is unchanged), parsed design-system vocabulary, and security-review reuse (one shared 1-hour window across `/flow:validate`, `/flow:done`, `/flow:validate-security`). Correctness comes first — most layers are content-addressed (a changed input changes the key → guaranteed miss), and a stale entry is never served speculatively.
84
+
85
+ ```sh
86
+ maude cache list # layers, entry counts, sizes, last-write time
87
+ maude cache stats # hit/miss counters + hit-rate per layer
88
+ maude cache inspect <layer> # list a layer's entries; add a <key> to print one
89
+ maude cache clear [layer] # wipe one layer (or everything)
90
+ ```
91
+
92
+ The `research/domain` and `codebase-intelligence` layers are **committed** (deterministic on a given tree, so collaborators share the hit); the per-brief, design-context, security, and scenario layers are gitignored. `maude init` drops a `.ai/.gitignore` that encodes the split.
93
+
94
+ ### Plugins call `maude` for executable logic
95
+
96
+ Plugin slash-commands reach all executable logic through the on-PATH `maude` binary — never a relative `cli/lib/*.mjs` path or a raw `$CLAUDE_PLUGIN_ROOT/dev-server/bin/*.sh` invocation ([DDR-062](.ai/decisions/DDR-062-plugins-reach-executable-logic-via-maude.md)). The marketplace copies each plugin alone (no sibling `cli/`, no `dev-server/`), and a flow command's `$CLAUDE_PLUGIN_ROOT` points at the flow plugin (which has no dev-server at all) — so the only contract that holds across every install shape is `maude`, which resolves bundled helpers from its own package root. Cache/preflight go via `maude cache …` / `maude preflight …`; the design dev-server's bash helpers go via **`maude design <verb>`** (`screenshot`, `server-up`, `prep`, `slug`, `smoke`, `runtime-health`, …) — see `maude design help`. Keep the global `maude` current; a stale binary means stale helpers.
97
+
69
98
  ## Runtime requirements
70
99
 
71
100
  - **Node ≥ 20** — for the dev server and CLI. Zero npm runtime deps.
@@ -79,6 +108,10 @@ Two clean paths, no middle ground ([DDR-047](.ai/decisions/DDR-047-collab-scope-
79
108
  - **v1.0 — git handoff OR loopback multi-tab.** Push / pull is the cross-machine story. On a single machine, two browser tabs (or two Claude Code instances editing the same repo) sync cursors, comments, and annotations live over loopback WebSocket. The dev server refuses any non-loopback `host` header on the collab WS endpoint.
80
109
  - **v1.1 — deploy a hub** (Phase 9, in-flight). Cross-machine live collab needs a hub binary you deploy yourself. No tunnel mode; no shared cloud.
81
110
 
111
+ ## Security
112
+
113
+ Solo mode (the default) is fully local — no accounts, no telemetry, no network trust surface, and the canvas sandbox is on by default. Linked (hub) mode is opt-in and carries a documented trust model with disclosed, bounded residuals (hub-pushed content is treated as untrusted input). The full account — what runs where, what the canvas sandbox does and doesn't close, `.tsx` sync's double opt-in, untrusted-context handling — is at [`/docs/security`](https://maude.iagh.cz/docs/security) (source: [`site/content/docs/security.mdx`](./site/content/docs/security.mdx)). To **report** a vulnerability, see [SECURITY.md](./SECURITY.md).
114
+
82
115
  ## What's where
83
116
 
84
117
  User-facing docs live in two places — the README points you the right way:
package/cli/bin/maude.mjs CHANGED
@@ -13,7 +13,10 @@ const PKG_ROOT = resolve(CLI_ROOT, '..');
13
13
  const COMMANDS = {
14
14
  init: () => import('../commands/init.mjs'),
15
15
  config: () => import('../commands/config.mjs'),
16
+ cache: () => import('../commands/cache.mjs'),
17
+ preflight: () => import('../commands/preflight.mjs'),
16
18
  design: () => import('../commands/design.mjs'),
19
+ 'scenario-report': () => import('../commands/scenario-report.mjs'),
17
20
  doctor: () => import('../commands/doctor.mjs'),
18
21
  help: () => import('../commands/help.mjs'),
19
22
  hub: () => import('../commands/hub.mjs'),
@@ -0,0 +1,181 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parseArgs } from '../lib/argv.mjs';
3
+ import { check, clear, entries, list, recordAccess, stats, write } from '../lib/cache.mjs';
4
+
5
+ // `maude cache <get|put|list|stats|inspect|clear>` — inspect and manage the
6
+ // sidecar cache layer (cli/lib/cache.mjs, Phase C / DDR-061).
7
+ //
8
+ // get/put are the PROGRAMMATIC surface that plugin slash-commands + agents call.
9
+ // The only cache entry point reachable from a plugin across install shapes is the
10
+ // `maude` binary on PATH (a declared plugin dependency) — NOT a relative path to
11
+ // cli/lib/cache.mjs, which the marketplace never copies beside a plugin (each
12
+ // plugin is copied alone into cache/<marketplace>/<plugin>/<version>/). See DDR-061.
13
+
14
+ export async function run({ args }) {
15
+ const { positional, flags } = parseArgs(args);
16
+ const sub = positional[0];
17
+
18
+ if (!sub || sub === 'help') {
19
+ process.stdout.write(usage());
20
+ return;
21
+ }
22
+ if (sub === 'get') return cmdGet(positional[1], positional[2], flags);
23
+ if (sub === 'put') return cmdPut(positional[1], positional[2], positional[3], flags);
24
+ if (sub === 'list') return cmdList();
25
+ if (sub === 'stats') return cmdStats();
26
+ if (sub === 'inspect') return cmdInspect(positional[1], positional[2]);
27
+ if (sub === 'clear') return cmdClear(positional[1]);
28
+
29
+ process.stderr.write(`maude cache: unknown subcommand "${sub}"\n${usage()}`);
30
+ process.exit(2);
31
+ }
32
+
33
+ function usage() {
34
+ return `maude cache <get|put|list|stats|inspect|clear> [args]
35
+
36
+ cache get <layer> <key> [--ttl-ms N] Print the cached value (compact JSON) on a
37
+ fresh hit, exit 0. On miss/stale: no stdout,
38
+ exit 1. (Omit --ttl-ms for SHA-keyed layers.)
39
+ cache put <layer> <key> [file] [--meta JSON]
40
+ Write a value (from <file> or stdin) into the cache.
41
+ cache list Layers with entry counts, sizes, last-write time.
42
+ cache stats Hit/miss counters and hit-rate per layer.
43
+ cache inspect <layer> [key] List entries in a layer; with <key>, print one entry.
44
+ cache clear [layer[/key]] Wipe one layer (or single entry), or everything.
45
+
46
+ Layers: research/domain, research/project, codebase-intelligence,
47
+ design-context, security, scenario, validate.
48
+ `;
49
+ }
50
+
51
+ // Programmatic read for slash-commands: stdout = compact JSON value on a fresh
52
+ // hit (exit 0); silent + exit 1 on miss or past-TTL, so bash `&&`/`||` branches.
53
+ function cmdGet(layer, key, flags) {
54
+ if (!layer || !key) throw new Error('maude cache get <layer> <key> [--ttl-ms N]');
55
+ const hit = check(layer, key);
56
+ const ttlMs = flags['ttl-ms'] != null ? Number(flags['ttl-ms']) : Number.POSITIVE_INFINITY;
57
+ const fresh = hit && hit.ageMs <= ttlMs;
58
+ recordAccess(layer, fresh ? 'hit' : 'miss'); // feed `maude cache stats`
59
+ if (!fresh) {
60
+ process.exitCode = 1; // miss / stale — no stdout
61
+ return;
62
+ }
63
+ process.stdout.write(`${JSON.stringify(hit.value)}\n`);
64
+ }
65
+
66
+ // Programmatic write for slash-commands. Value JSON comes from <file> or stdin.
67
+ function cmdPut(layer, key, file, flags) {
68
+ if (!layer || !key) throw new Error('maude cache put <layer> <key> [file] [--meta JSON]');
69
+ const raw = file ? readFileSync(file, 'utf8') : readFileSync(0, 'utf8');
70
+ let value;
71
+ try {
72
+ value = JSON.parse(raw);
73
+ } catch {
74
+ throw new Error('maude cache put: input is not valid JSON');
75
+ }
76
+ const meta = flags.meta ? JSON.parse(flags.meta) : {};
77
+ const { path } = write(layer, key, value, meta);
78
+ process.stderr.write(`cached ${layer}/${key} → ${path}\n`);
79
+ }
80
+
81
+ function fmtBytes(n) {
82
+ if (n < 1024) return `${n} B`;
83
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
84
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
85
+ }
86
+
87
+ function fmtAge(ms) {
88
+ const s = Math.round(ms / 1000);
89
+ if (s < 60) return `${s}s ago`;
90
+ const m = Math.round(s / 60);
91
+ if (m < 60) return `${m}m ago`;
92
+ const h = Math.round(m / 60);
93
+ if (h < 48) return `${h}h ago`;
94
+ return `${Math.round(h / 24)}d ago`;
95
+ }
96
+
97
+ function cmdList() {
98
+ const layers = list();
99
+ if (layers.length === 0) {
100
+ process.stdout.write('cache is empty (no entries written yet)\n');
101
+ return;
102
+ }
103
+ process.stdout.write('LAYER ENTRIES SIZE LAST WRITE\n');
104
+ for (const l of layers) {
105
+ process.stdout.write(
106
+ `${l.layer.padEnd(25)} ${String(l.entries).padStart(7)} ${fmtBytes(l.bytes).padEnd(9)} ${fmtAge(Date.now() - l.newest)}\n`
107
+ );
108
+ }
109
+ }
110
+
111
+ function cmdStats() {
112
+ const s = stats();
113
+ const allLayers = new Set([...Object.keys(s.hits), ...Object.keys(s.misses)]);
114
+ if (allLayers.size === 0) {
115
+ process.stdout.write('no cache activity recorded yet\n');
116
+ return;
117
+ }
118
+ process.stdout.write('LAYER HITS MISSES HIT-RATE\n');
119
+ for (const layer of [...allLayers].sort()) {
120
+ const hits = s.hits[layer] || 0;
121
+ const misses = s.misses[layer] || 0;
122
+ const total = hits + misses;
123
+ const rate = total ? `${Math.round((hits / total) * 100)}%` : '—';
124
+ process.stdout.write(
125
+ `${layer.padEnd(25)} ${String(hits).padStart(6)} ${String(misses).padStart(7)} ${rate.padStart(8)}\n`
126
+ );
127
+ }
128
+ if (s.since) process.stdout.write(`\nsince ${new Date(s.since).toISOString()}\n`);
129
+ }
130
+
131
+ function cmdInspect(layer, key) {
132
+ if (!layer) throw new Error('maude cache inspect <layer> [key]');
133
+ if (key) {
134
+ const hit = check(layer, key);
135
+ if (!hit) {
136
+ process.stderr.write(`(no entry ${layer}/${key})\n`);
137
+ process.exit(1);
138
+ }
139
+ process.stdout.write(
140
+ `${layer}/${key}\n written: ${new Date(hit.writtenAt).toISOString()} (${fmtAge(hit.ageMs)})\n meta: ${JSON.stringify(hit.meta)}\n\n${JSON.stringify(hit.value, null, 2)}\n`
141
+ );
142
+ return;
143
+ }
144
+ const list_ = entries(layer);
145
+ if (list_.length === 0) {
146
+ process.stdout.write(`(layer "${layer}" is empty)\n`);
147
+ return;
148
+ }
149
+ process.stdout.write(`${layer} — ${list_.length} entr${list_.length === 1 ? 'y' : 'ies'}\n`);
150
+ for (const e of list_) {
151
+ process.stdout.write(
152
+ ` ${e.key.padEnd(40)} ${fmtBytes(e.bytes).padEnd(9)} ${fmtAge(e.ageMs)}\n`
153
+ );
154
+ }
155
+ }
156
+
157
+ function cmdClear(target) {
158
+ if (!target) {
159
+ const { removed } = clear();
160
+ process.stdout.write(
161
+ `cleared entire cache (${removed} top-level item${removed === 1 ? '' : 's'})\n`
162
+ );
163
+ return;
164
+ }
165
+ // `layer/key` clears a single entry; bare `layer` clears the whole layer.
166
+ const slash = target.lastIndexOf('/');
167
+ if (slash > 0) {
168
+ const layer = target.slice(0, slash);
169
+ const key = target.slice(slash + 1);
170
+ // Only treat as entry-clear if the entry actually exists; otherwise treat
171
+ // the whole path as a (nested) layer to wipe.
172
+ if (check(layer, key)) {
173
+ clear(layer, key);
174
+ process.stdout.write(`cleared ${layer}/${key}\n`);
175
+ return;
176
+ }
177
+ }
178
+ const { removed } = clear(target);
179
+ if (removed) process.stdout.write(`cleared layer "${target}"\n`);
180
+ else process.stdout.write(`nothing to clear at "${target}"\n`);
181
+ }
@@ -0,0 +1,166 @@
1
+ // `maude cache` CLI end-to-end via spawnSync — seeds a temp cache dir through
2
+ // the cache lib, then exercises list / stats / inspect / clear over the bin.
3
+
4
+ import assert from 'node:assert/strict';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ import { test } from 'node:test';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { getOrCompute, write } from '../lib/cache.mjs';
12
+
13
+ const BIN = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'maude.mjs');
14
+
15
+ function runCli(args, cacheDir) {
16
+ return spawnSync(process.execPath, [BIN, 'cache', ...args], {
17
+ encoding: 'utf8',
18
+ env: { ...process.env, MAUDE_CACHE_DIR: cacheDir },
19
+ });
20
+ }
21
+
22
+ async function withCache(fn) {
23
+ const dir = mkdtempSync(join(tmpdir(), 'maude-cli-cache-'));
24
+ try {
25
+ return await fn(dir);
26
+ } finally {
27
+ rmSync(dir, { recursive: true, force: true });
28
+ }
29
+ }
30
+
31
+ test('cache help prints subcommand summary', async () => {
32
+ await withCache((dir) => {
33
+ const res = runCli(['help'], dir);
34
+ assert.equal(res.status, 0, res.stderr);
35
+ assert.match(res.stdout, /maude cache <get\|put\|list\|stats\|inspect\|clear>/);
36
+ });
37
+ });
38
+
39
+ test('cache list reports "empty" before any writes', async () => {
40
+ await withCache((dir) => {
41
+ const res = runCli(['list'], dir);
42
+ assert.equal(res.status, 0, res.stderr);
43
+ assert.match(res.stdout, /cache is empty/);
44
+ });
45
+ });
46
+
47
+ test('cache list shows seeded layers with entry counts', async () => {
48
+ await withCache((dir) => {
49
+ write('research/domain', 'finance.aaa', { mood: 1 }, {}, { cacheDir: dir });
50
+ write('research/domain', 'finance.bbb', { mood: 2 }, {}, { cacheDir: dir });
51
+ write('codebase-intelligence', 'sha123', { files: 9 }, {}, { cacheDir: dir });
52
+ const res = runCli(['list'], dir);
53
+ assert.equal(res.status, 0, res.stderr);
54
+ assert.match(res.stdout, /research\s+2/);
55
+ assert.match(res.stdout, /codebase-intelligence\s+1/);
56
+ });
57
+ });
58
+
59
+ test('cache stats reports hit-rate after getOrCompute activity', async () => {
60
+ await withCache(async (dir) => {
61
+ const base = {
62
+ cacheDir: dir,
63
+ layer: 'codebase-intelligence',
64
+ key: 'k',
65
+ ttlMs: 60_000,
66
+ compute: () => ({ v: 1 }),
67
+ };
68
+ await getOrCompute(base); // miss
69
+ await getOrCompute(base); // hit
70
+ const res = runCli(['stats'], dir);
71
+ assert.equal(res.status, 0, res.stderr);
72
+ assert.match(res.stdout, /codebase-intelligence/);
73
+ assert.match(res.stdout, /50%/);
74
+ });
75
+ });
76
+
77
+ test('cache inspect lists entries, and prints one entry by key', async () => {
78
+ await withCache((dir) => {
79
+ write('design-context', 'ds/tok1', { tokens: 42 }, { dsName: 'ds' }, { cacheDir: dir });
80
+ const listRes = runCli(['inspect', 'design-context'], dir);
81
+ assert.equal(listRes.status, 0, listRes.stderr);
82
+ assert.match(listRes.stdout, /ds\/tok1/);
83
+
84
+ const oneRes = runCli(['inspect', 'design-context', 'ds/tok1'], dir);
85
+ assert.equal(oneRes.status, 0, oneRes.stderr);
86
+ assert.match(oneRes.stdout, /"tokens": 42/);
87
+ });
88
+ });
89
+
90
+ test('cache clear <layer> wipes only that layer', async () => {
91
+ await withCache((dir) => {
92
+ write('research/domain', 'a', { x: 1 }, {}, { cacheDir: dir });
93
+ write('codebase-intelligence', 'b', { x: 2 }, {}, { cacheDir: dir });
94
+ const res = runCli(['clear', 'research/domain'], dir);
95
+ assert.equal(res.status, 0, res.stderr);
96
+ assert.match(res.stdout, /cleared layer "research\/domain"/);
97
+ assert.ok(!existsSync(join(dir, 'research', 'domain')));
98
+ assert.ok(existsSync(join(dir, 'codebase-intelligence')));
99
+ });
100
+ });
101
+
102
+ test('cache clear with no args wipes everything', async () => {
103
+ await withCache((dir) => {
104
+ write('research/domain', 'a', { x: 1 }, {}, { cacheDir: dir });
105
+ const res = runCli(['clear'], dir);
106
+ assert.equal(res.status, 0, res.stderr);
107
+ assert.match(res.stdout, /cleared entire cache/);
108
+ assert.ok(!existsSync(join(dir, 'research')));
109
+ });
110
+ });
111
+
112
+ test('get prints compact JSON on a fresh hit (exit 0), nothing on miss (exit 1)', async () => {
113
+ await withCache((dir) => {
114
+ write('codebase-intelligence', 'sha9', { files: 142 }, {}, { cacheDir: dir });
115
+ const hit = runCli(['get', 'codebase-intelligence', 'sha9'], dir);
116
+ assert.equal(hit.status, 0, hit.stderr);
117
+ assert.equal(hit.stdout.trim(), '{"files":142}');
118
+
119
+ const miss = runCli(['get', 'codebase-intelligence', 'absent'], dir);
120
+ assert.equal(miss.status, 1);
121
+ assert.equal(miss.stdout, '');
122
+ });
123
+ });
124
+
125
+ test('get treats a past-TTL entry as a miss (exit 1)', async () => {
126
+ await withCache((dir) => {
127
+ const { path } = write('security', 'head1', { verdict: 'PASS' }, {}, { cacheDir: dir });
128
+ const stored = JSON.parse(readFileSync(path, 'utf8'));
129
+ stored.writtenAt = Date.now() - 2 * 60 * 60 * 1000; // 2 h old
130
+ writeFileSync(path, JSON.stringify(stored), 'utf8');
131
+ const res = runCli(['get', 'security', 'head1', '--ttl-ms', String(60 * 60 * 1000)], dir);
132
+ assert.equal(res.status, 1, 'past-TTL must be a miss');
133
+ });
134
+ });
135
+
136
+ test('get feeds maude cache stats (hit + miss counters)', async () => {
137
+ await withCache((dir) => {
138
+ write('research/domain', 'k', { v: 1 }, {}, { cacheDir: dir });
139
+ runCli(['get', 'research/domain', 'k'], dir); // hit
140
+ runCli(['get', 'research/domain', 'absent'], dir); // miss
141
+ const res = runCli(['stats'], dir);
142
+ assert.equal(res.status, 0, res.stderr);
143
+ assert.match(res.stdout, /research\/domain/);
144
+ assert.match(res.stdout, /50%/);
145
+ });
146
+ });
147
+
148
+ test('put writes a value (from file) that get then reads back', async () => {
149
+ await withCache((dir) => {
150
+ const f = join(dir, 'payload.json');
151
+ writeFileSync(f, JSON.stringify({ mood: ['calm'], queries: 7 }), 'utf8');
152
+ const put = runCli(['put', 'research/domain', 'finance--discovery', f], dir);
153
+ assert.equal(put.status, 0, put.stderr);
154
+ const get = runCli(['get', 'research/domain', 'finance--discovery'], dir);
155
+ assert.equal(get.status, 0);
156
+ assert.deepEqual(JSON.parse(get.stdout), { mood: ['calm'], queries: 7 });
157
+ });
158
+ });
159
+
160
+ test('unknown subcommand exits non-zero', async () => {
161
+ await withCache((dir) => {
162
+ const res = runCli(['frobnicate'], dir);
163
+ assert.equal(res.status, 2);
164
+ assert.match(res.stderr, /unknown subcommand/);
165
+ });
166
+ });
@@ -6,7 +6,7 @@
6
6
 
7
7
  import assert from 'node:assert/strict';
8
8
  import { spawn } from 'node:child_process';
9
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
10
  import { createServer } from 'node:http';
11
11
  import { tmpdir } from 'node:os';
12
12
  import { dirname, join, resolve } from 'node:path';
@@ -202,6 +202,88 @@ test('status --json emits structured payload', async () => {
202
202
  assert.equal(payload.url, URL);
203
203
  assert.equal(payload.tokenStored, true);
204
204
  assert.equal(payload.hub.reachable, true);
205
- assert.equal(payload.sync.agent, 'not-implemented');
205
+ // No `maude design serve` running in this test → no _sync.json → idle.
206
+ assert.equal(payload.sync.agent, 'idle');
207
+ cleanup();
208
+ });
209
+
210
+ test('status reports zero-syncable when _sync.json is notSyncable (DDR-060 / 9.1-D)', async () => {
211
+ await runCli(['design', 'link', URL, '--token', 'mau_test']);
212
+ // Simulate the dev-server having written a zero-syncable _sync.json.
213
+ writeFileSync(
214
+ join(workspace, '.design/_sync.json'),
215
+ JSON.stringify({
216
+ linked: true,
217
+ notSyncable: true,
218
+ url: URL,
219
+ reason: '4 TSX canvas(es) found but none are syncable — see DDR-060.',
220
+ tsxCount: 4,
221
+ canvases: 0,
222
+ updatedAt: Date.now(),
223
+ }),
224
+ 'utf8'
225
+ );
226
+
227
+ const human = await runCli(['design', 'status']);
228
+ assert.equal(human.status, 0, human.stderr);
229
+ assert.match(human.stdout, /0 syncable canvases/);
230
+ assert.match(human.stdout, /DDR-060/);
231
+
232
+ const json = await runCli(['design', 'status', '--json']);
233
+ const payload = JSON.parse(json.stdout);
234
+ assert.equal(payload.sync.notSyncable, true);
235
+ assert.equal(payload.sync.tsxCount, 4);
236
+ cleanup();
237
+ });
238
+
239
+ // --------------------------------------------------- DDR-054 F2/F4 trust gate
240
+
241
+ const REMOTE_URL = 'http://hub.invalid:9999'; // .invalid never resolves (RFC 6761)
242
+
243
+ test('linking a non-loopback hub without --yes (non-TTY) refuses', async () => {
244
+ // The spawned child has no TTY on stdin, so the gate must refuse rather
245
+ // than silently link a remote hub in a script.
246
+ const res = await runCli(['design', 'link', REMOTE_URL, '--token', 'mau_x', '--force']);
247
+ assert.equal(res.status, 1);
248
+ assert.match(res.stderr, /requires confirmation/);
249
+ // No link written.
250
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
251
+ assert.equal(cfg.linkedHub, undefined);
252
+ cleanup();
253
+ });
254
+
255
+ test('linking a non-loopback hub with --yes records trust + links', async () => {
256
+ const res = await runCli(['design', 'link', REMOTE_URL, '--token', 'mau_x', '--yes', '--force']);
257
+ assert.equal(res.status, 0, res.stderr);
258
+ assert.match(res.stderr, /confirmed via --yes/);
259
+ assert.match(res.stderr, /UNTRUSTED/); // F3 linked-mode banner
260
+
261
+ const cfg = JSON.parse(readFileSync(join(workspace, '.design/config.json'), 'utf8'));
262
+ assert.equal(cfg.linkedHub.url, REMOTE_URL);
263
+
264
+ // Trust is recorded PER-MACHINE (hubs.json), never in a committable repo file.
265
+ const hubs = JSON.parse(readFileSync(hubsConfigPath, 'utf8'));
266
+ assert.ok(hubs.trusted.includes(REMOTE_URL), 'hub should be trusted on this machine');
267
+ assert.equal(
268
+ existsSync(join(workspace, '.maude/trusted-hubs')),
269
+ false,
270
+ 'no committable trust file'
271
+ );
272
+
273
+ // Re-linking the now-trusted hub no longer needs --yes.
274
+ const second = await runCli(['design', 'link', REMOTE_URL, '--token', 'mau_y', '--force']);
275
+ assert.equal(second.status, 0, second.stderr);
276
+ cleanup();
277
+ });
278
+
279
+ test('--adopt against a non-loopback hub lists the upload manifest in the gate', async () => {
280
+ writeFileSync(join(workspace, '.design/screen.html'), '<button>hi</button>', 'utf8');
281
+ const res = await runCli(['design', 'adopt', REMOTE_URL, '--token', 'mau_x', '--yes', '--force']);
282
+ assert.equal(res.status, 0, res.stderr);
283
+ assert.match(res.stderr, /will UPLOAD 1 local file/);
284
+ assert.match(res.stderr, /\.design\/screen\.html/);
285
+
286
+ const hubs = JSON.parse(readFileSync(hubsConfigPath, 'utf8'));
287
+ assert.equal(typeof hubs.hubs[REMOTE_URL].adoptedAt, 'number'); // F4 attestation
206
288
  cleanup();
207
289
  });
@@ -1,9 +1,11 @@
1
- import { execSync, spawn } from 'node:child_process';
1
+ import { execSync, spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
2
3
  import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
3
4
  import { createRequire } from 'node:module';
4
5
  import { basename, dirname, join, resolve } from 'node:path';
5
6
  import { parseArgs } from '../lib/argv.mjs';
6
7
  import { runAdopt, runLink, runStatus, runUnlink } from '../lib/design-link.mjs';
8
+ import { writeGitignoreBlock } from '../lib/gitignore-block.mjs';
7
9
 
8
10
  const SUBCOMMANDS = new Set([
9
11
  'serve',
@@ -16,6 +18,27 @@ const SUBCOMMANDS = new Set([
16
18
  'help',
17
19
  ]);
18
20
 
21
+ // Dev-tooling verbs that dispatch to the dev-server's bundled bash helpers
22
+ // (Phase C / DDR-062). Plugin markdown invokes these via `maude design <verb>`
23
+ // rather than `bash "$CLAUDE_PLUGIN_ROOT/dev-server/bin/<verb>.sh"` — the
24
+ // marketplace never copies the dev-server beside a plugin, and CLAUDE_PLUGIN_ROOT
25
+ // is unreliable in a real run. `maude` resolves the helper from its OWN package
26
+ // root and sets CLAUDE_PLUGIN_ROOT authoritatively for the child. The whitelist
27
+ // (not arbitrary `<verb>.sh` exec) keeps this off the path-traversal surface.
28
+ const BIN_VERBS = new Set([
29
+ 'screenshot',
30
+ 'server-up',
31
+ 'prep',
32
+ 'slug',
33
+ 'bootstrap-check',
34
+ 'runtime-health',
35
+ 'smoke',
36
+ 'canvas-edit',
37
+ 'handoff',
38
+ 'asset-sweep',
39
+ 'visual-sanity',
40
+ ]);
41
+
19
42
  export async function run({ args, pkgRoot }) {
20
43
  const { positional } = parseArgs(args);
21
44
  const sub = positional[0];
@@ -25,6 +48,10 @@ export async function run({ args, pkgRoot }) {
25
48
  return;
26
49
  }
27
50
 
51
+ // Dev-tooling verbs dispatch to the bundled bash helpers (DDR-062). Checked
52
+ // before SUBCOMMANDS so the whitelist owns the bin surface.
53
+ if (BIN_VERBS.has(sub)) return runBinDispatch(sub, { args, pkgRoot });
54
+
28
55
  if (!SUBCOMMANDS.has(sub)) {
29
56
  process.stderr.write(`maude design: unknown subcommand "${sub}"\n${usage()}`);
30
57
  process.exit(2);
@@ -39,8 +66,56 @@ export async function run({ args, pkgRoot }) {
39
66
  if (sub === 'adopt') return runAdopt({ args });
40
67
  }
41
68
 
69
+ // Run a whitelisted dev-server bash helper, resolving it from maude's OWN
70
+ // package root (NOT $CLAUDE_PLUGIN_ROOT) and setting CLAUDE_PLUGIN_ROOT
71
+ // authoritatively for the child so the script's sibling-resolution still works
72
+ // (DDR-045 / DDR-062). `stdio: 'inherit'` so the helper's stdout/stderr/exit-code
73
+ // pass straight through — preserves `$(maude design slug …)` capture,
74
+ // `eval "$(maude design prep --shell-export …)"`, and non-zero gating idioms.
75
+ // maude writes nothing of its own on this path (no banner on stdout).
76
+ function runBinDispatch(verb, { args, pkgRoot }) {
77
+ if (!BIN_VERBS.has(verb)) {
78
+ process.stderr.write(`maude design: "${verb}" is not a dev-tooling verb.\n${usage()}`);
79
+ process.exit(2);
80
+ }
81
+ const pluginRoot = join(pkgRoot, 'plugins', 'design');
82
+ const script = join(pluginRoot, 'dev-server', 'bin', `${verb}.sh`);
83
+ if (!existsSync(script)) {
84
+ process.stderr.write(
85
+ `maude design ${verb}: helper not found at ${script}. Reinstall maude (the dev-server bin ships in the npm package).\n`
86
+ );
87
+ process.exit(1);
88
+ }
89
+ const rest = args.slice(args.indexOf(verb) + 1); // everything after the verb token
90
+ const child = spawnSync('bash', [script, ...rest], {
91
+ stdio: 'inherit',
92
+ env: { ...process.env, CLAUDE_PLUGIN_ROOT: pluginRoot },
93
+ });
94
+ if (child.error) {
95
+ process.stderr.write(`maude design ${verb}: ${child.error.message}\n`);
96
+ process.exit(1);
97
+ }
98
+ process.exit(child.status ?? 1); // pass-through exit code
99
+ }
100
+
42
101
  function usage() {
43
- return `maude design <serve|init|export|link|unlink|status|adopt> [options]
102
+ return `maude design <verb> [options]
103
+
104
+ Lifecycle:
105
+ serve · init · export · link · adopt · unlink · status
106
+
107
+ Dev-tooling (dispatch to the dev-server bash helpers — DDR-062):
108
+ screenshot · server-up · prep · slug · bootstrap-check · runtime-health
109
+ smoke · canvas-edit · handoff · asset-sweep · visual-sanity
110
+ Invoke the bundled helper of the same name. maude resolves it from its
111
+ own package root and sets CLAUDE_PLUGIN_ROOT for the child; stdout,
112
+ stderr, and exit code pass straight through (so command-substitution
113
+ capture and non-zero gating work unchanged). Args after the verb are
114
+ forwarded verbatim, e.g.:
115
+ maude design slug "Some Canvas Name"
116
+ PORT=$(maude design server-up --root "$REPO")
117
+ eval "$(maude design prep --shell-export --shape edit --root "$REPO")"
118
+ maude design smoke --changed-only
44
119
 
45
120
  serve [--port N] [--root PATH]
46
121
  Start the design plugin's dev server in the current repo. Equivalent
@@ -455,8 +530,14 @@ async function runInit({ args, pkgRoot }) {
455
530
 
456
531
  // Build explicit copy plan: [srcPath, destPath, transform?]
457
532
  const plan = buildCorePlan({ inspirationRoot, designDir, dsName });
458
- const previewFiles = await readdir(join(inspirationRoot, 'core', 'preview'));
459
- for (const f of previewFiles) {
533
+ // withFileTypes + file filter: core/preview/ contains a `.archive` dir that
534
+ // readFile would choke on with EISDIR.
535
+ const previewEntries = await readdir(join(inspirationRoot, 'core', 'preview'), {
536
+ withFileTypes: true,
537
+ });
538
+ for (const entry of previewEntries) {
539
+ if (!entry.isFile()) continue;
540
+ const f = entry.name;
460
541
  plan.push({
461
542
  src: resolve(inspirationRoot, 'core', 'preview', f),
462
543
  dest: resolve(designDir, 'system', dsName, 'preview', f),
@@ -487,6 +568,16 @@ async function runInit({ args, pkgRoot }) {
487
568
  else stats.created.push(rel(designDir, destPath));
488
569
  }
489
570
 
571
+ // Phase 9 Task 9 (DDR-056) — write the design-runtime .gitignore block so a
572
+ // fresh project ignores per-machine runtime state (and stays correct if it
573
+ // later links to a hub). Idempotent + skipped under --dry-run.
574
+ if (!flags['dry-run']) {
575
+ const { action } = writeGitignoreBlock(cwd, { designRel: '.design' });
576
+ if (action !== 'unchanged') {
577
+ process.stdout.write(` .gitignore: ${action} maude design-runtime block\n`);
578
+ }
579
+ }
580
+
490
581
  printSummary(stats);
491
582
  printNextSteps({ dsName, payloadProvided: !!flags['discovery-payload'] });
492
583
  }