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,1742 @@
1
+ import os from "node:os";
2
+ import { createHash } from "node:crypto";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { existsSync, promises as fs } from "node:fs";
6
+ import { z } from "zod";
7
+ import { isoNow } from "../core/ids.js";
8
+ import { decodeHtmlEntities, escapeRichText, richTextFragment } from "../core/richText.js";
9
+ import { emitDomainTelemetryEvent } from "../core/telemetryContext.js";
10
+ import { fail, internalError, ok, stdoutTail } from "../core/result.js";
11
+ import { WorkspaceError } from "../core/workspace.js";
12
+ import { SessionOrgError, resolveSessionOrg } from "../core/sessionOrg.js";
13
+ import { loadSession } from "./session.js";
14
+ import { sessionFile, sessionDir as sessionWorkspaceDir } from "../core/workspace.js";
15
+ import { GateError, assertCampfireWriteAllowed, assertTargetOrg } from "../core/gate.js";
16
+ import { runProcess, sfCliEnv } from "../core/spawn.js";
17
+ import { sfQueryTruncation } from "../core/sfQuery.js";
18
+ import { appendReadRecord } from "../core/readsLog.js";
19
+ import { writeWritebackManifest, writebackManifestRows } from "../core/writebackManifest.js";
20
+ import { corpusToolsEnabled } from "../core/betaFeedback.js";
21
+ import { DELIVERY_TRUTH_REMEDY, UnknownWaveError, configItemsForSession, heldForDeliveryTruth, ownershipRefusal } from "../campfire/deriveFromBundle.js";
22
+ import { describeConfigItem } from "../campfire/configItemDescription.js";
23
+ // Campfire (VF-partial) read/write tools. The consultant org is REAL and SHARED —
24
+ // every write here: (1) rides the dedicated CC_MCP_CAMPFIRE_WRITE gate + org-id
25
+ // allowlist, (2) touches only allowlisted fields/objects, (3) is idempotent where
26
+ // a record is created (External_ID__c query-then-update), and (4) is read back
27
+ // after writing so the result reports org truth, not request intent.
28
+ const API = "v61.0";
29
+ const CAMPFIRE_NAME_RE = /^(REQ|EXT)-\d{1,10}$/;
30
+ // Concurrent Configuration Item writes. Modest on purpose: each one launches an sf CLI process and
31
+ // Salesforce applies per-org concurrency limits, so this is about not paying full latency 25 times
32
+ // over rather than about saturating anything.
33
+ const WRITE_CONCURRENCY = 5;
34
+ const SF_ID_RE = /^[a-zA-Z0-9]{15,18}$/;
35
+ const EXTERNAL_ID_RE = /^[A-Za-z0-9_-]{1,18}$/;
36
+ function soqlEscape(value) {
37
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
38
+ }
39
+ async function query(consultantOrg, soql) {
40
+ const args = ["data", "query", "-q", soql, "--target-org", consultantOrg, "--json"];
41
+ let run = await runProcess("sf", args, { env: sfCliEnv(), timeoutMs: 90_000 });
42
+ if (run.timedOut) {
43
+ // Read-only — one retry on a stalled CLI child is safe (live-caught intermittent hang).
44
+ run = await runProcess("sf", args, { env: sfCliEnv(), timeoutMs: 90_000 });
45
+ }
46
+ if (run.exitCode !== 0) {
47
+ const detail = run.timedOut ? "killed at timeout" : `exit ${run.exitCode}`;
48
+ throw new Error(`Campfire SOQL failed (${detail}): ${stdoutTail(run.stdout || run.stderr, 6)}`);
49
+ }
50
+ const parsed = JSON.parse(run.stdout);
51
+ const truncation = sfQueryTruncation(parsed);
52
+ if (truncation) {
53
+ // Thrown, not returned short: every caller treats the record list as the whole answer (missing-
54
+ // name checks, read-backs), and a capped list would let absence of data read as absence of rows.
55
+ throw new Error(`Campfire SOQL truncated: ${truncation}`);
56
+ }
57
+ return parsed.result?.records ?? [];
58
+ }
59
+ // Salesforce marks every OTHER row in a rolled-back batch with this. It is a consequence, never the
60
+ // cause — surfacing it alongside the real error would bury the one line that says what to fix.
61
+ const ROLLBACK_CODE = "ALL_OR_NONE_OPERATION_ROLLED_BACK";
62
+ /**
63
+ * POST/PATCH a Composite sObject Collections request and REFUSE unless every record succeeded.
64
+ *
65
+ * `label(i)` names row i the way the operator does (REQ-15356), so the error points at a
66
+ * requirement rather than an array index.
67
+ */
68
+ export function collectionFailure(raw, method, label) {
69
+ if (!Array.isArray(raw)) {
70
+ // A collections call ALWAYS answers with an array. Anything else means the shape changed under
71
+ // us, and silently assuming success is how this failed the first time.
72
+ return `Campfire batch ${method} returned an unexpected response shape (expected a per-record array, got ${JSON.stringify(raw).slice(0, 200)}). Nothing is confirmed — READ the records before retrying.`;
73
+ }
74
+ const failures = raw
75
+ .map((row, i) => ({ row, i }))
76
+ .filter(({ row }) => row.success !== true)
77
+ .map(({ row, i }) => ({
78
+ label: label(i),
79
+ errors: (row.errors ?? []).filter((e) => e.statusCode !== ROLLBACK_CODE)
80
+ }));
81
+ if (failures.length === 0)
82
+ return null;
83
+ // Lead with rows carrying a REAL error. Every other row in a rolled-back batch is stamped
84
+ // ALL_OR_NONE_OPERATION_ROLLED_BACK, and in the CF63 case that was 14 of 15 — listing those
85
+ // first would bury the single line that says what to fix.
86
+ const real = failures.filter((f) => f.errors.length > 0);
87
+ const shown = real.length > 0 ? real : failures;
88
+ const detail = shown
89
+ .slice(0, 6)
90
+ .map((f) => `${f.label}: ${f.errors.map((e) => `${e.statusCode ?? "ERROR"}${e.fields?.length ? ` on ${e.fields.join(", ")}` : ""} — ${e.message ?? ""}`).join("; ") ||
91
+ "rolled back because another row in this batch was invalid"}`)
92
+ .join("\n ");
93
+ return (`Campfire batch ${method} wrote NOTHING — allOrNone rolled the whole batch back because ${shown.length} row(s) were rejected:\n ${detail}` +
94
+ `${shown.length > 6 ? `\n …and ${shown.length - 6} more` : ""}` +
95
+ `\nNo append was applied, so fixing the named value(s) and re-running this call is safe and will NOT duplicate anything.`);
96
+ }
97
+ async function restCollections(consultantOrg, method, records, label,
98
+ // Defaults to the plain collections endpoint. Pass the UPSERT form
99
+ // (/composite/sobjects/<Object>/<ExternalIdField>) to make idempotency the org's job instead of
100
+ // a read-then-write race — the response shape is identical either way.
101
+ endpoint = `/services/data/${API}/composite/sobjects`) {
102
+ const raw = await restWrite(consultantOrg, method, endpoint, {
103
+ allOrNone: true,
104
+ records
105
+ });
106
+ const failure = collectionFailure(raw, method, label);
107
+ if (failure)
108
+ throw new Error(failure);
109
+ }
110
+ async function restWrite(consultantOrg, method, endpoint, body) {
111
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cc-mcp-cf-"));
112
+ const bodyFile = path.join(dir, "body.json");
113
+ try {
114
+ await fs.writeFile(bodyFile, JSON.stringify(body), "utf8");
115
+ const run = await runProcess("sf", ["api", "request", "rest", endpoint, "-X", method, "--body", `@${bodyFile}`, "--target-org", consultantOrg], { env: sfCliEnv(), timeoutMs: 90_000 });
116
+ const out = run.stdout.trim();
117
+ if (run.timedOut) {
118
+ // Live-caught: a PATCH child killed at timeout had already sent its request —
119
+ // the org processed the write ~30s AFTER the kill. Timeout on a write is
120
+ // AMBIGUOUS, never a confirmed failure.
121
+ throw new Error(`Campfire ${method} ${endpoint} killed at timeout — AMBIGUOUS: the write may still have landed. READ the record before any retry; an append-mode retry against a landed write duplicates the note block.`);
122
+ }
123
+ // Salesforce REST errors arrive as a JSON array with errorCode even on exit 0.
124
+ // The REST error body is on stdout — surface it first, never let CLI warnings mask it.
125
+ if (run.exitCode !== 0 || /"errorCode"/.test(out)) {
126
+ throw new Error(`Campfire ${method} ${endpoint} failed: ${stdoutTail(out || run.stderr, 8)}`);
127
+ }
128
+ if (!out)
129
+ return null; // PATCH returns 204 empty
130
+ try {
131
+ return JSON.parse(out);
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ finally {
138
+ await fs.rm(dir, { recursive: true, force: true });
139
+ }
140
+ }
141
+ async function resolveRequirement(consultantOrg, requirement) {
142
+ const trimmed = requirement.trim();
143
+ if (SF_ID_RE.test(trimmed) && !CAMPFIRE_NAME_RE.test(trimmed)) {
144
+ const rows = await query(consultantOrg, `SELECT Id, Name FROM Campfire_Requirement__c WHERE Id = '${soqlEscape(trimmed)}'`);
145
+ const row = rows.length === 1 ? rows[0] : undefined;
146
+ return row ? { id: String(row.Id), name: String(row.Name) } : null;
147
+ }
148
+ if (!CAMPFIRE_NAME_RE.test(trimmed))
149
+ return null;
150
+ const name = trimmed.replace(/^EXT-/, "REQ-");
151
+ const rows = await query(consultantOrg, `SELECT Id, Name FROM Campfire_Requirement__c WHERE Name = '${name}'`);
152
+ const row = rows.length === 1 ? rows[0] : undefined;
153
+ return row ? { id: String(row.Id), name: String(row.Name) } : null;
154
+ }
155
+ /**
156
+ * Bucket requirement references by the COLUMN each must be looked up on, keeping every caller
157
+ * spelling that points at one record.
158
+ *
159
+ * Both maps key to a LIST of references, because two different strings can name one record: EXT-####
160
+ * and REQ-#### are the same requirement, and so are the 15- and 18-character forms of one Id. A
161
+ * one-to-one map silently dropped whichever arrived first, so a batch naming a record two ways got
162
+ * one of them back and reported the other unresolved — a refusal on the write path.
163
+ *
164
+ * Ids key on the 15-character prefix because SOQL always ANSWERS with the 18-character form: a
165
+ * caller who passed the short form could never match its own row, which the single-reference
166
+ * resolver (a direct `WHERE Id =` comparison) accepted without complaint.
167
+ *
168
+ * Pure, so the reference algebra is testable without an org.
169
+ */
170
+ export function groupRequirementRefs(requirements) {
171
+ const byId = new Map(); // 15-char sf id -> original reference(s)
172
+ const byName = new Map(); // REQ-name -> original reference(s)
173
+ const unusable = [];
174
+ const add = (map, key, reference) => {
175
+ const list = map.get(key);
176
+ if (list)
177
+ list.push(reference);
178
+ else
179
+ map.set(key, [reference]);
180
+ };
181
+ for (const reference of requirements) {
182
+ const trimmed = reference.trim();
183
+ if (SF_ID_RE.test(trimmed) && !CAMPFIRE_NAME_RE.test(trimmed))
184
+ add(byId, trimmed.slice(0, 15), reference);
185
+ else if (CAMPFIRE_NAME_RE.test(trimmed))
186
+ add(byName, trimmed.replace(/^EXT-/, "REQ-"), reference);
187
+ else
188
+ unusable.push(reference);
189
+ }
190
+ return { byId, byName, unusable };
191
+ }
192
+ /**
193
+ * Resolve MANY requirement references in at most two queries, instead of two per reference.
194
+ *
195
+ * The single-reference resolver costs one round trip per requirement, and the supervisor reads
196
+ * EVERY requirement before it plans anything — so a twelve-requirement batch opened with twenty-four
197
+ * sequential SOQL calls before any work began. `probe` is the largest stage in the ledger (9.5
198
+ * minutes across 40 calls on the live batch), and this is a large part of why.
199
+ *
200
+ * Ids and Names are separated because they are different columns; both branches are one IN() query.
201
+ * Ambiguity is preserved rather than flattened: a reference matching zero or several rows is
202
+ * reported unresolved, exactly as the single resolver refuses anything that is not exactly one.
203
+ */
204
+ async function resolveRequirements(consultantOrg, requirements) {
205
+ const resolved = new Map();
206
+ const { byId, byName, unusable } = groupRequirementRefs(requirements);
207
+ const unresolved = [...unusable];
208
+ const inList = (values) => [...values].map((value) => `'${soqlEscape(value)}'`).join(", ");
209
+ // Count matches per reference so "two rows share this Name" stays a refusal rather than a
210
+ // silent pick — the single-reference path requires exactly one and this must not be laxer.
211
+ const hits = new Map();
212
+ const collect = (rows, keys) => {
213
+ for (const row of rows) {
214
+ for (const reference of keys(row)) {
215
+ const list = hits.get(reference) ?? [];
216
+ list.push({ id: String(row.Id), name: String(row.Name) });
217
+ hits.set(reference, list);
218
+ }
219
+ }
220
+ };
221
+ if (byId.size > 0) {
222
+ const rows = await query(consultantOrg, `SELECT Id, Name FROM Campfire_Requirement__c WHERE Id IN (${inList(byId.keys())})`);
223
+ collect(rows, (row) => byId.get(String(row.Id).slice(0, 15)) ?? []);
224
+ }
225
+ if (byName.size > 0) {
226
+ const rows = await query(consultantOrg, `SELECT Id, Name FROM Campfire_Requirement__c WHERE Name IN (${inList(byName.keys())})`);
227
+ collect(rows, (row) => byName.get(String(row.Name)) ?? []);
228
+ }
229
+ for (const reference of [...byId.values(), ...byName.values()].flat()) {
230
+ const found = hits.get(reference) ?? [];
231
+ if (found.length === 1)
232
+ resolved.set(reference, found[0]);
233
+ else
234
+ unresolved.push(reference);
235
+ }
236
+ return { resolved, unresolved };
237
+ }
238
+ // Everything read_campfire_requirements returns.
239
+ //
240
+ // This list omitted Solution_Design_Summary__c until 2026-07-31 — the field that carries the ACTUAL
241
+ // specification on every corpus we run (CF-series, SM3, EV). Reading a requirement therefore showed
242
+ // its user story and acceptance criteria with every solution field null, and the design summary —
243
+ // the component list, the API names, the exact error strings — simply absent. A supervisor
244
+ // grounding itself with this tool would have seen an empty requirement and had nothing to say so.
245
+ //
246
+ // Same class as the hollow mirror: a field list that omits the field carrying the content. The test
247
+ // below now ties this list to MIRROR_COPIED_FIELDS so the three lists (import, mirror, read) cannot
248
+ // drift apart again — the next field added to one is a failing test until it is added to all.
249
+ export const READ_FIELDS = [
250
+ "Id", "Name", "Requirement_Name__c", "Requirement_Stage__c", "Build_Status__c",
251
+ "Blocked_Reason__c", "Org_Deployed_To__c", "Deployment_Notes__c", "PM_Notes__c",
252
+ "Requirement_Details__c", "User_Story__c", "User_Story_Action__c", "User_Story_Such_That__c",
253
+ // Derived and refused on INSERT (hence its exemption from MIRROR_COPIED_FIELDS), but perfectly
254
+ // readable — and the import pulls it. Omitting it here meant re-reading a requirement returned
255
+ // LESS than importing it did, which is its own quiet inconsistency. Caught by the invariant test
256
+ // below the moment it was written.
257
+ "User_Story_Description__c",
258
+ "Acceptance_Criteria_Given__c", "Acceptance_Criteria_When__c", "Acceptance_Criteria_Then__c",
259
+ "Expected_Results__c", "Solution_Design_Summary__c", "Solution_Details__c", "Pre_Requisite__c",
260
+ "Number_of_Configuration_Items__c", "Number_of_Deployment_Tasks__c", "LastModifiedDate"
261
+ ];
262
+ // Writable requirement fields — a deliberate ALLOWLIST, never arbitrary fields on the
263
+ // shared org. Notes fields default to APPEND (timestamped block) so nothing human-written
264
+ // is ever clobbered; replace must be asked for explicitly.
265
+ // Legal picklist values, carried HERE rather than discovered per run.
266
+ //
267
+ // The tool used to say which fields you may set and never which values are legal, so a run had to
268
+ // find out for itself: PicklistValueInfo is not queryable through this passthrough (INVALID_TYPE),
269
+ // so the supervisor fell back to GROUP BY over live records to infer the vocabulary of a field it
270
+ // was about to write on the REAL SHARED org — several round-trips to learn something fixed.
271
+ //
272
+ // Validation matters MORE for the open picklists than the restricted ones, which is the opposite of
273
+ // the intuition. A RESTRICTED picklist is defended by the org: a wrong value is rejected loudly.
274
+ // An OPEN one accepts anything, so a plausible-but-wrong Build_Status__c ("Deployed", "Done") lands
275
+ // silently and corrupts a field consultants read. Both are checked here so the error arrives before
276
+ // the write, naming the legal set.
277
+ //
278
+ // Verified live against the Campfire org 2026-07-28. If Campfire adds a value this list will refuse
279
+ // it wrongly — the refusal says so and names the read that shows the current set, so the fix is one
280
+ // call rather than an investigation.
281
+ // EVERY content field a requirement can carry, not just the ones the CF-series corpus happened
282
+ // to use. mirror_requirements' original copy list carried Solution_Design_Summary__c alone, and
283
+ // the Pinsley-style records keep their entire design in Solution_Details__c — so the mirror
284
+ // silently produced HOLLOW copies (user story only, null solution design), and a whole test run
285
+ // executed against ~5% of its spec before anyone noticed (live-caught 2026-07-29, NewPinsleyTest
286
+ // set). Rule: if the import composes from a field (see session.ts CAMPFIRE_IMPORT_FIELDS), the
287
+ // mirror must copy it. The test suite holds this list against that rule.
288
+ export const MIRROR_COPIED_FIELDS = [
289
+ "Requirement_Name__c",
290
+ "User_Story__c",
291
+ "User_Story_Action__c",
292
+ "User_Story_Such_That__c",
293
+ // NOT User_Story_Description__c: readable (the import queries it) but the org REFUSES it on
294
+ // insert (INVALID_FIELD_FOR_INSERT_UPDATE, live-caught on the NewPinsleyTest2 re-mint) — it is
295
+ // derived from the three user-story parts above, which ARE copied, so the clone recomputes it.
296
+ "Acceptance_Criteria_Given__c",
297
+ "Acceptance_Criteria_When__c",
298
+ "Acceptance_Criteria_Then__c",
299
+ "Expected_Results__c",
300
+ "Solution_Design_Summary__c",
301
+ "Requirement_Details__c",
302
+ "Solution_Details__c",
303
+ "Pre_Requisite__c"
304
+ ];
305
+ const PICKLISTS = {
306
+ Build_Status__c: {
307
+ restricted: false,
308
+ values: ["Not Started", "In Progress", "In Progress - At Risk", "Build Complete", "Blocked", "Complete", "Withdrawn"]
309
+ },
310
+ Org_Deployed_To__c: {
311
+ restricted: true,
312
+ multi: true,
313
+ values: ["Development", "Partial Sandbox", "Full Sandbox", "Production"]
314
+ }
315
+ };
316
+ // Campfire_Deployment_Task__c's three RESTRICTED picklists, verified live 2026-07-28. Declared as
317
+ // enums rather than prose: the value set then rides in the tool SCHEMA, so it is known without
318
+ // reading a description and cannot be guessed. These were plain z.string() with descriptions that
319
+ // named no values at all ("Pre_Post_Deployment__c picklist value."), and a run duly guessed, planned
320
+ // to "retry without that field if needed", and burned calls sampling live records to find out.
321
+ const DEPLOYMENT_TASK_TYPE = z.enum([
322
+ "Conga",
323
+ "Data Creation / Update / Deletion",
324
+ "Feature Console",
325
+ "Flow Activation / Deactivation",
326
+ "Navigation Builder",
327
+ "Page Layout / Record Page Update",
328
+ "Page Layout Assignment",
329
+ "Permission Controls",
330
+ "Permission Group / Set Assignment",
331
+ "Scheduled Job",
332
+ "Report / Dashboard Folder - Sharing / Unsharing",
333
+ "Public Group / Queue - Adding / Removing Users",
334
+ "Activation / Deactivation",
335
+ "Other"
336
+ ]);
337
+ /**
338
+ * §9.4 — the KIND OF LABOUR a handed-back task is, which is a different question from what
339
+ * Salesforce artefact it touches.
340
+ *
341
+ * `DEPLOYMENT_TASK_TYPE` above is the org's own restricted picklist and answers "what is this
342
+ * thing"; this answers "what is someone going to have to DO", which is what an automation-lever
343
+ * analysis groups on. Six values, deliberately coarse — a taxonomy fine enough to argue about is
344
+ * one nobody classifies consistently, and the whole value here is that two runs on two machines
345
+ * put the same work in the same bucket.
346
+ *
347
+ * IT NEVER REACHES CAMPFIRE. This rides the `manual_work.recorded` event, which is shape-only, and
348
+ * the ledger refuses any value outside this set. The supervisor stamps it at creation because that
349
+ * is the one moment the answer is known for free: it just decided it could not automate this.
350
+ */
351
+ const MANUAL_TASK_KIND = z.enum(["config", "verification", "data", "release", "external", "other"]);
352
+ /**
353
+ * Absolute path to a shipped reference doc.
354
+ *
355
+ * Both places that pointed at the Configuration Items guide used a RELATIVE path, and they
356
+ * disagreed — "harness/reference/..." (true only in the repo) and "reference/..." (true only from
357
+ * the package root). On a real install the package sits wherever npm's global prefix put it, and the
358
+ * session cwd is somewhere else entirely, so NEITHER resolved and the guide the supervisor is told
359
+ * to read before finalising a writeback could not be opened. Same failure class as the skill that
360
+ * shipped nowhere: an instruction that silently cannot be followed.
361
+ *
362
+ * Resolved from this module's own location, so it is correct in the repo, in the tarball, and in
363
+ * every installed version.
364
+ */
365
+ export function referenceDoc(file) {
366
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
367
+ // Package layout (the tarball, and every installed version) puts reference/ beside dist/.
368
+ const packaged = path.join(root, "reference", file);
369
+ if (existsSync(packaged))
370
+ return packaged;
371
+ // Repo layout keeps it under harness/. Checked second so an install never pays a stat for a path
372
+ // that cannot exist there, and so dev and production resolve to the same FILE either way.
373
+ const inRepo = path.join(root, "harness", "reference", file);
374
+ return existsSync(inRepo) ? inRepo : packaged;
375
+ }
376
+ const CI_GUIDE = () => referenceDoc("campfire-configuration-items-guide.md");
377
+ const UPDATABLE_FIELDS = {
378
+ Deployment_Notes__c: { rich: true, appendable: true },
379
+ PM_Notes__c: { rich: true, appendable: true },
380
+ Build_Status__c: { rich: false, appendable: false },
381
+ Blocked_Reason__c: { rich: false, appendable: false },
382
+ Org_Deployed_To__c: { rich: false, appendable: false }
383
+ };
384
+ /**
385
+ * Field caps this tool KNOWS, so an oversize value is refused locally instead of taking a whole
386
+ * all-or-nothing batch down with it.
387
+ *
388
+ * The retro that produced this named the asymmetry exactly: the schema documents picklist values
389
+ * meticulously ("do not query the org to discover them") and said nothing about length, and length
390
+ * is what broke the CF63 writeback. Same class of knowledge, one of them written down.
391
+ *
392
+ * MEASURED, not guessed: `sf sobject describe Campfire_Requirement__c --target-org vfppartial`,
393
+ * 2026-07-31. (An earlier attempt described the DELIVERY org by mistake — its own lesser
394
+ * `Campfire_Requirement__c` has 15 fields and none of these — so the caps went unmeasured for a
395
+ * while and only `Blocked_Reason__c` was known, from the org's own STRING_TOO_LONG. Describe the
396
+ * CONSULTANT org, `vfppartial`. The target org's minted alias used to be `campfire-<orgid>`,
397
+ * which is exactly how the wrong org got described — it is `in2-<mydomain>` now, named after the
398
+ * org itself; see `targetOrgAlias`.)
399
+ *
400
+ * The two picklists are omitted deliberately: `checkPicklist` constrains them to an enumerated set,
401
+ * which is strictly tighter than their 255 / 4099 storage caps, so a length rule there would only
402
+ * ever be dead code.
403
+ *
404
+ * The notes fields matter MORE than their size suggests, because they are APPEND-mode: each run
405
+ * adds a timestamped block, so the value that gets written is the accumulated history plus the new
406
+ * note. That is why the cap is checked against the COMPOSED body rather than the caller's input —
407
+ * a note that is fine on its own can still overflow a field that is already nearly full, and on an
408
+ * all-or-nothing batch that one row refuses every row.
409
+ */
410
+ const MAX_LENGTHS = {
411
+ Blocked_Reason__c: 255,
412
+ Deployment_Notes__c: 32_768,
413
+ PM_Notes__c: 32_768
414
+ };
415
+ /**
416
+ * Everything about a deployment-task batch that can be known WITHOUT the org.
417
+ *
418
+ * Checked up front because the write is all-or-nothing: naming every problem at once beats
419
+ * discovering them one org round trip at a time, and a local refusal has written nothing to a
420
+ * shared org. Returns one line per problem; empty means the batch is worth sending.
421
+ */
422
+ export function checkDeploymentTasks(tasks) {
423
+ const problems = [];
424
+ const seen = new Set();
425
+ tasks.forEach((t, i) => {
426
+ const at = `tasks[${i}] (${t.externalId || t.requirement})`;
427
+ if (!EXTERNAL_ID_RE.test(t.externalId))
428
+ problems.push(`${at}: externalId must match ${EXTERNAL_ID_RE}`);
429
+ // A duplicate key inside ONE upsert batch is not idempotency, it is the batch racing itself —
430
+ // Salesforce rejects it, and catching it here says so in terms the caller can act on.
431
+ if (seen.has(t.externalId))
432
+ problems.push(`${at}: externalId ${t.externalId} appears more than once — an upsert batch cannot race itself`);
433
+ seen.add(t.externalId);
434
+ if (!t.description.trim())
435
+ problems.push(`${at}: description is empty — it is the task's only headline`);
436
+ // Live-caught FIELD_CUSTOM_VALIDATION_EXCEPTION on the singular tool. One row like this refuses
437
+ // the whole batch, so it is worth a local check.
438
+ if (t.type === "Other" && !t.otherType?.trim()) {
439
+ problems.push(`${at}: type 'Other' REQUIRES otherType (org validation rejects it otherwise)`);
440
+ }
441
+ });
442
+ return problems;
443
+ }
444
+ /**
445
+ * What changed on a confirmed write, without shipping the field contents back.
446
+ *
447
+ * Appendable rich-text fields grow by a timestamped block per run, so echoing before AND after
448
+ * returns every earlier run's notes twice on every write. Report the DELTA instead: for an append,
449
+ * how much was added; for a replace, a short head of the new value. The correctness question was
450
+ * already settled by readBackMatches before this is called.
451
+ */
452
+ export function summarizeWrite(names, before, after,
453
+ // Append-vs-replace is KNOWN at the call site (the field's allowlist entry plus the caller's
454
+ // mode). Inferring it from the value growing is wrong the moment a replace happens to be longer
455
+ // than what it replaced — "Not Started" -> "Build Complete" reported "appended 3 chars", which is
456
+ // both false and useless. Caught by its own test.
457
+ wasAppend) {
458
+ const len = (v) => (typeof v === "string" ? v.length : v === undefined || v === null ? 0 : String(v).length);
459
+ return names.map((field) => {
460
+ const a = len(after[field]);
461
+ if (wasAppend(field))
462
+ return { field, change: `appended ${a - len(before[field])} chars (now ${a})` };
463
+ const head = String(after[field] ?? "").slice(0, 80);
464
+ return { field, change: `set to ${JSON.stringify(head)}${a > 80 ? ` …(${a} chars)` : ""}` };
465
+ });
466
+ }
467
+ /** Reject an oversize value before it reaches the shared org. Returns an error message, or null. */
468
+ export function checkLength(field, value, appended = false) {
469
+ const cap = MAX_LENGTHS[field];
470
+ if (cap === undefined || value.length <= cap)
471
+ return null;
472
+ return (`${field} is capped at ${cap} characters and the value to be written is ${value.length}. ` +
473
+ (appended
474
+ ? `This field APPENDS, so that total is the accumulated history plus your new block — the field is full, not your note. ` +
475
+ `Write with mode 'replace' carrying only what still matters, or prune the record first.`
476
+ : `Put the detail in Deployment_Notes__c (32,768 chars) and keep this field to a one-line summary.`) +
477
+ ` The org would reject it with STRING_TOO_LONG, and because the batch writer is all-or-nothing, one oversize value refuses EVERY row in the call.`);
478
+ }
479
+ /** Reject a picklist value before it reaches the shared org. Returns an error message, or null. */
480
+ export function checkPicklist(field, value) {
481
+ const def = PICKLISTS[field];
482
+ if (!def)
483
+ return null;
484
+ // A multipicklist arrives semicolon-separated; every member must be legal.
485
+ const parts = def.multi ? value.split(";").map((v) => v.trim()).filter(Boolean) : [value];
486
+ const bad = parts.filter((v) => !def.values.includes(v));
487
+ if (bad.length === 0)
488
+ return null;
489
+ return (`${field} does not accept ${bad.map((b) => JSON.stringify(b)).join(", ")}. Legal value(s): ${def.values.join(" | ")}` +
490
+ `${def.multi ? " (semicolon-separated for more than one)" : ""}. ` +
491
+ (def.restricted
492
+ ? "This picklist is RESTRICTED, so the org would reject the write anyway."
493
+ : "This picklist is OPEN, so the org would have ACCEPTED a wrong value silently — which is why it is checked here.") +
494
+ ` Verified 2026-07-28; if Campfire has changed since, describe_object {targetOrg: "campfire", objectApiName: "Campfire_Requirement__c", match: "${field.replace(/__c$/, "")}"} shows the current set.`);
495
+ }
496
+ // Salesforce normalizes rich-text HTML on save (e.g. <br/> becomes <br>, entity forms
497
+ // shift) — live-caught as a false WRITE_NOT_CONFIRMED. Rich fields compare by text
498
+ // content; only non-rich fields compare verbatim. Exported for tests.
499
+ //
500
+ // Markup is stripped BEFORE entities are decoded, so an escaped `&lt;b&gt;` in the caller's text
501
+ // survives as the literal characters `<b>` instead of being stripped as if it were a tag.
502
+ export function normalizeRichText(value) {
503
+ return decodeHtmlEntities(value.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, ""))
504
+ .replace(/\s+/g, " ")
505
+ .trim();
506
+ }
507
+ export function readBackMatches(written, stored, rich) {
508
+ const w = String(written ?? "");
509
+ const s = String(stored ?? "");
510
+ return rich ? normalizeRichText(w) === normalizeRichText(s) : w === s;
511
+ }
512
+ /** Compare the exact Configuration Item rows we wrote against a fresh Campfire read. */
513
+ export function configItemReadBackProblems(expected, stored) {
514
+ const byExternalId = new Map(stored.map((row) => [String(row.External_ID__c ?? ""), row]));
515
+ const fields = [
516
+ "Requirement__c",
517
+ "Configuration_Item_API_Name__c",
518
+ "Object_API_Name__c",
519
+ "Report_Folder_API_Name__c",
520
+ "Type__c",
521
+ "Description__c"
522
+ ];
523
+ const display = (value) => (value === null || value === undefined ? "" : String(value));
524
+ const problems = [];
525
+ for (const item of expected) {
526
+ const row = byExternalId.get(item.externalId);
527
+ if (!row) {
528
+ problems.push({
529
+ externalId: item.externalId,
530
+ metadataKey: item.metadataKey,
531
+ field: "row",
532
+ expected: "(present)",
533
+ observed: "(missing)"
534
+ });
535
+ continue;
536
+ }
537
+ for (const field of fields) {
538
+ const rich = field === "Description__c";
539
+ if (readBackMatches(item.body[field], row[field], rich))
540
+ continue;
541
+ problems.push({
542
+ externalId: item.externalId,
543
+ metadataKey: item.metadataKey,
544
+ field,
545
+ expected: display(item.body[field]),
546
+ observed: display(row[field])
547
+ });
548
+ }
549
+ }
550
+ return problems;
551
+ }
552
+ export function appendBlock(existing, addition, rich) {
553
+ const stamp = `[cc-mcp ${isoNow()}]`;
554
+ if (rich) {
555
+ // Only the ADDITION is escaped. `existing` came back from the org already escaped, so escaping
556
+ // it again would turn every prior run's `&lt;` into `&amp;lt;` and its `<br>` into visible text
557
+ // — the accumulated note history degrading a little further on every single write.
558
+ const block = `<b>${stamp}</b><br/>${richTextFragment(addition)}`;
559
+ return existing && String(existing).trim() ? `${existing}<br/><br/>${block}` : block;
560
+ }
561
+ // Plain text is NOT escaped: nothing sanitizes it, so entities would be stored as literal
562
+ // ampersand-l-t rather than rendering as a `<`.
563
+ const block = `${stamp} ${addition}`;
564
+ return existing && String(existing).trim() ? `${existing}\n\n${block}` : block;
565
+ }
566
+ /**
567
+ * The SOQL that finds UNKEYED rows this write would otherwise duplicate. Rows written by anything
568
+ * other than this writer carry no External_ID__c, so the org's external-id upsert cannot see them
569
+ * — it finds nothing and inserts a second row alongside. Live-measured in the vfppartial Campfire
570
+ * org: 10,700 of 11,831 Configuration Items are unkeyed, so this is the dominant duplication
571
+ * vector, not the write race (QA2-012 Cause B).
572
+ *
573
+ * Identity deliberately includes the parent (e.g. Requirement__c): the unkeyed population is
574
+ * mostly the CLIENT's historical rows for other requirements — `PermissionSet
575
+ * QTC_System_Administrator` alone has 107 of them. Matching on type+name without the parent would
576
+ * stamp our external id onto a client record and overwrite its description.
577
+ */
578
+ export function orphanIdentitySoql(objectApiName, identity) {
579
+ const where = Object.entries(identity)
580
+ .map(([field, value]) => (value === null ? `${field} = null` : `${field} = '${soqlEscape(value)}'`))
581
+ .join(" AND ");
582
+ return `SELECT Id FROM ${objectApiName} WHERE External_ID__c = null AND ${where}`;
583
+ }
584
+ async function upsertByExternalId(consultantOrg, objectApiName, externalId, body,
585
+ // Identity fields that name the SAME component a pre-existing unkeyed row would carry. When
586
+ // provided, exactly one matching orphan is ADOPTED (external id stamped onto it) instead of a
587
+ // second row being created beside it. Omitted → no adoption pass (callers whose objects have no
588
+ // unkeyed population).
589
+ adoptIdentity) {
590
+ const upsertEndpoint = `/services/data/${API}/sobjects/${objectApiName}/External_ID__c/${externalId}`;
591
+ if (adoptIdentity) {
592
+ const orphans = await query(consultantOrg, orphanIdentitySoql(objectApiName, adoptIdentity));
593
+ if (orphans.length > 1) {
594
+ // More than one unkeyed row claiming the same identity is ALREADY corrupted data — adopting
595
+ // one at random would bless an arbitrary survivor and hide the rest. Cleanup is a human call.
596
+ throw new Error(`${orphans.length} ${objectApiName} rows with no External_ID__c match the same component identity ` +
597
+ `(${Object.entries(adoptIdentity)
598
+ .map(([k, v]) => `${k}=${v ?? "null"}`)
599
+ .join(", ")}): ${orphans.map((r) => String(r.Id)).join(", ")} — refusing to adopt or add a row. ` +
600
+ `These are pre-existing duplicates; deduplicate them in Campfire first.`);
601
+ }
602
+ const orphan = orphans[0];
603
+ if (orphan) {
604
+ const id = String(orphan.Id);
605
+ try {
606
+ // Stamping the external id IS the adoption: from here on, every session's upsert resolves
607
+ // to this row instead of inserting beside it.
608
+ await restWrite(consultantOrg, "PATCH", `/services/data/${API}/sobjects/${objectApiName}/${id}`, {
609
+ ...body,
610
+ External_ID__c: externalId
611
+ });
612
+ return { id, action: "adopted" };
613
+ }
614
+ catch (error) {
615
+ // Another session created a keyed row for this component between our query and our PATCH
616
+ // (the field is org-enforced Unique). That row is now the canonical one — fall through and
617
+ // let the atomic upsert update it.
618
+ if (!(error instanceof Error && error.message.includes("DUPLICATE_VALUE")))
619
+ throw error;
620
+ }
621
+ }
622
+ }
623
+ try {
624
+ // PATCH on the external-id resource is Salesforce's ATOMIC upsert: insert-or-update is decided
625
+ // org-side under the field's unique index, so two concurrent sessions writing the same id
626
+ // cannot both insert — the loser's request resolves to an update of the winner's row. The old
627
+ // query-then-write here was the race QA2-012 named (both query, both see nothing, both insert).
628
+ // External_ID__c is verified Unique + External ID in the Campfire schema.
629
+ const result = await restWrite(consultantOrg, "PATCH", upsertEndpoint, body);
630
+ const id = result && typeof result.id === "string" ? result.id : "";
631
+ if (!result || !id)
632
+ throw new Error(`upsert returned no id: ${JSON.stringify(result)}`);
633
+ return { id, action: result.created === true ? "created" : "updated" };
634
+ }
635
+ catch (error) {
636
+ // A write killed at timeout is AMBIGUOUS — the org may have processed it after the kill
637
+ // (live-caught: ~30s after). The record READ resolves the ambiguity mechanically instead of
638
+ // leaving the instruction in an error message for the caller to maybe follow.
639
+ if (error instanceof Error && error.message.includes("AMBIGUOUS")) {
640
+ const rows = await query(consultantOrg, `SELECT Id FROM ${objectApiName} WHERE External_ID__c = '${soqlEscape(externalId)}'`);
641
+ const landed = rows[0];
642
+ if (rows.length === 1 && landed) {
643
+ // The write landed. These bodies are full replacements (not appends), so the row holds
644
+ // exactly what this call sent; create-vs-update is indistinguishable post hoc and is
645
+ // reported as the conservative "updated".
646
+ return { id: String(landed.Id), action: "updated" };
647
+ }
648
+ throw new Error(`Campfire upsert of ${objectApiName} '${externalId}' timed out and a read-back found no row — the write did NOT land. Retrying this call is safe.`);
649
+ }
650
+ throw error;
651
+ }
652
+ }
653
+ // A STABLE Configuration Item key, derived from the component itself.
654
+ //
655
+ // Hand-picked external ids are the reason a bulk writer could not exist: they live in the model's
656
+ // head, so every row needed a separate call with a separate invented key, and re-running a
657
+ // writeback would duplicate rather than update. Deriving from the metadata key makes the same
658
+ // component produce the same id in every session, which is what makes the write idempotent
659
+ // ACROSS runs and not merely within one.
660
+ //
661
+ // 12 hex characters of sha256 is 48 bits — ample against collision for a batch's component set,
662
+ // and it fits the Text(18) field with the prefix.
663
+ function configItemExternalId(metadataKey) {
664
+ return `CI-${createHash("sha256").update(metadataKey).digest("hex").slice(0, 12)}`;
665
+ }
666
+ /**
667
+ * The Configuration Item description generator now lives in its own module — see
668
+ * `campfire/configItemDescription.ts` for the rule that governs what a description may assert.
669
+ * Re-exported here because this module is the tool surface every caller already imports.
670
+ */
671
+ export const configItemDescription = describeConfigItem;
672
+ /**
673
+ * A deployment task's only text surface: `description` as the bold first line, `richDescription` as
674
+ * the body. Extracted from the batch writer so the escaping it depends on is testable without an
675
+ * org — it is one of the three rich-text bodies this file composes.
676
+ */
677
+ export function deploymentTaskRichDescription(description, richDescription) {
678
+ const richBody = richDescription ? `<br/>${richTextFragment(richDescription)}` : "";
679
+ // The headline is one line by construction, so its newlines collapse to spaces rather than <br/>.
680
+ return `<b>${escapeRichText(description.replace(/\n/g, " "))}</b>${richBody}`;
681
+ }
682
+ export function registerCampfireTools(server) {
683
+ // ONE read tool, taking an array. There used to be a singular sibling, and it was the default a
684
+ // run reached for — so the loop's first step (read every requirement) opened a twelve-requirement
685
+ // batch with twenty-four sequential round trips. Reading one requirement is an array of one.
686
+ server.registerTool("read_campfire_requirements", {
687
+ title: "Read Campfire requirements in one call",
688
+ description: "Read Campfire requirements in full (content fields + Build_Status, Blocked_Reason, Org_Deployed_To, Deployment/PM notes, and the Configuration Item / Deployment Task ROLL-UP COUNTERS). Those two counters are DISPLAY ONLY and must never be used to verify a writeback: they are derived fields that lag their children (observed stale seconds after a write, live-caught 2026-08-03), and Campfire's own org automation files Deployment Tasks of its own off Configuration Item manifest rows, so the total is not yours to reconcile. Check what you wrote by querying the child records by `External_ID__c`. Read-only SOQL on the consultant org, at most THREE queries however many are named. USE THIS AT THE START OF EVERY BATCH — read them all in one call; one requirement is an array of one. Partial results are returned rather than refused: a read cannot corrupt anything, so a reference matching zero or several rows is reported by name in `unresolved` while the rest come back in full. Ambiguity is never flattened — exactly one match, or unresolved.",
689
+ inputSchema: {
690
+ sessionId: z
691
+ .string()
692
+ .describe("The session whose Campfire org to read. Bound when requirements were imported from Campfire (import_requirements { campfire }), so the consultant alias never has to be restated."),
693
+ requirements: z
694
+ .array(z.string())
695
+ .min(1)
696
+ .max(100)
697
+ .describe("REQ-####, EXT-####, or 15/18-char record Ids. Duplicates are collapsed.")
698
+ }
699
+ }, async ({ sessionId, requirements }) => {
700
+ try {
701
+ const campfire = await resolveSessionOrg(await loadSession(sessionId), "campfire", { verify: false });
702
+ const consultantOrg = campfire.alias;
703
+ assertTargetOrg(consultantOrg);
704
+ const startedAt = Date.now();
705
+ const asked = [...new Set(requirements.map((r) => r.trim()).filter(Boolean))];
706
+ // READ PROVENANCE (R9, R11-R12). This is the ONE probe that reads the CONSULTANT org, and
707
+ // recording that faithfully is the point: the contamination join is ORG-KEYED, so a
708
+ // requirement read filed under the deploy target would manufacture overlap out of work that
709
+ // never touched the client's org at all.
710
+ //
711
+ // No component keys, and this is not an opaque read. Opaque means "reached the org and its
712
+ // identities did not derive" — the number that bounds a clean claim. A requirement is a
713
+ // DATA record, not a metadata component, so there was never a component key to derive.
714
+ // Campfire org ids are frequently unrecorded (the binding is made at import, where an
715
+ // `sf org display` for an id nothing needed would be pure cost), so the record joins by
716
+ // alias and says so with a null id rather than inventing one.
717
+ const recordRead = async (rowCount, outcome) => appendReadRecord(sessionId, {
718
+ tool: "read_campfire_requirements",
719
+ orgId: campfire.orgId ?? null,
720
+ orgAlias: consultantOrg,
721
+ // Requirement names are identifiers (REQ-####/record ids), not org content.
722
+ scope: `Campfire_Requirement__c ${asked.join(",")}`,
723
+ object: "Campfire_Requirement__c",
724
+ pattern: "targeted",
725
+ keys: [],
726
+ rows: rowCount,
727
+ durationMs: Date.now() - startedAt,
728
+ outcome
729
+ });
730
+ // `query` THROWS on a non-zero exit — that is this tool's designed failure signal, not an
731
+ // internal error — so a bare append after the reads would skip the record on exactly the
732
+ // path where a call did reach the org and come back empty-handed. Recorded once either way.
733
+ let resolved;
734
+ let unresolved;
735
+ try {
736
+ ({ resolved, unresolved } = await resolveRequirements(consultantOrg, asked));
737
+ }
738
+ catch (error) {
739
+ await recordRead(0, "failed");
740
+ throw error;
741
+ }
742
+ if (resolved.size === 0) {
743
+ await recordRead(0, "failed");
744
+ return fail("REQUIREMENT_NOT_FOUND", `none of ${asked.length} reference(s) resolved to exactly one Campfire_Requirement__c: ${unresolved.join(", ")}`);
745
+ }
746
+ const ids = [...resolved.values()].map((r) => `'${soqlEscape(r.id)}'`).join(", ");
747
+ let rows;
748
+ try {
749
+ rows = await query(consultantOrg, `SELECT ${READ_FIELDS.join(", ")} FROM Campfire_Requirement__c WHERE Id IN (${ids})`);
750
+ }
751
+ catch (error) {
752
+ await recordRead(0, "failed");
753
+ throw error;
754
+ }
755
+ await recordRead(rows.length, "ok");
756
+ // Ordered as ASKED, not as the org returned them. The supervisor reads these against a
757
+ // requirement ladder it already has in mind, and a reordered list makes it re-sort by hand.
758
+ const byId = new Map(rows.map((row) => [String(row.Id), row]));
759
+ const records = [...resolved.values()].map((r) => byId.get(r.id)).filter(Boolean);
760
+ const summary = [
761
+ `read ${records.length} of ${asked.length} requirement(s)`,
762
+ ...records.map((record) => `${record.Name} — ${record.Requirement_Name__c ?? ""} | stage: ${record.Requirement_Stage__c ?? "-"} | buildStatus: ${record.Build_Status__c ?? "-"} | CIs: ${record.Number_of_Configuration_Items__c ?? 0} | tasks: ${record.Number_of_Deployment_Tasks__c ?? 0}`),
763
+ // The counters are printed because they are useful context and refused as EVIDENCE in the
764
+ // same breath. Live-caught 2026-08-03 (RV batch): a run verifying its writeback found them
765
+ // stale, and found two Deployment Tasks it had not written sitting beside the ones it had —
766
+ // created by Campfire's own automation off the Configuration Item manifest rows, seconds
767
+ // after the write and with no external ids. Both readings of a total are wrong there.
768
+ "CIs/tasks are Campfire's own ROLL-UP COUNTERS — they lag their children, and Campfire's automation" +
769
+ " files Deployment Tasks of its own. Never reconcile a writeback against them: query the child" +
770
+ " records by External_ID__c, which is the only key that identifies rows YOU wrote.",
771
+ ...(unresolved.length
772
+ ? [`UNRESOLVED (${unresolved.length}): ${unresolved.join(", ")} — each must match exactly one requirement.`]
773
+ : [])
774
+ ].join("\n");
775
+ return ok(summary, { records, unresolved });
776
+ }
777
+ catch (error) {
778
+ if (error instanceof UnknownWaveError)
779
+ return fail(error.code, error.message);
780
+ if (error instanceof GateError)
781
+ return fail(error.code, error.message);
782
+ if (error instanceof SessionOrgError)
783
+ return fail(error.code, error.message);
784
+ if (error instanceof WorkspaceError)
785
+ return fail(error.code, error.message);
786
+ return internalError(error);
787
+ }
788
+ });
789
+ // ---- requirement writeback ------------------------------------------------------------------
790
+ //
791
+ // ONE write tool, taking an array. SM301 retro: end-of-run writeback issued TEN sequential
792
+ // single-record PATCHes — ten separate timeout exposures, and one DID hit the ambiguous-timeout
793
+ // path mid-series, costing a manual read-before-retry. sObject Collections turns the same work
794
+ // into ONE request with all-or-none semantics, so the batch either lands whole or not at all, and
795
+ // read-back verifies every row in one query. Updating one requirement is an array of one.
796
+ server.registerTool("update_campfire_requirements", {
797
+ title: "Update allowlisted fields on Campfire requirements",
798
+ description: "NOT PART OF A DELIVERY RUN — the delivery loop never calls this. A writeback is the Configuration Items (campfire_write_config_items); this tool exists for OPERATOR-DIRECTED updates to the requirement record, when a human explicitly asks for one. Do not call it to record a delivery: notes, Build Status, Blocked Reason and Org Deployed To are human-owned or belong to deployment, which this program does not perform. GATED (Campfire write + consultant-org allowlist — the org is REAL and SHARED). Update ALLOWLISTED fields on Campfire requirements; any other field refuses. Notes fields APPEND a timestamped block by default (replace only when mode: replace), so nothing human-written is ever clobbered. Executed as ONE all-or-none sObject-Collections call — one requirement is an array of one, and a ten-requirement writeback is one timeout exposure instead of ten. Every row is validated before anything is written, and any row the ORG rejects is reported by requirement name with Salesforce's own error code and field. One read-back verifies every row and the result reports what CHANGED per field, not the field contents.",
799
+ inputSchema: {
800
+ sessionId: z
801
+ .string()
802
+ .describe("The session this writeback belongs to. The Campfire consultant org comes FROM the session — bound at import — so a write cannot be aimed at the wrong org by restating an alias."),
803
+ updates: z
804
+ .array(z.object({
805
+ requirement: z.string().describe("REQ-####, EXT-####, or record Id."),
806
+ fields: z
807
+ .record(z.string(), z.string())
808
+ .describe("Field API name -> value. Allowlist: Deployment_Notes__c, PM_Notes__c (both rich text, appended by default), Blocked_Reason__c (free text, HARD CAP 255 CHARACTERS — measured; longer refuses locally, and would otherwise take the whole all-or-nothing batch down with it, so keep it to a one-line summary and put the detail in Deployment_Notes__c), and two PICKLISTS whose legal values are fixed: " +
809
+ "Build_Status__c = Not Started | In Progress | In Progress - At Risk | Build Complete | Blocked | Complete | Withdrawn (a human sets this; a delivery run never does); " +
810
+ "Org_Deployed_To__c = Development | Partial Sandbox | Full Sandbox | Production (multi-select, semicolon-separated). " +
811
+ "Both are validated before the write — do not query the org to discover them."),
812
+ mode: z.enum(["append", "replace"]).optional().describe("For notes fields: append (default) or replace.")
813
+ }))
814
+ .min(1)
815
+ .max(50)
816
+ .describe("One entry per requirement. A requirement may appear only once per call.")
817
+ }
818
+ }, async ({ sessionId, updates }) => {
819
+ try {
820
+ const { alias: consultantOrg } = await resolveSessionOrg(await loadSession(sessionId), "campfire", { verify: false });
821
+ await assertCampfireWriteAllowed(consultantOrg);
822
+ // Validate EVERYTHING before touching the org — a batch that half-validates is a batch
823
+ // that half-writes.
824
+ const problems = [];
825
+ const allFields = new Set();
826
+ const seen = new Set();
827
+ for (const [i, u] of updates.entries()) {
828
+ const names = Object.keys(u.fields);
829
+ if (names.length === 0)
830
+ problems.push(`updates[${i}] (${u.requirement}): no fields`);
831
+ for (const [name, value] of Object.entries(u.fields)) {
832
+ if (!(name in UPDATABLE_FIELDS)) {
833
+ problems.push(`updates[${i}] (${u.requirement}): field ${name} is not allowlisted`);
834
+ continue;
835
+ }
836
+ const bad = checkPicklist(name, value);
837
+ if (bad)
838
+ problems.push(`updates[${i}] (${u.requirement}): ${bad}`);
839
+ // Length is NOT checked here. For a replace-mode field the input is what gets written,
840
+ // but for an append the written value is the record's accumulated history plus this
841
+ // block — unknowable until the before-image is read. Checking the raw input would pass
842
+ // the case that actually overflows, so the check lives at composition instead. Both
843
+ // still refuse before anything is written.
844
+ allFields.add(name);
845
+ }
846
+ const key = u.requirement.trim().toUpperCase().replace(/^EXT-/, "REQ-");
847
+ if (seen.has(key))
848
+ problems.push(`updates[${i}]: ${u.requirement} appears more than once — append composition cannot race itself`);
849
+ seen.add(key);
850
+ }
851
+ if (problems.length > 0)
852
+ return fail("BATCH_INVALID", `Nothing written. Fix:\n${problems.map((p) => ` ${p}`).join("\n")}`);
853
+ // Resolve every target in one query.
854
+ const ids = updates.map((u) => u.requirement.trim()).filter((r) => SF_ID_RE.test(r));
855
+ const names = updates
856
+ .map((u) => u.requirement.trim().replace(/^EXT-/, "REQ-"))
857
+ .filter((r) => CAMPFIRE_NAME_RE.test(r));
858
+ const fieldList = [...allFields];
859
+ const where = [
860
+ ids.length ? `Id IN (${ids.map((x) => `'${x}'`).join(",")})` : null,
861
+ names.length ? `Name IN (${names.map((x) => `'${x}'`).join(",")})` : null
862
+ ].filter(Boolean).join(" OR ");
863
+ const rows = await query(consultantOrg, `SELECT Id, Name, ${fieldList.join(", ")} FROM Campfire_Requirement__c WHERE ${where}`);
864
+ const byName = new Map(rows.map((r) => [String(r.Name), r]));
865
+ const byId = new Map(rows.map((r) => [String(r.Id), r]));
866
+ const resolvedRows = updates.map((u) => {
867
+ const raw = u.requirement.trim();
868
+ return SF_ID_RE.test(raw) ? byId.get(raw) : byName.get(raw.toUpperCase().replace(/^EXT-/, "REQ-"));
869
+ });
870
+ const missing = updates.filter((_, i) => !resolvedRows[i]).map((u) => u.requirement);
871
+ if (missing.length > 0)
872
+ return fail("REQUIREMENT_NOT_FOUND", `Nothing written. Not found: ${missing.join(", ")}.`);
873
+ // Compose per-row bodies (append against the just-read before-image) and write ONCE.
874
+ const overlong = [];
875
+ const records = updates.map((u, i) => {
876
+ const before = resolvedRows[i];
877
+ const body = { attributes: { type: "Campfire_Requirement__c" }, Id: before.Id };
878
+ for (const [name, value] of Object.entries(u.fields)) {
879
+ const spec = UPDATABLE_FIELDS[name];
880
+ const appended = spec.appendable && (u.mode ?? "append") === "append";
881
+ // REPLACE goes through the same escaping as APPEND. The field is rich text either way,
882
+ // so a `<` in the caller's value is swallowed by the sanitizer either way — the mode
883
+ // only decides whether the prior history stays, not whether the value is HTML.
884
+ body[name] = appended
885
+ ? appendBlock(before[name], value, spec.rich)
886
+ : spec.rich
887
+ ? richTextFragment(value)
888
+ : value;
889
+ // COMPOSED, not the caller's input — see checkLength. This is the check that most
890
+ // needs to be here rather than in the pre-validation loop above: the append target is
891
+ // only known after the before-image is read, and on an all-or-nothing batch a single
892
+ // overflowing row refuses all the others.
893
+ const tooLong = checkLength(name, String(body[name]), appended);
894
+ if (tooLong)
895
+ overlong.push(`${String(before.Name)}: ${tooLong}`);
896
+ }
897
+ return body;
898
+ });
899
+ if (overlong.length > 0) {
900
+ return fail("FIELD_VALUE_TOO_LONG", `Nothing written. ${overlong.length} row(s) would exceed a field cap:\n ${overlong.join("\n ")}`);
901
+ }
902
+ await restCollections(consultantOrg, "PATCH", records, (i) => String(resolvedRows[i]?.Name ?? updates[i]?.requirement ?? `row ${i}`));
903
+ // One read-back for the whole batch — and if the READ fails, the PATCH still landed.
904
+ // Reporting that as a plain failure is how an append gets applied twice: a blind retry
905
+ // composes a second timestamped block on top of the one already stored (live-caught).
906
+ let after;
907
+ try {
908
+ after = await query(consultantOrg, `SELECT Id, Name, ${fieldList.join(", ")} FROM Campfire_Requirement__c WHERE Id IN (${records.map((r) => `'${String(r.Id)}'`).join(",")})`);
909
+ }
910
+ catch (readBackError) {
911
+ return fail("WRITE_LANDED_READBACK_FAILED", `The batch PATCH succeeded for all ${records.length} requirement(s) (${resolvedRows.map((r) => String(r.Name)).join(", ")}) but the confirmation read failed. Do NOT blindly retry — an append-mode retry would duplicate the note block on every row. Read those records first, then reconcile. Read error: ${readBackError instanceof Error ? readBackError.message : String(readBackError)}`);
912
+ }
913
+ const afterById = new Map(after.map((r) => [String(r.Id), r]));
914
+ const drift = [];
915
+ for (const [i, u] of updates.entries()) {
916
+ const a = afterById.get(String(records[i].Id)) ?? {};
917
+ for (const name of Object.keys(u.fields)) {
918
+ if (!readBackMatches(records[i][name], a[name], UPDATABLE_FIELDS[name]?.rich ?? false)) {
919
+ drift.push(`${String(resolvedRows[i].Name)}.${name}`);
920
+ }
921
+ }
922
+ }
923
+ if (drift.length > 0) {
924
+ // This claim is now EARNED. Reaching here means restCollections saw success:true on every
925
+ // record, so the write really did land and the appends really are applied — which is why
926
+ // "do not retry" is the right advice. It used to be asserted on a response nobody read,
927
+ // and it was false in the one case it ever fired.
928
+ return fail("WRITE_NOT_CONFIRMED", `Salesforce accepted every row, but the read-back does not match for: ${drift.join(", ")} — so something transformed the value AFTER the write (org automation, a formula, a workflow). The appends ARE applied: do NOT re-run this call, or the note block will be duplicated. Read those records and reconcile.`);
929
+ }
930
+ // A SUMMARY, not the payload. Returning the full before AND after of every field written
931
+ // means Deployment_Notes__c — a rich-text field that ACCUMULATES a timestamped block per
932
+ // run — comes back twice per row; by CF63 it already carried three prior runs' notes, so a
933
+ // fifteen-requirement writeback would echo that history thirty times for no added value.
934
+ // The correctness question was settled by the read-back above; what a caller can act on is
935
+ // what CHANGED and by how much. The record is one read away if the full text is wanted.
936
+ const written = updates.map((u, i) => {
937
+ const names = Object.keys(u.fields);
938
+ const wasAppend = (f) => Boolean(UPDATABLE_FIELDS[f]?.appendable) && (u.mode ?? "append") === "append";
939
+ return {
940
+ id: String(resolvedRows[i].Id),
941
+ name: String(resolvedRows[i].Name),
942
+ fields: summarizeWrite(names, resolvedRows[i], afterById.get(String(records[i].Id)) ?? {}, wasAppend)
943
+ };
944
+ });
945
+ return ok([`updated ${updates.length} requirement(s) in one call — read-back confirmed:`, ...written.map((w) => ` ${w.name}: ${w.fields.map((f) => `${f.field} ${f.change}`).join("; ")}`)].join("\n"), { updated: updates.length, requirements: written });
946
+ }
947
+ catch (error) {
948
+ if (error instanceof GateError)
949
+ return fail(error.code, error.message);
950
+ if (error instanceof SessionOrgError)
951
+ return fail(error.code, error.message);
952
+ if (error instanceof WorkspaceError)
953
+ return fail(error.code, error.message);
954
+ return internalError(error);
955
+ }
956
+ });
957
+ // ---- corpus authoring — TEST HARNESS ONLY, off unless CC_MCP_CORPUS_TOOLS says otherwise ------
958
+ //
959
+ // The two tools below stand up and clone the CF-series MASTER CORPORA we run the product against.
960
+ // Neither has any role in delivering a real client's batch: a consultant session reads the
961
+ // requirements Campfire sent it, it never authors or mirrors them. So they are gated the same way
962
+ // the beta feedback tools are — THE FLAG IS THE REGISTRATION — and a delivery install ships with
963
+ // no trace of them.
964
+ //
965
+ // The saving is not the point of the tools, it is the point of the gate: a tool definition is
966
+ // resident in EVERY context this server is connected to before turn one, and these two are ~1.5 KB
967
+ // of description plus their schemas, paid by every delivery session AND by every analyzer subagent
968
+ // fanned out from one — none of which may write to the consultant org at all.
969
+ //
970
+ // The write gate (CC_MCP_CAMPFIRE_WRITE + allowlist) is unchanged and still applies on top. This
971
+ // decides only whether the model is TOLD the tools exist.
972
+ if (corpusToolsEnabled())
973
+ registerCorpusTools(server);
974
+ registerConfigItemTools(server);
975
+ }
976
+ /**
977
+ * The corpus-authoring pair, split out so the flag check above reads as one line.
978
+ *
979
+ * Standing up the SM3M masters required a raw `sf data import tree` because nothing on the tool
980
+ * surface CREATES requirements — import_requirements imports into a session and mirror_requirements
981
+ * clones records that already exist. This is that missing first step, for authoring NEW master
982
+ * content the operator supplies.
983
+ */
984
+ function registerCorpusTools(server) {
985
+ server.registerTool("create_campfire_requirements", {
986
+ title: "Create new Campfire requirement records in batch",
987
+ description: "GATED (Campfire write + consultant-org allowlist — the org is REAL and SHARED). Create NEW Campfire_Requirement__c records from supplied content — the authoring step mirror_requirements cannot do (it only clones records that already exist). Used to stand up master corpora (the SM3M set needed a raw CLI tree-import for exactly this). Every record gets Requirement_Stage__c 'Definition & Design' and Build_Status__c 'Not Started'. Refuses if ANY requirementName already exists (exact match) — re-running cannot duplicate a corpus. dryRun defaults TRUE and reports exactly what would be created; the real write is ONE all-or-none sObject-Collections call.",
988
+ inputSchema: {
989
+ sessionId: z.string().describe("The session whose Campfire consultant org to write to."),
990
+ records: z
991
+ .array(z.object({
992
+ requirementName: z.string().min(1).describe("Requirement_Name__c — the human-visible name, e.g. 'SM3M - R1 - …'."),
993
+ userStory: z.string().optional().describe("User_Story__c, e.g. 'As a service desk manager'."),
994
+ userStoryAction: z.string().optional().describe("User_Story_Action__c — 'I want …'."),
995
+ userStorySuchThat: z.string().optional().describe("User_Story_Such_That__c — 'such that …'."),
996
+ acceptanceCriteriaGiven: z.string().optional(),
997
+ acceptanceCriteriaWhen: z.string().optional(),
998
+ acceptanceCriteriaThen: z.string().optional(),
999
+ solutionDesignSummary: z.string().optional().describe("Solution_Design_Summary__c — the PRIMARY content field the import composes from."),
1000
+ solutionDetails: z.string().optional().describe("Solution_Details__c — Pinsley-style long design body."),
1001
+ requirementDetails: z.string().optional().describe("Requirement_Details__c (legacy content field)."),
1002
+ expectedResults: z.string().optional(),
1003
+ preRequisite: z.string().optional()
1004
+ }))
1005
+ .min(1)
1006
+ .max(50),
1007
+ dryRun: z.boolean().optional().describe("Default TRUE — returns what would be created without writing.")
1008
+ }
1009
+ }, async ({ sessionId, records, dryRun }) => {
1010
+ try {
1011
+ const { alias: consultantOrg } = await resolveSessionOrg(await loadSession(sessionId), "campfire", { verify: false });
1012
+ const names = records.map((r) => r.requirementName.trim());
1013
+ const dupes = names.filter((n, i) => names.indexOf(n) !== i);
1014
+ if (dupes.length > 0)
1015
+ return fail("BATCH_INVALID", `Duplicate requirementName(s) in the batch: ${[...new Set(dupes)].join(", ")}.`);
1016
+ const existing = await query(consultantOrg, `SELECT Name, Requirement_Name__c FROM Campfire_Requirement__c WHERE Requirement_Name__c IN (${names.map((n) => `'${soqlEscape(n)}'`).join(",")})`);
1017
+ if (existing.length > 0) {
1018
+ return fail("TARGET_EXISTS", `Refusing: ${existing.length} name(s) already exist: ${existing.map((r) => `${String(r.Requirement_Name__c)} (${String(r.Name)})`).join(", ")}. A duplicated corpus in the shared org is tedious to unpick.`);
1019
+ }
1020
+ if (dryRun !== false) {
1021
+ return ok([`DRY RUN — would create ${records.length} requirement(s) in ${consultantOrg}:`, ...names.map((n) => ` ${n}`), "", "Re-run with dryRun: false to create them."].join("\n"), { dryRun: true, wouldCreate: records.length, names });
1022
+ }
1023
+ await assertCampfireWriteAllowed(consultantOrg);
1024
+ // NOT ESCAPED, and that is an OPEN QUESTION rather than a decision.
1025
+ //
1026
+ // The rich-text escaping added after the REQ-15736 swallow (see core/richText.ts) covers the
1027
+ // three bodies whose fields are KNOWN rich text: the two notes fields, Configuration Item
1028
+ // Description__c, and Deployment Task Description_Rich__c. These eleven CONTENT fields have
1029
+ // never been type-described — MAX_LENGTHS' describe of 2026-07-31 measured lengths, not
1030
+ // types — and the read side (session.ts stripRichText) runs HTML stripping over all of them
1031
+ // DEFENSIVELY, which is evidence of nothing either way.
1032
+ //
1033
+ // Escaping is not the safe default here: on a plain Long Text Area it would store a literal
1034
+ // `&lt;` where the author wrote `<`, which is the same corruption pointed the other way. So
1035
+ // settle it before changing it — `describe_object {targetOrg: "campfire", objectApiName:
1036
+ // "Campfire_Requirement__c"}` names each field's type — and escape exactly the ones that
1037
+ // come back richtext. Corpus prose has not yet been seen to carry a `<`, which is why this
1038
+ // is a recorded gap and not an incident.
1039
+ const payload = records.map((r) => ({
1040
+ attributes: { type: "Campfire_Requirement__c" },
1041
+ Requirement_Stage__c: "Definition & Design",
1042
+ Build_Status__c: "Not Started",
1043
+ Requirement_Name__c: r.requirementName.trim(),
1044
+ User_Story__c: r.userStory ?? null,
1045
+ User_Story_Action__c: r.userStoryAction ?? null,
1046
+ User_Story_Such_That__c: r.userStorySuchThat ?? null,
1047
+ Acceptance_Criteria_Given__c: r.acceptanceCriteriaGiven ?? null,
1048
+ Acceptance_Criteria_When__c: r.acceptanceCriteriaWhen ?? null,
1049
+ Acceptance_Criteria_Then__c: r.acceptanceCriteriaThen ?? null,
1050
+ Solution_Design_Summary__c: r.solutionDesignSummary ?? null,
1051
+ Solution_Details__c: r.solutionDetails ?? null,
1052
+ Requirement_Details__c: r.requirementDetails ?? null,
1053
+ Expected_Results__c: r.expectedResults ?? null,
1054
+ Pre_Requisite__c: r.preRequisite ?? null
1055
+ }));
1056
+ await restCollections(consultantOrg, "POST", payload, (i) => names[i] ?? `row ${i}`);
1057
+ const created = await query(consultantOrg, `SELECT Name, Requirement_Name__c FROM Campfire_Requirement__c WHERE Requirement_Name__c IN (${names.map((n) => `'${soqlEscape(n)}'`).join(",")}) ORDER BY Name`);
1058
+ // The read-back COUNTS rows, so "found none" and "created none" are the same number. Saying
1059
+ // "created 0 requirement(s) — read-back confirmed" on a total failure is the create path's
1060
+ // version of the batch-update lie, and it would have gone unnoticed for exactly as long.
1061
+ if (created.length !== payload.length) {
1062
+ return fail("WRITE_NOT_CONFIRMED", `Wrote ${payload.length} requirement(s) but read back ${created.length}. Missing: ${names.filter((n) => !created.some((r) => String(r.Requirement_Name__c) === n)).join(", ") || "(none by name — check for a rename)"}. Inspect before retrying; a blind retry risks duplicates for the ones that DID land.`);
1063
+ }
1064
+ return ok([`created ${created.length} requirement(s) — read-back confirmed:`, ...created.map((r) => ` ${String(r.Name)} ${String(r.Requirement_Name__c)}`)].join("\n"), { created: created.length, requirements: created.map((r) => ({ name: String(r.Name), requirementName: String(r.Requirement_Name__c) })) });
1065
+ }
1066
+ catch (error) {
1067
+ if (error instanceof GateError)
1068
+ return fail(error.code, error.message);
1069
+ if (error instanceof SessionOrgError)
1070
+ return fail(error.code, error.message);
1071
+ if (error instanceof WorkspaceError)
1072
+ return fail(error.code, error.message);
1073
+ return internalError(error);
1074
+ }
1075
+ });
1076
+ // mirror_requirements — clone a whole test corpus under a new token.
1077
+ //
1078
+ // This exists because it kept being done BY HAND. Seven corpora (CF72 → CF78) were each produced
1079
+ // by querying the previous set, substituting the token, and inserting the copies through the CLI —
1080
+ // and doing it by hand is how a contaminant gets in: selecting the source by Name RANGE
1081
+ // (REQ-15502..REQ-15516) once swept in an unrelated record that merely fell inside those bounds.
1082
+ //
1083
+ // Selecting by the CONTENT token instead is inherently safer: a record that does not carry the
1084
+ // token is not part of the set, whatever its record Name happens to be.
1085
+ server.registerTool("mirror_requirements", {
1086
+ title: "Clone a requirement set under a new token",
1087
+ description: "GATED (Campfire write + consultant-org allowlist — the org is REAL and SHARED). Copy every Campfire requirement whose Requirement_Name__c contains `from` into a NEW set with `from` replaced by `to` everywhere (name, user story, acceptance criteria, solution design). Used to stand up the next test corpus as a faithful mirror of the last, so two runs differ only by the build under test. Copies CONTENT only: stage resets to 'Definition & Design', status to 'Not Started', and no notes, Configuration Items or Deployment Tasks come across. Selects by CONTENT TOKEN, never by record-name range — a range sweeps in anything that happens to fall inside it. Refuses if any target already exists, so it cannot half-create a set or silently duplicate one. Run with dryRun first: it returns exactly what would be created.",
1088
+ inputSchema: {
1089
+ sessionId: z.string().describe("Session whose Campfire consultant org to write to."),
1090
+ from: z.string().describe("Token identifying the SOURCE set, e.g. \"CF78\". Matched against Requirement_Name__c."),
1091
+ to: z.string().describe("Token for the new set, e.g. \"CF79\". Replaced everywhere it appears in the copied text."),
1092
+ alsoReplace: z
1093
+ .array(z.object({ from: z.string(), to: z.string() }))
1094
+ .optional()
1095
+ .describe("Additional token pairs to substitute, e.g. [{from: \"IN78\", to: \"IN79\"}] for a second object family in the same corpus."),
1096
+ dryRun: z.boolean().optional().describe("Default TRUE. Returns what would be created without writing. Pass false to actually create.")
1097
+ }
1098
+ }, async ({ sessionId, from, to, alsoReplace, dryRun }) => {
1099
+ try {
1100
+ const { alias: consultantOrg } = await resolveSessionOrg(await loadSession(sessionId), "campfire", { verify: false });
1101
+ const pairs = [{ from, to }, ...(alsoReplace ?? [])];
1102
+ const sub = (v) => {
1103
+ if (v === null || v === undefined)
1104
+ return null;
1105
+ let out = String(v);
1106
+ for (const p of pairs)
1107
+ out = out.split(p.from).join(p.to);
1108
+ return out;
1109
+ };
1110
+ const COPIED = MIRROR_COPIED_FIELDS;
1111
+ const source = await query(consultantOrg,
1112
+ // External_ID__c comes along, but NOT via COPIED — it is unique, so it needs a rule the
1113
+ // other fields do not have. See the guard below.
1114
+ `SELECT Name, External_ID__c, ${COPIED.join(", ")} FROM Campfire_Requirement__c WHERE Requirement_Name__c LIKE '%${soqlEscape(from)}%' ORDER BY Name`);
1115
+ if (source.length === 0) {
1116
+ return fail("SOURCE_SET_EMPTY", `No Campfire requirement has ${JSON.stringify(from)} in its Requirement_Name__c. Nothing to mirror.`);
1117
+ }
1118
+ const existing = await query(consultantOrg, `SELECT Name, Requirement_Name__c FROM Campfire_Requirement__c WHERE Requirement_Name__c LIKE '%${soqlEscape(to)}%'`);
1119
+ if (existing.length > 0) {
1120
+ // Fail closed: a partial or duplicated corpus is worse than no corpus, and both are
1121
+ // tedious to unpick in a shared org.
1122
+ return fail("TARGET_SET_EXISTS", `${existing.length} requirement(s) already carry ${JSON.stringify(to)}: ${existing.slice(0, 5).map((r) => String(r.Name)).join(", ")}${existing.length > 5 ? ", …" : ""}. Refusing to create a second set. Pick a different token, or delete those first.`);
1123
+ }
1124
+ const planned = source.map((r) => {
1125
+ const body = { Requirement_Stage__c: "Definition & Design", Build_Status__c: "Not Started" };
1126
+ for (const f of COPIED)
1127
+ body[f] = sub(r[f]);
1128
+ const sourceExtId = r.External_ID__c === null || r.External_ID__c === undefined ? null : String(r.External_ID__c);
1129
+ const extId = sourceExtId === null ? null : sub(sourceExtId);
1130
+ if (extId !== null)
1131
+ body.External_ID__c = extId;
1132
+ return { sourceName: String(r.Name), name: String(body.Requirement_Name__c), sourceExtId, extId, body };
1133
+ });
1134
+ // EXTERNAL ID IS THE CORPUS IDENTITY, and the mirror used to drop it.
1135
+ //
1136
+ // Every requirement in a real corpus carries one (the EV battery is BLE-A-EV-01…50) and the
1137
+ // Campfire list views key on it — so a mirrored set arrived with External_ID__c null and was
1138
+ // invisible to every list, which is why the SM302 corpus sat in no list at all despite being
1139
+ // minted correctly in every other respect.
1140
+ //
1141
+ // It cannot ride along in COPIED, because unlike every other copied field it is UNIQUE. Two
1142
+ // rules follow, and both must fail closed:
1143
+ const unchanged = planned.filter((p) => p.sourceExtId !== null && p.extId === p.sourceExtId);
1144
+ if (unchanged.length > 0) {
1145
+ // The token does not appear in the External ID, so copying it verbatim would either be
1146
+ // rejected by the org as a duplicate or — worse, if the field is not enforced unique —
1147
+ // silently produce two corpora sharing an identity.
1148
+ return fail("EXTERNAL_ID_NOT_TOKENIZED", `${unchanged.length} source requirement(s) have an External ID that ${JSON.stringify(from)} does not appear in, ` +
1149
+ `so mirroring cannot make it unique: ${unchanged.slice(0, 5).map((p) => `${p.sourceName} (${p.sourceExtId})`).join(", ")}${unchanged.length > 5 ? ", …" : ""}. ` +
1150
+ `Add an alsoReplace pair covering the External ID's own token (e.g. {from: "BLE-A", to: "BLE-B"}), or the mirrored set will collide with its source.`);
1151
+ }
1152
+ const wantedExtIds = planned.map((p) => p.extId).filter((x) => Boolean(x));
1153
+ if (wantedExtIds.length > 0) {
1154
+ const taken = await query(consultantOrg, `SELECT Name, External_ID__c FROM Campfire_Requirement__c WHERE External_ID__c IN (${wantedExtIds.map((x) => `'${soqlEscape(x)}'`).join(", ")})`);
1155
+ if (taken.length > 0) {
1156
+ return fail("TARGET_EXTERNAL_IDS_EXIST", `${taken.length} External ID(s) the mirror would create are already in use: ${taken.slice(0, 5).map((r) => String(r.External_ID__c)).join(", ")}${taken.length > 5 ? ", …" : ""}. ` +
1157
+ `The name check passed, so this is a set that was renamed without re-tokenizing its External IDs. Refusing rather than creating a second corpus under an identity that already means something.`);
1158
+ }
1159
+ }
1160
+ if (dryRun !== false) {
1161
+ return ok([
1162
+ `DRY RUN — would create ${planned.length} requirement(s) in ${consultantOrg} by mirroring ${from} -> ${to}${(alsoReplace ?? []).map((p) => ` and ${p.from} -> ${p.to}`).join("")}:`,
1163
+ ...planned.map((p) => ` ${p.sourceName} -> ${p.name}${p.extId ? ` [${p.sourceExtId} -> ${p.extId}]` : " [no External ID on source]"}`),
1164
+ "",
1165
+ "Content only: stage resets to 'Definition & Design', status to 'Not Started'; no notes, Configuration Items or Deployment Tasks are copied.",
1166
+ "Re-run with dryRun: false to create them."
1167
+ ].join("\n"), { dryRun: true, wouldCreate: planned.length, names: planned.map((p) => p.name) });
1168
+ }
1169
+ await assertCampfireWriteAllowed(consultantOrg);
1170
+ const created = [];
1171
+ const failed = [];
1172
+ for (const p of planned) {
1173
+ try {
1174
+ const res = await restWrite(consultantOrg, "POST", `/services/data/${API}/sobjects/Campfire_Requirement__c`, p.body);
1175
+ created.push(`${p.name} (${String(res?.id ?? "created")})`);
1176
+ }
1177
+ catch (error) {
1178
+ // Reported, not aborted: a half-created set is easier to finish than to diagnose.
1179
+ failed.push({ name: p.name, error: error instanceof Error ? error.message : String(error) });
1180
+ }
1181
+ }
1182
+ return ok([
1183
+ `Mirrored ${from} -> ${to}: ${created.length} created, ${failed.length} failed.`,
1184
+ ...created.map((c) => ` ${c}`),
1185
+ ...(failed.length ? ["", "FAILED:", ...failed.map((f) => ` ${f.name}: ${f.error}`)] : []),
1186
+ "",
1187
+ "Select them in Campfire and use Send to Target Org to start a run."
1188
+ ].join("\n"), { created: created.length, failed, names: planned.map((p) => p.name) });
1189
+ }
1190
+ catch (error) {
1191
+ if (error instanceof GateError)
1192
+ return fail(error.code, error.message);
1193
+ if (error instanceof SessionOrgError)
1194
+ return fail(error.code, error.message);
1195
+ if (error instanceof WorkspaceError)
1196
+ return fail(error.code, error.message);
1197
+ return internalError(error);
1198
+ }
1199
+ });
1200
+ }
1201
+ /**
1202
+ * The Configuration Item / Deployment Task writeback surface — always registered.
1203
+ *
1204
+ * Split from `registerCampfireTools` only because the corpus pair above it is conditional; keeping
1205
+ * these in one function with an `if` in the middle would put every tool after the flag inside the
1206
+ * branch. Nothing here is gated by `CC_MCP_CORPUS_TOOLS`.
1207
+ */
1208
+ function registerConfigItemTools(server) {
1209
+ server.registerTool("campfire_config_item_plan", {
1210
+ description: "DETERMINISTIC Configuration Item plan from direct_apply evidence bundles — no org contact, no gate, no guessing. The CI list and each row's description are COMPUTED from the submitted metadata, verdict, pre/post diff and independent post-org read. Every delivery of one component is retained in order; a matched read's derivation records the exact stated configuration, a mismatch records the OBSERVED org value, and a delivery with no authoritative read-back marks its row HELD — so nothing is ever published with the request standing in as fact. Crucially it EXPANDS CONTAINERS: a Configuration Item is one configurable COMPONENT, not one deployed FILE. It also returns requirementDisclosures: the observed-vs-approved deviations, grouped by requirement, FOR THE OPERATOR REVIEW — speak them, do not write them anywhere. Each one is scoped to that component's final delivery, the same rule the Configuration Item row's own description follows, so the two never disagree about what the record says. Takes the SAME sessionId as campfire_write_config_items: run this first, show the operator, then write the whole manifest with that one call.",
1211
+ inputSchema: {
1212
+ sessionId: z
1213
+ .string()
1214
+ .optional()
1215
+ .describe("The session to plan. THE NORMAL INPUT — everything else is derived from it: the evidence bundles, and each requirement's plan.json ownership. Same value campfire_write_config_items takes, so preview and write are called identically."),
1216
+ waves: z
1217
+ .array(z.string())
1218
+ .optional()
1219
+ .describe("Restrict to these evidence-bundle LABELS (the `label` you passed direct_apply) — for INCREMENTAL writeback after each wave. Omit at end of run to cover the whole session. This matters: Campfire increments the requirement's Configuration Item counter on EVERY save, so re-deriving the full session after each wave inflates the counter on every requirement. An unknown label is an error, never a silent empty contribution."),
1220
+ bundleDirs: z
1221
+ .array(z.string())
1222
+ .optional()
1223
+ .describe("ESCAPE HATCH: explicit absolute bundle paths. Prefer `waves` (labels)."),
1224
+ sessionDir: z
1225
+ .string()
1226
+ .optional()
1227
+ .describe("ESCAPE HATCH: an explicit session workspace path, for a session directory that is not where sessionId would resolve. Prefer sessionId."),
1228
+ ownership: z
1229
+ .array(z.object({
1230
+ typeName: z.string().describe("Metadata type as the deploy names it, e.g. CustomObject, CustomField."),
1231
+ fullName: z.string().describe("Component fullName, e.g. IN72_Test_Console__c.Priority__c."),
1232
+ extId: z.string().describe("The requirement that OWNS it, e.g. REQ-15412.")
1233
+ }))
1234
+ .optional()
1235
+ .describe("Explicit component→requirement mapping, for components no plan.json claims (a batch run without per-requirement analyzers leaves none, and plan.json is what normally files each Configuration Item), and to resolve a reported OWNERSHIP_CONFLICT by naming ONE claiming requirement. Naming a requirement none of the claiming plans mentions is reported, not obeyed.")
1236
+ }
1237
+ }, async ({ sessionId, waves, bundleDirs, sessionDir, ownership }) => {
1238
+ try {
1239
+ // sessionId is the normal input; sessionDir stays an override. This tool used to take ONLY
1240
+ // the path, while its writer took sessionId — so the documented "preview then write" pair
1241
+ // could not be called with the same argument, and calling the preview the way the writer
1242
+ // documents it returned NO_INPUT. Live-caught during the CF72 writeback.
1243
+ const resolvedSessionDir = sessionDir ?? (sessionId ? sessionWorkspaceDir(sessionId) : undefined);
1244
+ if (!bundleDirs?.length && !resolvedSessionDir) {
1245
+ return fail("NO_INPUT", "Provide sessionId (bundles and ownership are discovered from it), or explicit bundleDirs.");
1246
+ }
1247
+ const derivation = await configItemsForSession({ sessionDir: resolvedSessionDir, bundleDirs, waves, ownership });
1248
+ const { rows, unmapped, notes, ownershipGaps, ownershipSource, ownershipConflicts, bundlesRead, requirementDisclosures } = derivation;
1249
+ // Fail closed on contested or unattributed rows. A Configuration Item filed against the wrong
1250
+ // requirement (or none) is worse than no row at all: Campfire's manifest is what a consultant
1251
+ // later reads to know what a requirement delivered, and a confidently wrong one is not
1252
+ // self-correcting. CI accuracy is a measured success metric, so this refuses rather than
1253
+ // emitting a list whose attribution someone has to reconstruct by hand. The refusal is the
1254
+ // SAME function the write uses, so the preview cannot pass something the write will reject.
1255
+ const refusal = ownershipRefusal({ rows, ownershipConflicts, ownershipSource });
1256
+ if (refusal)
1257
+ return fail(refusal.code, refusal.message);
1258
+ // Delivery truth gates PER ROW, not per batch — the same split the write applies, so the
1259
+ // preview shows exactly which rows will write and which will hold, with the remedy inline.
1260
+ const held = heldForDeliveryTruth(rows);
1261
+ const lines = [
1262
+ // bundlesRead, NOT the bundleDirs input: on the recommended sessionDir path the input is
1263
+ // empty because bundles are discovered, and echoing it read "derived from 0 bundle(s)"
1264
+ // exactly when the most evidence had been consulted.
1265
+ `${rows.length} Configuration Item(s) derived from ${bundlesRead.length} bundle(s) — attribution: ${ownershipSource.toUpperCase()}${ownershipSource === "supervisor" ? " (asserted by the supervisor, not computed from plan.json)" : ""}`,
1266
+ ...rows.map((r) => {
1267
+ const parts = [`Type=${r.type}`, `API=${r.apiName}`];
1268
+ if (r.objectApiName)
1269
+ parts.push(`Object=${r.objectApiName}`);
1270
+ if (r.folderApiName)
1271
+ parts.push(`Folder=${r.folderApiName}`);
1272
+ if (r.destructive)
1273
+ parts.push("DESTRUCTIVE");
1274
+ if (r.origin !== "deployed")
1275
+ parts.push(r.origin.toUpperCase());
1276
+ else if (!r.verified)
1277
+ parts.push("UNVERIFIED");
1278
+ if (r.expandedFrom)
1279
+ parts.push(`from ${r.expandedFrom}`);
1280
+ if (r.deliveries?.length) {
1281
+ parts.push(`observed=${r.deliveries.map((delivery) => `${delivery.bundle}:${delivery.action}/${delivery.readBack}`).join(",")}`);
1282
+ }
1283
+ return ` - [${r.requirement ?? "UNOWNED"}] ${parts.join(" | ")}`;
1284
+ }),
1285
+ ...(unmapped.length ? ["", `UNMAPPED (${unmapped.length}) — resolve with the operator, never guess a Type:`, ...unmapped.map((u) => ` - ${u.component} [${u.bundle}]: ${u.reason}`)] : []),
1286
+ ...(ownershipGaps.length ? ["", `OWNERSHIP GAPS (${ownershipGaps.length}):`, ...ownershipGaps.map((g) => ` - ${g.component}`)] : []),
1287
+ ...(notes.length ? ["", "NOTES:", ...notes.map((n) => ` - ${n}`)] : []),
1288
+ ...(requirementDisclosures.length
1289
+ ? [
1290
+ "",
1291
+ "DEVIATIONS TO RAISE IN THE REVIEW — each is already on its Configuration Item row; do not write these anywhere:",
1292
+ ...requirementDisclosures.map((disclosure) => ` - ${disclosure.requirement}: ${disclosure.text}`)
1293
+ ]
1294
+ : []),
1295
+ "",
1296
+ // An ABSOLUTE path, because the two places that named this guide used relative paths that
1297
+ // resolve in the repo and nowhere else — so the doc the supervisor is told to read before
1298
+ // finalising a writeback could not actually be opened from a session.
1299
+ `Type mapping, field split and DEPENDENCY rules (what must ride along vs become a Deployment Task):`,
1300
+ ` ${CI_GUIDE()}`,
1301
+ "Read it before writing: a permission set granting FLS needs its fields in the same deployment,",
1302
+ "reports and dashboards need their folders, an approval process needs its field updates and alerts.",
1303
+ ...(held.length > 0
1304
+ ? [
1305
+ "",
1306
+ `HELD — ${held.length} row(s) whose delivery truth is unconfirmed will NOT be written by campfire_write_config_items (the rest write normally):`,
1307
+ ...held.map(({ row, reason }) => ` - ${row.metadataKey}: ${reason}`),
1308
+ DELIVERY_TRUTH_REMEDY
1309
+ ]
1310
+ : [])
1311
+ ];
1312
+ return ok(lines.join("\n"), {
1313
+ rows,
1314
+ heldForDeliveryTruth: held.map(({ row, reason }) => ({ metadataKey: row.metadataKey, reason })),
1315
+ guide: CI_GUIDE(),
1316
+ unmapped,
1317
+ notes,
1318
+ ownershipGaps,
1319
+ ownershipSource,
1320
+ ownershipConflicts,
1321
+ requirementDisclosures
1322
+ });
1323
+ }
1324
+ catch (error) {
1325
+ if (error instanceof UnknownWaveError)
1326
+ return fail(error.code, error.message);
1327
+ return internalError(error);
1328
+ }
1329
+ });
1330
+ server.registerTool("campfire_write_config_items", {
1331
+ description: "GATED (Campfire write + allowlist). Write the ENTIRE evidence-derived Configuration Item manifest for a session in ONE call. campfire_config_item_plan is the exact preview: run it first, show the operator, then call this with the same sessionId/ownership. Each description states the component's identity, an observed projection of its final delivered configuration, any deviations from what was approved, and — for a row this run did not deploy — why it carries no delivery; it is a computed summary, not a log of every observed delivery or the full configuration the org holds. A HELD row is skipped and reported with its remedy while every confirmed row writes, so one ambiguous wave never strands the manifest. Stable component-derived External_ID__c values make re-runs update rather than duplicate. After every upsert, this tool re-reads the Campfire rows by external id and compares Requirement, Type, API/Object/Folder names and the full Description before reporting them confirmed. Per-row write or confirmation failures are named. This is the ONLY Configuration Item writer; corrections are re-derived, never hand-keyed.",
1332
+ inputSchema: {
1333
+ sessionId: z
1334
+ .string()
1335
+ .describe("The session to write back. The Campfire org and the evidence bundles both come from it."),
1336
+ ownership: z
1337
+ .array(z.object({
1338
+ typeName: z.string(),
1339
+ fullName: z.string(),
1340
+ extId: z.string()
1341
+ }))
1342
+ .optional()
1343
+ .describe("Same explicit component→requirement mapping campfire_config_item_plan accepts — for components no plan.json claims, and to resolve an OWNERSHIP_CONFLICT by naming one of the claiming requirements."),
1344
+ waves: z
1345
+ .array(z.string())
1346
+ .optional()
1347
+ .describe("Restrict to these evidence-bundle LABELS (the `label` you passed direct_apply) — for INCREMENTAL writeback after each wave. Omit at end of run to cover the whole session. This matters: Campfire increments the requirement's Configuration Item counter on EVERY save, so re-deriving the full session after each wave inflates the counter on every requirement. An unknown label is an error, never a silent empty contribution."),
1348
+ skipExternalIds: z
1349
+ .array(z.string())
1350
+ .optional()
1351
+ .describe("External ids to leave alone — use when some rows were already written under different keys and you do not want duplicates.")
1352
+ }
1353
+ }, async ({ sessionId, waves, ownership, skipExternalIds }) => {
1354
+ try {
1355
+ const session = await loadSession(sessionId);
1356
+ const { alias: consultantOrg } = await resolveSessionOrg(session, "campfire", { verify: false });
1357
+ await assertCampfireWriteAllowed(consultantOrg);
1358
+ const sessionDir = path.dirname(sessionFile(sessionId));
1359
+ const { rows: derivedRows, ownershipGaps, ownershipConflicts, ownershipSource, notes, requirementDisclosures } = await configItemsForSession({
1360
+ sessionDir,
1361
+ waves,
1362
+ ownership
1363
+ });
1364
+ // Literally the same fail-closed contract as the preview — the same function, so the two
1365
+ // cannot drift apart in what they consider writable, or in how they say why it is not.
1366
+ const refusal = ownershipRefusal({ rows: derivedRows, ownershipConflicts, ownershipSource });
1367
+ if (refusal)
1368
+ return fail(refusal.code, refusal.message);
1369
+ // Delivery truth holds PER ROW — one ambiguous wave must not strand every confirmed row in
1370
+ // the session (the write loop's own doctrine, applied one gate earlier). Held rows are
1371
+ // excluded from the write and reported with the remedy; everything confirmed writes.
1372
+ const heldRows = heldForDeliveryTruth(derivedRows);
1373
+ const heldKeys = new Set(heldRows.map(({ row }) => row.metadataKey));
1374
+ const rows = derivedRows.filter((row) => !heldKeys.has(row.metadataKey));
1375
+ const skip = new Set(skipExternalIds ?? []);
1376
+ // `skip` is keyed by external id; the manifest counts rows, which are keyed by metadataKey.
1377
+ const skippedKeys = rows.filter((row) => skip.has(configItemExternalId(row.metadataKey))).map((row) => row.metadataKey);
1378
+ const written = [];
1379
+ const failures = [];
1380
+ let created = 0;
1381
+ let updated = 0;
1382
+ // A pre-existing row that carried no external id and was claimed by stamping ours onto it.
1383
+ // Counted apart from created/updated because it is the visible trace of QA2-012 Cause B:
1384
+ // adoptions mean unkeyed producers still exist somewhere.
1385
+ let adopted = 0;
1386
+ // Structured alongside the prose, because the prose alone does not reach every caller.
1387
+ // Live-caught CF75: this returned {created:24, updated:0, failed:1, total:25} and nothing
1388
+ // else — the failing row WAS named in the text, but a client rendering structuredContent
1389
+ // showed only the counts. Identifying the row then took a per-requirement counter comparison
1390
+ // and a scoped re-run, and it only worked because a first write makes count equal rows.
1391
+ // Same defect class as sf_read putting query rows in text alone.
1392
+ const failed = [];
1393
+ // Requirements are looked up once, not once per row. 25 rows spread across 14 requirements
1394
+ // meant 11 identical SOQL round-trips, and every one is an sf CLI process launch.
1395
+ const resolveCache = new Map();
1396
+ const resolveOnce = (requirement) => {
1397
+ const hit = resolveCache.get(requirement);
1398
+ if (hit)
1399
+ return hit;
1400
+ const p = resolveRequirement(consultantOrg, requirement);
1401
+ resolveCache.set(requirement, p);
1402
+ return p;
1403
+ };
1404
+ // Rows are written CONCURRENTLY. Measured across four runs this tool was 27.6% of all time
1405
+ // inside tools — 190s per call, ~7.6s per row — because each row cost a resolve query, an
1406
+ // existence query and an upsert, serially, each one an sf CLI launch.
1407
+ //
1408
+ // Safe to parallelise: every row targets a DIFFERENT Configuration Item, keyed by an external
1409
+ // id derived from its own component key, so no two rows contend. resolveRequirement and
1410
+ // upsertByExternalId hold no mutable state between calls.
1411
+ //
1412
+ // Results are collected BY INDEX and rendered in the original order afterwards, so the report
1413
+ // does not become non-deterministic just because the writes are not.
1414
+ const slots = new Array(rows.length);
1415
+ const expectedWrites = new Array(rows.length);
1416
+ let cursor = 0;
1417
+ const worker = async () => {
1418
+ for (;;) {
1419
+ const i = cursor++;
1420
+ const row = rows[i];
1421
+ if (!row)
1422
+ return;
1423
+ const externalId = configItemExternalId(row.metadataKey);
1424
+ if (skip.has(externalId)) {
1425
+ slots[i] = { line: ` skipped ${externalId} ${row.metadataKey}` };
1426
+ continue;
1427
+ }
1428
+ try {
1429
+ const resolved = await resolveOnce(row.requirement);
1430
+ if (!resolved) {
1431
+ const reason = `requirement ${row.requirement} did not resolve`;
1432
+ failures.push(` ${row.metadataKey}: ${reason}`);
1433
+ failed.push({ metadataKey: row.metadataKey, requirement: row.requirement, reason });
1434
+ continue;
1435
+ }
1436
+ const body = {
1437
+ Requirement__c: resolved.id,
1438
+ Configuration_Item_API_Name__c: row.apiName,
1439
+ // Null is load-bearing on UPDATE: a row that used to be object-specific or foldered
1440
+ // must not retain the old value when the delivered component is now global/bare.
1441
+ Object_API_Name__c: row.objectApiName ?? null,
1442
+ Report_Folder_API_Name__c: row.folderApiName ?? null,
1443
+ Type__c: row.type,
1444
+ Description__c: configItemDescription(row)
1445
+ };
1446
+ // The adoption identity mirrors the body's own component identity INCLUDING the
1447
+ // requirement parent — the unkeyed population is dominated by the client's
1448
+ // historical rows for other requirements, which must never be adopted.
1449
+ const result = await upsertByExternalId(consultantOrg, "Campfire_Configuration_Item__c", externalId, body, {
1450
+ Requirement__c: resolved.id,
1451
+ Type__c: String(row.type),
1452
+ Configuration_Item_API_Name__c: String(row.apiName),
1453
+ Object_API_Name__c: row.objectApiName ? String(row.objectApiName) : null
1454
+ });
1455
+ expectedWrites[i] = { externalId, metadataKey: row.metadataKey, body };
1456
+ slots[i] = {
1457
+ ok: result.action,
1458
+ line: ` ${result.action.padEnd(8)} ${externalId} [${resolved.name}] ${row.type}/${row.apiName}`
1459
+ };
1460
+ }
1461
+ catch (error) {
1462
+ // One bad row must not strand the rest — the same reasoning as the projection fix.
1463
+ const reason = error instanceof Error ? error.message : String(error);
1464
+ failures.push(` ${row.metadataKey}: ${reason}`);
1465
+ failed.push({ metadataKey: row.metadataKey, requirement: row.requirement, externalId, reason });
1466
+ }
1467
+ }
1468
+ };
1469
+ // Per-requirement progress for the session viewer, BEFORE the first upsert: the loop runs
1470
+ // concurrently over minutes, so without this every requirement reads as "not started" until
1471
+ // the whole call returns. Rows a requirement will not get (held, skipped) are excluded from
1472
+ // its count, so a requirement with nothing writable stays `pending` rather than claiming a
1473
+ // write it never attempts. Fail-open — see core/writebackManifest.ts.
1474
+ await writeWritebackManifest(sessionId, writebackManifestRows({
1475
+ rows: derivedRows,
1476
+ held: heldKeys,
1477
+ skipped: skippedKeys,
1478
+ at: isoNow()
1479
+ }));
1480
+ await Promise.all(Array.from({ length: Math.min(WRITE_CONCURRENCY, rows.length) }, worker));
1481
+ // The target-org read-back established what was delivered; this second read establishes
1482
+ // that Campfire now says the same thing. A successful PATCH is only a claim until every CI
1483
+ // field — especially the evidence-derived Description__c — is observed back by external id.
1484
+ const expected = expectedWrites.filter((item) => Boolean(item));
1485
+ if (expected.length > 0) {
1486
+ let stored = [];
1487
+ let readError;
1488
+ try {
1489
+ for (let i = 0; i < expected.length; i += 100) {
1490
+ const chunk = expected.slice(i, i + 100);
1491
+ stored = stored.concat(await query(consultantOrg, `SELECT External_ID__c, Requirement__c, Configuration_Item_API_Name__c, Object_API_Name__c, Report_Folder_API_Name__c, Type__c, Description__c ` +
1492
+ `FROM Campfire_Configuration_Item__c WHERE External_ID__c IN (${chunk.map((item) => `'${soqlEscape(item.externalId)}'`).join(",")})`));
1493
+ }
1494
+ }
1495
+ catch (error) {
1496
+ readError = error instanceof Error ? error.message : String(error);
1497
+ }
1498
+ const problems = readError
1499
+ ? expected.map((item) => ({
1500
+ externalId: item.externalId,
1501
+ metadataKey: item.metadataKey,
1502
+ field: "read-back",
1503
+ expected: "(confirmed)",
1504
+ observed: readError
1505
+ }))
1506
+ : configItemReadBackProblems(expected, stored);
1507
+ const byExternalId = new Map();
1508
+ for (const problem of problems) {
1509
+ const list = byExternalId.get(problem.externalId) ?? [];
1510
+ list.push(problem);
1511
+ byExternalId.set(problem.externalId, list);
1512
+ }
1513
+ for (const [externalId, rowProblems] of byExternalId) {
1514
+ const i = expectedWrites.findIndex((item) => item?.externalId === externalId);
1515
+ const row = i >= 0 ? rows[i] : undefined;
1516
+ const fields = rowProblems.map((problem) => problem.field).join(", ");
1517
+ const reason = `write landed but Campfire read-back did not confirm: ${fields}`;
1518
+ failures.push(` ${row?.metadataKey ?? externalId}: ${reason}`);
1519
+ failed.push({ metadataKey: row?.metadataKey ?? externalId, requirement: row?.requirement, externalId, reason });
1520
+ if (i >= 0)
1521
+ slots[i] = { line: ` unconfirmed ${externalId} ${row?.metadataKey ?? ""} (${fields})` };
1522
+ }
1523
+ }
1524
+ // The closing pass, after the read-back — an upsert that returned 200 is only a claim until
1525
+ // Campfire is observed saying the same thing, so `written` cannot be asserted any earlier.
1526
+ await writeWritebackManifest(sessionId, writebackManifestRows({
1527
+ rows: derivedRows,
1528
+ held: heldKeys,
1529
+ skipped: skippedKeys,
1530
+ failedRequirements: failed.map((f) => String(f.requirement ?? "")).filter(Boolean),
1531
+ done: true,
1532
+ at: isoNow()
1533
+ }));
1534
+ for (const slot of slots) {
1535
+ if (!slot)
1536
+ continue;
1537
+ if (slot.ok === "created")
1538
+ created += 1;
1539
+ else if (slot.ok === "updated")
1540
+ updated += 1;
1541
+ else if (slot.ok === "adopted")
1542
+ adopted += 1;
1543
+ if (slot.line)
1544
+ written.push(slot.line);
1545
+ }
1546
+ const lines = [
1547
+ `${created} created, ${updated} updated${adopted ? `, ${adopted} adopted (pre-existing unkeyed rows claimed by external id)` : ""}, ${failures.length} failed of ${rows.length} writable row(s)${heldRows.length ? ` (+${heldRows.length} held)` : ""} — attribution: ${ownershipSource.toUpperCase()}`,
1548
+ ...written,
1549
+ ...(failures.length ? ["", `FAILED (${failures.length}) — these rows are absent or NOT CONFIRMED in Campfire:`, ...failures] : []),
1550
+ ...(heldRows.length
1551
+ ? [
1552
+ "",
1553
+ `HELD (${heldRows.length}) — delivery truth unconfirmed, NOT written (every other row wrote normally):`,
1554
+ ...heldRows.map(({ row, reason }) => ` - ${row.metadataKey}: ${reason}`),
1555
+ DELIVERY_TRUTH_REMEDY
1556
+ ]
1557
+ : []),
1558
+ ...(ownershipGaps.length ? ["", `ownership gaps: ${ownershipGaps.length}`] : []),
1559
+ ...(notes.length ? ["", "NOTES:", ...notes.map((n) => ` - ${n}`)] : []),
1560
+ ...(requirementDisclosures.length
1561
+ ? [
1562
+ "",
1563
+ "DEVIATIONS TO RAISE IN THE REVIEW — each is already on its Configuration Item row; do not write these anywhere:",
1564
+ ...requirementDisclosures.map((disclosure) => ` - ${disclosure.requirement}: ${disclosure.text}`)
1565
+ ]
1566
+ : [])
1567
+ ];
1568
+ // WHAT LANDED IN CAMPFIRE, per write. The ledger previously saw only aggregate counts on
1569
+ // the tool call, so "24 created, 1 failed" could not be turned into "which requirement is
1570
+ // short a configuration item" — the first thing an operator asks. Identifiers only; no note
1571
+ // bodies, no field values.
1572
+ emitDomainTelemetryEvent("writeback.completed", {
1573
+ created,
1574
+ updated,
1575
+ adopted,
1576
+ failed: failures.length,
1577
+ total: rows.length,
1578
+ ...(failed.length > 0
1579
+ ? {
1580
+ failedRows: failed.slice(0, 200).map((f) => ({
1581
+ ...(f.metadataKey !== undefined ? { metadataKey: String(f.metadataKey) } : {}),
1582
+ ...(f.requirement !== undefined ? { requirement: String(f.requirement) } : {}),
1583
+ ...(f.externalId !== undefined ? { externalId: String(f.externalId) } : {})
1584
+ }))
1585
+ }
1586
+ : {}),
1587
+ ...(heldRows.length > 0
1588
+ ? { heldRows: heldRows.slice(0, 200).map(({ row }) => ({ metadataKey: String(row.metadataKey) })) }
1589
+ : {}),
1590
+ // `.map((g) => g.component)`, NOT `.map(String)`. A gap is `{component, reason}`, so
1591
+ // stringifying the object recorded the literal "[object Object]" for every one of them
1592
+ // — a legal 15-character string that passes the shape check cleanly, so the ledger
1593
+ // filled with an identical meaningless token and nothing failed. The `reason` is prose
1594
+ // and stays off the wire; the component key is the identifier this event exists to
1595
+ // carry. The operator-facing render one screen up already did this correctly.
1596
+ ...(ownershipGaps.length > 0
1597
+ ? { ownershipGaps: ownershipGaps.slice(0, 200).map((gap) => gap.component) }
1598
+ : {})
1599
+ }, { stage: "writeback", outcome: failures.length === 0 && heldRows.length === 0 ? "ok" : "partial" });
1600
+ return ok(lines.join("\n"), {
1601
+ created,
1602
+ updated,
1603
+ adopted,
1604
+ failed: failures.length,
1605
+ total: rows.length,
1606
+ // The rows themselves, not merely how many. A count of failures you cannot identify is
1607
+ // indistinguishable from a count you cannot trust.
1608
+ ...(failed.length > 0 ? { failures: failed } : {}),
1609
+ ...(heldRows.length > 0
1610
+ ? {
1611
+ heldForDeliveryTruth: heldRows.map(({ row, reason }) => ({ metadataKey: row.metadataKey, reason })),
1612
+ remedy: DELIVERY_TRUTH_REMEDY
1613
+ }
1614
+ : {}),
1615
+ ...(ownershipGaps.length > 0 ? { ownershipGaps } : {}),
1616
+ ...(requirementDisclosures.length > 0 ? { requirementDisclosures } : {})
1617
+ });
1618
+ }
1619
+ catch (error) {
1620
+ if (error instanceof UnknownWaveError)
1621
+ return fail(error.code, error.message);
1622
+ if (error instanceof GateError)
1623
+ return fail(error.code, error.message);
1624
+ if (error instanceof SessionOrgError)
1625
+ return fail(error.code, error.message);
1626
+ if (error instanceof WorkspaceError)
1627
+ return fail(error.code, error.message);
1628
+ return internalError(error);
1629
+ }
1630
+ });
1631
+ // ONE task writer, taking an array. The CF63 run wrote NINE tasks one at a time at the very end
1632
+ // of a long session — nine round trips, nine timeout exposures, and nine chances to stop
1633
+ // half-done with the manual work only partly recorded. Manual work is the single most important
1634
+ // output for a delivery firm (it is what a human still has to do), so a partial write there is
1635
+ // the worst place to have one. Filing one task is an array of one.
1636
+ //
1637
+ // Upsert through /composite/sobjects/<Object>/<ExternalIdField>, so idempotency is the ORG's job
1638
+ // rather than a read-then-write race: re-running the same batch updates in place.
1639
+ server.registerTool("create_campfire_deployment_tasks", {
1640
+ title: "File Campfire Deployment Tasks for work a human must do",
1641
+ description: "GATED (Campfire write + allowlist). Create/update Campfire_Deployment_Task__c rows — the todo surface for work a human must do (manual verifications, unset defaults, dependency wiring the toolchain can't deploy). Idempotent via External_ID__c (<=18 chars), executed as ONE all-or-none sObject-Collections UPSERT; the manual-work list is normally written in one go at the end of a delivery, and one task is an array of one. Re-running an identical batch is a no-op update, not a duplicate. The task's only text surface is Description_Rich__c (Name is an auto-number): `description` becomes its bold first line, `richDescription` the body. Every row is validated locally before anything is written; refuses on `TASKS_INVALID` / `REQUIREMENT_NOT_FOUND`. FILE A TASK ONLY FOR WHAT YOU COULD NOT DEPLOY — check the post projection rather than assuming.",
1642
+ inputSchema: {
1643
+ sessionId: z
1644
+ .string()
1645
+ .describe("The session this writeback belongs to. The Campfire consultant org comes FROM the session — bound at import — so a write cannot be aimed at the wrong org by restating an alias."),
1646
+ tasks: z
1647
+ .array(z.object({
1648
+ requirement: z.string().describe("REQ-####, EXT-####, or record Id the task belongs to."),
1649
+ externalId: z.string().describe("Idempotency key, <=18 chars [A-Za-z0-9_-]. Must be unique WITHIN this batch too."),
1650
+ description: z.string().describe("Short one-line summary — the bold first line of Description_Rich__c."),
1651
+ richDescription: z.string().optional().describe("Full instructions (steps, evidence links) — the body of Description_Rich__c."),
1652
+ type: DEPLOYMENT_TASK_TYPE.optional().describe("Type__c (RESTRICTED picklist, 14 active values, verified live). Pick the value that NAMES the work; prefer a specific one over 'Other' (which then REQUIRES otherType)."),
1653
+ otherType: z.string().optional().describe("Other_Type__c — REQUIRED by org validation when type is 'Other' (live-caught FIELD_CUSTOM_VALIDATION_EXCEPTION)."),
1654
+ prePost: z
1655
+ .enum(["-N/A-", "Post-Deployment", "Pre-Deployment"])
1656
+ .optional()
1657
+ .describe("Pre_Post_Deployment__c. 'Pre-Deployment' for work that must happen BEFORE the metadata lands (Certinia data-backed components); 'Post-Deployment' for wiring after it (assignments, activations, memberships)."),
1658
+ status: z
1659
+ .enum(["Not Started", "In Progress", "Blocked", "Complete", "Deprecated"])
1660
+ .optional()
1661
+ .describe("Status__c. A task this run is handing to a human starts 'Not Started'."),
1662
+ taskKind: MANUAL_TASK_KIND.optional().describe("WHAT KIND OF LABOUR this is, for the automation-lever analysis — NOT the Salesforce artefact (`type` already names that). config = Setup clicks producing configuration; verification = someone confirming something by looking; data = creating/editing/loading records; release = activation, sequencing, release mechanics; external = work in another system; other = none of those. Not written to Campfire; it rides the telemetry event, and nobody can recover it later from a description."),
1663
+ expectedMinutes: z
1664
+ .number()
1665
+ .int()
1666
+ .min(1)
1667
+ .max(2_400)
1668
+ .optional()
1669
+ .describe("YOUR estimate of how long this task takes a human, in minutes. It is the denominator of the lever analysis (frequency x minutes by kind), so a rough honest number beats an omission — but do not invent one for work you cannot size. Not written to Campfire; it rides the telemetry event."),
1670
+ sequence: z.number().optional()
1671
+ }))
1672
+ .min(1)
1673
+ .describe("The tasks to file. One call per delivery is the intended shape.")
1674
+ }
1675
+ }, async ({ sessionId, tasks }) => {
1676
+ try {
1677
+ const { alias: consultantOrg } = await resolveSessionOrg(await loadSession(sessionId), "campfire", { verify: false });
1678
+ await assertCampfireWriteAllowed(consultantOrg);
1679
+ const problems = checkDeploymentTasks(tasks);
1680
+ if (problems.length > 0)
1681
+ return fail("TASKS_INVALID", `Nothing written. ${problems.length} problem(s):\n ${problems.join("\n ")}`);
1682
+ // Resolve every requirement in at most TWO queries — the same helper the batch read uses.
1683
+ // This used to loop the single-reference resolver once per distinct requirement while a
1684
+ // comment above it claimed one query, which is two sf CLI launches per requirement on the
1685
+ // one write a run makes at the very end of a long session.
1686
+ const { resolved } = await resolveRequirements(consultantOrg, [...new Set(tasks.map((t) => t.requirement))]);
1687
+ // Keyed off membership in `resolved` rather than the reported unresolved list, so a
1688
+ // reference that fell out for ANY reason refuses the whole batch instead of being filed
1689
+ // against the wrong requirement.
1690
+ const unresolvedRefs = [...new Set(tasks.map((t) => t.requirement).filter((r) => !resolved.has(r)))];
1691
+ if (unresolvedRefs.length > 0) {
1692
+ return fail("REQUIREMENT_NOT_FOUND", `Nothing written. Did not resolve to exactly one Campfire_Requirement__c: ${unresolvedRefs.join(", ")}.`);
1693
+ }
1694
+ const records = tasks.map((t) => {
1695
+ return {
1696
+ attributes: { type: "Campfire_Deployment_Task__c" },
1697
+ External_ID__c: t.externalId,
1698
+ Campfire_Requirement__c: resolved.get(t.requirement).id,
1699
+ Description_Rich__c: deploymentTaskRichDescription(t.description, t.richDescription),
1700
+ ...(t.type ? { Type__c: t.type } : {}),
1701
+ ...(t.otherType ? { Other_Type__c: t.otherType } : {}),
1702
+ ...(t.prePost ? { Pre_Post_Deployment__c: t.prePost } : {}),
1703
+ ...(t.status ? { Status__c: t.status } : {}),
1704
+ ...(t.sequence !== undefined ? { Sequence__c: t.sequence } : {})
1705
+ };
1706
+ });
1707
+ await restCollections(consultantOrg, "PATCH", records, (i) => `${tasks[i]?.externalId ?? `row ${i}`} (${resolved.get(tasks[i]?.requirement ?? "")?.name ?? "?"})`, `/services/data/${API}/composite/sobjects/Campfire_Deployment_Task__c/External_ID__c`);
1708
+ // One read-back for the whole batch, by the key the org upserted on.
1709
+ const back = await query(consultantOrg, `SELECT Id, External_ID__c FROM Campfire_Deployment_Task__c WHERE External_ID__c IN (${tasks.map((t) => `'${soqlEscape(t.externalId)}'`).join(",")})`);
1710
+ const byExt = new Map(back.map((r) => [String(r.External_ID__c), String(r.Id)]));
1711
+ const missing = tasks.filter((t) => !byExt.has(t.externalId)).map((t) => t.externalId);
1712
+ if (missing.length > 0) {
1713
+ return fail("WRITE_NOT_CONFIRMED", `Salesforce accepted the batch but ${missing.length} task(s) did not read back: ${missing.join(", ")}. Inspect before retrying.`);
1714
+ }
1715
+ // One manual-work event per task — this is the only writer (the singular twin was merged
1716
+ // away in #5), and it is what makes the outstanding-manual-work inventory reconstructable
1717
+ // at all. `taskKind` and `expectedMinutes` are shape, never prose: the enum and the bounded
1718
+ // integer are refused by `convex/lib/events.ts` if they are anything else.
1719
+ for (const t of tasks) {
1720
+ emitDomainTelemetryEvent("manual_work.recorded", {
1721
+ taskId: byExt.get(t.externalId) ?? "",
1722
+ requirementId: resolved.get(t.requirement).name,
1723
+ ...(t.type ? { componentType: t.type } : {}),
1724
+ ...(t.taskKind ? { taskKind: t.taskKind } : {}),
1725
+ ...(t.expectedMinutes !== undefined ? { expectedMinutes: t.expectedMinutes } : {})
1726
+ }, { stage: "writeback", outcome: "recorded" });
1727
+ }
1728
+ return ok([`filed ${tasks.length} deployment task(s) in one call — read-back confirmed:`, ...tasks.map((t) => ` ${resolved.get(t.requirement).name} ${t.externalId}: ${t.description}`)].join("\n"), { filed: tasks.length, tasks: tasks.map((t) => ({ externalId: t.externalId, id: byExt.get(t.externalId), requirement: resolved.get(t.requirement).name })) });
1729
+ }
1730
+ catch (error) {
1731
+ if (error instanceof UnknownWaveError)
1732
+ return fail(error.code, error.message);
1733
+ if (error instanceof GateError)
1734
+ return fail(error.code, error.message);
1735
+ if (error instanceof SessionOrgError)
1736
+ return fail(error.code, error.message);
1737
+ if (error instanceof WorkspaceError)
1738
+ return fail(error.code, error.message);
1739
+ return internalError(error);
1740
+ }
1741
+ });
1742
+ }