@clearlysh/beehaven 0.8.16

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 (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/dist/__tests__/_cli-switch.js +105 -0
  4. package/dist/__tests__/agent-wrap-probe.js +136 -0
  5. package/dist/agent-attribution.js +192 -0
  6. package/dist/agent-identity-daemon.js +135 -0
  7. package/dist/agent-onboard.js +156 -0
  8. package/dist/agent-scope.js +79 -0
  9. package/dist/agent-sessions.js +370 -0
  10. package/dist/agent-state.js +482 -0
  11. package/dist/app-manifest.js +221 -0
  12. package/dist/app-signer.js +54 -0
  13. package/dist/app-signing.js +127 -0
  14. package/dist/audio-play.js +98 -0
  15. package/dist/avatar-protocol.js +25 -0
  16. package/dist/avatar-route.js +79 -0
  17. package/dist/bee/cloud-proxy.js +130 -0
  18. package/dist/blocked-actions.js +120 -0
  19. package/dist/build-channel.js +101 -0
  20. package/dist/buildings/daemon-workspace.js +475 -0
  21. package/dist/claude-bridge.js +310 -0
  22. package/dist/claude-credential-store.js +633 -0
  23. package/dist/clearly-sync.js +518 -0
  24. package/dist/clearly.js +616 -0
  25. package/dist/cli-state.js +334 -0
  26. package/dist/cli.js +6973 -0
  27. package/dist/commands/agent-skills.js +302 -0
  28. package/dist/commands/agent.js +1310 -0
  29. package/dist/commands/app.js +1123 -0
  30. package/dist/commands/auth.js +171 -0
  31. package/dist/commands/building-shell.js +392 -0
  32. package/dist/commands/canvas.js +932 -0
  33. package/dist/commands/claude.js +218 -0
  34. package/dist/commands/compliance.js +387 -0
  35. package/dist/commands/compose.js +507 -0
  36. package/dist/commands/composition.js +95 -0
  37. package/dist/commands/connect-session.js +409 -0
  38. package/dist/commands/conversations.js +160 -0
  39. package/dist/commands/costs.js +443 -0
  40. package/dist/commands/d1.js +253 -0
  41. package/dist/commands/daemon-cmd.js +162 -0
  42. package/dist/commands/deploy.js +164 -0
  43. package/dist/commands/design.js +617 -0
  44. package/dist/commands/dev-generate.js +125 -0
  45. package/dist/commands/dev.js +109 -0
  46. package/dist/commands/devices.js +135 -0
  47. package/dist/commands/explain-router.js +24 -0
  48. package/dist/commands/explain.js +882 -0
  49. package/dist/commands/explore.js +264 -0
  50. package/dist/commands/help.js +2418 -0
  51. package/dist/commands/init.js +408 -0
  52. package/dist/commands/install.js +129 -0
  53. package/dist/commands/library.js +229 -0
  54. package/dist/commands/marketplace.js +138 -0
  55. package/dist/commands/mcp-install.js +476 -0
  56. package/dist/commands/mention.js +257 -0
  57. package/dist/commands/omni.js +529 -0
  58. package/dist/commands/secrets.js +323 -0
  59. package/dist/commands/self-update.js +206 -0
  60. package/dist/commands/sheet.js +264 -0
  61. package/dist/commands/shell-output.js +309 -0
  62. package/dist/commands/shell.js +1048 -0
  63. package/dist/commands/shop.js +368 -0
  64. package/dist/commands/skills.js +311 -0
  65. package/dist/commands/slides.js +418 -0
  66. package/dist/commands/swarm.js +341 -0
  67. package/dist/commands/ticket.js +1301 -0
  68. package/dist/commands/tool.js +312 -0
  69. package/dist/commands/trash.js +106 -0
  70. package/dist/commands/treehouse.js +120 -0
  71. package/dist/commands/user.js +240 -0
  72. package/dist/commands/voice-tts.js +319 -0
  73. package/dist/commands/voice.js +166 -0
  74. package/dist/commands/watch-git.js +401 -0
  75. package/dist/commands/webhook.js +159 -0
  76. package/dist/commands/workspace.js +3354 -0
  77. package/dist/composition-repos.js +91 -0
  78. package/dist/daemon-avatar.js +130 -0
  79. package/dist/daemon-claude-code-auto-install.js +52 -0
  80. package/dist/daemon-client.js +363 -0
  81. package/dist/daemon-codex.js +349 -0
  82. package/dist/daemon-entry.js +101 -0
  83. package/dist/daemon-fetch-allowlist.js +194 -0
  84. package/dist/daemon-gemini.js +224 -0
  85. package/dist/daemon-mcp-clearly.js +353 -0
  86. package/dist/daemon-mcp-sandbox.js +127 -0
  87. package/dist/daemon-omni-company.js +338 -0
  88. package/dist/daemon-omni-hooks.js +375 -0
  89. package/dist/daemon-omni-identity.js +229 -0
  90. package/dist/daemon-omni-pipeline.js +251 -0
  91. package/dist/daemon-omni-policies.js +346 -0
  92. package/dist/daemon-omni-principal.js +580 -0
  93. package/dist/daemon-omni-tool-gate.js +116 -0
  94. package/dist/daemon-omni.js +1442 -0
  95. package/dist/daemon-path-transform.js +120 -0
  96. package/dist/daemon-write-sandbox.js +191 -0
  97. package/dist/daemon.js +4471 -0
  98. package/dist/dashboard.js +846 -0
  99. package/dist/dep-installer.js +286 -0
  100. package/dist/dev-mode.js +67 -0
  101. package/dist/doc-indexer.js +169 -0
  102. package/dist/e2e-crypto.js +94 -0
  103. package/dist/feature-flags.js +154 -0
  104. package/dist/file-browser.js +88 -0
  105. package/dist/first-party-hives.js +211 -0
  106. package/dist/folder-sync.js +226 -0
  107. package/dist/health.js +56 -0
  108. package/dist/ipc-auth.js +299 -0
  109. package/dist/ipc-client.js +495 -0
  110. package/dist/ipc-server.js +684 -0
  111. package/dist/keychain.js +224 -0
  112. package/dist/lib/admin-client.js +73 -0
  113. package/dist/lib/mobile-build-parser.js +156 -0
  114. package/dist/local-app-manager.js +660 -0
  115. package/dist/log-redaction.js +89 -0
  116. package/dist/logger.js +108 -0
  117. package/dist/manifest.js +143 -0
  118. package/dist/marketplace.js +793 -0
  119. package/dist/min-version-copy.js +51 -0
  120. package/dist/min-version.js +214 -0
  121. package/dist/public-surface.js +23 -0
  122. package/dist/relay-presets.js +89 -0
  123. package/dist/relay.js +2723 -0
  124. package/dist/repl.js +619 -0
  125. package/dist/setup-relay.js +363 -0
  126. package/dist/setup-service.js +536 -0
  127. package/dist/shared/bee-memory/index.js +6 -0
  128. package/dist/shared/bee-memory/types.js +682 -0
  129. package/dist/shared/claude-code/daemon-types.js +112 -0
  130. package/dist/shared/claude-code/index.js +2 -0
  131. package/dist/shared/claude-code/types.js +1 -0
  132. package/dist/shared/status/schema.js +133 -0
  133. package/dist/shell-session.js +198 -0
  134. package/dist/speak-lock.js +97 -0
  135. package/dist/spinner.js +57 -0
  136. package/dist/telemetry.js +166 -0
  137. package/dist/terminal/block-parser.js +207 -0
  138. package/dist/terminal/shell-integration.js +108 -0
  139. package/dist/transcript-tailer.js +215 -0
  140. package/dist/tts-qwen3.js +547 -0
  141. package/dist/types.js +4 -0
  142. package/dist/utm.js +38 -0
  143. package/dist/verb-surface.js +331 -0
  144. package/dist/version-utils.js +85 -0
  145. package/dist/watcher.js +207 -0
  146. package/package.json +88 -0
  147. package/python/qwen3_tts.py +230 -0
  148. package/scripts/stamp-build-channel.mjs +115 -0
  149. package/scripts/sync-installed.mjs +159 -0
  150. package/scripts/sync-skills.mjs +112 -0
  151. package/skills/README.md +24 -0
  152. package/skills/agent-doodle-space/SKILL.md +118 -0
  153. package/skills/agent-home/SKILL.md +98 -0
  154. package/skills/beehaven-cli/SKILL.md +144 -0
  155. package/skills/claude-shopper/README.md +90 -0
  156. package/skills/claude-shopper/SKILL.md +151 -0
  157. package/skills/clearly-agent/SKILL.md +183 -0
  158. package/skills/clearly-canvas/SKILL.md +222 -0
  159. package/skills/clearly-canvas/authoring.md +160 -0
  160. package/skills/clearly-canvas/communications.md +32 -0
  161. package/skills/clearly-canvas/composition-patterns.md +84 -0
  162. package/skills/clearly-canvas/example.mjs +78 -0
  163. package/skills/clearly-canvas/project-plans.md +156 -0
  164. package/skills/clearly-canvas/review.mjs +68 -0
  165. package/skills/clearly-workspace/SKILL.md +79 -0
  166. package/skills/clearly-workspace/survey.mjs +101 -0
  167. package/skills/codebase-map/SKILL.md +142 -0
  168. package/skills/design-craft/SKILL.md +300 -0
  169. package/skills/index.json +79 -0
  170. package/skills/layout-systems/SKILL.md +184 -0
  171. package/skills/pair-on-canvas/SKILL.md +92 -0
@@ -0,0 +1,882 @@
1
+ /**
2
+ * beehaven explain <action> — schema + examples for one action.
3
+ *
4
+ * Where `beehaven actions` lists every action name, `explain` renders
5
+ * ONE action's input schema, description, and 2-3 example invocations.
6
+ * Agents call this before `beehaven call <action>` to know what payload
7
+ * shape to send — no more guessing at field names.
8
+ *
9
+ * Strategy:
10
+ * 1. Try `describe-action` (home Hive exposes this); if the target
11
+ * has it, the response includes schema + description + examples.
12
+ * 2. If the target doesn't have describe-action (unknown-action error),
13
+ * fall back to `list-actions --format=full` and filter client-side.
14
+ * Every DO with list-actions + the right format flag gives us the
15
+ * same data.
16
+ * 3. If BOTH fail, suggest `beehaven cat /.workspace.json` — the raw
17
+ * manifest always carries action schemas.
18
+ *
19
+ * Routing: honors the CLI-side target (same as `call` / `actions`).
20
+ * Explicit --building wins, then file-based target, then legacy daemon
21
+ * session fallback.
22
+ */
23
+ import { rpcOnce } from '../ipc-client.js';
24
+ /** Same logic as workspace.ts:getTimeout — inlined to avoid importing the full module. */
25
+ function resolveTimeout(values) {
26
+ if (values.timeout) {
27
+ const n = parseInt(values.timeout, 10);
28
+ if (Number.isFinite(n) && n > 0)
29
+ return n;
30
+ }
31
+ return 30_000;
32
+ }
33
+ // ── Entry ──────────────────────────────────────────────────────────────
34
+ export async function cmdExplain(actionName, values = {}) {
35
+ if (!actionName) {
36
+ console.error(' Usage: beehaven explain <action>');
37
+ console.error(' Example: beehaven explain note-create');
38
+ console.error('');
39
+ console.error(' Tip: `beehaven actions` to list every action on the current target.');
40
+ process.exit(1);
41
+ }
42
+ if (!values.json) {
43
+ const target = values.building ? values.building : 'home';
44
+ console.error(`\x1b[2m→ ${target} describe-action ${actionName}\x1b[0m`);
45
+ }
46
+ const manifest = await fetchManifest(actionName, values);
47
+ if (!manifest) {
48
+ console.error(` No manifest for action "${actionName}".`);
49
+ console.error('');
50
+ console.error(' Try:');
51
+ console.error(' beehaven actions # is this action spelled right?');
52
+ console.error(' beehaven actions --search ' + actionName);
53
+ console.error(' beehaven cat /.workspace.json # raw manifest (always available)');
54
+ process.exit(1);
55
+ }
56
+ if (values.json) {
57
+ console.log(JSON.stringify(manifest, null, 2));
58
+ return;
59
+ }
60
+ render(manifest);
61
+ }
62
+ // ── Fetch ──────────────────────────────────────────────────────────────
63
+ async function fetchManifest(name, values) {
64
+ const timeout = resolveTimeout(values);
65
+ // Strategy 1: describe-action if available (home does).
66
+ try {
67
+ const { resolveBuildingForExplain } = await import('./explain-router.js');
68
+ const resolved = resolveBuildingForExplain('describe-action', { name }, values.building);
69
+ const raw = await rpcOnce(resolved.action, resolved.input, timeout);
70
+ const inner = unwrap(raw);
71
+ if (inner && !inner.error && (inner.name || inner.input || inner.description)) {
72
+ const m = normalize(inner);
73
+ return await enrichAlias(m, name, values, timeout);
74
+ }
75
+ }
76
+ catch {
77
+ // fall through to strategy 2
78
+ }
79
+ // Strategy 2: list-actions --format=full + filter.
80
+ try {
81
+ const { resolveBuildingForExplain } = await import('./explain-router.js');
82
+ const resolved = resolveBuildingForExplain('list-actions', { format: 'full' }, values.building);
83
+ const raw = await rpcOnce(resolved.action, resolved.input, timeout);
84
+ const inner = unwrap(raw);
85
+ const actions = inner?.actions ?? (Array.isArray(inner) ? inner : []);
86
+ const match = actions.find((a) => a.name === name);
87
+ if (match)
88
+ return normalize(match);
89
+ }
90
+ catch {
91
+ // fall through
92
+ }
93
+ return null;
94
+ }
95
+ /**
96
+ * ⚠⚠ AN ALIAS ANSWERS WITH A DIFFERENT SCHEMA THAN ITS CANONICAL NAME, SO ASK FOR BOTH (CBH2-18).
97
+ *
98
+ * `beehaven actions` LISTS actions under their alias names — `velocity` and `burndown` appear,
99
+ * `board-report` does not — so the name an agent discovers is exactly the name whose schema is
100
+ * degraded. One extra round trip on the rare alias path is worth strictly more than a correct
101
+ * answer that only the undiscoverable name can produce.
102
+ *
103
+ * ⚠ ONE EXTRA FETCH, AND ONLY ON AN ALIAS. `isAlias` comes from the server, so the common path
104
+ * (canonical name) costs nothing and this cannot recurse: the second call asks for the canonical
105
+ * name, whose own reply has `isAlias: false`.
106
+ *
107
+ * ⚠ NON-FATAL BY CONSTRUCTION. Enrichment is a bonus; a daemon hiccup on the second call must
108
+ * degrade to the alias's own manifest, never to an error. `explain` failing because the *optional*
109
+ * half of its answer failed would be strictly worse than the bug being fixed.
110
+ */
111
+ async function enrichAlias(m, requestedName, values, timeout) {
112
+ if (!m.isAlias || !m.name || m.name === requestedName)
113
+ return m;
114
+ try {
115
+ const { resolveBuildingForExplain } = await import('./explain-router.js');
116
+ const resolved = resolveBuildingForExplain('describe-action', { name: m.name }, values.building);
117
+ const raw = await rpcOnce(resolved.action, resolved.input, timeout);
118
+ const inner = unwrap(raw);
119
+ if (!inner || inner.error)
120
+ return m;
121
+ const canonical = normalize(inner);
122
+ return {
123
+ ...m,
124
+ input: mergeInputSchemas(m.input, canonical.input),
125
+ // The canonical registration is where a curated description lives, too.
126
+ description: m.description || canonical.description,
127
+ examples: m.examples ?? canonical.examples,
128
+ };
129
+ }
130
+ catch {
131
+ return m;
132
+ }
133
+ }
134
+ function unwrap(raw) {
135
+ if (!raw)
136
+ return null;
137
+ if (raw.type === 'ack' && raw.result !== undefined)
138
+ return raw.result;
139
+ if (raw.result !== undefined)
140
+ return raw.result;
141
+ return raw;
142
+ }
143
+ function normalize(raw) {
144
+ return {
145
+ name: raw.name ?? '',
146
+ description: raw.description,
147
+ category: raw.category,
148
+ aliases: Array.isArray(raw.aliases) ? raw.aliases : undefined,
149
+ input: raw.input ?? raw.inputSchema ?? raw.schema,
150
+ output: raw.output ?? raw.outputSchema,
151
+ examples: Array.isArray(raw.examples) ? raw.examples : undefined,
152
+ requested: typeof raw.requested === 'string' ? raw.requested : undefined,
153
+ isAlias: raw.isAlias === true ? true : undefined,
154
+ };
155
+ }
156
+ /**
157
+ * ⚠⚠ ONE ACTION, TWO PARTIAL SCHEMAS — MERGED PER PROPERTY, NOT PER ACTION (CBH2-18).
158
+ *
159
+ * `board-report` is reachable under four names, and the server answers each with a DIFFERENT
160
+ * subset of one schema. Measured live on staging:
161
+ *
162
+ * explain board-report → kind: { type:'string', description:'velocity | burndown | …' }
163
+ * explain velocity → kind: { type:'string', enum:['velocity','burndown',…] }
164
+ *
165
+ * So it is NOT simply that the alias is degraded — each NAME carries something the other lacks.
166
+ * The canonical name has the curated prose; the alias has the derived `enum`. A fix that merely
167
+ * preferred the canonical would recover the descriptions and still throw the enum away, i.e. fix
168
+ * the ticket and keep half the bug.
169
+ *
170
+ * ⚠ THE MERGE IS PER PROPERTY KEY, and that is the whole point. A whole-object
171
+ * `{...canonical, ...alias}` lets one side's `{type:'string'}` shadow the other side's
172
+ * `description` — the exact per-action-vs-per-field mistake `ACTION_INPUT_SCHEMAS` records making
173
+ * on `sheet-get`, where curation won outright and hid two working fields.
174
+ *
175
+ * ⚠ `required` IS UNIONED. Neither side is a superset: curation marks what it knows, the
176
+ * derivation marks what each handler's own "<field> is required" refusal proves.
177
+ *
178
+ * ⚠ NOT A GUESS WHEN EITHER SIDE IS NOT A JSON SCHEMA. A raw Zod descriptor map has no
179
+ * `properties`, so there is no key-wise merge to do; return the richer side untouched rather than
180
+ * inventing a shape neither server sent.
181
+ */
182
+ function mergeInputSchemas(requested, canonical) {
183
+ const isObj = (v) => !!v && typeof v === 'object';
184
+ if (!isObj(requested))
185
+ return canonical;
186
+ if (!isObj(canonical))
187
+ return requested;
188
+ const rp = requested.properties;
189
+ const cp = canonical.properties;
190
+ // One of them is not a JSON Schema (raw Zod map / field map) — do not invent a union.
191
+ if (!isObj(rp) || !isObj(cp))
192
+ return isObj(rp) ? requested : canonical;
193
+ const properties = {};
194
+ for (const key of new Set([...Object.keys(cp), ...Object.keys(rp)])) {
195
+ const rv = rp[key];
196
+ const cv = cp[key];
197
+ if (!isObj(rv) || !isObj(cv)) {
198
+ properties[key] = rv ?? cv;
199
+ continue;
200
+ }
201
+ // Requested wins on shared keys (it is the derived, complete-from-the-handler side)…
202
+ const merged = { ...cv, ...rv };
203
+ // …EXCEPT the description, which is the curated half and the reason to merge at all.
204
+ const prose = [cv?.description, rv?.description].find((d) => typeof d === 'string' && d.trim() !== '');
205
+ if (prose)
206
+ merged.description = prose;
207
+ else
208
+ delete merged.description;
209
+ properties[key] = merged;
210
+ }
211
+ const required = [
212
+ ...new Set([
213
+ ...(Array.isArray(canonical.required) ? canonical.required : []),
214
+ ...(Array.isArray(requested.required) ? requested.required : []),
215
+ ]),
216
+ ];
217
+ return {
218
+ ...canonical,
219
+ ...requested,
220
+ properties,
221
+ ...(required.length ? { required } : {}),
222
+ };
223
+ }
224
+ // ── Render ─────────────────────────────────────────────────────────────
225
+ function render(m) {
226
+ console.log('');
227
+ console.log(` ${bold(m.name)}${m.category ? dim(` [${m.category}]`) : ''}`);
228
+ if (m.description) {
229
+ console.log(indent(m.description));
230
+ }
231
+ console.log('');
232
+ // Aliases — quick scan row for agents
233
+ if (m.aliases && m.aliases.length > 0) {
234
+ console.log(` ${bold('Also called')} ${m.aliases.join(', ')}`);
235
+ console.log('');
236
+ }
237
+ // Input schema.
238
+ //
239
+ // ABSENCE IS NOT EMPTINESS. This printed "none (empty object)" whenever the manifest carried
240
+ // no schema — a positive claim that the action takes no arguments. 731 of 802 actions on the
241
+ // workspace register without a zod input (`defineApp(name, { run })` is the common form), so
242
+ // the CLI was telling an agent "this takes nothing" about 91% of the surface, including
243
+ // `ticket-create`, which requires projectId AND title.
244
+ //
245
+ // The server is not at fault: `describe-action` returns `input: null` for undeclared and a
246
+ // real object for declared. Only the renderer conflated them.
247
+ if (m.input && Object.keys(m.input).length > 0) {
248
+ console.log(` ${bold('Input')}`);
249
+ renderSchema(m.input);
250
+ console.log('');
251
+ }
252
+ else {
253
+ console.log(` ${bold('Input')} ${dim('not declared — the description above carries the shape')}`);
254
+ console.log('');
255
+ }
256
+ // Output schema
257
+ if (m.output && Object.keys(m.output).length > 0) {
258
+ console.log(` ${bold('Output')}`);
259
+ renderSchema(m.output);
260
+ console.log('');
261
+ }
262
+ // Examples
263
+ if (m.examples && m.examples.length > 0) {
264
+ console.log(` ${bold('Examples')}`);
265
+ for (const ex of m.examples) {
266
+ if (typeof ex === 'string') {
267
+ console.log(` beehaven call ${m.name} '${ex}'`);
268
+ }
269
+ else {
270
+ const input = typeof ex.input === 'string' ? ex.input : JSON.stringify(ex.input);
271
+ console.log(` beehaven call ${m.name} '${input}'`);
272
+ if (ex.note)
273
+ console.log(` ${dim('# ' + ex.note)}`);
274
+ }
275
+ }
276
+ console.log('');
277
+ }
278
+ else if (m.input && Object.keys(m.input).length > 0) {
279
+ // Synthesize a minimal example — but ONLY from a real schema. With no schema this
280
+ // synthesized `{}`, printing `beehaven call ticket-create '{}'` under the heading
281
+ // "Example": a copy-pasteable line that is guaranteed to fail. A fabricated example is
282
+ // worse than none, because it reads as verified.
283
+ const minimal = synthMinimalInput(m.input);
284
+ /**
285
+ * ⚠ AN EMPTY SYNTHESIS MEANS "we could not tell which fields are required" — print NOTHING
286
+ * rather than `'{}'`. `beehaven call ticket-create '{}'` under a heading that says "Example"
287
+ * is a copy-pasteable line guaranteed to fail, which is the case this block's own comment
288
+ * above was written for. The schema is rendered directly above; that is the honest answer.
289
+ */
290
+ if (Object.keys(minimal).length > 0) {
291
+ for (const line of exampleBlock(m.name, minimal))
292
+ console.log(line);
293
+ console.log('');
294
+ }
295
+ }
296
+ console.log(` ${dim('Call it:')} beehaven call ${m.name} '<json>'`);
297
+ console.log('');
298
+ }
299
+ /**
300
+ * The pure half of `renderSchema`: schema in, rows out. `null` means "not a field map" — the two
301
+ * degenerate cases the caller prints as `(opaque schema)` / the raw JSON.
302
+ */
303
+ function schemaRows(schema) {
304
+ if (!schema || typeof schema !== 'object')
305
+ return null;
306
+ // Zod-like nested: { shape: {...}, _def: {...} }
307
+ const fields = schema.shape ?? schema.properties ?? schema;
308
+ if (typeof fields !== 'object' || fields === null)
309
+ return null;
310
+ const entries = Object.entries(fields);
311
+ /**
312
+ * ⚠⚠ `required` LIVES ON THE SCHEMA, NOT ON THE FIELD — AND THIS DROPPED IT ENTIRELY (CBH2-11).
313
+ *
314
+ * `describeField` decides requiredness from `raw.optional`, which is the shape of our own
315
+ * `FieldSchema` (`{ type: 'string', optional: true }`). JSON Schema does not mark fields at all;
316
+ * it lists required NAMES in a sibling array. So for every JSON-Schema action `raw.optional` was
317
+ * `undefined`, `!undefined` was `true`, and the "optional" label never printed — every field
318
+ * rendered identically whether it was mandatory or not.
319
+ *
320
+ * ⚠ THE SERVER WAS SENDING IT THE WHOLE TIME. `describe-action { name: 'sprint-create' }`
321
+ * returns `required: ['boardId']`; only the renderer discarded it. And 614 fields across the
322
+ * derived map were promoted to `required` by reading each handler's own "<field> is required"
323
+ * refusal — all of that work was invisible at the one door agents are told to use.
324
+ *
325
+ * ⚠ MARKED POSITIVELY ("required"), NOT BY ABSENCE. With a JSON Schema the required set is the
326
+ * small, actionable one, and the old rendering made an unmarked field read as mandatory — the
327
+ * dangerous direction, because a caller who believes everything is required supplies junk for
328
+ * fields they should have omitted.
329
+ *
330
+ * ⚠ BOTH SHAPES STILL WORK: a sibling `required[]` wins where present; otherwise the per-field
331
+ * `optional` flag is honoured exactly as before, so nothing that rendered correctly changes.
332
+ */
333
+ const requiredNames = Array.isArray(schema?.required)
334
+ ? new Set(schema.required.map(String))
335
+ : null;
336
+ const alts = aliasGroups(schema);
337
+ return entries.map(([name, desc]) => {
338
+ const { type, required, hint } = describeField(desc);
339
+ /**
340
+ * ⚠⚠ THE ALIAS `required[]` CANNOT NAME (CBH2-91). 60 actions open
341
+ * `const compId = input.compositionId || input.id;` and refuse with the bare
342
+ * `'compositionId required'` — the refusal names ONE field, the code accepts EITHER. So `id`
343
+ * rendered as plain `optional` beside a required `compositionId`, which is not wrong about
344
+ * `compositionId` and says nothing at all about the alias.
345
+ *
346
+ * ⚠ THE ROW'S required/optional COLUMN IS UNTOUCHED, and that is deliberate. The deriver keeps
347
+ * `compositionId` in `required[]` because following it always produces a working call; a
348
+ * suppression that marked NEITHER was built, measured on 47 actions and removed. What was
349
+ * missing was only that `id` works too, and a hint is where that belongs — a third value in
350
+ * the required column would also overflow the 12-char pad every other row is aligned to.
351
+ */
352
+ const others = altsFor(alts, name);
353
+ const aliasHint = others.length
354
+ ? `${others.length === 1 ? 'or' : 'or one of'} \`${others.join('`, `')}\``
355
+ : null;
356
+ return {
357
+ name,
358
+ type,
359
+ required: requiredNames ? requiredNames.has(name) : required,
360
+ hint: [hint, aliasHint].filter(Boolean).join(' ') || undefined,
361
+ };
362
+ });
363
+ }
364
+ /**
365
+ * The alias disjunctions a schema declares: each group is a set of field names of which any one
366
+ * satisfies the same slot.
367
+ *
368
+ * ⚠ BOTH ENCODINGS. One disjunction is a top-level `anyOf`; several INDEPENDENT ones are an
369
+ * `allOf` of `anyOf`s, because flattening two groups into one `anyOf` would read as "any one of
370
+ * these five fields", a different and false claim. The deriver emits whichever fits; a reader that
371
+ * knew only `anyOf` would silently see nothing on `session-note-add`.
372
+ */
373
+ function aliasGroups(schema) {
374
+ const groups = [];
375
+ const take = (anyOf) => {
376
+ if (!Array.isArray(anyOf))
377
+ return;
378
+ const names = anyOf
379
+ .map((b) => (Array.isArray(b?.required) && b.required.length === 1 ? String(b.required[0]) : null))
380
+ .filter((n) => !!n);
381
+ if (names.length > 1)
382
+ groups.push(names);
383
+ };
384
+ take(schema?.anyOf);
385
+ if (Array.isArray(schema?.allOf))
386
+ for (const part of schema.allOf)
387
+ take(part?.anyOf);
388
+ return groups;
389
+ }
390
+ function altsFor(groups, name) {
391
+ const out = new Set();
392
+ for (const g of groups)
393
+ if (g.includes(name))
394
+ for (const n of g)
395
+ if (n !== name)
396
+ out.add(n);
397
+ return [...out];
398
+ }
399
+ /** The formatting half: rows in, printable lines out (padding + colour, no I/O). */
400
+ function formatSchemaRows(rows, indent = ' ') {
401
+ if (rows.length === 0)
402
+ return [`${indent}${dim('(empty)')}`];
403
+ const maxNameW = Math.min(30, Math.max(...rows.map((r) => r.name.length)));
404
+ return rows.map((r) => {
405
+ const nameCol = r.name.padEnd(maxNameW + 2);
406
+ const typeCol = r.type.padEnd(12);
407
+ /**
408
+ * ⚠ PAD THE VISIBLE TEXT, THEN COLOUR IT. `dim()` wraps the string in ANSI escapes, which
409
+ * `padEnd` counts as characters — so padding the coloured string mis-aligns every dimmed cell
410
+ * against every undimmed one. Width first, colour second.
411
+ */
412
+ /**
413
+ * ⚠ MARK BOTH WAYS ALWAYS. The fallback branch printed '' for required, so an action with a
414
+ * `required[]` said "required" and one without said nothing — two renderings of the same fact,
415
+ * and the blank one is indistinguishable from "we don't know".
416
+ */
417
+ const reqText = r.required ? 'required' : 'optional';
418
+ const padded = (' ' + reqText).padEnd(12);
419
+ const reqLabel = reqText === 'optional' ? dim(padded) : padded;
420
+ const hintLabel = r.hint ? ` ${dim('# ' + r.hint)}` : '';
421
+ return `${indent}${nameCol}${typeCol}${reqLabel}${hintLabel}`;
422
+ });
423
+ }
424
+ function renderSchema(schema, indent = ' ') {
425
+ const rows = schemaRows(schema);
426
+ if (rows === null) {
427
+ const isObj = !!schema && typeof schema === 'object';
428
+ console.log(`${indent}${dim(isObj ? JSON.stringify(schema) : '(opaque schema)')}`);
429
+ return;
430
+ }
431
+ for (const line of formatSchemaRows(rows, indent))
432
+ console.log(line);
433
+ }
434
+ /**
435
+ * ⚠⚠ THE IMPLEMENTATION'S TYPE NAMES LEAKED INTO THE PRODUCT (CBH2-31).
436
+ *
437
+ * The server answers "what does this take" in TWO shapes, and the renderer printed each one's
438
+ * vocabulary verbatim — so the same question got two visibly different answers:
439
+ *
440
+ * explain document-edit → id string required # The documentId…
441
+ * explain document-create → title ZodString? optional
442
+ *
443
+ * `ZodString?` is a zod INTERNAL, produced by `_describeInputSchema`'s walk of the registration.
444
+ * A caller reading it learns our validator library, not their argument type — and the trailing `?`
445
+ * duplicates, in a second notation, the requiredness the row already states in words.
446
+ *
447
+ * ⚠ THE `_def` BRANCH WAS ALREADY CORRECT and this is why the split was easy to miss: it does
448
+ * `replace(/^Zod/,'').toLowerCase()`, so it has always emitted `string`. Only the STRING-descriptor
449
+ * branch — the one the live server actually sends — passed the name through untouched.
450
+ *
451
+ * ⚠ ANCHORED ON `Zod` + AN UPPERCASE LETTER, so a user type that merely starts with those three
452
+ * characters is left alone. A blanket `/^Zod/` turns a field typed `Zodiac` into `iac`.
453
+ *
454
+ * ⚠ THE FALLBACK IS STRIP-AND-LOWERCASE, NOT `unknown`. An unmapped zod type still reads better as
455
+ * its own lowercased name (`ZodOptional` → `optional`) than as a word that claims we know nothing.
456
+ */
457
+ const ZOD_TYPE_ALIASES = {
458
+ bigint: 'number',
459
+ nativeenum: 'enum',
460
+ record: 'object',
461
+ tuple: 'array',
462
+ };
463
+ function normalizeTypeName(raw) {
464
+ const m = /^Zod([A-Z].*)$/.exec(raw);
465
+ if (!m)
466
+ return raw;
467
+ const bare = m[1].toLowerCase();
468
+ return ZOD_TYPE_ALIASES[bare] ?? bare;
469
+ }
470
+ /**
471
+ * ⚠⚠ THE ENUM WAS IN THE SCHEMA AND ON NEITHER LINE OF THE OUTPUT (CBH2-33).
472
+ *
473
+ * `canvas-align-blocks.axis` arrives as `{ type:'string', enum:['left','center-h',…] }`. The row
474
+ * printed `axis string required` and the Example printed `"axis":"example"` — so the six legal
475
+ * values were dropped from the description AND from the one line built to be pasted. Everything
476
+ * needed to be right was already in hand.
477
+ */
478
+ function fieldEnum(raw) {
479
+ const e = raw?.enum ?? raw?.enumValues;
480
+ if (Array.isArray(e) && e.length > 0)
481
+ return e.map(String);
482
+ return undefined;
483
+ }
484
+ function describeField(raw) {
485
+ if (typeof raw === 'string') {
486
+ /**
487
+ * ⚠⚠ THE TRAILING `?` IS THE OPTIONALITY MARKER AND THIS DISCARDED IT (CBH2-11 / CBH2-16).
488
+ *
489
+ * The server renders a zod field as the string `"ZodString?"` — `_describeInputSchema` appends
490
+ * `?` for `ZodOptional`/`ZodDefault`. This branch returned `required: true` unconditionally, so
491
+ * every raw-Zod action printed all fields as required and `synthMinimalInput` put all of them
492
+ * in the example. That is why `explain composition-create` still listed eight fields after the
493
+ * first fix: `fieldsDeclareOptionality` saw string descriptors and believed optionality was
494
+ * knowable, while this function could not read it.
495
+ *
496
+ * ⚠ FOUND BY A REVIEWER EXECUTING THE CODE, not by reading it — and it is one root cause under
497
+ * two tickets. The `?` was in the data the whole time.
498
+ */
499
+ const optional = raw.endsWith('?');
500
+ return { type: normalizeTypeName(optional ? raw.slice(0, -1) : raw), required: !optional };
501
+ }
502
+ if (!raw || typeof raw !== 'object') {
503
+ return { type: 'unknown', required: true };
504
+ }
505
+ // Our FieldSchema shape: { type: 'string', optional: true, default?: ... }
506
+ if (raw.type) {
507
+ const enumValues = fieldEnum(raw);
508
+ return {
509
+ type: normalizeTypeName(String(raw.type)),
510
+ required: !raw.optional,
511
+ // ⚠ PROSE FIRST, ENUM AS THE FALLBACK. A curated description usually spells the options out
512
+ // already ("velocity | burndown | cycle-time | summary"), so printing both is one fact twice.
513
+ hint: raw.description || (enumValues ? enumValues.join(' | ') : undefined),
514
+ enumValues,
515
+ };
516
+ }
517
+ // Zod-ish: { _def: { typeName: 'ZodString' } }
518
+ if (raw._def) {
519
+ const tn = raw._def.typeName ?? 'unknown';
520
+ const type = normalizeTypeName(tn);
521
+ const required = tn !== 'ZodOptional' && tn !== 'ZodDefault';
522
+ return { type, required };
523
+ }
524
+ return { type: 'object', required: true, hint: 'nested — see --json' };
525
+ }
526
+ /**
527
+ * ⚠⚠ THE "MINIMAL" EXAMPLE WAS NEITHER MINIMAL NOR RUNNABLE (CBH2-16).
528
+ *
529
+ * Two defects, one root cause shared with the required-field bug above:
530
+ *
531
+ * 1. IT INCLUDED EVERY FIELD. Requiredness came from `describeField().required`, which reads a
532
+ * per-field `optional` flag that JSON Schema does not have — so `!undefined` was `true` and
533
+ * nothing was filtered. `document-create`'s example listed eight fields including `html`,
534
+ * `markdown`, `content`, `body` and `text`, which are MUTUALLY EXCLUSIVE spellings of one
535
+ * argument. Pasting it asks for five bodies at once.
536
+ * 2. UNKNOWN TYPES BECAME `null`. `sampleValue` fell through to `null`, producing
537
+ * `{"title":null,"data":null,…}` — printed under a heading that says "Example", above a line
538
+ * that says "Call it:". Measured in `agent_activity` during this sprint: an agent pasted one
539
+ * and got `unknown kind 'example'` back.
540
+ *
541
+ * ⚠ AN UNRUNNABLE EXAMPLE IS WORSE THAN NO EXAMPLE. No example makes a caller read the schema; a
542
+ * broken one makes them run it, and on a WRITE verb that means a junk artifact in the workspace.
543
+ */
544
+ function synthMinimalInput(schema) {
545
+ if (!schema || typeof schema !== 'object')
546
+ return {};
547
+ const fields = schema.shape ?? schema.properties ?? schema;
548
+ if (typeof fields !== 'object' || fields === null)
549
+ return {};
550
+ // ⚠ The sibling `required[]` is the real answer when present — see `renderSchema`.
551
+ const requiredNames = Array.isArray(schema.required)
552
+ ? new Set(schema.required.map(String))
553
+ : null;
554
+ /**
555
+ * ⚠⚠ NO `required[]` MEANS WE CANNOT TELL — SO DO NOT CLAIM A MINIMAL EXAMPLE.
556
+ *
557
+ * Without it every field looked required, so the "minimal" example listed ALL of them. On
558
+ * `document-create` that is `html`, `markdown`, `content`, `body` AND `text` — five MUTUALLY
559
+ * EXCLUSIVE spellings of one argument, printed as a single copy-pasteable line. That is not a
560
+ * minimal example, it is an incorrect call rendered under a heading that says "Example".
561
+ *
562
+ * ⚠ Returning `{}` here makes the caller fall through to NO example block at all, which is what
563
+ * this file already argues for elsewhere: "A fabricated example is worse than none, because it
564
+ * reads as verified." The schema is printed directly above; it is the honest answer.
565
+ */
566
+ /**
567
+ * ⚠⚠ THREE CASES, AND COLLAPSING THEM IS A REGRESSION EITHER WAY. A first cut returned `{}`
568
+ * whenever there was no `required[]` — which is right for a JSON Schema and WRONG for our own
569
+ * FieldSchema shape, where requiredness is genuinely knowable from a per-field `optional` flag.
570
+ * It silently removed the example from every FieldSchema action. Caught by the existing suite.
571
+ */
572
+ const entries = Object.entries(fields);
573
+ const fieldsDeclareOptionality = entries.some(([, d]) => typeof d === 'string' || (d && typeof d === 'object' && 'optional' in d));
574
+ // Neither source of truth: a JSON Schema with no `required[]` and no per-field flags. We cannot
575
+ // tell, so we do not guess — see the caller, which then prints no Example at all.
576
+ if (!requiredNames && !fieldsDeclareOptionality)
577
+ return {};
578
+ const out = {};
579
+ for (const [name, desc] of entries) {
580
+ const { required } = describeField(desc);
581
+ const isRequired = requiredNames ? requiredNames.has(name) : required;
582
+ /**
583
+ * ⚠⚠ AN AUTHOR-SUPPLIED `example` OUTRANKS REQUIREDNESS (CBH2-97).
584
+ *
585
+ * `board-update` and `ticket-update` LIFT a flat call into `updates`, so `updates` is genuinely
586
+ * not required — and dropping it from `required[]` (the honest schema) also dropped it from the
587
+ * example, leaving `'{"id":"example"}'`: a copy-pasteable call that answers `changed: false`.
588
+ * A confident no-op, which is the very class CBH2-57 exists for.
589
+ *
590
+ * A field carrying an explicit `example` is an author saying "put THIS here" — a stronger
591
+ * statement than requiredness, and the only way to show a working shape for a field the schema
592
+ * correctly marks optional. `sampleValue` already prefers `example` over everything else; this
593
+ * is what lets the field reach it.
594
+ *
595
+ * ⚠ ZERO BLAST RADIUS AT THE TIME OF WRITING, MEASURED: 0 of 414 curated properties carried
596
+ * `example`, `default` or `const`, so nothing that prints an Example today changes shape. Only
597
+ * `default`/`const` are deliberately NOT honoured here — a default is what the handler would
598
+ * have used anyway, so putting it in a "minimal" example adds noise rather than meaning.
599
+ */
600
+ const carriesExample = !!desc && typeof desc === 'object' && 'example' in desc;
601
+ if (!isRequired && !carriesExample)
602
+ continue;
603
+ /**
604
+ * ⚠ THE WHOLE DESCRIPTOR, NOT ITS TYPE. This passed `type` — a lowercase string — so `enum`,
605
+ * `default` and `const` were unreachable from here and every constrained field got the
606
+ * `"example"` placeholder that its own schema forbids (CBH2-33).
607
+ */
608
+ out[name] = sampleValue(desc);
609
+ }
610
+ return out;
611
+ }
612
+ /**
613
+ * ⚠⚠ THE EXAMPLE WAS WELL-FORMED, RUNNABLE, AND STILL WROTE JUNK INTO THE GALLERY (CBH2-57).
614
+ *
615
+ * CBH2-15/16/33 fixed everything that made the printed line INVALID — the `null` values, the
616
+ * eight-mutually-exclusive-fields "minimal" example, the `"example"` string in a required enum.
617
+ * Swept across all 1,115 derived schemas on 2026-09-06: **591 print an Example and every one of
618
+ * them now satisfies its own schema** — 0 missing-required, 0 illegal enum, 0 type mismatch, 0
619
+ * nulls. The generator is correct.
620
+ *
621
+ * ⚠⚠ AND THE ORIGINAL HARM SURVIVED ALL OF IT, because it was never about validity. The ticket is
622
+ * titled *"prints a runnable example that writes junk into the gallery"*, and a hunter created a
623
+ * real card, `PLA-3517`, by pasting one. `beehaven call composition-create '{"title":"example"}'`
624
+ * is a perfectly valid call that puts a composition named "example" in someone's workspace. Making
625
+ * it valid made it MORE likely to be run, not less.
626
+ *
627
+ * So the fix is at the render, not the generator: a mutating verb's example is presented as a
628
+ * SHAPE, and the line says what running it verbatim would create. The reader keeps the exact call
629
+ * form — which is the whole value of an example to an agent — and loses the invitation.
630
+ *
631
+ * ⚠ IT IS A NAME-SHAPE HEURISTIC AND IT SAYS SO. There is no `mutates: true` on a registration to
632
+ * read, and inventing one for ~1,100 actions is a different ticket. The failure modes are
633
+ * asymmetric and that is what makes a heuristic acceptable here: a false POSITIVE prints a caution
634
+ * on a harmless read (noise), a false NEGATIVE prints an unmarked write (the bug). So the list
635
+ * leans inclusive, and `explain` is a documentation surface where over-cautioning costs nothing.
636
+ *
637
+ * ⚠ MATCHED ON WORD BOUNDARIES INSIDE A HYPHENATED NAME, never as a substring. `document-restore`
638
+ * must match `restore`; `board-report` must NOT match on `report` containing no verb at all, and —
639
+ * the case that actually bites — `note-content-seed` must not be missed because the verb is last
640
+ * while `list-actions` must not be caught because `list` is first. Splitting on `-`/`:` and testing
641
+ * each segment is what makes both come out right.
642
+ */
643
+ const MUTATING_SEGMENTS = new Set([
644
+ 'create', 'new', 'add', 'insert', 'append', 'seed', 'write', 'update', 'patch', 'edit', 'set',
645
+ 'rename', 'move', 'copy', 'duplicate', 'delete', 'remove', 'destroy', 'purge', 'empty', 'clear',
646
+ 'archive', 'restore', 'revert', 'rollback', 'rewind', 'undo', 'redo', 'send', 'publish', 'post',
647
+ 'upload', 'import', 'export', 'generate', 'draw', 'record', 'revoke', 'grant', 'approve',
648
+ 'reject', 'decide', 'start', 'stop', 'complete', 'cancel', 'reset', 'repair', 'backfill',
649
+ 'migrate', 'sync', 'install', 'uninstall', 'register', 'link', 'unlink', 'invite', 'admit',
650
+ 'evict', 'assign', 'toggle', 'pin', 'unpin', 'star', 'snooze', 'mark', 'takeover', 'upgrade',
651
+ 'downgrade', 'topup', 'checkout', 'claim', 'compact', 'vacuum', 'reap', 'apply', 'commit',
652
+ /**
653
+ * ⚠ ADDED FROM THE MEASURED "unknown" BUCKET (CBH2-96), each against a real action rather than
654
+ * from imagination: `agent-memory-forget`/`-remember`, `agent-slug-rotate`,
655
+ * `avatar:honey-spend`, `avatar:champion-unlock`, `avatar:accessory-equip`/`-unequip`,
656
+ * `admin:broadcast`, `admin:migration-run`, `_mcp-oauth-finalize`, `agent-mint-token`,
657
+ * `agent-session-end`, `agent-sandbox-exec`, `avatar:bootstrap`. Adding a segment is only safe
658
+ * when it names a verb that WRITES — every entry here moves actions out of "cannot tell" and
659
+ * into a positive claim, so a wrong one is the false-positive noise the list tolerates, but a
660
+ * word that is usually a noun (`report`, `session`) would move reads too.
661
+ */
662
+ 'forget', 'remember', 'rotate', 'spend', 'unlock', 'equip', 'unequip', 'broadcast', 'finalize',
663
+ 'mint', 'end', 'bootstrap', 'run', 'exec',
664
+ ]);
665
+ /**
666
+ * ⚠⚠ THE THIRD STATE: A NAME THAT CARRIES NO VERB AT ALL (CBH2-96).
667
+ *
668
+ * The classifier was a boolean over `MUTATING_SEGMENTS`, so every name it did not recognise fell
669
+ * into the SAFE bucket — and CBH2-96 names that blind spot exactly: *"a verb whose name carries no
670
+ * verb at all is still invisible to it"*. Measured across all 1,115 derived actions: 468 named a
671
+ * write and **647 did not** — and of those, only 320 name a READ. The other **327, 29% of the
672
+ * surface, named neither and were silently filed as harmless.** The bucket is not a corner:
673
+ * `agent-memory-forget`, `avatar:honey-spend`, `admin:broadcast`, `admin:migration-run`, `Bash`
674
+ * and `MultiEdit` were all in it. After the segments added below it is 500 / 320 / 295.
675
+ *
676
+ * ⚠⚠ THE DECISION, since CBH2-96 asks for one: KEEP THE HEURISTIC, STOP OVERSTATING IT, AND STOP
677
+ * DEFAULTING TO SAFE. A declared `mutates: true` on the registration is the real fix and remains
678
+ * the right one — it would also let `_rpc:catalog` report it and let the CLI's spend gate reason
679
+ * about it — but it is ~1,100 registrations across files this change does not own, and half of it
680
+ * (some declared, the rest inferred) is worse than either: a reader cannot tell which verbs were
681
+ * declared, so an unmarked write still reads as a checked one.
682
+ *
683
+ * What was actually wrong was not the inference, it was the CLAIM. The line asserted
684
+ * `⚠ <action> WRITES` — a fact a name test cannot establish — so a false positive was not merely
685
+ * noise, it was a confident wrong statement about someone's data. Three states, each saying only
686
+ * what it knows:
687
+ *
688
+ * write reads as a write verb → say so, and say it is read from the name
689
+ * read reads as a read verb → no caution
690
+ * unknown neither → say we cannot tell
691
+ *
692
+ * ⚠ The asymmetry that makes a heuristic acceptable is unchanged and now applies to the third
693
+ * state too: over-cautioning on a documentation surface costs a line of text, and an unmarked
694
+ * write costs someone real workspace data.
695
+ */
696
+ const READ_SEGMENTS = new Set([
697
+ 'list', 'get', 'search', 'read', 'find', 'show', 'describe', 'explain', 'status', 'report',
698
+ 'stats', 'diff', 'history', 'count', 'counts', 'info', 'catalog', 'perceive', 'look', 'preview',
699
+ 'brief', 'log', 'logs', 'map', 'summary', 'view', 'inspect', 'query', 'check', 'ask', 'recall',
700
+ 'resolve', 'actions', 'items', 'members', 'columns', 'comments', 'links', 'tree', 'ls', 'cat',
701
+ 'grep', 'glob', 'peek', 'fetch', 'estimate', 'validate', 'verify', 'compare', 'suggest',
702
+ 'recent', 'feed', 'usage', 'quota', 'whoami', 'ping', 'health', 'schema', 'sql', 'stat',
703
+ ]);
704
+ /**
705
+ * ⚠ WRITE WINS A TIE. `document-list` is a read and `note-content-seed` is a write; a name holding
706
+ * both kinds of segment (`board-report-export`) is a write, because that is the direction whose
707
+ * failure costs data.
708
+ */
709
+ export function classifyExample(action) {
710
+ const segs = String(action ?? '').toLowerCase().split(/[-:._]/);
711
+ if (segs.some((s) => MUTATING_SEGMENTS.has(s)))
712
+ return 'write';
713
+ if (segs.some((s) => READ_SEGMENTS.has(s)))
714
+ return 'read';
715
+ return 'unknown';
716
+ }
717
+ /**
718
+ * ⚠ KEPT AS THE BOOLEAN FACE OF `classifyExample`, never a second implementation. Existing guards
719
+ * assert on it in both directions (`note-content-seed` caught, `list-actions` and `board-report`
720
+ * not), and two independent name tests are exactly how the renderer and its guard drift apart.
721
+ */
722
+ export function exampleMutates(action) {
723
+ return classifyExample(action) === 'write';
724
+ }
725
+ /**
726
+ * The Example block, as LINES — pure, so a guard can assert what is printed without a terminal.
727
+ *
728
+ * ⚠⚠ RETURNING LINES RATHER THAN PRINTING IS THE POINT. `render` is `void` and talks to
729
+ * `console.log`, and this file's own history (CBH2-25) is guards that read it as TEXT and called
730
+ * themselves behavioural: "a reviewer broke the fix in one line and they stayed green". A guard
731
+ * still has to prove `render` CALLS this — a correct helper nobody calls proves nothing — so the
732
+ * suite captures `console.log` around the real `render` as well as unit-testing this.
733
+ */
734
+ export function exampleBlock(action, minimal) {
735
+ const call = ` beehaven call ${action} '${JSON.stringify(minimal)}'`;
736
+ const risk = classifyExample(action);
737
+ /**
738
+ * ⚠⚠ THE HEADING IS THE SAME FOR EVERY RISK CLASS — CBH2-17, AND THE READ BRANCH IS WHY.
739
+ *
740
+ * It used to return `[bold('Example'), call]` for a read: one heading, two behaviours, and the
741
+ * unlabelled half is the one the ticket copy-pasted. `beehaven call document-get
742
+ * '{"id":"example"}'` under a bare heading that says "Example" answers `document not found`.
743
+ *
744
+ * ⚠⚠ THE LABEL IS THE FIX, NOT A BETTER VALUE, AND THAT IS NOT A PREFERENCE — it is forced.
745
+ * An `id` names one row in one workspace, so there is no generic value that could stand in for
746
+ * it; ANY placeholder a synthesizer picks must fail. CBH2-15/16/33 already made these lines
747
+ * VALID against their own schemas (591 of 1,115 print one, all valid) and validity was never the
748
+ * complaint. A line that parses, type-checks and fails is exactly the shape that reads as a
749
+ * working invocation.
750
+ *
751
+ * ⚠ THE *CAUTION* STAYS DIFFERENTIATED, AND THE OLD GUARD'S ARGUMENT FOR THAT IS UNTOUCHED:
752
+ * "a caution on every row is a caution nobody reads". That argument is about the CONSEQUENCE
753
+ * line — a write creates real data, a read merely fails — and none of it applies to a statement
754
+ * about what the values ARE. Reads carry the heading and no third line.
755
+ */
756
+ const heading = ` ${bold('Example')} ${dim('— SHAPE ONLY. The values are placeholders, not a suggestion.')}`;
757
+ if (risk === 'read')
758
+ return [heading, call];
759
+ const caution = risk === 'write'
760
+ ? `⚠ ${action} reads as a WRITE verb (inferred from its name). Run this verbatim and you create real workspace data named "example".`
761
+ : `⚠ ${action}'s name says neither read nor write, so this cannot tell you which it is. Check before running it verbatim — if it writes, you create real workspace data named "example".`;
762
+ return [heading, call, ` ${dim(caution)}`];
763
+ }
764
+ /**
765
+ * ⚠⚠ IT ONLY EVER SAW A TYPE NAME, SO IT COULD NOT HAVE BEEN RIGHT (CBH2-33).
766
+ *
767
+ * `synthMinimalInput` called `sampleValue(type)` — a single lowercase string — and threw the rest
768
+ * of the field descriptor away. Everything that makes a value LEGAL rather than merely
769
+ * well-typed lives in the part that was discarded: `enum`, `default`, `const`, `example`. So a
770
+ * constrained field could only ever be filled with a placeholder that the handler must reject:
771
+ *
772
+ * explain canvas-align-blocks → '{"blockIds":[],"axis":"example"}'
773
+ * …while the schema says axis: enum ['left','center-h','right','top','center-v','bottom']
774
+ *
775
+ * 23 required-enum fields across the derived map were emitting a guaranteed-invalid example.
776
+ *
777
+ * ⚠ IT STILL ACCEPTS A BARE TYPE STRING. That is the FieldSchema path and three existing tests
778
+ * pin it; a previous pass on this file regressed exactly that surface. `string | descriptor`
779
+ * keeps the old contract byte-for-byte and adds the new one beside it.
780
+ *
781
+ * ⚠ ORDER: `example` → `default` → `const` → `enum[0]` → type. An author-supplied value outranks
782
+ * anything synthesized, and a declared default is what the handler would have used anyway.
783
+ * `enum[0]` is last of the real values because it is arbitrary among the legal ones — but arbitrary
784
+ * AND legal beats `"example"`, which is neither.
785
+ */
786
+ function sampleValue(field) {
787
+ if (field !== null && typeof field === 'object') {
788
+ if (field.example !== undefined)
789
+ return field.example;
790
+ if (Array.isArray(field.examples) && field.examples.length > 0)
791
+ return field.examples[0];
792
+ if (field.default !== undefined)
793
+ return field.default;
794
+ if (field.const !== undefined)
795
+ return field.const;
796
+ const e = fieldEnum(field);
797
+ if (e)
798
+ return e[0];
799
+ return sampleValue(describeField(field).type);
800
+ }
801
+ /**
802
+ * ⚠ THE STRING PATH GOES THROUGH `describeField` TOO, or the zod spelling silently degrades.
803
+ * The live server sends `"ZodNumber?"`, which matches no case below and would fall through to
804
+ * the `'example'` default — a STRING placeholder in a numeric field, which is the first of the
805
+ * three defects CBH2-33 reports. One vocabulary in, one vocabulary out.
806
+ */
807
+ const type = typeof field === 'string' ? describeField(field).type : 'unknown';
808
+ switch (type.toLowerCase()) {
809
+ case 'string':
810
+ return 'example';
811
+ case 'number':
812
+ case 'real':
813
+ case 'float':
814
+ return 0;
815
+ case 'int':
816
+ case 'integer':
817
+ return 0;
818
+ case 'boolean':
819
+ case 'bool':
820
+ return true;
821
+ case 'array':
822
+ return [];
823
+ case 'object':
824
+ case 'json':
825
+ return {};
826
+ case 'date':
827
+ return new Date().toISOString();
828
+ default:
829
+ /**
830
+ * ⚠ NEVER `null`. An unrecognised type used to fall through to null, which renders as
831
+ * `{"title":null}` — a value that reads as "explicitly blank" rather than "we did not know",
832
+ * and which several handlers accept and store. A placeholder string is obviously a
833
+ * placeholder; `null` is obviously nothing, and the two are not confusable in a paste.
834
+ */
835
+ return 'example';
836
+ }
837
+ }
838
+ // ── format helpers ─────────────────────────────────────────────────────
839
+ function bold(s) {
840
+ return `\x1b[1m${s}\x1b[0m`;
841
+ }
842
+ function dim(s) {
843
+ return `\x1b[2m${s}\x1b[0m`;
844
+ }
845
+ function indent(s) {
846
+ return s
847
+ .split('\n')
848
+ .map((l) => ' ' + l.trim())
849
+ .join('\n');
850
+ }
851
+ /**
852
+ * Exposed for tests.
853
+ *
854
+ * ⚠⚠ `schemaRows` / `formatSchemaRows` ARE HERE SO THE GUARDS CAN RUN THE CODE (CBH2-25).
855
+ * A previous pass on this file shipped guards that read `explain.ts` as TEXT and called themselves
856
+ * behavioural in their own docstrings; a reviewer broke the fix in one line and they stayed green.
857
+ * Anything a guard needs to assert about rendering must be reachable as a function, not as a
858
+ * regex over the source — so the extraction IS the fix, not tidying.
859
+ */
860
+ export const __testing = {
861
+ normalize,
862
+ describeField,
863
+ synthMinimalInput,
864
+ sampleValue,
865
+ schemaRows,
866
+ formatSchemaRows,
867
+ mergeInputSchemas,
868
+ normalizeTypeName,
869
+ exampleMutates,
870
+ classifyExample,
871
+ aliasGroups,
872
+ exampleBlock,
873
+ /**
874
+ * ⚠⚠ `render` IS EXPORTED SO A GUARD CAN ASSERT THE REAL CALL SITE, NOT JUST THE HELPER (CBH2-57).
875
+ *
876
+ * `exampleBlock` being correct proves nothing about what `explain` prints — the previous version
877
+ * of this defect was exactly that shape, a generator that had been fixed while the line a user
878
+ * reads had not. The suite captures `console.log` around this function and asserts the actual
879
+ * output, so deleting the call and inlining the old one-liner fails.
880
+ */
881
+ render,
882
+ };