@wootsup/yt-builder-mcp 1.10.0 → 1.12.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 (215) hide show
  1. package/README.md +12 -11
  2. package/bin/yt-builder-mcp.js +1 -1
  3. package/dist/auth.d.ts +1 -1
  4. package/dist/auth.js +1 -1
  5. package/dist/catalog/tool-catalog-markdown.d.ts.map +1 -1
  6. package/dist/catalog/tool-catalog-markdown.js +13 -1
  7. package/dist/catalog/tool-catalog-markdown.js.map +1 -1
  8. package/dist/cli/doctor-command.d.ts.map +1 -1
  9. package/dist/cli/doctor-command.js +22 -2
  10. package/dist/cli/doctor-command.js.map +1 -1
  11. package/dist/client.d.ts +3 -3
  12. package/dist/client.js +1 -1
  13. package/dist/client.js.map +1 -1
  14. package/dist/clients/index.d.ts +1 -1
  15. package/dist/clients/index.js +1 -1
  16. package/dist/diagnostics/ca-reexec.js +2 -2
  17. package/dist/diagnostics/diagnose-network-error.d.ts +1 -1
  18. package/dist/diagnostics/doctor.d.ts +34 -2
  19. package/dist/diagnostics/doctor.d.ts.map +1 -1
  20. package/dist/diagnostics/doctor.js +63 -2
  21. package/dist/diagnostics/doctor.js.map +1 -1
  22. package/dist/diagnostics/startup-self-probe.js +1 -1
  23. package/dist/gateway/advanced-read-tool.d.ts.map +1 -1
  24. package/dist/gateway/advanced-read-tool.js +14 -2
  25. package/dist/gateway/advanced-read-tool.js.map +1 -1
  26. package/dist/gateway/advanced-tool/discovery.d.ts.map +1 -1
  27. package/dist/gateway/advanced-tool/discovery.js +12 -1
  28. package/dist/gateway/advanced-tool/discovery.js.map +1 -1
  29. package/dist/gateway/advanced-tool/domains.d.ts.map +1 -1
  30. package/dist/gateway/advanced-tool/domains.js +5 -0
  31. package/dist/gateway/advanced-tool/domains.js.map +1 -1
  32. package/dist/gateway/advanced-tool/execute.d.ts +7 -1
  33. package/dist/gateway/advanced-tool/execute.d.ts.map +1 -1
  34. package/dist/gateway/advanced-tool/execute.js +58 -4
  35. package/dist/gateway/advanced-tool/execute.js.map +1 -1
  36. package/dist/gateway/essentials.d.ts +4 -4
  37. package/dist/gateway/essentials.d.ts.map +1 -1
  38. package/dist/gateway/essentials.js +12 -7
  39. package/dist/gateway/essentials.js.map +1 -1
  40. package/dist/install-skill.js +1 -1
  41. package/dist/net/extra-headers.d.ts +1 -1
  42. package/dist/net/extra-headers.js +1 -1
  43. package/dist/net/internal-host.js +2 -2
  44. package/dist/net/proxy-dispatcher.js +1 -1
  45. package/dist/net/proxy-dispatcher.js.map +1 -1
  46. package/dist/net/site-basic-auth.d.ts +2 -2
  47. package/dist/net/site-basic-auth.js +2 -2
  48. package/dist/proxy/bridge.d.ts +2 -2
  49. package/dist/proxy/bridge.js +3 -3
  50. package/dist/proxy/bridge.js.map +1 -1
  51. package/dist/server.d.ts +3 -3
  52. package/dist/server.js +3 -3
  53. package/dist/setup-cli.d.ts +2 -2
  54. package/dist/setup-cli.js +2 -2
  55. package/dist/setup-cli.js.map +1 -1
  56. package/dist/setup-npx-spec.d.ts +1 -1
  57. package/dist/setup-npx-spec.js +1 -1
  58. package/dist/setup-wizard-handshake.js +1 -1
  59. package/dist/setup-wizard-types.d.ts +1 -1
  60. package/dist/setup-wizard.js +3 -3
  61. package/dist/setup-wizard.js.map +1 -1
  62. package/dist/sites/client-pool.d.ts +1 -1
  63. package/dist/sites/client-pool.js +1 -1
  64. package/dist/sites/tools/sites-list.d.ts.map +1 -1
  65. package/dist/sites/tools/sites-list.js +26 -4
  66. package/dist/sites/tools/sites-list.js.map +1 -1
  67. package/dist/sites/tools/sites-test.js +1 -1
  68. package/dist/sites/tools/sites-test.js.map +1 -1
  69. package/dist/sites/tools/use-site.js +2 -2
  70. package/dist/sites/tools/use-site.js.map +1 -1
  71. package/dist/tools/budgeted-table.d.ts +21 -0
  72. package/dist/tools/budgeted-table.d.ts.map +1 -0
  73. package/dist/tools/budgeted-table.js +137 -0
  74. package/dist/tools/budgeted-table.js.map +1 -0
  75. package/dist/tools/elements/builders.d.ts.map +1 -1
  76. package/dist/tools/elements/builders.js +102 -40
  77. package/dist/tools/elements/builders.js.map +1 -1
  78. package/dist/tools/elements/handlers-write.d.ts +21 -0
  79. package/dist/tools/elements/handlers-write.d.ts.map +1 -1
  80. package/dist/tools/elements/handlers-write.js +231 -11
  81. package/dist/tools/elements/handlers-write.js.map +1 -1
  82. package/dist/tools/elements/handlers.d.ts +20 -12
  83. package/dist/tools/elements/handlers.d.ts.map +1 -1
  84. package/dist/tools/elements/handlers.js +77 -26
  85. package/dist/tools/elements/handlers.js.map +1 -1
  86. package/dist/tools/elements/schema-validation.d.ts +147 -7
  87. package/dist/tools/elements/schema-validation.d.ts.map +1 -1
  88. package/dist/tools/elements/schema-validation.js +508 -28
  89. package/dist/tools/elements/schema-validation.js.map +1 -1
  90. package/dist/tools/format/elements-format.d.ts.map +1 -1
  91. package/dist/tools/format/elements-format.js +56 -12
  92. package/dist/tools/format/elements-format.js.map +1 -1
  93. package/dist/tools/format/health-format.d.ts +16 -0
  94. package/dist/tools/format/health-format.d.ts.map +1 -1
  95. package/dist/tools/format/health-format.js +9 -0
  96. package/dist/tools/format/health-format.js.map +1 -1
  97. package/dist/tools/format/inspection-format.d.ts +7 -0
  98. package/dist/tools/format/inspection-format.d.ts.map +1 -1
  99. package/dist/tools/format/inspection-format.js +18 -0
  100. package/dist/tools/format/inspection-format.js.map +1 -1
  101. package/dist/tools/format/pages-format.d.ts +1 -1
  102. package/dist/tools/format/pages-format.d.ts.map +1 -1
  103. package/dist/tools/format/pages-format.js +7 -0
  104. package/dist/tools/format/pages-format.js.map +1 -1
  105. package/dist/tools/format/sources-format.d.ts +12 -5
  106. package/dist/tools/format/sources-format.d.ts.map +1 -1
  107. package/dist/tools/format/sources-format.js +31 -6
  108. package/dist/tools/format/sources-format.js.map +1 -1
  109. package/dist/tools/health.d.ts.map +1 -1
  110. package/dist/tools/health.js +85 -11
  111. package/dist/tools/health.js.map +1 -1
  112. package/dist/tools/index.d.ts.map +1 -1
  113. package/dist/tools/index.js +7 -0
  114. package/dist/tools/index.js.map +1 -1
  115. package/dist/tools/inspection.d.ts +34 -0
  116. package/dist/tools/inspection.d.ts.map +1 -1
  117. package/dist/tools/inspection.js +666 -62
  118. package/dist/tools/inspection.js.map +1 -1
  119. package/dist/tools/layout-flatten.d.ts +7 -1
  120. package/dist/tools/layout-flatten.d.ts.map +1 -1
  121. package/dist/tools/layout-flatten.js +24 -13
  122. package/dist/tools/layout-flatten.js.map +1 -1
  123. package/dist/tools/library.d.ts +29 -2
  124. package/dist/tools/library.d.ts.map +1 -1
  125. package/dist/tools/library.js +247 -34
  126. package/dist/tools/library.js.map +1 -1
  127. package/dist/tools/local-content/builders.js +14 -14
  128. package/dist/tools/local-content/builders.js.map +1 -1
  129. package/dist/tools/local-content/handlers.d.ts +3 -1
  130. package/dist/tools/local-content/handlers.d.ts.map +1 -1
  131. package/dist/tools/local-content/handlers.js +42 -9
  132. package/dist/tools/local-content/handlers.js.map +1 -1
  133. package/dist/tools/local-content/schemas.d.ts +9 -4
  134. package/dist/tools/local-content/schemas.d.ts.map +1 -1
  135. package/dist/tools/local-content/schemas.js +14 -6
  136. package/dist/tools/local-content/schemas.js.map +1 -1
  137. package/dist/tools/multi-items/builders.d.ts.map +1 -1
  138. package/dist/tools/multi-items/builders.js +11 -10
  139. package/dist/tools/multi-items/builders.js.map +1 -1
  140. package/dist/tools/navigation/builders.d.ts +18 -0
  141. package/dist/tools/navigation/builders.d.ts.map +1 -0
  142. package/dist/tools/navigation/builders.js +47 -0
  143. package/dist/tools/navigation/builders.js.map +1 -0
  144. package/dist/tools/navigation/handlers.d.ts +30 -0
  145. package/dist/tools/navigation/handlers.d.ts.map +1 -0
  146. package/dist/tools/navigation/handlers.js +41 -0
  147. package/dist/tools/navigation/handlers.js.map +1 -0
  148. package/dist/tools/navigation/index.d.ts +15 -0
  149. package/dist/tools/navigation/index.d.ts.map +1 -0
  150. package/dist/tools/navigation/index.js +14 -0
  151. package/dist/tools/navigation/index.js.map +1 -0
  152. package/dist/tools/navigation/schemas.d.ts +46 -0
  153. package/dist/tools/navigation/schemas.d.ts.map +1 -0
  154. package/dist/tools/navigation/schemas.js +41 -0
  155. package/dist/tools/navigation/schemas.js.map +1 -0
  156. package/dist/tools/pages/builders.d.ts.map +1 -1
  157. package/dist/tools/pages/builders.js +213 -81
  158. package/dist/tools/pages/builders.js.map +1 -1
  159. package/dist/tools/pages/handlers-audit.d.ts +112 -3
  160. package/dist/tools/pages/handlers-audit.d.ts.map +1 -1
  161. package/dist/tools/pages/handlers-audit.js +623 -7
  162. package/dist/tools/pages/handlers-audit.js.map +1 -1
  163. package/dist/tools/pages/handlers-read.d.ts +51 -8
  164. package/dist/tools/pages/handlers-read.d.ts.map +1 -1
  165. package/dist/tools/pages/handlers-read.js +348 -69
  166. package/dist/tools/pages/handlers-read.js.map +1 -1
  167. package/dist/tools/pages/handlers-write.d.ts +7 -3
  168. package/dist/tools/pages/handlers-write.d.ts.map +1 -1
  169. package/dist/tools/pages/handlers-write.js +69 -25
  170. package/dist/tools/pages/handlers-write.js.map +1 -1
  171. package/dist/tools/pages/header-transparency.d.ts +64 -0
  172. package/dist/tools/pages/header-transparency.d.ts.map +1 -0
  173. package/dist/tools/pages/header-transparency.js +119 -0
  174. package/dist/tools/pages/header-transparency.js.map +1 -0
  175. package/dist/tools/pages/schemas.d.ts +284 -13
  176. package/dist/tools/pages/schemas.d.ts.map +1 -1
  177. package/dist/tools/pages/schemas.js +434 -26
  178. package/dist/tools/pages/schemas.js.map +1 -1
  179. package/dist/tools/pages/target.d.ts +162 -0
  180. package/dist/tools/pages/target.d.ts.map +1 -0
  181. package/dist/tools/pages/target.js +239 -0
  182. package/dist/tools/pages/target.js.map +1 -0
  183. package/dist/tools/response-budget.d.ts +77 -0
  184. package/dist/tools/response-budget.d.ts.map +1 -0
  185. package/dist/tools/response-budget.js +134 -0
  186. package/dist/tools/response-budget.js.map +1 -0
  187. package/dist/tools/shared-schemas.d.ts +45 -0
  188. package/dist/tools/shared-schemas.d.ts.map +1 -1
  189. package/dist/tools/shared-schemas.js +50 -0
  190. package/dist/tools/shared-schemas.js.map +1 -1
  191. package/dist/tools/sources/builders.d.ts.map +1 -1
  192. package/dist/tools/sources/builders.js +55 -39
  193. package/dist/tools/sources/builders.js.map +1 -1
  194. package/dist/tools/sources/handlers-bind.d.ts +10 -11
  195. package/dist/tools/sources/handlers-bind.d.ts.map +1 -1
  196. package/dist/tools/sources/handlers-bind.js +110 -9
  197. package/dist/tools/sources/handlers-bind.js.map +1 -1
  198. package/dist/tools/sources/handlers.d.ts +4 -2
  199. package/dist/tools/sources/handlers.d.ts.map +1 -1
  200. package/dist/tools/sources/handlers.js +11 -7
  201. package/dist/tools/sources/handlers.js.map +1 -1
  202. package/dist/tools/sparse-fields.d.ts +61 -8
  203. package/dist/tools/sparse-fields.d.ts.map +1 -1
  204. package/dist/tools/sparse-fields.js +124 -12
  205. package/dist/tools/sparse-fields.js.map +1 -1
  206. package/dist/tools/sublayout/builders.d.ts.map +1 -1
  207. package/dist/tools/sublayout/builders.js +1 -2
  208. package/dist/tools/sublayout/builders.js.map +1 -1
  209. package/dist/tools/tool-builder/results.d.ts +19 -6
  210. package/dist/tools/tool-builder/results.d.ts.map +1 -1
  211. package/dist/tools/tool-builder/results.js +133 -6
  212. package/dist/tools/tool-builder/results.js.map +1 -1
  213. package/manifest.json +1 -1
  214. package/package.json +2 -2
  215. package/skills/yt-builder-mcp/SKILL.md +1713 -72
