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,233 @@
1
+ // RECONSTRUCT WHERE A RUN GOT TO, from the ledger plus the artifacts it left behind.
2
+ //
3
+ // A session directory has always held everything a run produced — the brief, one plan.json per
4
+ // requirement, the wave bundles with their verdicts, the question rounds with their answers, the
5
+ // authored trees. All of it is session-scoped and none of it needed changing for this. What was
6
+ // missing was the one fact none of those artifacts states: WHERE THE RUN HAD GOT TO.
7
+ //
8
+ // That is not recoverable by inference, and the failure is asymmetric in the direction that costs
9
+ // most. A chapter entered but not finished looks exactly like a chapter never entered: a session
10
+ // with plans on disk and no wave bundles is either mid-clarification, or approved-and-about-to-
11
+ // deploy, or rejected at the approval card and abandoned. Guess "about to deploy" on a run the
12
+ // operator actually rejected and the resumed session deploys work nobody approved.
13
+ //
14
+ // So the ledger states position and the artifacts state substance, and this module reads both:
15
+ // position from `phases.jsonl`, substance from the session directory, and the two are reported
16
+ // separately rather than reconciled into one confident story. A disagreement between them is
17
+ // itself the most useful thing a resumed session can be told.
18
+ import { existsSync, promises as fs } from "node:fs";
19
+ import path from "node:path";
20
+ import { readQuestionRounds, statusFor } from "./operatorQa.js";
21
+ import { chapterIndex, describeStall, ledgerHead, readPhaseLedger } from "./phases.js";
22
+ import { gradeWaves, readWaveBundles } from "../direct/waveGrade.js";
23
+ import { listDirs, sessionDir } from "./workspace.js";
24
+ /**
25
+ * Read everything, tolerantly. Every step is guarded on its own: this runs against a session some
26
+ * other process died in the middle of writing, so a half-written artifact is the expected input
27
+ * rather than an exceptional one, and one unreadable file must never cost the report the other
28
+ * eleven facts it could have carried.
29
+ */
30
+ export async function reconstruct(sessionId, now = new Date()) {
31
+ const root = sessionDir(sessionId);
32
+ const ledger = await readPhaseLedger(sessionId);
33
+ const chapter = ledgerHead(ledger);
34
+ let session = {};
35
+ try {
36
+ session = JSON.parse(await fs.readFile(path.join(root, "session.json"), "utf8"));
37
+ }
38
+ catch {
39
+ // A session with no readable session.json still has artifacts worth reporting on.
40
+ }
41
+ const requirements = await readRequirementStates(root);
42
+ const rounds = (await readQuestionRounds(sessionId).catch(() => []))
43
+ .map((r) => ({ round: r.round, status: statusFor(r.questions), questions: r.questions.length }))
44
+ .sort((a, b) => a.round - b.round);
45
+ const bundles = readWaveBundlesSafely(path.join(root, "direct"));
46
+ const waves = bundles.map((b) => ({ wave: b.wave, verdict: b.verdict, practice: b.dryRunOnly }));
47
+ const waveGrade = bundles.length > 0 ? safeGrade(bundles) : null;
48
+ const hasBrief = existsSync(path.join(root, "analysis", "brief.md"));
49
+ const evidence = { hasBrief, requirements, rounds, waves };
50
+ const { resumeInto, reason } = decideResume(chapter, evidence);
51
+ return {
52
+ sessionId,
53
+ chapter,
54
+ ledger,
55
+ lastMovedAgo: describeStall(ledger, now),
56
+ resumeInto,
57
+ resumeReason: reason,
58
+ orgAlias: session.targetOrg?.alias ?? null,
59
+ orgId: session.targetOrg?.orgId ?? null,
60
+ batchId: session.batchId ?? null,
61
+ hasBrief,
62
+ requirements,
63
+ rounds,
64
+ waves,
65
+ waveGrade,
66
+ discrepancies: findDiscrepancies(chapter, evidence)
67
+ };
68
+ }
69
+ /**
70
+ * WHICH CHAPTER TO RESUME INTO — the ledger's chapter, RE-ENTERED rather than advanced past.
71
+ *
72
+ * The rule is one line and the reasoning is the whole point: a boundary is crossed when a chapter
73
+ * FINISHES, so the last entry says which chapter the run was IN when it died, and that chapter is
74
+ * by construction incomplete. Resuming into the next one would skip whatever was left of it.
75
+ *
76
+ * With no ledger at all, the position is inferred from artifacts and SAID TO BE an inference. The
77
+ * inference is deliberately CONSERVATIVE — it never lands on `delivery` or later, because the two
78
+ * states artifacts genuinely cannot distinguish (approved-and-paused versus rejected-and-abandoned)
79
+ * differ precisely in whether deploying is allowed, and the safe reading of that ambiguity is the
80
+ * one that re-asks. A resumed run that re-confirms an approval costs the operator one card; one
81
+ * that assumes an approval it never had deploys unapproved work.
82
+ */
83
+ export function decideResume(chapter, evidence) {
84
+ if (chapter !== null) {
85
+ return {
86
+ resumeInto: chapter,
87
+ reason: `the phase ledger records ${chapter} as entered and never completed — a boundary is crossed when a chapter FINISHES, so that chapter is where the run stopped, not the one after it`
88
+ };
89
+ }
90
+ const inferred = inferChapter(evidence);
91
+ return {
92
+ resumeInto: inferred,
93
+ reason: `this session has NO phase ledger, so ${inferred} is INFERRED from the artifacts on disk, not recorded. Treat it as a starting point to confirm with the operator rather than as a fact about the run`
94
+ };
95
+ }
96
+ /** Artifact-only inference, capped at `scope-approval`. See `decideResume` for why it stops there. */
97
+ function inferChapter(evidence) {
98
+ if (evidence.waves.length > 0)
99
+ return "scope-approval";
100
+ if (evidence.rounds.some((r) => r.status !== "ANSWERED"))
101
+ return "clarification";
102
+ if (evidence.rounds.length > 0)
103
+ return "clarification";
104
+ if (evidence.requirements.some((r) => r.hasPlan))
105
+ return "clarification";
106
+ if (evidence.hasBrief)
107
+ return "parallel-analysis";
108
+ if (evidence.requirements.length > 0)
109
+ return "intake";
110
+ return "activation";
111
+ }
112
+ /**
113
+ * Where the artifacts and the ledger disagree.
114
+ *
115
+ * REPORTED, NEVER RESOLVED. Both sources are honest and neither is authoritative over the other:
116
+ * the ledger records what the supervisor SAID it had finished, the artifacts record what actually
117
+ * reached disk, and a gap between them is a fact about the run — usually that it died between doing
118
+ * the work and announcing it. Picking a winner here would throw away exactly the signal a resumed
119
+ * session most needs, so each mismatch is named and left for the reader.
120
+ */
121
+ export function findDiscrepancies(chapter, evidence) {
122
+ const out = [];
123
+ if (chapter === null)
124
+ return out;
125
+ const at = chapterIndex(chapter);
126
+ const missingPlans = evidence.requirements.filter((r) => !r.hasPlan);
127
+ if (at > chapterIndex("parallel-analysis") && missingPlans.length > 0) {
128
+ out.push(`the ledger is past analysis, but ${missingPlans.length} requirement(s) have no plan.json: ${missingPlans.map((r) => r.extId).join(", ")}. Either those analyzers never finished, or the batch was analyzed inline — which is legal, and in that case their Configuration Item ownership must be passed explicitly at writeback.`);
129
+ }
130
+ const unmerged = evidence.requirements.filter((r) => r.unmergedFragments > 0);
131
+ if (unmerged.length > 0) {
132
+ out.push(`${unmerged.length} requirement(s) have UNMERGED plan fragments: ${unmerged.map((r) => `${r.extId} (${r.unmergedFragments})`).join(", ")}. plan_waves will refuse with PLAN_FRAGMENTS_UNRESOLVED until they are merged into the one canonical plan.json.`);
133
+ }
134
+ const open = evidence.rounds.filter((r) => r.status !== "ANSWERED");
135
+ if (open.length > 0) {
136
+ out.push(`${open.length} question round(s) are still open: ${open.map((r) => `round ${r.round} (${r.status})`).join(", ")}. The operator was asked and the answers were never recorded — re-put those questions before acting on any assumption about them.`);
137
+ }
138
+ if (at < chapterIndex("delivery") && evidence.waves.length > 0) {
139
+ out.push(`the ledger has not reached delivery, but ${evidence.waves.length} wave bundle(s) exist in the org's evidence directory. Waves reached the org before the ledger recorded the boundary — read their verdicts before planning anything new, because the org has already moved.`);
140
+ }
141
+ if (at >= chapterIndex("delivery") && evidence.waves.length === 0) {
142
+ out.push("the ledger reached delivery but no wave bundle exists, so nothing has reached the org. That is the state of a run approved and then interrupted before its first apply.");
143
+ }
144
+ return out;
145
+ }
146
+ async function readRequirementStates(root) {
147
+ const dir = path.join(root, "requirements");
148
+ let index = [];
149
+ try {
150
+ index = JSON.parse(await fs.readFile(path.join(dir, "index.json"), "utf8"));
151
+ }
152
+ catch {
153
+ // No index: fall back to the directories, which is what the requirement tree looks like anyway.
154
+ index = (await listDirs(dir)).map((extId) => ({ extId }));
155
+ }
156
+ const states = [];
157
+ for (const entry of index) {
158
+ const reqDir = path.join(dir, entry.extId);
159
+ let fragments = 0;
160
+ try {
161
+ fragments = (await fs.readdir(reqDir)).filter((n) => /^plan\.fragment-.+\.json$/.test(n)).length;
162
+ }
163
+ catch {
164
+ // A requirement directory that vanished is reported as having no plan, which it does not.
165
+ }
166
+ const hasPlan = existsSync(path.join(reqDir, "plan.json"));
167
+ states.push({
168
+ extId: entry.extId,
169
+ title: entry.title ?? entry.extId,
170
+ hasPlan,
171
+ // Fragments beside a canonical plan.json are ARCHIVE MATERIAL the supervisor should have
172
+ // moved, not an unresolved merge. Only fragments with no plan.json beside them are the state
173
+ // plan_waves refuses on, so only those are counted.
174
+ unmergedFragments: hasPlan ? 0 : fragments
175
+ });
176
+ }
177
+ return states.sort((a, b) => a.extId.localeCompare(b.extId));
178
+ }
179
+ function readWaveBundlesSafely(directDir) {
180
+ try {
181
+ return readWaveBundles(directDir);
182
+ }
183
+ catch {
184
+ return [];
185
+ }
186
+ }
187
+ function safeGrade(bundles) {
188
+ try {
189
+ return gradeWaves(bundles).result;
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ }
195
+ /**
196
+ * The reconstruction as prose the supervisor relays and acts on.
197
+ *
198
+ * Ordered by what a resumed run has to decide FIRST: where it stands, whether it can still touch
199
+ * the org, what it must not assume, and only then the inventory. The auth question leads the
200
+ * "can it act" half because it is the one that fails at the worst moment — a run that starts
201
+ * planning and discovers at wave one that its token died an hour ago has spent the operator's time
202
+ * on work it cannot deliver.
203
+ */
204
+ export function renderReconstruction(r, auth) {
205
+ const lines = [
206
+ `RESUMING SESSION ${r.sessionId}`,
207
+ "",
208
+ r.chapter
209
+ ? `WHERE THE RUN STANDS: ${r.chapter}${r.lastMovedAgo ? `, last moved ${r.lastMovedAgo}` : ""}.`
210
+ : "WHERE THE RUN STANDS: no phase ledger — this session predates chapter tracking, or never crossed a boundary.",
211
+ ` Re-enter: ${r.resumeInto} — ${r.resumeReason}.`,
212
+ ""
213
+ ];
214
+ lines.push("ORG ACCESS:", ` ${auth.summary}`);
215
+ if (!auth.usable) {
216
+ lines.push(" You cannot deploy, project or read the org until this is fixed, and you must NOT fix it yourself.", " Card the operator with exactly one ask, then stop. Do not plan around a dead org — a plan built", " on a projection you could not take is a plan built on the requirement text alone.");
217
+ }
218
+ lines.push("");
219
+ if (r.batchId) {
220
+ lines.push(auth.telemetryRebound
221
+ ? `EVIDENCE: this batch's grant is still valid, so this session ships its own events and transcript, and close_batch will close the batch normally.`
222
+ : `EVIDENCE: this machine has NO usable grant for batch ${r.batchId} any more, so nothing from this resumed session reaches the IN2 ledger — including close_batch. The delivery itself is unaffected; say so plainly rather than reporting a close that did not ship.`, "");
223
+ }
224
+ lines.push("WHAT IS ON DISK:", ` requirements: ${r.requirements.length}${r.requirements.length > 0 ? ` (${r.requirements.filter((q) => q.hasPlan).length} with a plan.json)` : ""}`, ` analysis brief: ${r.hasBrief ? "written" : "NOT written"}`, ` question rounds: ${r.rounds.length > 0 ? r.rounds.map((q) => `round ${q.round} ${q.status}`).join(", ") : "none"}`, ` waves delivered to the org: ${r.waves.length > 0 ? r.waves.map((w) => `${w.wave} ${w.verdict}${w.practice ? " (practice)" : ""}`).join(", ") : "none"}`, ...(r.waveGrade ? [` the bundles currently grade ${r.waveGrade} — that is what the evidence supports, NOT a batch that was closed`] : []), "");
225
+ if (r.requirements.length > 0) {
226
+ lines.push("REQUIREMENTS:", ...r.requirements.map((q) => ` ${q.extId} ${q.hasPlan ? "planned" : "NO plan.json"} ${q.title}`), "");
227
+ }
228
+ if (r.discrepancies.length > 0) {
229
+ lines.push("READ THESE BEFORE DOING ANYTHING — the ledger and the artifacts do not agree, and neither is", "authoritative over the other:", ...r.discrepancies.map((d) => ` - ${d}`), "");
230
+ }
231
+ lines.push("HOW TO PICK THIS UP:", " 1. Read the artifacts before re-deriving anything. The brief, the plans and the wave verdicts are", " this run's own decisions; re-deriving them produces a second, differently-worded batch model and", " silently discards what the operator already agreed to.", " 2. THE ORG HAS MOVED if any wave landed. Re-`project_org` the scope you are about to touch rather", " than trusting a projection taken before those waves.", " 3. Tell the operator where the run stands and what you are about to do, in one message, before you", " do it. They were present for the first half of this run and are owed the join.", " 4. NEVER re-confirm an approval you cannot see. An approval card lives in the transcript of the", " dead session, not on disk — if this run is at or past scope-approval and you did not watch them", " approve it, put the plan to them again.", " 5. Call `advance_phase` at each boundary from here on, and `close_batch` at the end. A resumed run", " that is never closed reads as ABANDONED exactly like the one it resumed.");
232
+ return lines.join("\n");
233
+ }
@@ -0,0 +1,351 @@
1
+ import { entryRef } from "./planFile.js";
2
+ export function deriveRevisionChains(entries, exists) {
3
+ const revisionEntries = [];
4
+ const routes = [];
5
+ for (const entry of entries) {
6
+ // A ROUTE is work leaving by another path — a Deployment Task, a facet update on someone else's
7
+ // component. It owns no authored file and deploys through no wave, so it is not a state of the
8
+ // component and cannot be a link in its chain. Counting it as one is how a one-node "cycle"
9
+ // gets manufactured (`waves.ts:203-208` fixed the same mistake on the ownership side).
10
+ //
11
+ // A DELETE IS NOT A ROUTE, however it is marked. It carries no file and rides the destructive
12
+ // path, which is what tempts an author to write `deployable: false` beside it — the live
13
+ // workaround did exactly that — but a route carries no ORDER, and the whole reason a deletion
14
+ // belongs in the chain is that it must follow whatever this batch does to the component first.
15
+ // So `delete` wins over `deployable` here, and the old habit lands in the right place.
16
+ (entry.intent === "delete" || entry.deployable ? revisionEntries : routes).push(entry);
17
+ }
18
+ const byKey = new Map();
19
+ for (const entry of revisionEntries) {
20
+ const group = byKey.get(entry.key) ?? [];
21
+ group.push(entry);
22
+ byKey.set(entry.key, group);
23
+ }
24
+ const reaches = requirementReachability(revisionEntries);
25
+ const existence = memoizedExistence(exists);
26
+ const chains = [];
27
+ const refusals = [];
28
+ const hazards = [];
29
+ const inferences = [];
30
+ for (const [key, group] of byKey) {
31
+ const first = group[0];
32
+ if (!first)
33
+ continue;
34
+ // 1. INTENT. Declared wins; otherwise infer from positive evidence only.
35
+ const resolved = [];
36
+ const unverifiable = [];
37
+ for (const entry of group) {
38
+ if (entry.intent !== null) {
39
+ resolved.push({ entry, intent: entry.intent, intentSource: "declared" });
40
+ continue;
41
+ }
42
+ const state = existence(first);
43
+ if (state === "unknown") {
44
+ unverifiable.push(entry);
45
+ continue;
46
+ }
47
+ const intent = state === "present" ? "update" : "create";
48
+ inferences.push({ key, extId: entry.extId, index: entry.index, intent, from: state });
49
+ resolved.push({ entry, intent, intentSource: "inferred" });
50
+ }
51
+ if (unverifiable.length > 0) {
52
+ // No chain for this key. An entry that could be either a create or an update leaves the
53
+ // ORDER undefined, not merely the entry — emitting a partial chain would order the rest
54
+ // around a revision whose position nobody established.
55
+ hazards.push({
56
+ code: "INTENT_UNVERIFIABLE",
57
+ reason: "intent",
58
+ key,
59
+ message: [
60
+ `${key}: ${unverifiable.length} entr${unverifiable.length === 1 ? "y" : "ies"} declare no intent, and this session has not observed the component, so none can be inferred.`,
61
+ "Declare `intent: \"create\" | \"update\"` on them, or run project_org over this component first.",
62
+ ...unverifiable.map((e) => ` ${e.extId} entry ${e.index}`)
63
+ ].join("\n"),
64
+ entries: unverifiable.map(entryRef)
65
+ });
66
+ continue;
67
+ }
68
+ const creates = resolved.filter((r) => r.intent === "create");
69
+ const updates = resolved.filter((r) => r.intent === "update");
70
+ const deletes = resolved.filter((r) => r.intent === "delete");
71
+ /** The revisions that leave a component behind — everything a delete would be destroying. */
72
+ const survivors = [...creates, ...updates];
73
+ // 2. ROOT. Exactly one, and only three things can be one (R4).
74
+ if (creates.length > 1) {
75
+ // The REAL ownership conflict, and the one the model deliberately keeps. Legalising
76
+ // create-then-update does not legalise creating the same component twice.
77
+ refusals.push({
78
+ code: "DOUBLE_CREATE",
79
+ key,
80
+ message: [
81
+ `${key} is created twice in this batch. A component has exactly one create; a later state of it is an \`update\`.`,
82
+ ...creates.map((c) => ` ${c.entry.extId} entry ${c.entry.index}: intent=create (${c.intentSource})${c.entry.authoredFile ? ` file=${c.entry.authoredFile}` : ""}`),
83
+ "If one of these is meant to be a later state of the component, declare it `intent: \"update\"`."
84
+ ].join("\n"),
85
+ entries: creates.map((c) => entryRef(c.entry))
86
+ });
87
+ continue;
88
+ }
89
+ // A component is destroyed once. Two requirements each declaring the deletion is not a chain
90
+ // with two terminal links, it is two requirements that have not agreed who owns the removal —
91
+ // and a destructive package naming the same member twice is what neither of them meant.
92
+ if (deletes.length > 1) {
93
+ refusals.push({
94
+ code: "DOUBLE_DELETE",
95
+ key,
96
+ message: [
97
+ `${key} is deleted twice in this batch. A component has at most one deletion, and nothing follows it.`,
98
+ ...deletes.map((d) => ` ${d.entry.extId} entry ${d.entry.index}: intent=delete (${d.intentSource})`),
99
+ "Decide which requirement owns the removal and drop the other entry; the second deletion has nothing left to delete."
100
+ ].join("\n"),
101
+ entries: deletes.map((d) => entryRef(d.entry))
102
+ });
103
+ continue;
104
+ }
105
+ const deletion = deletes[0];
106
+ const state = existence(first);
107
+ let root;
108
+ if (creates.length === 1) {
109
+ root = "create";
110
+ }
111
+ else if (survivors.length === 0) {
112
+ // Delete-only: nothing in this batch builds the component, so the ORG is what grounds it.
113
+ // A DIFFERENT refusal from the update case on purpose, because the remedies are opposites:
114
+ // an update with no target should probably have been a create, while a delete with no target
115
+ // is a component that is already gone and the fix is to drop the entry.
116
+ if (state === "absent") {
117
+ refusals.push({
118
+ code: "DELETE_WITHOUT_TARGET",
119
+ key,
120
+ message: [
121
+ `${key} is deleted in this batch, and this session's projection covered it and found it absent — there is nothing to delete.`,
122
+ "It is already gone, or the fullName is wrong. Drop the entry or correct the name; do not submit a destructive",
123
+ "package for a member the org does not have.",
124
+ ...deletes.map((d) => ` ${d.entry.extId} entry ${d.entry.index}: intent=delete (${d.intentSource})`)
125
+ ].join("\n"),
126
+ entries: deletes.map((d) => entryRef(d.entry))
127
+ });
128
+ continue;
129
+ }
130
+ root = "brownfield-delete";
131
+ }
132
+ else {
133
+ // Update-only: the root has to be grounded in the ORG. Only demonstrated absence refuses.
134
+ if (state === "absent") {
135
+ refusals.push({
136
+ code: "UPDATE_WITHOUT_ROOT",
137
+ key,
138
+ message: [
139
+ `${key} is only updated in this batch, and this session's projection covered it and found it absent — there is nothing to update.`,
140
+ "Either the first revision should be `intent: \"create\"`, or the component belongs to a requirement that is not in this batch.",
141
+ ...updates.map((u) => ` ${u.entry.extId} entry ${u.entry.index}: intent=update (${u.intentSource})`)
142
+ ].join("\n"),
143
+ entries: updates.map((u) => entryRef(u.entry))
144
+ });
145
+ continue;
146
+ }
147
+ root = "brownfield-update";
148
+ }
149
+ // 3. ORDER. Updates chain linearly, or the ambiguity is named rather than guessed at.
150
+ //
151
+ // Checked BEFORE the unverified-root hazard is emitted: a chain that gets refused here is not
152
+ // going to be planned, and a hazard asking the operator to widen the projection for it would be
153
+ // one more thing to read on the way to the refusal that actually blocks them.
154
+ const ordering = orderUpdates(updates, reaches);
155
+ if ("unordered" in ordering) {
156
+ refusals.push({
157
+ code: "UNORDERED_UPDATES",
158
+ key,
159
+ message: [
160
+ `${key} has ${updates.length} update revisions with no derivable order between them — nothing says which state comes first.`,
161
+ "Add a `dependsOn` edge so one requirement's revision follows the other's (the supervisor derives revision order",
162
+ "from cross-requirement dependencies, the same way it orders waves). Entries needing an edge:",
163
+ ...ordering.unordered.map((u) => ` ${u.entry.extId} entry ${u.entry.index}${u.entry.authoredFile ? ` file=${u.entry.authoredFile}` : ""}`)
164
+ ].join("\n"),
165
+ entries: ordering.unordered.map((u) => entryRef(u.entry))
166
+ });
167
+ continue;
168
+ }
169
+ // 4. NOTHING FOLLOWS A DELETE. The deletion is terminal by construction — placed last below —
170
+ // so a contradiction is only visible in the requirement graph: an entry whose requirement the
171
+ // batch's own dependencies put strictly AFTER the deleting requirement is asking to deploy a
172
+ // component that no longer exists. Named rather than silently resequenced, because either the
173
+ // edge or the deletion is wrong and only the author knows which.
174
+ if (deletion) {
175
+ const stranded = survivors.filter((r) => strictlyAfter(r, deletion, reaches));
176
+ if (stranded.length > 0) {
177
+ refusals.push({
178
+ code: "REVISION_AFTER_DELETE",
179
+ key,
180
+ message: [
181
+ `${key} is deleted by ${deletion.entry.extId}, and ${stranded.length} revision(s) of it are ordered AFTER that deletion by this batch's own dependencies.`,
182
+ "A component cannot be created or updated once it has been destroyed. Either the deletion belongs later than",
183
+ "these revisions (remove the dependency edge that puts them after it), or they belong to a component this batch",
184
+ "is not deleting. Entries stranded after the deletion:",
185
+ ...stranded.map((r) => ` ${r.entry.extId} entry ${r.entry.index}: intent=${r.intent} (${r.intentSource})`)
186
+ ].join("\n"),
187
+ entries: stranded.map((r) => entryRef(r.entry))
188
+ });
189
+ continue;
190
+ }
191
+ }
192
+ if (root !== "create" && state === "unknown") {
193
+ // The chain's ORDER is derivable; only whether anything grounds it is not. So the chain
194
+ // stands and the open question is surfaced beside it — the alternative is refusing every
195
+ // brownfield edit the supervisor has not happened to project, which is most of them.
196
+ const grounded = root === "brownfield-delete" ? deletes : updates;
197
+ hazards.push({
198
+ code: "INTENT_UNVERIFIABLE",
199
+ reason: "root",
200
+ key,
201
+ message: [
202
+ root === "brownfield-delete"
203
+ ? `${key} is deleted in this batch and this session has not observed it, so nothing confirms it exists to be deleted.`
204
+ : `${key} is only updated in this batch and this session has not observed it, so nothing confirms it exists to be updated.`,
205
+ root === "brownfield-delete"
206
+ ? "Run project_org over this component to confirm it. A destructive package naming a member the org does not have fails the whole package, taking every other member down with it."
207
+ : "Run project_org over this component to confirm it, or declare the first revision as `intent: \"create\"` if it does not exist yet.",
208
+ ...grounded.map((r) => ` ${r.entry.extId} entry ${r.entry.index}`)
209
+ ].join("\n"),
210
+ entries: grounded.map((r) => entryRef(r.entry))
211
+ });
212
+ }
213
+ // The deletion goes LAST, always. It is the one revision whose position needs no derivation:
214
+ // whatever else this batch does to the component, it does it before the component is gone.
215
+ const sequence = [...creates, ...ordering.ordered, ...(deletion ? [deletion] : [])];
216
+ const revisions = sequence.map((link, at) => ({
217
+ entry: link.entry,
218
+ intent: link.intent,
219
+ intentSource: link.intentSource,
220
+ // Predecessor by KEY: the batch's create, or the prior update in this chain (R2). No ordinals
221
+ // anywhere — analyzers author plan.json independently per requirement, so "who is @2" would
222
+ // demand cross-plan coordination nothing provides.
223
+ predecessor: at === 0 ? null : (sequence[at - 1]?.entry ?? null)
224
+ }));
225
+ chains.push({ key, typeName: first.typeName, fullName: first.fullName, root, existence: state, revisions });
226
+ }
227
+ return { chains, refusals, hazards, inferences, routes };
228
+ }
229
+ /**
230
+ * "Does `a`'s requirement come after `b`'s, and unambiguously so?"
231
+ *
232
+ * The `!reaches(b, a)` half is what makes it strict: in a requirement cycle both directions are
233
+ * reachable, and reading that as an order would sequence entries nothing sequenced. Two entries
234
+ * from the SAME requirement are never strictly after each other — one plan.json's own entries carry
235
+ * no cross-requirement edge to derive from.
236
+ */
237
+ const strictlyAfter = (a, b, reaches) => a.entry.extId !== b.entry.extId && reaches(a.entry.extId, b.entry.extId) && !reaches(b.entry.extId, a.entry.extId);
238
+ /**
239
+ * Put a key's update revisions in order, or say which ones cannot be ordered.
240
+ *
241
+ * `dependsOn` names COMPONENT KEYS, so two updates of the same key cannot point at each other
242
+ * directly — they share the key. What they can point at is each other's REQUIREMENTS: the
243
+ * supervisor already derives cross-requirement ordering by matching each `dependsOn` key to
244
+ * whichever requirement owns that component (the analyzer skill states this as the one dependency
245
+ * mechanism), and revision order is that same relation read per key.
246
+ *
247
+ * Two updates authored by the same requirement therefore have no derivable order, and neither do
248
+ * two whose requirements are unrelated. Both are named, not guessed at.
249
+ */
250
+ function orderUpdates(updates, reaches) {
251
+ if (updates.length < 2)
252
+ return { ordered: [...updates] };
253
+ const unordered = new Set();
254
+ const afterCount = new Map(updates.map((u) => [u, 0]));
255
+ for (let i = 0; i < updates.length; i++) {
256
+ for (let j = i + 1; j < updates.length; j++) {
257
+ const a = updates[i];
258
+ const b = updates[j];
259
+ if (!a || !b)
260
+ continue;
261
+ if (strictlyAfter(a, b, reaches)) {
262
+ afterCount.set(a, (afterCount.get(a) ?? 0) + 1);
263
+ }
264
+ else if (strictlyAfter(b, a, reaches)) {
265
+ afterCount.set(b, (afterCount.get(b) ?? 0) + 1);
266
+ }
267
+ else {
268
+ unordered.add(a);
269
+ unordered.add(b);
270
+ }
271
+ }
272
+ }
273
+ if (unordered.size > 0) {
274
+ return { unordered: updates.filter((u) => unordered.has(u)) };
275
+ }
276
+ return { ordered: [...updates].sort((a, b) => (afterCount.get(a) ?? 0) - (afterCount.get(b) ?? 0)) };
277
+ }
278
+ /**
279
+ * "Does requirement A come after requirement B?", transitively, from the batch's own dependsOn keys.
280
+ *
281
+ * Cycle-safe on purpose: a genuine requirement cycle is `plan_waves`' diagnostic to report, not
282
+ * this module's to crash on. A cycle simply makes both directions reachable, which `strictlyAfter`
283
+ * reads as no order — so the entries get named rather than silently sequenced.
284
+ */
285
+ function requirementReachability(entries) {
286
+ const owners = new Map();
287
+ for (const entry of entries) {
288
+ // A DELETION DOES NOT OWN THE COMPONENT FOR ORDERING PURPOSES. `dependsOn: X` means "X must
289
+ // exist first", and the requirement that answers that is the one that BUILDS X — a deletion is
290
+ // the opposite claim. Counting it here manufactures an edge from every requirement that depends
291
+ // on X to the requirement removing X, which then reads as "your create comes after my delete"
292
+ // and refuses a perfectly ordinary create-then-delete pair (caught by the cycle fixture in
293
+ // `tools/planWaves.test.ts`, where it swallowed a genuine cycle by refusing its chain first).
294
+ if (entry.intent === "delete")
295
+ continue;
296
+ const set = owners.get(entry.key) ?? new Set();
297
+ set.add(entry.extId);
298
+ owners.set(entry.key, set);
299
+ }
300
+ const edges = new Map();
301
+ for (const entry of entries) {
302
+ const out = edges.get(entry.extId) ?? new Set();
303
+ for (const dep of entry.dependsOn) {
304
+ // An entry depending on its OWN key is the late-facet shape (`waves.ts:106-118` strips
305
+ // exactly this today), and under the revision model it reads "after the previous revision of
306
+ // me" — a statement about the chain, not about requirements. Feeding it to the requirement
307
+ // graph is worse than useless: with three revisions of one key it points at every OTHER
308
+ // owner at once, making two updates mutually reachable and destroying an order that other
309
+ // edges did establish.
310
+ if (dep === entry.key)
311
+ continue;
312
+ for (const owner of owners.get(dep) ?? []) {
313
+ if (owner !== entry.extId)
314
+ out.add(owner);
315
+ }
316
+ }
317
+ edges.set(entry.extId, out);
318
+ }
319
+ const closures = new Map();
320
+ const closure = (node) => {
321
+ const done = closures.get(node);
322
+ if (done)
323
+ return done;
324
+ const reached = new Set();
325
+ closures.set(node, reached); // seeded before recursing, so a cycle terminates
326
+ const stack = [...(edges.get(node) ?? [])];
327
+ while (stack.length > 0) {
328
+ const next = stack.pop();
329
+ if (next === undefined || reached.has(next))
330
+ continue;
331
+ reached.add(next);
332
+ for (const onward of edges.get(next) ?? [])
333
+ if (!reached.has(onward))
334
+ stack.push(onward);
335
+ }
336
+ return reached;
337
+ };
338
+ return (from, to) => closure(from).has(to);
339
+ }
340
+ /** One predicate call per key — the caller's may read a projection off disk. */
341
+ function memoizedExistence(exists) {
342
+ const cache = new Map();
343
+ return (component) => {
344
+ const hit = cache.get(component.key);
345
+ if (hit !== undefined)
346
+ return hit;
347
+ const state = exists({ typeName: component.typeName, fullName: component.fullName, key: component.key });
348
+ cache.set(component.key, state);
349
+ return state;
350
+ };
351
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Escaping and decoding for Salesforce RICH TEXT fields.
3
+ *
4
+ * A rich-text field stores HTML, and Salesforce runs a sanitizer over it on save. An unescaped `<`
5
+ * in a VALUE is read as the start of a tag, and everything through the next `>` is dropped as
6
+ * markup — silently. The write returns success; the stored text is short a clause.
7
+ *
8
+ * LIVE-CAUGHT 2026-08-02 (PV corpus, Arm A, REQ-15736): a deployment note carrying the validation
9
+ * rule `PV_Value__c <= 0` lost the formula's tail AND the `<br/>` that followed it. Nothing in the
10
+ * API response said so. It was found by the supervisor's read-back mismatch, and because
11
+ * Deployment_Notes__c APPENDS, it could not be repaired by re-running — a correction block had to be
12
+ * appended by hand.
13
+ *
14
+ * Salesforce config work is exactly the content that trips this: validation-rule formulas, SOQL
15
+ * comparisons, `&&`, and raw metadata XML are what a deployment note is FOR. So every caller-supplied
16
+ * string that becomes part of a rich-text body goes through `escapeRichText` here, and the markup the
17
+ * tools add (`<b>`, `<br/>`) is composed afterwards, around the escaped text.
18
+ *
19
+ * This lives in core/ rather than beside its callers because both a writer (tools/campfire.ts) and a
20
+ * reader (tools/session.ts) need the decoder, and the two must agree — a value that escapes one way
21
+ * and decodes another is a read-back mismatch waiting to happen.
22
+ */
23
+ /**
24
+ * Caller text -> safe rich-text content.
25
+ *
26
+ * `&` is replaced FIRST, or the rule would re-escape the ampersands the `<`/`>` rules just
27
+ * introduced and `a < b` would store as `a &amp;lt; b` — visible entity soup instead of a `<`.
28
+ *
29
+ * Only these three. `"` and `'` are legal in HTML text content and reach the field intact (the Arm A
30
+ * report noted quotes and `->` surviving), and escaping them only makes the stored note harder for a
31
+ * human to read in Campfire. They are still DECODED below, because Salesforce may normalize them on
32
+ * its own — escape narrowly, decode generously.
33
+ */
34
+ export function escapeRichText(value) {
35
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
36
+ }
37
+ const ENTITIES = {
38
+ amp: "&",
39
+ lt: "<",
40
+ gt: ">",
41
+ quot: '"',
42
+ apos: "'",
43
+ "#39": "'",
44
+ nbsp: " "
45
+ };
46
+ /**
47
+ * Stored rich text -> the characters it means.
48
+ *
49
+ * ONE pass, deliberately. A chain of `.replace(/&amp;/, "&")` then `.replace(/&lt;/, "<")` decodes
50
+ * twice: a stored `&amp;lt;` (which MEANS the literal five characters `&lt;`) loses its ampersand to
51
+ * the first rule and is then eaten by the second, arriving as `<`. That collapses the difference
52
+ * between a value containing `<` and one containing the text `&lt;` — the exact distinction the
53
+ * read-back check exists to police, so a real corruption could compare equal and pass.
54
+ */
55
+ export function decodeHtmlEntities(value) {
56
+ return value.replace(/&(amp|lt|gt|quot|apos|#39|nbsp);/gi, (match, name) => ENTITIES[name.toLowerCase()] ?? match);
57
+ }
58
+ /** Caller text -> a rich-text fragment: escaped, with newlines as the line breaks the field renders. */
59
+ export function richTextFragment(text) {
60
+ return escapeRichText(text).replace(/\n/g, "<br/>");
61
+ }