@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
@@ -0,0 +1,407 @@
1
+ // Sidecar cache layer — Phase C / DDR-061.
2
+ //
3
+ // A tiny, zero-dependency JSON cache that lets expensive cross-session work
4
+ // (ux-research-agent discovery, codebase-intelligence scans, design-context
5
+ // token parsing, security-review reuse) be reused instead of recomputed.
6
+ //
7
+ // Used by:
8
+ // - cli/commands/cache.mjs (`maude cache list|clear|inspect|stats`)
9
+ // - plugins/design/agents/ux-research-agent.md (research/domain + research/project)
10
+ // - plugins/flow/skills/codebase-intelligence/SKILL.md (codebase-intelligence)
11
+ // - plugins/design/commands/{edit,new}.md (design-context)
12
+ // - plugins/flow/commands/{done,validate,validate-security}.md (security)
13
+ // - plugins/flow/commands/validate.md (validate — skip-if-clean)
14
+ //
15
+ // On-disk layout (relative to the cache root, default `<repo>/.ai/cache/`):
16
+ //
17
+ // .ai/cache/
18
+ // ├── _stats.json # hit/miss counters (best-effort telemetry)
19
+ // ├── research/domain/<slug>.<sha8>.json # committed — shareable per-domain pool
20
+ // ├── research/project/<repo-sha>/<sha>.json # gitignored — brief-specific layer
21
+ // ├── codebase-intelligence/<files-sha>.json # committed — keyed on git SHA
22
+ // ├── design-context/<ds-name>/<tokens-sha>.json
23
+ // ├── security/<head-sha>.json # gitignored — review reuse, 1 h TTL
24
+ // ├── scenario/<name>/<route-sha>.json # gitignored — per-run
25
+ // └── validate/<branch>.json # gitignored — skip-if-clean, 24 h TTL
26
+ //
27
+ // ## Invalidation policy (load-bearing — see C6 + .ai/cache/README.md)
28
+ //
29
+ // | Layer | Fresh window (ttlMs) | Hard ceiling (maxAgeMs) | Invalidates on |
30
+ // | ---------------------- | -------------------- | ----------------------- | --------------------- |
31
+ // | research/domain | 7 days | 30 days | domain slug change |
32
+ // | research/project | 30 days | 90 days | brief SHA change |
33
+ // | codebase-intelligence | until SHA changes | — | git HEAD / files SHA |
34
+ // | design-context | until tokens change | — | tokens.css mtime SHA |
35
+ // | security | 1 hour | 1 hour | HEAD SHA change |
36
+ // | scenario | until covered change | — | covered-file mtime |
37
+ // | validate | 24 hours | 24 hours | tree fingerprint Δ |
38
+ //
39
+ // Most layers are keyed on a content/SHA so the key itself IS the invalidation
40
+ // signal (a changed input produces a new key → guaranteed miss). The TTL fields
41
+ // matter for the time-decayed layers (research/*, security) where the same key
42
+ // can legitimately go stale without its inputs changing.
43
+ //
44
+ // Correctness > hit-rate: a wrong cached payload silently biases everything
45
+ // downstream, so `getOrCompute` only serves a *stale* (past-ttl, within-maxAge)
46
+ // entry as a fallback when `compute()` THROWS — never speculatively.
47
+
48
+ import { spawnSync } from 'node:child_process';
49
+ import {
50
+ existsSync,
51
+ mkdirSync,
52
+ readFileSync,
53
+ readdirSync,
54
+ renameSync,
55
+ rmSync,
56
+ statSync,
57
+ writeFileSync,
58
+ } from 'node:fs';
59
+ import { join, relative, resolve, sep } from 'node:path';
60
+
61
+ const STATS_FILE = '_stats.json';
62
+ const RESERVED = new Set([STATS_FILE, 'README.md']);
63
+
64
+ // Written into a fresh cache root the first time the cache is used on a machine,
65
+ // so the invalidation policy is discoverable next to the data it governs.
66
+ // This file is gitignored (see ai-skeleton/gitignore + repo .gitignore).
67
+ const README = `# Maude sidecar cache
68
+
69
+ Generated by \`cli/lib/cache.mjs\` (Phase C / DDR-061). Reusable, cross-session
70
+ results for expensive work so the same computation is not re-run every invocation.
71
+
72
+ ## Layers
73
+
74
+ | Layer | Key | Fresh (ttl) | Ceiling (maxAge) | Invalidates on | Git |
75
+ | ----------------------- | ---------------------------- | ----------- | ---------------- | --------------------- | -------- |
76
+ | \`research/domain\` | \`<domain-slug>.<sha8>\` | 7 days | 30 days | domain slug change | committed |
77
+ | \`research/project\` | \`<repo-sha>/<brief-sha>\` | 30 days | 90 days | brief SHA change | ignored |
78
+ | \`codebase-intelligence\` | \`<files-or-head-sha>\` | until SHA Δ | — | git HEAD / files SHA | committed |
79
+ | \`design-context\` | \`<ds-name>/<tokens-sha>\` | until Δ | — | tokens.css mtime SHA | ignored |
80
+ | \`security\` | \`<head-sha>\` | 1 hour | 1 hour | HEAD SHA change | ignored |
81
+ | \`scenario\` | \`<name>/<covers-sha>\` | until Δ | — | covered-file mtime | ignored |
82
+ | \`validate\` | \`<branch>\` | 24 hours | 24 hours | tree fingerprint Δ | ignored |
83
+
84
+ ## Invalidation policy (load-bearing — correctness > hit-rate)
85
+
86
+ - Most layers are **content-addressed**: the key embeds a SHA of the inputs, so
87
+ a changed input produces a new key → a guaranteed miss. The key IS the
88
+ invalidation signal.
89
+ - The **time-decayed** layers (\`research/*\`, \`security\`) can go stale without
90
+ their inputs changing, so they carry an explicit TTL. \`security\` is a hard
91
+ 1-hour window keyed on HEAD; \`research/domain\` is fresh for 7 days and is
92
+ served stale (up to 30 days) ONLY as a fallback when recompute throws.
93
+ - A wrong cached payload silently biases everything downstream, so
94
+ \`getOrCompute\` never serves a stale entry speculatively — only on compute error.
95
+
96
+ ## Management
97
+
98
+ \`\`\`sh
99
+ maude cache list # layers, entry counts, sizes, last-write
100
+ maude cache stats # hit/miss counters per layer
101
+ maude cache inspect <layer> # pretty-print entries in a layer
102
+ maude cache clear [layer] # wipe one layer, or everything
103
+ \`\`\`
104
+ `;
105
+
106
+ const ensuredReadme = new Set();
107
+
108
+ // Lazily drop the README into a fresh cache root (once per root per process).
109
+ function ensureReadme(cacheRoot) {
110
+ if (ensuredReadme.has(cacheRoot)) return;
111
+ ensuredReadme.add(cacheRoot);
112
+ try {
113
+ const readmePath = join(cacheRoot, 'README.md');
114
+ if (!existsSync(readmePath)) {
115
+ mkdirSync(cacheRoot, { recursive: true });
116
+ writeFileSync(readmePath, README, 'utf8');
117
+ }
118
+ } catch {
119
+ /* documentation is non-fatal */
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Resolve the cache root directory.
125
+ * Order: opts.cacheDir → $MAUDE_CACHE_DIR → $CLAUDE_PROJECT_DIR/.ai/cache → cwd/.ai/cache.
126
+ */
127
+ export function resolveCacheRoot(opts = {}) {
128
+ if (opts.cacheDir) return resolve(opts.cacheDir);
129
+ if (process.env.MAUDE_CACHE_DIR) return resolve(process.env.MAUDE_CACHE_DIR);
130
+ if (process.env.CLAUDE_PROJECT_DIR) return resolve(process.env.CLAUDE_PROJECT_DIR, '.ai/cache');
131
+ return resolve(process.cwd(), '.ai/cache');
132
+ }
133
+
134
+ // Resolve <root>/<layer>/<key>.json, refusing any path that escapes the root.
135
+ function entryPath(cacheRoot, layer, key) {
136
+ if (typeof layer !== 'string' || layer.length === 0) {
137
+ throw new TypeError('cache: layer must be a non-empty string');
138
+ }
139
+ if (typeof key !== 'string' || key.length === 0) {
140
+ throw new TypeError('cache: key must be a non-empty string');
141
+ }
142
+ const full = resolve(cacheRoot, layer, `${key}.json`);
143
+ const rel = relative(cacheRoot, full);
144
+ if (rel.startsWith('..') || rel.startsWith(sep) || resolve(cacheRoot, rel) !== full) {
145
+ throw new Error(`cache: layer/key escapes cache root: ${layer}/${key}`);
146
+ }
147
+ return full;
148
+ }
149
+
150
+ // tempfile + rename — readers never observe a half-written file, and two
151
+ // concurrent writers each land an intact JSON document (last rename wins).
152
+ function atomicWriteJson(filePath, obj) {
153
+ mkdirSync(resolve(filePath, '..'), { recursive: true });
154
+ const tmp = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
155
+ writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
156
+ renameSync(tmp, filePath);
157
+ }
158
+
159
+ function bumpStat(cacheRoot, layer, kind) {
160
+ // Best-effort telemetry — never throw into the caller's hot path.
161
+ try {
162
+ const statsPath = join(cacheRoot, STATS_FILE);
163
+ let stats = { hits: {}, misses: {}, since: Date.now() };
164
+ if (existsSync(statsPath)) {
165
+ try {
166
+ stats = JSON.parse(readFileSync(statsPath, 'utf8'));
167
+ } catch {
168
+ /* corrupt stats — reset */
169
+ }
170
+ }
171
+ stats.hits ||= {};
172
+ stats.misses ||= {};
173
+ const bucket = kind === 'hit' ? stats.hits : stats.misses;
174
+ bucket[layer] = (bucket[layer] || 0) + 1;
175
+ atomicWriteJson(statsPath, stats);
176
+ } catch {
177
+ /* telemetry is non-fatal */
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Record a hit/miss against a layer's counters. Lets out-of-process callers
183
+ * (the `maude cache get` CLI used by plugin slash-commands) feed the same
184
+ * telemetry `getOrCompute` does, so `maude cache stats` reflects real usage.
185
+ */
186
+ export function recordAccess(layer, kind, opts = {}) {
187
+ bumpStat(resolveCacheRoot(opts), layer, kind === 'hit' ? 'hit' : 'miss');
188
+ }
189
+
190
+ /**
191
+ * Read a cache entry. Returns `null` on miss or corruption.
192
+ * @returns {{value:any, meta:object, writtenAt:number, ageMs:number, path:string}|null}
193
+ */
194
+ export function check(layer, key, opts = {}) {
195
+ const cacheRoot = resolveCacheRoot(opts);
196
+ const filePath = entryPath(cacheRoot, layer, key);
197
+ if (!existsSync(filePath)) return null;
198
+ let entry;
199
+ try {
200
+ entry = JSON.parse(readFileSync(filePath, 'utf8'));
201
+ } catch {
202
+ return null; // treat corrupt entries as a miss
203
+ }
204
+ if (!entry || typeof entry !== 'object' || typeof entry.writtenAt !== 'number') return null;
205
+ return {
206
+ value: entry.value,
207
+ meta: entry.meta || {},
208
+ writtenAt: entry.writtenAt,
209
+ ageMs: Date.now() - entry.writtenAt,
210
+ path: filePath,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Write a cache entry atomically.
216
+ * @returns {{path:string, writtenAt:number}}
217
+ */
218
+ export function write(layer, key, value, meta = {}, opts = {}) {
219
+ const cacheRoot = resolveCacheRoot(opts);
220
+ const filePath = entryPath(cacheRoot, layer, key);
221
+ ensureReadme(cacheRoot);
222
+ const writtenAt = Date.now();
223
+ atomicWriteJson(filePath, { v: 1, layer, key, writtenAt, meta, value });
224
+ return { path: filePath, writtenAt };
225
+ }
226
+
227
+ /**
228
+ * Get-or-compute. Returns the cached value when fresh (age ≤ ttlMs), otherwise
229
+ * runs `compute()`, caches the result, and returns it. If `compute()` throws
230
+ * and a stale-but-not-expired entry exists (age ≤ maxAgeMs), that stale value
231
+ * is served as a graceful fallback; otherwise the error propagates.
232
+ *
233
+ * @param {object} o
234
+ * @param {string} o.layer
235
+ * @param {string} o.key
236
+ * @param {number} [o.ttlMs=Infinity] fresh window; within it the cache is returned directly
237
+ * @param {number} [o.maxAgeMs=Infinity] hard ceiling for stale-on-error fallback
238
+ * @param {boolean} [o.force=false] bypass the cache read; always recompute
239
+ * @param {Function} o.compute () => value | Promise<value>
240
+ * @param {object} [o.meta] stored alongside the value
241
+ */
242
+ export async function getOrCompute({
243
+ layer,
244
+ key,
245
+ ttlMs = Number.POSITIVE_INFINITY,
246
+ maxAgeMs = Number.POSITIVE_INFINITY,
247
+ force = false,
248
+ compute,
249
+ meta = {},
250
+ ...opts
251
+ }) {
252
+ const cacheRoot = resolveCacheRoot(opts);
253
+ const hit = check(layer, key, { cacheDir: cacheRoot });
254
+ if (!force && hit && hit.ageMs <= ttlMs) {
255
+ bumpStat(cacheRoot, layer, 'hit');
256
+ return hit.value;
257
+ }
258
+ bumpStat(cacheRoot, layer, 'miss');
259
+ try {
260
+ const value = await compute();
261
+ write(layer, key, value, meta, { cacheDir: cacheRoot });
262
+ return value;
263
+ } catch (err) {
264
+ if (hit && hit.ageMs <= maxAgeMs) return hit.value; // stale-on-error fallback
265
+ throw err;
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Remove a single entry, a whole layer subtree, or (with no layer) every entry.
271
+ * Never deletes the cache root itself, the README, or _stats.json on a layer clear.
272
+ */
273
+ export function clear(layer, key, opts = {}) {
274
+ const cacheRoot = resolveCacheRoot(opts);
275
+ if (!existsSync(cacheRoot)) return { removed: 0 };
276
+ if (layer && key) {
277
+ const filePath = entryPath(cacheRoot, layer, key);
278
+ if (existsSync(filePath)) {
279
+ rmSync(filePath);
280
+ return { removed: 1 };
281
+ }
282
+ return { removed: 0 };
283
+ }
284
+ if (layer) {
285
+ const dir = resolve(cacheRoot, layer);
286
+ const rel = relative(cacheRoot, dir);
287
+ if (rel.startsWith('..') || resolve(cacheRoot, rel) !== dir) {
288
+ throw new Error(`cache: layer escapes cache root: ${layer}`);
289
+ }
290
+ if (existsSync(dir)) {
291
+ rmSync(dir, { recursive: true, force: true });
292
+ return { removed: 1 };
293
+ }
294
+ return { removed: 0 };
295
+ }
296
+ // Wipe everything except the README; reset stats.
297
+ let removed = 0;
298
+ for (const name of readdirSync(cacheRoot)) {
299
+ if (name === 'README.md') continue;
300
+ rmSync(join(cacheRoot, name), { recursive: true, force: true });
301
+ removed += 1;
302
+ }
303
+ return { removed };
304
+ }
305
+
306
+ /**
307
+ * List layers with aggregate stats. Each entry: { layer, entries, bytes, newest }.
308
+ */
309
+ export function list(opts = {}) {
310
+ const cacheRoot = resolveCacheRoot(opts);
311
+ if (!existsSync(cacheRoot)) return [];
312
+ const layers = new Map();
313
+ const walk = (dir, topLayer) => {
314
+ for (const name of readdirSync(dir)) {
315
+ if (dir === cacheRoot && RESERVED.has(name)) continue;
316
+ const full = join(dir, name);
317
+ const st = statSync(full);
318
+ if (st.isDirectory()) {
319
+ walk(full, topLayer || name);
320
+ } else if (name.endsWith('.json') && topLayer) {
321
+ const agg = layers.get(topLayer) || { layer: topLayer, entries: 0, bytes: 0, newest: 0 };
322
+ agg.entries += 1;
323
+ agg.bytes += st.size;
324
+ agg.newest = Math.max(agg.newest, st.mtimeMs);
325
+ layers.set(topLayer, agg);
326
+ }
327
+ }
328
+ };
329
+ walk(cacheRoot, null);
330
+ return [...layers.values()].sort((a, b) => a.layer.localeCompare(b.layer));
331
+ }
332
+
333
+ /**
334
+ * Enumerate the entries within one layer. Each: { key, writtenAt, ageMs, bytes }.
335
+ * The `key` is the entry path relative to the layer dir, sans `.json` extension.
336
+ */
337
+ export function entries(layer, opts = {}) {
338
+ const cacheRoot = resolveCacheRoot(opts);
339
+ const layerDir = resolve(cacheRoot, layer);
340
+ const rel = relative(cacheRoot, layerDir);
341
+ if (rel.startsWith('..') || resolve(cacheRoot, rel) !== layerDir) {
342
+ throw new Error(`cache: layer escapes cache root: ${layer}`);
343
+ }
344
+ if (!existsSync(layerDir)) return [];
345
+ const out = [];
346
+ const walk = (dir) => {
347
+ for (const name of readdirSync(dir)) {
348
+ const full = join(dir, name);
349
+ const st = statSync(full);
350
+ if (st.isDirectory()) {
351
+ walk(full);
352
+ } else if (name.endsWith('.json')) {
353
+ let writtenAt = st.mtimeMs;
354
+ try {
355
+ const parsed = JSON.parse(readFileSync(full, 'utf8'));
356
+ if (typeof parsed.writtenAt === 'number') writtenAt = parsed.writtenAt;
357
+ } catch {
358
+ /* fall back to mtime */
359
+ }
360
+ out.push({
361
+ key: relative(layerDir, full).slice(0, -'.json'.length),
362
+ writtenAt,
363
+ ageMs: Date.now() - writtenAt,
364
+ bytes: st.size,
365
+ });
366
+ }
367
+ }
368
+ };
369
+ walk(layerDir);
370
+ return out.sort((a, b) => b.writtenAt - a.writtenAt);
371
+ }
372
+
373
+ /**
374
+ * Read the hit/miss counters. Returns { hits, misses, since } (empty maps if none).
375
+ */
376
+ export function stats(opts = {}) {
377
+ const cacheRoot = resolveCacheRoot(opts);
378
+ const statsPath = join(cacheRoot, STATS_FILE);
379
+ if (!existsSync(statsPath)) return { hits: {}, misses: {}, since: null };
380
+ try {
381
+ const s = JSON.parse(readFileSync(statsPath, 'utf8'));
382
+ return { hits: s.hits || {}, misses: s.misses || {}, since: s.since || null };
383
+ } catch {
384
+ return { hits: {}, misses: {}, since: null };
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Convenience: 8-char hex digest of arbitrary input, for building cache keys.
390
+ * Uses git's object hashing via a pure JS fallback-free path is unnecessary —
391
+ * callers usually pass a git SHA directly; this is for slug/brief digests.
392
+ */
393
+ export function sha8(input) {
394
+ const r = spawnSync('git', ['hash-object', '--stdin'], {
395
+ input: String(input),
396
+ encoding: 'utf8',
397
+ });
398
+ if (r.status === 0 && r.stdout) return r.stdout.trim().slice(0, 8);
399
+ // Fallback: tiny non-crypto FNV-1a → 8 hex chars (only when git is absent).
400
+ let h = 0x811c9dc5;
401
+ const str = String(input);
402
+ for (let i = 0; i < str.length; i++) {
403
+ h ^= str.charCodeAt(i);
404
+ h = Math.imul(h, 0x01000193);
405
+ }
406
+ return (h >>> 0).toString(16).padStart(8, '0');
407
+ }