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,510 @@
1
+ import fs from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { sessionDir } from "../core/workspace.js";
5
+ import { computeContamination, renderContamination } from "./contamination.js";
6
+ import { historyLog } from "./orgRepo.js";
7
+ import { preImageTargets } from "./preImageTargets.js";
8
+ import { archivedContributingAttempts } from "./rollback.js";
9
+ import { resolveRollbackBaseline } from "./rollbackRun.js";
10
+ import { gradeWaves, readWaveBundles } from "./waveGrade.js";
11
+ import { plannedKey, readPlannedScope } from "./plannedScope.js";
12
+ import { expandComponent } from "./containers.js";
13
+ /** How much of the org's history the report SHOWS; the requirement join reads far deeper. */
14
+ const DISPLAYED_DELIVERIES = 50;
15
+ /**
16
+ * Decorate the graded bundles with the two facts only this report asks for.
17
+ *
18
+ * The scan and the GRADE both come from `waveGrade.ts`, which `finalizeBatch` also calls. That is
19
+ * the whole point: the report and the terminal event cannot disagree about how many waves delivered
20
+ * or what the batch's result is, because neither of them decides it.
21
+ */
22
+ async function readWaves(sessionId, graded, deliveries) {
23
+ const dir = path.join(sessionDir(sessionId), "direct");
24
+ // Requirement ids live on the mirror's commit trailers, not in the bundle — the bundle records
25
+ // what was deployed, the commit records what it was for. Joined by (session, wave) rather than
26
+ // by wave alone, since one org's history holds every session's waves.
27
+ const requirementsByWave = new Map();
28
+ for (const entry of deliveries) {
29
+ if (entry.sessionId === sessionId && entry.wave && !requirementsByWave.has(entry.wave)) {
30
+ requirementsByWave.set(entry.wave, entry.requirementIds);
31
+ }
32
+ }
33
+ return Promise.all(graded.map(async (wave) => {
34
+ const bundle = path.join(dir, wave.wave);
35
+ // Resolved ONCE and fed to both answers below — the image attestation and the
36
+ // touched-existing-state grade are two halves of one claim about one wave, and resolving
37
+ // the baseline separately is how they got to disagree.
38
+ const baseline = await resolveRollbackBaseline(bundle);
39
+ return {
40
+ wave: wave.wave,
41
+ verdict: wave.verdict,
42
+ dryRunOnly: wave.dryRunOnly,
43
+ classification: wave.classification,
44
+ resolvedBy: wave.resolvedBy,
45
+ rollbackImage: hasRollbackImage(baseline),
46
+ touchedExistingState: await touchedExistingState(bundle, baseline),
47
+ requirementIds: requirementsByWave.get(wave.wave) ?? []
48
+ };
49
+ }));
50
+ }
51
+ /**
52
+ * Whether the bundle holds a deployable rollback image AT AN ADDRESS `direct_rollback` would
53
+ * actually use.
54
+ *
55
+ * WHY: checking `<bundle>/pre/source` directly (as this used to) overclaims for a retried wave.
56
+ * `direct_rollback` resolves its pre-image root through `resolveRollbackBaseline` before it reads
57
+ * anything under `pre/source` — a legacy bundle retried before per-attempt archiving existed has no
58
+ * honest baseline and the tool REFUSES it outright (this report must then say NO, not YES), and an
59
+ * archived retry's honest baseline lives under `attempts/1/`, not the bundle's own attempt-N-
60
+ * overwritten `pre/` (this report must check THAT tree, not the top-level one). This report must
61
+ * never attest an image the rollback tool itself would not use.
62
+ */
63
+ function hasRollbackImage(baseline) {
64
+ if (!baseline.ok)
65
+ return false;
66
+ // `retrievePreImageSource` writes to `<preRoot>/pre/source`. Reading `<bundle>/source` instead
67
+ // found nothing on any bundle ever written, so every wave read as having no rollback image.
68
+ return existsSync(path.join(baseline.preRoot, "pre", "source"));
69
+ }
70
+ /**
71
+ * Did this wave touch anything that already existed?
72
+ *
73
+ * Asked of the REQUESTS and the PRE-PROJECTION — the same inputs, through the same
74
+ * `preImageTargets`, that the apply loop used to decide what to retrieve an image FOR. The report
75
+ * and the retrieve therefore cannot disagree about which components had prior state, which is the
76
+ * only way "this wave is missing its rollback image" means anything. For a retried wave that means
77
+ * every attempt that may have landed (`archivedContributingAttempts`), measured against the
78
+ * RESOLVED baseline's manifest — the final attempt's request alone erases whatever an earlier
79
+ * landed attempt destroyed.
80
+ *
81
+ * It is NOT asked of the three-way diff, which was the live-caught defect. The diff is a comparison
82
+ * of two projections and has no concept of intent: it files a CREATE as a `changed` entry whose
83
+ * `before` is the string `"null"` and a DELETE as one whose `after` is `"null"`, while `added` and
84
+ * `removed` sat empty on all four waves of the run that caught this. Counting `changed` entries
85
+ * therefore graded two brand-new fields as edits of existing state. Nor can the diff answer at all
86
+ * for a dryRunOnly bundle, which never reaches it and has no `diff` key — those defaulted to
87
+ * "added only", printing a false n/a over practice bundles that target live components.
88
+ *
89
+ * FAILS LOUD, not quiet. A bundle whose request or manifest cannot be read cannot establish that it
90
+ * added only, and the two directions of that guess are not symmetric: a wrong `false` hides a
91
+ * missing rollback image, which is the failure this finding exists to catch, while a wrong `true`
92
+ * costs one over-reported wave that a reader can check against the bundle. Every bundle that
93
+ * reaches a verdict has both files — `request.json` is written before the org is read at all — so
94
+ * this is reachable only on a corrupt bundle.
95
+ */
96
+ async function touchedExistingState(bundle, baseline) {
97
+ // A bundle the rollback tool refuses cannot establish that it added only — fail loud.
98
+ if (!baseline.ok)
99
+ return true;
100
+ try {
101
+ const request = JSON.parse(await fs.readFile(path.join(bundle, "request.json"), "utf8"));
102
+ // The manifest comes from the RESOLVED baseline, and the requests from every attempt that may
103
+ // have landed — a retried wave whose final attempt named only brand-new components still
104
+ // destroyed whatever an earlier landed attempt deleted, and grading it off the final request
105
+ // against the final pre/ read exactly that wave as "added only".
106
+ const manifest = JSON.parse(await fs.readFile(path.join(baseline.preRoot, "pre", "projection.json"), "utf8"));
107
+ if (!manifest.components)
108
+ return true;
109
+ const requests = [request, ...(await archivedContributingAttempts(bundle)).map((a) => a.request)];
110
+ return requests.some((r) => preImageTargets(r.edits ?? [], r.deletes ?? [], (r.facetUpdates ?? []).map((f) => f.objectApiName), { components: manifest.components }).length > 0);
111
+ }
112
+ catch {
113
+ return true;
114
+ }
115
+ }
116
+ /**
117
+ * The configurations a DELIVERING wave actually put in the org, at Configuration Item grain.
118
+ *
119
+ * Read from each delivered bundle's own `request.json` and expanded through the SAME
120
+ * `expandComponent` the planned scope and `campfire_config_item_plan` use. That shared expansion is
121
+ * the whole contract: a numerator counting deployed FILES against a denominator counting
122
+ * configurable COMPONENTS is QA round 1 item 24, and it would report a rate no evidence supports.
123
+ *
124
+ * A SET, not a sum. One component deployed twice — the revision model's ordinary create-then-update
125
+ * across two waves — is one configuration, and summing wave membership would produce rates above
126
+ * 100% on runs that did more work, not less.
127
+ *
128
+ * Practice runs are excluded by construction: only `delivered` bundles are read, and a dry run
129
+ * changed nothing by contract.
130
+ */
131
+ async function deliveredConfigurations(sessionId, waves) {
132
+ const dir = path.join(sessionDir(sessionId), "direct");
133
+ const out = new Set();
134
+ for (const wave of waves) {
135
+ if (wave.classification !== "delivered")
136
+ continue;
137
+ try {
138
+ const request = JSON.parse(await fs.readFile(path.join(dir, wave.wave, "request.json"), "utf8"));
139
+ for (const edit of request.edits ?? []) {
140
+ // The submitted XML is on the request, so a container expands here exactly as it did at
141
+ // planning. An edit with no XML counts once at its own grain, matching the planned side's
142
+ // treatment of an entry with no authored file.
143
+ for (const c of expandComponent(edit.typeName, edit.fullName, edit.xml)) {
144
+ out.add(plannedKey(c));
145
+ }
146
+ }
147
+ // A verified REMOVAL is a completed configuration — operator-voice.md is explicit that a
148
+ // removal counts as completed. It has no submitted XML to expand, so it counts at its own key.
149
+ for (const del of request.deletes ?? [])
150
+ out.add(plannedKey(del));
151
+ // A FACET UPDATE is a CRUD read-modify-write of the object itself, and
152
+ // `campfire_config_item_plan` derives a `CustomObject` Configuration Item from it
153
+ // (`deriveFromBundle.ts`, the `facetUpdates` loop). Omitting it here would make a batch whose
154
+ // only change to an object was its sharing model count that object as planned-but-not-
155
+ // automated, while Campfire records a CI for it — the two rows of the panel disagreeing,
156
+ // which is the failure this whole grain discipline exists to prevent.
157
+ for (const facet of request.facetUpdates ?? []) {
158
+ out.add(plannedKey({ typeName: "CustomObject", fullName: facet.objectApiName }));
159
+ }
160
+ }
161
+ catch {
162
+ // An unreadable bundle contributes nothing rather than failing the report. The consequence is
163
+ // a LOW numerator — a conservative rate, never an inflated one.
164
+ }
165
+ }
166
+ return out;
167
+ }
168
+ /**
169
+ * The four counts, or null when nothing recorded what this batch was asked for.
170
+ *
171
+ * The intersection is the point. `automated` counts only planned configurations the delivery
172
+ * proved, so the numerator can never exceed the denominator, and anything delivered that no plan
173
+ * owned lands in `outsideScope` — reported, and in neither half of the rate.
174
+ */
175
+ function computeScope(planned, delivered) {
176
+ const plannedKeys = new Set(planned.components.map(plannedKey));
177
+ const manualKeys = new Set(planned.manual.map(plannedKey));
178
+ // NOT in the denominator: no metadata deploy could ever satisfy these, so counting them
179
+ // against the rate measures the requirement mix, not the tool. Reported as their own count.
180
+ // Absent on scope files written before the field existed — reads as empty, the old math.
181
+ const unautomatableKeys = new Set((planned.unautomatable ?? []).map(plannedKey));
182
+ // A DELIVERY IS EVIDENCE, AND EVIDENCE OUTRANKS A PLAN-TIME ROUTE.
183
+ //
184
+ // The loops below dedup only OUTWARD FROM `plannedKeys`, so a component the plan routed to a
185
+ // person and a later wave then delivered was matched by no case: uncredited in the numerator,
186
+ // still counted as manual, AND flagged outsideScope — three wrong statements about one
187
+ // component, and the middle one is an actionable falsehood that sends a consultant into Setup to
188
+ // redo work the org already holds. Promoting the key here is the whole fix; the existing loops
189
+ // do the removals. For a manual key the denominator is unchanged (one gained on the left, one
190
+ // lost on the right). For an unautomatable key it GROWS by one, deliberately: those are excluded
191
+ // on the premise that no metadata deploy could ever satisfy them, and an observed delivery
192
+ // disproves the premise for that key.
193
+ for (const key of delivered) {
194
+ if (manualKeys.has(key) || unautomatableKeys.has(key))
195
+ plannedKeys.add(key);
196
+ }
197
+ // A component both deployed and routed is counted once, on the deployed side: the route was the
198
+ // fallback, and the deploy is what the org read-back can prove.
199
+ for (const key of plannedKeys) {
200
+ manualKeys.delete(key);
201
+ unautomatableKeys.delete(key);
202
+ }
203
+ // A key both ordinarily-manual and unautomatable (two plans, two causes) counts once, on the
204
+ // manual side — the fail-closed direction: against the rate rather than out of it.
205
+ for (const key of manualKeys)
206
+ unautomatableKeys.delete(key);
207
+ const denominator = plannedKeys.size + manualKeys.size;
208
+ const automated = [...plannedKeys].filter((key) => delivered.has(key)).length;
209
+ return {
210
+ planned: denominator,
211
+ automated,
212
+ manual: manualKeys.size,
213
+ unautomatable: unautomatableKeys.size,
214
+ // A zero denominator would make this 0/0. It cannot arise with a scope on disk that holds any
215
+ // component, and a scope holding none is not a batch anyone measured — report 0 rather than NaN.
216
+ automationRate: denominator === 0 ? 0 : Math.round((automated / denominator) * 100),
217
+ outsideScope: [...delivered].filter((key) => !plannedKeys.has(key)).length
218
+ };
219
+ }
220
+ /**
221
+ * Findings true of the BATCH.
222
+ *
223
+ * Deliberately conservative: each one is computed from evidence on disk and says what is missing
224
+ * rather than what it implies. "No wave in this batch has a deployable rollback image" is a fact;
225
+ * "this batch is unsafe" would be an opinion the evidence does not carry.
226
+ */
227
+ function batchFindings(waves, contamination) {
228
+ const findings = [];
229
+ // Only waves that changed or removed something that already existed can HAVE a rollback image;
230
+ // see WaveOutcome.touchedExistingState for why pure additions are excluded rather than graded.
231
+ const atRisk = waves.filter((w) => w.classification === "delivered" && w.touchedExistingState);
232
+ const withoutImage = atRisk.filter((w) => !w.rollbackImage);
233
+ if (atRisk.length > 0 && withoutImage.length === atRisk.length) {
234
+ findings.push(`NO ROLLBACK IMAGE FOR ANY WAVE THAT CHANGED EXISTING STATE — all ${atRisk.length} such wave(s) (${atRisk.map((w) => w.wave).join(", ")}) delivered without retrieving a deployable pre-image. ` +
235
+ `Undoing any of them means re-authoring the prior state by hand: the JSON snapshots under each bundle's pre/ verify the diff but cannot be redeployed. ` +
236
+ `Waves that only ADDED components are excluded — undoing an add is a deletion, so their missing pre-image is correct, not a gap.`);
237
+ }
238
+ else if (withoutImage.length > 0) {
239
+ findings.push(`${withoutImage.length} of ${atRisk.length} wave(s) that changed existing state have NO deployable rollback image: ${withoutImage.map((w) => w.wave).join(", ")}. The rest can be redeployed from their bundle's source/.`);
240
+ }
241
+ // Requirement attribution is mandatory per the playbook ("Pass `requirementIds` on every
242
+ // direct_apply"), and it is the ONLY thing that survives into the org's own history to answer
243
+ // "why does this field exist?" months later. A wave that delivered without it is a permanent gap
244
+ // rather than a formatting one, so the batch says so while the run is still in living memory.
245
+ const unattributed = waves.filter((w) => w.classification === "delivered" && w.requirementIds.length === 0);
246
+ if (unattributed.length > 0) {
247
+ findings.push(`${unattributed.length} delivering wave(s) recorded NO requirement ids (${unattributed.map((w) => w.wave).join(", ")}) — ` +
248
+ `the org's history will not say what those components were for. Pass \`requirementIds\` on every direct_apply; it cannot be added after the fact.`);
249
+ }
250
+ // A PRACTICE RUN THE ORG OR THE LINT REFUSED. Reported in EVERY case, whether or not it counted
251
+ // against the grade — the grade decides how loud this is, never whether the operator hears it.
252
+ // Its two halves are separate findings because they ask the reader for different things.
253
+ const blocked = waves.filter((w) => w.classification === "practice-blocked");
254
+ const unanswered = blocked.filter((w) => w.resolvedBy === null);
255
+ if (unanswered.length > 0) {
256
+ findings.push(`${unanswered.length} practice run(s) were REFUSED and nothing in this batch went on to do it (${unanswered.map((w) => `${w.wave} — ${w.verdict}`).join("; ")}). ` +
257
+ `A dry run exists to find this before the real wave, so it did its job — but the work it was checking has not been delivered here. ` +
258
+ `Check whether it was resolved elsewhere, abandoned on purpose, or is still outstanding.`);
259
+ }
260
+ const answered = blocked.filter((w) => w.resolvedBy !== null);
261
+ if (answered.length > 0) {
262
+ findings.push(`${answered.length} practice run(s) were refused and then delivered anyway by a later wave (${answered.map((w) => `${w.wave} → ${(w.resolvedBy ?? []).join(", ")}`).join("; ")}) — ` +
263
+ `not counted against this batch. That is a SCOPE match (same components, same operation, verified delivery), not proof the reason for the refusal was understood; the delivery log above shows both.`);
264
+ }
265
+ const ambiguous = waves.filter((w) => w.verdict === "AMBIGUOUS_DELIVERY" && !w.dryRunOnly);
266
+ if (ambiguous.length > 0) {
267
+ findings.push(`${ambiguous.length} wave(s) recorded an AMBIGUOUS_DELIVERY (${ambiguous.map((w) => w.wave).join(", ")}) — the post-image is partial, so what reached the org is not fully established from this machine's evidence.`);
268
+ }
269
+ if (contamination && contamination.readSet.opaqueReads > 0) {
270
+ findings.push(`${contamination.readSet.opaqueReads} org read(s) named no component, so the contamination result below is a floor and not a clean bill of health.`);
271
+ }
272
+ return findings;
273
+ }
274
+ /**
275
+ * Was this batch's work ALREADY DONE before it started?
276
+ *
277
+ * THE DEFECT (QA round 2, Matt Nelson): when every requested configuration already existed, the
278
+ * analyzers authored nothing to deploy, no bundle was written, and `gradeWaves` returned NO_WAVES —
279
+ * whose rendering is one sentence written for a different situation: "A run that only read has no
280
+ * batch result to state." The operator read a successful outcome as a dead end. His words: "here's
281
+ * what you asked for. It already existed in the org. Like duplicate verified."
282
+ *
283
+ * THE DISTINGUISHING EVIDENCE is the planned scope, and it is why this could not be decided from
284
+ * bundles alone. A read-only run and an already-satisfied run leave the SAME empty `direct/`
285
+ * directory; what separates them is that one of them planned N configurations and the other planned
286
+ * nothing. That fact now survives to close, so the two stop collapsing.
287
+ *
288
+ * NARROW ON PURPOSE — four conditions, each closing a way this could overclaim:
289
+ * - `NO_WAVES` only. A batch that graded COMPLETE, FAILED or NEEDS_ATTENTION has a delivery
290
+ * result of its own and this must never overwrite it.
291
+ * - NO WAVE BUNDLES AT ALL. `gradeResult` returns NO_WAVES for two different runs: one with no
292
+ * bundles, and one whose bundles were all accepted PRACTICE runs. Only the first is evidence
293
+ * that the analyzers found nothing to author. A batch that dry-ran three configurations and
294
+ * stopped there touched the org with nothing and verified the presence of nothing — declaring
295
+ * it "already configured, all 3 were already in place" invents a projection result out of a
296
+ * rehearsal. `core/telemetry.ts`'s `finalizeBatch` already classifies that run correctly ("a
297
+ * run that only read **or only practiced**"); this condition is what stops the two subsystems
298
+ * from contradicting each other on the same evidence.
299
+ * - A scope with at least one PLANNED component. Zero planned is a run that asked for nothing.
300
+ * - No MANUAL routes outstanding. A batch that still owes a person work is not satisfied, and
301
+ * saying so would be the flattering error §4 of the handoff forbids.
302
+ *
303
+ * What it does NOT establish: that anything was re-verified just now. The claim is "this batch
304
+ * deployed nothing and everything it planned was already present at planning time", grounded in
305
+ * the projection the scope records. The rendering says exactly that and no more.
306
+ */
307
+ function alreadySatisfied(result, waveCount, scope) {
308
+ return (result === "NO_WAVES" &&
309
+ waveCount === 0 &&
310
+ scope !== null &&
311
+ scope.components.length > 0 &&
312
+ scope.manual.length === 0);
313
+ }
314
+ /** Compute the batch's closing report. Reads only — no org contact, no writes. */
315
+ export async function composeBatchReport(input) {
316
+ // Each source is independently guarded: this runs at batch close, and one unavailable input must
317
+ // degrade its own section rather than cost the whole report.
318
+ // Reads DEEP, displays shallow. The join needs this session's own entries, and a busy org buries
319
+ // them: on the dev install this session's four waves sat at positions 51-64, so a 50-entry window
320
+ // attributed none of them. The requirement ids are the only record of what a wave was FOR, so the
321
+ // window that finds them is not the window a reader wants to scroll.
322
+ let history = [];
323
+ try {
324
+ history = await historyLog({
325
+ orgId: input.orgId,
326
+ limit: 500,
327
+ ...(input.root ? { root: input.root } : {})
328
+ });
329
+ }
330
+ catch {
331
+ history = [];
332
+ }
333
+ const deliveries = history.slice(0, DISPLAYED_DELIVERIES);
334
+ // Reads the log first: the bundles carry the verdicts, the log carries the requirement ids.
335
+ // The GRADE is not computed here — `gradeWaves` owns it, and `finalizeBatch` calls the same
336
+ // function on the same bundles, so the local report and the ledger's terminal event state one
337
+ // result rather than two that happen to agree.
338
+ const grade = gradeWaves(readWaveBundles(path.join(sessionDir(input.sessionId), "direct")));
339
+ const waves = await readWaves(input.sessionId, grade.waves, history);
340
+ let contamination = null;
341
+ try {
342
+ contamination = await computeContamination({
343
+ sessionId: input.sessionId,
344
+ orgId: input.orgId,
345
+ ...(input.orgAlias ? { orgAlias: input.orgAlias } : {}),
346
+ ...(input.root ? { root: input.root } : {})
347
+ });
348
+ }
349
+ catch {
350
+ contamination = null;
351
+ }
352
+ // The denominator, and the two counts taken over it. Independently guarded like every other
353
+ // source here: a session with no recorded scope reports no rate rather than failing the close.
354
+ const plannedScope = await readPlannedScope(input.sessionId);
355
+ // Resolved ONCE and used for both the counts and the page. Awaited unconditionally rather than
356
+ // inside the ternary so the page can still enumerate what the org proved on a batch that
357
+ // recorded no denominator.
358
+ const delivered = await deliveredConfigurations(input.sessionId, waves);
359
+ const scope = plannedScope ? computeScope(plannedScope, delivered) : null;
360
+ return {
361
+ sessionId: input.sessionId,
362
+ orgId: input.orgId,
363
+ generatedAt: new Date().toISOString(),
364
+ // ALREADY_SATISFIED is decided HERE rather than in `waveGrade`, and deliberately so. That
365
+ // module is synchronous and dependency-free because `finalizeBatch` may run inside a signal
366
+ // handler; the planned scope is an async read it must not take. So the grade stays the one
367
+ // rule about BUNDLES, and this narrows its NO_WAVES using evidence bundles cannot carry.
368
+ result: alreadySatisfied(grade.result, grade.waves.length, plannedScope) ? "ALREADY_SATISFIED" : grade.result,
369
+ scope,
370
+ plannedScope,
371
+ deliveredKeys: [...delivered],
372
+ waves,
373
+ deliveredWaves: grade.deliveredWaves,
374
+ problemWaves: grade.problemWaves,
375
+ practiceRuns: grade.practiceRuns,
376
+ findings: batchFindings(waves, contamination),
377
+ deliveries,
378
+ contamination
379
+ };
380
+ }
381
+ /** Render the report as the markdown the artifact holds and `close_batch` returns. */
382
+ export function renderBatchReport(report) {
383
+ const lines = [
384
+ `# Batch close — session ${report.sessionId}`,
385
+ ``,
386
+ `Org ${report.orgId} · generated ${report.generatedAt}`,
387
+ ``,
388
+ `## Result: ${report.result}`,
389
+ ``
390
+ ];
391
+ // THE DENOMINATOR, FIRST. The operator's complaint was not that the numbers were wrong but that
392
+ // the page never stated what was ASKED, so a 19-of-20 run read as unremarkable. The rate leads
393
+ // because it is the one line that carries the run's whole shape; the four counts follow it,
394
+ // because they are what makes it checkable rather than a slogan.
395
+ if (report.scope) {
396
+ lines.push(`${report.scope.automated} of ${report.scope.planned} planned configuration(s) automated (${report.scope.automationRate}%).`, ...(report.scope.manual > 0 ? [`${report.scope.manual} planned configuration(s) still need a person.`] : []),
397
+ // Stated beside the rate and in neither half of it — the mirror image of outsideScope.
398
+ // Silent exclusion would be the flattering-direction lie; the count and the reason travel
399
+ // with every rate this page states.
400
+ ...(report.scope.unautomatable > 0
401
+ ? [
402
+ `${report.scope.unautomatable} configuration(s) cannot be satisfied by any metadata deploy (data rows or no API) — excluded from the rate's denominator and listed as their own count.`
403
+ ]
404
+ : []),
405
+ // Stated, and in NEITHER half of the rate above. A rate that could absorb unrequested work
406
+ // is a lie in the flattering direction (QA round 1: a COMPLETE batch reported as 82%).
407
+ ...(report.scope.outsideScope > 0
408
+ ? [`${report.scope.outsideScope} delivered configuration(s) were outside the approved scope — counted in neither half of that rate.`]
409
+ : []), ``);
410
+ }
411
+ if (report.result === "ALREADY_SATISFIED") {
412
+ // NOT the read-only sentence. This batch asked for specific configurations and every one of
413
+ // them was already in the org, which is an outcome rather than an absence of one.
414
+ lines.push(`${report.scope?.planned ?? 0} planned configuration(s) were already present in this org, so no step needed to change anything.`, `Nothing was deployed and nothing was altered — the batch's configurations were verified as already in place.`, ``);
415
+ }
416
+ else if (report.waves.length === 0) {
417
+ lines.push(`No wave changed this org. A run that only read has no batch result to state.`, ``);
418
+ }
419
+ else {
420
+ // Practice runs get their OWN line rather than being folded into either count. A dry run that
421
+ // passed is the practice succeeding, and a scoreboard that buried three of them inside "need
422
+ // review" is what this report used to print over a run where every real wave delivered.
423
+ const practice = report.waves.filter((w) => w.dryRunOnly);
424
+ const accepted = practice.filter((w) => w.classification === "practice-accepted").length;
425
+ const cleared = practice.filter((w) => w.resolvedBy !== null).length;
426
+ const outstanding = practice.length - accepted - cleared;
427
+ lines.push(report.deliveredWaves === 0 && report.problemWaves === 0
428
+ ? `No wave changed this org — ${practice.length} practice run(s) only.`
429
+ : `${report.deliveredWaves} of ${report.waves.length - practice.length} real wave(s) delivered; ${report.problemWaves} need review.`, ...(practice.length > 0
430
+ ? [
431
+ `${practice.length} practice run(s) (dry runs, which change nothing): ${accepted} accepted` +
432
+ `${cleared > 0 ? `, ${cleared} refused but delivered later by a real wave` : ""}` +
433
+ `${outstanding > 0 ? `, ${outstanding} REFUSED with nothing in this batch delivering it` : ""}.`
434
+ ]
435
+ : []), ``, `| Wave | Verdict | Deployable rollback image | Requirements |`, `| --- | --- | --- | --- |`, ...report.waves.map((w) => `| ${w.wave}${w.dryRunOnly ? " (practice)" : ""} | ${w.verdict} | ${w.rollbackImage ? "yes" : w.touchedExistingState ? "NO" : "n/a (added only)"} | ${w.requirementIds.join(", ") || "—"} |`), ``);
436
+ }
437
+ if (report.findings.length > 0) {
438
+ lines.push(`## Batch-level findings`, ``, ...report.findings.map((f) => `- ${f}`), ``);
439
+ }
440
+ // The delivery log sits BESIDE contamination on purpose — see the module header. The
441
+ // driftExplained edges are what let a stale-parent classification explain itself.
442
+ lines.push(`## Delivery log`, ``);
443
+ if (report.deliveries.length === 0) {
444
+ lines.push(`No deliveries recorded for this org yet.`, ``);
445
+ }
446
+ else {
447
+ for (const e of report.deliveries) {
448
+ const staleParents = [...new Set(e.driftExplained.map((pair) => pair.split(" <- ")[0]))];
449
+ const flags = [
450
+ ...(e.ambiguous ? ["DELIVERY AMBIGUOUS — the post-image is partial"] : []),
451
+ ...(e.orderingUnreliable ? ["position in this list is not evidence of when it happened"] : []),
452
+ ...(e.revertOf ? [`rollback of ${e.revertOf}`] : []),
453
+ ...(e.drifted.length ? [`changed outside this machine's history first: ${e.drifted.join(", ")}`] : []),
454
+ ...(staleParents.length
455
+ ? [
456
+ `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`
457
+ ]
458
+ : []),
459
+ // A gap in the record, printed as one. Sits with the other flags rather than in a footnote
460
+ // because "could not check" belongs next to "checked and found nothing", never below it.
461
+ ...e.notComparable.map((entry) => `NOT COMPARABLE (no claim either way, not a clean result) — ${entry.split(" <> ")[0]}: ${entry.split(" <> ").slice(1).join("; ") || "reader changed"}`),
462
+ // What this entry's post-image does NOT contain. Sits with the flags for the same reason
463
+ // the line above does: a bound on the record belongs beside the record, never in a
464
+ // footnote — and this one is invisible in the tree, because the reader drops the facet it
465
+ // could not verify rather than storing a value it does not trust.
466
+ ...e.incompleteFacets.map((entry) => `INCOMPLETE RECORD (never read, not empty) — ${entry.split(" <> ")[0]}: ${(entry.split(" <> ")[1] ?? "?")
467
+ .split(",")
468
+ .map((f) => `${f.trim()}: NOT READ at capture`)
469
+ .join("; ")}`)
470
+ ];
471
+ lines.push(`- ${e.date} ${e.wave ?? "?"}${e.attempt && e.attempt > 1 ? ` (attempt ${e.attempt})` : ""} ${e.verdict ?? "?"}` +
472
+ `${e.sessionId ? ` session ${e.sessionId}` : ""}${e.requirementIds.length ? ` [${e.requirementIds.join(", ")}]` : ""}` +
473
+ `${flags.length ? `\n - ${flags.join("\n - ")}` : ""}`);
474
+ }
475
+ lines.push(``);
476
+ }
477
+ lines.push(`## Contamination (measurement validity)`, ``);
478
+ lines.push(report.contamination
479
+ ? renderContamination(report.contamination)
480
+ : `Not computed for this session. Absence of a report is NOT a clean result — nothing was measured.`);
481
+ return `${lines.join("\n")}\n`;
482
+ }
483
+ /** Where the report lands in the session's evidence. */
484
+ export function batchReportArtifacts(sessionId) {
485
+ const dir = sessionDir(sessionId);
486
+ return { json: path.join(dir, "batch-report.json"), markdown: path.join(dir, "BATCH-REPORT.md") };
487
+ }
488
+ /**
489
+ * Write the report beside the session's other evidence.
490
+ *
491
+ * FAIL-OPEN: this runs at batch close, after the work is done, so an unwritable session directory
492
+ * costs the artifact and nothing else. The contamination artifacts are written too, so the U5
493
+ * output keeps its own stable path for anything that already reads it.
494
+ */
495
+ export async function writeBatchReportArtifacts(report, rendered) {
496
+ try {
497
+ const files = batchReportArtifacts(report.sessionId);
498
+ await fs.mkdir(path.dirname(files.json), { recursive: true });
499
+ await fs.writeFile(files.json, `${JSON.stringify(report, null, 2)}\n`, "utf8");
500
+ await fs.writeFile(files.markdown, rendered ?? renderBatchReport(report), "utf8");
501
+ if (report.contamination) {
502
+ const { writeContaminationArtifacts } = await import("./contamination.js");
503
+ await writeContaminationArtifacts(report.contamination);
504
+ }
505
+ return files;
506
+ }
507
+ catch {
508
+ return null;
509
+ }
510
+ }
@@ -0,0 +1,75 @@
1
+ // THE NOISE BUDGET — the canonicalizations that keep the delivery comparators quiet.
2
+ //
3
+ // Every entry here is a live-caught class of Salesforce read-back behaviour that made a CORRECT
4
+ // deploy report drift. They were written for the intent check (compareIntent), but the unstated-change
5
+ // check compares a pre-image against a post-image over the same metadata and is exposed to the same
6
+ // classes — so they live in one module rather than being copied. A suppression that exists on one
7
+ // side and not the other is how two subsystems end up disagreeing about the same facet, which the
8
+ // removals module already paid for once (see its CF63 w4 note).
9
+ //
10
+ // The bar for adding an entry is a MEASURED org behaviour, not a plausible one: each of these
11
+ // silences a real alarm, and a guessed entry silences a real finding just as effectively.
12
+ // Elements the org OMITS from a read-back when their value equals the platform default. Stating
13
+ // one explicitly is correct authoring, so intent-says-default vs observed-absent is AGREEMENT.
14
+ // Keyed by leaf element name; extend as each is live-caught rather than guessed at.
15
+ export const PLATFORM_DEFAULT_WHEN_OMITTED = {
16
+ formulaTreatBlanksAs: "BlankAsBlank",
17
+ // CF77 w4-retry, both live-confirmed against the org read-back: the author states these on an
18
+ // approval step, the org stores them as the default and omits them entirely on read. Absent
19
+ // means "the value you asked for", so reporting drift here is a false alarm on a correct deploy.
20
+ whenMultipleApprovers: "FirstResponse",
21
+ rejectBehavior: { type: "RejectRequest" }
22
+ };
23
+ // A currency criterion is echoed back FORMATTED for the org's locale and currency:
24
+ // `10000` is stored, `"USD 10,000"` is what a read returns. Same number, different rendering, so
25
+ // comparing the strings reported drift on an approval process that deployed exactly as authored.
26
+ export function canonicalizeCurrencyish(v) {
27
+ if (typeof v !== "string")
28
+ return v;
29
+ const bare = v.replace(/^"|"$/g, "").trim();
30
+ const m = /^(?:[A-Z]{3}\s*)?(-?[\d,]+(?:\.\d+)?)$/.exec(bare);
31
+ if (!m)
32
+ return v;
33
+ const n = Number(m[1].replace(/,/g, ""));
34
+ return Number.isFinite(n) ? String(n) : v;
35
+ }
36
+ export const isPlainObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
37
+ /**
38
+ * Is this value "nothing", whatever shape it arrived in?
39
+ *
40
+ * An empty XML element parses to `""`; the org read-back returns the fully materialized skeleton.
41
+ * CF77 w5: the author wrote three empty `<layoutColumns/>` on the Custom Links section and the org
42
+ * returned `[{layoutItems: []}, …]` — identical meaning, reported as drift on a Layout that
43
+ * deployed exactly as written.
44
+ */
45
+ export function isEmptyish(v) {
46
+ if (v === "" || v === null || v === undefined)
47
+ return true;
48
+ if (Array.isArray(v))
49
+ return v.every(isEmptyish);
50
+ if (isPlainObj(v))
51
+ return Object.values(v).every(isEmptyish);
52
+ return false;
53
+ }
54
+ // XML parse yields strings for scalars; org read-back yields typed values. Compare
55
+ // on canonical string forms for scalar leaves (the projection normalizers already
56
+ // coerce the load-bearing booleans/numbers on the observed side).
57
+ export function coerceLeaf(v) {
58
+ if (typeof v === "number" || typeof v === "boolean")
59
+ return String(v);
60
+ if (typeof v === "string")
61
+ return v;
62
+ if (Array.isArray(v))
63
+ return v.map(coerceLeaf);
64
+ if (v && typeof v === "object") {
65
+ // Text-node-with-attributes shape (live-caught on CustomMetadata `<value xsi:type=…>text`):
66
+ // the org read-back returns {_: "5", $: {xsi:type}} while author XML (attrs ignored) yields
67
+ // the plain scalar "5". Collapse to the text so intent compares like-with-like — the xsi:type
68
+ // is a derivable type hint, not semantic content for delivery verification.
69
+ const rec = v;
70
+ if ("_" in rec && "$" in rec && Object.keys(rec).length === 2)
71
+ return coerceLeaf(rec._);
72
+ return Object.fromEntries(Object.entries(rec).map(([k, x]) => [k, coerceLeaf(x)]));
73
+ }
74
+ return v;
75
+ }