@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.
- package/LICENSE +183 -0
- package/dist/agents/BaseMarketingAgent.d.ts +120 -0
- package/dist/agents/BaseMarketingAgent.d.ts.map +1 -0
- package/dist/agents/BaseMarketingAgent.js +323 -0
- package/dist/agents/BaseMarketingAgent.js.map +1 -0
- package/dist/agents/MarketingExperimentationAgent.d.ts +123 -0
- package/dist/agents/MarketingExperimentationAgent.d.ts.map +1 -0
- package/dist/agents/MarketingExperimentationAgent.js +371 -0
- package/dist/agents/MarketingExperimentationAgent.js.map +1 -0
- package/dist/agents/MarketingOperationsAgent.d.ts +24 -0
- package/dist/agents/MarketingOperationsAgent.d.ts.map +1 -0
- package/dist/agents/MarketingOperationsAgent.js +36 -0
- package/dist/agents/MarketingOperationsAgent.js.map +1 -0
- package/dist/agents/MarketingPodcastIngestionAgent.d.ts +21 -0
- package/dist/agents/MarketingPodcastIngestionAgent.d.ts.map +1 -0
- package/dist/agents/MarketingPodcastIngestionAgent.js +33 -0
- package/dist/agents/MarketingPodcastIngestionAgent.js.map +1 -0
- package/dist/agents/MarketingStrategyOnboardingAgent.d.ts +206 -0
- package/dist/agents/MarketingStrategyOnboardingAgent.d.ts.map +1 -0
- package/dist/agents/MarketingStrategyOnboardingAgent.js +1264 -0
- package/dist/agents/MarketingStrategyOnboardingAgent.js.map +1 -0
- package/dist/agents/index.d.ts +14 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +25 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -0
- package/dist/generated/class-registrations-manifest.d.ts +7 -0
- package/dist/generated/class-registrations-manifest.d.ts.map +1 -0
- package/dist/generated/class-registrations-manifest.js +7 -0
- package/dist/generated/class-registrations-manifest.js.map +1 -0
- package/dist/generated/generated.d.ts +7 -0
- package/dist/generated/generated.d.ts.map +1 -0
- package/dist/generated/generated.js +7 -0
- package/dist/generated/generated.js.map +1 -0
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.d.ts +3522 -0
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.d.ts.map +1 -0
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.js +18854 -0
- package/dist/generated/graphql-schemas/__mj_BizAppsMarketing.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +107 -0
- package/dist/index.js.map +1 -0
- package/dist/programs/ContentPostProgramDriver.d.ts +44 -0
- package/dist/programs/ContentPostProgramDriver.d.ts.map +1 -0
- package/dist/programs/ContentPostProgramDriver.js +185 -0
- package/dist/programs/ContentPostProgramDriver.js.map +1 -0
- package/dist/programs/EmailProgramDriver.d.ts +56 -0
- package/dist/programs/EmailProgramDriver.d.ts.map +1 -0
- package/dist/programs/EmailProgramDriver.js +224 -0
- package/dist/programs/EmailProgramDriver.js.map +1 -0
- package/dist/programs/google/GoogleAdsClient.d.ts +93 -0
- package/dist/programs/google/GoogleAdsClient.d.ts.map +1 -0
- package/dist/programs/google/GoogleAdsClient.js +279 -0
- package/dist/programs/google/GoogleAdsClient.js.map +1 -0
- package/dist/programs/google/GoogleAdsProgramDriver.d.ts +56 -0
- package/dist/programs/google/GoogleAdsProgramDriver.d.ts.map +1 -0
- package/dist/programs/google/GoogleAdsProgramDriver.js +357 -0
- package/dist/programs/google/GoogleAdsProgramDriver.js.map +1 -0
- package/dist/programs/google/googleAdsExecutionContext.d.ts +47 -0
- package/dist/programs/google/googleAdsExecutionContext.d.ts.map +1 -0
- package/dist/programs/google/googleAdsExecutionContext.js +128 -0
- package/dist/programs/google/googleAdsExecutionContext.js.map +1 -0
- package/dist/programs/google/googleBrandAssets.d.ts +27 -0
- package/dist/programs/google/googleBrandAssets.d.ts.map +1 -0
- package/dist/programs/google/googleBrandAssets.js +81 -0
- package/dist/programs/google/googleBrandAssets.js.map +1 -0
- package/dist/programs/index.d.ts +18 -0
- package/dist/programs/index.d.ts.map +1 -0
- package/dist/programs/index.js +27 -0
- package/dist/programs/index.js.map +1 -0
- package/dist/programs/linkedin/LinkedInAdsClient.d.ts +145 -0
- package/dist/programs/linkedin/LinkedInAdsClient.d.ts.map +1 -0
- package/dist/programs/linkedin/LinkedInAdsClient.js +408 -0
- package/dist/programs/linkedin/LinkedInAdsClient.js.map +1 -0
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts +94 -0
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.d.ts.map +1 -0
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.js +449 -0
- package/dist/programs/linkedin/LinkedInAdsProgramDriver.js.map +1 -0
- package/dist/programs/linkedin/linkedInExecutionContext.d.ts +42 -0
- package/dist/programs/linkedin/linkedInExecutionContext.d.ts.map +1 -0
- package/dist/programs/linkedin/linkedInExecutionContext.js +97 -0
- package/dist/programs/linkedin/linkedInExecutionContext.js.map +1 -0
- package/package.json +46 -7
- 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
|