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,739 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { z } from "zod";
4
+ import { fail, internalError, ok, stdoutTail } from "../core/result.js";
5
+ import { WorkspaceError, sessionDir } from "../core/workspace.js";
6
+ import { loadSession } from "./session.js";
7
+ import { SessionOrgError, resolveSessionOrg } from "../core/sessionOrg.js";
8
+ import { GateError, assertTargetOrg } from "../core/gate.js";
9
+ import { runProcess, sfCliEnv } from "../core/spawn.js";
10
+ import { sfQueryTruncation } from "../core/sfQuery.js";
11
+ import { appendReadRecord, classifySfRead } from "../core/readsLog.js";
12
+ // Turn an sf CLI failure into something a supervisor can act on.
13
+ //
14
+ // `sf --json` prints a full error object on failure — name, message, exitCode, a stack, and a cause
15
+ // carrying a SECOND stack. Echoing that raw put ~40 lines of jsforce internals into the transcript
16
+ // for what was usually a one-line fact, and buried the fact.
17
+ //
18
+ // The case that matters most is NOT_FOUND on an sobject describe, which in this workflow is
19
+ // ORDINARY rather than broken: the batch's whole purpose is to create objects that do not exist
20
+ // yet, so describing one before it is applied is the expected answer, not a malfunction. Rendered
21
+ // as a raw stack it reads like the toolchain is failing, and invites the supervisor to go
22
+ // debugging instead of getting on with creating the thing.
23
+ //
24
+ // Still fail-closed: this is reported as a failure, never as a clean empty read. It says what
25
+ // happened, not that nothing is wrong.
26
+ export function sfReadFailure(matched, args, exitCode, body, stderr) {
27
+ const command = matched.join(" ");
28
+ let code;
29
+ let message;
30
+ try {
31
+ const parsed = JSON.parse(body);
32
+ code = parsed.errorCode ?? parsed.data?.errorCode ?? parsed.name;
33
+ message = parsed.message;
34
+ }
35
+ catch {
36
+ // Not JSON — fall through to the tail, which is still better than the whole buffer.
37
+ }
38
+ if (!code) {
39
+ return fail("SF_READ_FAILED", `sf ${command} exited ${exitCode}.\n${stdoutTail(body || stderr, 15)}`);
40
+ }
41
+ // Read the subject from ARGS, not from `matched` — `matched` is only the allowlist entry
42
+ // ("sobject describe"), so the flag is never in it. And guard the index: indexOf returning -1
43
+ // would make `[-1 + 1]` read element 0 and confidently report that "sobject" does not exist.
44
+ const flag = [args.indexOf("-s"), args.indexOf("--sobject")].find((i) => i >= 0);
45
+ const subject = flag === undefined ? undefined : args[flag + 1];
46
+ if (code === "NOT_FOUND" && matched.includes("describe")) {
47
+ return fail("SF_NOT_FOUND", [
48
+ `${subject ?? "That component"} does not exist in this org.`,
49
+ "If the batch is supposed to create it, this is the expected answer before it is applied —",
50
+ "carry on and create it. If it was supposed to already exist, the API name in the requirement",
51
+ "is wrong; project the org to see what is actually there."
52
+ ].join("\n"));
53
+ }
54
+ // INVALID_TYPE is "that API cannot see this type", NOT "this type does not exist" — and the two
55
+ // have different remedies. Live-caught (EV-31…40): `data query` on CustomPermission failed here,
56
+ // the run read the refusal as absence, and `org list metadata -m CustomPermission` returned the
57
+ // very components it had just concluded were missing. The crib belongs in the message because
58
+ // this is the one moment the caller is certain to be reading it.
59
+ if (code === "INVALID_TYPE") {
60
+ const named = /sObject type '([^']+)' is not supported/.exec(message ?? "")?.[1];
61
+ const subject = named ?? "That type";
62
+ return fail("SF_READ_FAILED", [
63
+ `${subject} is not queryable through that API. INVALID_TYPE means the API cannot SEE it — not that it does not exist.`,
64
+ `Retry with --use-tooling-api; if that also refuses, enumerate it as metadata instead — ["org","list","metadata","-m","${named ?? "<Type>"}"] — which is the path that works for CustomPermission and other types no query API exposes.`
65
+ ].join("\n"));
66
+ }
67
+ return fail("SF_READ_FAILED", `sf ${command} failed: ${code}${message ? ` — ${message}` : ""}`);
68
+ }
69
+ // Read-only advisory probes. Fail-closed posture throughout: an org-read error is
70
+ // reported as an error, never as "does not exist".
71
+ const OBJECT_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,79}(__c|__mdt|__e)?$/;
72
+ // Read-only sf CLI passthrough. list_components covers a FIXED set of types,
73
+ // so anything outside it dead-ends (live-caught: a run needed a type the tool did not cover and
74
+ // had nowhere to go). SOQL + describe are open-world, so this closes the gap dynamically instead
75
+ // of growing another hand-maintained table.
76
+ //
77
+ // NOT the gate — this list only supplies a friendly label for messages. The gate is sfReadRefusal,
78
+ // which works on canonical segments and refuses by verb. There is no shell (execFile with an argv
79
+ // array), so redirection and command chaining remain impossible either way.
80
+ export const SF_READ_ONLY_COMMANDS = [
81
+ ["data", "query"],
82
+ ["data", "get", "record"],
83
+ ["sobject", "describe"],
84
+ ["sobject", "list"],
85
+ ["org", "display"],
86
+ ["org", "list"],
87
+ ["limits", "display"]
88
+ ];
89
+ /**
90
+ * DENY-list of everything that can change an org, a file tree, or an auth store. Anything not
91
+ * matched here is allowed through unchanged.
92
+ *
93
+ * This was an ALLOW-list of seven command prefixes, and it was the wrong shape. The Salesforce CLI
94
+ * has a large read surface — `data export`, `data search`, `org list metadata`, `apex list log`,
95
+ * `apex get log`, `package version list`, `force:schema` and more — and every one of them refused
96
+ * with "not on the allowlist" even though none can write. The tool therefore fought its user on
97
+ * legitimate questions while providing no real safety, because a model with Bash can call the same
98
+ * CLI directly; the allowlist only ever constrained the honest path. Measured cost across one week:
99
+ * 217 calls, 56 failures.
100
+ *
101
+ * Inverting it keeps the one property worth keeping — no mutation reaches an org except through
102
+ * direct_apply and its evidence loop — and stops pretending a curated list of seven commands is the
103
+ * whole of "reading Salesforce".
104
+ *
105
+ * Named families only. This list is the FIRST of three layers and the weakest — it says nothing
106
+ * about a command it has never heard of, which is how `sf deploy metadata` (a live alias for
107
+ * `project deploy start`), the colon spelling of every entry below, and 146 unenumerated commands
108
+ * all ran unchallenged until 2026-07-31. The verb rules in sfReadRefusal are what actually hold.
109
+ *
110
+ * Kept anyway: an explicit family gives a precise refusal message, and it names the specific
111
+ * commands this loop must never take even if someone later widens SF_READ_VERBS.
112
+ */
113
+ export const SF_WRITE_COMMANDS = [
114
+ ["project", "deploy"],
115
+ ["project", "retrieve"],
116
+ ["project", "delete"],
117
+ ["project", "convert"],
118
+ ["project", "generate"],
119
+ ["data", "create"],
120
+ ["data", "update"],
121
+ ["data", "delete"],
122
+ ["data", "upsert"],
123
+ ["data", "import"],
124
+ ["data", "resume"],
125
+ ["apex", "run"],
126
+ ["apex", "test"],
127
+ ["org", "create"],
128
+ ["org", "delete"],
129
+ ["org", "login"],
130
+ ["org", "logout"],
131
+ ["org", "resume"],
132
+ ["org", "enable"],
133
+ ["org", "disable"],
134
+ ["org", "assign"],
135
+ ["package", "create"],
136
+ ["package", "install"],
137
+ ["package", "uninstall"],
138
+ // NOT ["package","version"] wholesale — `package version list` and `package version report` are
139
+ // reads. Only the mutating verbs.
140
+ ["package", "version", "create"],
141
+ ["package", "version", "delete"],
142
+ ["package", "version", "promote"],
143
+ ["package", "version", "update"],
144
+ ["alias", "set"],
145
+ ["alias", "unset"],
146
+ ["config", "set"],
147
+ ["config", "unset"],
148
+ ["schema", "generate"],
149
+ ["sobject", "create"],
150
+ ["force", "source"],
151
+ ["force", "data"],
152
+ ["force", "apex"],
153
+ ["force", "org"],
154
+ ["force", "mdapi"],
155
+ ["force", "package"],
156
+ ["agent", "create"],
157
+ ["lightning", "generate"],
158
+ ["static-resource", "generate"],
159
+ ["visualforce", "generate"],
160
+ ["community", "create"],
161
+ ["analytics", "generate"]
162
+ ];
163
+ /**
164
+ * The command as the CLI will actually resolve it: lowercased segments, before any flag.
165
+ *
166
+ * oclif accepts `sf project deploy start`, `sf project:deploy:start` and `sf project deploy:start`
167
+ * interchangeably. A gate that compares raw argv segments sees three different commands and blocks
168
+ * one of them, which is exactly what happened here — `matchedWriteCommand` matched literal argv
169
+ * from index 0, so `["project:deploy:start", "-x", "package.xml"]` walked straight through and
170
+ * deployed metadata. Normalising first is what makes any of the checks below meaningful.
171
+ */
172
+ export function canonicalSegments(args) {
173
+ const leading = [];
174
+ for (const arg of args) {
175
+ if (arg.startsWith("-"))
176
+ break;
177
+ leading.push(arg);
178
+ }
179
+ return leading
180
+ .join(":")
181
+ .split(":")
182
+ .map((segment) => segment.trim().toLowerCase())
183
+ .filter((segment) => segment.length > 0);
184
+ }
185
+ // Verbs that CHANGE something — an org, an auth store, a local tree, a package, a subscriber.
186
+ // Checked against every segment, so a write verb anywhere in the path is refused whatever the topic
187
+ // and whatever position it sits in (`org create user`, `force source deploy`, `package1 version
188
+ // create`). Topic-prefix matching cannot do this: it has to know the topic in advance.
189
+ const SF_WRITE_VERBS = new Set([
190
+ "create", "update", "delete", "upsert", "import", "install", "uninstall", "deploy", "retrieve",
191
+ "convert", "generate", "set", "unset", "run", "execute", "publish", "activate", "deactivate",
192
+ "enable", "disable", "assign", "promote", "reset", "refresh", "login", "logout",
193
+ "store", "grant", "revoke", "cancel", "schedule", "push-upgrade", "start", "restart", "stop",
194
+ "add", "remove", "rename", "move", "copy", "write", "apply", "migrate", "seed", "init"
195
+ // NOT "resume": `data query resume` collects a bulk query's results and only reads. The write
196
+ // resumes (`data import resume`, `data upsert resume`) already carry a write verb of their own,
197
+ // and `org resume sandbox` has no read verb, so it is refused by default anyway.
198
+ ]);
199
+ // Verbs that only READ. A command must contain one of these to run at all, so anything Salesforce
200
+ // ships in the future is refused until someone looks at it — the property the old comment claimed
201
+ // ("an unknown FUTURE subcommand of a dangerous family is blocked") and the old code did not have.
202
+ //
203
+ // The verb is not always last (`data get record`, `org list metadata`) and not always second
204
+ // (`package version list`), which is why this matches ANY segment rather than a fixed position.
205
+ const SF_READ_VERBS = new Set([
206
+ "query", "get", "list", "describe", "display", "report", "search", "export", "preview", "diff",
207
+ "check", "validate", "status", "history", "view", "read", "print", "show", "info", "log", "logs"
208
+ ]);
209
+ /**
210
+ * Commands that hand back a CREDENTIAL rather than data.
211
+ *
212
+ * `org auth show-sfdx-auth-url` returns a `force://` URL containing the REFRESH TOKEN — durable,
213
+ * full-privilege access to the org from any machine, long after this session ends. It reads rather
214
+ * than writes, so no verb rule can catch it; it has to be named. `org open` is the same shape (a
215
+ * frontdoor URL carrying a live session), and `org display --verbose` prints the same auth URL as
216
+ * `show-sfdx-auth-url`.
217
+ *
218
+ * `org display` WITHOUT --verbose still returns an access token and is still allowed, because it is
219
+ * how the loop resolves instance hosts and it was reachable under the old allowlist too. That is a
220
+ * deliberate line, not an oversight: a short-lived session token the harness already holds, versus
221
+ * a refresh token it does not.
222
+ */
223
+ function credentialExposure(segments, args) {
224
+ if (segments.includes("auth"))
225
+ return segments.join(" ");
226
+ if (segments[0] === "org" && segments[1] === "open")
227
+ return "org open";
228
+ if (segments[0] === "org" && segments[1] === "display" && args.includes("--verbose")) {
229
+ return "org display --verbose";
230
+ }
231
+ return null;
232
+ }
233
+ /** The write family this command belongs to, or null when no explicit family matches. */
234
+ export function matchedWriteCommand(args) {
235
+ const segments = canonicalSegments(args);
236
+ return SF_WRITE_COMMANDS.find((cmd) => cmd.every((seg, i) => segments[i] === seg)) ?? null;
237
+ }
238
+ /**
239
+ * Why this command may not run, or null to allow it.
240
+ *
241
+ * Three layers, cheapest first. The explicit family list is kept because it names the specific
242
+ * commands this loop must never take, but it is no longer load-bearing on its own: the verb rules
243
+ * below are what make an UNLISTED command safe by default. Before this, `sf deploy metadata` (a
244
+ * live alias for `project deploy start`) and `sf api request graphql --body mutation.txt` were both
245
+ * permitted, and so was every one of the 146 commands nobody had thought to enumerate.
246
+ */
247
+ export function sfReadRefusal(args) {
248
+ const segments = canonicalSegments(args);
249
+ if (segments.length === 0)
250
+ return "sf_read needs a command.";
251
+ const credential = credentialExposure(segments, args);
252
+ if (credential) {
253
+ return `sf_read will not run "${credential}" — it returns a credential rather than data. If an org needs re-authenticating, the operator does that through org_login.`;
254
+ }
255
+ const family = SF_WRITE_COMMANDS.find((cmd) => cmd.every((seg, i) => segments[i] === seg));
256
+ const writeVerb = segments.find((segment) => SF_WRITE_VERBS.has(segment));
257
+ if (family || writeVerb) {
258
+ const named = family ? family.join(" ") : `${segments.join(" ")}`;
259
+ return `sf_read is read-only and "${named}" mutates. Org changes flow through direct_apply, which projects before and after, diffs three ways and leaves an evidence bundle — none of which happens if a write goes around it.`;
260
+ }
261
+ if (!segments.some((segment) => SF_READ_VERBS.has(segment))) {
262
+ return `sf_read does not recognise "${segments.join(" ")}" as a read. Commands are allowed by their verb (query, list, describe, display, get, report, search…), so anything new is refused until it is reviewed rather than assumed safe. If this genuinely only reads, add its verb to SF_READ_VERBS.`;
263
+ }
264
+ return null;
265
+ }
266
+ export const SF_TARGET_FLAGS = new Set(["-o", "--target-org", "-u", "--targetusername"]);
267
+ const SF_OUTPUT_CAP = 24_000;
268
+ export function matchedReadCommand(args) {
269
+ return SF_READ_ONLY_COMMANDS.find((cmd) => cmd.every((seg, i) => args[i] === seg)) ?? null;
270
+ }
271
+ /**
272
+ * Resolve the org a READ should hit, allowing the symbolic role "campfire".
273
+ *
274
+ * A session is bound to TWO orgs — the target org it deploys to, and the Campfire consultant org
275
+ * it reads requirements from and writes Configuration Items back to. Every read tool defaulted to
276
+ * the target and could otherwise only take a literal alias, so verifying a writeback meant the
277
+ * supervisor GUESSING the consultant org's alias from nothing.
278
+ *
279
+ * Live (CF77): the first verification query ran against the deploy target and failed
280
+ * `INVALID_TYPE: Campfire_Configuration_Item__c is not supported` — correct, that object lives in
281
+ * the other org. The next attempt hard-coded an alias remembered from an unrelated context. The
282
+ * session knew the answer the whole time; the campfire write tools resolve it by role on every
283
+ * call. This just exposes the same lookup to reads.
284
+ *
285
+ * Returns the ORG ID as well as the alias. Read provenance joins on the id (R9): an alias is
286
+ * operator-controlled and can be repointed, so keying a contamination report on one would let a
287
+ * repoint silently merge two orgs' read-sets. A literal `targetOrg` override names an org this
288
+ * session was never bound to, so there is no recorded id to report — null, honestly, rather than an
289
+ * `sf org display` round-trip on every read.
290
+ */
291
+ async function resolveReadOrg(sessionId, override) {
292
+ const requested = override?.trim();
293
+ if (requested && requested !== "campfire" && requested !== "target")
294
+ return { alias: requested, orgId: null };
295
+ const role = requested === "campfire" ? "campfire" : "target";
296
+ // verify:false — reading the wrong org is visible and harmless, and the round-trip is not free.
297
+ const binding = await resolveSessionOrg(await loadSession(sessionId), role, { verify: false });
298
+ return { alias: binding.alias, orgId: binding.orgId ?? null };
299
+ }
300
+ /** Drop the `attributes` envelope Salesforce puts on every SOQL record. */
301
+ function stripAttributes(value) {
302
+ if (Array.isArray(value))
303
+ return value.map(stripAttributes);
304
+ if (!value || typeof value !== "object")
305
+ return value;
306
+ const out = {};
307
+ for (const [k, v] of Object.entries(value)) {
308
+ if (k === "attributes")
309
+ continue;
310
+ out[k] = stripAttributes(v);
311
+ }
312
+ return out;
313
+ }
314
+ export function registerOrgTools(server) {
315
+ server.registerTool("sf_read", {
316
+ description: "READ-ONLY sf CLI passthrough — works like the CLI. Pass any `sf` arguments WITHOUT the leading `sf` and without a target flag (the org is injected); --json is added unless you pass it. " +
317
+ "Commands are allowed BY VERB, so any non-mutating command runs (`data query` — add --use-tooling-api for Tooling objects — `sobject describe`, `org list metadata`, `apex get log`, and anything else that only reads); a mutating or unrecognised one is refused with the reason and the alternative. " +
318
+ "OVERSIZE OUTPUT IS NEVER DISCARDED: past the inline cap the full result is written to <session>/sf-read/ and the response carries the path, the row count and the first 2,000 chars, so a big describe becomes a file to grep rather than a refusal.",
319
+ inputSchema: {
320
+ sessionId: z.string().describe("The session whose org to read. Supplies the org so the alias never has to be restated."),
321
+ targetOrg: z
322
+ .string()
323
+ .optional()
324
+ .describe("Omit for the session's deploy target, which is the normal case. Pass \"campfire\" to read the session's Campfire CONSULTANT org — where requirements, Configuration Items and Deployment Tasks live — without knowing its alias; that is how you verify a writeback landed. Any other value is a literal alias, the open-world escape hatch for an org this session is not bound to."),
325
+ args: z
326
+ .array(z.string())
327
+ .min(1)
328
+ .describe('sf arguments without the leading "sf", e.g. ["data","query","-q","SELECT Id FROM Account LIMIT 1"] or ["sobject","describe","-s","CF71_Intake_Request__c"].'),
329
+ timeoutMs: z.number().optional().describe("Default 120000.")
330
+ }
331
+ }, async ({ sessionId, targetOrg: targetOrgOverride, args, timeoutMs }) => {
332
+ try {
333
+ // Session-derived by default; an explicit targetOrg is an intentional override for reading
334
+ // an org this session is not bound to. Reads skip the alias re-check (verify:false) —
335
+ // reading the wrong org is visible and harmless, and the round-trip is not free.
336
+ const org = await resolveReadOrg(sessionId, targetOrgOverride);
337
+ const targetOrg = org.alias;
338
+ assertTargetOrg(targetOrg);
339
+ // Gated on the CLI's own canonical form (colon and space spellings collapse to one), by
340
+ // VERB rather than by topic prefix, so an unlisted command is refused rather than assumed
341
+ // safe. See sfReadRefusal.
342
+ const refusal = sfReadRefusal(args);
343
+ if (refusal)
344
+ return fail("SF_COMMAND_NOT_ALLOWED", refusal);
345
+ // Label for messages only. An unrecognised-but-safe command is fine and simply names itself.
346
+ const matched = matchedReadCommand(args) ?? args.slice(0, 2);
347
+ const conflicting = args.find((a) => SF_TARGET_FLAGS.has(a));
348
+ if (conflicting) {
349
+ return fail("SF_TARGET_FLAG_CONFLICT", `Remove ${conflicting} from args — the target org is injected from the targetOrg parameter.`);
350
+ }
351
+ const full = [...args, "--target-org", targetOrg, ...(args.includes("--json") ? [] : ["--json"])];
352
+ const run = await runProcess("sf", full, { env: sfCliEnv(), timeoutMs: timeoutMs ?? 120_000 });
353
+ const raw = run.stdout.trim();
354
+ // READ PROVENANCE (R9, R11-R12). Recorded HERE — after the org answered, before any branch
355
+ // returns — so every path that reached the org leaves exactly one record and no path that
356
+ // did not (a gate refusal, a target-flag conflict) leaves any.
357
+ //
358
+ // sf_read is the open-world door, so it is the one probe that cannot know in advance what it
359
+ // is looking at. classifySfRead derives keys only from metadata-shaped reads and marks
360
+ // everything else OPAQUE rather than silently clean; the RAW QUERY never enters the record,
361
+ // only the object it named, because the reads segment ships in a provenance parcel while
362
+ // <session>/sf-read/ stays on this machine.
363
+ //
364
+ // Parsed ONCE, here, and reused by both branches below. A 5 MB describe is exactly the case
365
+ // this tool exists to handle well, and parsing it three times to answer three questions
366
+ // about the same bytes is the kind of cost that only shows up on the reads that hurt.
367
+ let parsedRaw;
368
+ try {
369
+ parsedRaw = JSON.parse(raw);
370
+ }
371
+ catch {
372
+ // Not JSON — classification falls through to an opaque record, which is the truth.
373
+ }
374
+ {
375
+ const seen = classifySfRead(args, parsedRaw);
376
+ const succeeded = run.exitCode === 0 && !run.timedOut;
377
+ await appendReadRecord(sessionId, {
378
+ tool: "sf_read",
379
+ orgId: org.orgId,
380
+ orgAlias: targetOrg,
381
+ scope: seen.scope,
382
+ pattern: seen.pattern,
383
+ keys: succeeded ? seen.keys : [],
384
+ rows: succeeded ? seen.rows : 0,
385
+ durationMs: run.durationMs,
386
+ outcome: succeeded ? "ok" : "failed",
387
+ ...(seen.object ? { object: seen.object } : {}),
388
+ // A FAILED read saw nothing, so it is not an unaccounted-for read — counting it opaque
389
+ // would inflate the very number that bounds the clean claim.
390
+ ...(succeeded && seen.opaque ? { opaque: true } : {})
391
+ });
392
+ }
393
+ if (run.timedOut)
394
+ return fail("SF_TIMEOUT", `sf ${matched.join(" ")} timed out.`);
395
+ const truncated = raw.length > SF_OUTPUT_CAP;
396
+ const body = truncated ? `${raw.slice(0, SF_OUTPUT_CAP)}\n…[truncated ${raw.length - SF_OUTPUT_CAP} chars — narrow the query]` : raw;
397
+ if (run.exitCode !== 0) {
398
+ return sfReadFailure(matched, args, run.exitCode, body, run.stderr);
399
+ }
400
+ if (truncated) {
401
+ // SPILL, don't refuse.
402
+ //
403
+ // This used to answer a legitimate question with nothing: the org had replied, the answer
404
+ // was correct, and the tool discarded it because it was large. That is the single worst
405
+ // thing a read tool can do — worse than truncating, because the caller is left with no
406
+ // data AND no file to go look at. It is what a developer would never accept from `sf`,
407
+ // where the obvious move is `sf ... > out.json` and then grep it.
408
+ //
409
+ // The full output now goes to disk and the response carries a readable head plus the
410
+ // path, so the answer is always recoverable: Read it with an offset, grep it, or narrow
411
+ // the query — the caller's choice, made with the data in hand rather than instead of it.
412
+ //
413
+ // (The cap itself stays: a 5 MB describe pasted into the context window is its own kind
414
+ // of failure, and the reason the cap existed in the first place.)
415
+ const dir = path.join(sessionDir(sessionId), "sf-read");
416
+ await fs.mkdir(dir, { recursive: true });
417
+ const file = path.join(dir, `${Date.now()}-${matched.join("-").replace(/[^a-z0-9-]/gi, "")}.json`);
418
+ await fs.writeFile(file, raw, "utf8");
419
+ // Not every read is a query envelope; the file is the answer either way.
420
+ const envelope = parsedRaw?.result;
421
+ const rows = envelope?.totalSize ?? envelope?.records?.length;
422
+ const describe = matched[0] === "sobject" && matched[1] === "describe";
423
+ const subject = args[args.indexOf("-s") + 1] ?? args[args.indexOf("--sobject") + 1];
424
+ return ok([
425
+ `sf ${matched.join(" ")} returned ${raw.length.toLocaleString()} chars${rows !== undefined ? ` (${rows.toLocaleString()} rows)` : ""} — over the ${SF_OUTPUT_CAP.toLocaleString()}-char inline cap, so the FULL output was written to:`,
426
+ ` ${file}`,
427
+ "",
428
+ "Nothing was lost. Read that file (with an offset for a slice), grep it, or re-run a narrower query.",
429
+ describe
430
+ ? `For a field inventory specifically, describe_object {sessionId, objectApiName: "${subject ?? "<object>"}", match: "<term>"} answers the same question compactly.`
431
+ : "",
432
+ "",
433
+ "First 2,000 chars:",
434
+ raw.slice(0, 2_000)
435
+ ]
436
+ .filter(Boolean)
437
+ .join("\n"), {
438
+ command: matched.join(" "),
439
+ truncated: true,
440
+ spilledTo: file,
441
+ bytes: raw.length,
442
+ ...(rows !== undefined ? { rows } : {})
443
+ });
444
+ }
445
+ // THE ROWS MUST BE IN structuredContent, not only in the text.
446
+ //
447
+ // Live-caught CF75: this returned {command, truncated} as its structured payload and put the
448
+ // records in the text alone. A client that renders structuredContent when present therefore
449
+ // showed the caller an envelope with no rows, on every single query. The run concluded the
450
+ // tool was broken, stopped using it, and — worse — briefly asserted a namespace was empty on
451
+ // the strength of a read that had returned nothing. A read tool that silently returns
452
+ // nothing is a correctness hazard, not an inconvenience.
453
+ //
454
+ // The two tools that appeared to "work" prove the mechanism: fail() nests the message inside
455
+ // structuredContent, and describe_object nests its `fields` there — so errors and field
456
+ // inventories both rendered, while sf_read's rows never did.
457
+ //
458
+ // `parsedRaw` is `body` here — truncation returned above, so raw and body are the same
459
+ // bytes. Undefined when the output was not JSON, in which case the text is the only
460
+ // representation, which is correct: there is nothing structured to hand back.
461
+ let parsed;
462
+ // Salesforce echoes an `attributes` block ({type, url}) on EVERY record. It is never the
463
+ // answer to anything — the type is in the query, the id is already a field — and it costs
464
+ // ~109 chars per row. One 80-row enumeration in the CF78 run carried 8.5 KB of it, and
465
+ // every query pays proportionally. Stripped here rather than asked of every caller.
466
+ if (parsedRaw && typeof parsedRaw === "object" && "result" in parsedRaw) {
467
+ parsed = { result: stripAttributes(parsedRaw.result) };
468
+ }
469
+ // The mirror is off exactly when `result` rides structuredContent, and on otherwise.
470
+ // `parsed === undefined` is NOT "parsing failed" — it also covers a valid JSON envelope
471
+ // with no `result` key (org list, some tooling reads). Both cases mirror, and must: with
472
+ // the client dropping the content channel (ok()'s header), whatever is not in
473
+ // structuredContent does not exist for the model, and CF75 was precisely a read whose rows
474
+ // rode the text alone. The wire carries the body twice on the mirrored branches; the model
475
+ // sees it once either way, which is the budget that matters.
476
+ return ok(body || "(empty result)", {
477
+ command: matched.join(" "),
478
+ truncated,
479
+ ...(parsed ?? {})
480
+ }, { mirrorText: parsed === undefined });
481
+ }
482
+ catch (error) {
483
+ if (error instanceof GateError)
484
+ return fail(error.code, error.message);
485
+ if (error instanceof SessionOrgError)
486
+ return fail(error.code, error.message);
487
+ if (error instanceof WorkspaceError)
488
+ return fail(error.code, error.message);
489
+ return internalError(error);
490
+ }
491
+ });
492
+ server.registerTool("list_components", {
493
+ description: "Enumerate the org's actual candidates for one component family — DISCOVERY, which project_org cannot do (it can only project components you can already name). Use when a binding is ambiguous or missing: 19 Account layouts, no 'Sales User' profile. Read-only, fail-closed, session-scoped. Layout and RecordType require objectApiName. Pass `match` to narrow. Prefer this over hand-writing the SOQL through sf_read: the queries encode traps (Layout needs the Tooling API; PermissionSet must exclude IsOwnedByProfile, or profile-owned sets come back looking like real ones).",
494
+ inputSchema: {
495
+ sessionId: z.string().describe("The session whose org to read. Supplies the org so the alias never has to be restated."),
496
+ targetOrg: z
497
+ .string()
498
+ .optional()
499
+ .describe("Omit for the session's deploy target, which is the normal case. Pass \"campfire\" to describe/list against the session's Campfire CONSULTANT org without knowing its alias — use it before querying Campfire objects rather than guessing a field or relationship name. Any other value is a literal alias."),
500
+ typeName: z.enum(["Layout", "Profile", "PermissionSet", "PermissionSetGroup", "RecordType"]),
501
+ objectApiName: z
502
+ .string()
503
+ .optional()
504
+ .describe("Required for Layout and RecordType: the object whose candidates to list."),
505
+ match: z.string().optional().describe("Case-insensitive substring filter on the returned names. An org can have hundreds of profiles and permission sets.")
506
+ }
507
+ }, async ({ sessionId, targetOrg: targetOrgOverride, typeName, objectApiName, match }) => {
508
+ try {
509
+ // Session-derived by default; an explicit targetOrg is an intentional override for reading
510
+ // an org this session is not bound to. Reads skip the alias re-check (verify:false) —
511
+ // reading the wrong org is visible and harmless, and the round-trip is not free.
512
+ const org = await resolveReadOrg(sessionId, targetOrgOverride);
513
+ const targetOrg = org.alias;
514
+ assertTargetOrg(targetOrg);
515
+ const needsObject = typeName === "Layout" || typeName === "RecordType";
516
+ if (needsObject) {
517
+ if (!objectApiName) {
518
+ return fail("OBJECT_REQUIRED", `${typeName} enumeration requires objectApiName.`);
519
+ }
520
+ if (!OBJECT_NAME_RE.test(objectApiName)) {
521
+ return fail("INVALID_OBJECT_NAME", `Object API name ${JSON.stringify(objectApiName)} fails the allowlist charset guard.`);
522
+ }
523
+ }
524
+ let soql;
525
+ let tooling = false;
526
+ let mapRow;
527
+ switch (typeName) {
528
+ case "Layout":
529
+ soql = `SELECT Name FROM Layout WHERE EntityDefinition.QualifiedApiName = '${objectApiName}' ORDER BY Name`;
530
+ tooling = true;
531
+ mapRow = (row) => ({
532
+ name: row.Name,
533
+ fullName: `${objectApiName}-${String(row.Name)}`
534
+ });
535
+ break;
536
+ case "Profile":
537
+ soql = "SELECT Name FROM Profile ORDER BY Name";
538
+ mapRow = (row) => ({ name: row.Name });
539
+ break;
540
+ case "PermissionSet":
541
+ soql = "SELECT Name, Label FROM PermissionSet WHERE IsOwnedByProfile = false ORDER BY Name";
542
+ mapRow = (row) => ({ name: row.Name, label: row.Label });
543
+ break;
544
+ case "PermissionSetGroup":
545
+ soql = "SELECT DeveloperName, MasterLabel FROM PermissionSetGroup ORDER BY DeveloperName";
546
+ mapRow = (row) => ({ name: row.DeveloperName, label: row.MasterLabel });
547
+ break;
548
+ case "RecordType":
549
+ soql = `SELECT DeveloperName, Name FROM RecordType WHERE SobjectType = '${objectApiName}' ORDER BY DeveloperName`;
550
+ mapRow = (row) => ({ name: row.DeveloperName, label: row.Name });
551
+ break;
552
+ }
553
+ const args = ["data", "query", "-q", soql, "--target-org", targetOrg, "--json"];
554
+ if (tooling)
555
+ args.splice(2, 0, "--use-tooling-api");
556
+ const run = await runProcess("sf", args, { env: sfCliEnv(), timeoutMs: 60_000 });
557
+ const scope = `${typeName}${objectApiName ? ` ${objectApiName}` : ""}${match ? ` match=${match}` : ""}`;
558
+ if (run.exitCode !== 0) {
559
+ // Still a record: the call reached the org (R9). A failed read saw nothing, so it carries
560
+ // no keys — and is not counted opaque, which is reserved for reads that DID return.
561
+ await appendReadRecord(sessionId, {
562
+ tool: "list_components", orgId: org.orgId, orgAlias: targetOrg, scope,
563
+ pattern: "enumeration", keys: [], rows: 0, durationMs: run.durationMs, outcome: "failed",
564
+ ...(objectApiName ? { object: objectApiName } : {})
565
+ });
566
+ return fail("ORG_READ_FAILED", `sf data query exited ${run.exitCode}${run.timedOut ? " (timeout)" : ""}. Fail-closed: candidates are UNKNOWN.\n${stdoutTail(run.stderr || run.stdout, 15)}`);
567
+ }
568
+ const parsed = JSON.parse(run.stdout);
569
+ const truncation = sfQueryTruncation(parsed);
570
+ if (truncation) {
571
+ // Same posture as the exit-code branch above: a capped read saw SOMETHING, but
572
+ // delivering it as complete would let absence of data read as absence of records.
573
+ // Refused, so nothing reaches the supervisor — recorded like a failed read.
574
+ await appendReadRecord(sessionId, {
575
+ tool: "list_components", orgId: org.orgId, orgAlias: targetOrg, scope,
576
+ pattern: "enumeration", keys: [], rows: 0, durationMs: run.durationMs, outcome: "failed",
577
+ ...(objectApiName ? { object: objectApiName } : {})
578
+ });
579
+ return fail("ORG_READ_TRUNCATED", `${truncation} Fail-closed: candidates are INCOMPLETE.`);
580
+ }
581
+ const every = (parsed.result?.records ?? []).map(mapRow);
582
+ // Filtered server-side for the same reason as describe_object: candidates the supervisor is
583
+ // not hunting for cost context whether or not it reads them.
584
+ const needle = match?.trim().toLowerCase();
585
+ const rows = needle
586
+ ? every.filter((row) => Object.values(row).some((v) => String(v).toLowerCase().includes(needle)))
587
+ : every;
588
+ // READ PROVENANCE (R9, R12). This tool's whole purpose is DISCOVERY, so its pattern is
589
+ // always ENUMERATION — it lists a family, it never reads a definition.
590
+ //
591
+ // Keyed off the DELIVERED rows, not the org's full answer. `match` filters after the query,
592
+ // so a filtered call caused a wide enumeration but the supervisor only ever saw the
593
+ // matches — and a name nobody saw cannot have influenced anything. Over-reporting here
594
+ // would make a narrow lookup read like a corpus sweep in the contamination report.
595
+ await appendReadRecord(sessionId, {
596
+ tool: "list_components",
597
+ orgId: org.orgId,
598
+ orgAlias: targetOrg,
599
+ scope,
600
+ pattern: "enumeration",
601
+ keys: rows
602
+ .map((row) => typeName === "Layout"
603
+ ? String(row.fullName)
604
+ : typeName === "RecordType"
605
+ ? `${objectApiName}.${String(row.name)}`
606
+ : String(row.name))
607
+ .map((fullName) => `${typeName}:${fullName}`),
608
+ rows: rows.length,
609
+ durationMs: run.durationMs,
610
+ outcome: "ok",
611
+ ...(objectApiName ? { object: objectApiName } : {})
612
+ });
613
+ if (needle && rows.length === 0) {
614
+ return ok(`${typeName}${objectApiName ? ` (${objectApiName})` : ""}: no candidate matching "${match}" among ${every.length}. Fail-closed — that is "none matched", not "none exist"; re-run without match to see them all.`, { typeName, objectApiName: objectApiName ?? null, match, matched: 0, total: every.length, candidates: [] });
615
+ }
616
+ const head = rows
617
+ .slice(0, 50)
618
+ .map((row) => Object.values(row).join("\t"))
619
+ .join("\n");
620
+ return ok(`${typeName}${objectApiName ? ` (${objectApiName})` : ""} in ${targetOrg}: ${needle ? `${rows.length} of ${every.length} matching "${match}"` : `${rows.length} candidate(s)`}\n${head}` +
621
+ (rows.length > 50 ? `\n… (${rows.length - 50} more)` : ""), { typeName, objectApiName: objectApiName ?? null, total: every.length, ...(needle ? { match } : {}), candidates: rows });
622
+ }
623
+ catch (error) {
624
+ if (error instanceof GateError)
625
+ return fail(error.code, error.message);
626
+ if (error instanceof SessionOrgError)
627
+ return fail(error.code, error.message);
628
+ if (error instanceof WorkspaceError)
629
+ return fail(error.code, error.message);
630
+ return internalError(error);
631
+ }
632
+ });
633
+ server.registerTool("describe_object", {
634
+ description: "Field inventory for one object via the Tooling API (FieldDefinition): apiName, dataType, label per field. Read-only, session-scoped. THE tool for binding work (mapping prose field references to real API names) — `sf_read sobject describe` returns ~800 KB for a standard object (it now spills the full answer to a file and hands back the path, so it is usable but costs a file read), and project_org costs a read per component. Pass `match` to get just the candidates you are hunting for instead of every field.",
635
+ inputSchema: {
636
+ sessionId: z.string().describe("The session whose org to read. Supplies the org so the alias never has to be restated."),
637
+ targetOrg: z
638
+ .string()
639
+ .optional()
640
+ .describe("Omit for the session's deploy target, which is the normal case. Pass \"campfire\" to describe/list against the session's Campfire CONSULTANT org without knowing its alias — use it before querying Campfire objects rather than guessing a field or relationship name. Any other value is a literal alias."),
641
+ objectApiName: z.string().describe("Object API name, e.g. Account or Invoice__c."),
642
+ match: z
643
+ .string()
644
+ .optional()
645
+ .describe("Case-insensitive substring filter on apiName or label. Binding work is a lookup, not an inventory: Account has 365 fields, and `match: \"priority\"` answers the question in two lines instead of twenty kilobytes. Omit only when you genuinely need the whole object.")
646
+ }
647
+ }, async ({ sessionId, targetOrg: targetOrgOverride, objectApiName, match }) => {
648
+ try {
649
+ // Session-derived by default; an explicit targetOrg is an intentional override for reading
650
+ // an org this session is not bound to. Reads skip the alias re-check (verify:false) —
651
+ // reading the wrong org is visible and harmless, and the round-trip is not free.
652
+ const org = await resolveReadOrg(sessionId, targetOrgOverride);
653
+ const targetOrg = org.alias;
654
+ assertTargetOrg(targetOrg);
655
+ if (!OBJECT_NAME_RE.test(objectApiName)) {
656
+ return fail("INVALID_OBJECT_NAME", `Object API name ${JSON.stringify(objectApiName)} fails the allowlist charset guard.`);
657
+ }
658
+ const soql = "SELECT QualifiedApiName, DataType, Label FROM FieldDefinition " +
659
+ `WHERE EntityDefinition.QualifiedApiName = '${objectApiName}' ORDER BY QualifiedApiName`;
660
+ const run = await runProcess("sf", ["data", "query", "--use-tooling-api", "-q", soql, "--target-org", targetOrg, "--json"], { env: sfCliEnv(), timeoutMs: 60_000 });
661
+ const scope = `${objectApiName}${match ? ` match=${match}` : ""}`;
662
+ if (run.exitCode !== 0) {
663
+ await appendReadRecord(sessionId, {
664
+ tool: "describe_object", orgId: org.orgId, orgAlias: targetOrg, scope, object: objectApiName,
665
+ pattern: "enumeration", keys: [], rows: 0, durationMs: run.durationMs, outcome: "failed"
666
+ });
667
+ return fail("ORG_READ_FAILED", `sf data query exited ${run.exitCode}${run.timedOut ? " (timeout)" : ""}. Fail-closed.\n${stdoutTail(run.stderr || run.stdout, 15)}`);
668
+ }
669
+ const parsed = JSON.parse(run.stdout);
670
+ const truncation = sfQueryTruncation(parsed);
671
+ if (truncation) {
672
+ await appendReadRecord(sessionId, {
673
+ tool: "describe_object", orgId: org.orgId, orgAlias: targetOrg, scope, object: objectApiName,
674
+ pattern: "enumeration", keys: [], rows: 0, durationMs: run.durationMs, outcome: "failed"
675
+ });
676
+ return fail("ORG_READ_TRUNCATED", `${truncation} Fail-closed: the field inventory is INCOMPLETE.`);
677
+ }
678
+ const records = parsed.result?.records ?? [];
679
+ const all = records.map((record) => ({
680
+ apiName: record.QualifiedApiName,
681
+ dataType: record.DataType,
682
+ label: record.Label
683
+ }));
684
+ // Filtered HERE rather than by the caller reading everything: the point is to keep the
685
+ // other 363 fields out of the transcript, which only works if they never arrive.
686
+ const needle = match?.trim().toLowerCase();
687
+ const fields = needle
688
+ ? all.filter((f) => f.apiName.toLowerCase().includes(needle) || f.label.toLowerCase().includes(needle))
689
+ : all;
690
+ // READ PROVENANCE (R9, R12) — ENUMERATION-PER-FIELD of the described object. The object is
691
+ // named because the call named it; each field is enumerated because the call listed them.
692
+ // Neither is a body read: this returns apiName/dataType/label, never a definition.
693
+ //
694
+ // Standard fields are keyed alongside custom ones — a field is a `CustomField` component
695
+ // regardless of suffix, and prior-arm work on a standard field has to stay joinable. Keys
696
+ // follow the DELIVERED rows for the same reason list_components does: `match` filters
697
+ // before the supervisor sees anything, and a name nobody saw influenced nothing.
698
+ await appendReadRecord(sessionId, {
699
+ tool: "describe_object",
700
+ orgId: org.orgId,
701
+ orgAlias: targetOrg,
702
+ scope,
703
+ object: objectApiName,
704
+ pattern: "enumeration",
705
+ // Zero rows means the object may not exist at all, so the object key is withheld:
706
+ // positive evidence only, and "we read CustomObject:X" would be a claim about something
707
+ // this call just failed to find.
708
+ keys: records.length
709
+ ? [`CustomObject:${objectApiName}`, ...fields.map((f) => `CustomField:${objectApiName}.${f.apiName}`)]
710
+ : [],
711
+ rows: fields.length,
712
+ durationMs: run.durationMs,
713
+ outcome: "ok"
714
+ });
715
+ if (records.length === 0) {
716
+ return ok(`object ${objectApiName}: 0 fields returned — the object may not exist in ${targetOrg} (verify with project_org, scope.components {typeName: CustomObject}).`, { object: objectApiName, exists: "unknown", fields: [] });
717
+ }
718
+ if (needle && fields.length === 0) {
719
+ return ok(`object ${objectApiName}: no field matching "${match}" among ${all.length} field(s). The prose name is not the API name — widen the match or read the projection.`, { object: objectApiName, match, matched: 0, totalFields: all.length, fields: [] });
720
+ }
721
+ const head = fields
722
+ .slice(0, 40)
723
+ .map((field) => `${field.apiName}\t${field.dataType}\t${field.label}`)
724
+ .join("\n");
725
+ return ok(`object ${objectApiName}: ${needle ? `${fields.length} of ${all.length} field(s) matching "${match}"` : `${fields.length} field(s)`}\napiName\tdataType\tlabel\n${head}` +
726
+ (fields.length > 40 ? `\n… (${fields.length - 40} more)` : "") +
727
+ (!needle && all.length > 60 ? `\nThis is the whole object. For a lookup, pass match to get only the candidates.` : ""), { object: objectApiName, totalFields: all.length, ...(needle ? { match } : {}), fields });
728
+ }
729
+ catch (error) {
730
+ if (error instanceof GateError)
731
+ return fail(error.code, error.message);
732
+ if (error instanceof SessionOrgError)
733
+ return fail(error.code, error.message);
734
+ if (error instanceof WorkspaceError)
735
+ return fail(error.code, error.message);
736
+ return internalError(error);
737
+ }
738
+ });
739
+ }