@mj-biz-apps/marketing-server 0.0.0 → 0.2.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 (87) hide show
  1. package/LICENSE +183 -0
  2. package/dist/agents/BaseMarketingAgent.d.ts +120 -0
  3. package/dist/agents/BaseMarketingAgent.d.ts.map +1 -0
  4. package/dist/agents/BaseMarketingAgent.js +323 -0
  5. package/dist/agents/BaseMarketingAgent.js.map +1 -0
  6. package/dist/agents/MarketingExperimentationAgent.d.ts +123 -0
  7. package/dist/agents/MarketingExperimentationAgent.d.ts.map +1 -0
  8. package/dist/agents/MarketingExperimentationAgent.js +371 -0
  9. package/dist/agents/MarketingExperimentationAgent.js.map +1 -0
  10. package/dist/agents/MarketingOperationsAgent.d.ts +24 -0
  11. package/dist/agents/MarketingOperationsAgent.d.ts.map +1 -0
  12. package/dist/agents/MarketingOperationsAgent.js +36 -0
  13. package/dist/agents/MarketingOperationsAgent.js.map +1 -0
  14. package/dist/agents/MarketingPodcastIngestionAgent.d.ts +21 -0
  15. package/dist/agents/MarketingPodcastIngestionAgent.d.ts.map +1 -0
  16. package/dist/agents/MarketingPodcastIngestionAgent.js +33 -0
  17. package/dist/agents/MarketingPodcastIngestionAgent.js.map +1 -0
  18. package/dist/agents/MarketingStrategyOnboardingAgent.d.ts +206 -0
  19. package/dist/agents/MarketingStrategyOnboardingAgent.d.ts.map +1 -0
  20. package/dist/agents/MarketingStrategyOnboardingAgent.js +1264 -0
  21. package/dist/agents/MarketingStrategyOnboardingAgent.js.map +1 -0
  22. package/dist/agents/index.d.ts +14 -0
  23. package/dist/agents/index.d.ts.map +1 -0
  24. package/dist/agents/index.js +25 -0
  25. package/dist/agents/index.js.map +1 -0
  26. package/dist/config.d.ts +6 -0
  27. package/dist/config.d.ts.map +1 -0
  28. package/dist/config.js +6 -0
  29. package/dist/config.js.map +1 -0
  30. package/dist/generated/class-registrations-manifest.d.ts +7 -0
  31. package/dist/generated/class-registrations-manifest.d.ts.map +1 -0
  32. package/dist/generated/class-registrations-manifest.js +7 -0
  33. package/dist/generated/class-registrations-manifest.js.map +1 -0
  34. package/dist/generated/generated.d.ts +7 -0
  35. package/dist/generated/generated.d.ts.map +1 -0
  36. package/dist/generated/generated.js +7 -0
  37. package/dist/generated/generated.js.map +1 -0
  38. package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.d.ts +3522 -0
  39. package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.d.ts.map +1 -0
  40. package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.js +18854 -0
  41. package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.js.map +1 -0
  42. package/dist/index.d.ts +33 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +107 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/programs/ContentPostProgramDriver.d.ts +44 -0
  47. package/dist/programs/ContentPostProgramDriver.d.ts.map +1 -0
  48. package/dist/programs/ContentPostProgramDriver.js +185 -0
  49. package/dist/programs/ContentPostProgramDriver.js.map +1 -0
  50. package/dist/programs/EmailProgramDriver.d.ts +56 -0
  51. package/dist/programs/EmailProgramDriver.d.ts.map +1 -0
  52. package/dist/programs/EmailProgramDriver.js +224 -0
  53. package/dist/programs/EmailProgramDriver.js.map +1 -0
  54. package/dist/programs/google/GoogleAdsClient.d.ts +93 -0
  55. package/dist/programs/google/GoogleAdsClient.d.ts.map +1 -0
  56. package/dist/programs/google/GoogleAdsClient.js +279 -0
  57. package/dist/programs/google/GoogleAdsClient.js.map +1 -0
  58. package/dist/programs/google/GoogleAdsProgramDriver.d.ts +56 -0
  59. package/dist/programs/google/GoogleAdsProgramDriver.d.ts.map +1 -0
  60. package/dist/programs/google/GoogleAdsProgramDriver.js +357 -0
  61. package/dist/programs/google/GoogleAdsProgramDriver.js.map +1 -0
  62. package/dist/programs/google/googleAdsExecutionContext.d.ts +47 -0
  63. package/dist/programs/google/googleAdsExecutionContext.d.ts.map +1 -0
  64. package/dist/programs/google/googleAdsExecutionContext.js +128 -0
  65. package/dist/programs/google/googleAdsExecutionContext.js.map +1 -0
  66. package/dist/programs/google/googleBrandAssets.d.ts +27 -0
  67. package/dist/programs/google/googleBrandAssets.d.ts.map +1 -0
  68. package/dist/programs/google/googleBrandAssets.js +81 -0
  69. package/dist/programs/google/googleBrandAssets.js.map +1 -0
  70. package/dist/programs/index.d.ts +18 -0
  71. package/dist/programs/index.d.ts.map +1 -0
  72. package/dist/programs/index.js +27 -0
  73. package/dist/programs/index.js.map +1 -0
  74. package/dist/programs/linkedin/LinkedInAdsClient.d.ts +145 -0
  75. package/dist/programs/linkedin/LinkedInAdsClient.d.ts.map +1 -0
  76. package/dist/programs/linkedin/LinkedInAdsClient.js +408 -0
  77. package/dist/programs/linkedin/LinkedInAdsClient.js.map +1 -0
  78. package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts +94 -0
  79. package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts.map +1 -0
  80. package/dist/programs/linkedin/LinkedInAdsProgramDriver.js +449 -0
  81. package/dist/programs/linkedin/LinkedInAdsProgramDriver.js.map +1 -0
  82. package/dist/programs/linkedin/linkedInExecutionContext.d.ts +42 -0
  83. package/dist/programs/linkedin/linkedInExecutionContext.d.ts.map +1 -0
  84. package/dist/programs/linkedin/linkedInExecutionContext.js +97 -0
  85. package/dist/programs/linkedin/linkedInExecutionContext.js.map +1 -0
  86. package/package.json +46 -7
  87. package/index.js +0 -1
