jsfe 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +27 -4
  2. package/dist/forms/action-projection.d.ts +4 -0
  3. package/dist/forms/action-projection.d.ts.map +1 -0
  4. package/dist/forms/action-projection.js +21 -0
  5. package/dist/forms/action-recovery.d.ts +12 -0
  6. package/dist/forms/action-recovery.d.ts.map +1 -0
  7. package/dist/forms/action-recovery.js +125 -0
  8. package/dist/forms/action-tools.d.ts +29 -0
  9. package/dist/forms/action-tools.d.ts.map +1 -0
  10. package/dist/forms/action-tools.js +6 -0
  11. package/dist/forms/address-flows.d.ts +2 -0
  12. package/dist/forms/address-flows.d.ts.map +1 -0
  13. package/dist/forms/address-flows.js +39 -0
  14. package/dist/forms/choice-flow.d.ts +419 -0
  15. package/dist/forms/choice-flow.d.ts.map +1 -0
  16. package/dist/forms/choice-flow.js +707 -0
  17. package/dist/forms/contracts.d.ts +237 -0
  18. package/dist/forms/contracts.d.ts.map +1 -0
  19. package/dist/forms/contracts.js +278 -0
  20. package/dist/forms/flows.d.ts +611 -0
  21. package/dist/forms/flows.d.ts.map +1 -0
  22. package/dist/forms/flows.js +675 -0
  23. package/dist/forms/identity-flows.d.ts +17 -0
  24. package/dist/forms/identity-flows.d.ts.map +1 -0
  25. package/dist/forms/identity-flows.js +302 -0
  26. package/dist/forms/index.d.ts +7 -0
  27. package/dist/forms/index.d.ts.map +1 -0
  28. package/dist/forms/index.js +10 -0
  29. package/dist/forms/prompt-compiler.d.ts +3 -0
  30. package/dist/forms/prompt-compiler.d.ts.map +1 -0
  31. package/dist/forms/prompt-compiler.js +30 -0
  32. package/dist/helpers/address.d.ts +27 -0
  33. package/dist/helpers/address.d.ts.map +1 -0
  34. package/dist/helpers/address.js +29 -0
  35. package/dist/helpers/answer.d.ts +5 -0
  36. package/dist/helpers/answer.d.ts.map +1 -1
  37. package/dist/helpers/answer.js +5 -3
  38. package/dist/helpers/choice-offer.d.ts +3 -0
  39. package/dist/helpers/choice-offer.d.ts.map +1 -0
  40. package/dist/helpers/choice-offer.js +6 -0
  41. package/dist/helpers/form-actions.d.ts +13 -0
  42. package/dist/helpers/form-actions.d.ts.map +1 -0
  43. package/dist/helpers/form-actions.js +144 -0
  44. package/dist/helpers/form-sources.d.ts +7 -0
  45. package/dist/helpers/form-sources.d.ts.map +1 -0
  46. package/dist/helpers/form-sources.js +47 -0
  47. package/dist/helpers/form-state.d.ts +94 -0
  48. package/dist/helpers/form-state.d.ts.map +1 -0
  49. package/dist/helpers/form-state.js +870 -0
  50. package/dist/helpers/index.d.ts +114 -1
  51. package/dist/helpers/index.d.ts.map +1 -1
  52. package/dist/helpers/index.js +9 -4
  53. package/dist/helpers/match-inputs.d.ts +14 -0
  54. package/dist/helpers/match-inputs.d.ts.map +1 -1
  55. package/dist/helpers/match-inputs.js +154 -41
  56. package/dist/helpers/money.d.ts +12 -0
  57. package/dist/helpers/money.d.ts.map +1 -0
  58. package/dist/helpers/money.js +54 -0
  59. package/dist/helpers/spoken-money.d.ts +20 -0
  60. package/dist/helpers/spoken-money.d.ts.map +1 -0
  61. package/dist/helpers/spoken-money.js +143 -0
  62. package/dist/host/action-journal.d.ts +98 -0
  63. package/dist/host/action-journal.d.ts.map +1 -0
  64. package/dist/host/action-journal.js +218 -0
  65. package/dist/host/action-session.d.ts +12 -0
  66. package/dist/host/action-session.d.ts.map +1 -0
  67. package/dist/host/action-session.js +29 -0
  68. package/dist/host/capabilities.d.ts +12 -0
  69. package/dist/host/capabilities.d.ts.map +1 -0
  70. package/dist/host/capabilities.js +43 -0
  71. package/dist/host/checkpoint-codec-error.d.ts +6 -0
  72. package/dist/host/checkpoint-codec-error.d.ts.map +1 -0
  73. package/dist/host/checkpoint-codec-error.js +4 -0
  74. package/dist/host/index.d.ts +6 -0
  75. package/dist/host/index.d.ts.map +1 -1
  76. package/dist/host/index.js +6 -0
  77. package/dist/host/intent-stages.d.ts +1 -0
  78. package/dist/host/intent-stages.d.ts.map +1 -1
  79. package/dist/host/intent-stages.js +3 -3
  80. package/dist/host/node.d.ts +17 -0
  81. package/dist/host/node.d.ts.map +1 -0
  82. package/dist/host/node.js +64 -0
  83. package/dist/host/recovery-identity.d.ts +16 -0
  84. package/dist/host/recovery-identity.d.ts.map +1 -0
  85. package/dist/host/recovery-identity.js +73 -0
  86. package/dist/host/recovery-policy.d.ts +5 -0
  87. package/dist/host/recovery-policy.d.ts.map +1 -0
  88. package/dist/host/recovery-policy.js +54 -0
  89. package/dist/host/runtime-manifest.d.ts +3 -0
  90. package/dist/host/runtime-manifest.d.ts.map +1 -0
  91. package/dist/host/runtime-manifest.js +38 -0
  92. package/dist/index.d.ts +55 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +155 -45
  95. package/dist/runtime-identity.d.ts +3 -0
  96. package/dist/runtime-identity.d.ts.map +1 -0
  97. package/dist/runtime-identity.js +1 -0
  98. package/docs/development-release.md +23 -0
  99. package/docs/forms.md +122 -0
  100. package/package.json +15 -4