@@ -8,15 +8,17 @@
8
8
  *
9
9
  * @license MIT
10
10
  */
11
- import { detailResult, tableResult } from '@getimo/mcp-toolkit';
11
+ import { detailResult } from '@getimo/mcp-toolkit';
12
12
  import { z } from 'zod';
13
13
  import { BIND_VIA_GATEWAY_EXAMPLE } from './bind-guidance.js';
14
14
  import { TYPES_COMPACT_COLUMNS, TYPES_TABLE_COLUMNS, buildTypeSchemaDetail, flattenTypesPayload, mapTypeRow, } from './format/inspection-format.js';
15
- import { SITE_ID_SCHEMA } from './shared-schemas.js';
15
+ import { INCLUDE_META_SCHEMA, SITE_ID_SCHEMA } from './shared-schemas.js';
16
16
  import { resolveSiteOrError } from './pool-resolve-helper.js';
17
17
  import { defineTool, errorResult, readOnly, structuredResult, withSiteMeta, } from './tool-builder.js';
18
- import { DEFAULT_FIELDS_TYPES_LIST, FIELDS, projectFields, projectedFieldsEcho, projectionFeedback, projectionWarning, } from './sparse-fields.js';
18
+ import { DEFAULT_FIELDS_TYPES_LIST, FIELDS, FIELD_NAMES, FIELD_NAME_CONTAINS, MAX_CHARS, applyMaxChars, projectFields, projectedFieldsEcho, projectionFeedback, projectionWarning, selectDescriptorsByName, } from './sparse-fields.js';
19
19
  import { ITEM_CHILDREN_OF_CONTAINER } from './multi-items/item-container-map.js';