@@ -0,0 +1,1264 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { RegisterClass, UUIDsEqual } from "@memberjunction/global";
8
+ import { createHash } from "node:crypto";
9
+ import { BaseAgent } from "@memberjunction/ai-agents";
10
+ import { LogError, LogStatus, RunView } from "@memberjunction/core";
11
+ import { BaseMarketingAgent } from './BaseMarketingAgent.js';
12
+ import { getGuidance, decideOnboardingStep, MESSAGING_SECTION_KEY, renderSpecMarkdown, isFoundationKey, PLAN_KEYS } from '@mj-biz-apps/marketing-core';
13
+ // These are marketing-core services (they moved out of the Actions package on 2026-08-08 to
14
+ // sit where every other open app keeps services). Imported from their defining package rather
15
+ // than re-exported through Actions — see typescript-style.md, no cross-package re-exports.
16
+ import { getOnboardingSnapshot, synthesizeFoundations, synthesizeSection, createStrategyApprovalRequest, synthesizePlan, materializeStrategyVersion, recordStrategyApprovalDecision, writeFinalizeRecommendations, } from '@mj-biz-apps/marketing-core';
17
+ /**
18
+ * Marketing Strategy Onboarding Agent — driver class.
19
+ *
20
+ * Owns the onboarding SHELL (action-surface rework Phase 2): the deterministic
21
+ * grill state machine runs in code before every prompt turn, not as an
22
+ * LLM-invoked action. Each turn this driver:
23
+ *
24
+ * 1. Loads the DB snapshot (strategy header → current version → Foundation
25
+ * confidences) and the conversation-threaded counters from the payload.
26
+ * 2. Asks `decideOnboardingStep` (pure, marketing-core) what happens next.
27
+ * 3. If the machine says synthesize AND the payload carries gathered
28
+ * evidence (`payload.rawSources`), it runs the synthesis service RIGHT
29
+ * HERE — LLM extraction + StrategySection persistence — then re-consults
30
+ * the machine, clears the consumed raw evidence (Rev 5 phase rule:
31
+ * nothing downstream may read raw source text), and records a
32
+ * `payload.lastSynthesis` summary for the prompt to narrate.
33
+ * 4. Publishes the resulting STATE as `payload.onboarding` (phase, weak vs
34
+ * settled sections, what has been synthesized, approval status). The
35
+ * template branches on that state; it is never handed a command.
36
+ *
37
+ * That last point is the Skip pattern and it is load-bearing. Skip's agents
38
+ * branch their prompts on payload the model can see and verify
39
+ * (`{% if _CURRENT_PAYLOAD.changeRequest %}`), and only ever inject evidence
40
+ * into preparePromptParams. An injected imperative is a second control channel
41
+ * with no defined behavior when it is missing — the hole two runaway-loop
42
+ * incidents fell through. State always has a branch.
43
+ *
44
+ * The LLM keeps exactly what it is good at: choosing evidence tools, asking
45
+ * sharp questions, and narrating. Flow control is code.
46
+ *
47
+ * The former `Check Onboarding Progress`, `Synthesize Foundations`, and
48
+ * `Synthesize Strategy Section` actions are retired; their engines live in
49
+ * `@mj-biz-apps/marketing-core` strategyOnboardingService.
50
+ *
51
+ * Brand context comes from BaseMarketingAgent's ladder (brand is the unit of
52
+ * marketing scope and of authorization); the onboarding-questions guidance is
53
+ * injected at start.
54
+ *
55
+ * Admin-only (onboarding governance — strategy artifacts are Leadership-Set).
56
+ */
57
+ /**
58
+ * Hard cap on shell passes within one run. Skip's drivers bound their loops with
59
+ * a literal MAX_* constant and a break rather than trusting the model
60
+ * (unified-code-generator's MAX_FIX_ITERATIONS, query-design's attemptsAllowed).
61
+ * A conversational turn needs 2-3 passes; more means the state stopped moving.
62
+ */
63
+ const MAX_SHELL_PASSES_PER_RUN = 6;
64
+ /**
65
+ * How many of the newest conversation details to scan when resolving the
66
+ * artifact version the founder approved. Artifacts are linked per-detail and
67
+ * we only ever want the newest one, so a bounded newest-first window is both
68
+ * correct and keeps the generated `IN (...)` filter from growing with the
69
+ * length of the conversation.
70
+ */
71
+ const RECENT_DETAILS_SCANNED_FOR_ARTIFACT = 200;
72
+ const PHASE_BY_ACTION = {
73
+ 'synthesize-foundations': 'Gather',
74
+ 'grill': 'Grill',
75
+ 'synthesize-strategy-section': 'Grill',
76
+ 'finalize-strategy-onboarding': 'ReadyForApproval',
77
+ 'complete': 'Complete',
78
+ };
79
+ let MarketingStrategyOnboardingAgent = class MarketingStrategyOnboardingAgent extends BaseMarketingAgent {
80
+ async initializeStartingPayload(params) {
81
+ await super.initializeStartingPayload(params);
82
+ const payload = params.payload;
83
+ if (!payload)
84
+ return;
85
+ if (!payload.guidanceOnboardingQuestions) {
86
+ payload.guidanceOnboardingQuestions = await getGuidance('onboarding-questions', params.contextUser);
87
+ }
88
+ // Defense in depth: the framework's absolute iteration ceiling is 5000,
89
+ // which is runaway territory for a conversational onboarding turn.
90
+ // Belt to the agent record's MaxIterationsPerRun (the knob Skip sizes
91
+ // per agent, 3-25). This is the absolute net, kept low for a chat turn.
92
+ params.absoluteMaxIterations = Math.min(params.absoluteMaxIterations ?? 12, 12);
93
+ }
94
+ async preparePromptParams(config, payload, params) {
95
+ // Shell runs BEFORE the base copies payload → prompt data, so this
96
+ // turn's state and synthesis results reach the template render.
97
+ await this.runOnboardingShell(payload, params);
98
+ return super.preparePromptParams(config, payload, params);
99
+ }
100
+ /**
101
+ * Deterministic brand resolution for multi-brand users. The base ladder
102
+ * sets `authorizedBrands` (instead of `brandId`) when the user is
103
+ * authorized for several brands, and the prompt can't be trusted to write
104
+ * `brandId` back — so when the founder has NAMED one of those brands
105
+ * anywhere in the conversation, the shell resolves it itself.
106
+ * Longest-name match wins ("Acme Health" beats "Acme").
107
+ *
108
+ * Brand names are only unique WITHIN a company, so a tie between two
109
+ * equally-long matched names is broken by the parent company name also
110
+ * appearing in the conversation. Still ambiguous ⇒ resolve nothing and
111
+ * let the agent ask; guessing here would write a strategy onto the wrong
112
+ * tenant's brand.
113
+ */
114
+ /** Is this brand one the caller actually holds a grant for? */
115
+ isAuthorizedBrand(payload, brandId) {
116
+ const brands = Array.isArray(payload.authorizedBrands) ? payload.authorizedBrands : [];
117
+ return brands.some(b => !!b && typeof b === 'object'
118
+ && typeof b.brandId === 'string'
119
+ && UUIDsEqual(b.brandId, brandId));
120
+ }
121
+ resolveBrandFromConversation(payload, params) {
122
+ const brands = (Array.isArray(payload.authorizedBrands) ? payload.authorizedBrands : [])
123
+ .filter((b) => !!b && typeof b === 'object');
124
+ if (brands.length === 0)
125
+ return undefined;
126
+ const rawText = (params.conversationMessages ?? [])
127
+ .filter(m => m.role === 'user')
128
+ .map(m => typeof m.content === 'string' ? m.content : m.content.map(b => b.type === 'text' ? b.content : '').join(' '))
129
+ .join('\n');
130
+ if (!rawText)
131
+ return undefined;
132
+ const text = rawText.toLowerCase();
133
+ // The AwaitingBrand form is a buttongroup whose option VALUES are brand
134
+ // ids, so a founder who clicks a button sends the UUID, not the name.
135
+ // Observed live: answering "Skip" via the button produced
136
+ // "Which brand are we onboarding?: B1CBC003-…" and name-matching found
137
+ // nothing, so the shell stayed in AwaitingBrand while the model went
138
+ // ahead and claimed the brand on its own. Match the id FIRST — it is
139
+ // exact and unambiguous — then fall back to the name.
140
+ // Pull out any UUID-shaped tokens and compare them properly. Extract-
141
+ // then-compare rather than substring-matching a lowercased blob: MJ's
142
+ // UUIDsEqual is the sanctioned comparison (SQL Server returns
143
+ // uppercase, Postgres lowercase, so `===` silently fails across
144
+ // platforms — see guides/UUID_COMPARISON_GUIDE.md).
145
+ const uuidsInText = rawText.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi) ?? [];
146
+ for (const token of uuidsInText) {
147
+ const hit = brands.find(b => typeof b.brandId === 'string' && UUIDsEqual(b.brandId, token));
148
+ if (hit)
149
+ return hit.brandId;
150
+ }
151
+ const matches = [];
152
+ for (const b of brands) {
153
+ const name = typeof b.name === 'string' ? b.name.trim().toLowerCase() : '';
154
+ const id = typeof b.brandId === 'string' ? b.brandId : undefined;
155
+ if (!id || name.length < 3)
156
+ continue;
157
+ if (text.includes(name)) {
158
+ matches.push({ id, len: name.length, companyName: typeof b.companyName === 'string' ? b.companyName.trim().toLowerCase() : '' });
159
+ }
160
+ }
161
+ if (matches.length === 0)
162
+ return undefined;
163
+ const longest = Math.max(...matches.map(m => m.len));
164
+ const top = matches.filter(m => m.len === longest);
165
+ if (top.length === 1)
166
+ return top[0].id;
167
+ const byCompany = top.filter(m => m.companyName.length >= 3 && text.includes(m.companyName));
168
+ return byCompany.length === 1 ? byCompany[0].id : undefined;
169
+ }
170
+ /**
171
+ * The founder's own words ARE the interview evidence. Reading them here
172
+ * (rather than asking the model to copy them into the payload and yield)
173
+ * removes the round-trip that made the loop possible: by the time the
174
+ * prompt runs, synthesis has already happened and the model's only job is
175
+ * to talk to the founder.
176
+ *
177
+ * Turns already folded into the running objective are skipped, so a
178
+ * re-render of the same turn can't double-count them.
179
+ */
180
+ deriveInterviewEvidence(payload, params) {
181
+ const consumed = asStringArray(payload.consumedUserTurns);
182
+ const out = [];
183
+ const messages = (params.conversationMessages ?? []).filter(m => m.role === 'user');
184
+ messages.forEach((m) => {
185
+ const text = typeof m.content === 'string'
186
+ ? m.content
187
+ : m.content.map(b => b.type === 'text' ? b.content : '').join(' ');
188
+ const body = text.trim();
189
+ if (body.length < 20)
190
+ return; // "yes" / "idk" carry no evidence
191
+ // P7: content-addressed, so a shifted message window can't cause a
192
+ // re-ingest (double-counted evidence) or a skip (lost answer).
193
+ const key = `turn-${createHash('sha1').update(body).digest('hex').slice(0, 12)}`;
194
+ if (consumed.includes(key))
195
+ return;
196
+ out.push({ channel: 'interview', label: 'Founder input', locator: key, text: body });
197
+ });
198
+ // NOTE: deliberately does NOT mark these consumed. The shell commits
199
+ // them only after a synthesis actually ingests them (commitConsumedTurns),
200
+ // because marking here discarded the founder's answers on any turn where
201
+ // the state machine chose to grill instead of synthesize.
202
+ return out;
203
+ }
204
+ /**
205
+ * Enforce the interview contract in CODE, not in the prompt.
206
+ *
207
+ * Skip's drivers validate the model's proposed next step and correct it
208
+ * (SkipRequirementsExpertAgent.validateChatNextStep demotes Chat->Success in
209
+ * update mode). A prompt rule is advice the model may ignore; this is the
210
+ * layer where an invariant actually holds.
211
+ *
212
+ * Two invariants:
213
+ * - In Grill, a reply MUST carry the batched questions. Observed live: the
214
+ * model narrated the synthesis and said "I'll start with Customer
215
+ * Journey" with no form attached, burning the founder's turn.
216
+ * - In AwaitingBrand, a reply MUST offer the brand choices, or the founder
217
+ * is asked a question with no way to answer it that the shell can read.
218
+ *
219
+ * A corrective Retry carries an errorMessage, so it counts against the
220
+ * framework's consecutive-unproductive-retry cap (10) and cannot loop.
221
+ */
222
+ /**
223
+ * P2 — terminate on Complete.
224
+ *
225
+ * `Chat` always terminates the TURN, but the run only *succeeds* on a
226
+ * Success step, and `FinalPayloadValidation` is evaluated nowhere else. An
227
+ * interview that can never emit Success can never be validated and never
228
+ * signals completion to a caller.
229
+ */
230
+ async validateChatNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
231
+ const payload = currentPayload;
232
+ const state = payload?.onboarding;
233
+ const phase = typeof state?.phase === 'string' ? state.phase : null;
234
+ if (phase === 'Complete') {
235
+ LogStatus('OnboardingShell: interview complete — ending the run with Success');
236
+ return { ...nextStep, step: 'Success', terminate: true };
237
+ }
238
+ return this.enforceInterviewContract(params, nextStep, currentPayload, agentRun, currentStep, phase, state);
239
+ }
240
+ /** Grill/AwaitingBrand replies must carry the form that makes them answerable. */
241
+ async enforceInterviewContract(params, nextStep, currentPayload, agentRun, currentStep, phase, state) {
242
+ const hasForm = !!nextStep.responseForm;
243
+ if (!hasForm && phase === 'Grill') {
244
+ const weak = Array.isArray(state?.weakSections) ? state.weakSections.length : 0;
245
+ if (weak > 0) {
246
+ LogStatus('OnboardingShell: rejecting a Grill reply with no responseForm — asking the model to attach the questions');
247
+ return {
248
+ ...nextStep,
249
+ step: 'Retry',
250
+ terminate: false,
251
+ errorMessage: `You are in the Grill phase with ${weak} Weak section(s) but attached no responseForm. ` +
252
+ `Do not announce that you will ask questions — ask them now: one sharp question per Weak ` +
253
+ `section (max five), every field optional, each with a placeholder, in a single responseForm ` +
254
+ `alongside your message.`,
255
+ retryInstructions: `You are in the Grill phase with ${weak} Weak section(s) but attached no responseForm. ` +
256
+ `Do not announce that you will ask questions — ask them now: one sharp question per Weak ` +
257
+ `section (max five), every field optional, each with a placeholder, in a single responseForm ` +
258
+ `alongside your message.`,
259
+ };
260
+ }
261
+ }
262
+ if (!hasForm && phase === 'AwaitingBrand') {
263
+ const choices = Array.isArray(state?.brandChoices)
264
+ ? state.brandChoices.length : 0;
265
+ if (choices > 1) {
266
+ LogStatus('OnboardingShell: rejecting an AwaitingBrand reply with no brand buttons');
267
+ return {
268
+ ...nextStep,
269
+ step: 'Retry',
270
+ terminate: false,
271
+ errorMessage: 'You asked which brand without attaching a responseForm. Offer the authorized brands as a ' +
272
+ 'buttongroup whose option values are the brand ids, so the answer can be read deterministically.',
273
+ retryInstructions: 'You asked which brand without attaching a responseForm. Offer the authorized brands as a ' +
274
+ 'buttongroup whose option values are the brand ids, so the answer can be read deterministically.',
275
+ };
276
+ }
277
+ }
278
+ return super.validateChatNextStep(params, nextStep, currentPayload, agentRun, currentStep);
279
+ }
280
+ /**
281
+ * P3 — ban the bare model Retry in CODE, not in prose.
282
+ *
283
+ * The framework's unproductive-retry cap only counts retries that carry an
284
+ * errorMessage (base-agent: `step==='Retry' && !terminate && !!errorMessage`).
285
+ * A model that emits a naked Retry is therefore exempt — that exemption is
286
+ * what let the 2026-08-05/06 loops run until the heap died. The prompt now
287
+ * forbids Retry, but a prompt is advice; this is the enforcement.
288
+ *
289
+ * Framework-generated retries are legitimate and must pass through: action
290
+ * results, artifact tools, context recovery and FinalPayloadValidation all
291
+ * carry retryInstructions or an errorMessage.
292
+ */
293
+ async validateRetryNextStep(params, nextStep, currentPayload, agentRun, currentStep) {
294
+ if (!nextStep.errorMessage && !nextStep.retryInstructions) {
295
+ LogError('OnboardingShell: demoting a bare model-emitted Retry to Chat — this agent has no state a retry advances');
296
+ const spoken = nextStep.message;
297
+ return {
298
+ ...nextStep,
299
+ step: 'Chat',
300
+ terminate: true,
301
+ message: spoken && spoken.trim().length > 0
302
+ ? spoken
303
+ : "Let me make sure I've got that — tell me a bit more and I'll fold it in.",
304
+ };
305
+ }
306
+ return super.validateRetryNextStep(params, nextStep, currentPayload, agentRun, currentStep);
307
+ }
308
+ /**
309
+ * P1 — read the founder's ACTUAL answer to the approval form.
310
+ *
311
+ * A depth-0 Chat step with a responseForm creates an `MJ: AI Agent Requests`
312
+ * row carrying the form schema; the founder's reply lands in `Response`.
313
+ * Reading it is the deterministic equivalent of Skip's
314
+ * IntentDetector.checkFormResponseForApproval, which parses the form token
315
+ * and lets an explicit choice BLOCK any LLM re-interpretation. Until now
316
+ * nothing in this app read that answer at all — the approve/revise buttons
317
+ * were decoration.
318
+ */
319
+ async readPriorFormAnswer(params) {
320
+ if (!params.lastRunId)
321
+ return null;
322
+ try {
323
+ const rv = new RunView();
324
+ const res = await rv.RunView({
325
+ EntityName: 'MJ: AI Agent Requests',
326
+ ExtraFilter: `OriginatingAgentRunID='${params.lastRunId}'`,
327
+ Fields: ['Response', 'Status'],
328
+ OrderBy: '__mj_CreatedAt DESC',
329
+ MaxRows: 1,
330
+ ResultType: 'simple',
331
+ }, params.contextUser);
332
+ const row = res.Success ? res.Results?.[0] : null;
333
+ if (!row?.Response)
334
+ return null;
335
+ const parsed = JSON.parse(row.Response);
336
+ return parsed && typeof parsed === 'object' ? parsed : null;
337
+ }
338
+ catch {
339
+ return null; // a malformed answer must never break the interview
340
+ }
341
+ }
342
+ /**
343
+ * Read the founder's form answer from where Explorer ACTUALLY delivers it.
344
+ *
345
+ * A responseForm click does not fill `MJ: AI Agent Requests`.`Response`
346
+ * (verified live: every row stays `Requested`/NULL). It arrives as a USER
347
+ * conversation message whose body is `@`-prefixed JSON:
348
+ *
349
+ * @{"_mode":"form","action":"formSubmit","fields":[
350
+ * {"name":"action","value":"approved",...},
351
+ * {"name":"feedback","value":null,...}]}
352
+ *
353
+ * Note the outer `action` is the transport verb ("formSubmit") — the
354
+ * founder's answer lives in `fields[]`. This flattens those into the
355
+ * {action, feedback, ...} record the approval flow reads. The most recent
356
+ * form answer wins; founder chit-chat after it does not cancel it (the
357
+ * materialized-once guard makes a re-applied approve a no-op).
358
+ */
359
+ async readFormAnswerFromConversation(params) {
360
+ // Read the stored rows, NOT params.conversationMessages. The
361
+ // conversation layer replaces a form message's raw JSON with its
362
+ // display text before the agent runs (verified live: the prompt
363
+ // payload for an approve turn contains zero occurrences of "_mode"
364
+ // while the ConversationDetail row carries the full formSubmit JSON).
365
+ // The in-memory transcript is a rendering; the table is the truth.
366
+ const conversationId = this.AgentRun?.ConversationID ?? null;
367
+ if (!conversationId)
368
+ return this.readFormAnswerFromTranscript(params);
369
+ try {
370
+ const rv = new RunView();
371
+ const res = await rv.RunView({
372
+ EntityName: 'MJ: Conversation Details',
373
+ ExtraFilter: `ConversationID='${conversationId}' AND Role='User'`,
374
+ OrderBy: '__mj_CreatedAt DESC',
375
+ MaxRows: 10,
376
+ Fields: ['Message'],
377
+ ResultType: 'simple',
378
+ }, params.contextUser);
379
+ if (!res.Success) {
380
+ LogError(`OnboardingShell: form-answer read failed (${res.ErrorMessage}) — falling back to transcript scan`);
381
+ return this.readFormAnswerFromTranscript(params);
382
+ }
383
+ for (const row of res.Results ?? []) {
384
+ if (!row.Message)
385
+ continue;
386
+ const flattened = this.parseFormSubmitMessage(row.Message);
387
+ if (flattened) {
388
+ LogStatus(`OnboardingShell: form answer from ConversationDetail — keys [${Object.keys(flattened).join(', ')}], action=${String(flattened.action ?? '(none)')}`);
389
+ return flattened;
390
+ }
391
+ }
392
+ LogStatus('OnboardingShell: no formSubmit among the latest user messages');
393
+ return null;
394
+ }
395
+ catch (err) {
396
+ LogError(`OnboardingShell: form-answer read threw (${err instanceof Error ? err.message : err}) — falling back to transcript scan`);
397
+ return this.readFormAnswerFromTranscript(params);
398
+ }
399
+ }
400
+ /** Fallback only: scan the in-memory transcript (known to be display-text in Explorer). */
401
+ readFormAnswerFromTranscript(params) {
402
+ const userMessages = (params.conversationMessages ?? []).filter(m => m.role === 'user');
403
+ for (let i = userMessages.length - 1; i >= 0; i--) {
404
+ const raw = typeof userMessages[i].content === 'string'
405
+ ? userMessages[i].content
406
+ : userMessages[i].content
407
+ .map(b => b.type === 'text' ? (b.content ?? '') : '').join(' ');
408
+ const flattened = this.parseFormSubmitMessage(raw);
409
+ if (flattened)
410
+ return flattened;
411
+ }
412
+ return null;
413
+ }
414
+ /** Parse one message as an Explorer formSubmit; null if it isn't one. */
415
+ parseFormSubmitMessage(raw) {
416
+ const text = raw.trim();
417
+ if (!text.includes('"_mode"') || !text.includes('"form"'))
418
+ return null;
419
+ try {
420
+ const jsonText = text.startsWith('@') ? text.slice(1) : text;
421
+ const parsed = JSON.parse(jsonText);
422
+ if (!parsed || typeof parsed !== 'object')
423
+ return null;
424
+ const obj = parsed;
425
+ if (obj._mode !== 'form' || !Array.isArray(obj.fields))
426
+ return null;
427
+ const out = {};
428
+ for (const f of obj.fields) {
429
+ if (f && typeof f === 'object'
430
+ && typeof f.name === 'string') {
431
+ out[f.name] = f.value;
432
+ }
433
+ }
434
+ return out;
435
+ }
436
+ catch {
437
+ return null; // a malformed form message must never break the interview
438
+ }
439
+ }
440
+ /**
441
+ * P1 — finalize intent, derived fresh EVERY turn and never latched.
442
+ *
443
+ * The previous one-way flag meant a single false positive disabled grilling
444
+ * and the messaging gate permanently. Skip's rules, ported: a long message
445
+ * is almost always a change request, and "approval + but…" is a change
446
+ * request, so negations and hedges near the trigger disqualify it.
447
+ */
448
+ founderAskedToFinalize(params) {
449
+ const last = (params.conversationMessages ?? []).filter(m => m.role === 'user').pop();
450
+ if (!last)
451
+ return false;
452
+ const text = (typeof last.content === 'string'
453
+ ? last.content
454
+ : last.content.map(b => b.type === 'text' ? b.content : '').join(' ')).trim();
455
+ if (!text)
456
+ return false;
457
+ // Long replies are answers, not sign-offs (Skip: "True approvals are SHORT").
458
+ if (text.length > 240)
459
+ return false;
460
+ const trigger = /\b(finali[sz]e|wrap (it |this )?up|we're done|that's everything)\b/i;
461
+ const m = trigger.exec(text);
462
+ if (!m)
463
+ return false;
464
+ const around = text.slice(Math.max(0, m.index - 40), m.index + m[0].length + 40);
465
+ if (/\b(not|don'?t|isn'?t|before we|unless|when we|once we|after we)\b/i.test(around))
466
+ return false;
467
+ return true;
468
+ }
469
+ /**
470
+ * P15 — keep the giant blobs out of the step log.
471
+ *
472
+ * MJ serializes the payload into PayloadAtStart/PayloadAtEnd on EVERY step.
473
+ * Ours carries whole scraped pages in `rawSources[].text`, the accumulated
474
+ * founder transcript, and the fully rendered strategy markdown — so a long
475
+ * interview writes megabytes of duplicated text per turn. Skip does the
476
+ * same projection on its two heaviest agents (base-skip-agent
477
+ * shouldSummarizePayload / summarizePayload).
478
+ *
479
+ * This affects LOGGING ONLY. The live payload is untouched.
480
+ */
481
+ serializePayloadAtStart(payload) {
482
+ return this.summarizeForLog(payload);
483
+ }
484
+ serializePayloadAtEnd(payload) {
485
+ return this.summarizeForLog(payload);
486
+ }
487
+ summarizeForLog(payload) {
488
+ if (!payload || typeof payload !== 'object')
489
+ return payload ? JSON.stringify(payload) : null;
490
+ try {
491
+ const clone = { ...payload };
492
+ if (Array.isArray(clone.rawSources)) {
493
+ clone.rawSources = clone.rawSources.map(src => {
494
+ if (!src || typeof src !== 'object')
495
+ return src;
496
+ const e = src;
497
+ const text = typeof e.text === 'string' ? e.text : '';
498
+ return { ...e, text: `[${text.length} chars elided for logging]` };
499
+ });
500
+ }
501
+ const spec = clone.spec;
502
+ if (spec && typeof spec.displayMarkdown === 'string') {
503
+ clone.spec = { ...spec, displayMarkdown: `[${spec.displayMarkdown.length} chars elided for logging]` };
504
+ }
505
+ for (const k of ['guidanceOnboardingQuestions', 'augmentedBusinessObjective']) {
506
+ const v = clone[k];
507
+ if (typeof v === 'string' && v.length > 2000)
508
+ clone[k] = `${v.slice(0, 2000)}…[${v.length} chars total]`;
509
+ }
510
+ return JSON.stringify(clone);
511
+ }
512
+ catch {
513
+ return JSON.stringify(payload);
514
+ }
515
+ }
516
+ /** Mark derived turns consumed — only after a synthesis has ingested them. */
517
+ commitConsumedTurns(payload, evidence) {
518
+ const consumed = asStringArray(payload.consumedUserTurns);
519
+ const locators = evidence.filter(e => e.channel === 'interview').map(e => e.locator);
520
+ if (locators.length > 0) {
521
+ payload.consumedUserTurns = Array.from(new Set([...consumed, ...locators]));
522
+ }
523
+ }
524
+ /**
525
+ * Deterministic finalize-intent detection on the LATEST founder message
526
+ * only (not history — an early "we'll finalize later" must not trigger).
527
+ * The LLM-set payload flag is the primary channel; this catches the
528
+ * explicit phrasings even when the LLM fails to set it.
529
+ */
530
+ latestUserMessageRequestsFinalize(params) {
531
+ const last = (params.conversationMessages ?? []).filter(m => m.role === 'user').pop();
532
+ if (!last)
533
+ return false;
534
+ const text = typeof last.content === 'string'
535
+ ? last.content
536
+ : last.content.map(b => b.type === 'text' ? b.content : '').join(' ');
537
+ return /\b(finali[sz]e|wrap (it |this )?up|we're done|that's everything)\b/i.test(text);
538
+ }
539
+ /** The deterministic onboarding shell. Fail-open: an error here logs and lets the turn proceed undirected. */
540
+ async runOnboardingShell(payload, params) {
541
+ if (!payload || !params.contextUser)
542
+ return;
543
+ // Runaway guard. The prompt's staging contract ends in `step: "Retry"`,
544
+ // so ANY turn where the shell fails to move the state forward becomes an
545
+ // infinite prompt loop that ends in an OOM crash (2026-08-06 incident;
546
+ // the 2026-08-05 one was the same shape). Count shell passes within the
547
+ // run and hand control back to the founder well before the framework's
548
+ // iteration ceiling.
549
+ // Per-RUN, not per-conversation: the payload round-trips between turns,
550
+ // so the counter has to reset when a new run starts or a normal
551
+ // multi-turn interview would trip the cap and halt itself.
552
+ const runId = this.AgentRun?.ID ?? null;
553
+ if (payload.shellRunId !== runId) {
554
+ payload.shellRunId = runId;
555
+ payload.shellTurns = 0;
556
+ }
557
+ const shellTurns = asNumber(payload.shellTurns) + 1;
558
+ payload.shellTurns = shellTurns;
559
+ if (shellTurns > MAX_SHELL_PASSES_PER_RUN) {
560
+ payload.rawSources = [];
561
+ this.publishState(payload, {
562
+ phase: 'Blocked',
563
+ note: `The interview engine ran ${shellTurns} passes in one turn without settling. Summarize what you have and ask the founder one question.`,
564
+ });
565
+ return;
566
+ }
567
+ // Either location is durable; prefer the published state so the model's
568
+ // view and the shell's view are read from one place.
569
+ const priorState = payload.onboarding;
570
+ const claimed = typeof payload.brandId === 'string'
571
+ ? payload.brandId
572
+ : (typeof priorState?.brandId === 'string' ? priorState.brandId : undefined);
573
+ // SECURITY: anything in the payload may have been written by the model
574
+ // (it returns payloadChangeRequests). A brand id is a SCOPE, so accept
575
+ // it only if it is one of this caller's authorized brands — otherwise a
576
+ // model that names a sibling brand could widen its own access.
577
+ let brandId = claimed && this.isAuthorizedBrand(payload, claimed) ? claimed : undefined;
578
+ if (claimed && !brandId) {
579
+ LogError(`OnboardingShell: ignoring unauthorized brandId in payload (${claimed}) — not among the caller's grants`);
580
+ payload.brandId = undefined;
581
+ }
582
+ if (brandId) {
583
+ payload.brandId = brandId;
584
+ if (typeof payload.brandName !== 'string') {
585
+ await this.hydrateBrandContext(payload, params.contextUser);
586
+ brandId = typeof payload.brandId === 'string' ? payload.brandId : brandId;
587
+ }
588
+ }
589
+ if (!brandId) {
590
+ const named = this.resolveBrandFromConversation(payload, params);
591
+ if (named) {
592
+ payload.brandId = named;
593
+ // Late resolution: pull brandName + the derived parent companyId
594
+ // from the Brand row the same way the base ladder would have.
595
+ // (Clears payload.brandId again if the row doesn't resolve.)
596
+ await this.hydrateBrandContext(payload, params.contextUser);
597
+ brandId = typeof payload.brandId === 'string' ? payload.brandId : undefined;
598
+ }
599
+ }
600
+ if (!brandId) {
601
+ // No brand = no snapshot = no directive. Say so explicitly instead
602
+ // of leaving the prompt undirected, which loops on the staging rule.
603
+ payload.rawSources = [];
604
+ const choices = this.brandChoiceLabels(payload);
605
+ this.publishState(payload, {
606
+ phase: 'AwaitingBrand',
607
+ brandChoices: choices,
608
+ note: choices.length > 0
609
+ ? 'Which brand this strategy is for has not been established yet.'
610
+ : 'No brand is resolved and this user has no authorized brands visible.',
611
+ });
612
+ return;
613
+ }
614
+ payload.shellError = null;
615
+ // brandName feeds the spec header, artifact name, and approval-task
616
+ // title. hydrateBrandContext normally sets it; fall back to the
617
+ // authorized-brand list (it carries names) so it never sticks at the
618
+ // 'the brand' placeholder once the brand is known.
619
+ if (typeof payload.brandName !== 'string' && Array.isArray(payload.authorizedBrands)) {
620
+ const match = payload.authorizedBrands.find((b) => !!b && typeof b === 'object' && b.brandId === brandId);
621
+ if (match && typeof match.name === 'string' && match.name.trim())
622
+ payload.brandName = match.name.trim();
623
+ }
624
+ // Refresh a spec that was created before the brand resolved — the
625
+ // placeholder header must never survive once the real name is known.
626
+ const specEarly = payload.spec;
627
+ if (specEarly && typeof payload.brandName === 'string' && payload.brandName.trim() && specEarly.brandName !== payload.brandName) {
628
+ specEarly.brandName = payload.brandName;
629
+ specEarly.name = `${payload.brandName} — Marketing Strategy`;
630
+ if (specEarly.readiness !== 'approved') {
631
+ specEarly.description = `Draft marketing strategy for ${payload.brandName}, produced by the strategy onboarding interview.`;
632
+ }
633
+ specEarly.displayMarkdown = renderSpecMarkdown(specEarly);
634
+ }
635
+ // P1 — the founder's explicit form answer outranks everything, and
636
+ // finalize intent is recomputed every turn rather than latched.
637
+ // The conversation is the real delivery channel for form clicks; the
638
+ // AgentRequest read is kept as a fallback for transports that do fill
639
+ // `Response` (it was the ONLY read until the live approve proved the
640
+ // rows stay NULL and the approval was silently ignored).
641
+ const formAnswer = await this.readFormAnswerFromConversation(params)
642
+ ?? await this.readPriorFormAnswer(params);
643
+ const formAction = typeof formAnswer?.action === 'string' ? formAnswer.action : null;
644
+ const formFeedback = typeof formAnswer?.feedback === 'string' ? formAnswer.feedback.trim() : '';
645
+ payload.founderApproved = formAction === 'approved';
646
+ // Notes typed alongside an approval are approval provenance, not a
647
+ // revision request — they get stamped on the published version.
648
+ if (formAction === 'approved' && formFeedback)
649
+ payload.approvalNotes = formFeedback;
650
+ if (formAction === 'revise') {
651
+ // An explicit "revise" is a change request — it must clear any
652
+ // finalize intent, not sit alongside it.
653
+ payload.founderRequestedFinalize = false;
654
+ if (formFeedback) {
655
+ payload.augmentedBusinessObjective =
656
+ `${String(payload.augmentedBusinessObjective ?? '')}\n[Founder revision request]: ${formFeedback}`.trim();
657
+ }
658
+ }
659
+ else {
660
+ payload.founderRequestedFinalize = this.founderAskedToFinalize(params);
661
+ }
662
+ try {
663
+ const state = {
664
+ brandId,
665
+ grillTurnsSinceLastSynth: asNumber(payload.grillTurnsSinceLastSynth),
666
+ lastSynthSectionKey: typeof payload.lastSynthSectionKey === 'string' ? payload.lastSynthSectionKey : null,
667
+ sectionsAlreadyResynthed: asStringArray(payload.sectionsAlreadyResynthed),
668
+ messagingGrillTurns: asNumber(payload.messagingGrillTurns),
669
+ founderRequestedFinalize: payload.founderRequestedFinalize === true,
670
+ founderApproved: payload.founderApproved === true
671
+ || payload.spec?.readiness === 'approved',
672
+ sectionAskCounts: asAskCounts(payload.sectionAskCounts),
673
+ };
674
+ // Evidence is derived HERE, in code — the prompt is never asked to
675
+ // stage it and hand control back. (The old contract was "LLM writes
676
+ // payload.rawSources, returns Retry, shell synthesizes"; a Retry that
677
+ // carries no errorMessage is exempt from the framework's
678
+ // unproductive-retry cap, so any turn where the shell failed to
679
+ // advance looped until the heap died. Twice.)
680
+ const evidence = [...asEvidence(payload.rawSources), ...this.deriveInterviewEvidence(payload, params)];
681
+ const objective = String(payload.augmentedBusinessObjective ?? payload.businessObjective ?? '');
682
+ const agentRunId = this.AgentRun?.ID ?? null;
683
+ let snapshot = await getOnboardingSnapshot(brandId, params.contextUser);
684
+ let decision = decideOnboardingStep(snapshot, state);
685
+ // Pre-approval gate (shell half): if the founder tried to finalize or approve
686
+ // past a Foundation that has no draft section, withdraw that intent — on the
687
+ // payload AND on the state any re-decision below reads — so nothing publishes
688
+ // until the missing sections exist and the document is re-presented.
689
+ if (decision.absentSections.length > 0 && (payload.founderApproved === true || payload.founderRequestedFinalize === true)) {
690
+ payload.founderApproved = false;
691
+ payload.founderRequestedFinalize = false;
692
+ state.founderApproved = false;
693
+ state.founderRequestedFinalize = false;
694
+ LogStatus(`OnboardingShell: finalize/approval withheld — ${decision.absentSections.join(', ')} have no draft section yet; synthesizing first`);
695
+ }
696
+ // BATCHED-QUESTION OVERRIDE.
697
+ // decideOnboardingStep still counts grill turns one-question-at-a-time
698
+ // (GRILL_BUDGET turns per section before a re-synth). We now ask every
699
+ // Weak section in ONE responseForm, so a single founder reply answers
700
+ // what used to take several turns — and waiting for another round
701
+ // leaves fresh, high-signal answers unused while the agent asks again.
702
+ // When the founder has just supplied interview evidence and anything
703
+ // is still Weak, synthesize NOW.
704
+ const freshInterview = evidence.some(e => e.channel === 'interview');
705
+ if (freshInterview && decision.nextAction === 'grill' && payload.synthesisDisabled !== true) {
706
+ LogStatus('OnboardingShell: fresh founder answers — re-synthesizing instead of grilling again');
707
+ decision = { ...decision, nextAction: 'synthesize-foundations' };
708
+ }
709
+ // Failure cap: after 2 failed synthesis attempts, disable automatic
710
+ // synthesis for this conversation and clear the staged evidence so the
711
+ // prompt's "return Retry" instruction can't loop (the 240-iteration
712
+ // burn of 2026-08-05 is the incident this guards).
713
+ const synthesisDisabled = payload.synthesisDisabled === true;
714
+ const wantsSynthesis = decision.nextAction === 'synthesize-foundations' || decision.nextAction === 'synthesize-strategy-section';
715
+ if (wantsSynthesis && !synthesisDisabled && asNumber(payload.synthesisAttempts) >= 2) {
716
+ payload.synthesisDisabled = true;
717
+ payload.rawSources = [];
718
+ payload.shellError = 'Automatic synthesis disabled after 2 failed attempts — continue the interview manually and flag the infrastructure issue.';
719
+ LogError('OnboardingShell: synthesis disabled after 2 failed attempts');
720
+ }
721
+ if (decision.nextAction === 'synthesize-foundations' && evidence.length > 0 && payload.synthesisDisabled !== true) {
722
+ LogStatus(`OnboardingShell: running foundation synthesis (${evidence.length} evidence source(s))`);
723
+ payload.synthesisAttempts = asNumber(payload.synthesisAttempts) + 1;
724
+ // Only re-persist sections that are still Weak. A first
725
+ // synthesis has no snapshot to protect (weakSections is empty
726
+ // and every slot is new), so the guard applies solely to
727
+ // re-synthesis — where overwriting a settled section can lose
728
+ // detail the founder already gave us.
729
+ // …and only when those settled sections live on the DRAFT being written: an
730
+ // Active-version snapshot (no draft yet) describes the live strategy, and
731
+ // protecting its sections skips them on the new, empty draft — the 2026-09-16
732
+ // re-onboarding persisted one section and finalized with six missing.
733
+ const protectSettled = snapshot.versionStatus === 'Draft' && (snapshot.sections ?? []).length > 0;
734
+ const summary = await synthesizeFoundations({
735
+ brandId, evidence, businessObjective: objective, agentRunId,
736
+ onlySections: protectSettled ? decision.weakSections : undefined,
737
+ contextUser: params.contextUser,
738
+ });
739
+ payload.synthesisAttempts = 0;
740
+ payload.lastSynthesis = {
741
+ type: 'foundations',
742
+ documentId: summary.documentId,
743
+ versionId: summary.versionId,
744
+ versionNumber: summary.versionNumber,
745
+ // P11: persisted travels with the result — narrating a
746
+ // section that failed to save is a fabrication the model
747
+ // cannot otherwise detect.
748
+ sections: summary.sections.map(s => ({ sectionKey: s.sectionKey, confidence: s.confidence, persisted: s.persisted })),
749
+ missing: summary.missing,
750
+ weakFieldCount: summary.weakFieldCount,
751
+ };
752
+ this.mergeSpec(payload, brandId, summary.sections, evidence);
753
+ this.commitConsumedTurns(payload, evidence);
754
+ payload.rawSources = [];
755
+ payload.synthesizedAt = new Date().toISOString();
756
+ payload.grillTurnsSinceLastSynth = 0;
757
+ snapshot = await getOnboardingSnapshot(brandId, params.contextUser);
758
+ decision = decideOnboardingStep(snapshot, { ...state, grillTurnsSinceLastSynth: 0 });
759
+ }
760
+ else if (decision.nextAction === 'synthesize-strategy-section' && payload.synthesisDisabled !== true) {
761
+ const sectionKey = String(decision.nextActionParams?.SectionKey ?? '');
762
+ LogStatus(`OnboardingShell: re-synthesizing section '${sectionKey}'`);
763
+ payload.synthesisAttempts = asNumber(payload.synthesisAttempts) + 1;
764
+ const result = await synthesizeSection({ brandId, evidence, businessObjective: objective, sectionKey, agentRunId, contextUser: params.contextUser });
765
+ payload.synthesisAttempts = 0;
766
+ payload.lastSynthesis = {
767
+ type: 'section',
768
+ sectionKey,
769
+ confidence: result.confidence,
770
+ skippedStrong: result.skippedStrong ?? false,
771
+ };
772
+ this.mergeSpec(payload, brandId, result.persisted ? [result] : [], evidence);
773
+ this.commitConsumedTurns(payload, evidence);
774
+ payload.rawSources = [];
775
+ payload.lastSynthSectionKey = sectionKey;
776
+ payload.sectionsAlreadyResynthed = Array.from(new Set([...state.sectionsAlreadyResynthed, sectionKey]));
777
+ payload.grillTurnsSinceLastSynth = 0;
778
+ snapshot = await getOnboardingSnapshot(brandId, params.contextUser);
779
+ decision = decideOnboardingStep(snapshot, {
780
+ ...state,
781
+ grillTurnsSinceLastSynth: 0,
782
+ lastSynthSectionKey: sectionKey,
783
+ sectionsAlreadyResynthed: [...state.sectionsAlreadyResynthed, sectionKey],
784
+ });
785
+ }
786
+ // Plan synthesis (P1–P5) runs when the Foundations settle, before
787
+ // approval is requested — the plan is part of what the founder
788
+ // approves. Slot-completeness (not a one-shot flag) gates it, so a
789
+ // slot that failed to parse/persist retries on the next finalize
790
+ // pass instead of being silently lost.
791
+ const planSpec = payload.spec;
792
+ const missingPlanKeys = PLAN_KEYS.filter(k => !planSpec?.plan?.[k]);
793
+ if (decision.nextAction === 'finalize-strategy-onboarding' && missingPlanKeys.length > 0 && payload.synthesisDisabled !== true) {
794
+ try {
795
+ LogStatus(`OnboardingShell: synthesizing Plan sections (${missingPlanKeys.join(', ')})`);
796
+ const planResult = await synthesizePlan({ brandId, businessObjective: objective, agentRunId, keys: missingPlanKeys, contextUser: params.contextUser });
797
+ const spec = payload.spec;
798
+ if (spec) {
799
+ for (const ps of planResult.sections) {
800
+ if (!ps.persisted)
801
+ continue;
802
+ spec.plan[ps.planKey] = {
803
+ fields: ps.fields,
804
+ narrative: ps.narrative,
805
+ confidence: ps.confidence ?? 'Weak',
806
+ };
807
+ }
808
+ spec.displayMarkdown = renderSpecMarkdown(spec);
809
+ }
810
+ payload.planSynthesizedAt = new Date().toISOString();
811
+ payload.lastPlanSynthesis = {
812
+ sections: planResult.sections.map(x => ({ planKey: x.planKey, confidence: x.confidence, persisted: x.persisted })),
813
+ };
814
+ }
815
+ catch (planErr) {
816
+ LogError(`OnboardingShell: plan synthesis failed (finalize proceeds without plan): ${planErr instanceof Error ? planErr.message : planErr}`);
817
+ }
818
+ }
819
+ // M2.2: the finalize step flips the spec to ready-for-review and
820
+ // raises the founder's approval Task exactly once per conversation.
821
+ if (decision.nextAction === 'finalize-strategy-onboarding' && !payload.approvalRequestId) {
822
+ try {
823
+ const spec = payload.spec;
824
+ if (spec) {
825
+ spec.readiness = 'ready-for-review';
826
+ spec.displayMarkdown = renderSpecMarkdown(spec);
827
+ }
828
+ const weak = decision.weakSections.length > 0 ? `Weak sections at approval time: ${decision.weakSections.join(', ')}.` : 'All sections Strong/Moderate.';
829
+ const { requestId } = await createStrategyApprovalRequest({
830
+ brandId,
831
+ brandName: typeof payload.brandName === 'string' ? payload.brandName : 'the brand',
832
+ strategyDocumentId: payload.lastSynthesis?.documentId ?? null,
833
+ summary: `The strategy-onboarding interview is complete. ${weak} Review the latest Marketing Strategy artifact in the onboarding conversation.`,
834
+ agentRunId,
835
+ contextUser: params.contextUser,
836
+ });
837
+ payload.approvalRequestId = requestId;
838
+ }
839
+ catch (taskErr) {
840
+ LogError(`OnboardingShell: approval-task creation failed (finalize proceeds): ${taskErr instanceof Error ? taskErr.message : taskErr}`);
841
+ }
842
+ }
843
+ // M2.3 — the founder approved, so PUBLISH.
844
+ //
845
+ // Until now the approve button only flipped a payload flag. The
846
+ // version stayed Draft, every section stayed unapproved, the task
847
+ // stayed Open, and the agent still announced the strategy was live
848
+ // — a fabricated success at the one moment the founder acts on it.
849
+ // Guarded on the materialized id alone (not on the error), so a
850
+ // transient failure retries if the founder speaks again.
851
+ if (decision.nextAction === 'complete' && !payload.materializedVersionId) {
852
+ await this.materializeApprovedStrategy(payload, brandId, params);
853
+ if (payload.materializationError) {
854
+ // A refused publish must not end the interview: withdraw the approval and
855
+ // re-present the document (ReadyForApproval), with the refusal as the
856
+ // engine note the prompt reads — the founder fixes the gap in THIS
857
+ // conversation instead of starting over.
858
+ payload.founderApproved = false;
859
+ decision = {
860
+ ...decision,
861
+ phase: 'Finalize',
862
+ nextAction: 'finalize-strategy-onboarding',
863
+ rationale: `Publishing was refused and the strategy remains Draft: ${payload.materializationError}`,
864
+ };
865
+ }
866
+ }
867
+ // P4 — the shell owns the flow-control counters.
868
+ // These feed GRILL_BUDGET and the messaging must-ask gate inside
869
+ // decideOnboardingStep. They used to be incremented by the MODEL on
870
+ // request, so a model that forgot meant the budget never fired and
871
+ // the interview could never finalize; one that over-counted
872
+ // finalized early. Skip's equivalent move: compute the flag in code
873
+ // and tell the model it was done for them.
874
+ if (decision.nextAction === 'grill') {
875
+ payload.grillTurnsSinceLastSynth = asNumber(payload.grillTurnsSinceLastSynth) + 1;
876
+ // Per-section ask budget. We ask about every unsettled section
877
+ // in one batched form, so each of them has been pressed once —
878
+ // charge them all, or the budget never fires and the section
879
+ // that can't be answered is re-asked forever.
880
+ const askCounts = asAskCounts(payload.sectionAskCounts);
881
+ for (const key of decision.weakSections)
882
+ askCounts[key] = (askCounts[key] ?? 0) + 1;
883
+ payload.sectionAskCounts = askCounts;
884
+ const targeted = String(decision.nextActionParams?.SectionKey ?? '');
885
+ if (targeted === MESSAGING_SECTION_KEY || decision.weakSections.includes(MESSAGING_SECTION_KEY)) {
886
+ payload.messagingGrillTurns = asNumber(payload.messagingGrillTurns) + 1;
887
+ }
888
+ }
889
+ const settled = (snapshot.sections ?? [])
890
+ .filter(s => s.confidenceLevel === 'Strong' || s.confidenceLevel === 'Moderate')
891
+ .map(s => s.sectionKey);
892
+ // The named gaps for everything still unsettled. This is what makes
893
+ // the questions land on the blocker: the engine knows Market
894
+ // Context is short a growth_rate, so the prompt can ask for THAT
895
+ // rather than circling the section for four rounds.
896
+ const gaps = (snapshot.sections ?? [])
897
+ .filter(s => decision.weakSections.includes(s.sectionKey))
898
+ .filter(s => (s.missingFields?.length ?? 0) > 0)
899
+ .map(s => ({ sectionKey: s.sectionKey, missing: [...(s.missingFields ?? [])] }));
900
+ this.publishState(payload, {
901
+ phase: PHASE_BY_ACTION[decision.nextAction] ?? 'Gather',
902
+ gaps,
903
+ weakSections: [...decision.weakSections],
904
+ settledSections: settled,
905
+ note: decision.rationale,
906
+ });
907
+ }
908
+ catch (e) {
909
+ // Fail-visible, not fail-open: an unexplained shell error used to
910
+ // leave the prompt with no state at all, which is the hole the
911
+ // runaway loop fell through.
912
+ LogError(`OnboardingShell: ${e instanceof Error ? e.message : String(e)}`);
913
+ payload.shellError = e instanceof Error ? e.message : String(e);
914
+ this.publishState(payload, {
915
+ phase: 'Blocked',
916
+ note: 'The onboarding engine hit an error this turn. Tell the founder plainly, and ask a question you can act on next turn.',
917
+ });
918
+ }
919
+ }
920
+ /**
921
+ * Button-group labels for the AwaitingBrand phase. Two brands of DIFFERENT
922
+ * companies can share a name, so a repeated name is qualified with its
923
+ * company — otherwise the founder is asked to choose between two identical
924
+ * buttons.
925
+ */
926
+ brandChoiceLabels(payload) {
927
+ const brands = (Array.isArray(payload.authorizedBrands) ? payload.authorizedBrands : [])
928
+ .filter((b) => !!b && typeof b === 'object');
929
+ const names = brands
930
+ .map(b => (typeof b.name === 'string' ? b.name.trim() : ''))
931
+ .filter(n => n.length > 0);
932
+ const seen = new Map();
933
+ for (const n of names)
934
+ seen.set(n.toLowerCase(), (seen.get(n.toLowerCase()) ?? 0) + 1);
935
+ const labels = [];
936
+ for (const b of brands) {
937
+ const name = typeof b.name === 'string' ? b.name.trim() : '';
938
+ if (!name)
939
+ continue;
940
+ const company = typeof b.companyName === 'string' ? b.companyName.trim() : '';
941
+ labels.push((seen.get(name.toLowerCase()) ?? 0) > 1 && company ? `${name} (${company})` : name);
942
+ }
943
+ return labels;
944
+ }
945
+ /**
946
+ * Publish the turn's STATE — never a command.
947
+ *
948
+ * Skip's agents don't take injected imperatives: their prompts branch on
949
+ * payload the model can see and verify (`{% if _CURRENT_PAYLOAD.changeRequest %}`),
950
+ * and `preparePromptParams` only ever adds evidence. A directive is a
951
+ * second control channel with no defined behavior when it's absent — which
952
+ * is exactly how a turn with no directive fell through to "gather and
953
+ * Retry" and looped. State always has a branch.
954
+ */
955
+ /**
956
+ * M2.3 — turn the founder's approval into published state.
957
+ *
958
+ * Runs in the shell, never as an LLM tool choice: approval is a boundary
959
+ * the model may narrate but must not decide. This calls the
960
+ * materialization SERVICE directly rather than the `Materialize Strategy
961
+ * Version` action — an action invoking another action is the documented
962
+ * anti-pattern, and that action is only the founder/UI/tasks-flow entry
963
+ * point onto this same call.
964
+ *
965
+ * Every outcome lands on the payload so the prompt can branch on what
966
+ * actually happened rather than on what it hoped happened.
967
+ */
968
+ async materializeApprovedStrategy(payload, brandId, params) {
969
+ const conversationId = this.AgentRun?.ConversationID ?? null;
970
+ if (!conversationId) {
971
+ payload.materializationError = 'No conversation on the agent run — cannot resolve the approved artifact version.';
972
+ LogError(`OnboardingShell: ${payload.materializationError}`);
973
+ return;
974
+ }
975
+ const artifactVersionId = await this.resolveApprovedArtifactVersionId(conversationId, params.contextUser);
976
+ if (!artifactVersionId) {
977
+ payload.materializationError = 'No strategy artifact version found in this conversation to publish.';
978
+ LogError(`OnboardingShell: ${payload.materializationError}`);
979
+ return;
980
+ }
981
+ const approvalNotes = typeof payload.approvalNotes === 'string' ? payload.approvalNotes : null;
982
+ try {
983
+ const result = await materializeStrategyVersion({
984
+ brandId,
985
+ artifactVersionId,
986
+ approvedByUserId: params.contextUser.ID,
987
+ approvalNotes,
988
+ contextUser: params.contextUser,
989
+ });
990
+ payload.materializedVersionId = result.versionId;
991
+ payload.materializedVersionNumber = result.versionNumber;
992
+ payload.materializedSections = result.sectionsWritten;
993
+ payload.materializationError = undefined;
994
+ // Stamp the working spec, in CODE (the contract: "approved is set by the
995
+ // materialization step, never by an agent"). Until now nothing did — the
996
+ // run's own artifact kept saying ready-for-review and the viewer kept
997
+ // showing "Draft — not yet materialized" on the very message announcing
998
+ // the publish. The stamp also makes the interview's terminal state
999
+ // (readiness === 'approved') reachable on any later turn.
1000
+ const publishedSpec = payload.spec;
1001
+ if (publishedSpec) {
1002
+ publishedSpec.readiness = 'approved';
1003
+ publishedSpec.materialization = {
1004
+ strategyDocumentId: result.documentId,
1005
+ strategyDocumentVersionId: result.versionId,
1006
+ versionNumber: result.versionNumber,
1007
+ materializedAt: new Date().toISOString(),
1008
+ };
1009
+ // The header said "Draft marketing strategy…" under an APPROVED badge.
1010
+ publishedSpec.description = `Marketing strategy for ${publishedSpec.brandName} — approved and published as version ${result.versionNumber}.`;
1011
+ publishedSpec.displayMarkdown = renderSpecMarkdown(publishedSpec);
1012
+ }
1013
+ LogStatus(`OnboardingShell: strategy v${result.versionNumber} is now Active — ${result.sectionsWritten} section(s) materialized, ${result.supersededVersions} superseded.`);
1014
+ }
1015
+ catch (err) {
1016
+ payload.materializationError = err instanceof Error ? err.message : String(err);
1017
+ LogError(`OnboardingShell: materialization FAILED — strategy remains Draft: ${payload.materializationError}`);
1018
+ return;
1019
+ }
1020
+ await this.closeApprovalTask(payload, approvalNotes, params.contextUser);
1021
+ // The strategy → execution handoff, in code. This used to live only
1022
+ // behind an LLM-optional action, so the first live publish wrote zero
1023
+ // recommendations because the model simply didn't call it. Best-effort
1024
+ // like the task ceremony: the strategy IS published; a rec failure is
1025
+ // surfaced, never reported as a failed publish.
1026
+ try {
1027
+ const versionId = typeof payload.materializedVersionId === 'string' ? payload.materializedVersionId : null;
1028
+ if (versionId) {
1029
+ const recResult = await writeFinalizeRecommendations({
1030
+ brandId,
1031
+ strategyDocumentVersionId: versionId,
1032
+ agentRunId: this.AgentRun?.ID ?? null,
1033
+ contextUser: params.contextUser,
1034
+ });
1035
+ payload.finalizeRecsCreated = recResult.created;
1036
+ if (recResult.errors.length > 0)
1037
+ payload.finalizeRecsError = recResult.errors.join('; ');
1038
+ }
1039
+ }
1040
+ catch (recErr) {
1041
+ payload.finalizeRecsError = recErr instanceof Error ? recErr.message : String(recErr);
1042
+ LogError(`OnboardingShell: finalize-recommendation write failed (strategy stays published): ${payload.finalizeRecsError}`);
1043
+ }
1044
+ }
1045
+ /**
1046
+ * Complete the founder's approval Task after a successful publish.
1047
+ *
1048
+ * Best-effort by design: the strategy IS Active by the time this runs, so
1049
+ * a stuck task must never turn a successful publish into a reported
1050
+ * failure. It surfaces on its own payload key instead.
1051
+ */
1052
+ async closeApprovalTask(payload, approvalNotes, contextUser) {
1053
+ const requestId = typeof payload.approvalRequestId === 'string' ? payload.approvalRequestId : null;
1054
+ if (!requestId)
1055
+ return;
1056
+ try {
1057
+ await recordStrategyApprovalDecision({
1058
+ requestId,
1059
+ outcomeCode: 'Approved',
1060
+ notes: approvalNotes,
1061
+ // This run consumed the decision (it just materialized the strategy);
1062
+ // saying so stops core from resuming the paused originating run on top of it.
1063
+ resumingAgentRunId: this.AgentRun?.ID ?? null,
1064
+ contextUser,
1065
+ });
1066
+ }
1067
+ catch (ceremonyErr) {
1068
+ payload.approvalRequestError = ceremonyErr instanceof Error ? ceremonyErr.message : String(ceremonyErr);
1069
+ LogError(`OnboardingShell: approval decision failed (strategy stays published): ${payload.approvalRequestError}`);
1070
+ }
1071
+ }
1072
+ /**
1073
+ * Resolve the newest artifact version emitted in this conversation — the
1074
+ * snapshot the founder actually approved, and the row whose id gets
1075
+ * stamped as ApprovedArtifactVersionID.
1076
+ *
1077
+ * Two hops, because the framework links artifacts through
1078
+ * ConversationDetailArtifact and neither that entity nor
1079
+ * `MJ: Artifact Versions` denormalizes ConversationID.
1080
+ */
1081
+ async resolveApprovedArtifactVersionId(conversationId, contextUser) {
1082
+ try {
1083
+ const rv = new RunView();
1084
+ // Newest-first and capped: we want the LATEST artifact, so recent
1085
+ // details are the only ones that can carry it. This also keeps the
1086
+ // IN clause below bounded on a long conversation.
1087
+ const details = await rv.RunView({
1088
+ EntityName: 'MJ: Conversation Details',
1089
+ ExtraFilter: `ConversationID='${conversationId}'`,
1090
+ OrderBy: '__mj_CreatedAt DESC',
1091
+ MaxRows: RECENT_DETAILS_SCANNED_FOR_ARTIFACT,
1092
+ Fields: ['ID'],
1093
+ ResultType: 'simple',
1094
+ }, contextUser);
1095
+ if (!details.Success) {
1096
+ LogError(`OnboardingShell: conversation-detail lookup failed: ${details.ErrorMessage}`);
1097
+ return null;
1098
+ }
1099
+ if (!details.Results?.length)
1100
+ return null;
1101
+ const idList = details.Results.map(d => `'${d.ID}'`).join(',');
1102
+ const links = await rv.RunView({
1103
+ EntityName: 'MJ: Conversation Detail Artifacts',
1104
+ ExtraFilter: `ConversationDetailID IN (${idList})`,
1105
+ OrderBy: '__mj_CreatedAt DESC',
1106
+ MaxRows: 1,
1107
+ Fields: ['ArtifactVersionID'],
1108
+ ResultType: 'simple',
1109
+ }, contextUser);
1110
+ if (!links.Success) {
1111
+ LogError(`OnboardingShell: artifact-link lookup failed: ${links.ErrorMessage}`);
1112
+ return null;
1113
+ }
1114
+ return links.Results?.[0]?.ArtifactVersionID ?? null;
1115
+ }
1116
+ catch (err) {
1117
+ LogError(`OnboardingShell: artifact-version resolution threw: ${err instanceof Error ? err.message : err}`);
1118
+ return null;
1119
+ }
1120
+ }
1121
+ publishState(payload, state) {
1122
+ const spec = payload.spec;
1123
+ payload.onboarding = {
1124
+ phase: state.phase,
1125
+ // The resolved brand is mirrored here as well as at payload.brandId.
1126
+ // (Verified 2026-08-06 against four real runs: top-level keys the
1127
+ // shell writes DO survive the payload round-trip — an earlier note
1128
+ // here claimed otherwise, which was wrong. The absences that
1129
+ // prompted that claim were turns where the shell returned early at
1130
+ // AwaitingBrand and never wrote the key at all.)
1131
+ // The mirror is still worth keeping: it puts the brand in the same
1132
+ // object the template renders, so state the model sees and state
1133
+ // the shell reads cannot drift apart.
1134
+ brandId: typeof payload.brandId === 'string' ? payload.brandId : null,
1135
+ brandName: typeof payload.brandName === 'string' ? payload.brandName : null,
1136
+ weakSections: state.weakSections ?? [],
1137
+ settledSections: state.settledSections ?? [],
1138
+ gaps: state.gaps ?? [],
1139
+ brandChoices: state.brandChoices ?? [],
1140
+ synthesizedAt: payload.synthesizedAt ?? null,
1141
+ planSynthesizedAt: payload.planSynthesizedAt ?? null,
1142
+ approvalRequestId: payload.approvalRequestId ?? null,
1143
+ // Publish outcome. `published` is the ONLY thing the model may
1144
+ // treat as "the strategy is live" — it is set from the
1145
+ // materialization result, never from the founder having clicked.
1146
+ published: typeof payload.materializedVersionId === 'string',
1147
+ publishedVersionNumber: payload.materializedVersionNumber ?? null,
1148
+ publishedSections: payload.materializedSections ?? null,
1149
+ publishError: payload.materializationError ?? null,
1150
+ specReadiness: spec?.readiness ?? null,
1151
+ foundationSlots: spec ? Object.keys(spec.foundations ?? {}).length : 0,
1152
+ planSlots: spec ? Object.keys(spec.plan ?? {}).length : 0,
1153
+ // Read-only for the template; the shell is the only writer (P4).
1154
+ grillTurnsSinceLastSynth: asNumber(payload.grillTurnsSinceLastSynth),
1155
+ messagingGrillTurns: asNumber(payload.messagingGrillTurns),
1156
+ note: state.note ?? null,
1157
+ };
1158
+ // Retired: directedAction/directedSection/directedRationale. One channel only.
1159
+ payload.directedAction = undefined;
1160
+ payload.directedSection = undefined;
1161
+ payload.directedRationale = undefined;
1162
+ }
1163
+ /**
1164
+ * Maintain payload.spec — the MarketingStrategySpec working draft that
1165
+ * artifact versions serialize (Rev 5 §2.7.1). Slots merge from synthesis
1166
+ * results (fields + narrative + derived confidence); sources accumulate
1167
+ * from consumed evidence; displayMarkdown re-renders via the one shared
1168
+ * renderer so the artifact viewer's extract rule never re-derives it.
1169
+ */
1170
+ mergeSpec(payload, brandId, sections, evidence) {
1171
+ const brandName = typeof payload.brandName === 'string' ? payload.brandName : 'the brand';
1172
+ const existing = payload.spec;
1173
+ const spec = existing ?? {
1174
+ specVersion: 1,
1175
+ name: `${brandName} — Marketing Strategy`,
1176
+ description: `Draft marketing strategy for ${brandName}, produced by the strategy onboarding interview.`,
1177
+ // MarketingStrategySpec.companyId is the brand's PARENT company —
1178
+ // derived, never a scope (BaseMarketingAgent sets payload.companyId
1179
+ // from Brand.CompanyID). The scope is spec.brandId, set below.
1180
+ companyId: typeof payload.companyId === 'string' ? payload.companyId : '',
1181
+ brandName,
1182
+ asOfDate: (typeof payload.currentDate === 'string' ? payload.currentDate : new Date().toISOString().slice(0, 10)),
1183
+ readiness: 'incomplete',
1184
+ foundations: {},
1185
+ plan: {},
1186
+ sources: [],
1187
+ openQuestions: [],
1188
+ };
1189
+ spec.brandId = brandId;
1190
+ if (!spec.companyId && typeof payload.companyId === 'string')
1191
+ spec.companyId = payload.companyId;
1192
+ // The brand may resolve AFTER the spec was first created (multi-brand
1193
+ // users start unresolved) — refresh the header fields whenever the
1194
+ // real name is known so the placeholder never sticks.
1195
+ if (brandName !== 'the brand' && spec.brandName !== brandName) {
1196
+ spec.brandName = brandName;
1197
+ spec.name = `${brandName} — Marketing Strategy`;
1198
+ if (spec.readiness !== 'approved') {
1199
+ spec.description = `Draft marketing strategy for ${brandName}, produced by the strategy onboarding interview.`;
1200
+ }
1201
+ }
1202
+ for (const s of sections) {
1203
+ if (!s.persisted || !isFoundationKey(s.sectionKey))
1204
+ continue;
1205
+ const slot = {
1206
+ fields: s.fields,
1207
+ narrative: s.narrative,
1208
+ confidence: s.confidence ?? 'Weak',
1209
+ };
1210
+ spec.foundations[s.sectionKey] = slot;
1211
+ }
1212
+ const channelToKind = {
1213
+ website: 'url', blogs: 'entity', linkedin: 'url', competitors: 'url', interview: 'interview',
1214
+ };
1215
+ for (const e of evidence) {
1216
+ if (!spec.sources.some(x => x.locator === e.locator && x.kind === channelToKind[e.channel])) {
1217
+ spec.sources.push({ kind: channelToKind[e.channel], locator: e.locator, label: e.label, retrievedAt: new Date().toISOString() });
1218
+ }
1219
+ }
1220
+ spec.displayMarkdown = renderSpecMarkdown(spec);
1221
+ payload.spec = spec;
1222
+ }
1223
+ };
1224
+ MarketingStrategyOnboardingAgent = __decorate([
1225
+ RegisterClass(BaseAgent, "MarketingStrategyOnboardingAgent")
1226
+ ], MarketingStrategyOnboardingAgent);
1227
+ export { MarketingStrategyOnboardingAgent };
1228
+ function asNumber(v) {
1229
+ const n = typeof v === 'number' ? v : parseInt(String(v ?? ''), 10);
1230
+ return Number.isFinite(n) && n >= 0 ? n : 0;
1231
+ }
1232
+ /**
1233
+ * Per-section ask counts off the payload. Anything malformed degrades to an
1234
+ * empty map — a corrupt counter must relax the ask budget, never invent one.
1235
+ */
1236
+ function asAskCounts(v) {
1237
+ if (!v || typeof v !== 'object' || Array.isArray(v))
1238
+ return {};
1239
+ const out = {};
1240
+ for (const [key, raw] of Object.entries(v)) {
1241
+ const n = asNumber(raw);
1242
+ if (n > 0)
1243
+ out[key] = n;
1244
+ }
1245
+ return out;
1246
+ }
1247
+ function asStringArray(v) {
1248
+ if (Array.isArray(v))
1249
+ return v.filter((x) => typeof x === 'string');
1250
+ if (typeof v === 'string' && v.trim())
1251
+ return v.split(',').map(s => s.trim()).filter(Boolean);
1252
+ return [];
1253
+ }
1254
+ function asEvidence(v) {
1255
+ if (!Array.isArray(v))
1256
+ return [];
1257
+ const channels = new Set(['website', 'blogs', 'linkedin', 'competitors', 'interview']);
1258
+ return v.filter((e) => e !== null && typeof e === 'object'
1259
+ && channels.has(String(e.channel))
1260
+ && typeof e.text === 'string'
1261
+ && e.text.trim().length > 0);
1262
+ }
1263
+ export function LoadMarketingStrategyOnboardingAgent() { }
1264
+ //# sourceMappingURL=MarketingStrategyOnboardingAgent.js.map