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,1613 @@
1
+ import path from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { z } from "zod";
4
+ import { fail, internalError, ok } from "../core/result.js";
5
+ import { WorkspaceError, sessionDir } from "../core/workspace.js";
6
+ import { OPERATOR_IDENTITY } from "../core/paths.js";
7
+ import { GateError, assertDirectWriteAllowed } from "../core/gate.js";
8
+ import { SessionOrgError, resolveSessionOrg } from "../core/sessionOrg.js";
9
+ import { loadRequirementsIndex, loadSession } from "./session.js";
10
+ import { projectionExistence } from "./waves.js";
11
+ import { readPlanFile } from "../core/planFile.js";
12
+ import { deriveRevisionChains } from "../core/revisionChain.js";
13
+ import { OrgReaderUnavailableError, createLiveOrgReader } from "../projection/bridge.js";
14
+ import { runLint } from "../direct/lint.js";
15
+ import { loadPredecessorPreImages, previewPredecessorDrops, runDirectApply, validateComponents } from "../direct/applyLoop.js";
16
+ import { RollbackError } from "../direct/rollback.js";
17
+ import { resolveRollbackBaseline, runRollback } from "../direct/rollbackRun.js";
18
+ import { OrgRepoError, historyDiff, historyIncompleteFacets, historyLog, historyShow } from "../direct/orgRepo.js";
19
+ import { computeContamination, renderContamination, writeContaminationArtifacts } from "../direct/contamination.js";
20
+ import { matchedComponentCount } from "../direct/waveCounts.js";
21
+ import { walkSourceTree } from "../direct/sourceTree.js";
22
+ import { assembleWave, resolveAuthored } from "../direct/waveAssembly.js";
23
+ import { compareToDerivation, readWaveDerivation, renderDivergence } from "../direct/waveDerivation.js";
24
+ import { ProjectionNotReadableError, readProjectionManifest } from "../direct/knownTokens.js";
25
+ import { collectSessionProjection, describeSources, projectionView, viewKnownTokens, viewProvenValues } from "../direct/sessionProjection.js";
26
+ import { emitDomainTelemetryEvent } from "../core/telemetryContext.js";
27
+ import { orgCallMeterSnapshot } from "../core/orgCallMeter.js";
28
+ import { appendReadRecord } from "../core/readsLog.js";
29
+ /**
30
+ * Which attempt at this wave label this is, 1-based.
31
+ *
32
+ * The ledger dedupes waves on (batchId, waveLabel, attempt) and this was hardcoded to 1, so a
33
+ * RETRY of the same label collided with its own first attempt and was dropped on arrival: attempt 1
34
+ * `REJECTED_BY_ORG`, the fixed retry `DIFF_VERIFIED`, and the dashboard kept showing red for a wave
35
+ * that had succeeded — permanently, since the row is never revisited.
36
+ *
37
+ * Persisted in the bundle because the bundle directory IS the identity here: a same-label re-run
38
+ * reuses it (that is what makes `direct_lint {projection: "<label>"}` resolve), so the previous
39
+ * attempt's marker is exactly where the next run will look. Written BEFORE the apply, not after —
40
+ * a crashed attempt that never wrote a verdict still consumed its number, and over-counting
41
+ * attempts is harmless while re-using one silently discards a result.
42
+ *
43
+ * A retry under a DIFFERENT label is a different wave to everything downstream, and no numbering
44
+ * here can change that; it is the operator naming two things the same work.
45
+ */
46
+ async function nextWaveAttempt(bundleDir) {
47
+ const marker = path.join(bundleDir, "attempt.json");
48
+ let previous = 0;
49
+ try {
50
+ const parsed = JSON.parse(await fs.readFile(marker, "utf8"));
51
+ if (typeof parsed.attempt === "number" && Number.isFinite(parsed.attempt))
52
+ previous = parsed.attempt;
53
+ }
54
+ catch {
55
+ // No marker, or unreadable — first attempt. Never fail an apply over bookkeeping.
56
+ }
57
+ const attempt = previous + 1;
58
+ try {
59
+ await fs.mkdir(bundleDir, { recursive: true });
60
+ await fs.writeFile(marker, `${JSON.stringify({ attempt }, null, 1)}\n`, "utf8");
61
+ }
62
+ catch {
63
+ // Same reasoning: a wave that cannot record its attempt number still deploys.
64
+ }
65
+ return attempt;
66
+ }
67
+ /**
68
+ * Preserve the PRIOR attempt's evidence before a retry overwrites it.
69
+ *
70
+ * The bundle dir is keyed by wave label alone and runDirectApply rewrites pre/ (and later post/
71
+ * and verdict.json) in place — so before this existed, a same-label retry replaced attempt 1's
72
+ * pre-image with org state attempt 1 had already changed, and a later rollback would "restore" a
73
+ * mid-wave state while reporting success. attempts/<n>/ is written once and never rewritten: the
74
+ * earliest archived attempt that captured a pre-image holds the baseline from before this wave
75
+ * first touched the org, which is the only state a rollback of the wave can honestly restore.
76
+ *
77
+ * First-write-wins, because attempt.json increments even for a run that never rewrote pre/ — an
78
+ * unsupported-metadata-type throw dies before request.json, let alone the pre-write — so a later
79
+ * archive call for the same number must not re-copy bytes a newer attempt has since replaced.
80
+ * "Written" is defined by COMPLETION, not by the directory existing: the copy lands in
81
+ * attempts/.tmp-<n> and is renamed into place only after every entry, plus the archive.json
82
+ * manifest recording which entries the prior attempt actually left, is on disk. A crash mid-copy
83
+ * therefore leaves only a temp dir the next call clears — never a half-archive that looks
84
+ * finished. The manifest is what lets resolveRollbackBaseline tell "attempt n left nothing
85
+ * behind, so it never reached the org" (a proven no-op — the bundle's own pre/ is still honest)
86
+ * apart from "attempt n's evidence is gone" (refuse). An attempt that left NOTHING still gets its
87
+ * directory and an all-false manifest, for the same reason: recorded absence is evidence, silent
88
+ * absence is indistinguishable from a legacy unarchived retry and refuses a rollback forever.
89
+ *
90
+ * Never throws: same contract as nextWaveAttempt — a wave that cannot record its bookkeeping
91
+ * still deploys.
92
+ */
93
+ export async function archivePriorAttempt(bundleDir, priorAttempt) {
94
+ const attemptsRoot = path.join(bundleDir, "attempts");
95
+ const dest = path.join(attemptsRoot, String(priorAttempt));
96
+ try {
97
+ await fs.access(dest);
98
+ return;
99
+ }
100
+ catch {
101
+ // Not yet archived — this call owns the write.
102
+ }
103
+ try {
104
+ const tmp = path.join(attemptsRoot, `.tmp-${priorAttempt}`);
105
+ await fs.rm(tmp, { recursive: true, force: true });
106
+ await fs.mkdir(tmp, { recursive: true });
107
+ const archived = {};
108
+ for (const entry of ["pre", "post", "verdict.json", "request.json"]) {
109
+ try {
110
+ await fs.cp(path.join(bundleDir, entry), path.join(tmp, entry), { recursive: true });
111
+ archived[entry] = true;
112
+ }
113
+ catch {
114
+ // The prior attempt never wrote this entry; the manifest records the absence as a fact.
115
+ archived[entry] = false;
116
+ }
117
+ }
118
+ await fs.writeFile(path.join(tmp, "archive.json"), `${JSON.stringify({ archived }, null, 1)}\n`, "utf8");
119
+ await fs.rename(tmp, dest);
120
+ }
121
+ catch {
122
+ // Never throws — see the contract above. A failed archive leaves at most a temp dir, and the
123
+ // resolver refuses an attempt with no completed archive rather than guessing.
124
+ }
125
+ }
126
+ const DEFAULT_API_VERSION = "61.0";
127
+ const LABEL_RE = /^[A-Za-z0-9_-]{1,40}$/;
128
+ // How many inline components still count as a "correction" rather than a batch.
129
+ //
130
+ // Set from the evidence rather than taste: in the CF73 run, 21 of 22 applies passed XML inline and
131
+ // ZERO used the wave path, even though the contract has always said to author files. Nothing was
132
+ // disobeyed — inline is one tool call and files are N writes plus an apply, so the cheap path won
133
+ // every time. A warning would have changed nothing; the contract already was the warning.
134
+ //
135
+ // Three is where a correction stops looking like a correction. Below it, inline stays frictionless
136
+ // for the genuine one-off (fix a label, re-emit a field). At or above it, the batch has to go
137
+ // through files, which is what makes analyzer authoring, wave assembly, per-wave lint, diffs and
138
+ // (later) git history mean anything at all. `sourceDir` remains for trees outside the session, so
139
+ // this closes a shortcut, not an escape route.
140
+ //
141
+ // SCOPED TO INLINE. `waveComponents` is a list of component KEYS, not XML — a wave of forty of them
142
+ // is the shape this guard exists to push callers toward, so it never counts against this limit.
143
+ const INLINE_MAX = 3;
144
+ // Where a wave's authored metadata lives. Making this DERIVED rather than passed is the point:
145
+ // the session already knows the workspace, so a caller that names the wave does not also have to
146
+ // carry an absolute path between the lint call and the apply call — and cannot have the two
147
+ // disagree. Returns undefined unless BOTH are given, so an explicit sourceDir still wins and
148
+ // nothing changes for callers that pass one.
149
+ function authoredWaveDir(sessionId, wave) {
150
+ if (!sessionId || !wave)
151
+ return undefined;
152
+ if (!LABEL_RE.test(wave))
153
+ return undefined;
154
+ return path.join(sessionDir(sessionId), "authored", wave);
155
+ }
156
+ /**
157
+ * A derived source tree that does not exist must refuse by NAME, not as a raw ENOENT. Live-caught:
158
+ * `direct_apply {wave: "no-such-wave"}` surfaced
159
+ * `INTERNAL_ERROR: ENOENT ... scandir '<path>/authored/no-such-wave'`, which reads as a server
160
+ * fault rather than "you named a wave you have not authored yet" — and gives the model nothing to
161
+ * correct toward. Listing the waves that DO exist turns a dead end into a choice, the same way the
162
+ * projection and wave-label refusals do.
163
+ */
164
+ async function assertAuthoredWaveExists(sessionId, wave, dir) {
165
+ try {
166
+ if ((await fs.stat(dir)).isDirectory())
167
+ return { ok: true, dir };
168
+ }
169
+ catch {
170
+ /* fall through to the listing refusal */
171
+ }
172
+ let available = [];
173
+ try {
174
+ available = (await fs.readdir(path.join(sessionDir(sessionId), "authored"), { withFileTypes: true }))
175
+ .filter((e) => e.isDirectory())
176
+ .map((e) => e.name)
177
+ .sort();
178
+ }
179
+ catch {
180
+ /* none authored yet */
181
+ }
182
+ return {
183
+ ok: false,
184
+ code: "WAVE_NOT_FOUND",
185
+ message: `no authored tree for wave "${wave}" (expected ${dir}).` +
186
+ (available.length ? ` Authored waves: ${available.join(", ")}.` : " Nothing authored in this session yet.") +
187
+ " Write the wave's source-format files there first."
188
+ };
189
+ }
190
+ async function isProjection(dir) {
191
+ try {
192
+ await fs.access(path.join(dir, "projection.json"));
193
+ return true;
194
+ }
195
+ catch {
196
+ return false;
197
+ }
198
+ }
199
+ /**
200
+ * The session's target-org alias, or undefined when it cannot be determined.
201
+ *
202
+ * Best-effort BY DESIGN: this only scopes which projections the referential tier may union, and an
203
+ * unbound or unreadable session should downgrade that union rather than fail a lint that has
204
+ * nothing to do with the org. `verify: false` for the same reason — the caller is naming a
205
+ * directory on disk, not touching the org.
206
+ */
207
+ async function sessionTargetOrg(sessionId) {
208
+ try {
209
+ const session = await loadSession(sessionId);
210
+ return (await resolveSessionOrg(session, "target", { verify: false })).alias;
211
+ }
212
+ catch {
213
+ return undefined;
214
+ }
215
+ }
216
+ /** Every projection label available in this session, for a useful refusal rather than a dead end. */
217
+ async function availableProjections(sessionId, targetOrg) {
218
+ const out = [];
219
+ const orgRoot = path.join(sessionDir(sessionId), "org-projection", targetOrg);
220
+ try {
221
+ for (const e of await fs.readdir(orgRoot, { withFileTypes: true })) {
222
+ if (e.isDirectory() && (await isProjection(path.join(orgRoot, e.name))))
223
+ out.push(e.name);
224
+ }
225
+ }
226
+ catch {
227
+ /* none yet */
228
+ }
229
+ const directRoot = path.join(sessionDir(sessionId), "direct");
230
+ try {
231
+ for (const e of await fs.readdir(directRoot, { withFileTypes: true })) {
232
+ if (e.isDirectory() && (await isProjection(path.join(directRoot, e.name, "post"))))
233
+ out.push(e.name);
234
+ }
235
+ }
236
+ catch {
237
+ /* none yet */
238
+ }
239
+ return [...new Set(out)].sort();
240
+ }
241
+ /**
242
+ * The facets a history entry's record does NOT contain, rendered for an operator.
243
+ *
244
+ * Printed rather than left to the trailer text, because the failure this guards against is a reader
245
+ * drawing a conclusion from an ABSENCE. `readLayoutSnapshot` deletes the facet it could not verify,
246
+ * so `relatedLists` missing from a stored layout looks exactly like a layout with none — and a
247
+ * supervisor who reads it that way authors a replace that deletes them. "NOT READ at capture" is
248
+ * the sentence that stops it.
249
+ */
250
+ function renderIncompleteFacets(records) {
251
+ if (records.length === 0)
252
+ return "";
253
+ const lines = records.map((r) => ` ${r.component} — ${r.facets.map((f) => `${f}: NOT READ at capture`).join("; ")}`);
254
+ return (`\n\nINCOMPLETE RECORD — this entry's post-image is missing ${records.length === 1 ? "a facet" : "facets"} the read ` +
255
+ `could not reach. NOT a claim that the facet is empty; it was never read, and nothing below shows it:\n${lines.join("\n")}`);
256
+ }
257
+ /**
258
+ * Resolve a projection LABEL to a directory, session-relative.
259
+ *
260
+ * A label, not a path, for the same reason `waves` is: the session already knows where projections
261
+ * live, so making the caller carry an absolute path between project_org and direct_lint is state
262
+ * living in the model's context that can go stale or disagree with itself.
263
+ *
264
+ * TWO sources, because both are real projections and the second is the common one: `project_org`
265
+ * writes org-projection/<org>/<label>/, and every `direct_apply` writes <bundle>/post/ — which is
266
+ * the org as of the moment that wave landed, and exactly what the NEXT wave should lint against.
267
+ * Resolving only the first would leave the model hand-building the post/ path, i.e. would not
268
+ * actually remove the path from the calling convention.
269
+ *
270
+ * Omitting the label does NOT pick one automatically — picking one is the failure mode. A session
271
+ * accumulates narrow probes and pre-deploy baselines alongside broad grounding projections, so
272
+ * "newest wins" lands on the wrong one routinely (MEASURED on CF72: wave2 against the pre-deploy
273
+ * baseline produced 2 REF_UNKNOWN findings naming 5 fields that exist and are spelled correctly;
274
+ * against wave1's post-image the same wave is clean). With no label the tools ground against ALL of
275
+ * them at once, freshest observation per component — see `collectSessionProjection`. A label is
276
+ * therefore an override: it narrows grounding to one org state on purpose.
277
+ */
278
+ export async function resolveProjection(sessionId, projection) {
279
+ if (!sessionId) {
280
+ return { ok: false, code: "SESSION_REQUIRED", message: "`projection` is a label resolved inside a session — pass sessionId too, or use projectionDir for a path outside it." };
281
+ }
282
+ if (!LABEL_RE.test(projection)) {
283
+ return { ok: false, code: "INVALID_LABEL", message: `projection label must match ${LABEL_RE}.` };
284
+ }
285
+ const session = await loadSession(sessionId);
286
+ const { alias: targetOrg } = await resolveSessionOrg(session, "target", { verify: false });
287
+ const fromProjectOrg = path.join(sessionDir(sessionId), "org-projection", targetOrg, projection);
288
+ const fromApply = path.join(sessionDir(sessionId), "direct", projection, "post");
289
+ const hits = [
290
+ ...((await isProjection(fromProjectOrg)) ? [fromProjectOrg] : []),
291
+ ...((await isProjection(fromApply)) ? [fromApply] : [])
292
+ ];
293
+ if (hits.length === 1)
294
+ return { ok: true, dir: hits[0] };
295
+ if (hits.length > 1) {
296
+ // project_org labels and direct_apply labels are separate namespaces, so a collision is
297
+ // possible. Refuse rather than pick: the two mean different org states.
298
+ return {
299
+ ok: false,
300
+ code: "AMBIGUOUS_PROJECTION",
301
+ message: `"${projection}" matches both a project_org projection and a deploy bundle's post-image:\n ${hits.join("\n ")}\nPass projectionDir with the one you mean.`
302
+ };
303
+ }
304
+ const available = await availableProjections(sessionId, targetOrg);
305
+ return {
306
+ ok: false,
307
+ code: "PROJECTION_NOT_FOUND",
308
+ message: `no projection labelled "${projection}" in this session.` +
309
+ (available.length ? ` Available: ${available.join(", ")}.` : " None captured yet — run project_org first.") +
310
+ " Labels come from project_org's `label`, or a direct_apply bundle label (its post-image)."
311
+ };
312
+ }
313
+ const EDIT_SCHEMA = z.object({
314
+ typeName: z.string().describe("Metadata type, e.g. CustomField, Flow, ListView. Any single-file XML or content-file type Salesforce's registry knows; directory bundles (LWC/Aura) refuse."),
315
+ fullName: z.string().describe("Component fullName, e.g. CF63_Intake_Request__c.My_Field__c (folder types: 'Folder/Name')."),
316
+ xml: z.string().describe("The component's (meta) XML (root element = typeName; xmlns/header auto-added)."),
317
+ content: z.string().optional().describe("REQUIRED for content-file types (EmailTemplate → the .email body, StaticResource → the .resource content, Document): the accompanying content-file body."),
318
+ contentEncoding: z.enum(["utf8", "base64"]).optional().describe("Encoding of `content`; base64 for binary static resources (default utf8).")
319
+ });
320
+ const WAVE_COMPONENT_SCHEMA = z.object({
321
+ typeName: z.string().describe("Metadata type, e.g. CustomField, Flow, Layout."),
322
+ fullName: z.string().describe("Component fullName, exactly as plan.json records it."),
323
+ path: z
324
+ .string()
325
+ .optional()
326
+ .describe("WHICH authored file, for a component this batch changes more than once. Matched against the plan entries' `authoredFile`. plan_waves emits it on multi-revision keys and leaves it off single-revision ones, so an ordinary wave list is bare {typeName, fullName}.")
327
+ });
328
+ /**
329
+ * The wave path: rebuild `<session>/authored/<wave>` from the files the analyzers authored, and hand
330
+ * back the tree to walk.
331
+ *
332
+ * This is the step the supervisor used to take as a separate `assemble_wave` call, and moving it
333
+ * inside is what closes the window between the two: whatever this writes is what the very next line
334
+ * lints and deploys, in the same call, so there is no longer a moment where the assembled tree and
335
+ * the deployed tree can be different things.
336
+ *
337
+ * The tree is a BUILD ARTIFACT — every call rebuilds it — so a fix that must survive belongs in the
338
+ * revision's own authored file under `requirements/<extId>/authored/`, which is the thing plan.json
339
+ * binds and the thing this copies from. `WAVE_EXISTS` is gone with the same reasoning: there is
340
+ * nothing authored in there to protect.
341
+ *
342
+ * Assembly refusals come back as data and are re-emitted by the caller through `fail()` with their
343
+ * own codes, so `OWNERSHIP_CONFLICT`, `COMPONENT_STILL_PROVISIONAL`, `COMPONENT_NOT_OWNED` and
344
+ * `NOTHING_TO_ASSEMBLE` read exactly as they did from the tool that used to own them.
345
+ */
346
+ async function assembleRequestedWave(sessionId, wave, waveComponents, sourceDir, ackIncomplete) {
347
+ if (sourceDir) {
348
+ return {
349
+ ok: false,
350
+ code: "AMBIGUOUS_INPUT",
351
+ message: "`waveComponents` assembles the analyzers' authored files into <session>/authored/<wave>; `sourceDir` names a tree that already exists outside the session. Pass one or the other."
352
+ };
353
+ }
354
+ if (!sessionId || !wave) {
355
+ return {
356
+ ok: false,
357
+ code: "WAVE_REQUIRED",
358
+ message: "`waveComponents` is the component list for a named wave — pass `sessionId` and `wave` with it."
359
+ };
360
+ }
361
+ const result = await assembleWave({ sessionId, wave, components: waveComponents, ...(ackIncomplete ? { ackIncomplete: true } : {}) });
362
+ if (!result.ok)
363
+ return { ok: false, code: result.code, message: result.message };
364
+ return { ok: true, dir: result.dir, report: result.report };
365
+ }
366
+ const normRel = (value) => value.split(/[\\/]/).filter(Boolean).join("/");
367
+ /**
368
+ * WHAT THIS WAVE IS DOING TO EACH OF ITS COMPONENTS, and which file holds the state it supersedes.
369
+ *
370
+ * Assembly answers "which file goes where"; this answers "and what does that file MEAN" — the two
371
+ * facts that live in plan.json and cannot survive `walkSourceTree`, which is lossy by design
372
+ * (`RevisionBinding` says why it stays that way). It reads the same plan entries the assembler
373
+ * resolved ownership from, and derives the same chains `plan_waves` ordered the waves by, so all
374
+ * three agree about a component with more than one state in this batch.
375
+ *
376
+ * FAIL-OPEN, ENTIRELY. Everything downstream of this is additive — intent recorded in the evidence
377
+ * bundle, a message branch on a check that already fires, and a warning-only preview. A batch whose
378
+ * plans cannot be read is a batch `plan_waves` and the assembler have already refused for better
379
+ * reasons and in more useful words; nothing here should be the thing that stops a delivery, so an
380
+ * unreadable plan, an unresolvable chain or a key this wave asks for that no chain covers each cost
381
+ * their own binding and nothing else.
382
+ *
383
+ * A key whose wave entry carries no `path` while the batch holds two states of it resolves to
384
+ * NOTHING rather than to a guess — assembly refuses that same request as `COMPONENT_AUTHORED_TWICE`,
385
+ * and inventing an answer here would only decide which of two files the guard below compares.
386
+ */
387
+ async function resolveWaveRevisions(sessionId, waveComponents) {
388
+ try {
389
+ const root = sessionDir(sessionId);
390
+ const entries = [];
391
+ const unreadable = [];
392
+ for (const requirement of await loadRequirementsIndex(sessionId)) {
393
+ const parsed = await readPlanFile(path.join(root, "requirements", requirement.extId, "plan.json"), requirement.extId);
394
+ if (!parsed.ok) {
395
+ // A plan that has not been written is an ordinary state mid-run; one that cannot be READ is
396
+ // not, and the difference is the only thing worth a note here.
397
+ if (parsed.error.code !== "PLAN_NOT_FOUND")
398
+ unreadable.push(`${requirement.extId} (${parsed.error.code})`);
399
+ continue;
400
+ }
401
+ entries.push(...parsed.plan.entries);
402
+ }
403
+ const note = unreadable.length > 0
404
+ ? `intent could not be read from ${unreadable.length} plan.json (${unreadable.join(", ")}) — the deploy is unaffected, but this wave's evidence bundle records no intent for those components`
405
+ : undefined;
406
+ if (entries.length === 0)
407
+ return { revisions: [], ...(note ? { note } : {}) };
408
+ // The SAME three-valued predicate plan_waves grounds its cross-check on, from local disk only —
409
+ // this session's own project_org runs plus every completed wave's post-image. Two derivations of
410
+ // "does this component exist?" is two answers to a question the batch has to be consistent about.
411
+ //
412
+ // Collected HERE rather than handed in by the caller, even though direct_lint has just built one
413
+ // of its own: a caller may pass `projection` to narrow grounding to a single org state on
414
+ // purpose, and intent inference must not inherit that narrowing — an entry whose intent depends
415
+ // on which projection the reader happened to name is an entry with two answers.
416
+ const view = await collectSessionProjection(root, await sessionTargetOrg(sessionId));
417
+ const chains = new Map(deriveRevisionChains(entries, projectionExistence(view)).chains.map((chain) => [chain.key, chain]));
418
+ const revisions = [];
419
+ for (const want of waveComponents) {
420
+ const chain = chains.get(`${want.typeName}:${want.fullName}`);
421
+ if (!chain)
422
+ continue;
423
+ // A DELETION IS NOT A STATE TO DEPLOY, so it is never one of these bindings. It carries no
424
+ // authored file, which already excludes it from the `path` branch; the single-revision branch
425
+ // has to exclude it explicitly, or a delete-only chain — one revision, no file — would hand
426
+ // this loop an `intent: "delete"` for a component the wave is about to WRITE. Deletions leave
427
+ // through `deletes`, which is a separate argument and a separate decision.
428
+ const states = chain.revisions.filter((r) => r.intent !== "delete");
429
+ const link = want.path
430
+ ? states.find((r) => r.entry.authoredFile && normRel(r.entry.authoredFile) === normRel(want.path))
431
+ : states.length === 1
432
+ ? states[0]
433
+ : undefined;
434
+ if (!link || link.intent === "delete")
435
+ continue;
436
+ const previous = link.predecessor;
437
+ // `resolveAuthored` is the one place an analyzer-written relative path crosses into a real one,
438
+ // and it stays the one place: an `authoredFile` of "../../../../.ssh/authorized_keys" resolves
439
+ // to null here rather than to a file this then opens and reads into a lint finding.
440
+ const previousFile = previous?.authoredFile
441
+ ? resolveAuthored(path.join(root, "requirements", previous.extId), previous.authoredFile)
442
+ : null;
443
+ revisions.push({
444
+ typeName: want.typeName,
445
+ fullName: want.fullName,
446
+ intent: link.intent,
447
+ intentSource: link.intentSource,
448
+ ...(previous?.authoredFile && previousFile
449
+ ? { predecessorFile: previousFile, predecessor: `${previous.extId}/${normRel(previous.authoredFile)}` }
450
+ : {})
451
+ });
452
+ }
453
+ return { revisions, ...(note ? { note } : {}) };
454
+ }
455
+ catch (error) {
456
+ return {
457
+ revisions: [],
458
+ note: `intent could not be derived for this wave (${error instanceof Error ? error.message : String(error)}) — the deploy is unaffected; the evidence bundle records no intent`
459
+ };
460
+ }
461
+ }
462
+ /** Every wave bundle recorded in this session, so a mistyped wave gets a choice, not a dead end. */
463
+ async function availableWaveBundles(sessionId) {
464
+ try {
465
+ const entries = await fs.readdir(path.join(sessionDir(sessionId), "direct"), { withFileTypes: true });
466
+ const out = [];
467
+ for (const entry of entries) {
468
+ if (!entry.isDirectory())
469
+ continue;
470
+ try {
471
+ await fs.access(path.join(sessionDir(sessionId), "direct", entry.name, "verdict.json"));
472
+ out.push(entry.name);
473
+ }
474
+ catch {
475
+ /* a directory that never reached a verdict is not a wave anyone can roll back */
476
+ }
477
+ }
478
+ return out.sort();
479
+ }
480
+ catch {
481
+ return [];
482
+ }
483
+ }
484
+ /**
485
+ * The label the ROLLBACK's own bundle takes — never the target wave's.
486
+ *
487
+ * A rollback is a wave and earns a bundle (R21), and `direct_apply`'s bundle directory IS its
488
+ * identity: reusing the target's label would make the rollback attempt 2 of the wave it is undoing
489
+ * and overwrite that bundle's `pre/`, `post/` and `request.json` — including the `pre/source/` tree
490
+ * the rollback is reading its bytes out of. Prefixed rather than suffixed so the bundles sort
491
+ * together.
492
+ *
493
+ * NEVER TRUNCATED, WHICH IS WHY THIS CAN REFUSE. `revert-` is 7 characters and a wave label may
494
+ * itself be the full 40, so the old `.slice(0, 40)` mapped every pair of waves sharing their first
495
+ * 33 characters onto ONE rollback bundle directory. That is not cosmetic: the bundle directory is
496
+ * the identity `nextWaveAttempt` counts against, so the second wave's rollback files itself as
497
+ * ATTEMPT 2 of the first wave's rollback — and the mirror then carries one rollback's evidence,
498
+ * verdict and diff under another rollback's address, permanently, since those rows are never
499
+ * revisited. No truncation is safe and no derived suffix is one an operator could predict, so a
500
+ * derived label that will not fit is refused by name and the operator passes their own `label`.
501
+ * Returns null for "cannot derive one"; an explicit label is already length-checked by LABEL_RE.
502
+ */
503
+ function rollbackLabel(wave, label) {
504
+ if (label)
505
+ return label;
506
+ const derived = `revert-${wave}`;
507
+ return derived.length <= 40 ? derived : null;
508
+ }
509
+ /**
510
+ * The component keys ONE requirement's plan.json owns, as a set of `typeName:fullName`.
511
+ *
512
+ * THE SCOPE OF `validate_components`, and the reason that tool is safe to hand fifteen analyzers at
513
+ * once. An analyzer is dispatched with one requirement and authors under one requirement's tree, so
514
+ * "my components" is a fact already written down — it does not need to be asserted in the call, and
515
+ * a call that could name any component would let one analyzer spend the org's validation queue on
516
+ * another analyzer's work, or discover a refusal it cannot fix and has no context for.
517
+ *
518
+ * DELETES ARE EXCLUDED. A deletion owns no authored file, so it can never be a member of a source
519
+ * checkOnly; validating one means submitting a destructive package, which is `direct_apply
520
+ * {dryRunOnly, deletes}` and is the supervisor's call to make deliberately.
521
+ *
522
+ * ROUTES ARE EXCLUDED for the same reason assembly excludes them: `deployable: false` means the work
523
+ * leaves by another path and there is no file to submit.
524
+ */
525
+ async function ownedComponentKeys(sessionId, extId) {
526
+ const parsed = await readPlanFile(path.join(sessionDir(sessionId), "requirements", extId, "plan.json"), extId);
527
+ if (!parsed.ok) {
528
+ return {
529
+ ok: false,
530
+ code: parsed.error.code,
531
+ message: parsed.error.code === "PLAN_NOT_FOUND"
532
+ ? `No plan.json for ${extId} yet. validate_components checks the components THAT PLAN owns, so write the plan (or at least its components[]) first — the components you have authored can carry draftNotes and be validated on the next call.`
533
+ : `${extId}'s plan.json could not be read (${parsed.error.code}): ${parsed.error.message}`
534
+ };
535
+ }
536
+ const keys = new Set(parsed.plan.entries.filter((e) => e.deployable && e.intent !== "delete").map((e) => e.key));
537
+ if (keys.size === 0) {
538
+ return {
539
+ ok: false,
540
+ code: "NOTHING_TO_VALIDATE",
541
+ message: `${extId}'s plan.json owns no deployable component. Routes (\`deployable: false\`) and deletions have no ` +
542
+ `authored file to submit, so there is nothing a checkOnly could be run over. A deletion is validated by the ` +
543
+ `supervisor with direct_apply {dryRunOnly, deletes} — a destructive package, not a source deploy.`
544
+ };
545
+ }
546
+ return { ok: true, keys };
547
+ }
548
+ /**
549
+ * Every token the batch's plans OWN — fullNames plus bare field names, the shapes the referential
550
+ * tier matches. What splits `REF_PENDING_DEPENDENCY` (the plan explains this unknown) from
551
+ * `REF_UNKNOWN` (nothing does). Delete entries are excluded: a reference to a component this batch
552
+ * REMOVES is exactly as broken as a reference to one that never existed.
553
+ *
554
+ * FAIL-OPEN to null: with no readable plans the lint simply classifies as it always has. An
555
+ * unreadable plan is refused by plan_waves and assembly in better words; the referential SPLIT must
556
+ * never be what stops a lint.
557
+ */
558
+ async function batchPlannedTokens(sessionId) {
559
+ try {
560
+ const root = sessionDir(sessionId);
561
+ const tokens = new Set();
562
+ for (const requirement of await loadRequirementsIndex(sessionId)) {
563
+ const parsed = await readPlanFile(path.join(root, "requirements", requirement.extId, "plan.json"), requirement.extId);
564
+ if (!parsed.ok)
565
+ continue;
566
+ for (const entry of parsed.plan.entries) {
567
+ if (entry.intent === "delete")
568
+ continue;
569
+ tokens.add(entry.fullName);
570
+ if (entry.typeName === "CustomField") {
571
+ const field = entry.fullName.split(".")[1];
572
+ if (field)
573
+ tokens.add(field);
574
+ }
575
+ }
576
+ }
577
+ return tokens.size > 0 ? tokens : null;
578
+ }
579
+ catch {
580
+ return null;
581
+ }
582
+ }
583
+ /** Inline XML and a wave component list are two different answers to "what is in this wave". */
584
+ const inlineVsWave = () => fail("AMBIGUOUS_INPUT", "Provide either `waveComponents` (the wave's component list — the files the analyzers authored are assembled for you) or inline `components` XML, not both.");
585
+ export function registerDirectTools(server) {
586
+ server.registerTool("direct_lint", {
587
+ description: "Millisecond LOCAL lint of metadata XML edits. Four tiers: SCHEMA (official Metadata API definitions — invalid enums, missing required elements, caught locally instead of a ~45s checkOnly round-trip) + LEMMA rules compiled from the live-caught quirk corpus + REFERENTIAL (every custom API token an edit references must exist in the org, be introduced by this batch's edits, or be PLANNED by the batch — a planned-but-unlanded reference is `REF_PENDING_DEPENDENCY`, an ordering fact; only the residual is `REF_UNKNOWN`, and direct_apply then verifies those against the org DIRECTLY: proven-present is suppressed, proven-absent BLOCKS the real deploy, ackable) + ORG-PRECEDENT (`UNPROVEN_ORG_TOKEN` — elements the API types as a free string but the ORG validates against its own contents; the finding names the values an existing component of that type proves acceptable. Warning, never error). " +
588
+ "GROUNDING IS AUTOMATIC: pass `sessionId` and both org-aware tiers run against everything this session knows about the target org, freshest observation per component; the result prints which tiers ran and against what. No org contact, no gate. Errors are ackable by rule name via `ackLints` on direct_apply (except malformed XML); the authoritative server-side tier (checkOnly) still runs inside direct_apply. " +
589
+ "NORMAL USE IS {sessionId, wave, waveComponents}: the analyzers' authored files are ASSEMBLED into <session>/authored/<wave> first, so there is no separate collection step. " +
590
+ "THIS CALL IS OPTIONAL — direct_apply assembles and lints identically, in the same call as the deploy. Use it to read a wave's findings before spending a checkOnly. " +
591
+ "When the batch changes one component MORE THAN ONCE, `PREVIEW_REPLACE_DROPS_EARLIER` names what the later file omits that the earlier one establishes. Warning only, never blocking, not ackable — it compares two authored files rather than the org, so the org-grounded check (`PREFLIGHT_REPLACE_DROPS_*`) still runs inside direct_apply. " +
592
+ "`sourceDir` takes a tree outside the session and inline `components` is for one-offs.",
593
+ inputSchema: {
594
+ sessionId: z
595
+ .string()
596
+ .optional()
597
+ .describe("The session this wave belongs to. With `wave`, the authored tree is derived — no path to carry."),
598
+ wave: z
599
+ .string()
600
+ .optional()
601
+ .describe("Wave label. With `sessionId` this resolves to <session>/authored/<wave>, which is where direct_apply also looks — author each wave there and neither call needs a path."),
602
+ waveComponents: z
603
+ .array(WAVE_COMPONENT_SCHEMA)
604
+ .nonempty()
605
+ .optional()
606
+ .describe("The components this wave contains, straight from plan_waves' output for that wave. Given these, the wave tree is ASSEMBLED from the files the analyzers authored before linting — so a wave nobody has assembled yet still lints. Omit to lint whatever is already at <session>/authored/<wave>."),
607
+ ackIncompleteWave: z.boolean().optional().describe("Acknowledge linting an INCOMPLETE wave: some requested components have no authored file, and only the assembled subset will lint — direct_lint deploys nothing regardless; the same flag on direct_apply is what consents to deploying it. Only pass this after the operator has agreed to partial delivery — an approved wave is a scope, and delivering part of it is a change to that scope."),
608
+ components: z.array(EDIT_SCHEMA).optional().describe("Inline metadata edits. Omit when using sessionId+wave or sourceDir."),
609
+ sourceDir: z.string().optional().describe("ESCAPE HATCH: an explicit absolute path to a source-format metadata tree (objects/…, flows/…, quickActions/…; a force-app/main/default wrapper is tolerated). Prefer sessionId+wave."),
610
+ projection: z
611
+ .string()
612
+ .optional()
613
+ .describe("OVERRIDE — ground the org-aware tiers against ONE projection instead of everything this session knows. A LABEL resolved inside the session (a `project_org` label, or a direct_apply bundle label meaning that wave's post-image); unknown labels refuse and list what is available. It NARROWS ground truth — leave it off for ordinary linting."),
614
+ projectionDir: z
615
+ .string()
616
+ .optional()
617
+ .describe("ESCAPE HATCH: absolute path to a projection outside this session. Narrows grounding exactly as `projection` does.")
618
+ }
619
+ }, async ({ sessionId, wave, waveComponents, ackIncompleteWave, components, sourceDir, projection, projectionDir }) => {
620
+ try {
621
+ let edits = (components ?? []);
622
+ let skippedNote = "";
623
+ let assemblyNote = "";
624
+ if (waveComponents?.length && components?.length)
625
+ return inlineVsWave();
626
+ // The wave tree is BUILT here when the caller names the wave's components, so lint reads a
627
+ // tree it just produced rather than one it hopes is current.
628
+ let walkDir = sourceDir ?? authoredWaveDir(sessionId, wave);
629
+ if (walkDir && components?.length) {
630
+ return fail("AMBIGUOUS_INPUT", "Provide either a source tree (sessionId+wave or sourceDir) or inline components, not both.");
631
+ }
632
+ if (waveComponents?.length) {
633
+ const assembled = await assembleRequestedWave(sessionId, wave, waveComponents, sourceDir, ackIncompleteWave);
634
+ if (!assembled.ok)
635
+ return fail(assembled.code, assembled.message);
636
+ walkDir = assembled.dir;
637
+ assemblyNote = `\n${assembled.report}`;
638
+ }
639
+ else if (walkDir && !sourceDir && sessionId && wave) {
640
+ const w = await assertAuthoredWaveExists(sessionId, wave, walkDir);
641
+ if (!w.ok)
642
+ return fail(w.code, w.message);
643
+ }
644
+ if (walkDir) {
645
+ const walked = await walkSourceTree(walkDir);
646
+ edits = walked.components;
647
+ if (walked.skipped.length)
648
+ skippedNote = `\nskipped ${walked.skipped.length} file(s): ${walked.skipped.map((s) => `${s.file} (${s.reason})`).join("; ")}`;
649
+ }
650
+ if (edits.length === 0) {
651
+ return fail("NO_WORK", walkDir
652
+ ? `No recognized metadata files under ${walkDir}.`
653
+ : "Provide sessionId+wave (the authored tree is derived), an explicit sourceDir, or inline components.");
654
+ }
655
+ // GROUNDING. The DEFAULT is everything this session knows about the target org, freshest
656
+ // observation per component — not a decision the caller makes and can get wrong. A
657
+ // `projection`/`projectionDir` is an OVERRIDE that narrows to ONE org state on purpose
658
+ // (a compareTo-style check); it is reported as narrowed so a downgraded result is never
659
+ // mistaken for the whole truth.
660
+ let resolvedProjectionDir = projectionDir;
661
+ if (projection && !projectionDir) {
662
+ const r = await resolveProjection(sessionId, projection);
663
+ if (!r.ok)
664
+ return fail(r.code, r.message);
665
+ resolvedProjectionDir = r.dir;
666
+ }
667
+ let known = null;
668
+ let proven = null;
669
+ let tiers = "tiers: schema+lemma (referential + org-precedent SKIPPED — no session and no projection to ground against)";
670
+ if (resolvedProjectionDir) {
671
+ let view;
672
+ try {
673
+ view = projectionView([{ dir: resolvedProjectionDir, manifest: await readProjectionManifest(resolvedProjectionDir) }]);
674
+ }
675
+ catch (error) {
676
+ if (error instanceof ProjectionNotReadableError)
677
+ return fail("PROJECTION_NOT_READABLE", error.message);
678
+ throw error;
679
+ }
680
+ known = viewKnownTokens(view);
681
+ proven = await viewProvenValues(view);
682
+ tiers =
683
+ `tiers: schema+lemma + referential + org-precedent (${known.size} known tokens from ${resolvedProjectionDir} ONLY — ` +
684
+ "an explicit projection narrows grounding on purpose; omit it to ground against every projection this session holds)";
685
+ }
686
+ else if (sessionId) {
687
+ const view = await collectSessionProjection(sessionDir(sessionId), await sessionTargetOrg(sessionId));
688
+ if (view.sources.length === 0) {
689
+ tiers =
690
+ "tiers: schema+lemma (referential + org-precedent SKIPPED — this session holds no projection of its target org yet; run project_org)";
691
+ }
692
+ else {
693
+ known = viewKnownTokens(view);
694
+ proven = await viewProvenValues(view);
695
+ tiers = `tiers: schema+lemma + referential + org-precedent (${known.size} known tokens from ${view.sources.length} session projection(s): ${describeSources(view)})`;
696
+ }
697
+ }
698
+ const result = runLint(edits, known, [], proven, sessionId ? await batchPlannedTokens(sessionId) : null);
699
+ // THE STATIC DROP PREVIEW (origin R5). When this batch changes a component more than once,
700
+ // the earlier state is sitting on disk as an authored file — so the omission a later file
701
+ // makes can be named HERE, before the ~45s checkOnly that would otherwise be the first thing
702
+ // to notice it. Warning-only and it never touches `blocked`: see `previewPredecessorDrops`.
703
+ if (waveComponents?.length && sessionId) {
704
+ const { revisions, note } = await resolveWaveRevisions(sessionId, waveComponents);
705
+ result.findings.push(...previewPredecessorDrops(edits, await loadPredecessorPreImages(revisions)));
706
+ if (note)
707
+ assemblyNote = `${assemblyNote}\n${note}`;
708
+ }
709
+ const summary = result.findings.length
710
+ ? result.findings.map((f) => `${f.severity.toUpperCase()} ${f.rule} [${f.component}]: ${f.message}`).join("\n")
711
+ : "clean — no findings";
712
+ return ok(`${result.blocked ? "BLOCKED" : "ok"} — ${result.findings.length} finding(s) over ${edits.length} component(s)\n${tiers}\n${summary}${skippedNote}${assemblyNote}`, {
713
+ findings: result.findings,
714
+ blocked: result.blocked,
715
+ componentsLinted: edits.length,
716
+ referentialTier: Boolean(known),
717
+ grounding: resolvedProjectionDir ? "projection" : known ? "session" : "none"
718
+ });
719
+ }
720
+ catch (error) {
721
+ return internalError(error);
722
+ }
723
+ });
724
+ server.registerTool("validate_components", {
725
+ title: "Ask the org whether it would accept the components you authored",
726
+ description: "SUBMIT YOUR OWN AUTHORED COMPONENTS TO SALESFORCE AS A checkOnly DEPLOY AND REPORT WHAT IT SAID. Mutates NOTHING — a checkOnly writes nothing and needs no write gate. " +
727
+ "FOR ANALYZERS, after you author and after direct_lint: `validate_components {sessionId, extId}`. It reads the components YOUR plan.json owns, assembles the files you bound to them, and asks the org. " +
728
+ "WHY THIS IS NOT direct_lint: the lint ladder is local and catches what a schema or a live-caught lemma can express. This is the ORG's own answer — the tier that returns `length must be specified`, `Cannot delete last filter`, `Invalid field name`. Historically that answer arrived at deploy time, serially, one wave at a time, where a refusal takes every other component in its wave down with it. Here it arrives while you still hold the context that produced the file. " +
729
+ "RECORD EVERY REFUSAL AS A BLOCKER WITH THE ORG'S MESSAGE VERBATIM. Salesforce's sentence is the evidence; your paraphrase of it is not. A refusal you can fix yourself (a missing required attribute, a typo'd reference) is a fix, not a blocker — the same rule direct_lint findings follow. " +
730
+ "SCOPED TO YOUR PLAN: only the components your requirement's plan.json owns and that are deployable. Routes and deletions are excluded — a deletion has no authored file, and validating one means a destructive package, which is the supervisor's deliberate call. " +
731
+ "CONCURRENCY, MEASURED (26 concurrent validation deploys on a live org, 2026-08-05): the org QUEUES these — it does not run them in parallel. Expect roughly one completion every 4-5 seconds across all callers, so with 8 analyzers validating at once a single call can take 30s while serially it would take 5s. That is the queue, not a hang; do not retry it. Wall-clock across the batch still improves ~1.4-1.6x, and that is not the main win — moving refusal discovery out of the serial deploy phase is. NO LOCK CONTENTION: six simultaneous validations of the SAME component on the SAME object all returned clean, so overlapping component sets are not a hazard here (a real deploy is a different question). Each caller gets its own deploy id and its own failures — attribution is never mixed. " +
732
+ "NOT A DEPLOY AND NOT A VERDICT: no evidence bundle, no pre/post projection, no diff, no org history entry. A clean result means the org would ACCEPT this metadata, never that it behaves correctly. " +
733
+ "A TIMEOUT IS NOT A PASS AND NOT A REFUSAL — the org never answered; it is reported as its own state.",
734
+ inputSchema: {
735
+ sessionId: z.string().describe("Your session, from your Task prompt. The target org is derived from it, exactly as for every other org-touching tool."),
736
+ extId: z
737
+ .string()
738
+ .describe("YOUR requirement id, e.g. \"REQ-15462\". The components validated are the ones this requirement's plan.json owns — you validate your own work, not the batch's."),
739
+ components: z
740
+ .array(WAVE_COMPONENT_SCHEMA)
741
+ .nonempty()
742
+ .optional()
743
+ .describe("Narrow to some of your own components (each must be one your plan.json owns; anything else is refused by name). Omit to validate everything deployable in your plan."),
744
+ apiVersion: z.string().optional()
745
+ }
746
+ }, async ({ sessionId, extId, components, apiVersion }) => {
747
+ const startedAt = new Date().toISOString();
748
+ try {
749
+ const owned = await ownedComponentKeys(sessionId, extId);
750
+ if (!owned.ok)
751
+ return fail(owned.code, owned.message);
752
+ // AN ANALYZER MAY ONLY NAME ITS OWN. Refused by name rather than filtered silently: a
753
+ // narrowing that quietly dropped the component the caller cared most about would report a
754
+ // clean validation of something else entirely.
755
+ const foreign = (components ?? []).filter((c) => !owned.keys.has(`${c.typeName}:${c.fullName}`));
756
+ if (foreign.length > 0) {
757
+ return fail("COMPONENT_NOT_OWNED", [
758
+ `${foreign.length} component(s) are not owned by ${extId}'s plan.json, so they are not yours to validate:`,
759
+ ...foreign.map((c) => ` ${c.typeName}:${c.fullName}`),
760
+ "",
761
+ `${extId} owns: ${[...owned.keys].sort().join(", ")}`,
762
+ "",
763
+ "If one of these IS yours, add it to your plan.json components[] with its authoredFile. If it belongs",
764
+ "to another requirement, that analyzer validates it — a dependsOn is something that must already exist,",
765
+ "not something you check."
766
+ ].join("\n"));
767
+ }
768
+ // Assembled through the SAME assembler direct_apply uses, into a scratch wave label of this
769
+ // requirement's own. That is what makes a clean answer here mean something later: the bytes
770
+ // submitted are the bytes the wave will submit, resolved by the same ownership rules, with
771
+ // the same provisional/orphan refusals firing at the same moment.
772
+ const wanted = (components ?? [...owned.keys].map((key) => {
773
+ const at = key.indexOf(":");
774
+ return { typeName: key.slice(0, at), fullName: key.slice(at + 1) };
775
+ }));
776
+ const waveLabel = `validate-${extId}`.slice(0, 40).replace(/[^A-Za-z0-9_-]/g, "-");
777
+ // ackIncomplete: this is not a delivery and there is no approved scope to under-deliver
778
+ // against. An analyzer mid-authoring legitimately has some components written and some not,
779
+ // and refusing to check the written ones until all of them exist would make the tool useless
780
+ // exactly when it is most useful. What was NOT assembled is named in the report below.
781
+ const assembled = await assembleRequestedWave(sessionId, waveLabel, wanted, undefined, true);
782
+ if (!assembled.ok)
783
+ return fail(assembled.code, assembled.message);
784
+ const walked = await walkSourceTree(assembled.dir);
785
+ if (walked.components.length === 0) {
786
+ return fail("NO_WORK", `None of ${extId}'s ${wanted.length} owned component(s) have an authored file yet, so there is nothing to submit. Author them under requirements/${extId}/authored/ and record each as its entry's authoredFile.`);
787
+ }
788
+ const session = await loadSession(sessionId);
789
+ const binding = await resolveSessionOrg(session, "target");
790
+ // NO assertDirectWriteAllowed. Deliberate, and the one line of this handler most worth
791
+ // reading twice: a checkOnly mutates nothing, so there is no write to authorize. The gate
792
+ // exists to guard the org's STATE, and this call cannot change it (measured — see
793
+ // .audit/parallel-validation.md). Gating it anyway would deny the analyzers the check on
794
+ // every install whose write authority arrives later, which is every install before approval.
795
+ const version = apiVersion?.trim() || DEFAULT_API_VERSION;
796
+ const result = await validateComponents({
797
+ edits: walked.components,
798
+ targetOrg: binding.alias,
799
+ apiVersion: version
800
+ });
801
+ // SHAPE ONLY, no component names and no org messages. A telemetry event travels off this
802
+ // machine; a refusal's text quotes the org about a client's metadata, and a component key
803
+ // names their schema. What the ledger needs to answer — is this check being used, does it
804
+ // catch anything, and is the queue as slow as the description claims — is answerable from
805
+ // counts and durations alone.
806
+ emitDomainTelemetryEvent("analysis.components_validated", {
807
+ extId,
808
+ componentCount: result.componentCount,
809
+ accepted: result.accepted,
810
+ failureCount: result.failures.length,
811
+ timedOut: result.timedOut,
812
+ durationMs: result.durationMs,
813
+ startedAt,
814
+ completedAt: new Date().toISOString()
815
+ }, {
816
+ stage: "analysis",
817
+ outcome: result.timedOut ? "unanswered" : result.accepted ? "confirmed" : "refused"
818
+ });
819
+ // READ PROVENANCE (R9). A checkOnly is a real round-trip to the org about real components,
820
+ // and a read-set that omitted it would understate what this session put to the org.
821
+ await appendReadRecord(sessionId, {
822
+ tool: "validate_components",
823
+ orgId: binding.orgId ?? null,
824
+ orgAlias: binding.alias,
825
+ scope: `validate ${extId}`,
826
+ pattern: "body",
827
+ keys: walked.components.map((c) => `${c.typeName}:${c.fullName}`),
828
+ manifests: [],
829
+ rows: walked.components.length,
830
+ durationMs: result.durationMs,
831
+ // A REFUSAL IS A SUCCESSFUL READ. The org answered — that answer is the whole product of
832
+ // this call. Only a timeout is a failed read, because only then did nothing come back.
833
+ outcome: result.timedOut ? "failed" : "ok"
834
+ });
835
+ const headline = result.timedOut
836
+ ? `NO ANSWER — the org did not respond within the timeout. This is neither a pass nor a refusal: nothing was validated, and nothing was deployed either. Re-run it; if it times out again, say so in your plan rather than treating silence as approval.`
837
+ : result.accepted
838
+ ? `ACCEPTED — the org would deploy all ${result.componentCount} component(s) as authored. This proves the metadata is DEPLOYABLE, never that it behaves correctly; runtime behavior is still unproven.`
839
+ : `REFUSED — the org will not accept ${result.failures.length} of ${result.componentCount} component(s) as authored.`;
840
+ const lines = [
841
+ headline,
842
+ `org status: ${result.status} (${(result.durationMs / 1000).toFixed(1)}s — the org queues concurrent validations, so a slow call is usually the queue)`,
843
+ ...(result.failures.length > 0
844
+ ? [
845
+ "",
846
+ "SALESFORCE'S OWN WORDS, verbatim — quote these, do not paraphrase them:",
847
+ ...result.failures.map((f) => ` ${f.component}: ${f.problem}${f.problemType ? ` [${f.problemType}]` : ""}${f.line ? ` (line ${f.line}${f.column ? `, col ${f.column}` : ""})` : ""}`),
848
+ "",
849
+ "Fix what is yours to fix and re-run — a missing required attribute or a mistyped reference is a typo,",
850
+ "not a decision. A refusal that means the requested DESIGN is not expressible is a BLOCKER: record it",
851
+ "in your plan.json with the org's message above quoted verbatim, and a recommendedAnswer for the",
852
+ "alternative. The supervisor consolidates it into the operator's one clarification card."
853
+ ]
854
+ : []),
855
+ ...(assembled.report.includes("NO authored file") ? ["", assembled.report] : [])
856
+ ];
857
+ return ok(lines.join("\n"), {
858
+ extId,
859
+ accepted: result.accepted,
860
+ timedOut: result.timedOut,
861
+ status: result.status,
862
+ componentCount: result.componentCount,
863
+ failures: result.failures,
864
+ durationMs: result.durationMs
865
+ });
866
+ }
867
+ catch (error) {
868
+ if (error instanceof SessionOrgError)
869
+ return fail(error.code, error.message);
870
+ if (error instanceof WorkspaceError)
871
+ return fail(error.code, error.message);
872
+ return internalError(error);
873
+ }
874
+ });
875
+ server.registerTool("direct_apply", {
876
+ description: "GATED (CC_MCP_DIRECT_WRITE=1 + allowlist, default T-Pinto; the consultant org is HARD-refused). " +
877
+ "NORMAL USE IS {sessionId, wave, waveComponents}: pass plan_waves' component list for the wave and this ASSEMBLES, lints and deploys it in ONE call, so the tree cannot be edited between the two. " +
878
+ "THE WAVE TREE IS A BUILD ARTIFACT rebuilt on every call: an edit made inside <session>/authored/<wave>/ is silently OVERWRITTEN rather than deployed — a wave-specific fix belongs in the revision's own file under requirements/<extId>/authored/. Nothing checks this; it is the one footgun with no refusal behind it. " +
879
+ "Assembly refuses rather than guessing, and each refusal names the decision and its fix: `OWNERSHIP_CONFLICT`, `COMPONENT_AUTHORED_TWICE`, `COMPONENT_STILL_PROVISIONAL`, `COMPONENT_NOT_OWNED`. Two softer cases never refuse: components nobody authored come back in `missing`, and a file plan.json forgot is used and reported under `recoveredFromDisk`. " +
880
+ "Author a wave BY HAND and pass {sessionId, wave} alone to deploy it as-is. Inline `components` is for ONE-OFF corrections and refuses past a handful (`INLINE_BATCH_REFUSED`). " +
881
+ "THE LOOP, in one call: pre-projection → lint (blockable, ackable via `ackLints`; includes the pre-flight full-file-replace diff, and grounds exactly as direct_lint does) → checkOnly → real deploy → optional destructive deletes → post-projection → three-way diff (intended vs actual vs everything-else-that-moved) → per-component intent verification → verdict. Every stage's raw output persists in an evidence bundle. " +
882
+ "VERDICTS: LINT_BLOCKED | REJECTED_BY_ORG | VALIDATED (`dryRunOnly`) | DIFF_VERIFIED | DELIVERED_WITH_DRIFT | AMBIGUOUS_DELIVERY (an UNRECOVERABLE deploy timeout OR a post-deploy read-back failure — the mutation may have LANDED; re-project to confirm, never blind-re-apply). A timed-out deploy is now recovered where possible: the Salesforce job outlives our watcher, so it is reported on by job id and cancelled if still running, and a recovered outcome is the org's real answer rather than an ambiguity. " +
883
+ "LOSS CHECKS, all self-describing when they fire: `removals` (what an edited component LOST, pre vs post) and its pre-deploy form `PREFLIGHT_REPLACE_DROPS_UNSTATED`/`_STATED`; `PREFLIGHT_PREIMAGE_FACET_INCOMPLETE` when the pre-image cannot support the question; `PREVIEW_REPLACE_DROPS_EARLIER` at lint time when the batch changes one component twice. Any removal blocks DIFF_VERIFIED. " +
884
+ "`unstatedChanges` (`unstated-changes.json`) reports facets your file never states that CHANGED VALUE — org normalization and reorders subtracted, so every entry is a real change nobody asked for, and any entry blocks DIFF_VERIFIED. " +
885
+ "Accepts ANY single-file-XML metadata type (resolved from Salesforce's SDR registry — no hand-maintained allowlist) AND content-file types (EmailTemplate/StaticResource/Document — body via `content`, base64 for binary). " +
886
+ "DIFF_VERIFIED is NOT gate certification; it is mechanical read-back proof of the edited slice (metadata shape; runtime behavior is never proven). " +
887
+ "LONG-RUNNING: checkOnly + deploy + two projections take typically 30-60s; up to 3 minutes on a large or brownfield wave — call with a generous timeout (measured over 121 applies: 25% exceed 47s, 10% exceed 88s, and a 60s client default kills real runs).",
888
+ inputSchema: {
889
+ sessionId: z
890
+ .string()
891
+ .describe("The session this work belongs to. The target org comes FROM the session — bound and verified by activate_session (or create_session) — so there is no targetOrg to pass and no alias to get wrong. Refuses if the session's alias has since been repointed at a different org."),
892
+ wave: z
893
+ .string()
894
+ .optional()
895
+ .describe("Wave label — resolves to <session>/authored/<wave>, the SAME tree direct_lint reads. Author the wave there and lint/apply cannot disagree about what is being deployed. Also used as the evidence-bundle label when `label` is omitted."),
896
+ waveComponents: z
897
+ .array(WAVE_COMPONENT_SCHEMA)
898
+ .nonempty()
899
+ .optional()
900
+ .describe("The components this wave contains, straight from plan_waves' output for that wave. Given these, the wave tree is REBUILT from the files the analyzers authored before anything is linted or deployed — so the thing this call validates is the thing it just assembled. Omit only when you authored the wave tree by hand."),
901
+ ackIncompleteWave: z.boolean().optional().describe("Acknowledge deploying an INCOMPLETE wave: some requested components have no authored file, and only the assembled subset will lint and deploy. Only pass this after the operator has agreed to partial delivery — an approved wave is a scope, and delivering part of it is a change to that scope."),
902
+ components: z
903
+ .array(EDIT_SCHEMA)
904
+ .optional()
905
+ .describe(`ESCAPE HATCH for one-off corrections: creates/updates as inline metadata XML. Refuses past ${INLINE_MAX} components unless ackInline is set — more than that is a batch, and a batch belongs in a wave. Omit when using wave or sourceDir.`),
906
+ ackInline: z
907
+ .boolean()
908
+ .optional()
909
+ .describe("Acknowledge deploying a batch inline anyway. Recorded in the evidence bundle. Prefer authoring files and passing `wave`."),
910
+ sourceDir: z
911
+ .string()
912
+ .optional()
913
+ .describe("ESCAPE HATCH: absolute path to a source-format metadata tree outside the session (a force-app/main/default wrapper is tolerated). Prefer `wave`. Mutually exclusive with `components`; can accompany deletes/objectFacetUpdates."),
914
+ deletes: z
915
+ .array(z.object({ typeName: z.string(), fullName: z.string() }))
916
+ .optional()
917
+ .describe("Destructive deletes (mdapi destructiveChanges deploy)."),
918
+ objectFacetUpdates: z
919
+ .array(z.object({ objectApiName: z.string(), facets: z.record(z.string(), z.unknown()) }))
920
+ .optional()
921
+ .describe("FALLBACK for parent-object TOP-LEVEL facet changes (enableActivities, compactLayoutAssignment, ...) via CRUD read-modify-write, which REPLACES the object's top-level properties — anything the read did not return resets. PREFER a full CustomObject source deploy (CustomObject merges; it rides the normal lint/checkOnly/diff path). ORDERING: in a combined call the metadata deploy runs BEFORE these. Child collections refuse."),
922
+ ackLints: z.array(z.string()).optional().describe("Lint rule names to override (explicit ack — recorded in evidence)."),
923
+ requirementIds: z
924
+ .array(z.string())
925
+ .optional()
926
+ .describe("The requirement(s) this wave delivers, e.g. [\"REQ-15462\"]. Recorded in the evidence bundle and carried into the org's history, which is the only place the question 'why does this field exist?' can be answered months later — the bundle proves WHAT changed and never what it was for. Identifiers only; they end up in a commit trailer."),
927
+ dryRunOnly: z
928
+ .boolean()
929
+ .optional()
930
+ .describe("Stop after checkOnly; write nothing. Covers file components AND deletes (the destructive package goes as a destructive checkOnly, so each delete reports its own verdict). objectFacetUpdates have no validation step — they report NOT VALIDATED, and a wave of only those refuses (`DRY_RUN_NOTHING_TO_VALIDATE`)."),
931
+ wideObjectScope: z
932
+ .boolean()
933
+ .optional()
934
+ .describe("Project the WHOLE parent object before and after, instead of only the components this wave touches. Off by default — it is slower and can trip the projection's component cap. Turn it on for a deliberate 'show me everything on this object, before and after'."),
935
+ label: z.string().optional().describe("Evidence-bundle label [A-Za-z0-9_-]{1,40}; default timestamp."),
936
+ apiVersion: z.string().optional()
937
+ }
938
+ }, async ({ sessionId, wave, waveComponents, ackIncompleteWave, components, sourceDir, deletes, objectFacetUpdates, ackLints, ackInline, requirementIds, dryRunOnly, wideObjectScope, label, apiVersion }) => {
939
+ try {
940
+ if (waveComponents?.length && components?.length)
941
+ return inlineVsWave();
942
+ if (components && components.length > INLINE_MAX && !ackInline) {
943
+ return fail("INLINE_BATCH_REFUSED", [
944
+ `${components.length} components inline. That is a batch, and a batch belongs in a wave.`,
945
+ "",
946
+ "Inline XML lives only in this tool call: there is no file to lint on its own, diff, hand back",
947
+ "for review, edit without re-emitting the whole component, or commit. It also means one agent",
948
+ "writes serially what the analyzers already wrote in parallel.",
949
+ "",
950
+ ` direct_apply {sessionId, wave, waveComponents} — pass this wave's component list from`,
951
+ ` plan_waves and the analyzers' authored files are assembled, linted and deployed in this`,
952
+ ` one call. Components no analyzer authored come back NAMED: write those as source-format`,
953
+ ` files under requirements/<extId>/authored/, record each as its entry's authoredFile, and`,
954
+ ` call again.`,
955
+ "",
956
+ `Genuinely a one-off? Send ${INLINE_MAX} or fewer. Genuinely need this batch inline? Pass ackInline: true —`,
957
+ "it is recorded in the evidence bundle."
958
+ ].join("\n"));
959
+ }
960
+ let edits = (components ?? []);
961
+ const dels = (deletes ?? []);
962
+ const facets = (objectFacetUpdates ?? []);
963
+ const notes = [];
964
+ if (wave && !LABEL_RE.test(wave))
965
+ return fail("INVALID_LABEL", `wave must match ${LABEL_RE}.`);
966
+ let walkDir = sourceDir ?? authoredWaveDir(sessionId, wave);
967
+ let revisions = [];
968
+ if (walkDir && components?.length) {
969
+ return fail("AMBIGUOUS_INPUT", "Provide either a source tree (wave or sourceDir) or inline components, not both.");
970
+ }
971
+ if (waveComponents?.length) {
972
+ // Assembly runs INSIDE the apply, so the tree this walks is the tree it just wrote — the
973
+ // lint, the checkOnly and the deploy all see the same files, with no window between them
974
+ // in which the tree could be edited or go stale.
975
+ const assembled = await assembleRequestedWave(sessionId, wave, waveComponents, sourceDir, ackIncompleteWave);
976
+ if (!assembled.ok)
977
+ return fail(assembled.code, assembled.message);
978
+ walkDir = assembled.dir;
979
+ notes.push(assembled.report);
980
+ // Intent rides ALONGSIDE the tree, because the tree cannot carry it. Derived from the same
981
+ // plan entries assembly just read, so the bundle's record of what this wave meant to do
982
+ // agrees with the files it deployed.
983
+ const resolved = await resolveWaveRevisions(sessionId, waveComponents);
984
+ revisions = resolved.revisions;
985
+ if (resolved.note)
986
+ notes.push(resolved.note);
987
+ // PROVENANCE, AS A WARNING AND NOTHING MORE. plan_waves is advisory and hand-ordering is
988
+ // legitimate — but nothing used to NOTICE when a submitted wave corresponded to no derived
989
+ // one, so a mistake and a judgement call looked identical. Now the divergence is named
990
+ // where it costs money: every extra wave is two org round-trips plus two projections.
991
+ //
992
+ // Silent when the session holds no derivation (plan_waves never ran, or ran before this
993
+ // shipped) — an absent plan is not evidence of a bad wave, and a warning about nothing is
994
+ // the fastest way to train a reader past the block that matters.
995
+ const derivation = await readWaveDerivation(sessionId);
996
+ if (derivation) {
997
+ const divergence = renderDivergence(compareToDerivation(waveComponents, derivation), derivation);
998
+ if (divergence)
999
+ notes.push(divergence);
1000
+ }
1001
+ }
1002
+ else if (walkDir && !sourceDir && sessionId && wave) {
1003
+ const w = await assertAuthoredWaveExists(sessionId, wave, walkDir);
1004
+ if (!w.ok)
1005
+ return fail(w.code, w.message);
1006
+ }
1007
+ if (walkDir) {
1008
+ const walked = await walkSourceTree(walkDir);
1009
+ edits = walked.components;
1010
+ if (walked.skipped.length) {
1011
+ notes.push(`skipped ${walked.skipped.length} unrecognized file(s): ${walked.skipped.map((s) => `${s.file} (${s.reason})`).join("; ")}`);
1012
+ }
1013
+ notes.push(`authored from ${edits.length} component(s) walked from ${walkDir}`);
1014
+ }
1015
+ if (edits.length === 0 && dels.length === 0 && facets.length === 0) {
1016
+ return fail("NO_WORK", walkDir
1017
+ ? `No recognized metadata files under ${walkDir}, and no deletes/facets.`
1018
+ : "Provide wave (the authored tree is derived), sourceDir, components, deletes, and/or objectFacetUpdates.");
1019
+ }
1020
+ // A practice run that can run no practice — now exactly ONE path rather than two.
1021
+ //
1022
+ // Deletes are checkable: a destructive mdapi package takes `--dry-run` like any other deploy
1023
+ // (checkOnly is a deploy-API option, not a property of the source format), and the org answers
1024
+ // per member — validated, blocked by a live reference, or not found. A delete-only dry run is
1025
+ // therefore a real practice run and no longer refuses.
1026
+ //
1027
+ // objectFacetUpdates have no such mode: a CRUD read-modify-write is applied or it is not.
1028
+ // A facet-only wave would return VALIDATED having checked nothing — and VALIDATED is the
1029
+ // verdict this tool maps to a `confirmed` telemetry outcome and the word an operator quotes,
1030
+ // so it would both report and RECORD a success nobody established.
1031
+ //
1032
+ // (The apply loop is independently safe: under dryRunOnly it performs no write at all and
1033
+ // labels the facet path NOT VALIDATED. This refusal is about not handing back a green light.)
1034
+ if (dryRunOnly && edits.length === 0 && dels.length === 0 && facets.length > 0) {
1035
+ return fail("DRY_RUN_NOTHING_TO_VALIDATE", [
1036
+ `dryRunOnly cannot check this wave: it carries only objectFacetUpdates, and that is the one path`,
1037
+ `Salesforce exposes no validation step for — a CRUD read-modify-write is applied or it is not.`,
1038
+ "",
1039
+ ` file components are submitted as a checkOnly deploy`,
1040
+ ` deletes are submitted as a destructive checkOnly (--metadata-dir --dry-run)`,
1041
+ ` objectFacetUpdates have no validation step at all`,
1042
+ "",
1043
+ `Reporting VALIDATED here would mean "checked and accepted" over work that was submitted nowhere.`,
1044
+ "",
1045
+ `Either drop dryRunOnly and apply for real — the post-deploy read-back is what verifies this path,`,
1046
+ `and it is stronger than a checkOnly — or add the file components or deletes you wanted validated,`,
1047
+ `in which case those are checked and the facets are reported NOT VALIDATED beside them.`
1048
+ ].join("\n"));
1049
+ }
1050
+ if (label && !LABEL_RE.test(label))
1051
+ return fail("INVALID_LABEL", `label must match ${LABEL_RE}.`);
1052
+ // The org is derived, never supplied. resolveSessionOrg re-checks that the session's alias
1053
+ // still points at the org the session was bound to, so a repointed alias is refused BY NAME
1054
+ // rather than surfacing later as a confusing allowlist miss.
1055
+ const session = await loadSession(sessionId);
1056
+ const binding = await resolveSessionOrg(session, "target");
1057
+ const targetOrg = binding.alias;
1058
+ await assertDirectWriteAllowed(targetOrg);
1059
+ const version = apiVersion?.trim() || DEFAULT_API_VERSION;
1060
+ // Evidence lives UNDER THE SESSION, not in a per-org pile. A bundle is the proof of what
1061
+ // THIS session's wave did, so the session is its only correct home. Filing by org alias
1062
+ // instead meant two sessions against the same org shared a directory and could collide on
1063
+ // label, deleting a session left its evidence orphaned, and — the tell — the CI planner had
1064
+ // to be handed absolute bundle paths because nothing could find them from the session.
1065
+ const bundleDir = path.join(sessionDir(sessionId), "direct",
1066
+ // `wave` defaults the bundle label so the NEXT wave can lint against this one by name:
1067
+ // direct_lint's `projection` resolves a bundle label to its post-image, and that only
1068
+ // works if the bundle is named after the wave rather than an opaque timestamp.
1069
+ label ?? wave ?? new Date().toISOString().replace(/[:.]/g, "-"));
1070
+ const reader = await createLiveOrgReader(targetOrg, version);
1071
+ const attempt = await nextWaveAttempt(bundleDir);
1072
+ if (attempt > 1)
1073
+ await archivePriorAttempt(bundleDir, attempt - 1);
1074
+ const startedAt = new Date().toISOString();
1075
+ const result = await runDirectApply({
1076
+ reader,
1077
+ targetOrg,
1078
+ apiVersion: version,
1079
+ edits,
1080
+ deletes: dels,
1081
+ facetUpdates: facets,
1082
+ ...(revisions.length ? { revisions } : {}),
1083
+ ...(await batchPlannedTokens(sessionId).then((t) => (t ? { plannedTokens: t } : {}))),
1084
+ acks: ackLints ?? [],
1085
+ ...(ackInline ? { ackInline: true } : {}),
1086
+ // The who/why the evidence bundles have never carried (origin R3). All three come from
1087
+ // state that already exists — the session's batch, the caller's requirement ids, and the
1088
+ // authenticated username the session was bound with — so nothing new is asked of the
1089
+ // supervisor and nothing is invented when they are absent.
1090
+ ...(session.batchId ? { batchId: session.batchId } : {}),
1091
+ ...(requirementIds?.length ? { requirementIds } : {}),
1092
+ ...(OPERATOR_IDENTITY ?? session.actors?.targetUsername
1093
+ ? { operator: OPERATOR_IDENTITY ?? session.actors?.targetUsername }
1094
+ : {}),
1095
+ dryRunOnly: dryRunOnly ?? false,
1096
+ ...(wideObjectScope ? { wideObjectScope: true } : {}),
1097
+ bundleDir
1098
+ });
1099
+ // Reported from the result the apply loop already computed — no extra read, no extra
1100
+ // round-trip, and nothing here can alter the verdict the evidence bundle records.
1101
+ //
1102
+ // COMPONENTS, not facets. `item.matched` is the number of matched FACETS on one component
1103
+ // — a field with nine compared facets contributes nine — while `intendedCount` below counts
1104
+ // components. Summing the former and dividing by the latter is a facets-per-component
1105
+ // ratio wearing an automation-rate label: the ops dashboard read "1800.0% — 432 of 24
1106
+ // components verified" off six clean waves (live-caught 2026-07-31).
1107
+ //
1108
+ // A component counts as matched when it reached its intended end state and nothing
1109
+ // mismatched — the same rule DIFF_VERIFIED uses, so the panel and the verdict agree.
1110
+ //
1111
+ // `item.found` was the first attempt at "reached its intended end state" and is wrong for
1112
+ // DELETES, where success IS absence: applyLoop records a removed component as
1113
+ // `matched: 1, mismatches: [], found: false`. Filtering on `found` therefore scored a wave
1114
+ // of three cleanly deleted components as "0.0% — 0 of 3 verified" beside a green
1115
+ // DIFF_VERIFIED, having just replaced a bug that scored six clean waves at 1800%.
1116
+ //
1117
+ // `matched > 0` covers both directions: compareIntent returns `matched: 0` with an
1118
+ // "(absent)" mismatch when an EDIT is missing, so a component that failed to land still
1119
+ // cannot count. The rule lives in direct/waveCounts.ts so its test pins THIS code rather
1120
+ // than a copy of it — the previous test asserted a local reimplementation and stayed green
1121
+ // while this line was wrong.
1122
+ // READ PROVENANCE (R9-R10, R12). A wave is a WRITE, but it reads too — a pre-projection
1123
+ // before it deploys and a post-projection after — and both are full component snapshots, so
1124
+ // the pattern is a body read. Omitting them would leave the read-set claiming a session
1125
+ // never looked at the very components it delivered.
1126
+ //
1127
+ // Pointers, not lists, for the same reason project_org uses them: the bundle's own
1128
+ // manifests already name every component. Only the ones that exist are pointed at — a
1129
+ // LINT_BLOCKED or REJECTED_BY_ORG wave never reaches its post-projection, and a pointer to
1130
+ // a file that was never written is a broken promise a later report has to handle.
1131
+ const manifests = [];
1132
+ for (const phase of ["pre", "post"]) {
1133
+ const file = path.join(bundleDir, phase, "projection.json");
1134
+ try {
1135
+ await fs.access(file);
1136
+ manifests.push(file);
1137
+ }
1138
+ catch {
1139
+ // Not written on this path. Absence is the honest record.
1140
+ }
1141
+ }
1142
+ await appendReadRecord(sessionId, {
1143
+ tool: "direct_apply",
1144
+ orgId: binding.orgId ?? null,
1145
+ orgAlias: targetOrg,
1146
+ scope: `wave ${path.basename(bundleDir)}`,
1147
+ pattern: "body",
1148
+ keys: [],
1149
+ manifests,
1150
+ rows: edits.length + dels.length + facets.length,
1151
+ durationMs: Date.now() - Date.parse(startedAt),
1152
+ outcome: "ok"
1153
+ });
1154
+ const matchedCount = matchedComponentCount(result.intent ?? []);
1155
+ const mismatchCount = (result.intent ?? []).reduce((sum, item) => sum + item.mismatches.length, 0);
1156
+ emitDomainTelemetryEvent("deployment.wave_completed", {
1157
+ waveLabel: path.basename(bundleDir),
1158
+ attempt,
1159
+ // Deduped: one wave naturally names the same requirement twice (two components, one
1160
+ // requirement), and the ingest validator REJECTS duplicates — which would silently
1161
+ // drop this event and mark the whole batch's requirement tracking incomplete.
1162
+ requirementIds: [...new Set(requirementIds ?? [])],
1163
+ dryRunOnly: dryRunOnly === true,
1164
+ // The read-back backoff, measured rather than assumed — and the measurement came back
1165
+ // ZERO on 28 of 28 waves, so the backoff is gone and this is now always 0. Still sent,
1166
+ // because a field that simply stops arriving is indistinguishable in the ledger from an
1167
+ // install too old to send it. `readbackSettled` is no longer sent at all: it answered
1168
+ // "did waiting help", and nothing waits. The ingest validator treats it as optional.
1169
+ readbackWaitMs: result.readbackWaitMs ?? 0,
1170
+ checkOnlyStatus: result.dryRun?.status,
1171
+ deployId: result.deploy?.id,
1172
+ deployStatus: result.deploy?.status,
1173
+ deployTimedOut: result.deploy?.timedOut ?? false,
1174
+ readbackVerdict: result.verdict,
1175
+ intendedCount: edits.length + dels.length + facets.length,
1176
+ matchedCount,
1177
+ mismatchCount,
1178
+ sideChangeCount: result.sideChanges?.length ?? 0,
1179
+ removalCount: result.removals?.length ?? 0,
1180
+ // WHERE THIS WAVE'S ORG TIME WENT, measured rather than argued (core/orgCallMeter.ts).
1181
+ //
1182
+ // A Windows run was observed taking up to ~8 minutes per wave against a Mac's ~34s
1183
+ // median, and the ledger could not apportion it. Counts and milliseconds, split by
1184
+ // KIND — `sf` CLI children (three per wave) against jsforce round-trips (tens, and
1185
+ // where a projection's time actually goes) — plus the first call of the wave on its
1186
+ // own, so a startup cost can be told apart from per-call latency. Those have opposite
1187
+ // fixes, and one number cannot distinguish them.
1188
+ //
1189
+ // Read HERE rather than carried on the result: the apply loop has five early returns
1190
+ // and threading a measurement through all of them for one telemetry field would put an
1191
+ // observability concern in the deploy path's control flow. The meter is cleared at the
1192
+ // wave's entry, so what it holds now is this wave's.
1193
+ //
1194
+ // Absent on a wave that never reached the org — see orgCallMeterSnapshot.
1195
+ ...orgCallMeterSnapshot(),
1196
+ startedAt,
1197
+ completedAt: new Date().toISOString()
1198
+ }, {
1199
+ stage: "deploy",
1200
+ outcome: result.verdict === "DIFF_VERIFIED" || result.verdict === "VALIDATED"
1201
+ ? "confirmed"
1202
+ : result.verdict
1203
+ });
1204
+ const lines = [
1205
+ `verdict: ${result.verdict}`,
1206
+ `bundle: ${result.bundleDir}`,
1207
+ // The mirror line is present whenever a state-changing wave ran, including when it
1208
+ // recorded NOTHING — "no change to record" and "could not record" are different answers
1209
+ // and a missing line would render both as silence.
1210
+ ...(result.mirror
1211
+ ? [
1212
+ result.mirror.error
1213
+ ? `history: NOT RECORDED (${result.mirror.error})`
1214
+ : result.mirror.committed
1215
+ ? `history: recorded as ${result.mirror.sha?.slice(0, 12)} (direct_history to read it; the sha is a display alias, never store it)`
1216
+ : "history: nothing to record — this wave left the org exactly as the last one did"
1217
+ ]
1218
+ : []),
1219
+ ...(result.mirror?.drifted?.length
1220
+ ? [`CHANGED OUTSIDE THIS MACHINE'S HISTORY: ${result.mirror.drifted.join(", ")}`]
1221
+ : []),
1222
+ ...(result.lint.findings.length ? [`lint: ${result.lint.findings.length} finding(s)`] : []),
1223
+ ...(result.dryRun ? [`dryRun: ${result.dryRun.status}`] : []),
1224
+ ...(result.deploy ? [`deploy: ${result.deploy.status}${result.deploy.id ? ` (${result.deploy.id})` : ""}`] : []),
1225
+ ...(result.facetUpdates ? result.facetUpdates.map((f) => `facets ${f.object}: ${f.status}`) : []),
1226
+ ...(result.intent
1227
+ ? result.intent.map((r) => `intent ${r.component}: ${r.mismatches.length === 0 ? `MATCH (${r.matched} leaves${r.orderOnly ? `, ${r.orderOnly} order-only` : ""})` : `${r.mismatches.length} MISMATCH`}`)
1228
+ : []),
1229
+ ...(result.sideChanges?.length ? [`SIDE CHANGES (unexpected): ${result.sideChanges.join(", ")}`] : []),
1230
+ // Named on the summary line, not left to the notes: this is the class that shipped green
1231
+ // on the EV-31…40 run, and a reader who only skims the first few lines must still see it.
1232
+ ...(result.unstatedChanges?.length
1233
+ ? [`UNSTATED CHANGES (nobody asked for these): ${result.unstatedChanges.map((u) => u.component).join(", ")}`]
1234
+ : []),
1235
+ ...notes,
1236
+ ...result.notes
1237
+ ];
1238
+ return ok(lines.join("\n"), result);
1239
+ }
1240
+ catch (error) {
1241
+ if (error instanceof GateError)
1242
+ return fail(error.code, error.message);
1243
+ // ORG_NOT_BOUND and ALIAS_REPOINTED are operator-actionable refusals with specific fixes.
1244
+ // Collapsing them into INTERNAL_ERROR would hide the one message that says what to do.
1245
+ if (error instanceof SessionOrgError)
1246
+ return fail(error.code, error.message);
1247
+ if (error instanceof WorkspaceError)
1248
+ return fail(error.code, error.message);
1249
+ if (error instanceof OrgReaderUnavailableError)
1250
+ return fail("ORG_READER_UNAVAILABLE", error.message);
1251
+ return internalError(error);
1252
+ }
1253
+ });
1254
+ server.registerTool("direct_history", {
1255
+ description: "READ-ONLY history of everything IN2 has delivered to this session's org, from the local org mirror. No org contact, no gate, no writes. " +
1256
+ "Every state-changing wave (DIFF_VERIFIED / DELIVERED_WITH_DRIFT / AMBIGUOUS_DELIVERY) records its post-image and the source XML it deployed here as one entry; " +
1257
+ "LINT_BLOCKED, REJECTED_BY_ORG and VALIDATED (practice run) never do, because they changed nothing. A re-delivery that leaves the org exactly as it was records nothing either. " +
1258
+ "`log` lists the entries newest-first, each with its verdict, requirement ids and any drift note (CHANGED OUTSIDE THIS MACHINE'S HISTORY / STALE PARENT / NOT COMPARABLE — the playbook says what each one means for the operator); " +
1259
+ "`show` names the files one entry touched; `diff` returns the actual before/after patch. Pass `component` (\"CustomField:Account.Region__c\") to follow ONE component through its deliveries — the 'when did this field last change, and to what?' question. " +
1260
+ "Every action flags an INCOMPLETE RECORD where one exists: a facet the read could not reach is DROPPED from the snapshot, so its absence is not evidence the org has nothing there — never author a full-file replacement from such a snapshot. " +
1261
+ "`contamination` answers a different question — measurement validity: which components a PRIOR session delivered into this org did THIS session go on to read. It is positive-evidence-only and never returns a clean verdict. " +
1262
+ "SCOPE, and it is narrow ON PURPOSE: this covers what THIS INSTALL delivered. A client admin's edit in Setup, and another consultant's wave from another machine, are both invisible here — they appear only as a component that had 'changed outside this machine's history' the next time IN2 touches it. " +
1263
+ "It is also a REBUILDABLE CACHE derived from the evidence bundles: the `sha` values are display aliases for a conversation happening now, and a rebuild rewrites every one of them. Never store a sha, and never quote one to an operator as an identifier they can come back with — address a wave by (session, wave) instead.",
1264
+ inputSchema: {
1265
+ sessionId: z
1266
+ .string()
1267
+ .describe("The session whose org's history to read. The org is derived from the session, exactly as it is for direct_apply — there is no org to name and none to get wrong."),
1268
+ action: z
1269
+ .enum(["log", "show", "diff", "contamination"])
1270
+ .optional()
1271
+ .describe("log (default) = the entry list; show = the files one entry touched; diff = the patch for one entry; contamination = what this session read of what prior sessions delivered here."),
1272
+ ref: z.string().optional().describe("Which entry, for show/diff: a sha from `log`. Defaults to the most recent entry."),
1273
+ against: z.string().optional().describe("diff only: compare `ref` against this earlier entry instead of against its own parent."),
1274
+ component: z
1275
+ .string()
1276
+ .optional()
1277
+ .describe('Narrow to one component, as "TypeName:FullName" (e.g. "CustomField:Account.Region__c", "Layout:Account-Account Layout").'),
1278
+ limit: z.number().optional().describe("log only: how many entries (default 25, max 500).")
1279
+ }
1280
+ }, async ({ sessionId, action, ref, against, component, limit }) => {
1281
+ try {
1282
+ // No `appendReadRecord` here, and the absence is deliberate: reads.jsonl records ORG reads
1283
+ // (origin R9), and this tool reads a local cache of what this install already delivered. A
1284
+ // record would inflate the read-set that the contamination metric (U5) intersects against
1285
+ // prior arms — an install reading its own history is not evidence it saw the org.
1286
+ const session = await loadSession(sessionId);
1287
+ const { alias, orgId } = await resolveSessionOrg(session, "target", { verify: false });
1288
+ if (!orgId) {
1289
+ return fail("ORG_NOT_BOUND", `Session ${sessionId} is bound to alias ${JSON.stringify(alias)} but recorded no Org ID, and the mirror is keyed by org id. Re-bind the session (activate_session, or create_session { targetOrgAlias, targetOrgId }).`);
1290
+ }
1291
+ // U5 / origin R22. Read-only like the rest of this tool, and local like the rest of it: the
1292
+ // report joins reads.jsonl against the mirror and goes nowhere near the ledger.
1293
+ if (action === "contamination") {
1294
+ const report = await computeContamination({ sessionId, orgId, orgAlias: alias });
1295
+ // PERSIST, don't just answer. This path used to compute the report and return it as text
1296
+ // only, so the one place the report could survive the conversation was the batch-close
1297
+ // hook — which never ran (see close_batch). A report that exists solely in a transcript is
1298
+ // one an operator cannot be handed and a bench harness cannot read, and the Arm A run's
1299
+ // report had to be captured by hand for exactly that reason. Writing here also means an
1300
+ // interrupted run keeps whatever it measured before it stopped.
1301
+ const written = await writeContaminationArtifacts(report);
1302
+ return ok(written
1303
+ ? `${renderContamination(report)}\n\nWritten to ${written.markdown}. The full batch-close report — this, plus the delivery log and the batch-level findings — comes from close_batch at the end of the run.`
1304
+ : renderContamination(report), report);
1305
+ }
1306
+ if ((action ?? "log") === "log") {
1307
+ const entries = await historyLog({ orgId, limit: limit ?? 25, ...(component ? { component } : {}) });
1308
+ if (entries.length === 0) {
1309
+ return ok(component
1310
+ ? `No delivery recorded for ${component} in this org. IN2 has either never touched it, or touched it without changing anything.`
1311
+ : "No deliveries recorded for this org yet.", { entries: [], orgId });
1312
+ }
1313
+ const lines = entries.map((e) => {
1314
+ // Distinct containers whose mirrored copy this history explained itself, from the
1315
+ // `<container> <- <child>` pairs the trailer records one per child.
1316
+ const staleParents = [...new Set(e.driftExplained.map((pair) => pair.split(" <- ")[0]))];
1317
+ const flags = [
1318
+ ...(e.ambiguous ? ["DELIVERY AMBIGUOUS — the post-image is partial"] : []),
1319
+ ...(e.orderingUnreliable ? ["position in this list is not evidence of when it happened"] : []),
1320
+ ...(e.revertOf ? [`rollback of ${e.revertOf}`] : []),
1321
+ ...(e.drifted.length ? [`changed outside this machine's history first: ${e.drifted.join(", ")}`] : []),
1322
+ ...(staleParents.length
1323
+ ? [
1324
+ `recorded shape of ${staleParents.join(", ")} was stale here — accounted for by this history's own delivery of ${e.driftExplained.length} child component(s), not an outside change`
1325
+ ]
1326
+ : []),
1327
+ // A check that could not run, printed beside the checks that did. Never folded into
1328
+ // the drift line: this history has no claim to make about these components.
1329
+ ...e.notComparable.map((entry) => `NOT COMPARABLE (no claim either way, NOT a clean result) — ${entry.split(" <> ")[0]}: ${entry.split(" <> ").slice(1).join("; ") || "reader changed"}`),
1330
+ // A bound on what this entry RECORDS, printed beside the findings drawn from it. The
1331
+ // facet is absent from the stored snapshot, so it cannot announce itself.
1332
+ ...e.incompleteFacets.map((entry) => `INCOMPLETE RECORD (never read, NOT empty) — ${entry.split(" <> ")[0]}: ${(entry.split(" <> ")[1] ?? "?")
1333
+ .split(",")
1334
+ .map((f) => `${f.trim()}: NOT READ at capture`)
1335
+ .join("; ")}`)
1336
+ ];
1337
+ return (`${e.sha.slice(0, 12)} ${e.date} ${e.wave ?? "?"}${e.attempt && e.attempt > 1 ? ` (attempt ${e.attempt})` : ""} ${e.verdict ?? "?"}` +
1338
+ `${e.requirementIds.length ? ` [${e.requirementIds.join(", ")}]` : ""}` +
1339
+ `${flags.length ? `\n ${flags.join("\n ")}` : ""}`);
1340
+ });
1341
+ return ok(`${entries.length} deliver${entries.length === 1 ? "y" : "ies"} recorded for this org${component ? ` touching ${component}` : ""} (newest first).\n${lines.join("\n")}`, { entries, orgId });
1342
+ }
1343
+ const target = ref ?? (await historyLog({ orgId, limit: 1 }))[0]?.sha;
1344
+ if (!target)
1345
+ return ok("No deliveries recorded for this org yet.", { orgId });
1346
+ if (action === "show") {
1347
+ const shown = await historyShow({ orgId, ref: target });
1348
+ const files = shown.files.map((f) => `${f.status} ${f.path}`).join("\n");
1349
+ return ok(`${shown.sha.slice(0, 12)}\n${shown.message.trim()}\n\n${files || "(no files)"}${renderIncompleteFacets(shown.incompleteFacets)}`, shown);
1350
+ }
1351
+ const patch = await historyDiff({
1352
+ orgId,
1353
+ ref: target,
1354
+ ...(against ? { against } : {}),
1355
+ ...(component ? { component } : {})
1356
+ });
1357
+ // The bound goes ABOVE the patch, not below it. The reader dropped the key it could not
1358
+ // verify, so the facet is invisible in the diff by construction — a reader who scrolls a
1359
+ // clean-looking patch and stops has been told something false.
1360
+ const bounds = await historyIncompleteFacets({ orgId, ref: target });
1361
+ const scoped = component ? bounds.filter((b) => b.component === component) : bounds;
1362
+ const bound = renderIncompleteFacets(scoped).trimStart();
1363
+ const body = patch.trim() || "(no changes in that entry for this scope)";
1364
+ // `text` is set by hand to the BOUND ALONE, suppressing ok()'s full-text mirror: the patch
1365
+ // is uncapped (raw `git show --patch`), it already rides `patch`, and mirroring the
1366
+ // composed text would put a second full copy into the one channel the model reads —
1367
+ // measured at 39 KB -> 80 KB of model-visible payload on a real diff. The warning must
1368
+ // still reach the model (the dropped-facet caveat is the part a patch cannot carry), so it
1369
+ // cannot simply opt out. Adversarial-review catch, 2026-08-21.
1370
+ return ok(bound ? `${bound}\n\n${body}` : body, {
1371
+ sha: target,
1372
+ patch,
1373
+ incompleteFacets: scoped,
1374
+ text: bound
1375
+ ? `${bound}\n\n(the full patch is in \`patch\`)`
1376
+ : patch.trim()
1377
+ ? "(the full patch is in `patch`)"
1378
+ : body
1379
+ });
1380
+ }
1381
+ catch (error) {
1382
+ if (error instanceof OrgRepoError)
1383
+ return fail(error.code, error.message);
1384
+ if (error instanceof SessionOrgError)
1385
+ return fail(error.code, error.message);
1386
+ if (error instanceof WorkspaceError)
1387
+ return fail(error.code, error.message);
1388
+ return internalError(error);
1389
+ }
1390
+ });
1391
+ server.registerTool("direct_rollback", {
1392
+ description: "Undo a wave IN2 delivered, one component at a time, through the same apply loop that delivered it. GATED for execution (CC_MCP_DIRECT_WRITE=1 + allowlist); planning is read-only. " +
1393
+ "ADDRESS THE WAVE BY (sessionId, wave) — never by a sha from direct_history. Those are display aliases and a mirror rebuild rewrites every one of them. " +
1394
+ "THREE MODES, and the default changes nothing: `plan` (default) classifies and cards; `execute` deploys what is executable; `verify` is the wave that follows an undelete. " +
1395
+ "PARTIAL BY DEFAULT: a component this refuses NEVER blocks the rest of the selection. Every selected component is classified before anything runs, and each class gets a different answer — a CREATE is undone by deleting it, a MODIFY by redeploying the pre-image the wave retrieved, and a change the Metadata API cannot undo is REFUSED, naming the limitation. " +
1396
+ "A CustomField the wave DELETED under 15 days ago is NOT re-created as metadata at all — the operator gets an UNDELETE CARD, which carries the Setup path, the renamed API name, the remaining window and the post-undelete checklist, because an undelete restores the field AND ITS DATA while a re-create restores the definition, reports success, and throws every value away. A lapse the probe could not confirm is carded too, never acked. " +
1397
+ "ACKS ARE PER COMPONENT AND NAMED: `ackComponents: [\"CustomField:Account.Region__c\"]`, the same explicit-override shape as `ackLints` — there is no bulk yes. Each held component is listed with the exact token that releases it. " +
1398
+ "THE R16 PREFLIGHT runs when the org can be read: live state is compared against what the wave recorded, and a divergence blocks that component pending ack. The result reports what the preflight and the deleted-field probe actually ANSWERED, never merely that they were attempted — absence of a divergence is not evidence that live state agrees, and presence that was never measured is not absence. The undelete window is RE-CHECKED at execution, per component. " +
1399
+ "Execution goes through the normal loop — lint, checkOnly, deploy, three-way diff, intent check, verdict — so the rollback earns its own evidence bundle and its commit is marked in the org mirror as a revert of the wave it undoes. " +
1400
+ "LONG-RUNNING when it executes (the same typically 30-60s, up to 3 minutes, as direct_apply, plus the preflight read).",
1401
+ inputSchema: {
1402
+ sessionId: z
1403
+ .string()
1404
+ .describe("The session that delivered the wave. The org is derived from it, exactly as for direct_apply — there is no org to pass and none to get wrong."),
1405
+ wave: z
1406
+ .string()
1407
+ .describe("The wave label to undo — the label passed to direct_apply, which is also its bundle directory. NOT a sha."),
1408
+ mode: z
1409
+ .enum(["plan", "execute", "verify"])
1410
+ .optional()
1411
+ .describe("plan (default) = classify, card, change nothing. execute = deploy everything that is executable and acked. verify = after an operator undeletes a carded field in Setup, compare the restored field against the wave's own pre-image and report what the undelete did not restore."),
1412
+ components: z
1413
+ .array(z.string())
1414
+ .optional()
1415
+ .describe('Partial selection as "TypeName:FullName" (e.g. "CustomField:Account.Region__c"). Omit for the whole wave. Anything named that the wave did not touch is reported, not silently dropped.'),
1416
+ ackComponents: z
1417
+ .array(z.string())
1418
+ .optional()
1419
+ .describe('Component keys the operator has explicitly acknowledged, releasing them to execute. One key per decision — data loss, a drift the preflight found, an incomplete pre-image, an org-wide default recalculation. Recorded in the evidence bundle.'),
1420
+ ackLints: z
1421
+ .array(z.string())
1422
+ .optional()
1423
+ .describe("Lint rule names to override on the rollback's own deploy, exactly as on direct_apply. A pre-image is the org's own former state, so this is rarely needed."),
1424
+ dryRunOnly: z
1425
+ .boolean()
1426
+ .optional()
1427
+ .describe("Practice run: checkOnly for the redeploys and a destructive checkOnly for the deletes, writing nothing. Worth doing first when the rollback deletes anything — a destructive package rolls back WHOLE, so one refused member takes the clean ones with it."),
1428
+ label: z.string().optional().describe("Evidence-bundle label for the ROLLBACK's own wave [A-Za-z0-9_-]{1,40}; default `revert-<wave>`. It must not be the target wave's label — that bundle is what the rollback reads its bytes from. If `revert-<wave>` would exceed 40 characters the call is REFUSED rather than truncated (a truncated default collides for any two waves sharing their first 33 characters, and the second rollback would then file itself as attempt 2 of the first) — pass one explicitly."),
1429
+ apiVersion: z.string().optional()
1430
+ }
1431
+ }, async ({ sessionId, wave, mode, components, ackComponents, ackLints, dryRunOnly, label, apiVersion }) => {
1432
+ const runMode = (mode ?? "plan");
1433
+ try {
1434
+ if (!LABEL_RE.test(wave))
1435
+ return fail("INVALID_LABEL", `wave must match ${LABEL_RE}.`);
1436
+ if (label && !LABEL_RE.test(label))
1437
+ return fail("INVALID_LABEL", `label must match ${LABEL_RE}.`);
1438
+ if (label === wave) {
1439
+ return fail("INVALID_LABEL", `the rollback's bundle label cannot be the wave it is undoing (${wave}) — that bundle holds the pre-image this rollback deploys from, and reusing the label would overwrite it.`);
1440
+ }
1441
+ // WITH THE OTHER LABEL CHECKS, and before anything is looked up: this is a property of the
1442
+ // two arguments alone, and a caller who has to name their own bundle should learn that from
1443
+ // the same refusal shape as every other label mistake rather than after a wave lookup.
1444
+ const bundleLabel = rollbackLabel(wave, label);
1445
+ if (bundleLabel === null) {
1446
+ return fail("INVALID_LABEL", `the default label for this rollback's own bundle, \`revert-${wave}\`, is ${`revert-${wave}`.length} characters — over the 40 every label is held to. ` +
1447
+ `It is not truncated, because truncation maps every wave sharing its first 33 characters onto ONE rollback bundle directory — and that directory is the identity: ` +
1448
+ `the second rollback would file itself as attempt 2 of the first one's, and its verdict, diff and evidence would land in the mirror under the wrong address. ` +
1449
+ `Pass an explicit \`label\` for this rollback (${LABEL_RE}), distinct from ${JSON.stringify(wave)}.`);
1450
+ }
1451
+ const session = await loadSession(sessionId);
1452
+ const binding = await resolveSessionOrg(session, "target");
1453
+ const targetOrg = binding.alias;
1454
+ if (!binding.orgId) {
1455
+ return fail("ORG_NOT_BOUND", `Session ${sessionId} is bound to alias ${JSON.stringify(targetOrg)} but recorded no Org ID, and both the mirror and the rollback's own commit are keyed by org id. Re-bind the session (activate_session, or create_session { targetOrgAlias, targetOrgId }).`);
1456
+ }
1457
+ const bundleDir = path.join(sessionDir(sessionId), "direct", wave);
1458
+ try {
1459
+ await fs.access(path.join(bundleDir, "verdict.json"));
1460
+ }
1461
+ catch {
1462
+ const available = await availableWaveBundles(sessionId);
1463
+ return fail("WAVE_NOT_FOUND", `no delivered wave "${wave}" in session ${sessionId} (expected ${bundleDir}).` +
1464
+ (available.length ? ` Waves recorded here: ${available.join(", ")}.` : " This session has delivered no waves.") +
1465
+ " A wave is addressed by the label direct_apply was called with, never by a sha.");
1466
+ }
1467
+ // WHICH pre/ THIS ROLLBACK MAY HONESTLY RESTORE. The earliest archived attempt that
1468
+ // captured a pre-image holds the baseline from before the wave first touched the org
1469
+ // (proven no-op attempts are skipped); a retried bundle with no completed archive predates
1470
+ // that fix and refuses rather than guessing (see resolveRollbackBaseline).
1471
+ const baseline = await resolveRollbackBaseline(bundleDir);
1472
+ if (!baseline.ok)
1473
+ return fail(baseline.code, baseline.message);
1474
+ // THE BUNDLE'S OWN ORG vs THE SESSION'S. Both are on this machine and a session can be
1475
+ // pointed at a different org than the one that produced a bundle sitting beside it; a
1476
+ // rollback that missed that would deploy one org's former state into another org. Read from
1477
+ // the resolved baseline, not unconditionally from bundleDir — for a retried wave that is
1478
+ // attempt 1's archive, and it is the org that image actually came from.
1479
+ const preManifest = await readJsonFile(path.join(baseline.preRoot, "pre", "projection.json"));
1480
+ if (preManifest?.orgId && preManifest.orgId.slice(0, 15) !== binding.orgId.slice(0, 15)) {
1481
+ return fail("ROLLBACK_ORG_MISMATCH", `wave ${wave} was delivered to org ${preManifest.orgId}, and this session is bound to ${binding.orgId}. Rolling it back here would deploy one org's former state into another. Open a session bound to ${preManifest.orgId} and run it there.`);
1482
+ }
1483
+ // The gate refuses an EXECUTE outright; on a plan it is reported instead, so an operator
1484
+ // learns the rollback cannot land here while reading it rather than after building it.
1485
+ let writeBlocked;
1486
+ try {
1487
+ await assertDirectWriteAllowed(targetOrg);
1488
+ }
1489
+ catch (error) {
1490
+ if (!(error instanceof GateError))
1491
+ throw error;
1492
+ if (runMode === "execute")
1493
+ return fail(error.code, error.message);
1494
+ writeBlocked = `${error.code} — this org has no write authority on this install, so this plan can be read but not executed here.`;
1495
+ }
1496
+ const version = apiVersion?.trim() || DEFAULT_API_VERSION;
1497
+ // FAIL-OPEN ON THE READER, and loudly. Without it there is no preflight and no deleted-field
1498
+ // probe, and every claim that depends on them degrades to UNVERIFIED in the result — which
1499
+ // is still a useful classification of the bundle, and is never mistaken for a clean one.
1500
+ let reader;
1501
+ let readerError;
1502
+ try {
1503
+ reader = await createLiveOrgReader(targetOrg, version);
1504
+ }
1505
+ catch (error) {
1506
+ readerError = error instanceof Error ? error.message : String(error);
1507
+ }
1508
+ const rollbackBundleDir = path.join(sessionDir(sessionId), "direct", bundleLabel);
1509
+ // Same attempt bookkeeping every wave gets: the mirror's sort key ends in attempt, so two
1510
+ // runs of one rollback have to be two entries rather than one overwriting the other. And the
1511
+ // SAME archive every wave gets, for the same reason: a rollback bundle IS a bundle — its own
1512
+ // "wave" the next tool over can retry and, one day, roll back. Without this, executing one
1513
+ // rollback twice left attempt 2 with no attempts/1/, and rolling back THAT bundle would
1514
+ // refuse forever (resolveRollbackBaseline finds attempt > 1 and no archive).
1515
+ if (runMode === "execute") {
1516
+ const rollbackAttempt = await nextWaveAttempt(rollbackBundleDir);
1517
+ if (rollbackAttempt > 1)
1518
+ await archivePriorAttempt(rollbackBundleDir, rollbackAttempt - 1);
1519
+ }
1520
+ // WHAT THE ROLLBACK IS FOR, carried from the wave it undoes. The requirement ids are the
1521
+ // only thing in the mirror that answers "why did this change?", and a revert of a delivery
1522
+ // is about the same requirements as the delivery.
1523
+ const targetRequest = await readJsonFile(path.join(bundleDir, "request.json"));
1524
+ const startedAt = new Date().toISOString();
1525
+ const result = await runRollback({
1526
+ mode: runMode,
1527
+ sessionId,
1528
+ wave,
1529
+ orgId: binding.orgId,
1530
+ orgAlias: targetOrg,
1531
+ apiVersion: version,
1532
+ bundleDir,
1533
+ preImageRoot: baseline.preRoot,
1534
+ rollbackBundleDir,
1535
+ ...(components?.length ? { components } : {}),
1536
+ ...(ackComponents?.length ? { ackComponents } : {}),
1537
+ ...(ackLints?.length ? { ackLints } : {}),
1538
+ ...(dryRunOnly ? { dryRunOnly: true } : {}),
1539
+ ...(reader ? { reader } : {}),
1540
+ ...(readerError ? { readerError } : {}),
1541
+ ...(session.batchId ? { batchId: session.batchId } : {}),
1542
+ ...(targetRequest?.requirementIds?.length ? { requirementIds: targetRequest.requirementIds } : {}),
1543
+ ...(OPERATOR_IDENTITY ?? session.actors?.targetUsername
1544
+ ? { operator: OPERATOR_IDENTITY ?? session.actors?.targetUsername }
1545
+ : {}),
1546
+ ...(writeBlocked ? { writeBlocked } : {})
1547
+ });
1548
+ // READ PROVENANCE (R9, R12). The preflight and the deleted-field probe are real org reads
1549
+ // of real components, and a read-set that omitted them would understate what this session
1550
+ // looked at — which is the one number the contamination metric cannot afford to flatter.
1551
+ if (reader) {
1552
+ const manifests = [];
1553
+ for (const phase of ["preflight", "verify"]) {
1554
+ const file = path.join(rollbackBundleDir, phase, "projection.json");
1555
+ try {
1556
+ await fs.access(file);
1557
+ manifests.push(file);
1558
+ }
1559
+ catch {
1560
+ /* not written on this path — absence is the honest record */
1561
+ }
1562
+ }
1563
+ await appendReadRecord(sessionId, {
1564
+ tool: "direct_rollback",
1565
+ orgId: binding.orgId,
1566
+ orgAlias: targetOrg,
1567
+ scope: `rollback of wave ${wave} (${runMode})`,
1568
+ pattern: "body",
1569
+ keys: [],
1570
+ manifests,
1571
+ rows: result.dispositions?.length ?? result.verification?.findings.length ?? 0,
1572
+ durationMs: Date.now() - Date.parse(startedAt),
1573
+ outcome: "ok"
1574
+ });
1575
+ }
1576
+ // BASELINE NOTE FIRST — an operator reading this rollback for a retried wave learns which
1577
+ // attempt's pre-image it is restoring before anything else, not buried under the plan text.
1578
+ //
1579
+ // `text` is OVERRIDDEN in the structured payload, not left to ok()'s mirror:
1580
+ // RollbackRunResult carries its own `text` field (the bare plan), which trips the
1581
+ // don't-clobber guard in ok() — so the mirrored copy was the plan WITHOUT the baseline
1582
+ // note, and on the one client we ship to (which drops the content channel, see
1583
+ // core/result.ts) the sentence naming WHICH attempt is being restored never reached the
1584
+ // model. Adversarial-review catch, 2026-08-21.
1585
+ const text = baseline.note ? `${baseline.note}\n\n${result.text}` : result.text;
1586
+ return ok(text, { ...result, text });
1587
+ }
1588
+ catch (error) {
1589
+ // The classifier's three refusals are operator-readable sentences by construction. Letting
1590
+ // them surface as INTERNAL_ERROR with a stack would replace an explanation of why this wave
1591
+ // cannot be rolled back with a server fault the operator can do nothing about.
1592
+ if (error instanceof RollbackError)
1593
+ return fail(error.code, error.message);
1594
+ if (error instanceof GateError)
1595
+ return fail(error.code, error.message);
1596
+ if (error instanceof SessionOrgError)
1597
+ return fail(error.code, error.message);
1598
+ if (error instanceof WorkspaceError)
1599
+ return fail(error.code, error.message);
1600
+ if (error instanceof OrgReaderUnavailableError)
1601
+ return fail("ORG_READER_UNAVAILABLE", error.message);
1602
+ return internalError(error);
1603
+ }
1604
+ });
1605
+ }
1606
+ async function readJsonFile(file) {
1607
+ try {
1608
+ return JSON.parse(await fs.readFile(file, "utf8"));
1609
+ }
1610
+ catch {
1611
+ return null;
1612
+ }
1613
+ }