@worca/app 1.3.0 → 1.4.0-rc.1

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 (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -0,0 +1,47 @@
1
+ // src/shared/forms/paths.mjs
2
+ // The one path language of ask forms (spec §3): `data.a.b` and `data.items[].id`.
3
+ // Used by `bind`, `enumFrom`, `defaultFrom` and `options.from`. No expressions, no
4
+ // indexes, no functions — a path either names a value or a column of values.
5
+
6
+ const PATH_RE = /^data(\.[A-Za-z_][A-Za-z0-9_]*(\[\])?)+$/;
7
+
8
+ export const isValidPath = (path) => typeof path === 'string' && path.length <= 200 && PATH_RE.test(path);
9
+
10
+ const segments = (path) => path.split('.').slice(1).map((part) => (
11
+ part.endsWith('[]') ? { key: part.slice(0, -2), each: true } : { key: part, each: false }));
12
+
13
+ /** Resolve against `{ data }`. A path with a `[]` segment returns a flat array
14
+ * (missing branches contribute nothing); any other path returns the value or undefined. */
15
+ export function resolvePath(path, root) {
16
+ if (!isValidPath(path)) return undefined;
17
+ let cur = [root && root.data];
18
+ let many = false;
19
+ for (const { key, each } of segments(path)) {
20
+ cur = cur.map((c) => (c !== null && typeof c === 'object' && !Array.isArray(c) && Object.hasOwn(c, key) ? c[key] : undefined));
21
+ if (each) {
22
+ many = true;
23
+ cur = cur.flatMap((c) => (Array.isArray(c) ? c : []));
24
+ }
25
+ }
26
+ return many ? cur.filter((c) => c !== undefined) : cur[0];
27
+ }
28
+
29
+ /** The schema a path lands on inside a data schema, or null when the path does not exist.
30
+ * An object with no `properties` is opaque: any path below it is accepted as `{}`. */
31
+ export function schemaAtPath(path, dataSchema) {
32
+ if (!isValidPath(path)) return null;
33
+ let cur = dataSchema;
34
+ for (const { key, each } of segments(path)) {
35
+ if (!cur || cur.type !== 'object') return null;
36
+ if (!cur.properties) return {};
37
+ cur = Object.hasOwn(cur.properties, key) ? cur.properties[key] : null;
38
+ if (!cur) return null;
39
+ if (each) {
40
+ if (cur.type !== 'array' || !cur.items) return null;
41
+ cur = cur.items;
42
+ }
43
+ }
44
+ return cur || null;
45
+ }
46
+
47
+ export const pathInSchema = (path, dataSchema) => schemaAtPath(path, dataSchema) !== null;
@@ -0,0 +1,309 @@
1
+ // src/shared/forms/project.mjs
2
+ // The text projection of a form ask (spec §8): what the CLI prints, what a chat channel
3
+ // posts, what History and Ask Worca quote — and the reverse, turning a typed line back
4
+ // into values. Derived from the same declaration the web renderer draws, so a surface
5
+ // without pixels can still answer.
6
+ import { resolvePath } from './paths.mjs';
7
+ import { walkLayout } from './layout.mjs';
8
+ import { widgetClass } from './catalog.mjs';
9
+
10
+ const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
11
+ const MAX_TABLE_ROWS = 10;
12
+ const REPLY_HINT_FIELDS = 3;
13
+ const ROW_WIDGETS = new Set(['rank', 'table-select', 'gallery', 'review-list']);
14
+ const TRUE_WORDS = new Set(['y', 'yes', 'true', 'on', '1']);
15
+ const FALSE_WORDS = new Set(['n', 'no', 'false', 'off', '0']);
16
+
17
+ const rowsOf = (path, data) => {
18
+ const rows = typeof path === 'string' ? resolvePath(path, { data }) : [];
19
+ return Array.isArray(rows) ? rows.filter(isObject) : [];
20
+ };
21
+ const isScalar = (v) => ['string', 'number', 'boolean'].includes(typeof v);
22
+ /** A row's property, OWN keys only (C20): the row is agent data and the key is the form author's
23
+ * text (`titleKey`, a column's `key`, `options.value`) — every row inherits `constructor`. */
24
+ const own = (r, k) => (isObject(r) && typeof k === 'string' && Object.hasOwn(r, k) ? r[k] : undefined);
25
+ /** A scalar as text. Anything else — absent, null, an object — is no text at all, so the words
26
+ * "undefined", "null" and "[object Object]" never reach a chat channel. */
27
+ const text = (v) => (isScalar(v) ? String(v) : '');
28
+ /** A bound value as prose: text, or an object / a list as its JSON; '' for nothing. */
29
+ const prose = (v) => text(v) || (v !== null && typeof v === 'object' ? JSON.stringify(v) : '');
30
+ /** ` — <text>` when a row carries that property, else nothing. */
31
+ const tail = (r, k) => (text(own(r, k)) === '' ? '' : ` — ${text(own(r, k))}`);
32
+ /** The id a row can be answered with — a text one (gate 1, C22); null for a row that has none. */
33
+ const idOf = (r) => { const id = own(r, 'id'); return typeof id === 'string' && id !== '' ? id : null; };
34
+ const rowLabel = (item, r) => [own(r, item.titleKey || 'title'), own(r, item.captionKey || 'caption'), own(r, 'name'), own(r, 'label'), own(r, 'id')]
35
+ .map(text).find((s) => s !== '') || '';
36
+ /** `- <id>: <label>`; a row with no text id is shown by its label alone. */
37
+ const rowLine = (item, r) => (idOf(r) === null ? `- ${rowLabel(item, r)}` : `- ${idOf(r)}: ${rowLabel(item, r)}`);
38
+ /** The rows of a row widget that can be CHOSEN: the ones with a text id. */
39
+ const choices = (item, data) => rowsOf(item.bind, data).filter((r) => idOf(r) !== null);
40
+ /** The text display widgets, which spec §6.2 lets bind a `file` instead of a value. */
41
+ const TEXT_WIDGETS = new Set(['markdown', 'code', 'diff', 'table', 'json']);
42
+ const BODY_CHARS = 160;
43
+ const oneLine = (s) => { const t = String(s).replace(/\s+/g, ' ').trim(); return t.length > BODY_CHARS ? `${t.slice(0, BODY_CHARS - 1)}…` : t; };
44
+ /** An author's label for a choice. Text only — which is also what keeps the inherited
45
+ * `labels.constructor` (a function) from ever being one. */
46
+ const labelOf = (item, v) => (isObject(item.labels) && typeof item.labels[v] === 'string' ? item.labels[v] : String(v));
47
+ const size = (bytes) => (bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round((bytes || 0) / 1024))} KB`);
48
+
49
+ function optionsOf(item, schema, data) {
50
+ if (Array.isArray(item.suggest)) return item.suggest.map((s) => ({ value: s, label: s }));
51
+ if (isObject(item.options)) {
52
+ return rowsOf(item.options.from, data).map((r) => ({ value: own(r, item.options.value || 'id'), label: text(own(r, item.options.label || 'label')) }))
53
+ .filter((o) => isScalar(o.value) && o.value !== '').map((o) => ({ value: o.value, label: o.label || String(o.value) }));
54
+ }
55
+ if (ROW_WIDGETS.has(item.widget)) return choices(item, data).map((r) => ({ value: idOf(r), label: rowLabel(item, r) }));
56
+ const en = schema.enum || (schema.items && schema.items.enum) || [];
57
+ return en.map((v) => ({ value: v, label: labelOf(item, v) }));
58
+ }
59
+
60
+ /** A review-list's per-item sub-prompts: its verdict, then its optional note. */
61
+ function itemFieldsOf(item, schema) {
62
+ const p = schema.items.properties;
63
+ const verdict = { field: 'verdict', label: 'Verdict', widget: 'select', type: 'string', schema: p.verdict,
64
+ options: p.verdict.enum.map((v) => ({ value: v, label: labelOf(item, v) })),
65
+ default: p.verdict.default !== undefined ? p.verdict.default : p.verdict.enum[0], required: true };
66
+ if (!p.note) return [verdict];
67
+ return [verdict, { field: 'note', label: 'Note', widget: 'text', type: 'string', schema: p.note, options: [], default: undefined, required: false }];
68
+ }
69
+
70
+ /** The input fields of an ask, in layout order. `ask` = { data, layout, answerSchema }.
71
+ * → [{ field, label, widget, type, schema, options: [{ value, label }], items, itemFields,
72
+ * verdicts, free, default, required, when }]
73
+ * `items` is [{ id, label }] for the row widgets (rank, table-select, gallery, review-list),
74
+ * else null; `itemFields` is set for review-list only. */
75
+ export function promptFields(ask) {
76
+ const props = (ask.answerSchema && ask.answerSchema.properties) || {};
77
+ const required = new Set((ask.answerSchema && ask.answerSchema.required) || []);
78
+ const out = [];
79
+ walkLayout(ask.layout, (raw, eff) => {
80
+ if (!eff || widgetClass(eff.widget, eff) !== 'input' || typeof eff.field !== 'string' || !Object.hasOwn(props, eff.field) || out.some((f) => f.field === eff.field)) return;
81
+ const s = props[eff.field];
82
+ const review = eff.widget === 'review-list';
83
+ out.push({
84
+ field: eff.field, label: String(eff.label || s.title || eff.field), widget: eff.widget, type: s.type, schema: s,
85
+ options: optionsOf(eff, s, ask.data),
86
+ items: ROW_WIDGETS.has(eff.widget) ? choices(eff, ask.data).map((r) => ({ id: idOf(r), label: rowLabel(eff, r) })) : null,
87
+ itemFields: review ? itemFieldsOf(eff, s) : null,
88
+ verdicts: review ? s.items.properties.verdict.enum.slice() : [],
89
+ free: Array.isArray(eff.suggest),
90
+ default: s.default, required: required.has(eff.field), when: eff.when || null,
91
+ });
92
+ });
93
+ return out;
94
+ }
95
+
96
+ function describe(f) {
97
+ const bits = [];
98
+ if (f.widget === 'rank') bits.push(`order of: ${f.options.map((o) => o.value).join(', ')}`);
99
+ else if (f.widget === 'review-list') bits.push(`per item ${f.verdicts.join('/')} for: ${f.options.map((o) => o.value).join(', ')}`);
100
+ else if (f.options.length) bits.push(`${f.type === 'array' ? 'any of' : 'one of'}${f.free ? ' (or your own text)' : ''}: ${f.options.map((o, i) => `${i + 1}) ${o.label}`).join(' ')}`);
101
+ else {
102
+ const s = f.schema;
103
+ const range = s.minimum !== undefined && s.maximum !== undefined ? ` ${s.minimum}–${s.maximum}` : s.minimum !== undefined ? ` ≥ ${s.minimum}` : '';
104
+ bits.push(`${f.type === 'boolean' ? 'yes/no' : f.type}${range}${s.format ? ` (${s.format})` : ''}${s.pattern ? ` /${s.pattern}/` : ''}`);
105
+ }
106
+ if (f.default !== undefined) bits.push(`default ${JSON.stringify(f.default)}`);
107
+ if (!f.required) bits.push('optional');
108
+ if (f.when) bits.push(`only when ${Object.entries(f.when).map(([k, v]) => `${k}=${Array.isArray(v) ? v.join('/') : v}`).join(', ')}`);
109
+ return bits.join(' · ');
110
+ }
111
+
112
+ /** `ask` = the P2 envelope ({ title, agent, data, layout, answerSchema, files }). → string
113
+ * `ref`: a non-empty string appends ONE reply line naming the first fields. `maxChars`:
114
+ * 0 / omitted = no cap; otherwise DISPLAY text is dropped from the end until it fits and
115
+ * the cut is marked by a lone `…` line — the title, the prompts and the reply line never are. */
116
+ export function projectForm(ask, { ref, maxChars = 0 } = {}) {
117
+ const data = ask.data;
118
+ const fileLine = (rel) => {
119
+ const f = (ask.files || []).find((x) => x.rel === rel);
120
+ return f ? `${rel} (${f.mime}, ${size(f.bytes)})` : String(rel);
121
+ };
122
+ const fields = promptFields(ask);
123
+ const blocks = [{ keep: true, lines: [`${ask.title}${ask.agent ? ` — ${ask.agent}` : ''}`, ''] }];
124
+ const show = (...lines) => blocks.push({ keep: false, lines });
125
+ walkLayout(ask.layout, (raw, eff) => {
126
+ if (!eff) return;
127
+ const w = eff.widget;
128
+ const v = typeof eff.bind === 'string' ? resolvePath(eff.bind, { data }) : undefined;
129
+ // An absent optional value prints NOTHING: this text is posted to a chat channel, and the
130
+ // word "undefined" in it is a bug the reader sees. A missing file in a row is a dash.
131
+ const fileOr = (rel) => (text(rel) === '' ? '—' : fileLine(text(rel)));
132
+ const isFile = typeof eff.bind === 'string' && Array.isArray(ask.fileRefs) && ask.fileRefs.some((r) => isObject(r) && r.path === eff.bind);
133
+ if (TEXT_WIDGETS.has(w) && isFile) {
134
+ if (text(v) !== '') show(`[${w}] ${fileLine(text(v))}`);
135
+ } else if (w === 'markdown') {
136
+ if (prose(v) !== '') show(prose(v), '');
137
+ } else if (w === 'callout') {
138
+ const said = [text(eff.title), text(v) || text(eff.text)].filter((s) => s !== '').join(' ');
139
+ if (said !== '') show(said, '');
140
+ } else if (w === 'image' || w === 'pdf' || w === 'media') {
141
+ if (text(v) !== '') show(`[${w}] ${fileLine(text(v))}`);
142
+ } else if (w === 'compare') {
143
+ const before = resolvePath(eff.before, { data });
144
+ const after = resolvePath(eff.after, { data });
145
+ if (text(before) !== '' || text(after) !== '') show(`[compare] ${fileOr(before)} -> ${fileOr(after)}`);
146
+ } else if (w === 'gallery') {
147
+ show(...rowsOf(eff.bind, data).map((r) => `[image] ${idOf(r) === null ? '' : `${idOf(r)}: `}${fileOr(own(r, eff.fileKey || 'file'))}${tail(r, eff.captionKey || 'caption')}`));
148
+ } else if (w === 'file-list') {
149
+ show(...rowsOf(eff.bind, data).map((r) => `[file] ${fileOr(own(r, eff.fileKey || 'file'))}${tail(r, eff.noteKey || 'note')}`));
150
+ } else if (w === 'code' || w === 'diff') {
151
+ if (prose(v) !== '') show(`[${w}] ${text(eff.name)}`.trim(), prose(v), '');
152
+ } else if (w === 'json') {
153
+ if (v !== undefined && v !== null) show(`[json] ${JSON.stringify(v)}`);
154
+ } else if (w === 'table' || w === 'table-select') {
155
+ // gate 1 holds `columns` to [{ key, label }], but a persisted ask outlives the gate that admitted it
156
+ const cols = (Array.isArray(eff.columns) ? eff.columns : []).filter((c) => isObject(c) && typeof c.key === 'string');
157
+ const rows = rowsOf(eff.bind, data);
158
+ if (cols.length) {
159
+ show(cols.map((c) => c.label || c.key).join(' | '),
160
+ ...rows.slice(0, MAX_TABLE_ROWS).map((r) => cols.map((c) => `${prose(own(r, c.key)) || '—'}${text(own(r, c.key)) === '' ? '' : text(c.unit)}`).join(' | ')),
161
+ ...(rows.length > MAX_TABLE_ROWS ? [`… ${rows.length - MAX_TABLE_ROWS} more rows`] : []));
162
+ }
163
+ } else if (w === 'review-list') {
164
+ // a reviewer on a text surface must see WHAT is being judged, not only its title
165
+ show(...rowsOf(eff.bind, data).map((r) => `${rowLine(eff, r)}${text(own(r, eff.bodyKey || 'body')) ? ` — ${oneLine(text(own(r, eff.bodyKey || 'body')))}` : ''}`));
166
+ } else if (w === 'rank') {
167
+ show(...rowsOf(eff.bind, data).map((r) => rowLine(eff, r)));
168
+ }
169
+ const at = fields.findIndex((f) => f.field === eff.field);
170
+ if (at >= 0 && widgetClass(w, eff) === 'input') blocks.push({ keep: true, lines: [`${at + 1}. ${fields[at].label} {${fields[at].field}}`, ` ${describe(fields[at])}`] });
171
+ });
172
+ if (typeof ref === 'string' && ref !== '') {
173
+ const hint = fields.filter((f) => !f.when).slice(0, REPLY_HINT_FIELDS).map((f) => `${f.field}=${f.type === 'array' ? '<a,b>' : '<value>'}`);
174
+ blocks.push({ keep: true, lines: ['', `Reply: /answer ${ref} ${hint.join(' | ')}`.trimEnd()] });
175
+ }
176
+ const render = (bs) => bs.flatMap((b) => b.lines).join('\n').replace(/\n{3,}/g, '\n\n');
177
+ if (!(maxChars > 0) || render(blocks).length <= maxChars) return render(blocks);
178
+ const kept = blocks.slice();
179
+ let marker = -1; // the one `…` line; it always sits after the next block to go
180
+ while (render(kept).length > maxChars) {
181
+ const last = kept.findLastIndex((b) => !b.keep);
182
+ if (last < 0) break;
183
+ if (marker >= 0) kept.splice(marker, 1);
184
+ kept[last] = { keep: true, lines: ['…'] };
185
+ marker = last;
186
+ }
187
+ return render(kept);
188
+ }
189
+
190
+ /** Split on an unescaped separator. A backslash pair is kept as-is for unescapeToken(). */
191
+ function splitOn(text, sep) {
192
+ const parts = [''];
193
+ for (let i = 0; i < text.length; i += 1) {
194
+ if (text[i] === '\\' && i + 1 < text.length) { parts[parts.length - 1] += text[i] + text[i + 1]; i += 1; } else if (text[i] === sep) parts.push('');
195
+ else parts[parts.length - 1] += text[i];
196
+ }
197
+ return parts;
198
+ }
199
+ /** Only `\|` `\,` `\=` `\:` and `\\` unescape. Any other `\x` stays two characters, so a
200
+ * Windows path typed into chat (C:\Users\dev) survives byte for byte. */
201
+ const unescapeToken = (s) => s.replace(/\\([|,=:\\])/g, '$1').trim();
202
+ const hasUnescaped = (text, ch) => splitOn(text, ch).length > 1;
203
+
204
+ /** `t` is one UNESCAPED token. A number enum keeps C10: in [1,2,4,8] "4" is the value 4. For text
205
+ * values (row ids, mostly) what the prompt SHOWS decides: an exact label first. Then, when a
206
+ * number is both an option's position and a DIFFERENT option's id — rows 3,1,2, or the subset
207
+ * 2,5 — neither reading is safe: the prompt numbers the options and the agent named the ids, and
208
+ * guessing sends the agent a row the human did not choose. It is refused, and a label settles it.
209
+ * After that: the exact value, the 1-based ordinal, value or label ignoring case, free text. */
210
+ function pick(f, t) {
211
+ const low = t.toLowerCase();
212
+ const exact = f.options.find((o) => String(o.value) === t);
213
+ if (exact && typeof exact.value !== 'string') return { ok: true, value: exact.value };
214
+ const shown = f.options.find((o) => o.label === t);
215
+ if (shown) return { ok: true, value: shown.value };
216
+ const nth = /^\d+$/.test(t) ? f.options[Number(t) - 1] : undefined;
217
+ if (exact && nth && nth !== exact) {
218
+ return { ok: false, code: 'enum', message: `"${t}" is both option ${t} ("${nth.label}") and the id of "${exact.label}": type the label of the one you mean` };
219
+ }
220
+ if (exact) return { ok: true, value: exact.value };
221
+ if (nth) return { ok: true, value: nth.value };
222
+ const loose = f.options.find((o) => String(o.value).toLowerCase() === low || o.label.toLowerCase() === low);
223
+ if (loose) return { ok: true, value: loose.value };
224
+ if (f.free && t !== '') return { ok: true, value: t };
225
+ return { ok: false, code: 'enum', message: `"${t}" is not one of: ${f.options.map((o) => o.value).join(', ')}` };
226
+ }
227
+
228
+ /** One typed entry → a value of the field's type. `f` is a promptFields() entry (or one of
229
+ * its `itemFields`). → { ok: true, value } | { ok: false, code, message }. An empty entry
230
+ * is `{ ok: true, value: undefined }` ("use the default"). A trailing `\r` is not data. */
231
+ export function coerceInput(f, text) {
232
+ const raw = String(text === undefined || text === null ? '' : text).replace(/\r$/, '');
233
+ if (raw.trim() === '') return { ok: true, value: undefined };
234
+ if (f.widget === 'review-list') {
235
+ const value = [];
236
+ for (const part of splitOn(raw, ',')) {
237
+ if (part.trim() === '') continue; // a trailing comma is not an item
238
+ const [id, verdict = '', ...note] = splitOn(part, ':').map(unescapeToken);
239
+ if (!f.options.some((o) => o.value === id)) return { ok: false, code: 'enum', message: `"${id}" is not an item` };
240
+ const v = pick(f.itemFields[0], verdict); // value, number or label — exactly what the per-item prompt takes
241
+ if (!v.ok) return v;
242
+ value.push(note.join(':') ? { id, verdict: v.value, note: note.join(':') } : { id, verdict: v.value });
243
+ }
244
+ const given = new Set(value.map((x) => x.id));
245
+ for (const o of f.options) if (!given.has(o.value)) value.push({ id: o.value, verdict: f.itemFields[0].default });
246
+ return { ok: true, value };
247
+ }
248
+ if (f.type === 'array') {
249
+ const value = [];
250
+ for (const part of splitOn(raw, ',')) {
251
+ if (part.trim() === '') continue;
252
+ const p = f.options.length ? pick(f, unescapeToken(part)) : { ok: true, value: unescapeToken(part) };
253
+ if (!p.ok) return p;
254
+ value.push(p.value);
255
+ }
256
+ // C18: a rank is a full reorder. The items named come first, the rest keep their data order —
257
+ // the same courtesy a review-list gives its unlisted items. A repeat is gate 3's to refuse.
258
+ if (f.widget === 'rank') for (const o of f.options) if (!value.includes(o.value)) value.push(o.value);
259
+ return { ok: true, value };
260
+ }
261
+ if (f.type === 'boolean') {
262
+ const t = raw.trim().toLowerCase();
263
+ if (TRUE_WORDS.has(t)) return { ok: true, value: true };
264
+ if (FALSE_WORDS.has(t)) return { ok: true, value: false };
265
+ return { ok: false, code: 'type', message: 'answer yes or no' };
266
+ }
267
+ if (f.options.length) return pick(f, unescapeToken(raw));
268
+ if (f.type === 'number' || f.type === 'integer') {
269
+ const n = Number(raw.trim());
270
+ return Number.isFinite(n) ? { ok: true, value: n } : { ok: false, code: 'type', message: `"${raw.trim()}" is not a number` };
271
+ }
272
+ return { ok: true, value: unescapeToken(raw) };
273
+ }
274
+
275
+ /** `field=value | field2=a,b` → { values, errors: [{ path, code, message }] }.
276
+ * - split on unescaped `|`; inside a pair only the FIRST unescaped `=` splits;
277
+ * - a name that is not an input field is `unknown-field`; a field given twice is `duplicate-field`;
278
+ * - text with no unescaped `=` is the value of `bareField`, or of the only field of a one-field form;
279
+ * - commas split only when the field's type is `array`.
280
+ * Values are coerced, NOT validated — feed them to collectAnswer. */
281
+ export function parseAnswerLine(text, ask, { bareField } = {}) {
282
+ const fields = promptFields(ask);
283
+ const values = {};
284
+ const errors = [];
285
+ const line = String(text || '').trim();
286
+ if (line === '') return { values, errors };
287
+ const bare = fields.find((f) => f.field === bareField) || (fields.length === 1 ? fields[0] : null);
288
+ const put = (f, rawValue) => {
289
+ if (Object.prototype.hasOwnProperty.call(values, f.field)) { errors.push({ path: f.field, code: 'duplicate-field', message: `"${f.field}" is given twice` }); return; }
290
+ const r = coerceInput(f, rawValue);
291
+ if (!r.ok) errors.push({ path: f.field, code: r.code, message: r.message });
292
+ else if (r.value !== undefined) values[f.field] = r.value;
293
+ };
294
+ if (!hasUnescaped(line, '=')) {
295
+ if (bare) put(bare, line); else errors.push({ path: '', code: 'parse', message: 'write the answer as field=value | field2=value' });
296
+ return { values, errors };
297
+ }
298
+ for (const part of splitOn(line, '|')) {
299
+ if (part.trim() === '') continue;
300
+ const eq = splitOn(part, '=');
301
+ if (eq.length < 2) { errors.push({ path: '', code: 'parse', message: `write "${part.trim()}" as field=value` }); continue; }
302
+ const name = unescapeToken(eq[0]);
303
+ const f = fields.find((x) => x.field === name);
304
+ // where a bare answer is possible, the likeliest cause is free text that holds a "=" (C9)
305
+ if (!f) { errors.push({ path: name, code: 'unknown-field', message: `"${name}" is not a field of this form${bare ? ' (a literal = in an answer is written \\=)' : ''}` }); continue; }
306
+ put(f, eq.slice(1).join('='));
307
+ }
308
+ return { values, errors };
309
+ }
@@ -0,0 +1,205 @@
1
+ // src/shared/forms/schema.mjs
2
+ // The ask-forms schema dialect (spec §3.1): a closed JSON Schema subset plus `file`,
3
+ // `enumFrom` and `defaultFrom`. One validator for all three gates, server and browser.
4
+ // Closed on purpose: it is what keeps the text projection and the form-def checks decidable.
5
+ import { isValidPath, resolvePath } from './paths.mjs';
6
+
7
+ export const SCHEMA_TYPES = Object.freeze(['string', 'number', 'integer', 'boolean', 'array', 'object', 'file']);
8
+ export const SCHEMA_FORMATS = Object.freeze(['date', 'date-time', 'email', 'uri']);
9
+ const KEYS = new Set(['type', 'enum', 'enumFrom', 'default', 'defaultFrom', 'required', 'minimum', 'maximum',
10
+ 'multipleOf', 'minLength', 'maxLength', 'pattern', 'patternHint', 'format', 'items', 'minItems', 'maxItems',
11
+ 'uniqueItems', 'properties', 'title', 'description', 'accept']);
12
+ const TYPES = new Set(SCHEMA_TYPES);
13
+ /** The types a keyword can act on. One that sits elsewhere (`minItems` on the items node, a
14
+ * `minimum` on a string) would be ignored by validate() — so it is refused here, by name. */
15
+ const SCALARS = Object.freeze(['string', 'number', 'integer', 'boolean']);
16
+ const APPLIES = Object.freeze({
17
+ minLength: ['string'], maxLength: ['string'], pattern: ['string'], patternHint: ['string'], format: ['string'],
18
+ minimum: ['number', 'integer'], maximum: ['number', 'integer'], multipleOf: ['number', 'integer'],
19
+ items: ['array'], minItems: ['array'], maxItems: ['array'], uniqueItems: ['array'],
20
+ properties: ['object'], required: ['object'],
21
+ enum: SCALARS, enumFrom: SCALARS,
22
+ });
23
+ const FORMATS = new Set(SCHEMA_FORMATS);
24
+ /** Is this JSON value of that scalar type? */
25
+ const FITS = Object.freeze({
26
+ string: (v) => typeof v === 'string', number: (v) => typeof v === 'number', integer: (v) => Number.isInteger(v), boolean: (v) => typeof v === 'boolean',
27
+ });
28
+ const MAX_DEPTH = 3; // object → array → object, then scalars
29
+
30
+ const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
31
+ const isScalar = (v) => ['string', 'number', 'boolean'].includes(typeof v);
32
+ const clone = (v) => JSON.parse(JSON.stringify(v));
33
+ const err = (path, code, message) => ({ path, code, message });
34
+ /** A shape alone lets 2026-13-45 through, and the agent would resume with it: the day must exist. */
35
+ function realDate(y, m, d) {
36
+ const t = new Date(0);
37
+ t.setUTCFullYear(y, m - 1, d); // not Date.UTC(): that maps the years 0–99 onto 1900–1999
38
+ return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d;
39
+ }
40
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
41
+ const DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
42
+ const FORMAT_OK = Object.freeze({
43
+ date: (v) => { const m = DATE_RE.exec(v); return m !== null && realDate(Number(m[1]), Number(m[2]), Number(m[3])); },
44
+ 'date-time': (v) => {
45
+ const m = DATE_TIME_RE.exec(v);
46
+ return m !== null && realDate(Number(m[1]), Number(m[2]), Number(m[3]))
47
+ && Number(m[4]) < 24 && Number(m[5]) < 60 && (m[6] === undefined || Number(m[6]) < 60);
48
+ },
49
+ email: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
50
+ uri: (v) => /^[a-z][a-z0-9+.-]*:\S+$/i.test(v),
51
+ });
52
+
53
+ function compiles(pattern) {
54
+ try { return Boolean(new RegExp(pattern)); } catch { return false; }
55
+ }
56
+
57
+ /** Is `schema` inside the dialect? `side` is 'data' (may use `file`, may hold opaque
58
+ * objects) or 'answer' (may use `enumFrom` / `defaultFrom`). → [{ path, code, message }] */
59
+ export function checkDialect(schema, { side }) {
60
+ const errors = [];
61
+ const bad = (path, message) => errors.push(err(path, 'dialect', message));
62
+ (function walk(node, path, depth) {
63
+ if (!isObject(node)) { bad(path, 'schema must be an object'); return; }
64
+ // lists nest too: object → array → object → a list of scalars is the deepest shape there is
65
+ if (depth > MAX_DEPTH + 1) { bad(path, 'nesting is limited to object → array → object'); return; }
66
+ const before = errors.length;
67
+ for (const k of Object.keys(node)) if (!KEYS.has(k)) bad(path, `unsupported keyword "${k}"`);
68
+ if (!TYPES.has(node.type)) { bad(path, `unknown type ${JSON.stringify(node.type)}`); return; }
69
+ for (const k of Object.keys(node)) if (Object.hasOwn(APPLIES, k) && !APPLIES[k].includes(node.type)) bad(path, `"${k}" does not apply to ${node.type}`);
70
+ if (node.type === 'file' && side !== 'data') bad(path, '"file" is a data-side type');
71
+ if (node.accept !== undefined && (node.type !== 'file' || !Array.isArray(node.accept) || !node.accept.every((a) => typeof a === 'string'))) bad(path, '"accept" is a list of mime patterns on a file');
72
+ for (const k of ['enumFrom', 'defaultFrom']) {
73
+ if (node[k] === undefined) continue;
74
+ if (side !== 'answer') bad(path, `"${k}" is an answer-side keyword`);
75
+ else if (!isValidPath(node[k])) bad(path, `"${k}" is not a valid path`);
76
+ }
77
+ if (node.enum !== undefined && (!Array.isArray(node.enum) || node.enum.length === 0 || !node.enum.every(isScalar))) bad(path, '"enum" is a non-empty list of scalars');
78
+ // a value of another type can never be answered: `{ type: 'integer', enum: ['a'] }` is a field nobody can fill
79
+ else if (node.enum !== undefined && Object.hasOwn(FITS, node.type) && !node.enum.every(FITS[node.type])) bad(path, `"enum" values must be of type ${node.type}`);
80
+ if (node.enum !== undefined && node.enumFrom !== undefined) bad(path, 'use "enum" or "enumFrom", not both');
81
+ if (node.pattern !== undefined && (typeof node.pattern !== 'string' || !compiles(node.pattern))) bad(path, '"pattern" does not compile');
82
+ if (node.format !== undefined && !FORMATS.has(node.format)) bad(path, `unknown format "${node.format}"`);
83
+ for (const k of ['minimum', 'maximum', 'multipleOf']) {
84
+ if (node[k] !== undefined && !Number.isFinite(node[k])) bad(path, `"${k}" must be a number`);
85
+ }
86
+ if (Number.isFinite(node.multipleOf) && node.multipleOf <= 0) bad(path, '"multipleOf" must be greater than 0');
87
+ for (const k of ['minLength', 'maxLength', 'minItems', 'maxItems']) {
88
+ if (node[k] !== undefined && !(Number.isInteger(node[k]) && node[k] >= 0)) bad(path, `"${k}" must be a whole number, 0 or more`);
89
+ }
90
+ for (const k of ['title', 'description', 'patternHint']) if (node[k] !== undefined && typeof node[k] !== 'string') bad(path, `"${k}" must be text`);
91
+ if (node.uniqueItems !== undefined && typeof node.uniqueItems !== 'boolean') bad(path, '"uniqueItems" must be true or false');
92
+ if (node.type === 'array') {
93
+ if (node.items === undefined) bad(path, 'an array needs "items"');
94
+ else walk(node.items, `${path}[]`, depth + 1);
95
+ }
96
+ if (node.type === 'object') {
97
+ if (depth >= MAX_DEPTH) bad(path, 'nesting is limited to object → array → object');
98
+ if (node.properties === undefined) {
99
+ if (side !== 'data' || depth === 0) bad(path, 'an object needs "properties"');
100
+ } else if (!isObject(node.properties)) bad(path, '"properties" must be an object');
101
+ else for (const [k, v] of Object.entries(node.properties)) {
102
+ // the one name an assignment cannot hold: `out[k] = v` would set a prototype instead
103
+ if (k === '__proto__') bad(path, '"__proto__" cannot be a property name');
104
+ else walk(v, path ? `${path}.${k}` : k, depth + 1);
105
+ }
106
+ if (node.required !== undefined) {
107
+ if (!Array.isArray(node.required) || !node.required.every((r) => typeof r === 'string')) bad(path, '"required" is a list of property names');
108
+ else for (const r of node.required) if (!isObject(node.properties) || !Object.hasOwn(node.properties, r)) bad(path, `"required" names unknown property "${r}"`);
109
+ }
110
+ }
111
+ // A `default` prefills the web form even where auto mode never looks (a `when`-hidden field),
112
+ // so it is held to its own node here. Only a node that is otherwise sound is run: check()
113
+ // assumes the shape this walk has just verified. `enumFrom` is unknown until ask time.
114
+ if (node.default !== undefined && errors.length === before) {
115
+ const own = [];
116
+ check(node, node.default, path, own);
117
+ if (own.length) bad(path, `"default" does not fit its own schema: ${own[0].message}`);
118
+ }
119
+ })(schema, '', 0);
120
+ return errors;
121
+ }
122
+
123
+ const isMissing = (v) => v === undefined || v === null || v === '';
124
+
125
+ function check(schema, v, path, errors) {
126
+ const push = (code, message) => errors.push(err(path, code, message));
127
+ switch (schema.type) {
128
+ case 'object': {
129
+ if (!isObject(v)) { push('type', 'Must be an object.'); return; }
130
+ if (!schema.properties) return;
131
+ // own keys only: `constructor` and `toString` are inherited by every object, and are neither
132
+ // a declared property nor a supplied value
133
+ for (const k of schema.required || []) if (!Object.hasOwn(v, k) || isMissing(v[k])) errors.push(err(path ? `${path}.${k}` : k, 'required', 'Required.'));
134
+ for (const k of Object.keys(v)) {
135
+ if (!Object.hasOwn(schema.properties, k)) { errors.push(err(path ? `${path}.${k}` : k, 'unknown-key', 'Not a declared property.')); continue; }
136
+ if (!isMissing(v[k])) check(schema.properties[k], v[k], path ? `${path}.${k}` : k, errors);
137
+ }
138
+ return;
139
+ }
140
+ case 'array': {
141
+ if (!Array.isArray(v)) { push('type', 'Must be a list.'); return; }
142
+ if (schema.minItems !== undefined && v.length < schema.minItems) push('minItems', `Pick at least ${schema.minItems}.`);
143
+ if (schema.maxItems !== undefined && v.length > schema.maxItems) push('maxItems', `Pick at most ${schema.maxItems}.`);
144
+ if (schema.uniqueItems && new Set(v.map((x) => JSON.stringify(x))).size !== v.length) push('unique', 'Entries must be different.');
145
+ v.forEach((item, i) => check(schema.items, item, `${path}[${i}]`, errors));
146
+ return;
147
+ }
148
+ case 'string': case 'file': {
149
+ if (typeof v !== 'string') { push('type', 'Must be text.'); return; }
150
+ if (schema.type === 'file' && (v.includes('\0') || v.trim() === '')) push('type', 'Must be a file path.');
151
+ if (schema.minLength !== undefined && v.length < schema.minLength) push('minLength', `At least ${schema.minLength} characters.`);
152
+ if (schema.maxLength !== undefined && v.length > schema.maxLength) push('maxLength', `At most ${schema.maxLength} characters.`);
153
+ if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(v)) push('pattern', schema.patternHint || 'Does not match the expected format.');
154
+ if (schema.format !== undefined && Object.hasOwn(FORMAT_OK, schema.format) && !FORMAT_OK[schema.format](v)) push('format', `Must be a valid ${schema.format}.`);
155
+ break;
156
+ }
157
+ case 'number': case 'integer': {
158
+ if (typeof v !== 'number' || !Number.isFinite(v)) { push('type', 'Must be a number.'); return; }
159
+ if (schema.type === 'integer' && !Number.isInteger(v)) push('type', 'Must be a whole number.');
160
+ if (schema.minimum !== undefined && v < schema.minimum) push('min', `Minimum is ${schema.minimum}.`);
161
+ if (schema.maximum !== undefined && v > schema.maximum) push('max', `Maximum is ${schema.maximum}.`);
162
+ if (schema.multipleOf !== undefined && Math.abs(v / schema.multipleOf - Math.round(v / schema.multipleOf)) > 1e-9) push('multiple', `Must be a multiple of ${schema.multipleOf}.`);
163
+ break;
164
+ }
165
+ case 'boolean':
166
+ if (typeof v !== 'boolean') { push('type', 'Must be on or off.'); return; }
167
+ break;
168
+ default:
169
+ push('dialect', `unknown type ${JSON.stringify(schema.type)}`);
170
+ return;
171
+ }
172
+ if (schema.enum !== undefined && !schema.enum.includes(v)) push('enum', 'Not one of the allowed choices.');
173
+ }
174
+
175
+ /** → { ok, errors: [{ path, code, message }] }. An empty string counts as missing. */
176
+ export function validate(schema, value) {
177
+ const errors = [];
178
+ check(schema, value, '', errors);
179
+ return { ok: errors.length === 0, errors };
180
+ }
181
+
182
+ /** Materialize `enumFrom` → `enum` and `defaultFrom` → `default` against the agent's
183
+ * data. Done once at ask time and stored with the ask, so gate 3 sees a closed set. */
184
+ export function resolveAnswerSchema(answerSchema, data) {
185
+ const out = clone(answerSchema);
186
+ (function walk(node) {
187
+ if (!isObject(node)) return;
188
+ if (node.enumFrom !== undefined) {
189
+ const found = resolvePath(node.enumFrom, { data });
190
+ node.enum = (Array.isArray(found) ? found : [found]).filter(isScalar);
191
+ delete node.enumFrom;
192
+ }
193
+ if (node.defaultFrom !== undefined) {
194
+ const found = resolvePath(node.defaultFrom, { data });
195
+ // A list field takes a list and any other field one value. `null` and an empty list suggest
196
+ // nothing: the field then has no default, and auto mode falls back to its first choice.
197
+ const usable = found !== undefined && found !== null && Array.isArray(found) === (node.type === 'array') && !(Array.isArray(found) && found.length === 0);
198
+ if (usable) node.default = clone(found);
199
+ delete node.defaultFrom;
200
+ }
201
+ if (node.items) walk(node.items);
202
+ if (isObject(node.properties)) for (const v of Object.values(node.properties)) walk(v);
203
+ })(out);
204
+ return out;
205
+ }