@@ -0,0 +1,675 @@
1
+ /**
2
+ * The FORM convention's system workers (owner direction and decisions 2026-10-19; spec: the jsfe-flow-designer skill,
3
+ * references/collect-input-pattern.md and references/manifests.md). This file is their SOURCE: the flows below are
4
+ * written into system.flows.dev.json by id, and --check fails when the file differs. Never edit them in the JSON.
5
+ *
6
+ * node forms/system-forms.mjs --write # replace / append these flows in system.flows.dev.json
7
+ * node forms/system-forms.mjs --check # exit 1 when system.flows.dev.json differs from this source
8
+ *
9
+ * Collection: run-form (the reentrant loop, shared scope), collect-input (ask one input, read the answer against the
10
+ * form chain), collect-input-accept (validate + store, one reading at a time), disambiguate-input,
11
+ * form-done (hand the result to the parent, clear the form), the field collectors (collect-phone…) and
12
+ * the sub-forms collect-address and collect-credit-card, validate-card-number / validate-card-expiration.
13
+ * Retrieval: provide-data (gate → load → answer), load-source, answer-facts.
14
+ *
15
+ * State lives in cargo.forms[<form id>] = { values, attempts, replays, parent, result_input, specs }: cargo is the only
16
+ * state that survives DISPATCH and reboot, so a caller who leaves a form resumes it. Results of finished forms:
17
+ * cargo.formResults[<form id>].
18
+ */
19
+ import { resolveFormInputs, validateFormIncompleteOutcome, validateFormExhaustion } from '../helpers/form-state.js';
20
+ // ── step helpers ────────────────────────────────────────────────────────────────────────────────────────────────
21
+ const SET = (id, variable, value) => ({ id, type: 'SET', variable, value });
22
+ const SIDE = (id, value) => SET(id, 'assignment_side_effect', value);
23
+ const NOOP = (id) => SET(id, 'noop', 'true');
24
+ const SAY = (id, value, value_es) => ({ id, type: 'SAY', value, value_es });
25
+ const END = (id, extra = {}) => ({ id, type: 'END', ...extra });
26
+ const FLOW = (id, value, callType = 'call', parameters) => ({ id, type: 'FLOW', value, callType, ...(callType === 'replace' ? { initializeDefaults: true } : {}), ...(parameters ? { parameters } : {}) });
27
+ /** One conditional step: a CASE with one branch and a no-op default (the engine has no step-level guard). */
28
+ const WHEN = (id, condition, step) => ({ id, type: 'CASE', branches: { [`condition: ${condition}`]: step, default: NOOP(`${id}_skip`) } });
29
+ 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
+ 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 optionalIncomplete = (id, condition, form, reason) => WHEN(id, `(${condition}) && !!formIncompleteOutcome(cargo,${form},{outcome:'unresolved',reason:${reason}}).owner`, incompleteDispatch(id + '_dispatch', form, reason));
33
+ /** A SET value that yields this text: with {{ }} it is a template (as is); without, a JS string literal. */
34
+ export const textValue = (text) => (String(text).includes('{{') ? String(text) : JSON.stringify(String(text)));
35
+ // Keys for a numbered menu: 1..9 without 8, the language key (owner rule 2026-10-19).
36
+ const KEYS_JS = "(function (n) { var k = []; for (var i = 1; k.length < n && i <= 9; i++) { if (i !== 8) { k.push(String(i)); } } return k; })";
37
+ // ── field presets (decision 2A): ONE library, read only by collect-input ─────────────────────────────────────────
38
+ // A form uses a preset by name ({ name, field }); keys it gives override the preset's (e.g. a tenant's account pattern).
39
+ export const FIELD_PRESETS = {
40
+ phone: { label: 'phone number', label_es: 'número de teléfono', profile: { kind: 'phone' }, digits: { min: 10, max: 11 },
41
+ question: 'What is your phone number?', question_es: '¿Cuál es su número de teléfono?',
42
+ retry: 'Please say or enter your 10-digit phone number.', retry_es: 'Por favor diga o ingrese su número de teléfono de 10 dígitos.' },
43
+ email: { label: 'email address', label_es: 'correo electrónico', profile: { kind: 'email' },
44
+ question: 'What is your email address?', question_es: '¿Cuál es su correo electrónico?',
45
+ retry: 'Please say your email address, for example juan at gmail dot com.', retry_es: 'Por favor diga su correo electrónico, por ejemplo juan arroba gmail punto com.' },
46
+ zip: { label: 'ZIP code', label_es: 'código postal', profile: { kind: 'digits', min: 5, max: 5 }, digits: { min: 5, max: 5 },
47
+ question: 'What is your ZIP code?', question_es: '¿Cuál es su código postal?',
48
+ retry: 'Please say or enter your 5-digit ZIP code.', retry_es: 'Por favor diga o ingrese su código postal de 5 dígitos.' },
49
+ account_number: { label: 'account number', label_es: 'número de cuenta', profile: { kind: 'identifier', pattern: '^\\d{6,12}$' }, digits: { min: 6, max: 12 },
50
+ question: 'What is your account number?', question_es: '¿Cuál es su número de cuenta?',
51
+ retry: 'Please say or enter your account number.', retry_es: 'Por favor diga o ingrese su número de cuenta.' },
52
+ ssn_last4: { label: 'last four digits of your Social Security number', label_es: 'últimos cuatro dígitos de su Seguro Social',
53
+ profile: { kind: 'digits', min: 4, max: 4 }, digits: { min: 4, max: 4 }, pii: true,
54
+ question: 'What are the last four digits of your Social Security number?', question_es: '¿Cuáles son los últimos cuatro dígitos de su Seguro Social?' },
55
+ amount: { label: 'amount', label_es: 'monto', profile: { kind: 'amount', min: 0.01 }, amount: true,
56
+ question: 'How much?', question_es: '¿Cuánto?' },
57
+ street_address: { label: 'street address', label_es: 'dirección', profile: { kind: 'text' },
58
+ question: 'What is your street address?', question_es: '¿Cuál es su dirección?' },
59
+ city: { label: 'city', label_es: 'ciudad', profile: { kind: 'text' }, question: 'What city?', question_es: '¿En qué ciudad?' },
60
+ state: { label: 'state', label_es: 'estado', profile: { kind: 'pattern', regex: '^\\s*(?:Alabama|Alaska|Arizona|Arkansas|California|Colorado|Connecticut|Delaware|Florida|Georgia|Hawaii|Idaho|Illinois|Indiana|Iowa|Kansas|Kentucky|Louisiana|Maine|Maryland|Massachusetts|Michigan|Minnesota|Mississippi|Missouri|Montana|Nebraska|Nevada|New Hampshire|New Jersey|New Mexico|New York|North Carolina|North Dakota|Ohio|Oklahoma|Oregon|Pennsylvania|Rhode Island|South Carolina|South Dakota|Tennessee|Texas|Utah|Vermont|Virginia|Washington|West Virginia|Wisconsin|Wyoming|District of Columbia|Puerto Rico|AL|AK|AZ|AR|CA|CO|CT|DE|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|MO|MT|NE|NV|NH|NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|VA|WA|WV|WI|WY|DC|PR)\\s*$' },
61
+ question: 'What state?', question_es: '¿En qué estado?' },
62
+ card_number: { label: 'card number', label_es: 'número de tarjeta', profile: { kind: 'digits', min: 13, max: 19 }, digits: { min: 13, max: 19, complete: [{ first: '4', lengths: [16, 19] }, { first: '25', lengths: [16] }, { first: '3', lengths: [15] }, { first: '6', lengths: [16, 19] }] },
63
+ pii: true, validator: 'validate-card-number',
64
+ question: 'Please say or enter your card number.', question_es: 'Por favor diga o ingrese el número de su tarjeta.',
65
+ retry: 'Please say or enter the full card number.', retry_es: 'Por favor diga o ingrese el número completo de su tarjeta.' },
66
+ card_expiration: { label: 'expiration date', label_es: 'fecha de vencimiento',
67
+ profile: { kind: 'pattern', regex: '\\b(0?[1-9]|1[0-2])\\s*(?:/|-|\\s|de)?\\s*(?:20)?(\\d{2})\\b' }, digits: { min: 3, max: 6 },
68
+ validator: 'validate-card-expiration',
69
+ question: 'What is the expiration date, month and year?', question_es: '¿Cuál es la fecha de vencimiento, mes y año?',
70
+ retry: 'Please say or enter the expiration as four digits, month then year, like 0 9 2 8.', retry_es: 'Por favor diga o ingrese el vencimiento en cuatro dígitos, mes y año, por ejemplo 0 9 2 8.' },
71
+ card_cvv: { label: 'security code', label_es: 'código de seguridad', profile: { kind: 'digits', min: 3, max: 4 }, digits: { min: 3, max: 4 }, pii: true,
72
+ question: 'What is the security code on the back of the card?', question_es: '¿Cuál es el código de seguridad al reverso de la tarjeta?' },
73
+ card_zip: { label: 'billing ZIP code', label_es: 'código postal de facturación', profile: { kind: 'digits', min: 5, max: 5 }, digits: { min: 5, max: 5 },
74
+ question: 'What is the billing ZIP code for the card?', question_es: '¿Cuál es el código postal de facturación de la tarjeta?' },
75
+ };
76
+ // Field collectors: one-input forms over a preset, callable directly (result in cargo.formResults[<id>]).
77
+ const FIELD_COLLECTORS = { 'collect-phone': 'phone', 'collect-email': 'email', 'collect-zip': 'zip', 'collect-street-address': 'street_address',
78
+ 'collect-city': 'city', 'collect-card-number': 'card_number', 'collect-card-expiration': 'card_expiration', 'collect-card-cvv': 'card_cvv',
79
+ 'collect-card-zip': 'card_zip' };
80
+ // ── a form flow (the shape every form follows; tenant forms are written the same way) ──────────────────────────────
81
+ /**
82
+ * The form shell: declare inputs, run the loop, hand the result on. `after` = the form's own steps once complete.
83
+ * `confirm` ({ question, question_es } — SAY-style templates that may read cargo.forms[<id>].values; build them in
84
+ * `before_confirm` SET steps when they need computing): the exact confirmation, asked every time after the loop and
85
+ * never an input. YES runs `after`; NO runs `declined`; a correction given there goes through the form's own matching
86
+ * and the form runs again (so whatever the correction made missing is asked).
87
+ */
88
+ export function formFlow({ id, name, version, description, inputs, after = [], extraVars = {}, parameters = [], confirm = null, before_confirm = [], declined = [], stop = '', close_on_stop = false, resume_on_parent_change = false, before = [], done = true, primary = false, scope = 'isolated', validator = '', confirmation_condition = 'true', prompt = undefined, prompt_es = undefined, incomplete_outcome = null, on_exhausted = null }) {
89
+ validateFormExhaustion(on_exhausted);
90
+ const incompleteFrom = incomplete_outcome && Object.hasOwn(incomplete_outcome, 'from');
91
+ if (incompleteFrom) {
92
+ if (Object.keys(incomplete_outcome).length !== 1 || typeof incomplete_outcome.from !== 'string' || !/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(incomplete_outcome.from) || !parameters.some(p => p.name === incomplete_outcome.from.split('.')[0] && p.type === 'object'))
93
+ throw new Error('FORM incomplete_outcome.from must reference a declared object parameter');
94
+ }
95
+ else
96
+ validateFormIncompleteOutcome(incomplete_outcome);
97
+ const incompleteExpression = incompleteFrom ? `formPath(currentContext, ${JSON.stringify(incomplete_outcome.from)})` : 'form_incomplete_outcome';
98
+ const confirmSteps = confirm ? [
99
+ // FLOW parameters are paths only: the question is built here, as a SAY-style template, then passed by name
100
+ SET(`${id}_confirm_q`, 'form_confirm_q', textValue(confirm.question)),
101
+ SET(`${id}_confirm_q_es`, 'form_confirm_q_es', textValue(confirm.question_es || confirm.question)),
102
+ FLOW(`${id}_confirm`, 'confirm-form', 'call', { form: '{{form_instance}}', question: '{{form_confirm_q}}', question_es: '{{form_confirm_q_es}}', words: '{{form_confirmation.words}}', clarifications: '{{form_confirmation.clarifications}}', no_keys: '{{form_confirmation.no_keys}}', interrupts: '{{form_confirmation.interrupts}}', exit_context: '{{exit_context}}', exit_context_es: '{{exit_context_es}}' }),
103
+ { id: `${id}_confirmed`, type: 'CASE', branches: {
104
+ [`condition: (cargo.formConfirm || {})[form_instance] === 'changed'`]: FLOW(`${id}_again`, id, 'replace', { parent_form: '{{parent_form}}', result_input: '{{result_input}}', form_instance: '{{form_instance}}' }),
105
+ // NO: the declined flow takes the form's place, so the action below never runs
106
+ [`condition: (cargo.formConfirm || {})[form_instance] === 'no'`]: declined.length ? FLOW(`${id}_declined`, `${id}-declined`, 'replace', { form_instance: '{{form_instance}}' }) : END(`${id}_declined_end`, { outcome: 'resolved', reason: 'declined' }),
107
+ default: NOOP(`${id}_confirmed_yes`)
108
+ } },
109
+ ] : [];
110
+ return {
111
+ 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
+ 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
+ steps: [
114
+ SET(`${id}_resume_kind`, 'form_internal_resume', '!!form_instance'),
115
+ 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
+ SIDE(`${id}_task`, `(!parent_form ? (cargo.formTasks = cargo.formTasks || {}, cargo.formTasks[${JSON.stringify(id)}] = form_instance) : true, true)`),
117
+ 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)'),
119
+ 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
+ ...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
+ ...before,
122
+ SET(`${id}_initial`, 'initial_readings', 'form_internal_resume ? [] : formInitialReadings(cargo.forms[form_instance], currentContext, cargo)'),
123
+ WHEN(`${id}_accept_initial`, 'initial_readings.length > 0', FLOW(`${id}_initial_accept`, 'collect-input-accept', 'call', { hits: '{{initial_readings}}' })),
124
+ FLOW(`${id}_run`, 'run-form', 'call', { rf_form: '{{form_instance}}', ...(resume_on_parent_change ? { rf_resume_parent: true } : {}), ...(stop ? { rf_stop: stop } : {}) }),
125
+ ...(resume_on_parent_change ? [
126
+ WHEN(`${id}_parent_capture`, "rf_status === 'parent_changed'", SET(`${id}_parent_id`, 'form_resume_owner', 'cargo.forms[form_instance].parent')),
127
+ WHEN(`${id}_parent_close`, "rf_status === 'parent_changed'", SIDE(`${id}_parent_release`, '(closeFormInstance(cargo, form_instance, false), true)')),
128
+ WHEN(`${id}_parent_resume`, "rf_status === 'parent_changed'", FLOW(`${id}_parent_reenter`, 'resume-form', 'reboot', { form: '{{form_resume_owner}}' })),
129
+ ] : []),
130
+ ...(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`)),
132
+ ...(validator ? [
133
+ SET(`${id}_values`, 'form_values', 'cargo.forms[form_instance].values'),
134
+ SIDE(`${id}_validation_reset`, '(cargo.formCheck = null, true)'),
135
+ FLOW(`${id}_validate`, validator, 'call', { form: '{{form_instance}}', values: '{{form_values}}' }),
136
+ SET(`${id}_validation`, 'form_validation', 'applyFormValidation(cargo, form_instance, cargo.formCheck)'),
137
+ 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
+ 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
+ optionalIncomplete(`${id}_optional_validation_failed`, "form_validation === 'failed'", 'form_instance', "form_instance+'_incomplete'"),
140
+ WHEN(`${id}_validation_failed`, "form_validation === 'failed'", FLOW(`${id}_validation_give_up`, 'give-up', 'reboot', { context: '{{exit_context}}', context_es: '{{exit_context_es}}' })),
141
+ WHEN(`${id}_validation_message`, "form_validation === 'retry' && cargo.formCheck.message", SAY(`${id}_validation_say`, '{{cargo.formCheck.message}}', '{{cargo.formCheck.message_es}}')),
142
+ 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}}' })),
143
+ ] : []),
144
+ ...before_confirm,
145
+ ...(confirm ? [SET(`${id}_confirmation_applicable`, 'form_should_confirm', `!!(${confirmation_condition}) && formInputApplies(form_confirmation, cargo.forms[form_instance].values, cargo, cargo.forms[form_instance])`)] : []),
146
+ ...confirmSteps.map((step, index) => WHEN(`${id}_confirmation_guard_${index}`, 'form_should_confirm === true', step)),
147
+ ...after,
148
+ ...(done ? [FLOW(`${id}_done`, 'form-done', 'call', { form: '{{form_instance}}' })] : []),
149
+ ],
150
+ };
151
+ }
152
+ /**
153
+ * A form's NO branch as its own flow, `${id}-declined`: it replaces the form, says and does `steps`, then clears the
154
+ * form's sensitive values (a card number, a security code). The rest is kept, so a caller who asks again resumes.
155
+ */
156
+ export function declinedFlow(id, steps) {
157
+ return { id: `${id}-declined`, name: `${id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join('')}Declined`, version: '1.0.0', description: `Worker: what ${id} says and does when its confirmation is declined; its sensitive values are cleared.`,
158
+ steps: [...steps, FLOW(`${id}_declined_forget`, 'form-done', 'call', { form: '{{form_instance}}', pii_only: 'true' })] };
159
+ }
160
+ // ── run-form: the reentrant loop (decision 1B) ───────────────────────────────────────────────────────────────────
161
+ const runForm = {
162
+ id: 'run-form', name: 'RunForm', version: '1.0.0',
163
+ 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
+ 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: [] }),
166
+ steps: [
167
+ SIDE('rf_pass_count', '(cargo.forms[rf_form] ? (cargo.forms[rf_form].replays = Number(cargo.forms[rf_form].replays || 0) + 1) : true, true)'),
168
+ SET('rf_pass', 'rf_status', "formInstanceApplies(cargo, rf_form) ? '' : (closeFormInstance(cargo, rf_form, false), 'unwound')"),
169
+ WHEN('rf_unwound', "rf_status === 'unwound'", END('rf_unwound_end')),
170
+ WHEN('rf_parent_changed', "rf_resume_parent === true && cargo.forms[rf_form].context_only && cargo.forms[cargo.forms[rf_form].parent].revision !== cargo.forms[rf_form].parent_revision", SET('rf_parent_status', 'rf_status', "'parent_changed'")),
171
+ WHEN('rf_parent_end', "rf_status === 'parent_changed'", END('rf_parent_changed_end')),
172
+ SET('rf_cap', 'rf_max', 'formWorkLimit(cargo, rf_form)'),
173
+ WHEN('rf_runaway', 'cargo.forms[rf_form].replays > rf_max', incompleteDispatch('rf_runaway_out', 'rf_form', "rf_form+'_incomplete'")),
174
+ WHEN('rf_stopped_early', '[].concat(rf_stop || []).some(function(p) { return !!formPath({cargo:cargo}, p); })', SET('rf_stopped_status', 'rf_status', "'stopped'")),
175
+ WHEN('rf_stop_before_pending', "rf_status === 'stopped'", END('rf_stopped_end')),
176
+ SET('rf_deferred', 'rf_deferred', `(${takeReadyProviderRequests.toString()})(cargo,rf_form)`),
177
+ WHEN('rf_deferred_answer', 'rf_deferred.length > 0', FLOW('rf_answer_deferred', 'provide-queued-data', 'call', { form: '{{rf_form}}', requests: '{{rf_deferred}}' })),
178
+ SET('rf_pending', 'rf_pending', 'pendingFormReadings(cargo, rf_form)'),
179
+ WHEN('rf_pending_accept', 'rf_pending.length > 0', FLOW('rf_pending_batch', 'collect-input-accept', 'call', { hits: '{{rf_pending}}' })),
180
+ 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}}' })),
181
+ SET('rf_find', 'rf_next', 'nextFormInput(cargo.forms[rf_form], cargo)'),
182
+ WHEN('rf_blocked', "rf_next === '__blocked__'", incompleteDispatch('rf_blocked_end', 'rf_form', "'form_dependencies_blocked'")),
183
+ SET('rf_find_form', 'rf_next_form', "rf_next ? ((cargo.forms[rf_form].specs || []).filter(function (i) { return i.name === rf_next; })[0] || {}).form || '' : ''"),
184
+ SET('rf_done', 'rf_status', "(function () { var stops = [].concat(typeof rf_stop !== 'undefined' && rf_stop ? rf_stop : []); return stops.some(function (p) { return !!formPath({cargo:cargo},p); }) ? 'stopped' : (rf_next ? '' : 'complete'); })()"),
185
+ WHEN('rf_complete', "rf_status === 'complete' || rf_status === 'stopped'", END('rf_complete_end')),
186
+ SET('rf_questions', 'rf_questions', 'cargo.forms[rf_form].questions'),
187
+ SET('rf_provides', 'rf_provides', 'cargo.forms[rf_form].providers'),
188
+ { id: 'rf_collect', type: 'CASE', branches: {
189
+ 'condition: rf_next_form': FLOW('rf_subform', '{{rf_next_form}}', 'call', { parent_form: '{{rf_form}}', result_input: '{{rf_next}}' }),
190
+ default: FLOW('rf_field', 'collect-input', 'call', { form: '{{rf_form}}', current: '{{rf_next}}', questions: '{{rf_questions}}', provides: '{{rf_provides}}', exit_context: '{{exit_context}}', exit_context_es: '{{exit_context_es}}' })
191
+ } },
192
+ FLOW('rf_replay', 'run-form', 'replace', { rf_form: '{{rf_form}}', rf_resume: 'true', rf_stop: '{{rf_stop}}', rf_resume_parent: '{{rf_resume_parent}}' }),
193
+ ],
194
+ };
195
+ // ── collect-input: ask the current input, read the answer against the form chain ────────────────────────────────────
196
+ const CHAIN_JS = 'formInputChain(cargo.forms, form, cargo)';
197
+ const collectInput = {
198
+ id: 'collect-input', name: 'CollectInput', version: '1.0.0', scope: 'isolated',
199
+ 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
+ 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: '' }),
202
+ steps: [
203
+ 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
+ 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'"))),
206
+ SET('ci_count_missed', 'ci_missed', "Number(((cargo.forms[form] || {}).attempts || {})[current] || 0)"),
207
+ SET('ci_max_attempts', 'ci_max', 'Number(ci_spec.max_attempts || 3)'),
208
+ SET('ci_context', 'ci_ctx', "typeof exit_context !== 'undefined' && exit_context ? exit_context : 'your request'"),
209
+ SET('ci_context_es', 'ci_ctx_es', "typeof exit_context_es !== 'undefined' && exit_context_es ? exit_context_es : 'su solicitud'"),
210
+ WHEN('ci_resolve_exhausted', 'ci_missed >= ci_max', SET('ci_exhausted_policy', 'ci_exhaustion', 'exhaustFormInput(cargo,form,current)')),
211
+ 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
+ optionalIncomplete('ci_optional_exhausted', 'ci_missed >= ci_max', 'form', "form+'_incomplete'"),
213
+ 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
+ FLOW('ci_prepare', 'prepare-input', 'call', { form: '{{form}}', input: '{{current}}' }),
215
+ // `refresh`: a flow run before EVERY ask, which computes what the question shows into cargo.forms[form].data
216
+ WHEN('ci_refresh', 'ci_spec.refresh', FLOW('ci_refresh_flow', '{{ci_spec.refresh}}', 'call', { form: '{{form}}', input: '{{current}}' })),
217
+ 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
+ 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))"),
221
+ 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
+ SET('ci_keypad', 'ci_digits', 'ci_spec.digits || null'),
223
+ SET('ci_is_amount', 'ci_amount', "ci_spec.amount === 'marked' ? 'marked' : (ci_spec.amount === true || ci_spec.profile.kind === 'amount')"),
224
+ SET('ci_as_spoken', 'ci_as_spoken', 'ci_spec.as_spoken === true'),
225
+ SET('ci_is_pii', 'ci_pii', 'ci_spec.pii === true'),
226
+ SET('ci_precheck', 'ci_pre', "ci_spec.answer_precheck || ''"),
227
+ SET('ci_validator', 'ci_val', "ci_spec.answer_validator || ''"),
228
+ SET('ci_reader_context', 'ci_reader', '({form:form,current:current,names:ci_names,questions:provides && Array.isArray(questions)?questions:[]})'),
229
+ FLOW('ci_ask', 'ask-choice', 'call', { question: '{{ci_q}}', question_es: '{{ci_q_es}}', choices: '{{ci_choices}}', accept_other: true, digits: '{{ci_digits}}', amount: '{{ci_amount}}', as_spoken: '{{ci_as_spoken}}', pii: '{{ci_pii}}', exit_context: '{{ci_ctx}}', exit_context_es: '{{ci_ctx_es}}', form_context: '{{ci_reader}}', answer_precheck: '{{ci_pre}}', answer_validator: '{{ci_val}}' }),
230
+ // a key the input routes to a handler of its own (route_keys: [{ prefix, flow }]): that flow takes the answer
231
+ SET('ci_route_to', 'ci_routed', "(ci_spec.route_keys || []).filter(function (r) { return String(cargo.choice || '').indexOf(r.prefix) === 0; }).map(function (r) { return r.flow; })[0] || ''"),
232
+ WHEN('ci_route_key', 'ci_routed', FLOW('ci_route_key_flow', '{{ci_routed}}', 'call', { form: '{{form}}', input: '{{current}}' })),
233
+ WHEN('ci_routed_end', 'ci_routed', END('ci_routed_done')),
234
+ SET('ci_build_chain', 'ci_chain', CHAIN_JS),
235
+ SET('ci_match', 'ci_m', "readFormAnswer(cargo.choiceText, ci_chain, ci_names.length > 1 ? { form: form, names: ci_names } : { form: form, name: current }, cargo.choice, provides && Array.isArray(questions) ? questions : [])"),
236
+ // The labels and validators ride on each reading, so the presets stay in this one flow.
237
+ SET('ci_enrich', 'ci_m', 'enrichFormMatches(ci_m, ci_chain, form, ci_names, current)'),
238
+ 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) : ''"),
239
+ SET('ci_count_words', 'ci_words', "String(cargo.choiceText || '').split(/\\s+/).filter(function (w) { return /[a-záéíóúüñ]/i.test(w); }).length"),
240
+ // Independent field clauses are committed before a provider can suspend on its gate.
241
+ 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}}' })),
242
+ WHEN('ci_question_fields', 'ci_m.questions.length > 0 && !ci_m.ambiguous.length && ci_m.hits.length > 0', FLOW('ci_question_accept_fields', 'collect-input-accept', 'call', { hits: '{{ci_m.hits}}' })),
243
+ SET('ci_skip_gate', 'ci_skip_gate', "!provides || !provides.gate || [].concat(provides.gate.verified || []).every(function(p){return !!formPath({cargo:cargo},p);}) ? 'true' : ''"),
244
+ WHEN('ci_explicit_question', 'ci_m.questions.length > 0', FLOW('ci_explicit_provide', 'provide-data', 'call', { form: '{{form}}', provides: '{{provides}}', asked: '{{ci_qkey}}', asked_keys: '{{ci_m.questions}}', no_menu: 'true', no_gate: '{{ci_skip_gate}}' })),
245
+ WHEN('ci_explicit_question_done', 'ci_m.questions.length > 0', END('ci_explicit_question_end')),
246
+ { id: 'ci_route', type: 'CASE', branches: {
247
+ 'condition: ci_m.ambiguous.length > 0': FLOW('ci_ambiguous', 'disambiguate-input', 'call', { readings: '{{ci_m.ambiguous}}', hits: '{{ci_m.hits}}', remaining: '{{ci_m.remaining}}' }),
248
+ 'condition: ci_m.hits.length > 0': FLOW('ci_accept', 'collect-input-accept', 'call', { hits: '{{ci_m.hits}}' }),
249
+ "condition: ci_m.questions.length > 0": END('ci_question_handled'),
250
+ "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)")
253
+ } },
254
+ ],
255
+ };
256
+ // ── collect-input-accept: validate and store ONE reading, then the rest (self-replace) ────────────────────────────
257
+ const prepareInput = {
258
+ id: 'prepare-input', name: 'PrepareInput', version: '2.0.0', scope: 'isolated',
259
+ description: 'Worker: run a field or group preparation once before prompting or validation, against committed prerequisites.',
260
+ parameters: [{ name: 'form', type: 'string' }, { name: 'input', type: 'string' }], variables: vars({ pi_spec: null }),
261
+ steps: [SET('pi_find', 'pi_spec', 'formPreparation(cargo, form, input)'),
262
+ WHEN('pi_run', 'pi_spec && pi_spec.flow', FLOW('pi_invoke', '{{pi_spec.flow}}', 'call', { form: '{{form}}', input: '{{pi_spec.input}}' })),
263
+ WHEN('pi_mark', 'pi_spec && pi_spec.flow', SIDE('pi_prepared', '(cargo.forms[form].prepared[pi_spec.input] = true, true)'))],
264
+ };
265
+ const validateBatch = {
266
+ id: 'validate-input-batch', name: 'ValidateInputBatch', version: '2.0.0', scope: 'isolated',
267
+ 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: '' }),
270
+ steps: [
271
+ SET('vb_init_accepted', 'accepted', 'Array.isArray(accepted) ? accepted : []'),
272
+ SET('vb_init_rejected', 'rejected', 'Array.isArray(rejected) ? rejected : []'),
273
+ SET('vb_init_pending', 'pending', 'Array.isArray(pending) ? pending : []'),
274
+ 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)')),
276
+ WHEN('vb_end', '!vb_h', END('vb_end_done')),
277
+ SET('vb_rest', 'vb_rest', 'hits.slice(1)'),
278
+ SET('vb_relevant', 'vb_relevant', 'formReadingRelevant(cargo, vb_h, accepted)'),
279
+ SET('vb_ready', 'vb_ready', 'formReadingReady(cargo, vb_h, accepted, rejected)'),
280
+ WHEN('vb_prepare', 'vb_ready === true', FLOW('vb_prepare_field', 'prepare-input', 'call', { form: '{{vb_h.form}}', input: '{{vb_h.name}}' })),
281
+ SET('vb_values', 'vb_values', 'proposedFormValues(cargo, vb_h.form, accepted)'),
282
+ SET('vb_known', 'vb_known', "!!(!vb_h.revalidate && !vb_h.validate_always && cargo.forms[vb_h.form] && !cargo.forms[vb_h.form].errors[vb_h.name] && cargo.forms[vb_h.form].accepted[vb_h.name] && formValuesEqual(vb_values[vb_h.name], vb_h.value))"),
283
+ SET('vb_outcome', 'vb_outcome', "vb_known ? 'known' : (hasFormValue(vb_values[vb_h.name]) && !formValuesEqual(vb_values[vb_h.name],vb_h.value) ? 'changed' : (vb_h.current ? 'stored' : 'taken'))"),
284
+ SIDE('vb_reset', "(cargo.inputCheck = vb_h.invalid ? { ok: false } : (vb_h.validator && !vb_known ? null : { ok: true, value: vb_h.value }), true)"),
285
+ 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
+ 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}}' }),
289
+ ],
290
+ };
291
+ const accept = {
292
+ id: 'collect-input-accept', name: 'CollectInputAccept', version: '2.0.0', scope: 'isolated',
293
+ description: 'Worker: validate all readings, commit accepted changes once with transitive invalidation, then acknowledge and run declared callbacks.',
294
+ parameters: [{ name: 'hits', type: 'array' }],
295
+ variables: vars({ ca_ordered: [], ca_results: [], ca_partition: null }),
296
+ steps: [
297
+ SET('ca_expand', 'hits', 'expandFormActivations(cargo, hits)'),
298
+ SET('ca_partition', 'ca_partition', 'exclusiveFormReadings(cargo, hits)'),
299
+ WHEN('ca_ambiguous', 'ca_partition.ambiguous.length > 0', FLOW('ca_clarify', 'disambiguate-input', 'call', { readings: '{{ca_partition.ambiguous}}', hits: '{{ca_partition.hits}}' })),
300
+ WHEN('ca_ambiguity_return', 'ca_partition.ambiguous.length > 0', END('ca_clarified_end')),
301
+ SET('ca_order', 'ca_ordered', 'orderFormReadings(cargo, hits)'),
302
+ FLOW('ca_validate', 'validate-input-batch', 'call', { hits: '{{ca_ordered}}' }),
303
+ SIDE('ca_settle', '(cargo.inputBatch = settleFormActivations(cargo.inputBatch, cargo), true)'),
304
+ SIDE('ca_rejections', 'rejectFormReadings(cargo, cargo.inputBatch.rejected)'),
305
+ SIDE('ca_commit', 'acceptFormReadings(cargo, cargo.inputBatch.accepted, cargo.inputBatch.pending)'),
306
+ SIDE('ca_pending', 'queueFormReadings(cargo, cargo.inputBatch.pending)'),
307
+ SET('ca_results', 'ca_results', 'cargo.inputBatch.accepted.concat(cargo.inputBatch.rejected, cargo.inputBatch.pending)'),
308
+ FLOW('ca_after', 'input-accepted-effects', 'call', { readings: '{{ca_results}}' }),
309
+ ],
310
+ };
311
+ const acceptedEffects = {
312
+ id: 'input-accepted-effects', name: 'InputAcceptedEffects', version: '2.0.0', scope: 'isolated',
313
+ description: 'Worker: acknowledge accepted or rejected candidates and run their callbacks after the batch commit.',
314
+ parameters: [{ name: 'readings', type: 'array' }],
315
+ variables: vars({ ca_h: null, ca_rest: [], ca_ack: '', ca_ack_es: '' }),
316
+ steps: [
317
+ SET('ie_first', 'ca_h', 'readings.length ? readings[0] : null'),
318
+ WHEN('ie_none', '!ca_h', END('ie_end')),
319
+ 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)")),
321
+ 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
+ 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
+ WHEN('ie_say', 'ca_ack', SAY('ie_ack', '{{ca_ack}}', '{{ca_ack_es}}')),
324
+ WHEN('ie_callback', "ca_h.after && ca_h.outcome !== 'known' && ca_h.outcome !== 'rejected' && ca_h.outcome !== 'pending'", FLOW('ie_after', '{{ca_h.after}}', 'call', { form: '{{ca_h.form}}', input: '{{ca_h.name}}', value: '{{ca_h.value}}' })),
325
+ WHEN('ie_more', 'ca_rest.length > 0', FLOW('ie_next', 'input-accepted-effects', 'replace', { readings: '{{ca_rest}}' })),
326
+ ],
327
+ };
328
+ // ── disambiguate-input: two or more readings claim the same words — ask, never rank ─────────────────────────────────
329
+ const disambiguate = {
330
+ id: 'disambiguate-input', name: 'DisambiguateInput', version: '1.0.0', scope: 'isolated',
331
+ 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
+ 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: [] }),
334
+ steps: [
335
+ WHEN('di_store_clear', 'Array.isArray(hits) && hits.length > 0', FLOW('di_store_hits', 'collect-input-accept', 'call', { hits: '{{hits}}' })),
336
+ SET('di_number', 'di_keys', `${KEYS_JS}(readings.length)`),
337
+ SET('di_build', 'di_choices', "readings.map(function (r, i) { var own = String(r.label + ' ' + r.label_es).toLowerCase().split(/\\s+/).filter(function (w) { return w.length > 3 && !readings.some(function (o, j) { return j !== i && String(o.label + ' ' + o.label_es).toLowerCase().split(/\\s+/).indexOf(w) > -1; }); }); return { key: 'r' + i, words: [di_keys[i], r.label, r.label_es].concat(own) }; })"),
338
+ SET('di_said', 'di_value', "readings[0].pii ? '' : String(readings[0].value)"),
339
+ 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
+ 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 } }),
342
+ SET('di_picked', 'di_pick', "/^r\\d+$/.test(String(cargo.choice || '')) ? [readings[Number(String(cargo.choice).slice(1))]] : null"),
343
+ WHEN('di_store_pick', 'di_pick', FLOW('di_store_pick_flow', 'collect-input-accept', 'call', { hits: '{{di_pick}}' })),
344
+ SET('di_remaining_first', 'di_next', 'Array.isArray(remaining) && remaining.length ? remaining[0] : []'),
345
+ SET('di_remaining_rest', 'di_rest', 'Array.isArray(remaining) ? remaining.slice(1) : []'),
346
+ WHEN('di_remaining', 'di_next.length > 0', FLOW('di_next_group', 'disambiguate-input', 'replace', { readings: '{{di_next}}', remaining: '{{di_rest}}' })),
347
+ ],
348
+ };
349
+ // ── confirm-form: the exact confirmation of a finished form; a correction is matched against the form chain ───────
350
+ const confirmForm = {
351
+ id: 'confirm-form', name: 'ConfirmForm', version: '1.0.0', scope: 'isolated',
352
+ 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
+ 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: '' }),
355
+ steps: [
356
+ SIDE('cf_reset', "(cargo.formConfirm = cargo.formConfirm || {}, cargo.formConfirm[form] = '', true)"),
357
+ 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
+ SET('cf_initial_chain', 'cf_chain', CHAIN_JS),
359
+ 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}}' }),
361
+ 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
+ WHEN('cf_interrupt', 'cf_event', FLOW('cf_event_flow', '{{cf_event}}', 'reboot', { form: '{{form}}', event: '{{cargo.choice}}', text: '{{cargo.choiceText}}' })),
363
+ SET('cf_build_chain', 'cf_chain', CHAIN_JS),
364
+ // a correction wins over a NO said with it ('no, make it $40'); a YES is never a correction
365
+ SET('cf_question_catalog', 'cf_questions', 'cargo.forms[form].questions || []'),
366
+ SET('cf_provider_catalog', 'cf_provides', 'cargo.forms[form].providers'),
367
+ 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)"),
369
+ 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
+ SET('cf_count_words', 'cf_words', "String(cargo.choiceText || '').split(/\\s+/).filter(function (w) { return /[a-záéíóúüñ]/i.test(w); }).length"),
371
+ 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}}' })),
372
+ WHEN('cf_question_provide', 'cf_m.questions.length > 0', FLOW('cf_question_answer', 'provide-data', 'call', { form: '{{form}}', provides: '{{cf_provides}}', asked_keys: '{{cf_m.questions}}', no_menu: 'true', no_gate: '{{cf_skip_gate}}' })),
373
+ { id: 'cf_route', type: 'CASE', branches: {
374
+ 'condition: cf_m.questions.length > 0': SET('cf_question_repeat', 'cf_result', "'changed'"),
375
+ "condition: cargo.choice === 'yes'": SET('cf_answered', 'cf_result', "'yes'"),
376
+ '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
+ 'condition: cf_m.hits.length > 0': FLOW('cf_accept', 'collect-input-accept', 'call', { hits: '{{cf_m.hits}}' }),
378
+ "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'"),
381
+ default: SET('cf_unclear', 'cf_result', "'unclear'")
382
+ } },
383
+ SET('cf_changed', 'cf_result', "cf_result ? cf_result : 'changed'"),
384
+ // 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}}' })),
388
+ 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)"),
390
+ ],
391
+ };
392
+ // ── form-done: the result goes to the parent (or cargo.formResults); the form is cleared ─────────────────────────────
393
+ const formDone = {
394
+ id: 'form-done', name: 'FormDone', version: '1.0.0', scope: 'isolated',
395
+ description: "Worker: a finished form hands its values to its parent's result input (a sub-form) and to cargo.formResults[form], without pii values unless keep_pii, then clears cargo.forms[form]. With pii_only, only the form's pii values are cleared. Params: form, keep_pii, pii_only.",
396
+ parameters: [{ name: 'form', type: 'string', description: 'The form id' }, { name: 'keep_pii', type: 'boolean', description: 'Keep pii values in the result (the next step uses them)' }, { name: 'pii_only', type: 'string', description: "'true': only clear the form's pii values (a declined confirmation); the form stays" }],
397
+ steps: [
398
+ SIDE('fd_pii_only', "(function (F) { var f = cargo.forms && cargo.forms[F]; if (f && typeof pii_only !== 'undefined' && pii_only === 'true') { (f.specs || []).forEach(function (i) { if (i.pii) { delete f.values[i.name]; } else { /* kept for a return */ } }); } else { /* the whole hand-on below */ } return true; })(form)"),
399
+ WHEN('fd_only_pii', "typeof pii_only !== 'undefined' && pii_only === 'true'", END('fd_only_pii_end')),
400
+ SIDE('fd_hand_on', '(closeFormInstance(cargo, form), true)'),
401
+ ],
402
+ };
403
+ // ── validators (contract: { input, value } → cargo.inputCheck = { ok, value, message, message_es }) ────────────────
404
+ const validators = [
405
+ { id: 'validate-card-number', name: 'ValidateCardNumber', version: '1.0.0', scope: 'isolated',
406
+ description: 'Validator: a card number passes the Luhn check (validateCardNumber). Params: input, value.',
407
+ parameters: [{ name: 'input', type: 'string', description: 'input name' }, { name: 'value', type: 'string', description: 'the reading' }],
408
+ steps: [SIDE('vcn_check', "(cargo.inputCheck = validateCardNumber(value) ? { ok: true, value: String(value).replace(/\\D/g, '') } : { ok: false, message: \"That card number doesn't look right.\", message_es: 'Ese número de tarjeta no parece correcto.' }, true)")] },
409
+ { id: 'validate-card-expiration', name: 'ValidateCardExpiration', version: '1.0.0', scope: 'isolated',
410
+ description: 'Validator: month and year, a real month, not expired (validateExpiration). Normalizes to MMYY. Params: input, value.',
411
+ parameters: [{ name: 'input', type: 'string', description: 'input name' }, { name: 'value', type: 'string', description: 'the reading' }],
412
+ steps: [SIDE('vce_check', "(function (v) { var m = /^(0?[1-9]|1[0-2])\\D*(?:20)?(\\d{2})$/.exec(String(v).replace(/\\s+/g, '').replace(/de/gi, '')) || /^(\\d{2})(\\d{2})$/.exec(String(v).replace(/\\D/g, '')); var mm = m ? ('0' + m[1]).slice(-2) : ''; var yy = m ? m[2] : ''; cargo.inputCheck = m && validateExpiration(mm, yy) ? { ok: true, value: mm + yy } : { ok: false, message: \"That expiration date doesn't look right.\", message_es: 'Esa fecha de vencimiento no parece correcta.' }; return true; })(value)")] },
413
+ ];
414
+ // ── field collectors and sub-forms ──────────────────────────────────────────────────────────────────────────────────
415
+ const fieldCollectors = Object.entries(FIELD_COLLECTORS).map(([id, field]) => formFlow({
416
+ id, name: id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(''), version: '1.0.0',
417
+ description: `Field collector: the ${FIELD_PRESETS[field].label} (preset '${field}'). Result: cargo.formResults['${id}'].${field}.`,
418
+ inputs: [{ name: field, field }],
419
+ }));
420
+ const subForms = [
421
+ formFlow({ id: 'collect-address', name: 'CollectAddress', version: '1.0.0',
422
+ description: "Sub-form: street address, city, state, ZIP, in any order. Result: the parent's result input (or cargo.formResults['collect-address']).",
423
+ inputs: [{ name: 'street_address', field: 'street_address' }, { name: 'city', field: 'city', confirm_against: ['state'] }, { name: 'state', field: 'state' }, { name: 'zip', field: 'zip' }],
424
+ extraVars: { exit_context: 'your address', exit_context_es: 'su dirección' } }),
425
+ formFlow({ id: 'collect-credit-card', name: 'CollectCreditCard', version: '1.0.0',
426
+ description: "Sub-form: card number, expiration, security code, billing ZIP, in any order. Result (with keep_pii for the charge step): the parent's result input.",
427
+ inputs: [{ name: 'card_number', field: 'card_number' }, { name: 'card_expiration', field: 'card_expiration' }, { name: 'card_cvv', field: 'card_cvv' }, { name: 'card_zip', field: 'card_zip' }],
428
+ extraVars: { exit_context: 'your payment', exit_context_es: 'su pago' } }),
429
+ ];
430
+ // collect-credit-card hands the card on WITH its pii (the charge needs it); the parent clears it after use.
431
+ subForms[1].steps[subForms[1].steps.length - 1].parameters.keep_pii = true;
432
+ // ── retrieval: provide-data, load-source, answer-facts (manifests.md §8) ───────────────────────────────────────────
433
+ const PATH_JS = "(function (p) { var o = { cargo: cargo }; String(p || '').split('.').forEach(function (k) { o = o === undefined || o === null ? undefined : o[k]; }); return o; })";
434
+ // Sentence templates (provider catalogs): {field} is a value of the fact (or {value} for a single one) formatted by the
435
+ // fact's `format` (one name, or one per field); {name} is the caller's name; {count} / {items} for a list. [optional part]
436
+ // is said only when every {placeholder} in it has a value; [first|second] says the first alternative whose placeholders
437
+ // all have values (the last one is the fallback). Formats: money, money_nonzero (a zero amount counts as no value), date,
438
+ // date_no_year, last4, digits (one by one on a call), text. A `say` may be an object keyed by the value's `state` field
439
+ // ({ all: …, some: …, none: … }) when the sentence itself changes with the data.
440
+ const RENDER_JS = "function fmt1(v, f, lang) { if (v === undefined || v === null || v === '') { return ''; } else if (f === 'money') { return amountToSpeech(v, lang, !!cargo.voice); } else if (f === 'money_nonzero') { return Number(v) === 0 ? '' : amountToSpeech(v, lang, !!cargo.voice); } else if (f === 'date') { return dateToWords(v, lang, true); } else if (f === 'date_no_year') { return dateToWords(v, lang, false); } else if (f === 'last4') { return last4(v, !!cargo.voice); } else if (f === 'digits') { return cargo.voice ? String(v).split('').join(', ') : String(v); } else { return String(v); } } function fill(t, obj, fmts, lang, extra) { var one = function (seg) { var gone = false; var out = String(seg).replace(/\\{(\\w+)\\}/g, function (m, k) { var v = Object.prototype.hasOwnProperty.call(extra, k) ? String(extra[k] || '') : fmt1(obj !== null && typeof obj === 'object' ? obj[k] : (k === 'value' ? obj : undefined), fmts && typeof fmts === 'object' ? fmts[k] : fmts, lang); if (v === '') { gone = true; } else { /* filled */ } return v; }); return { out: out, gone: gone }; }; var t2 = String(t).replace(/\\[([^\\]]*)\\]/g, function (m, seg) { var alts = seg.split('|'); for (var i = 0; i < alts.length; i++) { var r = one(alts[i]); if (!r.gone || i === alts.length - 1) { return r.gone && alts.length === 1 ? '' : r.out; } else { /* try the next alternative */ } } return ''; }); return one(t2).out.replace(/\\s+([.,;:])/g, '$1').replace(/\\s{2,}/g, ' ').trim(); } function empty(v) { return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0) || (v !== null && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).every(function (k) { return v[k] === undefined || v[k] === null || v[k] === ''; })); } function render(f, src, lang, name) { var es = lang === 'es'; var o = src; String(f.path).split('.').forEach(function (k) { o = o === undefined || o === null ? undefined : o[k]; }); var label = es ? (f.label_es || f.label) : f.label; var extra = { name: name, label: label }; if (empty(o)) { var miss = es ? (f.missing_es || f.missing) : f.missing; return miss ? fill(miss, {}, null, lang, extra) : (es ? 'No veo ' + label + ' en su ' + (provides.subject_es || provides.subject || 'cuenta') + '.' : (\"I don't see a \" + label + ' on your ' + (provides.subject || 'account') + '.')); } else if (f.format && typeof f.format === 'object' && f.format.type === 'list' && Array.isArray(o)) { var it = es ? (f.format.item_es || f.format.item) : f.format.item; extra.count = String(o.length); extra.items = o.map(function (x) { return fill(it, x, f.format.fields, lang, { name: name }); }).join(es ? (f.format.join_es || f.format.join || '; ') : (f.format.join || '; ')); var lt = o.length === 1 ? (es ? (f.say_one_es || f.say_one || f.say_es || f.say) : (f.say_one || f.say)) : (es ? (f.say_es || f.say) : f.say); return fill(lt || '{items}', o, null, lang, extra); } else { var t = es ? (f.say_es || f.say) : f.say; if (t && typeof t === 'object') { t = t[o && o.state] || t.default || ''; } else { /* one sentence */ } return fill(t || (es ? 'Su {label} es {value}.' : 'Your {label} is {value}.'), o, f.format, lang, extra); } }";
441
+ const NAME_JS = "(function (p) { var o = { cargo: cargo }; String(p || 'cargo.displayName').split('.').forEach(function (k) { o = o === undefined || o === null ? undefined : o[k]; }); var n = String(o || '').trim(); return n && n === n.toUpperCase() ? n.toLowerCase().replace(/(^|[\\s'-])([a-záéíóúñ])/g, function (m, a, c) { return a + c.toUpperCase(); }) : n; })(provides.name_path)";
442
+ /** Schedule deferred catalogs once authorized or once the existing task gate ends; each still enforces its own gate. */
443
+ export function takeReadyProviderRequests(cargo, form) {
444
+ const current = cargo.forms && cargo.forms[form];
445
+ const owner = current && cargo.forms[current.task];
446
+ if (!owner || !owner.providerRequests || owner.providerGateActive)
447
+ return [];
448
+ const read = path => String(path || '').split('.').reduce((value, key) => value == null ? undefined : value[key], { cargo });
449
+ const ready = [], waiting = [];
450
+ const gateActive = Object.keys(owner.activeGates || {}).length > 0;
451
+ for (const request of owner.providerRequests) {
452
+ const paths = [].concat(request.provides.gate && request.provides.gate.verified || []);
453
+ (!gateActive || paths.length && paths.every(path => !!read(path)) ? ready : waiting).push(request);
454
+ }
455
+ if (waiting.length)
456
+ owner.providerRequests = waiting;
457
+ else
458
+ delete owner.providerRequests;
459
+ return ready;
460
+ }
461
+ /** Register private questions on their owning task while one provider gate is suspended. */
462
+ export function beginProviderRequest(cargo, form, provides, keys, gateParams) {
463
+ const current = cargo.forms && cargo.forms[form];
464
+ const owner = current && cargo.forms[current.task];
465
+ if (!owner)
466
+ return { owner: '', queued: false };
467
+ const waiting = !!owner.providerRequests || Object.keys(owner.activeGates || {}).length > 0;
468
+ const queue = owner.providerRequests || (owner.providerRequests = []);
469
+ if (!waiting)
470
+ owner.providerGateActive = true;
471
+ const catalog = JSON.stringify(provides);
472
+ let request = queue.find(entry => entry.catalog === catalog);
473
+ if (!request) {
474
+ request = { catalog, provides, keys: [], gate_params: gateParams };
475
+ queue.push(request);
476
+ }
477
+ for (const key of keys)
478
+ if (!request.keys.includes(key))
479
+ request.keys.push(key);
480
+ return { owner: owner.id, queued: waiting };
481
+ }
482
+ /** Consume once after the active gate returns; every deferred catalog still enforces its own gate. */
483
+ export function finishProviderRequest(cargo, owner, provides, keys) {
484
+ const state = cargo.forms && cargo.forms[owner];
485
+ const queue = state && state.providerRequests || [];
486
+ if (state) {
487
+ delete state.providerRequests;
488
+ delete state.providerGateActive;
489
+ }
490
+ const catalog = JSON.stringify(provides);
491
+ const own = queue.find(entry => entry.catalog === catalog);
492
+ return { keys: own ? own.keys : keys, remaining: queue.filter(entry => entry.catalog !== catalog) };
493
+ }
494
+ const provideData = {
495
+ id: 'provide-data', name: 'ProvideData', version: '1.0.0', scope: 'isolated',
496
+ description: "Worker: answer a question about a provider catalog. Resolves `asked` (a fact or set key; empty = ask which), passes the catalog's gate for non-public facts (gate.flow, called with gate_params; then every path in gate.verified must be truthy), loads the source (load-source), says the facts (answer-facts). Params: provides, asked.",
497
+ 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
+ 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
+ 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' })),
501
+ 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
+ // Nothing specific asked: offer the sets and facts as a menu (keys 1..n, never 8; at most 7 options shown).
503
+ SET('pd_menu_items', 'pd_menu', "(provides.sets || []).concat(provides.facts).slice(0, 7)"),
504
+ SET('pd_menu_keys', 'pd_mkeys', `${KEYS_JS}(pd_menu.length)`),
505
+ SET('pd_menu_q', 'pd_q', "'What would you like to know? ' + pd_menu.map(function (m, i) { return (cargo.voice ? 'Press ' + pd_mkeys[i] + ' or say ' : 'Say ') + String(m.label || m.key).toUpperCase(); }).join(', ') + '.'"),
506
+ SET('pd_menu_q_es', 'pd_q_es', "'¿Qué le gustaría saber? ' + pd_menu.map(function (m, i) { return (cargo.voice ? 'Presione ' + pd_mkeys[i] + ' o diga ' : 'Diga ') + String(m.label_es || m.label || m.key).toUpperCase(); }).join(', ') + '.'"),
507
+ WHEN('pd_ask_which', '!pd_keys.length', FLOW('pd_ask', 'ask-choice', 'call', { question: '{{pd_q}}', question_es: '{{pd_q_es}}', choices: '{{pd_menu_choices}}', digits: { min: 1, max: 1 } })),
508
+ SET('pd_from_menu', 'pd_keys', "pd_keys.length ? pd_keys : (function (k) { var s = (provides.sets || []).filter(function (x) { return x.key === k; })[0]; return s ? s.facts.slice() : provides.facts.some(function (f) { return f.key === k; }) ? [k] : []; })(String(cargo.choice || ''))"),
509
+ WHEN('pd_nothing', '!pd_keys.length', END('pd_nothing_end')),
510
+ SET('pd_needs_gate', 'pd_gated', "!!provides.gate && pd_keys.some(function (k) { var f = provides.facts.filter(function (x) { return x.key === k; })[0]; return !f || f.public !== true; })"),
511
+ // Suspend one private request per owning task. Further questions merge into its queue rather than nesting gates.
512
+ SET('pd_request', 'pd_request', `pd_gated && no_gate !== 'true' ? (${beginProviderRequest.toString()})(cargo,form,provides,pd_keys,gate_params) : {owner:'',queued:false}`),
513
+ WHEN('pd_queued', 'pd_request.queued', END('pd_deferred_end')),
514
+ WHEN('pd_gate', "pd_gated && no_gate !== 'true'", FLOW('pd_gate_flow', '{{provides.gate.flow}}', 'call', { gate_params: '{{gate_params}}', parent_form: '{{pd_request.owner}}' })),
515
+ SET('pd_deferred', 'pd_deferred', `(${finishProviderRequest.toString()})(cargo,pd_request.owner,provides,pd_keys)`),
516
+ SET('pd_merged_keys', 'pd_keys', 'pd_deferred.keys'),
517
+ 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' })),
519
+ WHEN('pd_load', 'provides.source && provides.source.flow', FLOW('pd_load_flow', 'load-source', 'call', { source: '{{provides.source}}' })),
520
+ FLOW('pd_answer', 'answer-facts', 'call', { provides: '{{provides}}', keys: '{{pd_keys}}' }),
521
+ WHEN('pd_deferred_more', 'pd_deferred.remaining.length > 0', FLOW('pd_answer_deferred', 'provide-queued-data', 'call', { form: '{{form}}', requests: '{{pd_deferred.remaining}}' })),
522
+ SET('pd_ready', 'pd_ready', `(${takeReadyProviderRequests.toString()})(cargo,form)`),
523
+ WHEN('pd_ready_more', 'pd_ready.length > 0', FLOW('pd_answer_ready', 'provide-queued-data', 'call', { form: '{{form}}', requests: '{{pd_ready}}' })),
524
+ // a fact may close the exchange (`close: true`): 'Is there anything else…?' instead of the menu
525
+ SET('pd_closing', 'pd_close', "no_menu !== 'true' && pd_keys.length > 0 && pd_keys.every(function (k) { var f = provides.facts.filter(function (x) { return x.key === k; })[0]; return !!f && f.close === true; })"),
526
+ SET('pd_name', 'pd_who', NAME_JS),
527
+ WHEN('pd_close_say', 'pd_close', { id: 'pd_anything_else', type: 'SAY', value: "{{anythingElse('en', pd_who)}}", value_es: "{{anythingElse('es', pd_who)}}" }),
528
+ WHEN('pd_menu_after', "provides.menu && no_menu !== 'true' && !pd_close", FLOW('pd_menu_flow', 'provider-menu', 'call', { provides: '{{provides}}' })),
529
+ ],
530
+ };
531
+ const provideQueuedData = {
532
+ id: 'provide-queued-data', name: 'ProvideQueuedData', version: '1.0.0', scope: 'isolated',
533
+ description: 'Worker: answer the deduplicated private questions deferred during one owning task gate, checking each catalog before retrieval.',
534
+ parameters: [{ name: 'form', type: 'string' }, { name: 'requests', type: 'array' }],
535
+ variables: vars({ requests: [], pqd_first: null, pqd_rest: [], pqd_verified: '' }),
536
+ steps: [SET('pqd_first', 'pqd_first', 'requests[0] || null'), WHEN('pqd_done', '!pqd_first', END('pqd_end')),
537
+ SET('pqd_rest', 'pqd_rest', 'requests.slice(1)'),
538
+ SET('pqd_verified', 'pqd_verified', "!pqd_first.provides.gate || [].concat(pqd_first.provides.gate.verified || []).every(function(p){return !!formPath({cargo:cargo},p);}) ? 'true' : ''"),
539
+ FLOW('pqd_answer', 'provide-data', 'call', { form: '{{form}}', provides: '{{pqd_first.provides}}', asked_keys: '{{pqd_first.keys}}', gate_params: '{{pqd_first.gate_params}}', no_menu: 'true', no_gate: '{{pqd_verified}}' }),
540
+ WHEN('pqd_more', 'pqd_rest.length > 0', FLOW('pqd_next', 'provide-queued-data', 'replace', { form: '{{form}}', requests: '{{pqd_rest}}' }))]
541
+ };
542
+ // the menu's choices, built where the expression can see pd_menu (FLOW parameters are paths only)
543
+ provideData.variables.pd_menu_choices = { type: 'array', value: [] };
544
+ provideData.steps.splice(provideData.steps.findIndex((s) => s.id === 'pd_menu_q'), 0, SET('pd_menu_build', 'pd_menu_choices', "pd_menu.map(function (m, i) { return { key: m.key, words: [pd_mkeys[i], m.label || m.key, m.label_es || ''].filter(Boolean) }; })"));
545
+ // provider-menu: what next, after an answer — the catalog's actions, another fact, or done (owner MENU-1016 shape)
546
+ const providerMenu = {
547
+ id: 'provider-menu', name: 'ProviderMenu', version: '1.0.0', scope: 'isolated',
548
+ description: "Worker: after a provider answered, offer its menu (provides.menu: question(_voice)(_es) with {verb} / {name}; choices [{ key, words, flow, exact, set }]; done { words, say(_es) }). A fact or set named by its words is answered and the menu comes back; an action runs its flow (replace) after setting its `set` cargo fields; done says goodbye. Other words: DISPATCH. No words: asked once more, then 'anything else?'. Params: provides, retry.",
549
+ parameters: [{ name: 'provides', type: 'object', description: 'The provider catalog' }, { name: 'retry', type: 'string', description: "'1' on the re-ask" }],
550
+ variables: vars({ retry: '', pm_q: '', pm_q_es: '', pm_choices: [], pm_key: '', pm_keys: [], pm_close: false, pm_flow: '', pm_bye: '', pm_bye_es: '', pm_who: '', pm_sorry: '', pm_sorry_es: '' }),
551
+ steps: [
552
+ SET('pm_name', 'pm_who', NAME_JS),
553
+ SET('pm_question', 'pm_q', `(function () { ${RENDER_JS} var m = provides.menu; var t = cargo.voice && m.question_voice ? m.question_voice : m.question; return (retry === '1' ? "Sorry, I didn't catch that. " : '') + fill(t, {}, null, 'en', { name: pm_who, verb: cargo.verb || (cargo.voice ? 'say' : 'type') }); })()`),
554
+ SET('pm_question_es', 'pm_q_es', `(function () { ${RENDER_JS} var m = provides.menu; var t = cargo.voice && (m.question_voice_es || m.question_voice) ? (m.question_voice_es || m.question_voice) : (m.question_es || m.question); return (retry === '1' ? 'Disculpe, no le entendí. ' : '') + fill(t, {}, null, 'es', { name: pm_who, verb: cargo.verb_es || (cargo.voice ? 'diga' : 'escriba') }); })()`),
555
+ SET('pm_build', 'pm_choices', "provides.facts.concat(provides.sets || []).filter(function (f) { return Array.isArray(f.words) && f.words.length; }).map(function (f) { return { key: 'fact:' + f.key, words: f.words }; }).concat((provides.menu.choices || []).map(function (c) { return { key: 'act:' + c.key, words: c.words, exact: c.exact === true }; })).concat(provides.menu.done ? [{ key: 'done', words: provides.menu.done.words, exact: true }] : [])"),
556
+ FLOW('pm_ask', 'ask-choice', 'call', { question: '{{pm_q}}', question_es: '{{pm_q_es}}', choices: '{{pm_choices}}', accept_other: true, digits: { min: 1, max: 1 }, exit_context: '{{provides.subject}}', exit_context_es: '{{provides.subject_es}}' }),
557
+ SET('pm_take', 'pm_key', "String(cargo.choice || '').indexOf('fact:') === 0 ? pickProviderQuestion(cargo.choiceText, pm_choices.filter(function (c) { return c.key.indexOf('fact:') === 0; })) : String(cargo.choice || '')"),
558
+ SET('pm_fact_keys', 'pm_keys', "pm_key.indexOf('fact:') === 0 ? (function (k) { var s = (provides.sets || []).filter(function (x) { return x.key === k; })[0]; return s ? s.facts.slice() : [k]; })(pm_key.slice(5)) : []"),
559
+ SET('pm_closing', 'pm_close', "pm_keys.length > 0 && pm_keys.every(function (key) { var fact = provides.facts.find(function (f) { return f.key === key; }); return !!fact && fact.close === true; })"),
560
+ SET('pm_action_flow', 'pm_flow', "pm_key.indexOf('act:') === 0 ? (function (c) { if (c && c.set) { Object.keys(c.set).forEach(function (k) { cargo[k] = c.set[k]; }); } else { /* nothing to set */ } return c ? c.flow : ''; })((provides.menu.choices || []).filter(function (x) { return 'act:' + x.key === pm_key; })[0]) : ''"),
561
+ SET('pm_goodbye', 'pm_bye', `(function () { ${RENDER_JS} var d = provides.menu.done || {}; return fill(d.say || 'Glad I could help. Have a great day[, {name}]!', {}, null, 'en', { name: pm_who }); })()`),
562
+ SET('pm_goodbye_es', 'pm_bye_es', `(function () { ${RENDER_JS} var d = provides.menu.done || {}; return fill(d.say_es || d.say || 'Me alegra haber ayudado. ¡Que tenga un buen día[, {name}]!', {}, null, 'es', { name: pm_who }); })()`),
563
+ SET('pm_sorry_en', 'pm_sorry', "\"Sorry, I didn't catch that. \" + anythingElse('en', pm_who)"),
564
+ SET('pm_sorry_es_text', 'pm_sorry_es', "'Disculpe, no le entendí. ' + anythingElse('es', pm_who)"),
565
+ { id: 'pm_route', type: 'CASE', branches: {
566
+ 'condition: pm_keys.length > 0': FLOW('pm_answer', 'answer-facts', 'call', { provides: '{{provides}}', keys: '{{pm_keys}}' }),
567
+ 'condition: pm_flow': FLOW('pm_act', '{{pm_flow}}', 'replace'),
568
+ "condition: pm_key === 'done'": { id: 'pm_done', type: 'SAY', value: '{{pm_bye}}', value_es: '{{pm_bye_es}}', outcome: 'resolved' },
569
+ "condition: /[a-záéíóúüñ]/i.test(String(cargo.choiceText || ''))": { id: 'pm_elsewhere', type: 'DISPATCH' },
570
+ "condition: retry !== '1'": FLOW('pm_reask', 'provider-menu', 'replace', { provides: '{{provides}}', retry: '1' }),
571
+ default: { id: 'pm_give_back', type: 'SAY', value: '{{pm_sorry}}', value_es: '{{pm_sorry_es}}' }
572
+ } },
573
+ WHEN('pm_close_say', 'pm_close', { id: 'pm_anything_else', type: 'SAY', value: "{{anythingElse('en', pm_who)}}", value_es: "{{anythingElse('es', pm_who)}}" }),
574
+ WHEN('pm_again', 'pm_keys.length > 0 && !pm_close', FLOW('pm_menu_again', 'provider-menu', 'replace', { provides: '{{provides}}' })),
575
+ ],
576
+ };
577
+ const loadSource = {
578
+ id: 'load-source', name: 'LoadSource', version: '1.0.0', scope: 'isolated',
579
+ description: "Worker: load a provider's data when missing or stale (refresh: per_session | per_turn | max_age_ms) through the tenant's loader flow (source.flow), which writes source.result. Still missing after the loader: give-up (a service the caller needs failed). Param: source.",
580
+ parameters: [{ name: 'source', type: 'object', description: '{ flow, result, refresh, max_age_ms }' }],
581
+ variables: vars({ ls_stale: false, ls_have: false }),
582
+ steps: [
583
+ SET('ls_is_stale', 'ls_stale', 'sourceNeedsLoad(cargo, source)'),
584
+ WHEN('ls_begin', 'ls_stale', SIDE('ls_clear_old', 'beginSourceRefresh(cargo, source)')),
585
+ WHEN('ls_load', 'ls_stale', FLOW('ls_loader', '{{source.flow}}', 'call')),
586
+ 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' })),
588
+ ],
589
+ };
590
+ const answerFacts = {
591
+ id: 'answer-facts', name: 'AnswerFacts', version: '1.0.0', scope: 'isolated',
592
+ description: "Worker: say the facts `keys` of a provider catalog in one message, each from its path in the source result through its sentence template (say / say_es: {field} formatted by `format`, {name}, [optional part], [first|second]; a list: format { type: 'list', item, join }, {count}, {items}), per language and channel. A missing value says `missing` (never invented). A fact with its own `flow` is answered by that flow. Params: provides, keys.",
593
+ parameters: [{ name: 'provides', type: 'object', description: 'The provider catalog' }, { name: 'keys', type: 'array', description: 'Fact keys to say' }],
594
+ variables: vars({ af_inline: [], af_flows: [], af_text: '', af_text_es: '', af_first: null, af_rest: [], af_who: '' }),
595
+ steps: [
596
+ SET('af_split_inline', 'af_inline', "provides.facts.filter(function (f) { return keys.indexOf(f.key) > -1 && !f.flow; })"),
597
+ SET('af_split_flows', 'af_flows', "provides.facts.filter(function (f) { return keys.indexOf(f.key) > -1 && !!f.flow; })"),
598
+ SET('af_name', 'af_who', NAME_JS),
599
+ SET('af_say_en', 'af_text', `(function () { ${RENDER_JS} var src = ${PATH_JS}((provides.source || {}).result) || {}; return af_inline.map(function (f) { return render(f, src, 'en', af_who); }).join(' '); })()`),
600
+ SET('af_say_es', 'af_text_es', `(function () { ${RENDER_JS} var src = ${PATH_JS}((provides.source || {}).result) || {}; return af_inline.map(function (f) { return render(f, src, 'es', af_who); }).join(' '); })()`),
601
+ WHEN('af_say', 'af_text', SAY('af_say_text', '{{af_text}}', '{{af_text_es}}')),
602
+ SET('af_take_first', 'af_first', 'af_flows.length ? af_flows[0] : null'),
603
+ SET('af_take_rest', 'af_rest', 'af_flows.slice(1).map(function (f) { return f.key; })'),
604
+ WHEN('af_own_flow', 'af_first', FLOW('af_own_flow_call', '{{af_first.flow}}', 'call')),
605
+ WHEN('af_more', 'af_rest.length > 0', FLOW('af_more_call', 'answer-facts', 'replace', { provides: '{{provides}}', keys: '{{af_rest}}' })),
606
+ ],
607
+ };
608
+ const performAction = {
609
+ id: 'perform-action', name: 'PerformAction', version: '1.0.0', scope: 'isolated',
610
+ description: 'Worker: enforce form, gate and confirmation invariants; invoke a declared action once per prepared request and retain its explicit outcome.',
611
+ parameters: [{ name: 'form', type: 'string' }, { name: 'action', type: 'object' }, { name: 'request', type: 'object' }, { name: 'defer_receipt', type: 'boolean' }],
612
+ variables: vars({ defer_receipt: false, pa_record: null, pa_run: false, pa_execute: false, pa_consult: false, pa_result: null, pa_claim: null, pa_completion: null }),
613
+ steps: [
614
+ WHEN('pa_gate', 'action.gate && action.gate.flow', FLOW('pa_gate_call', '{{action.gate.flow}}', 'call', { gate_params: '{{request.gate_params}}' })),
615
+ SET('pa_prepare', 'pa_record', 'prepareFormAction(cargo, form, action, request)'),
616
+ SET('pa_begin', 'pa_run', 'beginFormAction(cargo, form, action, pa_record)'),
617
+ SET('pa_consult', 'pa_consult', "pa_run === true || ['attempted','unknown'].indexOf(pa_record.status) >= 0"),
618
+ WHEN('pa_claim_needed', 'pa_consult === true', { id: 'pa_claim_tool', type: 'CALL-TOOL', tool: 'jsfe:action-claim', variable: 'pa_claim', args: { record: '{{pa_record}}', action: '{{action}}' }, onFail: SET('pa_claim_failed', 'pa_claim', "({status:'hold',outcome:{status:'unknown',reason:'journal_claim_failed'}})") }),
619
+ WHEN('pa_receipt', 'pa_claim && pa_claim.receipt && pa_claim.receipt.key', SIDE('pa_durable_key', '(cargo.forms[form].actions[action.action].durableKey = pa_claim.receipt.key, true)')),
620
+ SET('pa_execute', 'pa_execute', "pa_run === true && pa_claim && pa_claim.status === 'execute'"),
621
+ WHEN('pa_reset', 'pa_execute === true', SIDE('pa_clear_result', '(cargo.actionOutcome = null, true)')),
622
+ WHEN('pa_invoke', 'pa_execute === true', FLOW('pa_execute_flow', '{{action.flow}}', 'call', { request: '{{pa_record.request}}', key: '{{pa_record.key}}' })),
623
+ WHEN('pa_complete_needed', 'pa_execute === true', { id: 'pa_complete_tool', type: 'CALL-TOOL', tool: 'jsfe:action-complete', variable: 'pa_completion', args: { record: '{{pa_record}}', action: '{{action}}', outcome: '{{cargo.actionOutcome}}' }, onFail: SET('pa_complete_failed', 'pa_completion', "({status:'unknown',outcome:{status:'unknown',reason:'journal_completion_failed'}})") }),
624
+ SET('pa_finish', 'pa_result', "pa_run === true || pa_claim && pa_claim.status === 'replay' ? finishFormAction(cargo, form, action, pa_execute === true ? pa_completion && pa_completion.outcome : pa_claim && pa_claim.outcome) : (pa_record.result || { status: pa_record.status === 'attempted' || pa_record.status === 'unknown' ? 'unknown' : 'unconfirmed' })"),
625
+ SIDE('pa_receipt_result', '(cargo.actionReceipt = (pa_execute === true ? pa_completion : pa_claim) && (pa_execute === true ? pa_completion : pa_claim).receipt || null, true)'),
626
+ { id: 'pa_applied', type: 'SET', variable: 'noop', requireTrue: true, value: 'defer_receipt === true || consumeActionReceipt(cargo, cargo.actionReceipt)' },
627
+ SIDE('pa_return', '(cargo.actionResult = pa_result, true)'),
628
+ ],
629
+ };
630
+ const sendTextAction = {
631
+ id: 'send-text-action', name: 'SendTextAction', version: '1.0.0', scope: 'isolated',
632
+ description: 'Worker: send the explicitly prepared text through the platform transport; sent or unknown result, with no automatic resend.',
633
+ parameters: [{ name: 'request', type: 'object' }, { name: 'key', type: 'string' }],
634
+ steps: [
635
+ WHEN('sta_invalid', '!readPhone(request.to)', SIDE('sta_rejected', "(cargo.actionOutcome = {status:'failed',reason:'invalid_destination'}, true)")),
636
+ WHEN('sta_stop', '!readPhone(request.to)', END('sta_invalid_end')),
637
+ SIDE('sta_destination', "(cargo.textTo = readPhone(request.to), cargo.textTo4 = last4(cargo.textTo, !!cargo.voice), true)"),
638
+ FLOW('sta_send', 'send-text', 'call', { message: '{{request.message}}' }),
639
+ SIDE('sta_outcome', "(cargo.actionOutcome = {status:cargo.textSent ? 'sent' : 'unknown'}, true)"),
640
+ ],
641
+ };
642
+ const resumeForm = { id: 'resume-form', name: 'ResumeForm', version: '1.0.0', scope: 'isolated', description: 'Resume an owned form instance with its validated progress and saved arguments.',
643
+ parameters: [{ name: 'form', type: 'string' }], variables: vars({ target: '', parent: '', slot: '' }), steps: [
644
+ 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
+ FLOW('resume_owned_form', '{{target}}', 'replace', { form_instance: '{{form}}', parent_form: '{{parent}}', result_input: '{{slot}}' })
646
+ ] };
647
+ const LIBRARY_FLOWS = [resumeForm, performAction, sendTextAction, prepareInput, runForm, collectInput, validateBatch, accept, acceptedEffects, disambiguate, confirmForm, formDone, providerMenu, ...validators, ...fieldCollectors, ...subForms, provideData, provideQueuedData, loadSource, answerFacts];
648
+ const LIBRARY_CONTRACTS = {
649
+ 'resume-form': { role: 'worker' },
650
+ 'perform-action': { role: 'worker', tools: ['jsfe:action-claim', 'jsfe:action-complete'], result: { path: 'cargo.actionResult', type: 'object' } },
651
+ 'send-text-action': { role: 'worker', result: { path: 'cargo.actionOutcome', type: 'object' } },
652
+ 'prepare-input': { role: 'worker' }, 'run-form': { role: 'worker' }, 'collect-input': { role: 'worker' },
653
+ 'validate-input-batch': { role: 'worker', result: { path: 'cargo.inputBatch', type: 'object' } },
654
+ 'collect-input-accept': { role: 'worker' }, 'input-accepted-effects': { role: 'worker' },
655
+ 'disambiguate-input': { role: 'worker' }, 'confirm-form': { role: 'worker' }, 'form-done': { role: 'worker' },
656
+ 'provider-menu': { role: 'worker' }, 'provide-data': { role: 'worker' }, 'provide-queued-data': { role: 'worker' },
657
+ 'load-source': { role: 'worker' }, 'answer-facts': { role: 'worker' },
658
+ 'validate-card-number': { role: 'worker', result: { path: 'cargo.inputCheck', type: 'object' } },
659
+ 'validate-card-expiration': { role: 'worker', result: { path: 'cargo.inputCheck', type: 'object' } },
660
+ 'collect-phone': { role: 'collector' }, 'collect-email': { role: 'collector' }, 'collect-zip': { role: 'collector' },
661
+ 'collect-street-address': { role: 'collector' }, 'collect-city': { role: 'collector' }, 'collect-card-number': { role: 'collector' },
662
+ 'collect-card-expiration': { role: 'collector' }, 'collect-card-cvv': { role: 'collector' }, 'collect-card-zip': { role: 'collector' },
663
+ 'collect-address': { role: 'collector' }, 'collect-credit-card': { role: 'collector' }
664
+ };
665
+ export const FLOWS = LIBRARY_FLOWS.map(flow => {
666
+ if (flow.variables?.contract)
667
+ return flow;
668
+ const declaration = LIBRARY_CONTRACTS[flow.id];
669
+ if (!declaration)
670
+ throw new Error('Shared worker needs an explicit contract: ' + flow.id);
671
+ if ((declaration.role === 'collector') !== (flow.metadata?.formProtocol === 2))
672
+ throw new Error('Shared collector contract mismatch: ' + flow.id);
673
+ return { ...flow, variables: { ...flow.variables, contract: { type: 'object', value: { version: 1, summary: flow.description,
674
+ parametersFrom: 'parameters', ...declaration, ...(declaration.role === 'collector' ? { form: { inputs: 'inputs' } } : {}) } } } };
675
+ });