jsfe 1.7.1 → 1.7.3

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 (40) hide show
  1. package/README.md +5 -0
  2. package/dist/forms/contracts.d.ts.map +1 -1
  3. package/dist/forms/contracts.js +15 -5
  4. package/dist/forms/flows.d.ts +3 -155
  5. package/dist/forms/flows.d.ts.map +1 -1
  6. package/dist/forms/flows.js +98 -32
  7. package/dist/forms/proposal-runtime.d.ts +5 -0
  8. package/dist/forms/proposal-runtime.d.ts.map +1 -0
  9. package/dist/forms/proposal-runtime.js +9 -0
  10. package/dist/helpers/form-conversation.d.ts +28 -0
  11. package/dist/helpers/form-conversation.d.ts.map +1 -0
  12. package/dist/helpers/form-conversation.js +57 -0
  13. package/dist/helpers/form-proposals.d.ts +17 -0
  14. package/dist/helpers/form-proposals.d.ts.map +1 -0
  15. package/dist/helpers/form-proposals.js +292 -0
  16. package/dist/helpers/form-state.d.ts +1 -0
  17. package/dist/helpers/form-state.d.ts.map +1 -1
  18. package/dist/helpers/form-state.js +10 -2
  19. package/dist/helpers/index.d.ts +35 -1
  20. package/dist/helpers/index.d.ts.map +1 -1
  21. package/dist/helpers/index.js +6 -1
  22. package/dist/helpers/match-inputs.js +1 -1
  23. package/dist/helpers/proposal-contract.d.ts +7 -0
  24. package/dist/helpers/proposal-contract.d.ts.map +1 -0
  25. package/dist/helpers/proposal-contract.js +95 -0
  26. package/dist/host/capabilities.d.ts.map +1 -1
  27. package/dist/host/capabilities.js +6 -0
  28. package/dist/host/flow-recovery.d.ts +19 -0
  29. package/dist/host/flow-recovery.d.ts.map +1 -0
  30. package/dist/host/flow-recovery.js +43 -0
  31. package/dist/host/index.d.ts +2 -0
  32. package/dist/host/index.d.ts.map +1 -1
  33. package/dist/host/index.js +1 -0
  34. package/dist/index.d.ts +13 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +31 -2
  37. package/dist/runtime-identity.js +1 -1
  38. package/docs/development-release.md +12 -0
  39. package/docs/forms.md +40 -1
  40. package/package.json +1 -1
@@ -16,6 +16,7 @@
16
16
  * state that survives DISPATCH and reboot, so a caller who leaves a form resumes it. Results of finished forms:
17
17
  * cargo.formResults[<form id>].
18
18
  */
19
+ import { INPUT_PROPOSALS_REQUIREMENT, hasInputProposals, proposalRuntimeSteps } from './proposal-runtime.js';
19
20
  import { resolveFormInputs, validateFormIncompleteOutcome, validateFormExhaustion } from '../helpers/form-state.js';
20
21
  // ── step helpers ────────────────────────────────────────────────────────────────────────────────────────────────
21
22
  const SET = (id, variable, value) => ({ id, type: 'SET', variable, value });
@@ -28,7 +29,10 @@ const FLOW = (id, value, callType = 'call', parameters) => ({ id, type: 'FLOW',
28
29
  const WHEN = (id, condition, step) => ({ id, type: 'CASE', branches: { [`condition: ${condition}`]: step, default: NOOP(`${id}_skip`) } });
29
30
  const vars = (o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, { type: Array.isArray(v) ? 'array' : v === null || typeof v === 'object' ? 'object' : typeof v, value: v }]));
30
31
  const incompleteAttrs = (form, reason) => ({ outcome: `{{formIncompleteOutcome(cargo,${form},{outcome:'unresolved',reason:${reason}}).outcome}}`, reason: `{{formIncompleteOutcome(cargo,${form},{outcome:'unresolved',reason:${reason}}).reason}}` });
31
- const incompleteDispatch = (id, form, reason) => ({ id, type: 'DISPATCH', ...incompleteAttrs(form, reason) });
32
+ const recoveryAttrs = (form, reason, failure = false, mode = 'resume') => { const call = `formConversationOutcome(cargo,${form},${reason},${failure},${JSON.stringify(mode)})`; return { outcome: `{{${call}.outcome}}`, reason: `{{${call}.reason}}`, recovery: { version: 1, mode: `{{${call}.recovery.mode}}`, reason: `{{${call}.recovery.reason}}`, form: `{{${call}.recovery.form || ''}}`, flow: `{{${call}.recovery.flow || ''}}` } }; };
33
+ const incompleteDispatch = (id, form, reason) => ({ id, type: 'DISPATCH', ...recoveryAttrs(form, reason, true, 'none') });
34
+ const yieldDispatch = (id, form, reason) => ({ id, type: 'DISPATCH', ...recoveryAttrs(form, reason) });
35
+ const recoveryStamp = (id, form, reason, mode = 'resume') => ({ ...SET(id, 'noop', 'true'), recovery: recoveryAttrs(form, reason, true, mode).recovery });
32
36
  const optionalIncomplete = (id, condition, form, reason) => WHEN(id, `(${condition}) && !!formIncompleteOutcome(cargo,${form},{outcome:'unresolved',reason:${reason}}).owner`, incompleteDispatch(id + '_dispatch', form, reason));
33
37
  /** A SET value that yields this text: with {{ }} it is a template (as is); without, a JS string literal. */
34
38
  export const textValue = (text) => (String(text).includes('{{') ? String(text) : JSON.stringify(String(text)));
