@caelo-cms/shared 0.10.22 → 0.10.24

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 (249) hide show
  1. package/dist/ai-tools.d.ts +289 -273
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +342 -323
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auth-forms.d.ts.map +1 -1
  6. package/dist/auth-forms.js +4 -1
  7. package/dist/auth-forms.js.map +1 -1
  8. package/dist/base-css.d.ts +35 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +40 -0
  11. package/dist/base-css.js.map +1 -0
  12. package/dist/build-page.d.ts +330 -0
  13. package/dist/build-page.d.ts.map +1 -0
  14. package/dist/build-page.js +282 -0
  15. package/dist/build-page.js.map +1 -0
  16. package/dist/content.d.ts +322 -9
  17. package/dist/content.d.ts.map +1 -1
  18. package/dist/content.js +354 -11
  19. package/dist/content.js.map +1 -1
  20. package/dist/css-gradient-scan.d.ts +14 -0
  21. package/dist/css-gradient-scan.d.ts.map +1 -0
  22. package/dist/css-gradient-scan.js +81 -0
  23. package/dist/css-gradient-scan.js.map +1 -0
  24. package/dist/css-var-scan.d.ts +56 -0
  25. package/dist/css-var-scan.d.ts.map +1 -0
  26. package/dist/css-var-scan.js +97 -0
  27. package/dist/css-var-scan.js.map +1 -0
  28. package/dist/design-draft-shell.d.ts +21 -0
  29. package/dist/design-draft-shell.d.ts.map +1 -0
  30. package/dist/design-draft-shell.js +81 -0
  31. package/dist/design-draft-shell.js.map +1 -0
  32. package/dist/design-manifest.d.ts +36 -0
  33. package/dist/design-manifest.d.ts.map +1 -0
  34. package/dist/design-manifest.js +90 -0
  35. package/dist/design-manifest.js.map +1 -0
  36. package/dist/fonts.d.ts +89 -0
  37. package/dist/fonts.d.ts.map +1 -0
  38. package/dist/fonts.js +241 -0
  39. package/dist/fonts.js.map +1 -0
  40. package/dist/genesis-inventory.d.ts +32 -0
  41. package/dist/genesis-inventory.d.ts.map +1 -0
  42. package/dist/genesis-inventory.js +186 -0
  43. package/dist/genesis-inventory.js.map +1 -0
  44. package/dist/genesis.d.ts +102 -0
  45. package/dist/genesis.d.ts.map +1 -0
  46. package/dist/genesis.js +145 -0
  47. package/dist/genesis.js.map +1 -0
  48. package/dist/i18n.d.ts +29 -36
  49. package/dist/i18n.d.ts.map +1 -1
  50. package/dist/i18n.js +53 -128
  51. package/dist/i18n.js.map +1 -1
  52. package/dist/index.d.ts +28 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +28 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/interactions.d.ts +23 -0
  57. package/dist/interactions.d.ts.map +1 -0
  58. package/dist/interactions.js +44 -0
  59. package/dist/interactions.js.map +1 -0
  60. package/dist/media.d.ts +101 -16
  61. package/dist/media.d.ts.map +1 -1
  62. package/dist/media.js +126 -15
  63. package/dist/media.js.map +1 -1
  64. package/dist/page-log.d.ts +94 -0
  65. package/dist/page-log.d.ts.map +1 -0
  66. package/dist/page-log.js +111 -0
  67. package/dist/page-log.js.map +1 -0
  68. package/dist/preview-compose.d.ts +85 -12
  69. package/dist/preview-compose.d.ts.map +1 -1
  70. package/dist/preview-compose.js +157 -67
  71. package/dist/preview-compose.js.map +1 -1
  72. package/dist/proposal-status.d.ts +40 -0
  73. package/dist/proposal-status.d.ts.map +1 -0
  74. package/dist/proposal-status.js +34 -0
  75. package/dist/proposal-status.js.map +1 -0
  76. package/dist/responsive-images.d.ts +64 -0
  77. package/dist/responsive-images.d.ts.map +1 -0
  78. package/dist/responsive-images.js +98 -0
  79. package/dist/responsive-images.js.map +1 -0
  80. package/dist/safe-keys.d.ts +9 -0
  81. package/dist/safe-keys.d.ts.map +1 -0
  82. package/dist/safe-keys.js +20 -0
  83. package/dist/safe-keys.js.map +1 -0
  84. package/dist/seo.d.ts +11 -16
  85. package/dist/seo.d.ts.map +1 -1
  86. package/dist/seo.js +7 -23
  87. package/dist/seo.js.map +1 -1
  88. package/dist/skills.d.ts +14 -68
  89. package/dist/skills.d.ts.map +1 -1
  90. package/dist/skills.js +19 -113
  91. package/dist/skills.js.map +1 -1
  92. package/dist/strip-cdata.d.ts +7 -0
  93. package/dist/strip-cdata.d.ts.map +1 -0
  94. package/dist/strip-cdata.js +48 -0
  95. package/dist/strip-cdata.js.map +1 -0
  96. package/dist/structured-sets.d.ts +6 -52
  97. package/dist/structured-sets.d.ts.map +1 -1
  98. package/dist/structured-sets.js +6 -68
  99. package/dist/structured-sets.js.map +1 -1
  100. package/dist/subagents.d.ts +105 -3
  101. package/dist/subagents.d.ts.map +1 -1
  102. package/dist/subagents.js +224 -41
  103. package/dist/subagents.js.map +1 -1
  104. package/dist/template-engine.d.ts +85 -0
  105. package/dist/template-engine.d.ts.map +1 -0
  106. package/dist/template-engine.js +403 -0
  107. package/dist/template-engine.js.map +1 -0
  108. package/dist/theme-importers/auto-detect.d.ts +26 -0
  109. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  110. package/dist/theme-importers/auto-detect.js +42 -0
  111. package/dist/theme-importers/auto-detect.js.map +1 -0
  112. package/dist/theme-importers/css-comments.d.ts +12 -0
  113. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  114. package/dist/theme-importers/css-comments.js +15 -0
  115. package/dist/theme-importers/css-comments.js.map +1 -0
  116. package/dist/theme-importers/dtcg.d.ts +46 -0
  117. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  118. package/dist/theme-importers/dtcg.js +111 -0
  119. package/dist/theme-importers/dtcg.js.map +1 -0
  120. package/dist/theme-importers/loose.d.ts +3 -0
  121. package/dist/theme-importers/loose.d.ts.map +1 -0
  122. package/dist/theme-importers/loose.js +76 -0
  123. package/dist/theme-importers/loose.js.map +1 -0
  124. package/dist/theme-importers/shadcn.d.ts +24 -0
  125. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  126. package/dist/theme-importers/shadcn.js +135 -0
  127. package/dist/theme-importers/shadcn.js.map +1 -0
  128. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  129. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  130. package/dist/theme-importers/style-dictionary.js +125 -0
  131. package/dist/theme-importers/style-dictionary.js.map +1 -0
  132. package/dist/theme-importers/tailwind.d.ts +3 -0
  133. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  134. package/dist/theme-importers/tailwind.js +218 -0
  135. package/dist/theme-importers/tailwind.js.map +1 -0
  136. package/dist/theme-literal-binding.d.ts +37 -0
  137. package/dist/theme-literal-binding.d.ts.map +1 -0
  138. package/dist/theme-literal-binding.js +138 -0
  139. package/dist/theme-literal-binding.js.map +1 -0
  140. package/dist/theme-normalize.d.ts +31 -0
  141. package/dist/theme-normalize.d.ts.map +1 -0
  142. package/dist/theme-normalize.js +587 -0
  143. package/dist/theme-normalize.js.map +1 -0
  144. package/dist/theme-ramp.d.ts +55 -0
  145. package/dist/theme-ramp.d.ts.map +1 -0
  146. package/dist/theme-ramp.js +149 -0
  147. package/dist/theme-ramp.js.map +1 -0
  148. package/dist/theme-render.d.ts +105 -0
  149. package/dist/theme-render.d.ts.map +1 -0
  150. package/dist/theme-render.js +441 -0
  151. package/dist/theme-render.js.map +1 -0
  152. package/dist/themes-errors.d.ts +109 -0
  153. package/dist/themes-errors.d.ts.map +1 -0
  154. package/dist/themes-errors.js +170 -0
  155. package/dist/themes-errors.js.map +1 -0
  156. package/dist/themes.d.ts +343 -0
  157. package/dist/themes.d.ts.map +1 -0
  158. package/dist/themes.js +697 -0
  159. package/dist/themes.js.map +1 -0
  160. package/dist/version.d.ts +7 -4
  161. package/dist/version.d.ts.map +1 -1
  162. package/dist/version.js +6 -3
  163. package/dist/version.js.map +1 -1
  164. package/package.json +10 -2
  165. package/src/__tests__/redos-hardening.test.ts +160 -0
  166. package/src/ai-tools-add-module-modes.test.ts +106 -0
  167. package/src/ai-tools-position.test.ts +134 -0
  168. package/src/ai-tools.test.ts +81 -0
  169. package/src/ai-tools.ts +1105 -0
  170. package/src/auth-forms.ts +36 -0
  171. package/src/base-css.ts +42 -0
  172. package/src/build-page.test.ts +228 -0
  173. package/src/build-page.ts +319 -0
  174. package/src/cap-failures.ts +67 -0
  175. package/src/content.test.ts +170 -0
  176. package/src/content.ts +620 -0
  177. package/src/context.ts +43 -0
  178. package/src/css-gradient-scan.ts +88 -0
  179. package/src/css-var-scan.test.ts +96 -0
  180. package/src/css-var-scan.ts +144 -0
  181. package/src/derive-module-type.test.ts +80 -0
  182. package/src/design-draft-shell.test.ts +85 -0
  183. package/src/design-draft-shell.ts +109 -0
  184. package/src/design-manifest.ts +93 -0
  185. package/src/fonts.test.ts +157 -0
  186. package/src/fonts.ts +296 -0
  187. package/src/genesis-inventory.test.ts +86 -0
  188. package/src/genesis-inventory.ts +215 -0
  189. package/src/genesis-sanitize.test.ts +35 -0
  190. package/src/genesis.ts +158 -0
  191. package/src/i18n.test.ts +58 -0
  192. package/src/i18n.ts +91 -0
  193. package/src/index.test.ts +10 -0
  194. package/src/index.ts +59 -0
  195. package/src/interactions.ts +48 -0
  196. package/src/logger.ts +147 -0
  197. package/src/media.test.ts +160 -0
  198. package/src/media.ts +355 -0
  199. package/src/page-log.test.ts +163 -0
  200. package/src/page-log.ts +124 -0
  201. package/src/preview-compose.test.ts +637 -0
  202. package/src/preview-compose.ts +656 -0
  203. package/src/preview-scanner.test.ts +96 -0
  204. package/src/preview-scanner.ts +214 -0
  205. package/src/proposal-status.test.ts +69 -0
  206. package/src/proposal-status.ts +40 -0
  207. package/src/responsive-images.test.ts +104 -0
  208. package/src/responsive-images.ts +151 -0
  209. package/src/result.ts +29 -0
  210. package/src/safe-keys.ts +21 -0
  211. package/src/seo.test.ts +194 -0
  212. package/src/seo.ts +233 -0
  213. package/src/skills.ts +48 -0
  214. package/src/snapshots.test.ts +80 -0
  215. package/src/snapshots.ts +81 -0
  216. package/src/strip-cdata.test.ts +41 -0
  217. package/src/strip-cdata.ts +50 -0
  218. package/src/structured-sets.ts +114 -0
  219. package/src/subagents.test.ts +262 -0
  220. package/src/subagents.ts +432 -0
  221. package/src/template-engine.test.ts +379 -0
  222. package/src/template-engine.ts +520 -0
  223. package/src/theme-gradient.test.ts +92 -0
  224. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  225. package/src/theme-importers/auto-detect.ts +84 -0
  226. package/src/theme-importers/css-comments.ts +15 -0
  227. package/src/theme-importers/dtcg.ts +106 -0
  228. package/src/theme-importers/loose.ts +76 -0
  229. package/src/theme-importers/shadcn.ts +133 -0
  230. package/src/theme-importers/style-dictionary.ts +125 -0
  231. package/src/theme-importers/tailwind.ts +217 -0
  232. package/src/theme-literal-binding.test.ts +71 -0
  233. package/src/theme-literal-binding.ts +159 -0
  234. package/src/theme-motion.test.ts +115 -0
  235. package/src/theme-normalize-envelope.test.ts +43 -0
  236. package/src/theme-normalize-gradient.test.ts +135 -0
  237. package/src/theme-normalize.ts +661 -0
  238. package/src/theme-ramp.ts +187 -0
  239. package/src/theme-render-sanitize.test.ts +45 -0
  240. package/src/theme-render.test.ts +119 -0
  241. package/src/theme-render.ts +487 -0
  242. package/src/theme-shadow.test.ts +56 -0
  243. package/src/themes-errors.ts +199 -0
  244. package/src/themes.ts +842 -0
  245. package/src/version.ts +66 -0
  246. package/dist/translation.d.ts +0 -127
  247. package/dist/translation.d.ts.map +0 -1
  248. package/dist/translation.js +0 -208
  249. package/dist/translation.js.map +0 -1
