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.
Files changed (115) hide show
  1. package/README.md +369 -0
  2. package/agents/plan-requirement.md +32 -0
  3. package/dist/campfire/configItem.js +201 -0
  4. package/dist/campfire/configItemDescription.js +379 -0
  5. package/dist/campfire/deriveFromBundle.js +1013 -0
  6. package/dist/cli.js +465 -0
  7. package/dist/core/betaFeedback.js +493 -0
  8. package/dist/core/chapterInstructions.js +473 -0
  9. package/dist/core/claudeAssets.js +61 -0
  10. package/dist/core/gate.js +201 -0
  11. package/dist/core/ids.js +9 -0
  12. package/dist/core/install.js +60 -0
  13. package/dist/core/operatorQa.js +462 -0
  14. package/dist/core/orgCallMeter.js +178 -0
  15. package/dist/core/parcels.js +1235 -0
  16. package/dist/core/paths.js +105 -0
  17. package/dist/core/phases.js +225 -0
  18. package/dist/core/planFile.js +251 -0
  19. package/dist/core/planFragments.js +119 -0
  20. package/dist/core/readsLog.js +398 -0
  21. package/dist/core/registration.js +24 -0
  22. package/dist/core/result.js +38 -0
  23. package/dist/core/resume.js +233 -0
  24. package/dist/core/revisionChain.js +351 -0
  25. package/dist/core/richText.js +61 -0
  26. package/dist/core/semanticHash.js +89 -0
  27. package/dist/core/sessionOrg.js +60 -0
  28. package/dist/core/sfQuery.js +19 -0
  29. package/dist/core/spawn.js +182 -0
  30. package/dist/core/supervisorContract.js +230 -0
  31. package/dist/core/telemetry.js +1297 -0
  32. package/dist/core/telemetryContext.js +154 -0
  33. package/dist/core/telemetrySpool.js +244 -0
  34. package/dist/core/transcriptUpload.js +516 -0
  35. package/dist/core/update.js +363 -0
  36. package/dist/core/viewer.js +117 -0
  37. package/dist/core/workspace.js +92 -0
  38. package/dist/core/writeGrants.js +93 -0
  39. package/dist/core/writebackManifest.js +92 -0
  40. package/dist/direct/applyLoop.js +2516 -0
  41. package/dist/direct/batchReport.js +510 -0
  42. package/dist/direct/comparatorNoise.js +75 -0
  43. package/dist/direct/containers.js +85 -0
  44. package/dist/direct/contamination.js +221 -0
  45. package/dist/direct/crud.js +56 -0
  46. package/dist/direct/destructive.js +168 -0
  47. package/dist/direct/knownTokens.js +94 -0
  48. package/dist/direct/lint.js +382 -0
  49. package/dist/direct/metadataSchema.generated.json +1 -0
  50. package/dist/direct/metadataSchema.js +179 -0
  51. package/dist/direct/operatorPage.js +349 -0
  52. package/dist/direct/orgRepo.js +1531 -0
  53. package/dist/direct/orgRepoSpawnCount.js +21 -0
  54. package/dist/direct/plannedScope.js +119 -0
  55. package/dist/direct/preImageTargets.js +63 -0
  56. package/dist/direct/refVerify.js +191 -0
  57. package/dist/direct/registry.js +129 -0
  58. package/dist/direct/removals.js +262 -0
  59. package/dist/direct/rollback.js +1513 -0
  60. package/dist/direct/rollbackRun.js +1360 -0
  61. package/dist/direct/sessionProjection.js +126 -0
  62. package/dist/direct/snapshotComparability.js +165 -0
  63. package/dist/direct/sourceTree.js +113 -0
  64. package/dist/direct/unstatedChanges.js +106 -0
  65. package/dist/direct/waveAssembly.js +556 -0
  66. package/dist/direct/waveCounts.js +19 -0
  67. package/dist/direct/waveDerivation.js +152 -0
  68. package/dist/direct/waveGrade.js +185 -0
  69. package/dist/index.js +90 -0
  70. package/dist/orgread/customFieldSnapshot.js +48 -0
  71. package/dist/orgread/fieldMetadata.js +178 -0
  72. package/dist/orgread/genericSnapshot.js +61 -0
  73. package/dist/orgread/layoutSnapshot.js +151 -0
  74. package/dist/orgread/salesforceOrgReader.js +184 -0
  75. package/dist/orgread/snapshotTypes.js +9 -0
  76. package/dist/projection/bridge.js +26 -0
  77. package/dist/projection/diff.js +141 -0
  78. package/dist/projection/mapmd.js +213 -0
  79. package/dist/projection/nameAmbiguity.js +106 -0
  80. package/dist/projection/precedent.js +136 -0
  81. package/dist/projection/project.js +214 -0
  82. package/dist/projection/scope.js +110 -0
  83. package/dist/projection/types.js +5 -0
  84. package/dist/tools/analyze.js +322 -0
  85. package/dist/tools/auth.js +915 -0
  86. package/dist/tools/campfire.js +1742 -0
  87. package/dist/tools/closeBatch.js +154 -0
  88. package/dist/tools/closeText.js +35 -0
  89. package/dist/tools/direct.js +1613 -0
  90. package/dist/tools/feedback.js +318 -0
  91. package/dist/tools/operator.js +287 -0
  92. package/dist/tools/org.js +739 -0
  93. package/dist/tools/phase.js +249 -0
  94. package/dist/tools/projection.js +172 -0
  95. package/dist/tools/session.js +313 -0
  96. package/dist/tools/update.js +82 -0
  97. package/dist/tools/waves.js +793 -0
  98. package/package.json +48 -0
  99. package/reference/campfire-configuration-items-guide.md +710 -0
  100. package/reference/deployability-cribs.md +119 -0
  101. package/reference/operator-voice.md +141 -0
  102. package/reference/org-read-cribs.md +34 -0
  103. package/reference/scoped-dml-interim.md +68 -0
  104. package/reference/supervisor-playbook.md +313 -0
  105. package/reference/templates.md +233 -0
  106. package/skills/in2-delivery/SKILL.md +322 -0
  107. package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
  108. package/skills/in2-delivery/references/deployability-cribs.md +119 -0
  109. package/skills/in2-delivery/references/operator-voice.md +141 -0
  110. package/skills/in2-delivery/references/org-read-cribs.md +34 -0
  111. package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
  112. package/skills/in2-delivery/references/templates.md +233 -0
  113. package/skills/plan-requirement/SKILL.md +188 -0
  114. package/viewer/index.html +899 -0
  115. 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
+ }