@@ -111,11 +115,12 @@ export function formFlow({ id, name, version, description, inputs, after = [], e
111
115
  id, name, version, description, scope, metadata: { formProtocol: 2 }, parameters: [...parameters, { name: 'form_instance', type: 'string', internal: true, description: 'Internal instance to resume' }], ...(primary ? { primary: true } : {}), ...(prompt ? { prompt, prompt_es } : {}),
112
116
  variables: vars({ form_on_exhausted: on_exhausted, form_incomplete_outcome: incompleteFrom ? null : incomplete_outcome, form_instance: '', form_internal_resume: false, form_resume_owner: '', form_arguments: {}, form_exhaustion: null, form_values: {}, form_validation: '', initial_readings: [], inputs: resolveFormInputs(inputs, FIELD_PRESETS), parent_form: '', result_input: '', questions: [], provides: null, exit_context: '', exit_context_es: '', form_confirm_q: '', form_confirm_q_es: '', form_confirmation: confirm || {}, form_should_confirm: false, ...extraVars }),
113
117
  steps: [
118
+ ...proposalRuntimeSteps(id, [], hasInputProposals(inputs) ? [INPUT_PROPOSALS_REQUIREMENT] : []),
114
119
  SET(`${id}_resume_kind`, 'form_internal_resume', '!!form_instance'),
115
120
  SET(`${id}_open`, 'form_instance', `openFormInstance(cargo, ${JSON.stringify(id)}, inputs, { parent: parent_form || null, input: result_input || '', context:!!parent_form && !result_input, resume: form_instance || (!parent_form ? (cargo.formTasks || {})[${JSON.stringify(id)}] || '' : ''), providers: provides, questions: questions, incomplete_outcome: ${incompleteExpression}, on_exhausted: form_on_exhausted })`),
116
121
  SIDE(`${id}_task`, `(!parent_form ? (cargo.formTasks = cargo.formTasks || {}, cargo.formTasks[${JSON.stringify(id)}] = form_instance) : true, true)`),
117
122
  SIDE(`${id}_provider_reset`, '(delete cargo.forms[form_instance].providerRequests, delete cargo.forms[form_instance].activeGates, delete cargo.forms[form_instance].providerGateActive, true)'),
118
- SIDE(`${id}_pass_reset`, '(cargo.forms[form_instance].replays = 0, form_internal_resume ? true : (cargo.forms[form_instance].attempts = {}), true)'),
123
+ SIDE(`${id}_pass_reset`, '(cargo.forms[form_instance].replays = 0, form_internal_resume ? true : (cargo.forms[form_instance].attempts = {},cargo.forms[form_instance].misses={},cargo.forms[form_instance].feedback={}), true)'),
119
124
  SET(`${id}_arguments`, 'form_arguments', `rememberFormArguments(cargo.forms[form_instance], currentContext, ${JSON.stringify(parameters.filter(p => !p.internal && !/^internal\b/i.test(p.description || '')).map(p => p.name))}, form_internal_resume)`),
120
125
  ...parameters.filter(p => !p.internal && !/^internal\b/i.test(p.description || '')).map(p => SET(`${id}_restore_${p.name}`, p.name, `form_internal_resume ? form_arguments[${JSON.stringify(p.name)}] : currentContext[${JSON.stringify(p.name)}]`)),
121
126
  ...before,
@@ -128,7 +133,7 @@ export function formFlow({ id, name, version, description, inputs, after = [], e
128
133
  WHEN(`${id}_parent_resume`, "rf_status === 'parent_changed'", FLOW(`${id}_parent_reenter`, 'resume-form', 'reboot', { form: '{{form_resume_owner}}' })),
129
134
  ] : []),
130
135
  ...(close_on_stop ? [WHEN(`${id}_close_stopped`, "rf_status === 'stopped'", SIDE(`${id}_stop_close`, '(closeFormInstance(cargo, form_instance, false), true)'))] : []),
131
- WHEN(`${id}_left`, "rf_status !== 'complete'", END(`${id}_left_end`)),
136
+ WHEN(`${id}_left`, "rf_status !== 'complete' || !cargo.forms[form_instance]", END(`${id}_left_end`)),
132
137
  ...(validator ? [
133
138
  SET(`${id}_values`, 'form_values', 'cargo.forms[form_instance].values'),
134
139
  SIDE(`${id}_validation_reset`, '(cargo.formCheck = null, true)'),
@@ -137,6 +142,7 @@ export function formFlow({ id, name, version, description, inputs, after = [], e
137
142
  WHEN(`${id}_resolve_validation_exhausted`, "form_validation === 'failed'", SET(`${id}_validation_exhaustion`, 'form_exhaustion', "exhaustFormInput(cargo,form_instance,((cargo.formCheck || {}).invalid || [])[0] ? cargo.formCheck.invalid[0].name : '')")),
138
143
  WHEN(`${id}_recover_validation_exhausted`, 'form_exhaustion', FLOW(`${id}_validation_recovery`, '{{form_exhaustion.flow}}', 'reboot', { form: '{{form_exhaustion.form}}', failed_form: '{{form_exhaustion.failed_form}}', input: '{{form_exhaustion.input}}', failed_input: '{{form_exhaustion.failed_input}}', reason: '{{form_exhaustion.reason}}' })),
139
144
  optionalIncomplete(`${id}_optional_validation_failed`, "form_validation === 'failed'", 'form_instance', "form_instance+'_incomplete'"),
145
+ WHEN(`${id}_validation_failed_recovery`, "form_validation === 'failed'", recoveryStamp(`${id}_validation_stamp`, 'form_instance', "'validation_exhausted'", 'none')),
140
146
  WHEN(`${id}_validation_failed`, "form_validation === 'failed'", FLOW(`${id}_validation_give_up`, 'give-up', 'reboot', { context: '{{exit_context}}', context_es: '{{exit_context_es}}' })),
141
147
  WHEN(`${id}_validation_message`, "form_validation === 'retry' && cargo.formCheck.message", SAY(`${id}_validation_say`, '{{cargo.formCheck.message}}', '{{cargo.formCheck.message_es}}')),
142
148
  WHEN(`${id}_validation_retry`, "form_validation === 'retry'", FLOW(`${id}_validate_again`, id, 'replace', { parent_form: '{{parent_form}}', result_input: '{{result_input}}', form_instance: '{{form_instance}}' })),
@@ -162,7 +168,7 @@ const runForm = {
162
168
  id: 'run-form', name: 'RunForm', version: '1.0.0',
163
169
  description: "Worker (shared scope, called by a form flow): the FORM loop. Merges the form's activation parameters into cargo.forms[rf_form] once, then each pass finds the first missing input whose `when` holds, collects it (collect-input) or runs its sub-form, and replaces itself until nothing is missing. Sets rf_status: 'complete' | 'stopped' (a path in rf_stop was set) | 'unwound'. Reads the form's `inputs`, `parent_form`, `result_input`, `questions`, `provides`, `exit_context(_es)`.",
164
170
  parameters: [{ name: 'rf_resume_parent', type: 'boolean', description: 'Yield a context collector when its owning form accepts a change' }, { name: 'rf_form', type: 'string', description: 'The form id (the calling form flow id)' }, { name: 'rf_resume', type: 'string', description: "'true' on the loop's own replays" }, { name: 'rf_stop', type: 'string', description: 'A cargo path (or list) that ends the form when set' }],
165
- variables: vars({ rf_stop: '', rf_resume: '', rf_resume_parent: false, rf_status: '', rf_next: '', rf_next_form: '', rf_questions: [], rf_provides: null, rf_max: 0, rf_pending: [], rf_deferred: [] }),
171
+ variables: vars({ rf_stop: '', rf_resume: '', rf_resume_parent: false, rf_status: '', rf_next: '', rf_next_form: '', rf_questions: [], rf_provides: null, rf_max: 0, rf_pending: [], rf_deferred: [], rf_proposal: null, rf_proposal_readings: [] }),
166
172
  steps: [
167
173
  SIDE('rf_pass_count', '(cargo.forms[rf_form] ? (cargo.forms[rf_form].replays = Number(cargo.forms[rf_form].replays || 0) + 1) : true, true)'),
168
174
  SET('rf_pass', 'rf_status', "formInstanceApplies(cargo, rf_form) ? '' : (closeFormInstance(cargo, rf_form, false), 'unwound')"),
@@ -178,6 +184,12 @@ const runForm = {
178
184
  SET('rf_pending', 'rf_pending', 'pendingFormReadings(cargo, rf_form)'),
179
185
  WHEN('rf_pending_accept', 'rf_pending.length > 0', FLOW('rf_pending_batch', 'collect-input-accept', 'call', { hits: '{{rf_pending}}' })),
180
186
  WHEN('rf_pending_repeat', 'rf_pending.length > 0', FLOW('rf_pending_again', 'run-form', 'replace', { rf_form: '{{rf_form}}', rf_resume: 'true', rf_stop: '{{rf_stop}}' })),
187
+ SET('rf_proposal_readings', 'rf_proposal_readings', 'formProposalReadings(cargo,rf_form)'),
188
+ WHEN('rf_proposal_revalidate', 'rf_proposal_readings.length > 0', FLOW('rf_proposal_batch', 'collect-input-accept', 'call', { hits: '{{rf_proposal_readings}}' })),
189
+ WHEN('rf_proposal_owner_closed', '!cargo.forms[rf_form]', END('rf_proposal_closed_end')),
190
+ SET('rf_proposal', 'rf_proposal', 'nextFormProposal(cargo,rf_form)'),
191
+ WHEN('rf_proposal_ask', 'rf_proposal', FLOW('rf_proposal_confirm', 'confirm-input-proposal', 'call', { parent_form: '{{rf_form}}', input: '{{rf_proposal.input}}' })),
192
+ WHEN('rf_proposal_repeat', 'rf_proposal || rf_proposal_readings.length > 0', FLOW('rf_proposal_again', 'run-form', 'replace', { rf_form: '{{rf_form}}', rf_resume: 'true', rf_stop: '{{rf_stop}}', rf_resume_parent: '{{rf_resume_parent}}' })),
181
193
  SET('rf_find', 'rf_next', 'nextFormInput(cargo.forms[rf_form], cargo)'),
182
194
  WHEN('rf_blocked', "rf_next === '__blocked__'", incompleteDispatch('rf_blocked_end', 'rf_form', "'form_dependencies_blocked'")),
183
195
  SET('rf_find_form', 'rf_next_form', "rf_next ? ((cargo.forms[rf_form].specs || []).filter(function (i) { return i.name === rf_next; })[0] || {}).form || '' : ''"),
@@ -198,26 +210,31 @@ const collectInput = {
198
210
  id: 'collect-input', name: 'CollectInput', version: '1.0.0', scope: 'isolated',
199
211
  description: "Worker: ask the form's current input once and file the answer wherever it belongs in the form chain (matchInputs, decided by scope; a declared confirm_against pair is asked). One reading → collect-input-accept; ambiguous → disambiguate-input; a question about the form's data → provide-data; a short miss → counted, the loop re-asks; 3+ words that fit nothing → DISPATCH. Params: form, current, questions, provides, exit_context(_es).",
200
212
  parameters: ['form', 'current', 'questions', 'provides', 'exit_context', 'exit_context_es'].map((n) => ({ name: n, type: ['questions'].includes(n) ? 'array' : n === 'provides' ? 'object' : 'string', description: n })),
201
- variables: vars({ ci_exhaustion: null, ci_d: {}, ci_pre: '', ci_val: '', ci_routed: '', ci_names: [], ci_chain: [], ci_spec: null, ci_choices: [], ci_q: '', ci_q_es: '', ci_missed: 0, ci_max: 3, ci_m: null, ci_qkey: '', ci_skip_gate: '', ci_words: 0, ci_digits: null, ci_amount: false, ci_as_spoken: false, ci_reader: {}, ci_pii: false, ci_ctx: '', ci_ctx_es: '' }),
213
+ variables: vars({ ci_exhaustion: null, ci_d: {}, ci_pre: '', ci_val: '', ci_routed: '', ci_names: [], ci_chain: [], ci_spec: null, ci_choices: [], ci_q: '', ci_q_es: '', ci_missed: 0, ci_max: 3, ci_misses: 0, ci_miss_limit: 2, ci_m: null, ci_qkey: '', ci_skip_gate: '', ci_words: 0, ci_digits: null, ci_amount: false, ci_as_spoken: false, ci_reader: {}, ci_pii: false, ci_ctx: '', ci_ctx_es: '' }),
202
214
  steps: [
203
215
  SET('ci_find_spec', 'ci_spec', "(function () { var s = ((cargo.forms[form] || {}).specs || []).filter(function (s) { return s.name === current; })[0]; return s ? (Array.isArray(s.any_of) ? Object.assign({}, s, { profile: { kind: 'group' } }) : s) : null; })()"),
204
216
  SET('ci_current_names', 'ci_names', "Array.isArray(ci_spec && ci_spec.any_of) ? ci_spec.any_of.map(function (m) { return m.name; }) : [current]"),
205
- WHEN('ci_no_spec', '!ci_spec', END('ci_no_spec_end', incompleteAttrs('form', "'collect_input_spec'"))),
217
+ WHEN('ci_no_spec', '!ci_spec', END('ci_no_spec_end', recoveryAttrs('form', "'collect_input_spec'", true, 'none'))),
206
218
  SET('ci_count_missed', 'ci_missed', "Number(((cargo.forms[form] || {}).attempts || {})[current] || 0)"),
207
219
  SET('ci_max_attempts', 'ci_max', 'Number(ci_spec.max_attempts || 3)'),
220
+ SET('ci_context_misses', 'ci_misses', 'Number(((cargo.forms[form] || {}).misses || {})[current] || 0)'),
221
+ SET('ci_context_limit', 'ci_miss_limit', 'Number(ci_spec.max_misses || 2)'),
222
+ WHEN('ci_context_yield', 'ci_misses >= ci_miss_limit', yieldDispatch('ci_misses_dispatch', 'form', "'input_misses_exhausted'")),
208
223
  SET('ci_context', 'ci_ctx', "typeof exit_context !== 'undefined' && exit_context ? exit_context : 'your request'"),
209
224
  SET('ci_context_es', 'ci_ctx_es', "typeof exit_context_es !== 'undefined' && exit_context_es ? exit_context_es : 'su solicitud'"),
210
225
  WHEN('ci_resolve_exhausted', 'ci_missed >= ci_max', SET('ci_exhausted_policy', 'ci_exhaustion', 'exhaustFormInput(cargo,form,current)')),
211
226
  WHEN('ci_declared_exhausted', 'ci_exhaustion', FLOW('ci_exhausted_recovery', '{{ci_exhaustion.flow}}', 'reboot', { form: '{{ci_exhaustion.form}}', failed_form: '{{ci_exhaustion.failed_form}}', input: '{{ci_exhaustion.input}}', failed_input: '{{ci_exhaustion.failed_input}}', reason: '{{ci_exhaustion.reason}}' })),
212
227
  optionalIncomplete('ci_optional_exhausted', 'ci_missed >= ci_max', 'form', "form+'_incomplete'"),
228
+ WHEN('ci_failure_recovery', 'ci_missed >= ci_max', recoveryStamp('ci_failure_stamp', 'form', "'validation_exhausted'")),
213
229
  WHEN('ci_give_up', 'ci_missed >= ci_max', FLOW('ci_give_up_flow', 'give-up', 'reboot', { context: '{{ci_ctx}}', context_es: '{{ci_ctx_es}}' })),
214
230
  FLOW('ci_prepare', 'prepare-input', 'call', { form: '{{form}}', input: '{{current}}' }),
215
231
  // `refresh`: a flow run before EVERY ask, which computes what the question shows into cargo.forms[form].data
216
232
  WHEN('ci_refresh', 'ci_spec.refresh', FLOW('ci_refresh_flow', '{{ci_spec.refresh}}', 'call', { form: '{{form}}', input: '{{current}}' })),
217
233
  SET('ci_refetch_spec', 'ci_spec', "(function(){var spec=cargo.forms[form].specs.filter(function(s){return s.name===current;})[0];return spec&&spec.any_of?Object.assign({},spec,{profile:{kind:'group'}}):spec;})()"),
218
234
  SET('ci_data', 'ci_d', '(cargo.forms[form] || {}).data || {}'),
219
- SET('ci_question', 'ci_q', "ci_spec.question_from && ci_d[ci_spec.question_from] ? ci_d[ci_spec.question_from] : (ci_missed > 0 && ci_spec.retry ? ci_spec.retry : ci_spec.question)"),
220
- SET('ci_question_es', 'ci_q_es', "ci_spec.question_from && (ci_d[ci_spec.question_from + '_es'] || ci_d[ci_spec.question_from]) ? (ci_d[ci_spec.question_from + '_es'] || ci_d[ci_spec.question_from]) : (ci_missed > 0 && (ci_spec.retry_es || ci_spec.retry) ? (ci_spec.retry_es || ci_spec.retry) : (ci_spec.question_es || ci_spec.question))"),
235
+ SET('ci_question', 'ci_q', "formInputPrompt(cargo,form,current,ci_spec,ci_d,'en')"),
236
+ SET('ci_question_es', 'ci_q_es', "formInputPrompt(cargo,form,current,ci_spec,ci_d,'es')"),
237
+ SIDE('ci_feedback_consumed', 'consumeFormFeedback(cargo,form,current)'),
221
238
  SET('ci_own_choices', 'ci_choices', "ci_spec.choices_from && Array.isArray(ci_d[ci_spec.choices_from]) ? ci_d[ci_spec.choices_from] : (ci_spec.profile.kind === 'choice' && Array.isArray(ci_spec.profile.choices) ? ci_spec.profile.choices : [])"),
222
239
  SET('ci_keypad', 'ci_digits', 'ci_spec.digits || null'),
223
240
  SET('ci_is_amount', 'ci_amount', "ci_spec.amount === 'marked' ? 'marked' : (ci_spec.amount === true || ci_spec.profile.kind === 'amount')"),
@@ -236,6 +253,7 @@ const collectInput = {
236
253
  // The labels and validators ride on each reading, so the presets stay in this one flow.
237
254
  SET('ci_enrich', 'ci_m', 'enrichFormMatches(ci_m, ci_chain, form, ci_names, current)'),
238
255
  SET('ci_question_key', 'ci_qkey', "ci_m.questions.length ? ci_m.questions[0] : !ci_m.hits.length && !ci_m.ambiguous.length && Array.isArray(questions) && questions.length && provides ? pickChoice(cargo.choiceText, questions, false) : ''"),
256
+ WHEN('ci_understood', 'ci_m.hits.length || ci_m.ambiguous.length || ci_m.questions.length || ci_qkey && ci_qkey.indexOf("__") !== 0', SIDE('ci_clear_misses', 'clearFormMisses(cargo,form,current)')),
239
257
  SET('ci_count_words', 'ci_words', "String(cargo.choiceText || '').split(/\\s+/).filter(function (w) { return /[a-záéíóúüñ]/i.test(w); }).length"),
240
258
  // Independent field clauses are committed before a provider can suspend on its gate.
241
259
  WHEN('ci_question_ambiguity', 'ci_m.questions.length > 0 && ci_m.ambiguous.length > 0', FLOW('ci_question_clarify', 'disambiguate-input', 'call', { readings: '{{ci_m.ambiguous}}', hits: '{{ci_m.hits}}', remaining: '{{ci_m.remaining}}' })),
@@ -248,8 +266,8 @@ const collectInput = {
248
266
  'condition: ci_m.hits.length > 0': FLOW('ci_accept', 'collect-input-accept', 'call', { hits: '{{ci_m.hits}}' }),
249
267
  "condition: ci_m.questions.length > 0": END('ci_question_handled'),
250
268
  "condition: ci_qkey && ci_qkey.indexOf('__') !== 0": FLOW('ci_question_asked', 'provide-data', 'call', { form: '{{form}}', provides: '{{provides}}', asked: '{{ci_qkey}}', no_menu: 'true', no_gate: '{{ci_skip_gate}}' }),
251
- 'condition: ci_words >= 3': incompleteDispatch('ci_elsewhere', 'form', "form+'_incomplete'"),
252
- default: SIDE('ci_miss', "(cargo.forms[form].attempts = cargo.forms[form].attempts || {}, cargo.forms[form].attempts[current] = ci_missed + 1, true)")
269
+ 'condition: ci_words >= 3': yieldDispatch('ci_elsewhere', 'form', "'input_not_matched'"),
270
+ default: SIDE('ci_miss', "(missFormInput(cargo,form,current),true)")
253
271
  } },
254
272
  ],
255
273
  };
@@ -265,14 +283,15 @@ const prepareInput = {
265
283
  const validateBatch = {
266
284
  id: 'validate-input-batch', name: 'ValidateInputBatch', version: '2.0.0', scope: 'isolated',
267
285
  description: 'Worker: validate dependency-ordered candidates; return accepted, rejected and pending readings before a single commit.',
268
- parameters: ['hits', 'accepted', 'rejected', 'pending'].map(name => ({ name, type: 'array' })),
269
- variables: vars({ accepted: [], rejected: [], pending: [], vb_h: null, vb_rest: [], vb_values: {}, vb_known: false, vb_ready: false, vb_relevant: false, vb_outcome: '' }),
286
+ parameters: ['hits', 'accepted', 'rejected', 'pending', 'ignored'].map(name => ({ name, type: 'array' })),
287
+ variables: vars({ accepted: [], rejected: [], pending: [], ignored: [], vb_h: null, vb_rest: [], vb_values: {}, vb_known: false, vb_ready: false, vb_relevant: false, vb_outcome: '', vb_candidate: null, vb_proposal_values: {}, vb_proposal: null, vb_proposal_ok: true }),
270
288
  steps: [
271
289
  SET('vb_init_accepted', 'accepted', 'Array.isArray(accepted) ? accepted : []'),
272
290
  SET('vb_init_rejected', 'rejected', 'Array.isArray(rejected) ? rejected : []'),
273
291
  SET('vb_init_pending', 'pending', 'Array.isArray(pending) ? pending : []'),
292
+ SET('vb_init_ignored', 'ignored', 'Array.isArray(ignored) ? ignored : []'),
274
293
  SET('vb_first', 'vb_h', 'hits.length ? hits[0] : null'),
275
- WHEN('vb_done', '!vb_h', SIDE('vb_result', '(cargo.inputBatch = { accepted: accepted, rejected: rejected, pending: pending }, true)')),
294
+ WHEN('vb_done', '!vb_h', SIDE('vb_result', '(cargo.inputBatch = { accepted: accepted, rejected: rejected, pending: pending, ignored: ignored }, true)')),
276
295
  WHEN('vb_end', '!vb_h', END('vb_end_done')),
277
296
  SET('vb_rest', 'vb_rest', 'hits.slice(1)'),
278
297
  SET('vb_relevant', 'vb_relevant', 'formReadingRelevant(cargo, vb_h, accepted)'),
@@ -284,8 +303,15 @@ const validateBatch = {
284
303
  SIDE('vb_reset', "(cargo.inputCheck = vb_h.invalid ? { ok: false } : (vb_h.validator && !vb_known ? null : { ok: true, value: vb_h.value }), true)"),
285
304
  WHEN('vb_validator', 'vb_ready === true && vb_h.validator && !vb_known && !vb_h.invalid', FLOW('vb_validate', '{{vb_h.validator}}', 'call', { form: '{{vb_h.form}}', input: '{{vb_h.name}}', value: '{{vb_h.value}}', values: '{{vb_values}}', reading: '{{vb_h}}' })),
286
305
  WHEN('vb_refused', 'vb_h.refused === true', SIDE('vb_withhold_refusal', "(cargo.inputCheck=Object.assign({},cargo.inputCheck || {},{ok:false}),true)")),
287
- SIDE('vb_take', "(vb_relevant !== true ? true : vb_ready !== true ? pending.push(Object.assign({}, vb_h, {outcome:'pending'})) : cargo.inputCheck && cargo.inputCheck.ok === true ? accepted.push(Object.assign({}, vb_h, { ok: true, value: cargo.inputCheck.value !== undefined && cargo.inputCheck.value !== null ? cargo.inputCheck.value : vb_h.value, outcome: vb_outcome })) : rejected.push(Object.assign({}, vb_h, { outcome: 'rejected', message: cargo.inputCheck && cargo.inputCheck.message, message_es: cargo.inputCheck && cargo.inputCheck.message_es })), true)"),
288
- FLOW('vb_next', 'validate-input-batch', 'replace', { hits: '{{vb_rest}}', accepted: '{{accepted}}', rejected: '{{rejected}}', pending: '{{pending}}' }),
306
+ SET('vb_candidate', 'vb_candidate', "Object.assign({},vb_h,{ok:!!(cargo.inputCheck && cargo.inputCheck.ok === true),value:cargo.inputCheck && cargo.inputCheck.value !== undefined && cargo.inputCheck.value !== null ? cargo.inputCheck.value : vb_h.value,message:cargo.inputCheck && cargo.inputCheck.message,message_es:cargo.inputCheck && cargo.inputCheck.message_es})"),
307
+ SET('vb_proposal_policy', 'vb_proposal', 'vb_relevant === true && vb_ready === true && vb_candidate.ok && !vb_known ? formProposalPolicy(cargo,vb_candidate,cargo.inputCheck,accepted) : null'),
308
+ SET('vb_proposal_values', 'vb_proposal_values', 'proposedFormValues(cargo,vb_h.form,accepted.concat([vb_candidate]))'),
309
+ SIDE('vb_proposal_reset', '(cargo.proposalCheck=undefined,true)'),
310
+ WHEN('vb_proposal_prepare', 'vb_proposal && vb_proposal.prepare', FLOW('vb_proposal_formatter', '{{vb_proposal.prepare}}', 'call', { form: '{{vb_candidate.form}}', input: '{{vb_candidate.name}}', value: '{{vb_candidate.value}}', values: '{{vb_proposal_values}}', reading: '{{vb_candidate}}' })),
311
+ SET('vb_proposal_gate', 'vb_proposal_ok', 'vb_relevant !== true || vb_ready !== true || !vb_candidate.ok || vb_known ? true : stageFormProposal(cargo,vb_candidate,formProposalDescriptor(vb_proposal,vb_proposal && vb_proposal.prepare ? cargo.proposalCheck : undefined))'),
312
+ { ...SET('vb_proposal_gate_valid', 'noop', "typeof vb_proposal_ok === 'boolean'"), requireTrue: true },
313
+ SIDE('vb_take', "(vb_relevant !== true ? ignored.push(vb_h) : vb_ready !== true ? pending.push(Object.assign({}, vb_h, {outcome:'pending'})) : vb_candidate.ok === true ? (vb_proposal_ok === true ? accepted.push(Object.assign({}, vb_candidate, { outcome: vb_outcome })) : null) : rejected.push(Object.assign({}, vb_candidate, { outcome: 'rejected' })), true)"),
314
+ FLOW('vb_next', 'validate-input-batch', 'replace', { hits: '{{vb_rest}}', accepted: '{{accepted}}', rejected: '{{rejected}}', pending: '{{pending}}', ignored: '{{ignored}}' }),
289
315
  ],
290
316
  };
291
317
  const accept = {
@@ -301,7 +327,9 @@ const accept = {
301
327
  SET('ca_order', 'ca_ordered', 'orderFormReadings(cargo, hits)'),
302
328
  FLOW('ca_validate', 'validate-input-batch', 'call', { hits: '{{ca_ordered}}' }),
303
329
  SIDE('ca_settle', '(cargo.inputBatch = settleFormActivations(cargo.inputBatch, cargo), true)'),
330
+ { ...SIDE('ca_proposal_validation', '(settleFormProposalValidation(cargo,cargo.inputBatch),true)'), requireTrue: true },
304
331
  SIDE('ca_rejections', 'rejectFormReadings(cargo, cargo.inputBatch.rejected)'),
332
+ { ...SIDE('ca_proposal_commit', 'commitFormProposals(cargo,cargo.inputBatch.accepted)'), requireTrue: true },
305
333
  SIDE('ca_commit', 'acceptFormReadings(cargo, cargo.inputBatch.accepted, cargo.inputBatch.pending)'),
306
334
  SIDE('ca_pending', 'queueFormReadings(cargo, cargo.inputBatch.pending)'),
307
335
  SET('ca_results', 'ca_results', 'cargo.inputBatch.accepted.concat(cargo.inputBatch.rejected, cargo.inputBatch.pending)'),
@@ -317,7 +345,7 @@ const acceptedEffects = {
317
345
  SET('ie_first', 'ca_h', 'readings.length ? readings[0] : null'),
318
346
  WHEN('ie_none', '!ca_h', END('ie_end')),
319
347
  SET('ie_rest', 'ca_rest', 'readings.slice(1)'),
320
- WHEN('ie_reject', "ca_h.outcome === 'rejected'", SIDE('ie_attempt', "(cargo.forms[ca_h.form].attempts[ca_h.asked || ca_h.name] = Number(cargo.forms[ca_h.form].attempts[ca_h.asked || ca_h.name] || 0) + 1, true)")),
348
+ WHEN('ie_reject', "ca_h.outcome === 'rejected'", SIDE('ie_attempt', "(cargo.forms[ca_h.form].attempts[ca_h.asked || ca_h.name] = Number(cargo.forms[ca_h.form].attempts[ca_h.asked || ca_h.name] || 0) + 1, cargo.forms[ca_h.form].feedback=cargo.forms[ca_h.form].feedback || {}, cargo.forms[ca_h.form].feedback[ca_h.asked || ca_h.name]='invalid', true)")),
321
349
  SET('ie_say_en', 'ca_ack', "ca_h.outcome === 'rejected' ? (ca_h.message || ('That ' + (ca_h.label || ca_h.name) + \" doesn't look right.\")) : ca_h.silent === true || ca_h.fromPending ? '' : ca_h.outcome === 'known' && !ca_h.current ? ('Thank you, I already have your ' + ca_h.label + '.') : ca_h.outcome === 'changed' ? ('Okay, I updated your ' + ca_h.label + '.') : (ca_h.outcome === 'taken' || ca_h.outcome === 'pending') && ca_h.origin !== 'activation' ? ('Got it, your ' + ca_h.label + '.') : ''"),
322
350
  SET('ie_say_es', 'ca_ack_es', "ca_h.outcome === 'rejected' ? (ca_h.message_es || ca_h.message || ('Ese ' + (ca_h.label_es || ca_h.label || ca_h.name) + ' no parece correcto.')) : ca_h.silent === true || ca_h.fromPending ? '' : ca_h.outcome === 'known' && !ca_h.current ? ('Gracias, ya tengo su ' + ca_h.label_es + '.') : ca_h.outcome === 'changed' ? ('Listo, actualicé su ' + ca_h.label_es + '.') : (ca_h.outcome === 'taken' || ca_h.outcome === 'pending') && ca_h.origin !== 'activation' ? ('Entendido, su ' + ca_h.label_es + '.') : ''"),
323
351
  WHEN('ie_say', 'ca_ack', SAY('ie_ack', '{{ca_ack}}', '{{ca_ack_es}}')),
@@ -330,7 +358,7 @@ const disambiguate = {
330
358
  id: 'disambiguate-input', name: 'DisambiguateInput', version: '1.0.0', scope: 'isolated',
331
359
  description: "Worker: the answer fits two or more inputs at the same scope (or a declared confirm_against pair). Stores the unambiguous readings, asks which input the rest was for (keys 1..n, never 8), stores the pick. No pick: nothing stored, not a miss. Params: readings, hits.",
332
360
  parameters: [{ name: 'readings', type: 'array', description: 'The ambiguous readings' }, { name: 'hits', type: 'array', description: 'Readings to store as they are' }, { name: 'remaining', type: 'array' }],
333
- variables: vars({ di_keys: [], di_choices: [], di_q: '', di_q_es: '', di_pick: null, di_value: '', remaining: [], di_next: [], di_rest: [] }),
361
+ variables: vars({ di_form: '', di_input: '', di_prompt: '', di_prompt_es: '', di_words: 0, di_misses: 0, di_keys: [], di_choices: [], di_q: '', di_q_es: '', di_pick: null, di_value: '', remaining: [], di_next: [], di_rest: [] }),
334
362
  steps: [
335
363
  WHEN('di_store_clear', 'Array.isArray(hits) && hits.length > 0', FLOW('di_store_hits', 'collect-input-accept', 'call', { hits: '{{hits}}' })),
336
364
  SET('di_number', 'di_keys', `${KEYS_JS}(readings.length)`),
@@ -338,8 +366,19 @@ const disambiguate = {
338
366
  SET('di_said', 'di_value', "readings[0].pii ? '' : String(readings[0].value)"),
339
367
  SET('di_question', 'di_q', "(di_value ? 'Is ' + di_value + ' your ' : 'Is that your ') + readings.map(function (r) { return r.label; }).join(' or your ') + '? ' + readings.map(function (r, i) { return (cargo.voice ? 'Press ' + di_keys[i] + ' or say ' : 'Say ') + r.label.toUpperCase(); }).join(', ') + '.'"),
340
368
  SET('di_question_es', 'di_q_es', "(di_value ? '¿' + di_value + ' es su ' : '¿Eso es su ') + readings.map(function (r) { return r.label_es; }).join(' o su ') + '? ' + readings.map(function (r, i) { return (cargo.voice ? 'Presione ' + di_keys[i] + ' o diga ' : 'Diga ') + r.label_es.toUpperCase(); }).join(', ') + '.'"),
341
- FLOW('di_ask', 'ask-choice', 'call', { question: '{{di_q}}', question_es: '{{di_q_es}}', choices: '{{di_choices}}', accept_other: true, digits: { min: 1, max: 1 } }),
369
+ SET('di_owner', 'di_form', 'readings[0].form'),
370
+ SET('di_counter', 'di_input', "'__ambiguity:'+readings.map(function(r){return r.form+':'+r.name;}).sort().join('|')"),
371
+ SET('di_prompt', 'di_prompt', "formInputPrompt(cargo,di_form,di_input,{question:di_q,question_es:di_q_es},{},'en')"),
372
+ SET('di_prompt_es', 'di_prompt_es', "formInputPrompt(cargo,di_form,di_input,{question:di_q,question_es:di_q_es},{},'es')"),
373
+ SIDE('di_feedback_consumed', 'consumeFormFeedback(cargo,di_form,di_input)'),
374
+ FLOW('di_ask', 'ask-choice', 'call', { question: '{{di_prompt}}', question_es: '{{di_prompt_es}}', choices: '{{di_choices}}', accept_other: true, digits: { min: 1, max: 1 } }),
342
375
  SET('di_picked', 'di_pick', "/^r\\d+$/.test(String(cargo.choice || '')) ? [readings[Number(String(cargo.choice).slice(1))]] : null"),
376
+ WHEN('di_understood', 'di_pick', SIDE('di_clear_misses', 'clearFormMisses(cargo,di_form,di_input)')),
377
+ SET('di_word_count', 'di_words', "String(cargo.choiceText || '').split(/\\s+/).filter(function(w){return /[a-záéíóúüñ]/i.test(w);}).length"),
378
+ WHEN('di_context', '!di_pick && di_words >= 3', yieldDispatch('di_context_yield', 'di_form', "'ambiguity_not_resolved'")),
379
+ WHEN('di_miss', '!di_pick', SET('di_miss_count', 'di_misses', 'missFormInput(cargo,di_form,di_input)')),
380
+ WHEN('di_exhausted', '!di_pick && di_misses >= 2', yieldDispatch('di_misses_yield', 'di_form', "'ambiguity_misses_exhausted'")),
381
+ WHEN('di_retry', '!di_pick', FLOW('di_retry_question', 'disambiguate-input', 'replace', { readings: '{{readings}}', hits: [], remaining: '{{remaining}}' })),
343
382
  WHEN('di_store_pick', 'di_pick', FLOW('di_store_pick_flow', 'collect-input-accept', 'call', { hits: '{{di_pick}}' })),
344
383
  SET('di_remaining_first', 'di_next', 'Array.isArray(remaining) && remaining.length ? remaining[0] : []'),
345
384
  SET('di_remaining_rest', 'di_rest', 'Array.isArray(remaining) ? remaining.slice(1) : []'),
@@ -351,13 +390,16 @@ const confirmForm = {
351
390
  id: 'confirm-form', name: 'ConfirmForm', version: '1.0.0', scope: 'isolated',
352
391
  description: "Worker: ask a form's confirmation (exact YES / NO; keys 1 and 2). A correction instead ('no, make it $40') is read against the form chain and stored, and the result is 'changed' so the form runs again; an unclear short answer is asked again, a long unrelated one is DISPATCHed. Result: cargo.formConfirm[form] = 'yes' | 'no' | 'changed'. Params: form, question(_es), exit_context(_es).",
353
392
  parameters: ['form', 'question', 'question_es', 'exit_context', 'exit_context_es', 'words', 'interrupts', 'no_keys', 'clarifications'].map((n) => ({ name: n, type: ['words', 'interrupts', 'no_keys', 'clarifications'].includes(n) ? 'array' : 'string', description: n })),
354
- variables: vars({ cf_presets: FIELD_PRESETS, cf_chain: [], cf_m: null, cf_result: '', cf_words: 0, cf_tries: 0, words: [], clarifications: [], no_keys: ['2'], interrupts: [], cf_choices: [], cf_event: '', cf_amount: false, cf_questions: [], cf_provides: null, cf_skip_gate: '' }),
393
+ variables: vars({ cf_presets: FIELD_PRESETS, cf_chain: [], cf_m: null, cf_current: null, cf_result: '', cf_prompt: '', cf_prompt_es: '', cf_words: 0, cf_tries: 0, words: [], clarifications: [], no_keys: ['2'], interrupts: [], cf_choices: [], cf_event: '', cf_amount: false, cf_questions: [], cf_provides: null, cf_skip_gate: '' }),
355
394
  steps: [
356
395
  SIDE('cf_reset', "(cargo.formConfirm = cargo.formConfirm || {}, cargo.formConfirm[form] = '', true)"),
357
396
  SET('cf_choices', 'cf_choices', "[{key:'yes',words:['1'].concat(Array.isArray(words)?words:[]),exact:true},{key:'no',words:Array.isArray(no_keys)?no_keys:['2']}].concat((Array.isArray(interrupts)?interrupts:[]).map(function(event){return {key:'event:'+event.key,words:event.words,exact:event.exact!==false};}))"),
358
397
  SET('cf_initial_chain', 'cf_chain', CHAIN_JS),
359
398
  SET('cf_amount_context', 'cf_amount', "cf_chain.some(function(input){return input.profile && input.profile.kind==='amount';})"),
360
- FLOW('cf_ask', 'ask-choice', 'call', { amount: '{{cf_amount}}', question: '{{question}}', question_es: '{{question_es}}', choices: '{{cf_choices}}', accept_other: true, reask_long: true, digits: { min: 1, max: 1 }, exit_context: '{{exit_context}}', exit_context_es: '{{exit_context_es}}' }),
399
+ SET('cf_prompt', 'cf_prompt', "formInputPrompt(cargo,form,'__confirm',{question:question,question_es:question_es},{},'en')"),
400
+ SET('cf_prompt_es', 'cf_prompt_es', "formInputPrompt(cargo,form,'__confirm',{question:question,question_es:question_es},{},'es')"),
401
+ SIDE('cf_feedback_consumed', "consumeFormFeedback(cargo,form,'__confirm')"),
402
+ FLOW('cf_ask', 'ask-choice', 'call', { amount: '{{cf_amount}}', question: '{{cf_prompt}}', question_es: '{{cf_prompt_es}}', choices: '{{cf_choices}}', accept_other: true, reask_long: true, digits: { min: 1, max: 1 }, exit_context: '{{exit_context}}', exit_context_es: '{{exit_context_es}}' }),
361
403
  SET('cf_event', 'cf_event', "(Array.isArray(interrupts)?interrupts:[]).filter(function(event){return cargo.choice==='event:'+event.key;}).map(function(event){return event.flow;})[0] || ''"),
362
404
  WHEN('cf_interrupt', 'cf_event', FLOW('cf_event_flow', '{{cf_event}}', 'reboot', { form: '{{form}}', event: '{{cargo.choice}}', text: '{{cargo.choiceText}}' })),
363
405
  SET('cf_build_chain', 'cf_chain', CHAIN_JS),
@@ -365,7 +407,8 @@ const confirmForm = {
365
407
  SET('cf_question_catalog', 'cf_questions', 'cargo.forms[form].questions || []'),
366
408
  SET('cf_provider_catalog', 'cf_provides', 'cargo.forms[form].providers'),
367
409
  SET('cf_gate_policy', 'cf_skip_gate', "!cf_provides || !cf_provides.gate || [].concat(cf_provides.gate.verified || []).every(function(p){return !!formPath({cargo:cargo},p);}) ? 'true' : ''"),
368
- SET('cf_match', 'cf_m', "cargo.choice === 'yes' || cargo.choice === 'no' && pickChoice(cargo.choiceText, [{key:'no',words:Array.isArray(no_keys)?no_keys:['2'],exact:true}], false) === 'no' ? { hits: [], ambiguous: [], questions:[] } : readFormAnswer(cargo.choiceText, cf_chain, { form: form, name: '' }, '',cf_questions)"),
410
+ SET('cf_current', 'cf_current', "cargo.forms[form].proposalPrompt ? {form:cargo.forms[form].parent,name:cargo.forms[form].proposalPrompt.input} : {form:form,name:''}"),
411
+ SET('cf_match', 'cf_m', "cargo.choice === 'yes' || cargo.choice === 'no' && pickChoice(cargo.choiceText, [{key:'no',words:Array.isArray(no_keys)?no_keys:['2'],exact:true}], false) === 'no' ? { hits: [], ambiguous: [], questions:[] } : readFormAnswer(cargo.choiceText, cf_chain, cf_current, '',cf_questions)"),
369
412
  SET('cf_enrich', 'cf_m', "(function(m){m.hits=m.hits.map(function(h){return Object.assign({},h,{silent:true});});return m;})(enrichFormMatches(cf_m, cf_chain, form))"),
370
413
  SET('cf_count_words', 'cf_words', "String(cargo.choiceText || '').split(/\\s+/).filter(function (w) { return /[a-záéíóúüñ]/i.test(w); }).length"),
371
414
  WHEN('cf_question_fields', 'cf_m.questions.length > 0 && cf_m.hits.length > 0', FLOW('cf_question_accept', 'collect-input-accept', 'call', { hits: '{{cf_m.hits}}' })),
@@ -376,19 +419,41 @@ const confirmForm = {
376
419
  'condition: cf_m.ambiguous.length > 0': FLOW('cf_ambiguous', 'disambiguate-input', 'call', { readings: '{{cf_m.ambiguous}}', hits: '{{cf_m.hits}}', remaining: '{{cf_m.remaining}}' }),
377
420
  'condition: cf_m.hits.length > 0': FLOW('cf_accept', 'collect-input-accept', 'call', { hits: '{{cf_m.hits}}' }),
378
421
  "condition: cargo.choice === 'no'": SET('cf_declined', 'cf_result', "'no'"),
379
- "condition: Array.isArray(clarifications) && pickChoice(String(cargo.choiceText || '').replace(/[?¿]/g,''),[{key:'clarify',words:clarifications,exact:true}],false)==='clarify'": SET('cf_clarification', 'cf_result', "'unclear'"),
380
- 'condition: cf_words >= 3': incompleteDispatch('cf_elsewhere', 'form', "form+'_unconfirmed'"),
422
+ "condition: Array.isArray(clarifications) && pickChoice(String(cargo.choiceText || '').replace(/[?¿]/g,''),[{key:'clarify',words:clarifications,exact:true}],false)==='clarify'": SET('cf_clarification', 'cf_result', "'clarification'"),
423
+ 'condition: cf_words >= 3': yieldDispatch('cf_elsewhere', 'form', "'confirmation_not_matched'"),
381
424
  default: SET('cf_unclear', 'cf_result', "'unclear'")
382
425
  } },
383
426
  SET('cf_changed', 'cf_result', "cf_result ? cf_result : 'changed'"),
384
427
  // an unclear short answer is asked again (the form runs again and reaches this question), at most twice
385
- SET('cf_tries_count', 'cf_tries', "cf_result === 'unclear' ? (cargo.forms[form] ? ((cargo.forms[form].attempts = cargo.forms[form].attempts || {}).__confirm = Number(cargo.forms[form].attempts.__confirm || 0) + 1) : 9) : 0"),
386
- optionalIncomplete('cf_optional_exhausted', 'cf_tries > 2', 'form', "form+'_unconfirmed'"),
387
- WHEN('cf_give_up', 'cf_tries > 2', FLOW('cf_give_up_flow', 'give-up', 'reboot', { context: '{{exit_context}}', context_es: '{{exit_context_es}}' })),
428
+ SET('cf_tries_count', 'cf_tries', "cf_result === 'unclear' ? missFormInput(cargo,form,'__confirm') : (clearFormMisses(cargo,form,'__confirm'),0)"),
429
+ WHEN('cf_misses_exhausted', 'cf_tries >= 2', yieldDispatch('cf_misses_dispatch', 'form', "'confirmation_misses_exhausted'")),
388
430
  WHEN('cf_bind_revision', "cf_result === 'yes'", SIDE('cf_bind', 'confirmFormRevision(cargo, form)')),
389
- SIDE('cf_store', "(cargo.formConfirm[form] = cf_result === 'unclear' ? 'changed' : cf_result, true)"),
431
+ SIDE('cf_store', "(cargo.formConfirm[form] = cf_result === 'unclear' || cf_result === 'clarification' ? 'changed' : cf_result, true)"),
390
432
  ],
391
433
  };
434
+ // ── proposed input: owned confirmation before canonical acceptance or callbacks ────────────────────────────
435
+ const confirmInputProposal = formFlow({ id: 'confirm-input-proposal', name: 'ConfirmInputProposal', version: '1.0.0', description: 'Confirm an owned validated input candidate; retain it on yield and authorize only normal input acceptance.',
436
+ parameters: [{ name: 'input', type: 'string', description: 'Owning input awaiting proposal confirmation' }], extraVars: { input: '', proposal: null, proposal_readings: [] }, inputs: [],
437
+ before: [
438
+ SET('proposal_refresh_readings', 'proposal_readings', 'formProposalReadings(cargo,parent_form,input,false)'),
439
+ WHEN('proposal_refresh', 'proposal_readings.length > 0', FLOW('proposal_refresh_batch', 'collect-input-accept', 'call', { hits: '{{proposal_readings}}' })),
440
+ SET('proposal_current', 'proposal', 'cargo.forms[parent_form] && cargo.forms[parent_form].inputProposals && cargo.forms[parent_form].inputProposals[input] || null'),
441
+ WHEN('proposal_missing', "!proposal || !['pending','confirmed'].includes(proposal.status) || proposal.committed", FLOW('proposal_missing_close', 'form-done', 'replace', { form: '{{form_instance}}' })),
442
+ { ...SIDE('proposal_bind', '(bindFormProposal(cargo,parent_form,input,form_instance),true)'), requireTrue: true },
443
+ SET('proposal_confirmation', 'form_confirmation', 'Object.assign({},proposal.descriptor)'),
444
+ ],
445
+ confirm: { question: '{{proposal.descriptor.question}}', question_es: '{{proposal.descriptor.question_es || proposal.descriptor.question}}' },
446
+ after: [{ ...SIDE('proposal_confirmed', "decideFormProposal(cargo,parent_form,input,form_instance,'confirmed')"), requireTrue: true }],
447
+ declined: [NOOP('proposal_declined_marker')],
448
+ });
449
+ const proposalDeclined = { id: 'confirm-input-proposal-declined', name: 'ConfirmInputProposalDeclined', version: '1.0.0', scope: 'isolated', description: 'Reject an owned input proposal without accepting its candidate or handing incomplete data to its parent.', parameters: [{ name: 'form_instance', type: 'string' }], variables: vars({ owner: '', input: '', proposal: null }), steps: [
450
+ SET('proposal_decline_owner', 'owner', 'cargo.forms[form_instance].parent'),
451
+ SET('proposal_decline_input', 'input', 'cargo.forms[form_instance].proposalPrompt.input'),
452
+ SET('proposal_decline_record', 'proposal', 'cargo.forms[owner].inputProposals[input]'),
453
+ { ...SIDE('proposal_decline_decision', "decideFormProposal(cargo,owner,input,form_instance,'declined')"), requireTrue: true },
454
+ SIDE('proposal_decline_close', '(closeFormInstance(cargo,form_instance,false),true)'),
455
+ WHEN('proposal_decline_callback', 'proposal.descriptor.on_declined', FLOW('proposal_decline_invoke', '{{proposal.descriptor.on_declined}}', 'call', { form: '{{owner}}', input: '{{input}}', value: '{{proposal.value}}', reading: '{{proposal.reading}}' })),
456
+ ] };
392
457
  // ── form-done: the result goes to the parent (or cargo.formResults); the form is cleared ─────────────────────────────
393
458
  const formDone = {
394
459
  id: 'form-done', name: 'FormDone', version: '1.0.0', scope: 'isolated',
@@ -497,7 +562,7 @@ const provideData = {
497
562
  parameters: [{ name: 'form', type: 'string', description: 'Originating FORM instance for task-owned deferred questions' }, { name: 'provides', type: 'object', description: 'The provider catalog' }, { name: 'asked', type: 'string', description: 'Fact or set key' }, { name: 'asked_keys', type: 'array', description: 'Independent requested facts or sets' }, { name: 'no_menu', type: 'string', description: "'true': answer only (a question asked inside a form)" }, { name: 'gate_params', type: 'object', description: 'Identifiers the caller already gave, passed to the gate flow as gate_params' }, { name: 'no_gate', type: 'string', description: "'true': the caller is inside a task on the verified account; the gate flow is not run (the verified paths are still checked)" }],
498
563
  variables: vars({ form: '', pd_request: null, pd_deferred: null, pd_ready: [], asked_keys: [], no_gate: '', pd_close: false, pd_who: '', no_menu: '', gate_params: null, pd_keys: [], pd_menu: [], pd_mkeys: [], pd_q: '', pd_q_es: '', pd_gated: false, pd_ok: false }),
499
564
  steps: [
500
- WHEN('pd_no_catalog', "!provides || typeof provides !== 'object' || !Array.isArray(provides.facts)", END('pd_no_catalog_end', { outcome: 'unresolved', reason: 'provide_data_catalog' })),
565
+ WHEN('pd_no_catalog', "!provides || typeof provides !== 'object' || !Array.isArray(provides.facts)", END('pd_no_catalog_end', { outcome: 'unresolved', reason: 'provide_data_catalog', recovery: { version: 1, mode: 'none', reason: 'provide_data_catalog' } })),
501
566
  SET('pd_resolve', 'pd_keys', "(function () { var requested=Array.isArray(asked_keys)&&asked_keys.length?asked_keys:[typeof asked!=='undefined'?String(asked||''):''];return requested.reduce(function(keys,a){var set=(provides.sets||[]).filter(function(x){return x.key===a;})[0];var facts=set?set.facts:provides.facts.some(function(f){return f.key===a;})?[a]:[];return keys.concat(facts.filter(function(key){return keys.indexOf(key)<0;}));},[]); })()"),
502
567
  // Nothing specific asked: offer the sets and facts as a menu (keys 1..n, never 8; at most 7 options shown).
503
568
  SET('pd_menu_items', 'pd_menu', "(provides.sets || []).concat(provides.facts).slice(0, 7)"),
@@ -515,7 +580,7 @@ const provideData = {
515
580
  SET('pd_deferred', 'pd_deferred', `(${finishProviderRequest.toString()})(cargo,pd_request.owner,provides,pd_keys)`),
516
581
  SET('pd_merged_keys', 'pd_keys', 'pd_deferred.keys'),
517
582
  SET('pd_gate_check', 'pd_ok', `!pd_gated || [].concat(provides.gate.verified).every(function (p) { return !!${PATH_JS}(p); })`),
518
- WHEN('pd_not_verified', '!pd_ok', END('pd_not_verified_end', { outcome: 'unresolved', reason: 'provide_data_not_verified' })),
583
+ WHEN('pd_not_verified', '!pd_ok', END('pd_not_verified_end', { outcome: 'unresolved', reason: 'provide_data_not_verified', recovery: { version: 1, mode: 'none', reason: 'provide_data_not_verified' } })),
519
584
  WHEN('pd_load', 'provides.source && provides.source.flow', FLOW('pd_load_flow', 'load-source', 'call', { source: '{{provides.source}}' })),
520
585
  FLOW('pd_answer', 'answer-facts', 'call', { provides: '{{provides}}', keys: '{{pd_keys}}' }),
521
586
  WHEN('pd_deferred_more', 'pd_deferred.remaining.length > 0', FLOW('pd_answer_deferred', 'provide-queued-data', 'call', { form: '{{form}}', requests: '{{pd_deferred.remaining}}' })),
@@ -584,7 +649,7 @@ const loadSource = {
584
649
  WHEN('ls_begin', 'ls_stale', SIDE('ls_clear_old', 'beginSourceRefresh(cargo, source)')),
585
650
  WHEN('ls_load', 'ls_stale', FLOW('ls_loader', '{{source.flow}}', 'call')),
586
651
  SET('ls_loaded', 'ls_have', 'ls_stale ? completeSourceRefresh(cargo, source) : true'),
587
- WHEN('ls_failed', '!ls_have', FLOW('ls_give_up', 'give-up', 'reboot', { context: 'your request', context_es: 'su solicitud' })),
652
+ WHEN('ls_failed', '!ls_have', { ...FLOW('ls_give_up', 'give-up', 'reboot', { context: 'your request', context_es: 'su solicitud' }), recovery: { version: 1, mode: 'none', reason: 'source_unavailable' } }),
588
653
  ],
589
654
  };
590
655
  const answerFacts = {
@@ -644,8 +709,9 @@ const resumeForm = { id: 'resume-form', name: 'ResumeForm', version: '1.0.0', sc
644
709
  SET('resume_definition', 'target', 'cargo.forms[form].definition'), SET('resume_parent', 'parent', "cargo.forms[form].parent || ''"), SET('resume_slot', 'slot', "cargo.forms[form].result_input || ''"),
645
710
  FLOW('resume_owned_form', '{{target}}', 'replace', { form_instance: '{{form}}', parent_form: '{{parent}}', result_input: '{{slot}}' })
646
711
  ] };
647
- const LIBRARY_FLOWS = [resumeForm, performAction, sendTextAction, prepareInput, runForm, collectInput, validateBatch, accept, acceptedEffects, disambiguate, confirmForm, formDone, providerMenu, ...validators, ...fieldCollectors, ...subForms, provideData, provideQueuedData, loadSource, answerFacts];
712
+ const LIBRARY_FLOWS = [confirmInputProposal, proposalDeclined, resumeForm, performAction, sendTextAction, prepareInput, runForm, collectInput, validateBatch, accept, acceptedEffects, disambiguate, confirmForm, formDone, providerMenu, ...validators, ...fieldCollectors, ...subForms, provideData, provideQueuedData, loadSource, answerFacts];
648
713
  const LIBRARY_CONTRACTS = {
714
+ 'confirm-input-proposal': { role: 'collector', requires: [INPUT_PROPOSALS_REQUIREMENT] }, 'confirm-input-proposal-declined': { role: 'worker', requires: [INPUT_PROPOSALS_REQUIREMENT] },
649
715
  'resume-form': { role: 'worker' },
650
716
  'perform-action': { role: 'worker', tools: ['jsfe:action-claim', 'jsfe:action-complete'], result: { path: 'cargo.actionResult', type: 'object' } },
651
717
  'send-text-action': { role: 'worker', result: { path: 'cargo.actionOutcome', type: 'object' } },
@@ -670,6 +736,6 @@ export const FLOWS = LIBRARY_FLOWS.map(flow => {
670
736
  throw new Error('Shared worker needs an explicit contract: ' + flow.id);
671
737
  if ((declaration.role === 'collector') !== (flow.metadata?.formProtocol === 2))
672
738
  throw new Error('Shared collector contract mismatch: ' + flow.id);
673
- return { ...flow, variables: { ...flow.variables, contract: { type: 'object', value: { version: 1, summary: flow.description,
739
+ return { ...flow, steps: proposalRuntimeSteps(flow.id, flow.steps, declaration.requires), variables: { ...flow.variables, contract: { type: 'object', value: { version: 1, summary: flow.description,
674
740
  parametersFrom: 'parameters', ...declaration, ...(declaration.role === 'collector' ? { form: { inputs: 'inputs' } } : {}) } } } };
675
741
  });
@@ -0,0 +1,5 @@
1
+ export function proposalRuntimeSteps(id: any, steps: any, requirements?: any[]): any;
2
+ /** Versioned engine support; declaring proposal consent must never become a no-op on older runtimes. */
3
+ export const INPUT_PROPOSALS_REQUIREMENT: "jsfe:input-proposals:v1";
4
+ export function hasInputProposals(inputs: any): any;
5
+ //# sourceMappingURL=proposal-runtime.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proposal-runtime.d.ts","sourceRoot":"","sources":["../../src/forms/proposal-runtime.js"],"names":[],"mappings":"AAEA,qFAIC;AAND,wGAAwG;AACxG,0CAA2C,yBAAyB,CAAC;AAM9D,oDAA4K"}
@@ -0,0 +1,9 @@
1
+ /** Versioned engine support; declaring proposal consent must never become a no-op on older runtimes. */
2
+ export const INPUT_PROPOSALS_REQUIREMENT = 'jsfe:input-proposals:v1';
3
+ export function proposalRuntimeSteps(id, steps, requirements = []) {
4
+ if (!requirements.includes(INPUT_PROPOSALS_REQUIREMENT))
5
+ return steps;
6
+ const guard = { id: id + '_proposal_protocol', type: 'SET', variable: 'noop', value: 'formProposalProtocol() === 1', requireTrue: true };
7
+ return steps[0]?.type === 'SET' && steps[0]?.value === guard.value && steps[0]?.requireTrue === true ? steps : [guard, ...steps];
8
+ }
9
+ export const hasInputProposals = inputs => (inputs || []).some(input => input.proposal !== undefined || hasInputProposals(input.any_of) || hasInputProposals(input.schema?.inputs));
@@ -0,0 +1,28 @@
1
+ export function clearFormMisses(cargo: any, id: any, input: any): boolean;
2
+ export function missFormInput(cargo: any, id: any, input: any): any;
3
+ /** Explicit retry wins. Otherwise acknowledge only an actual unrecognized answer, once per re-ask. */
4
+ export function formInputPrompt(cargo: any, id: any, input: any, spec: any, data?: {}, language?: string): any;
5
+ export function consumeFormFeedback(cargo: any, id: any, input: any): boolean;
6
+ /** Recovery refers to the actual owning root, and never permits a fresh attempt at an unknown action. */
7
+ export function formConversationOutcome(cargo: any, id: any, reason: any, failure?: boolean, recoveryMode?: string): {
8
+ outcome: any;
9
+ reason: any;
10
+ recovery: {
11
+ version: number;
12
+ mode: string;
13
+ reason: any;
14
+ form?: undefined;
15
+ flow?: undefined;
16
+ };
17
+ } | {
18
+ outcome: any;
19
+ reason: any;
20
+ recovery: {
21
+ version: number;
22
+ mode: string;
23
+ reason: any;
24
+ form: any;
25
+ flow: any;
26
+ };
27
+ };
28
+ //# sourceMappingURL=form-conversation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form-conversation.d.ts","sourceRoot":"","sources":["../../src/helpers/form-conversation.js"],"names":[],"mappings":"AAGA,0EAGC;AACD,oEAGC;AACD,sGAAsG;AACtG,+GAMC;AACD,8EAAwI;AAExI,yGAAyG;AACzG;;;;;;;;;;;;;;;;;;;;EAUC"}
@@ -0,0 +1,57 @@
1
+ /** Shared FORM retry, contextual yield and recovery policy; all state is conversation-owned. */
2
+ import { formIncompleteOutcome } from './form-state.js';
3
+ export function clearFormMisses(cargo, id, input) {
4
+ const state = cargo.forms?.[id];
5
+ if (!state)
6
+ return true;
7
+ state.misses ||= {};
8
+ state.feedback ||= {};
9
+ delete state.misses[input];
10
+ delete state.feedback[input];
11
+ return true;
12
+ }
13
+ export function missFormInput(cargo, id, input) {
14
+ const state = cargo.forms?.[id];
15
+ if (!state)
16
+ throw new Error('Cannot record a miss without its owned form');
17
+ state.misses ||= {};
18
+ state.feedback ||= {};
19
+ state.misses[input] = Number(state.misses[input] || 0) + 1;
20
+ state.feedback[input] = 'miss';
21
+ return state.misses[input];
22
+ }
23
+ /** Explicit retry wins. Otherwise acknowledge only an actual unrecognized answer, once per re-ask. */
24
+ export function formInputPrompt(cargo, id, input, spec, data = {}, language = 'en') {
25
+ const es = language === 'es', feedback = cargo.forms?.[id]?.feedback?.[input];
26
+ const question = spec.question_from && (es ? (data[spec.question_from + '_es'] || data[spec.question_from]) : data[spec.question_from]) || (es ? (spec.question_es || spec.question) : spec.question) || '';
27
+ const retry = es ? spec.retry_es : spec.retry;
28
+ if (feedback && retry)
29
+ return retry;
30
+ return (feedback === 'miss' ? (es ? 'Disculpe, no le entendí. ' : "Sorry, I didn't catch that. ") : '') + question;
31
+ }
32
+ export function consumeFormFeedback(cargo, id, input) { if (cargo.forms?.[id]?.feedback)
33
+ delete cargo.forms[id].feedback[input]; return true; }
34
+ /** Recovery refers to the actual owning root, and never permits a fresh attempt at an unknown action. */
35
+ export function formConversationOutcome(cargo, id, reason, failure = false, recoveryMode = 'resume') {
36
+ const completed = formIncompleteOutcome(cargo, id, { outcome: 'unresolved', reason });
37
+ if (completed.owner && ['resolved', 'deflected'].includes(completed.outcome))
38
+ return { outcome: completed.outcome, reason: completed.reason, recovery: { version: 1, mode: 'none', reason: completed.reason } };
39
+ const seen = new Set();
40
+ let state = cargo.forms?.[id], root = state;
41
+ while (state && !seen.has(state.id)) {
42
+ seen.add(state.id);
43
+ root = state;
44
+ state = cargo.forms?.[state.parent];
45
+ }
46
+ if (!root)
47
+ return { outcome: 'unresolved', reason, recovery: { version: 1, mode: 'none', reason } };
48
+ const belongs = form => { const visited = new Set(); while (form && !visited.has(form.id)) {
49
+ if (form.id === root.id)
50
+ return true;
51
+ visited.add(form.id);
52
+ form = cargo.forms?.[form.parent];
53
+ } return false; };
54
+ const unknown = Object.values(cargo.forms || {}).filter(belongs).some(form => Object.values(form.actions || {}).some(record => ['attempted', 'unknown'].includes(record.status)));
55
+ const mode = unknown ? 'hold' : recoveryMode, why = unknown ? 'action_result_unknown' : completed.owner ? completed.reason : reason;
56
+ return { outcome: unknown ? 'unresolved' : completed.owner ? completed.outcome : failure ? 'unresolved' : 'pending', reason: why, recovery: { version: 1, mode, reason: why, form: root.id, flow: root.definition } };
57
+ }
@@ -0,0 +1,17 @@
1
+ /** Static policy selectors use this batch's proposed values; a validator's explicit descriptor independently applies. */
2
+ export function formProposalPolicy(cargo: any, reading: any, check: any, accepted?: any[]): any;
3
+ /** A null descriptor admits ordinary input, but cannot silently waive the same retained candidate's consent. */
4
+ export function stageFormProposal(cargo: any, reading: any, descriptor: any): any;
5
+ /** Only actionable records enter the confirmation worker; dependency-deferred records await the normal batch. */
6
+ export function nextFormProposal(cargo: any, owner: any): any;
7
+ /** Owner replay issues approval only for confirmed records; named child refresh never invents approval. */
8
+ export function formProposalReadings(cargo: any, owner: any, input?: string, approve?: boolean): any[];
9
+ /** Bind only a real context child; ordinary child slots and unrelated owners cannot collect consent. */
10
+ export function bindFormProposal(cargo: any, owner: any, input: any, childId: any): any;
11
+ /** Exact YES must have passed the ordinary confirm-form revision check; NO never admits the candidate. */
12
+ export function decideFormProposal(cargo: any, owner: any, input: any, childId: any, decision: any): boolean;
13
+ /** Validate the entire final batch before consuming any proof; invoke immediately before acceptFormReadings. */
14
+ export function commitFormProposals(cargo: any, accepted?: any[]): boolean;
15
+ /** Failed validation withdraws the candidate; unmet prerequisites wait for the normal dependency replay. */
16
+ export function settleFormProposalValidation(cargo: any, batch?: {}): {};
17
+ //# sourceMappingURL=form-proposals.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form-proposals.d.ts","sourceRoot":"","sources":["../../src/helpers/form-proposals.js"],"names":[],"mappings":"AAgEA,yHAAyH;AACzH,gGASC;AACD,gHAAgH;AAChH,kFAkCC;AACD,iHAAiH;AACjH,8DAOC;AACD,2GAA2G;AAC3G,uGAYC;AACD,wGAAwG;AACxG,wFAMC;AACD,0GAA0G;AAC1G,6GASC;AACD,gHAAgH;AAChH,2EAmBC;AACD,4GAA4G;AAC5G,yEAIC"}