in2-agent-server 0.20260821.185429
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/README.md +369 -0
- package/agents/plan-requirement.md +32 -0
- package/dist/campfire/configItem.js +201 -0
- package/dist/campfire/configItemDescription.js +379 -0
- package/dist/campfire/deriveFromBundle.js +1013 -0
- package/dist/cli.js +465 -0
- package/dist/core/betaFeedback.js +493 -0
- package/dist/core/chapterInstructions.js +473 -0
- package/dist/core/claudeAssets.js +61 -0
- package/dist/core/gate.js +201 -0
- package/dist/core/ids.js +9 -0
- package/dist/core/install.js +60 -0
- package/dist/core/operatorQa.js +462 -0
- package/dist/core/orgCallMeter.js +178 -0
- package/dist/core/parcels.js +1235 -0
- package/dist/core/paths.js +105 -0
- package/dist/core/phases.js +225 -0
- package/dist/core/planFile.js +251 -0
- package/dist/core/planFragments.js +119 -0
- package/dist/core/readsLog.js +398 -0
- package/dist/core/registration.js +24 -0
- package/dist/core/result.js +38 -0
- package/dist/core/resume.js +233 -0
- package/dist/core/revisionChain.js +351 -0
- package/dist/core/richText.js +61 -0
- package/dist/core/semanticHash.js +89 -0
- package/dist/core/sessionOrg.js +60 -0
- package/dist/core/sfQuery.js +19 -0
- package/dist/core/spawn.js +182 -0
- package/dist/core/supervisorContract.js +230 -0
- package/dist/core/telemetry.js +1297 -0
- package/dist/core/telemetryContext.js +154 -0
- package/dist/core/telemetrySpool.js +244 -0
- package/dist/core/transcriptUpload.js +516 -0
- package/dist/core/update.js +363 -0
- package/dist/core/viewer.js +117 -0
- package/dist/core/workspace.js +92 -0
- package/dist/core/writeGrants.js +93 -0
- package/dist/core/writebackManifest.js +92 -0
- package/dist/direct/applyLoop.js +2516 -0
- package/dist/direct/batchReport.js +510 -0
- package/dist/direct/comparatorNoise.js +75 -0
- package/dist/direct/containers.js +85 -0
- package/dist/direct/contamination.js +221 -0
- package/dist/direct/crud.js +56 -0
- package/dist/direct/destructive.js +168 -0
- package/dist/direct/knownTokens.js +94 -0
- package/dist/direct/lint.js +382 -0
- package/dist/direct/metadataSchema.generated.json +1 -0
- package/dist/direct/metadataSchema.js +179 -0
- package/dist/direct/operatorPage.js +349 -0
- package/dist/direct/orgRepo.js +1531 -0
- package/dist/direct/orgRepoSpawnCount.js +21 -0
- package/dist/direct/plannedScope.js +119 -0
- package/dist/direct/preImageTargets.js +63 -0
- package/dist/direct/refVerify.js +191 -0
- package/dist/direct/registry.js +129 -0
- package/dist/direct/removals.js +262 -0
- package/dist/direct/rollback.js +1513 -0
- package/dist/direct/rollbackRun.js +1360 -0
- package/dist/direct/sessionProjection.js +126 -0
- package/dist/direct/snapshotComparability.js +165 -0
- package/dist/direct/sourceTree.js +113 -0
- package/dist/direct/unstatedChanges.js +106 -0
- package/dist/direct/waveAssembly.js +556 -0
- package/dist/direct/waveCounts.js +19 -0
- package/dist/direct/waveDerivation.js +152 -0
- package/dist/direct/waveGrade.js +185 -0
- package/dist/index.js +90 -0
- package/dist/orgread/customFieldSnapshot.js +48 -0
- package/dist/orgread/fieldMetadata.js +178 -0
- package/dist/orgread/genericSnapshot.js +61 -0
- package/dist/orgread/layoutSnapshot.js +151 -0
- package/dist/orgread/salesforceOrgReader.js +184 -0
- package/dist/orgread/snapshotTypes.js +9 -0
- package/dist/projection/bridge.js +26 -0
- package/dist/projection/diff.js +141 -0
- package/dist/projection/mapmd.js +213 -0
- package/dist/projection/nameAmbiguity.js +106 -0
- package/dist/projection/precedent.js +136 -0
- package/dist/projection/project.js +214 -0
- package/dist/projection/scope.js +110 -0
- package/dist/projection/types.js +5 -0
- package/dist/tools/analyze.js +322 -0
- package/dist/tools/auth.js +915 -0
- package/dist/tools/campfire.js +1742 -0
- package/dist/tools/closeBatch.js +154 -0
- package/dist/tools/closeText.js +35 -0
- package/dist/tools/direct.js +1613 -0
- package/dist/tools/feedback.js +318 -0
- package/dist/tools/operator.js +287 -0
- package/dist/tools/org.js +739 -0
- package/dist/tools/phase.js +249 -0
- package/dist/tools/projection.js +172 -0
- package/dist/tools/session.js +313 -0
- package/dist/tools/update.js +82 -0
- package/dist/tools/waves.js +793 -0
- package/package.json +48 -0
- package/reference/campfire-configuration-items-guide.md +710 -0
- package/reference/deployability-cribs.md +119 -0
- package/reference/operator-voice.md +141 -0
- package/reference/org-read-cribs.md +34 -0
- package/reference/scoped-dml-interim.md +68 -0
- package/reference/supervisor-playbook.md +313 -0
- package/reference/templates.md +233 -0
- package/skills/in2-delivery/SKILL.md +322 -0
- package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
- package/skills/in2-delivery/references/deployability-cribs.md +119 -0
- package/skills/in2-delivery/references/operator-voice.md +141 -0
- package/skills/in2-delivery/references/org-read-cribs.md +34 -0
- package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
- package/skills/in2-delivery/references/templates.md +233 -0
- package/skills/plan-requirement/SKILL.md +188 -0
- package/viewer/index.html +899 -0
- package/viewer/server.mjs +433 -0
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { fail, internalError, ok } from "../core/result.js";
|
|
6
|
+
import { checkForUpdate, scheduleUpdateAtClose, startDetachedInstall, updateInstalledUnderThisProcess, updateActionFor } from "../core/update.js";
|
|
7
|
+
import { recordDirectWriteGrant } from "../core/writeGrants.js";
|
|
8
|
+
import { opaqueId, isoNow } from "../core/ids.js";
|
|
9
|
+
import { ensureDir, fileExists, readJson, sessionDir, sessionFile, writeJsonAtomic } from "../core/workspace.js";
|
|
10
|
+
import { SESSION_SCHEMA_VERSION, composeCampfireProse, importRequirementEntries, producerIdentity } from "./session.js";
|
|
11
|
+
import { WORKSPACE_ROOT } from "../core/paths.js";
|
|
12
|
+
import { ensureViewer, viewerBaseUrl, viewerNotice, viewerPort } from "../core/viewer.js";
|
|
13
|
+
import { runProcess, sfCliEnv, spawnDetached } from "../core/spawn.js";
|
|
14
|
+
import { campfireAllowlistedOrgIds } from "../core/gate.js";
|
|
15
|
+
import { installId } from "../core/telemetry.js";
|
|
16
|
+
import { installedVersion } from "../core/install.js";
|
|
17
|
+
import { bindBatchContext, emitDomainTelemetryEvent, processTelemetryIdentity } from "../core/telemetryContext.js";
|
|
18
|
+
// Interactive org authentication for the START of a session, so an operator can point the
|
|
19
|
+
// harness at any org they can log into rather than a pre-provisioned alias.
|
|
20
|
+
//
|
|
21
|
+
// WHY THE sf CLI IS THE WHOLE ANSWER: every org path in this server authenticates through the
|
|
22
|
+
// CLI's stored auth — deploys shell out to `sf`, and the jsforce reader / CRUD facet writer use
|
|
23
|
+
// `Org.create({aliasOrUsername})`, which reads the same `~/.sf` auth files. No token, client
|
|
24
|
+
// secret or redirect URI ever passes through this process. And `sf org login web` IS a full
|
|
25
|
+
// callback-included OAuth flow: it starts a localhost listener, opens the browser to Salesforce's
|
|
26
|
+
// authorize endpoint with redirect_uri=http://localhost:<port>/OauthRedirect, exchanges the code
|
|
27
|
+
// and writes the auth file. The bespoke OAuth plumbing that in2-console and the older
|
|
28
|
+
// vfp-configuration-project needed exists because those are WEB apps — browser/server code
|
|
29
|
+
// cannot shell to a CLI on the end user's machine and must own the token lifecycle. This server
|
|
30
|
+
// is a local stdio subprocess on the user's own machine, so it inherits the CLI's store.
|
|
31
|
+
//
|
|
32
|
+
// The human authenticates directly with Salesforce in their own browser. This tool never asks
|
|
33
|
+
// for, receives, or stores a username, password, or token — it only launches the flow.
|
|
34
|
+
const ALIAS_RE = /^[A-Za-z][A-Za-z0-9._-]{0,59}$/;
|
|
35
|
+
function authLogPath(alias) {
|
|
36
|
+
return path.join(WORKSPACE_ROOT, "..", "auth", `login-${alias}.log`);
|
|
37
|
+
}
|
|
38
|
+
async function readOrgDisplay(alias) {
|
|
39
|
+
const run = await runProcess("sf", ["org", "display", "--target-org", alias, "--json"], {
|
|
40
|
+
env: sfCliEnv(),
|
|
41
|
+
timeoutMs: 60_000
|
|
42
|
+
});
|
|
43
|
+
if (run.timedOut)
|
|
44
|
+
return { ok: false, detail: "sf org display timed out" };
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(run.stdout);
|
|
47
|
+
if (parsed.result?.id)
|
|
48
|
+
return { ok: true, result: parsed.result };
|
|
49
|
+
return { ok: false, detail: parsed.message ?? parsed.name ?? `exit ${run.exitCode}` };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { ok: false, detail: run.stderr.slice(-300) || run.stdout.slice(-300) || `exit ${run.exitCode}` };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The local sf alias pointing at a given Org ID, or null.
|
|
57
|
+
*
|
|
58
|
+
* activate_session knows the requirements came FROM Campfire — the launch service read them there —
|
|
59
|
+
* but the payload names only the TARGET org, so the consultant org was never recorded. That made
|
|
60
|
+
* activation, the product's primary path, the one path from which Campfire writeback was impossible
|
|
61
|
+
* (live-caught: a Campfire writeback refused ORG_NOT_BOUND on a fully successful delivery).
|
|
62
|
+
*
|
|
63
|
+
* The Campfire org is not a guess: it is the org on the Campfire write allowlist, by definition.
|
|
64
|
+
* What is machine-specific is the ALIAS, so it is resolved from the CLI's own org list rather than
|
|
65
|
+
* assumed. A miss leaves the binding absent, and the existing refusal still names the fix.
|
|
66
|
+
*/
|
|
67
|
+
async function aliasForOrgId(orgId) {
|
|
68
|
+
const run = await runProcess("sf", ["org", "list", "--json"], { env: sfCliEnv(), timeoutMs: 60_000 });
|
|
69
|
+
if (run.exitCode !== 0)
|
|
70
|
+
return null;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(run.stdout);
|
|
73
|
+
const want = orgId.slice(0, 15);
|
|
74
|
+
for (const group of Object.values(parsed.result ?? {})) {
|
|
75
|
+
if (!Array.isArray(group))
|
|
76
|
+
continue;
|
|
77
|
+
for (const org of group) {
|
|
78
|
+
const id = typeof org.orgId === "string" ? org.orgId : "";
|
|
79
|
+
const alias = typeof org.alias === "string" ? org.alias : "";
|
|
80
|
+
if (id.slice(0, 15) === want && alias)
|
|
81
|
+
return alias;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
export async function resolveCampfireBinding(campfireOrgId, deps) {
|
|
91
|
+
if (!campfireOrgId)
|
|
92
|
+
return { status: "unknown_org" };
|
|
93
|
+
const alias = await deps.aliasForOrgId(campfireOrgId);
|
|
94
|
+
if (!alias)
|
|
95
|
+
return { status: "not_authenticated", orgId: campfireOrgId };
|
|
96
|
+
// CONNECTED is the only state that counts as authenticated, exactly as `org_login` reads it. A
|
|
97
|
+
// readable alias whose status is anything else still needs a login, and a read that fails at all
|
|
98
|
+
// is indistinguishable from an unauthenticated alias from here — both are "this will not work at
|
|
99
|
+
// writeback", which is the only thing the operator needs told.
|
|
100
|
+
const display = await deps.readOrgDisplay(alias);
|
|
101
|
+
if (!display.ok)
|
|
102
|
+
return { status: "expired", alias, orgId: campfireOrgId, detail: display.detail };
|
|
103
|
+
if (display.result.connectedStatus !== "Connected") {
|
|
104
|
+
return {
|
|
105
|
+
status: "expired",
|
|
106
|
+
alias,
|
|
107
|
+
orgId: display.result.id ?? campfireOrgId,
|
|
108
|
+
detail: display.result.connectedStatus ?? "not connected",
|
|
109
|
+
...(display.result.instanceUrl ? { instanceUrl: display.result.instanceUrl } : {})
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
status: "bound",
|
|
114
|
+
alias,
|
|
115
|
+
orgId: display.result.id ?? campfireOrgId,
|
|
116
|
+
...(display.result.instanceUrl ? { instanceUrl: display.result.instanceUrl } : {})
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** VF-partial's login host, so the remedy below is copy-pasteable rather than a shape to fill in. */
|
|
120
|
+
const CAMPFIRE_DEFAULT_INSTANCE_URL = "https://vfp--vfpartial.sandbox.my.salesforce.com";
|
|
121
|
+
/**
|
|
122
|
+
* Whether to log a launch-service-minted Campfire credential into the CLI, and under which alias.
|
|
123
|
+
*
|
|
124
|
+
* The launch service can now authenticate the Campfire org in the same browser round-trip as the
|
|
125
|
+
* target org and hand the credential back on the activation payload. That closes the structural
|
|
126
|
+
* hole: the product MINTS the credential rather than hoping a human made one by hand.
|
|
127
|
+
*
|
|
128
|
+
* TWO RULES, both of which are about not making things worse:
|
|
129
|
+
*
|
|
130
|
+
* 1. A LIVE local binding is never replaced. That alias may be the consultant's own
|
|
131
|
+
* `sf org login web` sign-in, which lives under VF-partial's session policy and in practice
|
|
132
|
+
* outlives an activation-minted token by a wide margin. Swapping a durable credential for a
|
|
133
|
+
* shorter-lived one is a downgrade, and it would be an invisible one.
|
|
134
|
+
* 2. An EXPIRED local alias is reused by NAME. Two aliases for one org is how an operator ends up
|
|
135
|
+
* re-authenticating the one the session is not using.
|
|
136
|
+
*
|
|
137
|
+
* Absence changes nothing anywhere: no credential, or no known Campfire org to verify it against,
|
|
138
|
+
* means this does nothing and the run falls back to the minute-zero card.
|
|
139
|
+
*/
|
|
140
|
+
/**
|
|
141
|
+
* The activation-minted alias for the TARGET org — named after the ORG, never after Campfire.
|
|
142
|
+
*
|
|
143
|
+
* The old shape was `campfire-<orgId>`, which put the word "campfire" on the one org in
|
|
144
|
+
* `sf org list` that is NOT the Campfire org — the delivery target. Anyone (or any model) reading
|
|
145
|
+
* the org list to answer "which org is Campfire?" picked the delivery org, which is exactly the
|
|
146
|
+
* mistake campfire.ts's field-cap comment records happening: the DELIVERY org was described in
|
|
147
|
+
* place of the consultant org and the measurement was wrong for days. An opaque org id tells a
|
|
148
|
+
* human nothing; the MyDomain label — `tpinto12122025`, `vfp--vfpartial.sandbox` — is the name
|
|
149
|
+
* people actually use for an org, it is globally unique by construction, and it survives in the
|
|
150
|
+
* instance URL every activation payload already carries.
|
|
151
|
+
*
|
|
152
|
+
* `in2-` prefixed so activation-minted aliases stay recognizable (and deletable) as a family, and
|
|
153
|
+
* so the alias can never collide with a hand-made login for the same org (rule 1 of
|
|
154
|
+
* campfireLoginPlan: a live human-made binding is never replaced). The 15-char org id rides as a
|
|
155
|
+
* SUFFIX — `in2-tpinto12122025-00Dfn00000BFjbB` — so the alias stays unambiguous even if two orgs
|
|
156
|
+
* ever share a readable label (a refreshed sandbox, a renamed MyDomain): the name half is for the
|
|
157
|
+
* human, the id half is the ground truth. With no usable URL the label half is simply absent.
|
|
158
|
+
*/
|
|
159
|
+
export function targetOrgAlias(instanceUrl, orgId) {
|
|
160
|
+
const ALIAS_MAX = 60;
|
|
161
|
+
const id = orgId.slice(0, 15);
|
|
162
|
+
let label = "";
|
|
163
|
+
try {
|
|
164
|
+
const host = new URL(instanceUrl ?? "").hostname;
|
|
165
|
+
// Everything before the platform suffix is the org's own name: `tpinto12122025` from
|
|
166
|
+
// `tpinto12122025.my.salesforce.com`, `vfp--vfpartial.sandbox` from the sandbox form.
|
|
167
|
+
label = host.replace(/\.(my\.)?salesforce(?:liveagent|-setup)?\.com$/i, "").replace(/\.my$/i, "");
|
|
168
|
+
// A dot would survive the alias grammar but reads as structure; keep the sandbox qualifier
|
|
169
|
+
// visible without it.
|
|
170
|
+
label = label.replace(/\.sandbox$/i, "").replace(/[^A-Za-z0-9._-]/g, "-");
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
label = "";
|
|
174
|
+
}
|
|
175
|
+
// The id suffix is non-negotiable; when the whole thing would overflow, the LABEL is what gives.
|
|
176
|
+
const room = ALIAS_MAX - "in2-".length - id.length - 1;
|
|
177
|
+
const trimmed = label.slice(0, Math.max(0, room));
|
|
178
|
+
const alias = trimmed ? `in2-${trimmed}-${id}` : `in2-${id}`;
|
|
179
|
+
return ALIAS_RE.test(alias) ? alias : `in2-${id}`;
|
|
180
|
+
}
|
|
181
|
+
export function campfireLoginPlan(binding, minted, campfireOrgId) {
|
|
182
|
+
const alias = "alias" in binding && binding.alias ? binding.alias : `campfire-org-${(campfireOrgId ?? "").slice(0, 15)}`;
|
|
183
|
+
if (!minted?.authUrl || !campfireOrgId)
|
|
184
|
+
return { login: false, alias };
|
|
185
|
+
if (binding.status === "bound")
|
|
186
|
+
return { login: false, alias };
|
|
187
|
+
return { login: true, alias };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* What activation TELLS the operator about a Campfire org that will not work, at minute zero.
|
|
191
|
+
*
|
|
192
|
+
* The consequence is the half a supervisor cannot infer and the half that makes this worth saying
|
|
193
|
+
* early: nothing fails now. Analysis, approval and every wave will run exactly as they would with a
|
|
194
|
+
* live binding. Only the last step of the run breaks, and by then the org has been changed.
|
|
195
|
+
*/
|
|
196
|
+
export function campfireBindingNotice(binding) {
|
|
197
|
+
if (binding.status === "bound")
|
|
198
|
+
return [];
|
|
199
|
+
const alias = "alias" in binding ? binding.alias : "campfire";
|
|
200
|
+
const instanceUrl = ("instanceUrl" in binding && binding.instanceUrl) || CAMPFIRE_DEFAULT_INSTANCE_URL;
|
|
201
|
+
const org = "orgId" in binding && binding.orgId ? binding.orgId : "unknown — nothing in this activation named one";
|
|
202
|
+
const cause = binding.status === "expired"
|
|
203
|
+
? `The Campfire (consultant) org's local sign-in has EXPIRED — alias ${alias} no longer authenticates (${binding.detail}).`
|
|
204
|
+
: binding.status === "not_authenticated"
|
|
205
|
+
? `The Campfire (consultant) org is NOT authenticated on this machine — no local sf alias points at org ${org}.`
|
|
206
|
+
: "This activation named no Campfire (consultant) org, and none is configured on this install.";
|
|
207
|
+
return [
|
|
208
|
+
"",
|
|
209
|
+
`CAMPFIRE ORG: ${cause}`,
|
|
210
|
+
`Campfire org: ${org}`,
|
|
211
|
+
"Campfire writeback will NOT be possible from this machine until that is fixed. Everything else in this run works:",
|
|
212
|
+
"analysis, approval and every deploy wave will succeed, and only the final writeback step will refuse — after the org",
|
|
213
|
+
"has already been changed. Card this to the operator NOW, at the start, not at the end.",
|
|
214
|
+
"The fix, either way (they complete the browser sign-in; you never handle a password or token):",
|
|
215
|
+
` org_login {alias: "${alias}", instanceUrl: "${instanceUrl}", mode: "restart"}`,
|
|
216
|
+
` or the same flow by hand: sf org login web --alias ${alias} --instance-url ${instanceUrl}`,
|
|
217
|
+
"Then re-run activate_session, or bind it with create_session, so the session records the live alias."
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* What activation says once it has ALREADY installed the stale-path update, rather than asking.
|
|
222
|
+
*
|
|
223
|
+
* Only the `stale` path installs at activation — the run was never going to start on this build,
|
|
224
|
+
* so nothing is in flight for an in-place npm install to skew. Two outcomes:
|
|
225
|
+
* - the install failed → fall back to the round-1 imperative, naming the error. Activation
|
|
226
|
+
* must not fail because an update did, and must not pretend either.
|
|
227
|
+
* - the install landed → the disk is current and THIS conversation is not. There is no hot
|
|
228
|
+
* swap any more, so the restart is unconditional: tell the operator to
|
|
229
|
+
* start a new chat, then STOP.
|
|
230
|
+
*/
|
|
231
|
+
function installedNotice(update, installed) {
|
|
232
|
+
if (!installed.ok) {
|
|
233
|
+
return [
|
|
234
|
+
"",
|
|
235
|
+
`THIS BUILD IS TOO OLD TO START A BATCH, AND THE AUTOMATIC UPDATE FAILED: this server is ${installed.fromVersion}, the published release is ${update.latestVersion}.`,
|
|
236
|
+
`The install did not happen: ${installed.error ?? "unknown error"}`,
|
|
237
|
+
"This server is UNCHANGED and still on the old build. Do NOT begin the delivery loop. Card the operator:",
|
|
238
|
+
"ask them to run `npm install -g in2-agent-server@latest` in a terminal, then start a new chat and paste",
|
|
239
|
+
"their activation prompt again. If they cannot, let them decide whether to continue on the old build."
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
// ok but not updated: the registry no longer offered a newer build between the check's fetch and
|
|
243
|
+
// the install's (npm replication lag, or a dist-tag rollback). Nothing was installed, so "TOOLS
|
|
244
|
+
// UPDATING" + a restart would loop the operator straight back to this same build reporting the
|
|
245
|
+
// same false success. The structured field maps this to `current` (updateStructuredFields), and
|
|
246
|
+
// the prose must agree with it.
|
|
247
|
+
if (!installed.updated) {
|
|
248
|
+
return [
|
|
249
|
+
"",
|
|
250
|
+
`Note: activation's version check saw ${update.latestVersion} published, but the registry no longer offers a build newer than ${installed.fromVersion} — nothing was installed, and nothing needs restarting. If this persists across sessions, the registry may still be replicating a fresh release.`
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
return [
|
|
254
|
+
"",
|
|
255
|
+
`TOOLS UPDATING AUTOMATICALLY: version ${installed.toVersion} is installing in the background (this conversation started on ${installed.fromVersion}, which is too old to run a batch).`,
|
|
256
|
+
"THIS conversation keeps running the old build regardless — an npm install cannot replace code a running",
|
|
257
|
+
"process has loaded. Tell the operator, in their words, that the tools are being updated and this chat",
|
|
258
|
+
"cannot use the new version: they must START A NEW CHAT (give the install a minute or two) and paste",
|
|
259
|
+
"their activation prompt again. Their requirements and this activation are unaffected — nothing is lost",
|
|
260
|
+
"by restarting. If the new chat still reports an old build, the background install had not finished or",
|
|
261
|
+
"failed; it re-runs automatically at that activation.",
|
|
262
|
+
"Then STOP. Do not read requirements, do not project the org, do not deploy anything in this session."
|
|
263
|
+
];
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* What activation TELLS the supervisor about a build that is behind the published release.
|
|
267
|
+
*
|
|
268
|
+
* THREE REGISTERS, because "an update exists", "the update will land by itself", and "this build
|
|
269
|
+
* predates two days of fixes" are different facts and must not read the same. LIVE-CAUGHT
|
|
270
|
+
* (QA round 1, 2026-08-06): two testers were shown an `UPDATE AVAILABLE` line at activation, ran
|
|
271
|
+
* anyway, and one of them lost a multi-hour run to a bug fixed ~21 hours after their build was cut
|
|
272
|
+
* — failing at step 8 of 9, with the org already changed. The notice was true, competed with
|
|
273
|
+
* fourteen other lines, and changed nothing.
|
|
274
|
+
*
|
|
275
|
+
* So under npm distribution: `behind` is one informational line with NOTHING for anyone to do —
|
|
276
|
+
* close_batch installs it and the next session is current. `stale` installs immediately (nothing
|
|
277
|
+
* was going to run on this build anyway) and reports what happened via installedNotice, which
|
|
278
|
+
* carries the unconditional restart instruction — there is no hot swap, so a chat can never pick
|
|
279
|
+
* up an installed update.
|
|
280
|
+
*/
|
|
281
|
+
/**
|
|
282
|
+
* The version facts as structured FIELDS, beside the prose the notice carries.
|
|
283
|
+
*
|
|
284
|
+
* QA round 4 (2026-08-20, session e6ec08ce): the whole update notice lived in the text channel,
|
|
285
|
+
* the client dropped it (see ok()'s header in core/result.ts), and a tester activated on a
|
|
286
|
+
* 5-day-stale build with the model reporting the activation result "only contained sessionId,
|
|
287
|
+
* batchId… no update-related line". The text mirror fixes the notice; these fields make the facts
|
|
288
|
+
* keyable regardless of which channel a client renders.
|
|
289
|
+
*
|
|
290
|
+
* `serverVersion` is the build THIS CONVERSATION'S ANSWERS COME FROM — always the running build:
|
|
291
|
+
* with the hot swap gone, no install can change what this session answers from, and reporting an
|
|
292
|
+
* installed toVersion here would make serverVersion === latestVersion read as "current" on the
|
|
293
|
+
* exact path whose prose says START A NEW CHAT.
|
|
294
|
+
*
|
|
295
|
+
* `updateStatus`, exhaustively: `current` (no update), `unknown` (the check itself failed —
|
|
296
|
+
* checkForUpdate's catch returns updateAvailable:false with a `reason` and no latestVersion, and
|
|
297
|
+
* asserting `current` there is a false positive for a build that may be days stale),
|
|
298
|
+
* `installed-restart-required` (the stale path installed the new build; this session cannot use
|
|
299
|
+
* it), `install-failed`, and the pre-install verdicts from `updateActionFor` (`update-at-close`,
|
|
300
|
+
* `stop-and-update`, `update-by-hand`).
|
|
301
|
+
*/
|
|
302
|
+
export function updateStructuredFields(update, installed) {
|
|
303
|
+
const running = update.currentVersion;
|
|
304
|
+
if (!update.updateAvailable) {
|
|
305
|
+
const checkFailed = update.reason !== undefined && update.latestVersion === undefined;
|
|
306
|
+
return { serverVersion: running, updateStatus: checkFailed ? "unknown" : "current" };
|
|
307
|
+
}
|
|
308
|
+
const status = !installed
|
|
309
|
+
? updateActionFor(update)
|
|
310
|
+
: !installed.ok
|
|
311
|
+
? "install-failed"
|
|
312
|
+
: !installed.updated
|
|
313
|
+
? "current"
|
|
314
|
+
: "installed-restart-required";
|
|
315
|
+
return { serverVersion: running, updateStatus: status, latestVersion: update.latestVersion };
|
|
316
|
+
}
|
|
317
|
+
export function updateNotice(update, installed) {
|
|
318
|
+
if (!update.updateAvailable)
|
|
319
|
+
return [];
|
|
320
|
+
if (installed)
|
|
321
|
+
return installedNotice(update, installed);
|
|
322
|
+
const age = update.behindDays !== undefined
|
|
323
|
+
? ` — ${update.behindDays} day${update.behindDays === 1 ? "" : "s"} of fixes behind`
|
|
324
|
+
: "";
|
|
325
|
+
const versions = `this server is ${update.currentVersion}, the published release is ${update.latestVersion}${age}.`;
|
|
326
|
+
// Not managed: the updater cannot help, so the words must not tell anyone to run it.
|
|
327
|
+
if (!update.managed) {
|
|
328
|
+
return [
|
|
329
|
+
"",
|
|
330
|
+
`${update.staleness === "stale" ? "STOP — THIS BUILD IS TOO OLD TO START A BATCH" : "UPDATE AVAILABLE"}: ${versions}`,
|
|
331
|
+
"This install is not npm-managed (a repo checkout updates with git pull, not the updater).",
|
|
332
|
+
...(update.staleness === "stale"
|
|
333
|
+
? [
|
|
334
|
+
"Card that to the operator and STOP. Do not begin the delivery loop on this build — a run started",
|
|
335
|
+
"here can fail at the last step with the org already changed, which is the expensive way to find out."
|
|
336
|
+
]
|
|
337
|
+
: [])
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
// `stale` without an install result should not happen — activation installs before composing
|
|
341
|
+
// this notice — but the function must stay honest for any caller: a build too old to run gets a
|
|
342
|
+
// stop, never a "pending" line that reads as permission.
|
|
343
|
+
if (update.staleness === "stale") {
|
|
344
|
+
return [
|
|
345
|
+
"",
|
|
346
|
+
`STOP — THIS BUILD IS TOO OLD TO START A BATCH: ${versions}`,
|
|
347
|
+
"A build this far behind has repeatedly cost a whole run: the failure lands at the LAST step, after the",
|
|
348
|
+
"org has been changed, from a bug that was already fixed. Run `update_server {apply: true}`, then tell",
|
|
349
|
+
"the operator to START A NEW CHAT and paste their activation prompt again — nothing is lost by restarting.",
|
|
350
|
+
"Do not begin the delivery loop in this session.",
|
|
351
|
+
`(Deliberate exception — developing against a pinned build: set CC_MCP_STALE_AFTER_DAYS=0 to disable this block.)`
|
|
352
|
+
];
|
|
353
|
+
}
|
|
354
|
+
// `behind` — informational, and deliberately actionless: every imperative wording of this line
|
|
355
|
+
// was ignored three QA rounds running, so the action moved to close_batch where no one can skip it.
|
|
356
|
+
return [
|
|
357
|
+
"",
|
|
358
|
+
`UPDATE PENDING: ${versions}`,
|
|
359
|
+
"Nothing to do now — it installs automatically when this batch closes, and the NEXT session runs it.",
|
|
360
|
+
"Do not run update_server mid-batch."
|
|
361
|
+
];
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Browser flows this process has launched, by alias.
|
|
365
|
+
*
|
|
366
|
+
* This is what lets ONE tool both start a login and report on one: with no flow outstanding the
|
|
367
|
+
* call means "log this org in", and with a flow outstanding the same call means "how is it going".
|
|
368
|
+
*
|
|
369
|
+
* Deliberately in-process and not on disk. A restarted server has no browser window of its own to
|
|
370
|
+
* poll and no way to know whether the one it never opened is still alive, so launching again is the
|
|
371
|
+
* right answer there — which is exactly what the two-tool surface did, since `org_login` relaunched
|
|
372
|
+
* on every call. `mode: "restart"` is the escape hatch for a window that died within one process.
|
|
373
|
+
*/
|
|
374
|
+
const pendingLogins = new Map();
|
|
375
|
+
/** The login log's tail, for a status report that has to explain a flow that did not finish. */
|
|
376
|
+
async function loginLogTail(alias) {
|
|
377
|
+
try {
|
|
378
|
+
const log = await fs.readFile(authLogPath(alias), "utf8");
|
|
379
|
+
return `\n\nlogin log tail:\n${log.slice(-800)}`;
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return "";
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// org_login registers UNCONDITIONALLY. It used to be gated behind CC_MCP_ORG_LOGIN=1.
|
|
386
|
+
//
|
|
387
|
+
// The gating argument (2026-07-28, a412d3c) was that the tool had never fired across CF73-76 and
|
|
388
|
+
// that unused tool surface is a per-call cost. The argument that beat it is that the case this tool
|
|
389
|
+
// was kept for — named in the gating comment itself as "a session whose org auth expires mid-run,
|
|
390
|
+
// with no fresh code to hand" — then fired twice and killed both runs, while CC_MCP_ORG_LOGIN was
|
|
391
|
+
// set NOWHERE outside its own test: not in setup.sh's generated launcher, not in run-server.sh. The
|
|
392
|
+
// tool was therefore off in 100% of real installs at the exact moment both resident doctrine
|
|
393
|
+
// surfaces were telling the model to call it, the playbook routing `ORG_ID_UNRESOLVED` / "No
|
|
394
|
+
// authorization information found" — the error a dead Campfire binding actually produces — straight
|
|
395
|
+
// at it. Doctrine naming a tool the server does not register is worse than either half alone; the
|
|
396
|
+
// model improvises into the vacuum, and on both incidents it improvised "start a new session".
|
|
397
|
+
//
|
|
398
|
+
// Both recoveries the doctrine now offers run through here: the target org's activation token, which
|
|
399
|
+
// expires in about a day, and the Campfire org, whose credential this product never mints at all.
|
|
400
|
+
// `authRegistration.test.ts` pins the unconditional registration against the doctrine that names it.
|
|
401
|
+
export function registerAuthTools(server) {
|
|
402
|
+
server.registerTool("org_login", {
|
|
403
|
+
description: "The whole org-authentication lifecycle for one alias: START the interactive Salesforce OAuth login (sf CLI web-server flow), and REPORT where it stands. With `mode` omitted it starts a flow when the alias is not connected and none is already pending, and reports otherwise — so the operator flow is: call it, they finish the login in their browser, call it again until it says CONNECTED. The operator authenticates directly with Salesforce; this tool never asks for or handles a username, password, or token. NON-BLOCKING: the flow is launched detached and the call returns immediately. A CONNECTED result names the orgId/username/instance behind the alias AND whether direct_apply may write to that org — which makes `mode: \"status\"` the right check BEFORE any write, since an alias is operator-controlled and can be repointed. When the org is connected but NOT on the direct-write allowlist, the result carries the exact re-registration command the operator needs, because server env is fixed at spawn. CONNECTED is the only status that counts as authenticated — an expired alias gets a fresh flow. Sandboxes/My Domain: pass instanceUrl (e.g. https://test.salesforce.com or the My Domain URL). Nothing about the org is read or changed here; the login only creates local CLI auth.",
|
|
404
|
+
inputSchema: {
|
|
405
|
+
alias: z.string().describe("Local alias to store the auth under, e.g. mytest. Used as targetOrg everywhere else."),
|
|
406
|
+
mode: z
|
|
407
|
+
.enum(["auto", "status", "restart"])
|
|
408
|
+
.optional()
|
|
409
|
+
.describe("auto (default): launch only if the alias is not connected and no flow from this server is pending; otherwise report. status: never launch — poll a flow already running, or confirm which org an alias points at before a write. restart: always launch a fresh browser flow, even if the alias is already connected — re-authenticates an alias whose auth expired or whose window died."),
|
|
410
|
+
instanceUrl: z
|
|
411
|
+
.string()
|
|
412
|
+
.optional()
|
|
413
|
+
.describe("Login host for sandboxes / My Domain, e.g. https://test.salesforce.com. Defaults to production login."),
|
|
414
|
+
setDefault: z.boolean().optional().describe("Also set this org as the CLI's default target org.")
|
|
415
|
+
}
|
|
416
|
+
}, async ({ alias, mode, instanceUrl, setDefault }) => {
|
|
417
|
+
try {
|
|
418
|
+
if (!ALIAS_RE.test(alias)) {
|
|
419
|
+
return fail("INVALID_ALIAS", `alias must match ${ALIAS_RE} (letter first, then letters/digits/._-).`);
|
|
420
|
+
}
|
|
421
|
+
if (instanceUrl !== undefined) {
|
|
422
|
+
let parsed;
|
|
423
|
+
try {
|
|
424
|
+
parsed = new URL(instanceUrl);
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return fail("INVALID_INSTANCE_URL", `instanceUrl is not a URL: ${instanceUrl}`);
|
|
428
|
+
}
|
|
429
|
+
if (parsed.protocol !== "https:") {
|
|
430
|
+
return fail("INVALID_INSTANCE_URL", "instanceUrl must be https — refusing to start an OAuth flow over a non-TLS host.");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const launch = async () => {
|
|
434
|
+
const logFile = authLogPath(alias);
|
|
435
|
+
await fs.mkdir(path.dirname(logFile), { recursive: true });
|
|
436
|
+
await fs.writeFile(logFile, `--- org_login ${alias} @ ${new Date().toISOString()} ---\n`, "utf8");
|
|
437
|
+
const args = [
|
|
438
|
+
"org",
|
|
439
|
+
"login",
|
|
440
|
+
"web",
|
|
441
|
+
"--alias",
|
|
442
|
+
alias,
|
|
443
|
+
...(instanceUrl ? ["--instance-url", instanceUrl] : []),
|
|
444
|
+
...(setDefault ? ["--set-default"] : [])
|
|
445
|
+
];
|
|
446
|
+
const { pid } = spawnDetached("sf", args, { env: sfCliEnv(), logFile });
|
|
447
|
+
pendingLogins.set(alias, { ...(pid !== undefined ? { pid } : {}), startedAt: new Date().toISOString() });
|
|
448
|
+
return ok([
|
|
449
|
+
`Browser OAuth flow LAUNCHED for alias ${alias}${instanceUrl ? ` (instance ${instanceUrl})` : ""} — pid ${pid ?? "unknown"}.`,
|
|
450
|
+
"",
|
|
451
|
+
"The operator must now complete the login in the browser window that just opened (Salesforce login + Allow).",
|
|
452
|
+
"This tool does not wait. Call org_login { alias } again to check — poll until it reports CONNECTED.",
|
|
453
|
+
`Login output is teed to ${logFile} (a not-yet-connected report shows its tail).`,
|
|
454
|
+
"",
|
|
455
|
+
"If no browser opened: the operator can run the same flow themselves —",
|
|
456
|
+
` sf org login web --alias ${alias}${instanceUrl ? ` --instance-url ${instanceUrl}` : ""}`
|
|
457
|
+
].join("\n"), { alias, pid, logFile, launched: true, connected: false });
|
|
458
|
+
};
|
|
459
|
+
// Restart is answered BEFORE the org is read, because it is the one mode whose whole point
|
|
460
|
+
// is to re-authenticate an alias that already looks fine — a connected short-circuit would
|
|
461
|
+
// make it unreachable in exactly the case (an alias pointing at the wrong org) it exists for.
|
|
462
|
+
if (mode === "restart")
|
|
463
|
+
return await launch();
|
|
464
|
+
const display = await readOrgDisplay(alias);
|
|
465
|
+
// CONNECTED is the only state that counts as authenticated. A readable alias whose status
|
|
466
|
+
// is anything else (expired, revoked) still needs a login, and the tool this merged with
|
|
467
|
+
// launched one for exactly that case — only a Connected alias short-circuited it.
|
|
468
|
+
const connected = display.ok && display.result.connectedStatus === "Connected";
|
|
469
|
+
if (connected)
|
|
470
|
+
pendingLogins.delete(alias);
|
|
471
|
+
const pending = pendingLogins.get(alias);
|
|
472
|
+
if (!connected && mode !== "status" && !pending)
|
|
473
|
+
return await launch();
|
|
474
|
+
if (!display.ok) {
|
|
475
|
+
// A failed read and an unauthenticated alias are the same answer from `sf org display`,
|
|
476
|
+
// so neither may ever be reported as connected. The log tail is what distinguishes them
|
|
477
|
+
// for a human.
|
|
478
|
+
return ok([
|
|
479
|
+
`alias ${alias}: NOT yet authenticated (${display.detail}).`,
|
|
480
|
+
pending
|
|
481
|
+
? `A browser flow launched by this server is still outstanding (pid ${pending.pid ?? "unknown"}, started ${pending.startedAt}) — the operator has not finished it yet. Poll again, or pass mode:"restart" to open a fresh one.`
|
|
482
|
+
: "If a browser flow is in progress, the operator has not finished it yet — poll again.",
|
|
483
|
+
await loginLogTail(alias)
|
|
484
|
+
].join("\n"), {
|
|
485
|
+
alias,
|
|
486
|
+
connected: false,
|
|
487
|
+
launched: false,
|
|
488
|
+
...(pending ? { pending: true } : {}),
|
|
489
|
+
detail: display.detail
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const r = display.result;
|
|
493
|
+
const { directAllowlistedOrgIds, directAllowlistOpen, isConsultantOrg } = await import("../core/gate.js");
|
|
494
|
+
const allow = directAllowlistedOrgIds();
|
|
495
|
+
const short = (r.id ?? "").slice(0, 15);
|
|
496
|
+
const consultant = isConsultantOrg(r.id ?? "");
|
|
497
|
+
const wildcard = directAllowlistOpen();
|
|
498
|
+
const writeReady = !consultant && (wildcard || allow.includes(short));
|
|
499
|
+
return ok([
|
|
500
|
+
`alias ${alias}: ${connected ? "CONNECTED — no login needed" : `status ${r.connectedStatus ?? "unknown"}`}`,
|
|
501
|
+
`orgId: ${r.id}`,
|
|
502
|
+
`username: ${r.username}`,
|
|
503
|
+
`instance: ${r.instanceUrl}`,
|
|
504
|
+
"",
|
|
505
|
+
writeReady
|
|
506
|
+
? `direct-write: OK — this org is writable by direct_apply${wildcard ? " (allowlist is WIDE OPEN via CC_MCP_DIRECT_ALLOWLIST=*)" : ""}.`
|
|
507
|
+
: consultant
|
|
508
|
+
? "direct-write: HARD-REFUSED — this is the REAL shared consultant org. The direct tier refuses it by org id no matter what the allowlist says, and no re-registration changes that. Reads are fine; Campfire writes ride their own CC_MCP_CAMPFIRE_WRITE gate."
|
|
509
|
+
: [
|
|
510
|
+
"direct-write allowlist: NOT LISTED — reads (project_org, sf_read) work now, but direct_apply will refuse.",
|
|
511
|
+
"Server env is fixed at spawn, so the operator must re-register and start a fresh session:",
|
|
512
|
+
` claude mcp add cc-mcp --scope user --env CC_MCP_DIRECT_WRITE=1 --env CC_MCP_DIRECT_ALLOWLIST=${r.id} -- <node24> <path>/apps/cc-mcp-server/dist/index.js`
|
|
513
|
+
].join("\n")
|
|
514
|
+
].join("\n"), {
|
|
515
|
+
alias,
|
|
516
|
+
connected,
|
|
517
|
+
// Retained from the tool this merged with, where it meant "no browser window was
|
|
518
|
+
// opened for you". `launched: false` says the same thing; both ship so a caller
|
|
519
|
+
// keying on either still reads correctly.
|
|
520
|
+
...(connected ? { alreadyConnected: true } : {}),
|
|
521
|
+
launched: false,
|
|
522
|
+
...(pending ? { pending: true } : {}),
|
|
523
|
+
orgId: r.id,
|
|
524
|
+
username: r.username,
|
|
525
|
+
instanceUrl: r.instanceUrl,
|
|
526
|
+
directWriteAllowlisted: writeReady,
|
|
527
|
+
consultantOrgRefused: consultant
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
return internalError(error);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* What DELIVERY needs from an activation: an auth URL to log the CLI in with, and an org to bind
|
|
537
|
+
* the session to. Nothing else. Everything the ledger wants is enrichment, so an older launch
|
|
538
|
+
* service, a rolled-back deploy, or an unreachable reporting database cannot stop a consultant.
|
|
539
|
+
*/
|
|
540
|
+
export function activationIsUsable(payload) {
|
|
541
|
+
return Boolean(payload.authUrl && payload.orgId);
|
|
542
|
+
}
|
|
543
|
+
export function activationEnrichment(payload) {
|
|
544
|
+
return {
|
|
545
|
+
...(payload.batchId ? { batchId: payload.batchId } : {}),
|
|
546
|
+
actors: {
|
|
547
|
+
...(payload.campfireUserId ? { campfireUserId: payload.campfireUserId } : {}),
|
|
548
|
+
...(payload.campfireOrgId ? { campfireOrgId: payload.campfireOrgId } : {}),
|
|
549
|
+
...(payload.targetUserId ? { targetUserId: payload.targetUserId } : {}),
|
|
550
|
+
targetUsername: payload.username
|
|
551
|
+
},
|
|
552
|
+
bindable: Boolean(payload.batchId && payload.telemetryGrant?.credential),
|
|
553
|
+
pmfDue: payload.pmfDue === true
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
// The launch service must be reached over TLS — the response body carries a refresh token.
|
|
557
|
+
// localhost is exempted so the flow can be developed and tested without certificates.
|
|
558
|
+
export function assertSafeLaunchUrl(raw) {
|
|
559
|
+
let url;
|
|
560
|
+
try {
|
|
561
|
+
url = new URL(raw);
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
throw new Error(`launchUrl is not a URL: ${raw}`);
|
|
565
|
+
}
|
|
566
|
+
const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
567
|
+
if (url.protocol !== "https:" && !isLocal) {
|
|
568
|
+
throw new Error(`launchUrl must be https (got ${url.protocol}//) — the activation response carries a refresh token and must not cross the network in plaintext.`);
|
|
569
|
+
}
|
|
570
|
+
return url;
|
|
571
|
+
}
|
|
572
|
+
// Campfire requirement -> the same prose shape composeCampfireProse produces locally, so a
|
|
573
|
+
// Campfire-started session and a locally-imported one are byte-identical. The launch payload is
|
|
574
|
+
// camelCase (it crossed Apex), so map it back onto the SOQL field names the composer expects.
|
|
575
|
+
export function proseFromActivation(req) {
|
|
576
|
+
return composeCampfireProse({
|
|
577
|
+
Id: req.id ?? "",
|
|
578
|
+
Name: req.reqId,
|
|
579
|
+
Requirement_Name__c: req.requirementName,
|
|
580
|
+
User_Story__c: req.userStory,
|
|
581
|
+
User_Story_Action__c: req.userStoryAction,
|
|
582
|
+
User_Story_Such_That__c: req.userStorySuchThat,
|
|
583
|
+
User_Story_Description__c: req.userStoryDescription,
|
|
584
|
+
Acceptance_Criteria_Given__c: req.acceptanceCriteriaGiven,
|
|
585
|
+
Acceptance_Criteria_When__c: req.acceptanceCriteriaWhen,
|
|
586
|
+
Acceptance_Criteria_Then__c: req.acceptanceCriteriaThen,
|
|
587
|
+
Solution_Design_Summary__c: req.solutionDesignSummary,
|
|
588
|
+
Requirement_Details__c: req.requirementDetails,
|
|
589
|
+
Solution_Details__c: req.solutionDetails
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
export function registerActivationTools(server) {
|
|
593
|
+
server.registerTool("activate_session", {
|
|
594
|
+
description: "Start a session from a Campfire activation code. The operator clicked 'Configure in external org' in Campfire, authenticated THEIR target org in the browser, and pasted a prompt containing a single-use code. This redeems it: authenticates the org into the sf CLI (no manual `sf` login), creates the session workspace, and imports the requirements they selected — replacing create_session + import_requirements for that flow. Codes are single-use and expire in minutes; a refused code means it was already redeemed, expired, or wrong, and the operator must re-send from Campfire. Reports which Salesforce USER the session will act as — surface that to the operator, it is the identity every org change will be attributed to. ALSO REPORTS THE CAMPFIRE ORG'S CONDITION, and a `CAMPFIRE ORG:` line in the result is a CARD YOU RAISE IMMEDIATELY: that org is never authenticated by this product, so a machine that has not signed into it will deliver every wave normally and then refuse the final writeback, thirty minutes in, with the org already changed. The result names the fix; relay it before starting the loop.",
|
|
595
|
+
inputSchema: {
|
|
596
|
+
code: z.string().describe("The activation code from the pasted starting prompt."),
|
|
597
|
+
launchUrl: z
|
|
598
|
+
.string()
|
|
599
|
+
.optional()
|
|
600
|
+
.describe("Base URL of the launch service. Defaults to CC_MCP_LAUNCH_URL. Must be https (localhost exempt)."),
|
|
601
|
+
alias: z.string().optional().describe("Local sf alias to store the org under. Default: in2-<mydomain>-<orgId15>."),
|
|
602
|
+
sessionId: z.string().optional().describe("Session id to create/reopen. Generated when omitted.")
|
|
603
|
+
}
|
|
604
|
+
}, async ({ code, launchUrl, alias, sessionId }) => {
|
|
605
|
+
try {
|
|
606
|
+
const base = (launchUrl ?? process.env.CC_MCP_LAUNCH_URL ?? "").trim();
|
|
607
|
+
if (!base) {
|
|
608
|
+
return fail("LAUNCH_URL_NOT_CONFIGURED", "No launch service URL. Pass launchUrl, or start the server with CC_MCP_LAUNCH_URL set.");
|
|
609
|
+
}
|
|
610
|
+
let endpoint;
|
|
611
|
+
try {
|
|
612
|
+
endpoint = assertSafeLaunchUrl(base);
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
return fail("INVALID_LAUNCH_URL", error instanceof Error ? error.message : String(error));
|
|
616
|
+
}
|
|
617
|
+
// The session identity exists before redemption so the launch service can bind the
|
|
618
|
+
// activation, install, process, and local workspace in one authenticated exchange.
|
|
619
|
+
// Minted BEFORE redemption so the launch service can bind install/process/session in the
|
|
620
|
+
// one exchange the activation code authenticates. That moves the id earlier than it used
|
|
621
|
+
// to be, so its entropy can no longer come from the not-yet-known org — a random source
|
|
622
|
+
// replaces it, which is strictly stronger than the old timestamp+orgId pair.
|
|
623
|
+
const id = sessionId?.trim() || opaqueId("ses", { at: isoNow(), entropy: randomBytes(16).toString("hex") });
|
|
624
|
+
const processIdentity = processTelemetryIdentity() ?? {
|
|
625
|
+
installId: installId(),
|
|
626
|
+
runId: `run_uninstrumented_${process.pid}`,
|
|
627
|
+
serverVersion: installedVersion()
|
|
628
|
+
};
|
|
629
|
+
// 1. Redeem — exactly once, server-side.
|
|
630
|
+
const activateUrl = new URL("/api/activate", endpoint).toString();
|
|
631
|
+
let payload;
|
|
632
|
+
try {
|
|
633
|
+
const res = await fetch(activateUrl, {
|
|
634
|
+
method: "POST",
|
|
635
|
+
headers: { "content-type": "application/json" },
|
|
636
|
+
body: JSON.stringify({
|
|
637
|
+
code,
|
|
638
|
+
installId: processIdentity.installId,
|
|
639
|
+
runId: processIdentity.runId,
|
|
640
|
+
sessionId: id,
|
|
641
|
+
serverVersion: processIdentity.serverVersion
|
|
642
|
+
}),
|
|
643
|
+
signal: AbortSignal.timeout(30_000)
|
|
644
|
+
});
|
|
645
|
+
const json = (await res.json().catch(() => ({})));
|
|
646
|
+
if (!res.ok) {
|
|
647
|
+
return fail("ACTIVATION_REFUSED", json.error ?? `The launch service refused the code (HTTP ${res.status}). Re-send the requirements from Campfire to get a fresh one.`);
|
|
648
|
+
}
|
|
649
|
+
payload = json;
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
return fail("LAUNCH_SERVICE_UNREACHABLE", `Could not reach the launch service at ${activateUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
653
|
+
}
|
|
654
|
+
if (!activationIsUsable(payload)) {
|
|
655
|
+
return fail("ACTIVATION_INCOMPLETE", "The launch service returned no auth URL or org id.");
|
|
656
|
+
}
|
|
657
|
+
const enrichment = activationEnrichment(payload);
|
|
658
|
+
// 2. Authenticate the CLI. STDIN only — argv is visible in `ps`, and a temp file can
|
|
659
|
+
// outlive a crash.
|
|
660
|
+
const orgAlias = alias?.trim() || targetOrgAlias(payload.instanceUrl, payload.orgId);
|
|
661
|
+
if (!ALIAS_RE.test(orgAlias))
|
|
662
|
+
return fail("INVALID_ALIAS", `derived alias ${orgAlias} is not valid.`);
|
|
663
|
+
const login = await runProcess("sf",
|
|
664
|
+
// --sfdx-url-stdin MUST come last. oclif types it as a value flag (`--sfdx-url-stdin=<value>`)
|
|
665
|
+
// even though the documented usage is `echo url | sf org login sfdx-url --sfdx-url-stdin`,
|
|
666
|
+
// so anything after it gets swallowed as its value — live-caught: it ate `--alias` and the
|
|
667
|
+
// alias became a stray positional (exit 2). Last position leaves nothing to consume.
|
|
668
|
+
["org", "login", "sfdx-url", "--alias", orgAlias, "--json", "--sfdx-url-stdin"], { env: sfCliEnv(), timeoutMs: 120_000, input: payload.authUrl });
|
|
669
|
+
if (login.exitCode !== 0) {
|
|
670
|
+
// Never echo the auth URL, even on failure.
|
|
671
|
+
return fail("ORG_LOGIN_FAILED", `sf org login sfdx-url failed (exit ${login.exitCode}). The activation code was consumed, so a retry needs a fresh one from Campfire.\n${login.stderr.slice(-400) || login.stdout.slice(-400)}`);
|
|
672
|
+
}
|
|
673
|
+
// 3. Confirm the CLI really landed on the org the launch service asserted. A mismatch
|
|
674
|
+
// means the payload was not what it claimed — refuse rather than deliver elsewhere.
|
|
675
|
+
const display = await readOrgDisplay(orgAlias);
|
|
676
|
+
if (!display.ok) {
|
|
677
|
+
return fail("ORG_VERIFY_FAILED", `Authenticated but could not read the org back: ${display.detail}`);
|
|
678
|
+
}
|
|
679
|
+
const landed = (display.result.id ?? "").slice(0, 15);
|
|
680
|
+
if (landed !== payload.orgId.slice(0, 15)) {
|
|
681
|
+
return fail("ORG_ID_MISMATCH", `The launch service said org ${payload.orgId} but the CLI authenticated ${display.result.id}. Refusing fail-closed.`);
|
|
682
|
+
}
|
|
683
|
+
// 3b. Grant direct-tier write authority to EXACTLY this org.
|
|
684
|
+
//
|
|
685
|
+
// This is the only place a grant is minted, and it sits deliberately after the mismatch
|
|
686
|
+
// check above: authority is issued against an org id the CLI actually authenticated and
|
|
687
|
+
// that the launch service independently named. Neither the operator nor the model chooses
|
|
688
|
+
// it, which is what makes it a safe replacement for CC_MCP_DIRECT_ALLOWLIST=* — that
|
|
689
|
+
// wildcard authorizes every org the machine is logged into, including, for a consultant,
|
|
690
|
+
// their own production org.
|
|
691
|
+
const grant = await recordDirectWriteGrant({
|
|
692
|
+
orgId: display.result.id ?? payload.orgId,
|
|
693
|
+
alias: orgAlias,
|
|
694
|
+
source: "campfire-activation",
|
|
695
|
+
...(display.result.username ? { username: display.result.username } : {})
|
|
696
|
+
});
|
|
697
|
+
// 3c. Bind the Campfire org too, and SAY SO WHEN IT CANNOT BE BOUND. The requirements in
|
|
698
|
+
// this session CAME from Campfire, so the session genuinely has a consultant org — recording
|
|
699
|
+
// it here is what makes writeback possible from an activated session without the operator
|
|
700
|
+
// restating an alias.
|
|
701
|
+
//
|
|
702
|
+
// The result now carries the condition rather than only the success case. This used to
|
|
703
|
+
// record null on a miss and say nothing, which is how a fresh machine reached step 8 of 9
|
|
704
|
+
// before discovering that writeback was never possible (see resolveCampfireBinding).
|
|
705
|
+
const campfireOrgId = payload.campfireOrgId ?? campfireAllowlistedOrgIds()[0];
|
|
706
|
+
let campfire = await resolveCampfireBinding(campfireOrgId, { aliasForOrgId, readOrgDisplay });
|
|
707
|
+
// The launch service may have minted a Campfire credential in the same browser round-trip
|
|
708
|
+
// that authenticated the target org. Log it in exactly as the target org's was — STDIN
|
|
709
|
+
// only, never argv, never disk — and then RE-READ the binding rather than assuming the
|
|
710
|
+
// login worked. "We logged something in" and "writeback will work" are different claims,
|
|
711
|
+
// and only the second is worth reporting.
|
|
712
|
+
//
|
|
713
|
+
// Every failure here is non-fatal: the delivery does not depend on this org, and the
|
|
714
|
+
// notice below already names the manual fix.
|
|
715
|
+
const plan = campfireLoginPlan(campfire, payload.campfireAuthUrl ? { authUrl: payload.campfireAuthUrl, ...(payload.campfireInstanceUrl ? { instanceUrl: payload.campfireInstanceUrl } : {}) } : undefined, campfireOrgId);
|
|
716
|
+
if (plan.login && ALIAS_RE.test(plan.alias)) {
|
|
717
|
+
const campfireLogin = await runProcess("sf",
|
|
718
|
+
// --sfdx-url-stdin LAST, for the same oclif reason as the target-org login above: it is
|
|
719
|
+
// typed as a value flag and swallows whatever follows it.
|
|
720
|
+
["org", "login", "sfdx-url", "--alias", plan.alias, "--json", "--sfdx-url-stdin"], { env: sfCliEnv(), timeoutMs: 120_000, input: payload.campfireAuthUrl });
|
|
721
|
+
if (campfireLogin.exitCode === 0) {
|
|
722
|
+
campfire = await resolveCampfireBinding(campfireOrgId, { aliasForOrgId, readOrgDisplay });
|
|
723
|
+
}
|
|
724
|
+
// A failed Campfire login is never echoed — the auth URL carries a refresh token, and the
|
|
725
|
+
// CLI's error text can quote its own argv.
|
|
726
|
+
}
|
|
727
|
+
const campfireBinding = campfire.status === "bound" ? { alias: campfire.alias, orgId: campfire.orgId } : null;
|
|
728
|
+
// 4. Session + requirements, through the SAME path a local import uses.
|
|
729
|
+
const file = sessionFile(id);
|
|
730
|
+
let session;
|
|
731
|
+
if (fileExists(file)) {
|
|
732
|
+
session = await readJson(file);
|
|
733
|
+
// First batch wins. Re-activating an existing session into a different batch is a
|
|
734
|
+
// reporting ambiguity, not a delivery problem, so the original attribution is kept and
|
|
735
|
+
// the activation proceeds exactly as it did before the ledger existed.
|
|
736
|
+
if (!session.batchId && enrichment.batchId)
|
|
737
|
+
session.batchId = enrichment.batchId;
|
|
738
|
+
session.actors = enrichment.actors;
|
|
739
|
+
// Written on both branches, and STICKY on a re-activation: once a session has been told
|
|
740
|
+
// the question is due, a second activation that could not reach the ledger must not
|
|
741
|
+
// silently retract it. The flag can only be cleared by recording an answer.
|
|
742
|
+
if (enrichment.pmfDue)
|
|
743
|
+
session.pmfDue = true;
|
|
744
|
+
if (campfireBinding && !session.campfireOrg?.alias) {
|
|
745
|
+
session.campfireOrg = campfireBinding;
|
|
746
|
+
}
|
|
747
|
+
await writeJsonAtomic(file, session);
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
session = {
|
|
751
|
+
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
752
|
+
sessionId: id,
|
|
753
|
+
...(enrichment.batchId ? { batchId: enrichment.batchId } : {}),
|
|
754
|
+
actors: enrichment.actors,
|
|
755
|
+
requirementBatchRevisionId: opaqueId("rbrev", { sessionId: id }),
|
|
756
|
+
...(enrichment.pmfDue ? { pmfDue: true } : {}),
|
|
757
|
+
targetOrg: { alias: orgAlias, orgId: display.result.id ?? payload.orgId },
|
|
758
|
+
...(campfireBinding ? { campfireOrg: campfireBinding } : {}),
|
|
759
|
+
producer: await producerIdentity(),
|
|
760
|
+
createdAt: isoNow()
|
|
761
|
+
};
|
|
762
|
+
await ensureDir(sessionDir(id));
|
|
763
|
+
await writeJsonAtomic(file, session);
|
|
764
|
+
}
|
|
765
|
+
const entries = payload.requirements.map((r) => ({
|
|
766
|
+
extId: r.reqId,
|
|
767
|
+
title: r.requirementName ?? r.reqId,
|
|
768
|
+
text: proseFromActivation(r),
|
|
769
|
+
...(r.id ? { campfireId: r.id } : {})
|
|
770
|
+
}));
|
|
771
|
+
const imported = entries.length > 0 ? await importRequirementEntries(id, session, entries) : [];
|
|
772
|
+
// No batch id or no grant means no correlated destination, so nothing is bound and nothing
|
|
773
|
+
// is emitted. The run continues exactly as it would have before the ledger existed.
|
|
774
|
+
if (enrichment.bindable && session.batchId) {
|
|
775
|
+
bindBatchContext({
|
|
776
|
+
// The session's own attribution, so events and the session record cannot disagree.
|
|
777
|
+
batchId: session.batchId,
|
|
778
|
+
installId: processIdentity.installId,
|
|
779
|
+
runId: processIdentity.runId,
|
|
780
|
+
sessionId: id,
|
|
781
|
+
serverVersion: processIdentity.serverVersion,
|
|
782
|
+
credential: payload.telemetryGrant.credential,
|
|
783
|
+
expiresAt: payload.telemetryGrant.expiresAt,
|
|
784
|
+
endpoint: new URL("/api/telemetry/v2", endpoint).toString()
|
|
785
|
+
});
|
|
786
|
+
emitDomainTelemetryEvent("requirements.imported", {
|
|
787
|
+
requirements: imported.flatMap((requirement) => requirement.inputContentHash
|
|
788
|
+
? [{
|
|
789
|
+
externalRequirementId: requirement.extId,
|
|
790
|
+
requirementRevisionId: requirement.requirementRevisionId,
|
|
791
|
+
inputContentHash: requirement.inputContentHash
|
|
792
|
+
}]
|
|
793
|
+
: [])
|
|
794
|
+
}, { stage: "intake", outcome: "imported" });
|
|
795
|
+
emitDomainTelemetryEvent("batch.activated", { requirementCount: imported.length }, { stage: "intake", outcome: "activated" });
|
|
796
|
+
}
|
|
797
|
+
// Preflight. This is the one moment a stale install can be caught cheaply: it is the first
|
|
798
|
+
// call of every session, the machine is demonstrably online (a code was just redeemed), and
|
|
799
|
+
// nothing has been read or written yet — so acting on the answer costs the operator
|
|
800
|
+
// nothing. Discovering it later means a half-finished delivery run on old code.
|
|
801
|
+
// checkForUpdate never throws; an offline machine simply reports nothing to do.
|
|
802
|
+
const update = await checkForUpdate();
|
|
803
|
+
// AND ACT ON IT, rather than mentioning it. LIVE-CAUGHT (QA round 2, 2026-08-06): a tester
|
|
804
|
+
// ran a full batch on a build 41.6 hours old after being shown `UPDATE AVAILABLE` here, then
|
|
805
|
+
// updated by hand in a later session — the third time across two QA rounds that this line was
|
|
806
|
+
// read and not acted on. Under npm distribution the acting splits by staleness:
|
|
807
|
+
// stale — install NOW. The run must not start on this build anyway (the round-1 gate), so
|
|
808
|
+
// nothing is in flight for an in-place npm install to skew, and the operator's
|
|
809
|
+
// restart lands on the new version instead of on the same stale one.
|
|
810
|
+
// behind — safe to run, so nothing installs mid-run (an in-place install can hand a later
|
|
811
|
+
// lazy import a different build's module). close_batch installs it after the
|
|
812
|
+
// evidence ships, so the NEXT session is current with no one asked to do anything.
|
|
813
|
+
// A build close_batch installed UNDER this still-running process poisons any further
|
|
814
|
+
// activation here: the disk (and installedVersion's memo notwithstanding, every future
|
|
815
|
+
// lazy import) is the new build while the loaded modules are the old one. The successor
|
|
816
|
+
// batch is legitimate — in a NEW conversation. Say so instead of starting the loop.
|
|
817
|
+
const installedUnder = updateInstalledUnderThisProcess();
|
|
818
|
+
const restartNotice = installedUnder
|
|
819
|
+
? [
|
|
820
|
+
"",
|
|
821
|
+
`STOP — A SERVER UPDATE (${installedUnder}) WAS INSTALLED WHEN THE PREVIOUS BATCH CLOSED, and this conversation is still running the build before it.`,
|
|
822
|
+
"Running another batch here would mix two builds' code. Tell the operator to START A NEW CHAT and",
|
|
823
|
+
"paste this activation prompt again — the activation itself succeeded and nothing is lost. Then STOP."
|
|
824
|
+
]
|
|
825
|
+
: [];
|
|
826
|
+
const action = updateActionFor(update);
|
|
827
|
+
let installed;
|
|
828
|
+
if (action === "stop-and-update") {
|
|
829
|
+
// DETACHED, not awaited. The activation code was redeemed (single-use) several steps
|
|
830
|
+
// ago, and an awaited `npm install -g` can hold this tool call for minutes on a cold
|
|
831
|
+
// cache — a client timeout then reads as a failed activation whose retry is impossible.
|
|
832
|
+
// The session stops on this path either way, so npm runs in the background and the next
|
|
833
|
+
// session's gate re-checks; a start that failed silently just fires the gate again.
|
|
834
|
+
const started = await startDetachedInstall();
|
|
835
|
+
installed = started.ok
|
|
836
|
+
? { ok: true, fromVersion: update.currentVersion, toVersion: started.toVersion, updated: true }
|
|
837
|
+
: { ok: false, fromVersion: update.currentVersion, updated: false, failure: started.failure, error: started.error };
|
|
838
|
+
}
|
|
839
|
+
else if (action === "update-at-close") {
|
|
840
|
+
scheduleUpdateAtClose();
|
|
841
|
+
}
|
|
842
|
+
// The live-progress viewer. Read-only, spawned detached, and reused when one is already
|
|
843
|
+
// serving — see src/core/viewer.ts for why nothing here can fail the activation. Awaited
|
|
844
|
+
// rather than fired-and-forgotten only because the result text needs to know whether to
|
|
845
|
+
// print a URL or a WARN, and the whole thing is capped at ~2s.
|
|
846
|
+
const viewer = await ensureViewer().catch((error) => ({
|
|
847
|
+
status: "warn",
|
|
848
|
+
url: viewerBaseUrl(),
|
|
849
|
+
port: viewerPort(),
|
|
850
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
851
|
+
}));
|
|
852
|
+
return ok([
|
|
853
|
+
`Session ${id} ACTIVATED from Campfire.`,
|
|
854
|
+
"",
|
|
855
|
+
`Acting as: ${payload.username} (every org change will be attributed to this user)`,
|
|
856
|
+
`Target org: ${display.result.id} — alias ${orgAlias}`,
|
|
857
|
+
`Instance: ${display.result.instanceUrl}`,
|
|
858
|
+
// Say what authority was just granted, and how narrow it is. An operator should never
|
|
859
|
+
// have to infer which orgs this install can write to.
|
|
860
|
+
`Write authority: THIS org only (${grant.orgId}), until ${grant.expiresAt.slice(0, 10)}. No other org is writable by this install.`,
|
|
861
|
+
// Provider narrowness belongs beside org narrowness: a machine that also carries another
|
|
862
|
+
// Salesforce MCP must be told, at the same moment it learns which org is writable, which
|
|
863
|
+
// MCP is doing the writing.
|
|
864
|
+
`Provider: this batch runs on the IN2 MCP alone. Do not use any other Salesforce MCP for its reads, deploys, verification, rollback, or close.`,
|
|
865
|
+
`Requirements imported: ${imported.length}`,
|
|
866
|
+
...imported.map((e) => ` ${e.extId} — ${e.title}`),
|
|
867
|
+
// Placed ABOVE the update notice and the next-steps block deliberately: it is the one
|
|
868
|
+
// thing in this result that is cheap to fix now and expensive to discover in thirty
|
|
869
|
+
// minutes. Empty when the Campfire org is live, which is the normal case.
|
|
870
|
+
...campfireBindingNotice(campfire),
|
|
871
|
+
...restartNotice,
|
|
872
|
+
...updateNotice(update, installed),
|
|
873
|
+
// The one line the operator wants immediately: where to watch. Relayed verbatim —
|
|
874
|
+
// never invent a URL, and never a different session's.
|
|
875
|
+
...viewerNotice(viewer, id),
|
|
876
|
+
"",
|
|
877
|
+
// The operator's prompt is one line; they will not describe the workflow. State the
|
|
878
|
+
// next steps outright rather than pointing at "the standard loop" — the model may
|
|
879
|
+
// never have seen that document, since it only loads from a specific directory.
|
|
880
|
+
"The org is authenticated in the sf CLI; no manual login is needed.",
|
|
881
|
+
"",
|
|
882
|
+
"NEXT, without asking the operator how to proceed:",
|
|
883
|
+
" 1. Read all imported requirements above and form the batch model (shared objects, the dependency ladder between them, cross-requirement ambiguity).",
|
|
884
|
+
" 2. `project_org` ONCE over the touched scope — it is ground truth, and requirement-asserted API names are untrusted until confirmed there.",
|
|
885
|
+
" 3. Fan out one plan-requirement analyzer per requirement against that projection; each writes its plan.json ownership (which is what maps Configuration Items to requirements later).",
|
|
886
|
+
" 4. Come back with ONE clarification card, then ONE approval card. Change nothing in the org before they approve.",
|
|
887
|
+
...(enrichment.pmfDue
|
|
888
|
+
? [
|
|
889
|
+
"",
|
|
890
|
+
// The one run-scoped instruction the static contract cannot carry: it is composed
|
|
891
|
+
// at connect time from the environment, long before any activation happened, so
|
|
892
|
+
// this is the only channel that can tell THIS session the ask is due.
|
|
893
|
+
"FEEDBACK: the relational question is DUE for this operator on this batch. At close, after the three usual cards, ask ONE more: \"How would you feel if you could no longer use IN2?\" — Very disappointed / Somewhat disappointed / Not disappointed / I no longer use IN2 — and record it with `record_operator_feedback {pmfAnswer}`. It is due because of THEIR batch history, not this run's; never ask it on a session that was not told to."
|
|
894
|
+
]
|
|
895
|
+
: [])
|
|
896
|
+
].join("\n"), {
|
|
897
|
+
sessionId: id,
|
|
898
|
+
...(enrichment.batchId ? { batchId: enrichment.batchId } : {}),
|
|
899
|
+
orgAlias,
|
|
900
|
+
orgId: display.result.id,
|
|
901
|
+
...(enrichment.actors.targetUserId ? { targetUserId: enrichment.actors.targetUserId } : {}),
|
|
902
|
+
username: payload.username,
|
|
903
|
+
// Structured too, so a caller keying on the field sees the same answer as the prose.
|
|
904
|
+
campfireOrgStatus: campfire.status,
|
|
905
|
+
...("alias" in campfire ? { campfireOrgAlias: campfire.alias } : {}),
|
|
906
|
+
...(viewer.status === "warn" ? {} : { viewerUrl: `${viewer.url}/?session=${encodeURIComponent(id)}` }),
|
|
907
|
+
...updateStructuredFields(update, installed),
|
|
908
|
+
imported: imported
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
catch (error) {
|
|
912
|
+
return internalError(error);
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
}
|