20
+ import { budgetedTableResult } from "./budgeted-table.js";
21
+ import { RESPONSE_BUDGET } from "./response-budget.js";
20
22
  // ─── outputSchemas (Wave G.2 §4) ─────────────────────────────────────
21
23
  const TYPES_LIST_OUTPUT_SCHEMA = z.object({
22
24
  items: z.array(z.record(z.string(), z.unknown())),
@@ -38,7 +40,173 @@ const TYPE_SCHEMA_OUTPUT_SCHEMA = z.object({
38
40
  // field descriptors — each `name` is a valid prop key for element_add /
39
41
  // _update_settings.
40
42
  fields: z.array(z.record(z.string(), z.unknown())).optional(),
43
+ // Task 7b (plan 2026-07-27-02): the fieldset groups' own sentences, stated
44
+ // ONCE each and joined on a field's `group`. YOOtheme parks the semantics of
45
+ // a whole class of fields there rather than on the field
46
+ // (grid.image_width/image_height carry the crop rule this way and declare
47
+ // nothing themselves), so a caller that reads only a descriptor's
48
+ // `description` misses the rule it needed. Narrows with the descriptor
49
+ // selection, so a `field_names` read carries only the groups it named.
50
+ //
51
+ // The RULE is stated here rather than in the 600-char tool description,
52
+ // which names only the key. Same carrier choice, and the same reason, as the
53
+ // `site_id` param description in `docs/mcp-response-budgets.md` §9.2: a
54
+ // schema `.describe()` rides in the wire `tools/list` payload (measured
55
+ // there at 19.2 % of it for `outputSchema`), so this states MORE while
56
+ // costing the tool-description budget nothing.
57
+ groups: z
58
+ .array(z.object({
59
+ name: z.string().describe('Matches a field descriptor\'s `group`.'),
60
+ description: z
61
+ .string()
62
+ .describe("The fieldset group's own sentence, as YOOtheme ships it."),
63
+ }))
64
+ .optional()
65
+ .describe('Fieldset groups that carry a rule of their own, stated once each. ' +
66
+ 'YOOtheme parks the semantics of a whole class of fields on the ' +
67
+ 'GROUP rather than on the field — grid.image_width/image_height ' +
68
+ 'declare no description and the crop rule lives here — so a ' +
69
+ 'descriptor with no `description` is not undocumented, you are ' +
70
+ 'reading at the wrong level. Join on the field\'s `group`. ' +
71
+ 'Narrows with field_names/name_contains.'),
72
+ // Task 7b Step 4 (plan 2026-07-27-02, inventory groups B, A, L). NOT narrowed
73
+ // by a field_names/name_contains selection: these describe the element TYPE,
74
+ // and the caller asking for two fields of a 154-field type is exactly the
75
+ // caller about to write those two fields.
76
+ //
77
+ // The `.describe()` texts below are deliberately terse. They carry ONLY what
78
+ // a caller cannot read off the arrays themselves — the CNF semantics, the
79
+ // deletion consequence, the `&&`/`parent.` grammar, and what each
80
+ // media_autoswap key means. Everything that IS in the data (which props, which
81
+ // flags, which elements invert the precedence) is stated per element in the
82
+ // RESPONSE, where it costs bytes once per call instead of once per session in
83
+ // every host's tools/list.
84
+ //
85
+ // MEASURED, 2026-07-28: a first draft of these describes added 1953 B to the
86
+ // wire tools/list (136422 B → 138375 B, `tests/perf/token-baseline.test.ts`)
87
+ // and made this tool the largest entry on the server. Spending ~2 KB of every
88
+ // session's discovery payload to narrate four keys defeats the point of
89
+ // shipping them as arrays, so the prose was cut to the non-derivable facts.
90
+ renders_only_if: z
91
+ .array(z.array(z.string()))
92
+ .optional()
93
+ .describe('ALL groups need >=1 non-empty member or YOOtheme DELETES this element at ' +
94
+ 'render (no error; an emptied parent then collapses too). Applies to ' +
95
+ 'PRESENT-but-empty props; for ABSENT ones see placeholder_fallback. A ' +
96
+ "member may be `a&&b`; `parent.` prefixes the container's prop."),
97
+ placeholder_fallback: z
98
+ .object({ suppressed_by: z.array(z.string()), fills: z.array(z.string()) })
99
+ .optional()
100
+ .describe('Why renders_only_if often does NOT delete: leave every suppressed_by key ' +
101
+ "UNSET and YOOtheme merges its placeholder (prerender, front end too), so " +
102
+ 'fills render as lorem ipsum/"Title"/a placeholder image on the LIVE page. ' +
103
+ 'Write any suppressed_by key, even "", to suppress the merge and get deletion.'),
104
+ // 2026-07-28. The shipped text said only "it BLANKS (not CSS-hide)", which
105
+ // rules out ONE wrong reading and leaves the other: a caller that turns off
106
+ // a container's `show_title`, watches its titles disappear, and reads
107
+ // "BLANKS" concludes its write was destroyed — and reaches for the one
108
+ // repair that cannot work, re-writing props that were never lost.
109
+ //
110
+ // MEASURED, YOOtheme Pro 5.0.37 (a two-item `grid`, written with
111
+ // element_update_settings + page_publish, read back with page_get_layout and
112
+ // from the published page): `show_title:false` took rendered `.el-title` from
113
+ // 2 to 0 with neither title string anywhere in the section HTML, while the
114
+ // grid's stored children subtree stayed byte-identical (equal sha256, 699 B,
115
+ // `diff` exit 0) and the whole-layout diff was ONE added line — the flag, on
116
+ // the GRID node, nothing on any child. Flipping it back restored the baseline
117
+ // render byte-for-byte. Stripped to `link` alone the items left the output
118
+ // entirely (`.el-item` 2 -> 0, `.uk-grid` 2 -> 1) while the read-back still
119
+ // carried `link` and `link_text` in full. The full measurement, including the
120
+ // source-bound case, is in SKILL.md; the pin is in
121
+ // tests/tools/inspection-blanked-by-parent-describe.test.ts.
122
+ //
123
+ // The two halves are BOTH here, and deliberately not split with SKILL.md,
124
+ // because the audience the defect reached is the cold agent reading one tool
125
+ // response with no skill installed.
126
+ blanked_by_parent: z
127
+ .record(z.string(), z.array(z.string()))
128
+ .optional()
129
+ .describe('Container show_* flag -> props of THIS element the RENDER blanks (not ' +
130
+ 'CSS-hide). Blanks the OUTPUT only: your stored value survives and the ' +
131
+ 'flag is reversible. Runs before renders_only_if, so clearing one can ' +
132
+ 'drop the whole item — intersect the two to see which.'),
133
+ media_autoswap: z
134
+ .object({
135
+ precedence: z.array(z.string()),
136
+ sniffed: z.array(z.string()).optional(),
137
+ clears_on_conflict: z.array(z.string()).optional(),
138
+ })
139
+ .optional()
140
+ .describe('Image/video collision, per element — not uniform across YOOtheme. ' +
141
+ 'precedence: first non-empty renders. sniffed: URL-sniffed, value MOVED ' +
142
+ 'between image/video. clears_on_conflict: prop SET FALSE, not just skipped.'),
143
+ // Task 7b Steps 5-6 (plan 2026-07-27-02, inventory groups P/M/D/E/K/S/O/N/F).
144
+ // Two keys, one mechanism: an id list that costs ~14 B per reference, and the
145
+ // note texts stated ONCE per response. The alternative measured at 2.5-3.0 KB
146
+ // (the inventory's per-prop cost table) because `link_striptags` alone is
147
+ // referenced by 17 (type, prop) pairs.
148
+ //
149
+ // These two `.describe()` texts say only what a caller CANNOT read off one
150
+ // response: that `rules` is a POINTER (an opaque id is useless without knowing
151
+ // where it resolves), why the pointed-at rule is worth following, and the
152
+ // narrowing asymmetry (element-level ids always resolve, field-level ones only
153
+ // while their field is returned — invisible from a single response).
154
+ //
155
+ // MEASURED, 2026-07-28: a first draft of these two texts (495 chars) cost
156
+ // 704 B of the wire tools/list and made this tool the largest entry on the
157
+ // server. The full prose lives in SKILL.md's `rules` + `rule_notes` section
158
+ // instead, where it costs the wire nothing and is ledger-PINNED
159
+ // (skill-md-behavior-truth.test.ts) so it cannot silently vanish — the same
160
+ // carrier choice, and the same reason, as `placeholder`/`enum_labels` in the
161
+ // tool description above.
162
+ rules: z
163
+ .array(z.string())
164
+ .optional()
165
+ .describe('Shared render-rule ids — resolve each in `rule_notes`. Each marks a case ' +
166
+ 'where the write succeeds and the render differs; read before writing ' +
167
+ 'that prop.'),
168
+ rule_notes: z
169
+ .record(z.string(), z.string())
170
+ .optional()
171
+ .describe('id -> rule text, for the ids this response references. Element-level ids ' +
172
+ 'always resolve; a field-level one only while its field is returned.'),
173
+ // 2026-07-28. Found by LIVE verification: both 4.5.33 sites (wp-dev,
174
+ // joomla-dev — their own yootheme_builder_health says so) were served
175
+ // `no_effect: "No effect in YOOtheme Pro 5.0.37 …"`. The claim was read in a
176
+ // 5.0.37 tree; nobody read theirs. Every version-scoped key in this response
177
+ // now names the build it was read on, and `status` says whether that is the
178
+ // build you are running.
179
+ //
180
+ // `keys` is the BOUND, and it is why the describe below can say "those
181
+ // ONLY". This response also carries `binding_contract`, whose `child_type`
182
+ // comes from ItemContainerMap — read on YT-Pro 4.5.33, a DIFFERENT build,
183
+ // with a per-build difference recorded in that file's own docblock. The
184
+ // first draft described this record as covering "the version-scoped keys
185
+ // here", which vouched for it. Listing the covered keys ends that by
186
+ // construction rather than by prose kept in sync with a growing surface.
187
+ //
188
+ // The rule rides in this `.describe()` rather than the 600-char tool
189
+ // description for the same reason `groups` does — a schema describe is on the
190
+ // wire tools/list payload and costs the tool-description budget nothing
191
+ // (`docs/mcp-response-budgets.md` §9.2).
192
+ claims_verified_on: z
193
+ .object({
194
+ build: z.string(),
195
+ here: z.string().nullable(),
196
+ status: z.enum(['match', 'differs', 'unknown']),
197
+ keys: z.array(z.string()),
198
+ })
199
+ .optional()
200
+ .describe('Build the keys named in `keys` were READ on vs this site — those ' +
201
+ 'ONLY, never the whole response. "differs"/"unknown" = NOT ' +
202
+ 're-checked here, so they are reports, not facts, and no_effect ' +
203
+ 'ships as no_effect_unverified.'),
204
+ // The descriptors the TYPE declares — unchanged by a selection, so a caller
205
+ // always knows the size of what it did not ask for (Task 4 / P2).
41
206
  field_count: z.number(),
207
+ // The descriptors THIS response carries. Equal to `field_count` when no
208
+ // `field_names`/`name_contains` selection was made.
209
+ returned_count: z.number(),
42
210
  // Wave-6 R3 (2026-05-29): binding contract annotation. Surfaces the
43
211
  // Multi-Items binding pattern for container types (grid/slider/switcher/
44
212
  // list/accordion/gallery/panel/overlay), and identifies content-binding
@@ -270,6 +438,234 @@ function deriveBindingContractBase(elementType) {
270
438
  * (tests/tools/inspection-minimal-layout-bare.test.ts).
271
439
  */
272
440
  export const __test_deriveBindingContract = deriveBindingContract;
441
+ /**
442
+ * ELEMENT_WRITE-4 (part 1) — the node-level keys the YT schema serializer lists
443
+ * in `fields[]` even though they are SIBLINGS of `props` on the node, not props.
444
+ * Writing `props.name` via element_add is silently accepted (the key IS in
445
+ * fields[]) but never renders as the builder label. Annotating them steers the
446
+ * agent to set them OUTSIDE props (a first-class `name` arg is the cross-file
447
+ * follow-up owned by the element_add builder schema).
448
+ */
449
+ const NODE_LEVEL_FIELD_KEYS = new Set(['name', 'status', 'source', 'id']);
450
+ /**
451
+ * Annotate node-level descriptors (`name`/`status`/`source`/`id`) with
452
+ * `group:'node-level'` + a value_hint so the agent knows they live OUTSIDE
453
+ * `props`. Non-destructive: returns a shallow-cloned array; other descriptors
454
+ * pass through untouched.
455
+ *
456
+ * `group:'node-level'` OVERRIDES whatever group the descriptor arrived with.
457
+ * That is a change from "only fill an ABSENT group", and it is load-bearing:
458
+ * Task 7b Step 1 (plan 2026-07-27-02) taught the PHP projection to read
459
+ * YOOtheme's fieldset TREE, and YOOtheme does list some of these keys inside a
460
+ * UI group. MEASURED over the real element.php configs: `column.source` and
461
+ * `column.id` now arrive as `{"group":"Advanced"}`, which under the old
462
+ * fill-if-absent rule SUPPRESSED the node-level marker on exactly the two keys
463
+ * whose mis-write is silent (`props.source` is ignored; the real `source` is a
464
+ * sibling of `props`). "This key is not a prop" outranks "this key sits in the
465
+ * Advanced panel" — the first prevents a silent no-op, the second is navigation.
466
+ * The `value_hint` carried the warning either way, but a consumer branching on
467
+ * `group` would have lost it.
468
+ *
469
+ * Safe against the two synthetic groups the PHP side assigns
470
+ * (`runtime-accepted` on button.content/link, `responsive-width` on
471
+ * column.width_*): neither list contains any of these four names, so no
472
+ * deliberate group is clobbered.
473
+ */
474
+ function annotateNodeLevelFields(fields) {
475
+ return fields.map((field) => {
476
+ const name = field.name;
477
+ if (typeof name !== 'string' || !NODE_LEVEL_FIELD_KEYS.has(name)) {
478
+ return field;
479
+ }
480
+ return {
481
+ ...field,
482
+ group: 'node-level',
483
+ value_hint: typeof field.value_hint === 'string' && field.value_hint !== ''
484
+ ? field.value_hint
485
+ : 'builder label / node metadata — lives OUTSIDE `props` (a SIBLING of ' +
486
+ 'props on the node). Do NOT set it as props.' + name + ' via ' +
487
+ 'element_update_settings; it is silently ignored there.',
488
+ };
489
+ });
490
+ }
491
+ /**
492
+ * Narrow the root `groups` list to the groups the SELECTED descriptors name.
493
+ *
494
+ * Returns `undefined` when the backend sent no usable list, so a schema without
495
+ * groups (and a payload from an older backend) adds no key and the response
496
+ * stays byte-identical to before. The PHP transport's twin is
497
+ * `McpElementTypeGetSchemaHandler::narrowGroups()`.
498
+ */
499
+ function narrowGroupsToSelection(raw, selected) {
500
+ if (!Array.isArray(raw))
501
+ return undefined;
502
+ const groups = [];
503
+ for (const entry of raw) {
504
+ if (entry === null || typeof entry !== 'object')
505
+ continue;
506
+ const { name, description } = entry;
507
+ if (typeof name === 'string' && name !== '' && typeof description === 'string') {
508
+ groups.push({ name, description });
509
+ }
510
+ }
511
+ if (groups.length === 0)
512
+ return undefined;
513
+ if (selected === undefined)
514
+ return groups;
515
+ const named = new Set();
516
+ for (const field of selected) {
517
+ if (typeof field.group === 'string')
518
+ named.add(field.group);
519
+ }
520
+ return groups.filter((g) => named.has(g.name));
521
+ }
522
+ /**
523
+ * Lift the three element-level render rules off the backend payload.
524
+ *
525
+ * Every branch is shape-VALIDATED rather than cast, so a malformed or older
526
+ * backend contributes no key and the response stays byte-identical to before —
527
+ * the same fail-quiet contract as `narrowGroupsToSelection`. A half-valid
528
+ * `media_autoswap` (no `precedence`) is dropped whole rather than forwarded
529
+ * partially: `precedence` is the fact, and the optional keys qualify it.
530
+ */
531
+ function extractRenderRules(schema) {
532
+ const out = {};
533
+ const isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string');
534
+ const gate = schema.renders_only_if;
535
+ if (Array.isArray(gate) && gate.length > 0 && gate.every(isStringList)) {
536
+ out.renders_only_if = gate;
537
+ }
538
+ const blanked = schema.blanked_by_parent;
539
+ if (blanked !== null && typeof blanked === 'object' && !Array.isArray(blanked)) {
540
+ const map = {};
541
+ for (const [flag, props] of Object.entries(blanked)) {
542
+ if (isStringList(props))
543
+ map[flag] = props;
544
+ }
545
+ if (Object.keys(map).length > 0)
546
+ out.blanked_by_parent = map;
547
+ }
548
+ const media = schema.media_autoswap;
549
+ if (media !== null && typeof media === 'object' && !Array.isArray(media)) {
550
+ const { precedence, sniffed, clears_on_conflict } = media;
551
+ if (isStringList(precedence) && precedence.length > 0) {
552
+ out.media_autoswap = {
553
+ precedence,
554
+ ...(isStringList(sniffed) ? { sniffed } : {}),
555
+ ...(isStringList(clears_on_conflict) ? { clears_on_conflict } : {}),
556
+ };
557
+ }
558
+ }
559
+ // An EMPTY `fills` is meaningful (the merge fires but adds nothing non-empty,
560
+ // so the gate still drops the node) and is forwarded. A missing or malformed
561
+ // `suppressed_by` is absence: it is the half that decides absent-vs-empty, and
562
+ // half a rule here turns that decision into a guess.
563
+ const fallback = schema.placeholder_fallback;
564
+ if (fallback !== null && typeof fallback === 'object' && !Array.isArray(fallback)) {
565
+ const { suppressed_by, fills } = fallback;
566
+ if (isStringList(suppressed_by) && suppressed_by.length > 0 && isStringList(fills)) {
567
+ out.placeholder_fallback = { suppressed_by, fills };
568
+ }
569
+ }
570
+ return out;
571
+ }
572
+ /**
573
+ * Which YOOtheme build the version-scoped keys in this response were READ on,
574
+ * versus what the site reports (2026-07-28).
575
+ *
576
+ * Found by live verification: `element_type_get_schema({element_type:'grid',
577
+ * field_names:['lightbox_image_orientation'], site_id:'wp-dev'})` returned
578
+ * `no_effect: "No effect in YOOtheme Pro 5.0.37 …"` on a site whose own
579
+ * `yootheme_builder_health` reports `"yootheme_version":"4.5.33"`. Same on
580
+ * `joomla-dev`. The PHP side now frames such a claim for the build it is
581
+ * actually talking to; this lifts the record onto the MCP surface so the
582
+ * framing survives the hop.
583
+ *
584
+ * Fail-quiet and shape-VALIDATED like {@link extractRenderRules}: a backend that
585
+ * predates the record (any deployed 1.11.0, which is every site today) sends no
586
+ * key, and the response stays byte-identical to before. `here` is `null` in the
587
+ * `unknown` stance — a real value, so it is validated as nullable rather than
588
+ * dropped, because "we could not resolve it" is exactly the fact worth shipping.
589
+ *
590
+ * ── `keys` IS REQUIRED, NOT OPTIONAL ───────────────────────────────────────
591
+ * `keys` is the record's BOUND: the response keys it vouches for. Without it the
592
+ * record is an unbounded statement about the whole response — and this function's
593
+ * own caller composes `binding_contract` into that response from
594
+ * `ItemContainerMap::MAP`, read on YT-Pro 4.5.33, a DIFFERENT build from the one
595
+ * the record names. A record that reached the wire unbounded would therefore
596
+ * assert exactly the thing it exists to prevent. No shipped plugin emits the
597
+ * unbounded shape (the record has never been released), so requiring the bound
598
+ * costs no compatibility and closes the hole by construction.
599
+ */
600
+ function extractClaimProvenance(schema) {
601
+ const raw = schema.claims_verified_on;
602
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
603
+ return undefined;
604
+ const { build, here, status, keys } = raw;
605
+ if (typeof build !== 'string' || build === '')
606
+ return undefined;
607
+ if (status !== 'match' && status !== 'differs' && status !== 'unknown')
608
+ return undefined;
609
+ if (here !== null && typeof here !== 'string')
610
+ return undefined;
611
+ if (!Array.isArray(keys) || keys.length === 0)
612
+ return undefined;
613
+ if (!keys.every((k) => typeof k === 'string'))
614
+ return undefined;
615
+ return { build, here, status, keys };
616
+ }
617
+ /**
618
+ * Task 7b Steps 5-6 (plan 2026-07-27-02, inventory groups P, M, D, E, K, S, O, N,
619
+ * F) — the SHARED render-rule notes: `rules` (a list of ids, on the element and on
620
+ * each field) resolved by `rule_notes` (id → the one shared sentence set).
621
+ *
622
+ * Distinct from {@link extractRenderRules} above, which forwards the four
623
+ * machine-readable Step-4 arrays (`renders_only_if` & co.). These are the nine
624
+ * groups an agent cannot deduce and that have no array form — prose, paid for
625
+ * ONCE per response instead of once per referencing prop.
626
+ *
627
+ * Narrowing is the point of this function. `rule_notes` must carry exactly the
628
+ * ids the RESPONSE still references, so a `field_names` read of two descriptors
629
+ * out of 154 does not ship the notes of the 152 it dropped — that would undo
630
+ * Task 4 for the narrow reads it exists to serve, which is the same failure
631
+ * {@link narrowGroupsToSelection} exists to prevent. The element's OWN `rules`
632
+ * always survive: they describe the container the caller is about to write into,
633
+ * not any one descriptor.
634
+ *
635
+ * Read off `selected` (the descriptors this response carries) rather than the
636
+ * sub-key-projected `fields`, for the same reason `groups` is: `fields:["type"]`
637
+ * strips the `rules` key itself, and narrowing on the projected list would then
638
+ * delete every note the caller can still see referenced nowhere.
639
+ *
640
+ * A referenced id with no note is dropped rather than forwarded as a dangling
641
+ * pointer, and a note nobody references is dropped rather than forwarded as
642
+ * dead weight.
643
+ */
644
+ function extractSharedRuleNotes(schema, selected) {
645
+ const isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string');
646
+ const elementRules = isStringList(schema.rules) ? schema.rules : [];
647
+ const referenced = new Set(elementRules);
648
+ for (const field of selected ?? []) {
649
+ const refs = field.rules;
650
+ if (isStringList(refs)) {
651
+ for (const id of refs)
652
+ referenced.add(id);
653
+ }
654
+ }
655
+ const rawNotes = schema.rule_notes;
656
+ const notes = {};
657
+ if (rawNotes !== null && typeof rawNotes === 'object' && !Array.isArray(rawNotes)) {
658
+ for (const [id, text] of Object.entries(rawNotes)) {
659
+ if (typeof text === 'string' && text !== '' && referenced.has(id)) {
660
+ notes[id] = text;
661
+ }
662
+ }
663
+ }
664
+ return {
665
+ ...(elementRules.length > 0 ? { rules: elementRules } : {}),
666
+ ...(Object.keys(notes).length > 0 ? { rule_notes: notes } : {}),
667
+ };
668
+ }
273
669
  export function buildInspectionTools(pool) {
274
670
  return [
275
671
  defineTool({
@@ -282,15 +678,17 @@ export function buildInspectionTools(pool) {
282
678
  // headline `content`) — call element_type_get_schema first.
283
679
  'Keywords: "slider"/"carousel" → `slideshow`/`panel-slider`/`overlay-slider`. ' +
284
680
  'Prop keys differ per element (grid_item `title` vs headline `content`) — ' +
285
- 'use element_type_get_schema before element_add/bind. ' +
286
- 'Operates on the default site unless site_id is provided.',
681
+ 'use element_type_get_schema before element_add/bind. Container rows carry ' +
682
+ '`requires_child_type` (grid→grid_item); `has_children`/`has_children_support` ' +
683
+ 'are aliases (same value).',
287
684
  inputSchema: {
288
685
  site_id: SITE_ID_SCHEMA,
289
686
  fields: FIELDS,
687
+ include_meta: INCLUDE_META_SCHEMA,
290
688
  },
291
689
  outputSchema: TYPES_LIST_OUTPUT_SCHEMA,
292
690
  annotations: readOnly('List Element Types'),
293
- handler: async ({ site_id, fields }) => {
691
+ handler: async ({ site_id, fields, include_meta }) => {
294
692
  const r = await resolveSiteOrError(pool, site_id);
295
693
  if (!r.ok)
296
694
  return r.error;
@@ -299,18 +697,28 @@ export function buildInspectionTools(pool) {
299
697
  const data = await siteClient.get('/element-types');
300
698
  const flat = flattenTypesPayload(data);
301
699
  const mapped = flat.map(mapTypeRow);
302
- const mappedRecords = mapped;
700
+ // ELEMENT_WRITE-26: surface the container→item pairing per row
701
+ // (grid→grid_item, button→button_item) so the Multi-Items child
702
+ // requirement is visible WITHOUT opening each type's schema.
703
+ // Present only on container rows; leaf/item rows stay unchanged.
704
+ const mappedRecords = mapped.map((row) => {
705
+ const child = CONTAINER_TO_ITEM_TYPE[row.name];
706
+ return child !== undefined
707
+ ? { ...row, requires_child_type: child }
708
+ : { ...row };
709
+ });
303
710
  const items = projectFields(mappedRecords, fields, DEFAULT_FIELDS_TYPES_LIST);
304
711
  const echo = projectedFieldsEcho(fields, DEFAULT_FIELDS_TYPES_LIST);
305
712
  const feedback = projectionFeedback(mappedRecords, fields);
306
713
  // F23 (2026-06-09): shared loud-warning helper.
307
714
  const unknownNote = projectionWarning(feedback);
308
- const toolkitResult = tableResult(mappedRecords, {
715
+ const toolkitResult = budgetedTableResult(mappedRecords, {
309
716
  columns: [...TYPES_TABLE_COLUMNS],
310
717
  compactColumns: [...TYPES_COMPACT_COLUMNS],
311
718
  header: (count) => `${String(count)} element types${unknownNote}`,
312
719
  footer: 'Use yootheme_builder_element_type_get_schema <name> for fields.',
313
- });
720
+ includeMeta: include_meta,
721
+ }, RESPONSE_BUDGET.BULKY);
314
722
  return withSiteMeta(structuredResult(toolkitResult, {
315
723
  items,
316
724
  total: items.length,
@@ -365,6 +773,19 @@ function buildElementTypeGetSchemaTool(pool) {
365
773
  .describe('DEPRECATED alias of `element_type` — use `element_type` ' +
366
774
  'instead. Kept for 1.0.x backward-compatibility; will be ' +
367
775
  'removed in a future major.'),
776
+ // ELEMENT_WRITE-17 / SOURCES_BINDING-23: this was the only read tool with
777
+ // NO way to bound its response (grid returns ~154 field descriptors in one
778
+ // blob). `fields[]` sparse-projects each field descriptor to the requested
779
+ // sub-keys; `max_chars` caps the text leg — mirroring the sibling reads.
780
+ fields: FIELDS,
781
+ // Plan 2026-07-27-02 Task 4 (P1, closes F8): SELECT which descriptors
782
+ // come back, instead of receiving all 154 with 88.6 % of them cut off
783
+ // (measured: `docs/mcp-response-size-baseline.md` §2). Orthogonal to
784
+ // `fields[]` — that one picks sub-keys OF a descriptor, these pick the
785
+ // descriptors themselves, and a selected one is returned COMPLETE.
786
+ field_names: FIELD_NAMES,
787
+ name_contains: FIELD_NAME_CONTAINS,
788
+ max_chars: MAX_CHARS,
368
789
  };
369
790
  const TYPE_SCHEMA_REFINED = z
370
791
  .object(TYPE_SCHEMA_SHAPE)
@@ -375,12 +796,77 @@ function buildElementTypeGetSchemaTool(pool) {
375
796
  });
376
797
  return defineTool({
377
798
  name: 'yootheme_builder_element_type_get_schema',
378
- description: 'Fetch a type\'s prop schema before `element_add`/`element_update_settings`: ' +
379
- 'an unknown prop key on a known schema is hard-rejected (not silently ' +
380
- 'dropped); the error always lists `valid_prop_keys`, plus a `did_you_mean` ' +
381
- 'hint when a close field exists. Returns `{name,type,label?}`. Use ' +
382
- '`element_type` (`type_name` is a deprecated alias). Operates on the ' +
383
- 'default site unless site_id is provided.',
799
+ // 544 chars the per-tool budget is 600
800
+ // (`tests/gateway/description-length.test.ts`). Task 4 bought the room
801
+ // for the selector by tightening prose, not by dropping a fact; Task 6
802
+ // bought the room for `text?`/`description?` the same way. The five
803
+ // edits that paid for it are pure wording, no fact removed: "Returns
804
+ // each field as" "Each field:", "prop keys are rejected" → "prop keys
805
+ // rejected", "hidden in the UI" → "hidden in UI", "for
806
+ // containers/items" → "(containers/items)", and "valid_prop_keys +
807
+ // did_you_mean" → "valid_prop_keys+did_you_mean" (the same `a+b` form
808
+ // "value_hint+enum" already uses two clauses later).
809
+ //
810
+ // Task 5b Step 3 (2026-07-28) — RESTORED "boolean". Task 4's tightening
811
+ // pass went one edit too far: `checkbox = boolean true/false` became
812
+ // `checkbox = true/false` (commit 139cb7d68). That deleted the word
813
+ // carrying the VALUE TYPE, and this clause was the only statement of a
814
+ // checkbox prop's type anywhere on the tool surface — an agent writing
815
+ // `element_update_settings({props:{image_expand:"1"}})` had nothing left
816
+ // to tell it the field wants a boolean rather than a truthy string.
817
+ // Naming the two values is not naming the type. Cost of the restore: 8
818
+ // chars, paid for many times over by dropping the repeated site_id
819
+ // sentence (57 chars here; the fact now rides in the `site_id` param
820
+ // description, which is where a host reads it while building the call).
821
+ // Pinned in tests/skills/skill-md-behavior-truth.test.ts (Task 5b block).
822
+ // Task 7b Steps 1+3 (plan 2026-07-27-02): the descriptor grew two
823
+ // DECISION-RELEVANT keys, so the illustrative shape names them.
824
+ // - `groups[]` — the enclosing fieldset group's own sentence, at the
825
+ // schema ROOT and joined on the field's `group`. YOOtheme parks the
826
+ // semantics of a whole class of fields there rather than on the field
827
+ // (grid.image_width/image_height carry the crop rule this way and
828
+ // declare nothing themselves), so a caller that reads only
829
+ // `description` misses the rule it needed. It is the one key of the
830
+ // set NOT self-describing from a field descriptor — a field naming
831
+ // `group:"Width/Height"` gives no reason to look at a root key — so
832
+ // this is where the budget is spent. The RULE rides in the
833
+ // `outputSchema` `.describe()`, which is on the wire and costs this
834
+ // budget nothing (`docs/mcp-response-budgets.md` §9.2).
835
+ // Was `group_description?` per field until the hoist measured 32
836
+ // occurrences of 5 distinct strings.
837
+ // - `no_effect` — the prop is declared and read by nothing. Naming it
838
+ // here is what stops an agent treating the descriptor as writable
839
+ // guidance; the VALUE carries the full read fact + the build it was
840
+ // read on, so no further prose is spent in this budget.
841
+ // The shape list is illustrative, not exhaustive (it has never named
842
+ // `default`/`enable`/`show`/`filter`/`constraint`). `placeholder` and
843
+ // `enum_labels` are therefore documented in SKILL.md rather than
844
+ // here — the budget is nearly spent, and both are self-describing once
845
+ // seen in a response, sitting next to the key they qualify
846
+ // (`enum_labels` beside `enum`).
847
+ // Space for the two additions was earned inside this same description,
848
+ // not by raising the budget: "Fetch a type's prop schema before" →
849
+ // "Prop schema; read before", "unknown prop keys" → "unknown keys"
850
+ // (the word `prop` is already in the opening clause), and "Read
851
+ // value_hint+enum first" → "value_hint+enum first". MEASURED: 544 →
852
+ // 595 chars, inside the 600-char tool-description budget.
853
+ // No fact left: every clause the pre-change description carried is still
854
+ // here, and the pinned phrases are VERBATIM — "checkbox = boolean
855
+ // true/false", "Select descriptors: field_names/name_contains",
856
+ // "field_count/returned_count", "Bound size with fields[]/max_chars".
857
+ // ("Bound size:" was tried and correctly REJECTED by the P4 max_chars
858
+ // pin in tests/skills/skill-md-behavior-truth.test.ts — the four chars
859
+ // were found elsewhere rather than by weakening the pin.)
860
+ description: 'Prop schema; read before element_add/element_update_settings: ' +
861
+ 'unknown keys rejected (error lists valid_prop_keys+' +
862
+ 'did_you_mean). Field: {name,type,label?,text?,description?,enum?,' +
863
+ 'value_hint?,group?,no_effect?} + groups[] + ' +
864
+ 'field_count/returned_count + binding_contract ' +
865
+ '(containers/items). value_hint+enum first: some defaults are SEO-fatal ' +
866
+ '(headline.title_element→h1); checkbox = boolean true/false; ' +
867
+ 'no_effect = read by nothing, never write it; ' +
868
+ 'group:"runtime-accepted" = honoured at render, hidden in UI. Select ' +
869
+ 'descriptors: field_names/name_contains. Bound size with fields[]/max_chars.',
384
870
  inputSchema: TYPE_SCHEMA_SHAPE,
385
871
  // F-203 follow-up: the refined object is what the SDK actually
386
872
  // validates. The raw shape above stays for handler-type inference.
@@ -424,64 +910,182 @@ function buildElementTypeGetSchemaTool(pool) {
424
910
  const label = typeof schema.label === 'string' ? schema.label : undefined;
425
911
  const origin = typeof schema.origin === 'string' ? schema.origin : undefined;
426
912
  // `fields` is a list of {name,type,label?} field descriptors.
427
- const fields = Array.isArray(schema.fields)
913
+ const rawFields = Array.isArray(schema.fields)
428
914
  ? schema.fields
429
915
  : undefined;
430
- const fieldCount = fields !== undefined ? fields.length : 0;
916
+ // ELEMENT_WRITE-4 (part 1): the node-level keys {name,status,
917
+ // source,id} are siblings of `props`, NOT props — but they render
918
+ // in the Fields table with no group, so an agent writes props.name
919
+ // via element_add and it is silently accepted (name is a fields[]
920
+ // entry). Annotate them so the agent knows they live OUTSIDE props.
921
+ const annotatedFields = rawFields !== undefined ? annotateNodeLevelFields(rawFields) : undefined;
922
+ // ELEMENT_WRITE-17 / SOURCES_BINDING-23: project each descriptor to
923
+ // the requested sub-keys when `fields[]` was passed (bounds the
924
+ // structured leg); `max_chars` bounds the text leg below.
925
+ const fieldsParam = Array.isArray(args.fields) &&
926
+ args.fields.every((f) => typeof f === 'string')
927
+ ? args.fields
928
+ : undefined;
929
+ const maxChars = typeof args.max_chars === 'number' ? args.max_chars : undefined;
930
+ // Task 4 (P1): SELECT the descriptors first, THEN sub-key-project
931
+ // them. The order is load-bearing — `fields:["type"]` strips the
932
+ // very `name`/`label` the selector matches on, so projecting first
933
+ // would make the two arguments mutually exclusive instead of
934
+ // composable.
935
+ const fieldNamesParam = Array.isArray(args.field_names) &&
936
+ args.field_names.every((f) => typeof f === 'string')
937
+ ? args.field_names
938
+ : undefined;
939
+ const nameContains = typeof args.name_contains === 'string' ? args.name_contains : undefined;
940
+ const selected = annotatedFields !== undefined
941
+ ? selectDescriptorsByName(annotatedFields, fieldNamesParam, nameContains)
942
+ : undefined;
943
+ const fields = selected !== undefined
944
+ ? projectFields(selected, fieldsParam, [])
945
+ : undefined;
946
+ // `field_count` keeps its meaning — the descriptors the TYPE has —
947
+ // so a call with no selector is unchanged. `returned_count` is what
948
+ // this response actually carries, so a selection is never silent
949
+ // (P2) and the caller can see what it did not ask for.
950
+ const fieldCount = annotatedFields !== undefined ? annotatedFields.length : 0;
951
+ const returnedCount = selected !== undefined ? selected.length : 0;
952
+ // Task 7b (plan 2026-07-27-02): each fieldset group's sentence is
953
+ // stated ONCE at the schema root and joined on the field's
954
+ // `group` (MEASURED: 32 occurrences of 5 distinct strings, 7334 B
955
+ // carrying 1204 B of payload). Narrow it with the selection —
956
+ // otherwise a two-descriptor read of a 154-field type ships every
957
+ // group sentence the type has, undoing Task 4 for exactly the
958
+ // narrow reads it exists to serve. Read off `selected`, i.e.
959
+ // BEFORE the `fields[]` sub-key whitelist: `fields:["type"]`
960
+ // strips the join key, and narrowing on the projected list would
961
+ // then delete every sentence. Same ordering rule, same reason, as
962
+ // selection-before-projection above.
963
+ const groups = narrowGroupsToSelection(schema.groups, selected);
964
+ // Task 7b Step 4: the element-level render rules. Read off
965
+ // `schema`, NOT narrowed by `selected` — see the pass-through
966
+ // comment at the structuredResult call below for why.
967
+ const renderRules = extractRenderRules(schema);
968
+ // Task 7b Steps 5-6: the shared rule notes, narrowed to the ids
969
+ // this response still references. See extractSharedRuleNotes().
970
+ const sharedRules = extractSharedRuleNotes(schema, selected);
971
+ // 2026-07-28: which build the version-scoped keys above were read
972
+ // on. Element-level like `renderRules`, and for the same reason —
973
+ // it qualifies claims the caller is about to act on, so a narrow
974
+ // field selection must not be able to drop it.
975
+ const claimProvenance = extractClaimProvenance(schema);
431
976
  // Wave-6 R3 (2026-05-29): attach a binding contract so
432
977
  // the agent doesn't have to reverse-engineer the
433
978
  // Multi-Items pattern from layout + inspection probes.
434
979
  const bindingContract = deriveBindingContract(name);
435
- const toolkitResult = detailResult(buildTypeSchemaDetail({
436
- name,
437
- label,
438
- origin,
439
- fields,
440
- ...(bindingContract !== undefined
441
- ? {
442
- bindingContract: {
443
- kind: bindingContract.kind,
444
- pattern: bindingContract.pattern,
445
- ...(bindingContract.child_type !== undefined
446
- ? { child_type: bindingContract.child_type }
447
- : {}),
448
- ...(bindingContract.binding_target_props !== undefined
449
- ? {
450
- binding_target_props: bindingContract.binding_target_props,
451
- }
452
- : {}),
453
- ...(bindingContract.canonical_text_prop !== undefined
454
- ? {
455
- canonical_text_prop: bindingContract.canonical_text_prop,
456
- }
457
- : {}),
458
- ...(bindingContract.location_prop !== undefined
459
- ? { location_prop: bindingContract.location_prop }
460
- : {}),
461
- ...(bindingContract.set_via_tool !== undefined
462
- ? { set_via_tool: bindingContract.set_via_tool }
463
- : {}),
464
- ...(bindingContract.discover_source_tool !== undefined
465
- ? {
466
- discover_source_tool: bindingContract.discover_source_tool,
467
- }
468
- : {}),
469
- ...(bindingContract.do_not !== undefined
470
- ? { do_not: bindingContract.do_not }
471
- : {}),
472
- ...(bindingContract.minimal_layout !== undefined
473
- ? { minimal_layout: bindingContract.minimal_layout }
474
- : {}),
475
- },
476
- }
477
- : {}),
478
- }));
479
- return withSiteMeta(structuredResult(toolkitResult, {
980
+ const toolkitResult = detailResult({
981
+ ...buildTypeSchemaDetail({
982
+ name,
983
+ label,
984
+ origin,
985
+ fields,
986
+ fieldCount,
987
+ returnedCount,
988
+ // Deliberately NOT handed to the detail renderer: the TS
989
+ // text leg is a NAME|TYPE|LABEL table and never carried
990
+ // `group_description` either, so the hoist moves the fact
991
+ // between root and field WITHOUT adding a new payload to
992
+ // the text leg. Its carrier is unchanged —
993
+ // `structuredContent` here, the JSON text leg on PHP.
994
+ ...(bindingContract !== undefined
995
+ ? {
996
+ bindingContract: {
997
+ kind: bindingContract.kind,
998
+ pattern: bindingContract.pattern,
999
+ ...(bindingContract.child_type !== undefined
1000
+ ? { child_type: bindingContract.child_type }
1001
+ : {}),
1002
+ ...(bindingContract.binding_target_props !== undefined
1003
+ ? {
1004
+ binding_target_props: bindingContract.binding_target_props,
1005
+ }
1006
+ : {}),
1007
+ ...(bindingContract.canonical_text_prop !== undefined
1008
+ ? {
1009
+ canonical_text_prop: bindingContract.canonical_text_prop,
1010
+ }
1011
+ : {}),
1012
+ ...(bindingContract.location_prop !== undefined
1013
+ ? { location_prop: bindingContract.location_prop }
1014
+ : {}),
1015
+ ...(bindingContract.set_via_tool !== undefined
1016
+ ? { set_via_tool: bindingContract.set_via_tool }
1017
+ : {}),
1018
+ ...(bindingContract.discover_source_tool !== undefined
1019
+ ? {
1020
+ discover_source_tool: bindingContract.discover_source_tool,
1021
+ }
1022
+ : {}),
1023
+ ...(bindingContract.do_not !== undefined
1024
+ ? { do_not: bindingContract.do_not }
1025
+ : {}),
1026
+ ...(bindingContract.minimal_layout !== undefined
1027
+ ? { minimal_layout: bindingContract.minimal_layout }
1028
+ : {}),
1029
+ },
1030
+ }
1031
+ : {}),
1032
+ }),
1033
+ includeMeta: false,
1034
+ });
1035
+ return withSiteMeta(structuredResult(applyMaxChars(toolkitResult, maxChars), {
480
1036
  name,
481
1037
  ...(label !== undefined ? { label } : {}),
482
1038
  ...(origin !== undefined ? { origin } : {}),
1039
+ ...(groups !== undefined ? { groups } : {}),
1040
+ // Task 7b Step 4 (plan 2026-07-27-02, inventory groups B/A/L):
1041
+ // element-level render rules, forwarded whole. Deliberately
1042
+ // NOT narrowed by the descriptor selection the way `groups`
1043
+ // is — a group sentence belongs to the fields it annotates,
1044
+ // but "this element is deleted at render when these props are
1045
+ // empty" is true of the element regardless of which
1046
+ // descriptors were asked for, and suppressing it on a narrow
1047
+ // read would hide it from exactly the caller about to write.
1048
+ //
1049
+ // Same carrier choice as `groups`: structuredContent here,
1050
+ // the JSON text leg on the PHP transport. The TS text leg is
1051
+ // a NAME|TYPE|LABEL table and never carried element-level
1052
+ // keys, so nothing is added to it.
1053
+ ...(renderRules.renders_only_if !== undefined
1054
+ ? { renders_only_if: renderRules.renders_only_if }
1055
+ : {}),
1056
+ ...(renderRules.blanked_by_parent !== undefined
1057
+ ? { blanked_by_parent: renderRules.blanked_by_parent }
1058
+ : {}),
1059
+ ...(renderRules.media_autoswap !== undefined
1060
+ ? { media_autoswap: renderRules.media_autoswap }
1061
+ : {}),
1062
+ // Directly after the gate it qualifies: a caller that sees
1063
+ // `renders_only_if` alone concludes "absent props render
1064
+ // nothing", which is false on 27 of the 28 gated types.
1065
+ ...(renderRules.placeholder_fallback !== undefined
1066
+ ? { placeholder_fallback: renderRules.placeholder_fallback }
1067
+ : {}),
1068
+ // Task 7b Steps 5-6: the element's own rule ids, then the note
1069
+ // texts resolving every id the response still references
1070
+ // (element-level or on a returned field). Placed BEFORE
1071
+ // `fields` so a reader meets the vocabulary before the
1072
+ // descriptors that use it.
1073
+ ...(sharedRules.rules !== undefined ? { rules: sharedRules.rules } : {}),
1074
+ ...(sharedRules.rule_notes !== undefined
1075
+ ? { rule_notes: sharedRules.rule_notes }
1076
+ : {}),
1077
+ // 2026-07-28: LAST of the element-level keys and still BEFORE
1078
+ // `fields`, so a reader meets the provenance of the claims
1079
+ // before the descriptors that carry them — the same ordering
1080
+ // argument as `rule_notes` above. A `differs`/`unknown` status
1081
+ // is what tells a caller that `no_effect_unverified` on a
1082
+ // descriptor below is a report, not a fact about their site.
1083
+ ...(claimProvenance !== undefined
1084
+ ? { claims_verified_on: claimProvenance }
1085
+ : {}),
483
1086
  ...(fields !== undefined ? { fields } : {}),
484
1087
  field_count: fieldCount,
1088
+ returned_count: returnedCount,
485
1089
  ...(bindingContract !== undefined
486
1090
  ? { binding_contract: bindingContract }
487
1091
  : {}),