@@ -0,0 +1,432 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P10.5 — Subagent invocation shapes (Zod) + result parser.
5
+ *
6
+ * A subagent is just a chat-runner turn. The `spawn_subagent` AI tool
7
+ * lets the parent kick off another chat-runner turn with a constrained
8
+ * tool set + a seed task message; the matcher inside that turn engages
9
+ * whichever skill the task wording matches. Same chat-runner code path,
10
+ * different inputs.
11
+ *
12
+ * This file holds:
13
+ * - subagentSpec — single-spawn input shape.
14
+ * - spawnSubagentToolInput — wraps one spec.
15
+ * - spawnSubagentsToolInput — wraps an array (parallel batch).
16
+ * - Return-shape variants the AI can request:
17
+ * verdict ({pass, issues[], suggestions[]})
18
+ * tree ({tree: [...], rationale})
19
+ * freeform ({text})
20
+ * rebuild ({pages[], contentNotes[], skipped[], summary})
21
+ * - parseSubagentResult — pulls JSON out of the subagent's final
22
+ * assistant message (handles ```json fences) and validates against
23
+ * the requested shape.
24
+ */
25
+
26
+ import { z } from "zod";
27
+
28
+ const expectedReturnShape = z.enum(["verdict", "tree", "freeform", "rebuild"]);
29
+ export type ExpectedReturnShape = z.infer<typeof expectedReturnShape>;
30
+
31
+ /**
32
+ * Run #8 R2a — the SINGLE source of truth for the return-shape enum.
33
+ *
34
+ * The spawn tools' hand-written provider `inputSchema` (JSON Schema) must
35
+ * carry the same enum values as this Zod schema; in run #8 the live
36
+ * validator rejected `"rebuild"` because the runtime resolved a stale
37
+ * pre-#266 build of this module while the provider schema advertised the
38
+ * new value (issue #251 drift class, resolution flavour). Tool files
39
+ * derive their JSON-Schema enums from this array instead of re-typing the
40
+ * literals, and a unit test asserts the tool schema accepts every shape
41
+ * this parser knows.
42
+ */
43
+ export const EXPECTED_RETURN_SHAPES: readonly ExpectedReturnShape[] = expectedReturnShape.options;
44
+
45
+ /**
46
+ * issue #306 — model tier a subagent runs at. `inherit` (the default) is
47
+ * today's behaviour: the child reuses the parent chat's provider+model.
48
+ * `mid` / `small` route the child onto a cheaper model the Owner mapped
49
+ * in the active provider's config (`ai_providers.config.modelTiers`).
50
+ * The tier VOCABULARY is deliberately abstract — tool results and
51
+ * editor-facing text never name the underlying model (CLAUDE.md §2:
52
+ * provider brand never surfaces in the editor chat UI); concrete model
53
+ * ids appear only on Owner surfaces (security panel, cost dashboard).
54
+ */
55
+ export const subagentModelTier = z.enum(["inherit", "mid", "small"]);
56
+ export type SubagentModelTier = z.infer<typeof subagentModelTier>;
57
+ /** Single source of truth for the tier enum (same #251 drift-guard pattern
58
+ * as EXPECTED_RETURN_SHAPES — tool JSON schemas derive from this array). */
59
+ export const SUBAGENT_MODEL_TIERS: readonly SubagentModelTier[] = subagentModelTier.options;
60
+
61
+ /**
62
+ * One subagent spec. The parent supplies role + task + optional
63
+ * narrowing. The handler creates the ephemeral chat session, appends
64
+ * the task as the seed user message, calls runChatTurn directly with
65
+ * `excludedToolNames=spawn_subagent,spawn_subagents` (depth cap) +
66
+ * `allowedToolNames` from the spec.
67
+ */
68
+ export const subagentSpec = z
69
+ .object({
70
+ /** Owner-readable role label for the verdicts UI + the subagent_runs row. */
71
+ role: z.string().min(1).max(120),
72
+ /** The seed user message. The matcher engages skills based on this text. */
73
+ task: z.string().min(1).max(8000),
74
+ /**
75
+ * Optional tool-catalogue narrowing. When omitted, the subagent
76
+ * gets the default registry MINUS the spawn tools. When set, the
77
+ * subagent gets the INTERSECTION of (default minus spawn) and
78
+ * `allowedToolNames` — typically read-only ops for safety.
79
+ */
80
+ allowedToolNames: z.array(z.string().min(1).max(120)).optional(),
81
+ /** Zod-validated return shape. Defaults to `verdict`. */
82
+ expectedReturnShape: expectedReturnShape.default("verdict"),
83
+ /**
84
+ * Per-spawn cost cap in microcents. OPTIONAL on purpose (issue #304):
85
+ * when omitted, the spawn orchestrator derives the cap from the armed
86
+ * run budget (#297) or falls back to SUBAGENT_CHILD_CAP_MICROCENTS.
87
+ * The old schema default (50M µ¢ = $0.50) sat BELOW the empirically
88
+ * observed 90–167M µ¢ per-child spend of migration page batches
89
+ * (runs #14/#15), so every child errored at the cap — a default here
90
+ * would make "AI omitted it" indistinguishable from "AI chose $0.50".
91
+ */
92
+ maxCostMicrocents: z.number().int().nonnegative().optional(),
93
+ /**
94
+ * Per-spawn wall-clock timeout. Default 300s (5 min): the documented
95
+ * fan-out use cases are page BUILDS — a Genesis design draft (full page +
96
+ * image generation) and a migration per-type rebuild each legitimately run
97
+ * 2–4 min. The old 60s default aborted every build child mid-work (Genesis
98
+ * live-run 2026-07: all 3 draft children `timed_out` at ~60000ms, so the
99
+ * flow saved 0 drafts; the abort also skips the child's `ai_calls` write, so
100
+ * the roll-up read `$0.00` — the child WAS building, not idle). Quick
101
+ * reviewer children (qa/legal/menu/categorizer) finish well under this
102
+ * ceiling, so the higher default is harmless for them. The AI can still
103
+ * pass a smaller `timeoutMs` for a known-fast child.
104
+ */
105
+ timeoutMs: z.number().int().min(1000).max(600_000).default(300_000),
106
+ /**
107
+ * issue #306 — model tier for this child. Default `inherit` keeps
108
+ * single-model behaviour byte-identical to pre-#306 (conservative
109
+ * default: nothing changes until a caller opts in per-spawn AND the
110
+ * Owner has mapped the tier). A requested-but-unmapped tier is a
111
+ * LOUD structured error at spawn time — never a silent downgrade to
112
+ * the parent's model (CLAUDE.md §2 no-fallbacks).
113
+ */
114
+ tier: subagentModelTier.default("inherit"),
115
+ /**
116
+ * Optional active page id. When passed, the spawn handler routes
117
+ * it through to the runChatTurn invocation as `activePageId`,
118
+ * giving the subagent the same Current-page volatile chunk a
119
+ * normal /edit chat would see. The subagent still has to call
120
+ * `pages.get_with_modules` to pull module HTML.
121
+ */
122
+ activePageId: z.string().uuid().optional(),
123
+ })
124
+ .strict();
125
+ export type SubagentSpec = z.infer<typeof subagentSpec>;
126
+
127
+ export const spawnSubagentToolInput = subagentSpec;
128
+ export type SpawnSubagentToolInput = SubagentSpec;
129
+
130
+ export const spawnSubagentsToolInput = z
131
+ .object({
132
+ // issue #304 — 32 matches the tool's advertised provider-schema
133
+ // maxItems (SUBAGENT_MAX_BATCH default). The previous max(8) silently
134
+ // rejected the very batches the provider schema invited (#251 drift
135
+ // class): a 14-page migration fan-out failed Zod validation at
136
+ // dispatch and fell back to serial building. SUBAGENT_MAX_BATCH must
137
+ // never be env-raised past this hard bound.
138
+ subagents: z.array(subagentSpec).min(1).max(32),
139
+ })
140
+ .strict();
141
+ export type SpawnSubagentsToolInput = z.infer<typeof spawnSubagentsToolInput>;
142
+
143
+ // ---------------------------------------------------------------------
144
+ // Return-shape variants the parent asks the subagent to emit
145
+ // ---------------------------------------------------------------------
146
+
147
+ export const verdictReturnShape = z
148
+ .object({
149
+ pass: z.boolean(),
150
+ issues: z
151
+ .array(z.union([z.string().min(1).max(2000), z.record(z.string(), z.unknown())]))
152
+ .max(50),
153
+ suggestions: z.array(z.string().min(1).max(2000)).max(50).default([]),
154
+ })
155
+ .strict();
156
+ export type VerdictReturn = z.infer<typeof verdictReturnShape>;
157
+
158
+ export const treeReturnShape = z
159
+ .object({
160
+ tree: z.array(z.unknown()).max(500),
161
+ rationale: z.string().max(4000).default(""),
162
+ })
163
+ .strict();
164
+ export type TreeReturn = z.infer<typeof treeReturnShape>;
165
+
166
+ export const freeformReturnShape = z
167
+ .object({
168
+ text: z.string().min(1).max(40_000),
169
+ })
170
+ .strict();
171
+ export type FreeformReturn = z.infer<typeof freeformReturnShape>;
172
+
173
+ /**
174
+ * issue #264 — compact per-page rebuild summary for migration fan-out
175
+ * subagents. The orchestrator chat's context grows by THIS shape only
176
+ * (never by the subagent's full transcript), so it is deliberately
177
+ * bounded: per-page status + short notes, deliberate omissions with a
178
+ * reason, and a one-paragraph summary. `pageId`/`slug` are both
179
+ * optional because a subagent that failed before resolving a page can
180
+ * still report the slug it was briefed with (or vice versa).
181
+ */
182
+ export const rebuildReturnShape = z
183
+ .object({
184
+ pages: z
185
+ .array(
186
+ z
187
+ .object({
188
+ pageId: z.string().uuid().optional(),
189
+ slug: z.string().min(1).max(500).optional(),
190
+ /**
191
+ * issue #306 — `needs_escalation`: the child detected the page
192
+ * needs something it is not equipped to BUILD (no matching
193
+ * module/pattern, a layout decision, unexpected source
194
+ * structure) and hands it back instead of improvising. The
195
+ * orchestrator re-dispatches exactly those pages one capability
196
+ * step up (see subagent-batch.ts escalation waves). The reason
197
+ * lives in `notes` and is REQUIRED for this status — a blind
198
+ * escalation would just re-run the same confusion at higher
199
+ * cost (enforced by the superRefine below).
200
+ */
201
+ status: z.enum(["rebuilt", "skipped", "failed", "needs_escalation"]),
202
+ /** Content-completeness note: what was dropped/merged and why, or
203
+ * why skipped/failed — or, for `needs_escalation`, the REQUIRED
204
+ * reason the page needs a more capable pass. */
205
+ notes: z.string().max(2000).optional(),
206
+ })
207
+ .strict()
208
+ .superRefine((page, ctx) => {
209
+ if (page.status === "needs_escalation" && !page.notes?.trim()) {
210
+ ctx.addIssue({
211
+ code: "custom",
212
+ path: ["notes"],
213
+ message:
214
+ 'status "needs_escalation" requires `notes` explaining WHAT new thing this page needs (missing pattern, layout decision, unexpected structure) — the escalated pass is briefed from it',
215
+ });
216
+ }
217
+ }),
218
+ )
219
+ .min(1)
220
+ .max(100),
221
+ /** Cross-page content-completeness observations (e.g. "source pricing table had a footnote row I folded into the caption"). */
222
+ contentNotes: z.array(z.string().min(1).max(2000)).max(50).default([]),
223
+ /** Items deliberately left out — the orchestrator relays these verbatim. */
224
+ skipped: z
225
+ .array(
226
+ z
227
+ .object({
228
+ item: z.string().min(1).max(500),
229
+ reason: z.string().min(1).max(1000),
230
+ })
231
+ .strict(),
232
+ )
233
+ .max(100)
234
+ .default([]),
235
+ summary: z.string().max(4000).default(""),
236
+ })
237
+ .strict();
238
+ export type RebuildReturn = z.infer<typeof rebuildReturnShape>;
239
+
240
+ // ---------------------------------------------------------------------
241
+ // Result parser — handles `````json {…} ````` fences + raw JSON.
242
+ // ---------------------------------------------------------------------
243
+
244
+ function stripFences(text: string): string {
245
+ const trimmed = text.trim();
246
+ if (trimmed.startsWith("```")) {
247
+ const firstNl = trimmed.indexOf("\n");
248
+ const last = trimmed.lastIndexOf("```");
249
+ if (firstNl !== -1 && last > firstNl) {
250
+ return trimmed.slice(firstNl + 1, last).trim();
251
+ }
252
+ }
253
+ // Common case: raw JSON wrapped in prose. Pull out the first {...}
254
+ // block by brace-balancing. If no braces, return as-is.
255
+ const firstBrace = trimmed.indexOf("{");
256
+ if (firstBrace === -1) return trimmed;
257
+ let depth = 0;
258
+ let inStr = false;
259
+ let escaped = false;
260
+ for (let i = firstBrace; i < trimmed.length; i++) {
261
+ const ch = trimmed[i];
262
+ if (escaped) {
263
+ escaped = false;
264
+ continue;
265
+ }
266
+ if (inStr) {
267
+ if (ch === "\\") {
268
+ escaped = true;
269
+ continue;
270
+ }
271
+ if (ch === '"') inStr = false;
272
+ continue;
273
+ }
274
+ if (ch === '"') {
275
+ inStr = true;
276
+ continue;
277
+ }
278
+ if (ch === "{") depth += 1;
279
+ else if (ch === "}") {
280
+ depth -= 1;
281
+ if (depth === 0) return trimmed.slice(firstBrace, i + 1);
282
+ }
283
+ }
284
+ return trimmed;
285
+ }
286
+
287
+ export type ParseSuccess =
288
+ | { ok: true; shape: "verdict"; value: VerdictReturn }
289
+ | { ok: true; shape: "tree"; value: TreeReturn }
290
+ | { ok: true; shape: "freeform"; value: FreeformReturn }
291
+ | { ok: true; shape: "rebuild"; value: RebuildReturn };
292
+
293
+ export type ParseResult = ParseSuccess | { ok: false; error: string };
294
+
295
+ /**
296
+ * Run #10 D2 — validate an ALREADY-PARSED value against the requested
297
+ * return shape. This is the structured half of the result channel: the
298
+ * `submit_result` tool hands the payload straight from the tool-call
299
+ * arguments (already JSON — no fence-stripping, no brace-balancing),
300
+ * so the "response is not valid JSON" failure class cannot occur.
301
+ * `parseSubagentResult` (final-text fallback) delegates here after its
302
+ * JSON extraction.
303
+ *
304
+ * For `freeform`, accepts `{text: "..."}` OR a bare string (wrapped as
305
+ * `{text}`) so the model can pass its prose directly.
306
+ */
307
+ export function validateSubagentResultValue(
308
+ value: unknown,
309
+ shape: ExpectedReturnShape,
310
+ ): ParseResult {
311
+ // Defense-in-depth for the structured shapes: a provider can
312
+ // double-encode the payload so `value` arrives as a JSON string even
313
+ // after the `submit_result` inputSchema fix (and the final-text
314
+ // fallback path never passes through normalize-args at all). The
315
+ // structured shapes want an object/array, so decode a JSON-looking
316
+ // string before parsing. `freeform` legitimately wants a bare string,
317
+ // so it is left untouched.
318
+ let coerced = value;
319
+ if (shape !== "freeform" && typeof coerced === "string") {
320
+ const trimmed = coerced.trim();
321
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
322
+ try {
323
+ coerced = JSON.parse(trimmed);
324
+ } catch {
325
+ // leave as-is; the shape parse below reports the real mismatch
326
+ }
327
+ }
328
+ }
329
+
330
+ // v0.2.67 — when the schema rejects, include the actual top-level
331
+ // keys the subagent returned so the parent AI can tell whether the
332
+ // subagent ignored the schema entirely (returned freeform text under
333
+ // a different shape) vs. got close but mistyped a single field.
334
+ const observedKeys =
335
+ typeof coerced === "object" && coerced !== null && !Array.isArray(coerced)
336
+ ? Object.keys(coerced as Record<string, unknown>)
337
+ : [];
338
+ const observedSummary =
339
+ observedKeys.length > 0
340
+ ? ` got keys: [${observedKeys.slice(0, 8).join(", ")}]`
341
+ : ` got: ${typeof coerced} (${Array.isArray(coerced) ? "array" : "scalar"})`;
342
+ const issueSummary = (issues: readonly z.ZodIssue[]): string =>
343
+ issues
344
+ .slice(0, 3)
345
+ .map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`)
346
+ .join("; ");
347
+
348
+ if (shape === "freeform") {
349
+ if (typeof value === "string") {
350
+ if (value.trim().length === 0) return { ok: false, error: "subagent returned empty text" };
351
+ return { ok: true, shape: "freeform", value: { text: value.trim() } };
352
+ }
353
+ const validated = freeformReturnShape.safeParse(value);
354
+ if (!validated.success) {
355
+ return {
356
+ ok: false,
357
+ error: `freeform shape mismatch (expected {text: string} or a plain string):${observedSummary}; ${issueSummary(validated.error.issues)}`,
358
+ };
359
+ }
360
+ return { ok: true, shape: "freeform", value: validated.data };
361
+ }
362
+ if (shape === "verdict") {
363
+ const validated = verdictReturnShape.safeParse(coerced);
364
+ if (!validated.success) {
365
+ return {
366
+ ok: false,
367
+ error: `verdict shape mismatch (expected {pass: boolean, issues: array, suggestions?: array}):${observedSummary}; ${issueSummary(validated.error.issues)}`,
368
+ };
369
+ }
370
+ return { ok: true, shape: "verdict", value: validated.data };
371
+ }
372
+ if (shape === "rebuild") {
373
+ const validated = rebuildReturnShape.safeParse(coerced);
374
+ if (!validated.success) {
375
+ return {
376
+ ok: false,
377
+ error: `rebuild shape mismatch (expected {pages: [{pageId?, slug?, status: "rebuilt"|"skipped"|"failed"|"needs_escalation", notes?}], contentNotes?: string[], skipped?: [{item, reason}], summary?: string}):${observedSummary}; ${issueSummary(validated.error.issues)}`,
378
+ };
379
+ }
380
+ return { ok: true, shape: "rebuild", value: validated.data };
381
+ }
382
+ // tree
383
+ const validated = treeReturnShape.safeParse(coerced);
384
+ if (!validated.success) {
385
+ return {
386
+ ok: false,
387
+ error: `tree shape mismatch (expected {tree: array, rationale?: string}):${observedSummary}; ${issueSummary(validated.error.issues)}`,
388
+ };
389
+ }
390
+ return { ok: true, shape: "tree", value: validated.data };
391
+ }
392
+
393
+ /**
394
+ * Pull JSON out of the subagent's final assistant text and validate
395
+ * against the requested shape. On schema mismatch, returns
396
+ * `{ok: false, error}` so the caller can decide whether to retry.
397
+ *
398
+ * For `freeform`, accepts EITHER `{text: "..."}` JSON or raw text;
399
+ * raw text is wrapped as `{text: rawText}` so the caller always gets
400
+ * the same shape.
401
+ *
402
+ * Run #10 D2 — this is now the FALLBACK channel; the canonical path is
403
+ * the child calling `submit_result`, whose payload is validated by
404
+ * `validateSubagentResultValue` without any text extraction.
405
+ */
406
+ export function parseSubagentResult(text: string, shape: ExpectedReturnShape): ParseResult {
407
+ if (shape === "freeform") {
408
+ // Try JSON first; on failure treat the whole thing as freeform text.
409
+ try {
410
+ const stripped = stripFences(text);
411
+ const parsed = JSON.parse(stripped) as unknown;
412
+ const validated = freeformReturnShape.safeParse(parsed);
413
+ if (validated.success) return { ok: true, shape: "freeform", value: validated.data };
414
+ } catch {
415
+ /* fall through */
416
+ }
417
+ if (text.trim().length === 0) return { ok: false, error: "subagent returned empty text" };
418
+ return { ok: true, shape: "freeform", value: { text: text.trim() } };
419
+ }
420
+
421
+ const stripped = stripFences(text);
422
+ let parsed: unknown;
423
+ try {
424
+ parsed = JSON.parse(stripped);
425
+ } catch (e) {
426
+ return {
427
+ ok: false,
428
+ error: `subagent response is not valid JSON: ${(e as Error).message}; first 200 chars: ${stripped.slice(0, 200)}`,
429
+ };
430
+ }
431
+ return validateSubagentResultValue(parsed, shape);
432
+ }