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,556 @@
1
+ // Wave assembly — gather the files analyzers authored into the one tree a wave deploys.
2
+ //
3
+ // This is `assemble_wave`'s machinery (tools/assemble.ts) as a module, so that direct_lint and
4
+ // direct_apply can each run it as an internal step instead of the supervisor calling a separate
5
+ // tool and hoping the tree it assembled is still the tree that gets deployed. Everything that made
6
+ // the tool safe moves with identical codes and identical detection — COMPONENT_NOT_OWNED,
7
+ // COMPONENT_STILL_PROVISIONAL, NOTHING_TO_ASSEMBLE, the two `missing` reasons, recovery-from-disk,
8
+ // path safety, the content-file sibling copy, prune-on-rebuild.
9
+ //
10
+ // TWO THINGS ARE DELIBERATELY DIFFERENT, and both come from the component revision model:
11
+ //
12
+ // 1. A component may have MORE THAN ONE state in a batch. `OWNERSHIP_CONFLICT` and
13
+ // `COMPONENT_AUTHORED_TWICE` used to read every second file for a key as an error, because
14
+ // nothing could say which file belonged in which wave. The wave request now carries that
15
+ // knowledge as a `path` discriminator, so the refusals narrow to what is still genuinely
16
+ // wrong: two requirements CREATING one component, and one wave that would take two files.
17
+ //
18
+ // 2. The wave tree is a BUILD ARTIFACT. Every call rebuilds `<session>/authored/<wave>` from the
19
+ // authored files, so `WAVE_EXISTS` is gone: there is no edit in there worth protecting,
20
+ // because a wave-specific fix belongs in the revision's own authored file — which is the
21
+ // thing that lints, the thing plan.json binds, and the thing the next call would copy over
22
+ // anyway. Rebuilding is also what closes the lint-to-apply window: apply lints exactly what
23
+ // it just assembled, in the same call.
24
+ import path from "node:path";
25
+ import { promises as fs } from "node:fs";
26
+ import { fileExists, readJson, sessionDir } from "../core/workspace.js";
27
+ import { parsePlanFile } from "../core/planFile.js";
28
+ import { resolveSourcePath } from "./applyLoop.js";
29
+ import { resolveComponentFromRelPath } from "./sourceTree.js";
30
+ const WAVE_RE = /^[A-Za-z0-9_-]{1,40}$/;
31
+ export const componentKey = (typeName, fullName) => `${typeName}:${fullName}`;
32
+ /**
33
+ * Resolve an analyzer-declared relative path inside its own requirement directory.
34
+ *
35
+ * `authoredFile` is written by a subagent into a JSON file, so it is untrusted input reaching a
36
+ * filesystem API. Without this, `"../../../../.ssh/authorized_keys"` would resolve happily and then
37
+ * be COPIED somewhere by the assembler. Absolute paths are rejected for the same reason. This is the
38
+ * one place the value crosses into a path, so it is the one place that has to check.
39
+ */
40
+ export function resolveAuthored(requirementDir, authoredFile) {
41
+ if (!authoredFile || path.isAbsolute(authoredFile))
42
+ return null;
43
+ const root = path.resolve(requirementDir, "authored");
44
+ const resolved = path.resolve(root, authoredFile);
45
+ return resolved === root || resolved.startsWith(`${root}${path.sep}`) ? resolved : null;
46
+ }
47
+ const normRel = (value) => value.split(/[\\/]/).filter(Boolean).join("/");
48
+ // `direct/` holds no `tools/` imports, so this is the same two-line read as tools/session.ts's
49
+ // `loadRequirementsIndex`, narrowed to the one field assembly uses.
50
+ async function requirementExtIds(sessionId) {
51
+ const file = path.join(sessionDir(sessionId), "requirements", "index.json");
52
+ if (!fileExists(file))
53
+ return [];
54
+ const index = await readJson(file);
55
+ return index.map((entry) => entry.extId).filter((extId) => typeof extId === "string");
56
+ }
57
+ // Reads through U1's canonical parser — the one reader of this schema (`core/planFile.ts`).
58
+ // `null` — no readable plan.json, an unparseable one, or one with no components[] key — is
59
+ // distinct from an EMPTY components[]: an empty list still means a requirement was analyzed and
60
+ // claims nothing, which is exactly the state the orphan-file refusal exists to catch. parsePlanFile
61
+ // treats a missing components key as a legal empty plan, so the key's absence is checked on the
62
+ // parsed document to keep that distinction. A malformed plan maps to null exactly as the old
63
+ // direct JSON.parse did — assembly cannot fix a broken plan, only decline to read ownership from
64
+ // it (resolve_blocker is where a broken plan gets repaired).
65
+ async function readPlanEntries(requirementDir) {
66
+ const text = await fs.readFile(path.join(requirementDir, "plan.json"), "utf8").catch(() => null);
67
+ if (text === null)
68
+ return null;
69
+ const parsed = parsePlanFile(text, path.basename(requirementDir));
70
+ if (!parsed.ok || parsed.plan.document.components === undefined)
71
+ return null;
72
+ return parsed.plan.entries.map((e) => ({
73
+ typeName: e.typeName,
74
+ fullName: e.fullName,
75
+ ...(e.authoredFile !== null ? { authoredFile: e.authoredFile } : {}),
76
+ ...(e.intent !== null ? { intent: e.intent } : {}),
77
+ deployable: e.deployable,
78
+ provisional: e.provisional,
79
+ ...(e.blockedBy !== null ? { blockedBy: e.blockedBy } : {})
80
+ }));
81
+ }
82
+ /**
83
+ * Where a bound file lands in the wave tree.
84
+ *
85
+ * Derived from the RESOLVED COMPONENT — the registry's type directory plus the conventional
86
+ * filename — never copied from the source path. Two shapes make that load-bearing:
87
+ *
88
+ * - A second revision's authored file has to sit somewhere its sibling revision does not, and the
89
+ * natural move is a subdirectory (the EV run's `ci05-update/objects/…`). Copied verbatim that
90
+ * prefix survives into the wave tree, where the walker does not recognise it — `sourceTree.ts`
91
+ * reads the FIRST segment as a type directory (sourceTree.ts:25-31) — so the revision assembles,
92
+ * lints as nothing, and deploys nothing, reporting success the whole way.
93
+ * - An `authoredFile` whose basename disagrees with `fullName` makes the wave tree name a
94
+ * DIFFERENT component than the plan owns, because identity in that tree comes from the path.
95
+ *
96
+ * Deriving forward makes the destination a function of identity, so both shapes land where the
97
+ * walker will find them, under the name plan.json claims. A type the forward resolver cannot place
98
+ * (bundles, unknown types) keeps its source-relative path — today's behaviour, unchanged.
99
+ */
100
+ function destinationFor(typeName, fullName, srcRel) {
101
+ const resolved = resolveSourcePath({ typeName, fullName, xml: "", content: "" });
102
+ if ("files" in resolved) {
103
+ const meta = resolved.files.find((f) => f.from === "xml");
104
+ if (meta)
105
+ return meta.relPath;
106
+ }
107
+ // A decomposed child that deploys inside its parent's container file (Workflow's WorkflowAlert,
108
+ // SharingRules' rules): the authored file IS the container, and that is where it belongs.
109
+ if ("container" in resolved)
110
+ return resolved.container.containerPath;
111
+ return normRel(srcRel);
112
+ }
113
+ // Index every authored component across all plans. Built from plan.json rather than by walking the
114
+ // authored directories, because ownership is what plan.json exists to state — a stray file nobody
115
+ // claims must not silently make it into a deploy.
116
+ async function indexOwnership(sessionId, extIds) {
117
+ const root = sessionDir(sessionId);
118
+ const out = {
119
+ bound: new Map(),
120
+ notAuthored: new Map(),
121
+ diskAll: new Map(),
122
+ provisional: new Map(),
123
+ recovered: [],
124
+ badPaths: []
125
+ };
126
+ for (const extId of extIds) {
127
+ const reqDir = path.join(root, "requirements", extId);
128
+ const entries = await readPlanEntries(reqDir);
129
+ if (!entries)
130
+ continue;
131
+ // Everything on disk under this requirement's authored/ tree, indexed by the component it
132
+ // resolves to. This is the recovery path for a plan.json that names a component but never
133
+ // recorded its `authoredFile` — which is what the SUPERVISOR produces when it authors the
134
+ // components analyzers withheld, and it made assembly report "no analyzer authored it" about
135
+ // files sitting at the exact expected path (CF77). Built from the same reverse mapping
136
+ // direct_lint uses, so it needs no per-type forward path table to drift out of date.
137
+ const authoredRoot = path.join(reqDir, "authored");
138
+ const onDisk = new Map();
139
+ const indexDisk = async (dir) => {
140
+ for (const e of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
141
+ const full = path.join(dir, e.name);
142
+ if (e.isDirectory()) {
143
+ await indexDisk(full);
144
+ continue;
145
+ }
146
+ const rel = path.relative(authoredRoot, full);
147
+ const resolved = resolveComponentFromRelPath(rel);
148
+ if (!resolved)
149
+ continue;
150
+ const key = componentKey(resolved.typeName, resolved.fullName);
151
+ onDisk.set(key, rel);
152
+ out.diskAll.set(key, { extId, rel });
153
+ }
154
+ };
155
+ await indexDisk(authoredRoot);
156
+ for (const entry of entries) {
157
+ if (!entry.typeName || !entry.fullName)
158
+ continue;
159
+ // A DELETION OWNS NO FILE, so it takes no part in assembly. `core/planFile.ts` already refuses
160
+ // an `intent: "delete"` entry that BINDS a file, but the recovery path below would otherwise
161
+ // find one anyway: a delete entry has no `authoredFile`, which sends it to the on-disk lookup,
162
+ // and a stray file for that key under the same requirement would be adopted as its state and
163
+ // COPIED INTO THE WAVE — deploying the component the entry exists to remove. The deletion is
164
+ // ordered by plan_waves and executed through direct_apply's `deletes`; it never has a wave slot.
165
+ if (entry.intent === "delete")
166
+ continue;
167
+ const key = componentKey(entry.typeName, entry.fullName);
168
+ let rel = entry.authoredFile;
169
+ if (!rel) {
170
+ const found = onDisk.get(key);
171
+ if (!found) {
172
+ out.notAuthored.set(key, extId);
173
+ continue;
174
+ }
175
+ // Recovered. Say so — plan.json is what Configuration Item ownership is derived from, so a
176
+ // silent recovery would leave the writeback attributing it to nobody.
177
+ out.recovered.push(`${key} (${extId}) found at authored/${found} — record it as authoredFile in plan.json`);
178
+ rel = found;
179
+ }
180
+ const src = resolveAuthored(reqDir, rel);
181
+ if (!src) {
182
+ out.badPaths.push(`${extId} -> ${rel}`);
183
+ continue;
184
+ }
185
+ // PROVISIONAL: authored against a blocker the operator has not answered yet.
186
+ //
187
+ // This gate is what makes authoring-while-blocked safe, and it replaces the old rule that
188
+ // forbade authoring at all. That rule pushed a component's whole design into draftNotes PROSE,
189
+ // which the lint ladder cannot reach — live near-miss: a flow formula written as
190
+ // `{!$Record.CF78_Category__c}='Process'` with no TEXT() wrapper would have failed at deploy,
191
+ // and was caught only because the supervisor happened to know the rule. A provisional FILE is
192
+ // linted like any other; it just cannot be deployed until resolve_blocker promotes it.
193
+ if (entry.provisional) {
194
+ const held = out.provisional.get(key) ?? [];
195
+ held.push({ rel, note: `${extId} (blocker ${String(entry.blockedBy ?? "?")})` });
196
+ out.provisional.set(key, held);
197
+ continue;
198
+ }
199
+ const files = out.bound.get(key) ?? [];
200
+ // The same file listed twice is an idempotent restatement, not a second revision.
201
+ if (files.some((f) => f.extId === extId && normRel(f.rel) === normRel(rel)))
202
+ continue;
203
+ files.push({ key, typeName: entry.typeName, fullName: entry.fullName, extId, intent: entry.intent, rel, src });
204
+ out.bound.set(key, files);
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+ const describeFile = (f) => `${f.extId}: ${f.rel}${f.intent ? ` (intent: ${f.intent})` : ""}`;
210
+ /**
211
+ * Two requirements claiming one key.
212
+ *
213
+ * Under the revision model this is only an ownership ERROR when they both CREATE the component —
214
+ * a create in one requirement and an update in another is the shape the model exists to express,
215
+ * and refusing it is what forced the EV run to hand-copy files past every guard. Intent has to be
216
+ * DECLARED for that: an undeclared pair is indistinguishable from a double create, and resolving it
217
+ * silently is how a duplicated Configuration Item reaches the writeback. So unknown intent still
218
+ * contests, exactly as today, and the refusal says which case it is.
219
+ */
220
+ function ownershipConflicts(keys, bound) {
221
+ const out = [];
222
+ for (const key of keys) {
223
+ const files = bound.get(key) ?? [];
224
+ const claimants = [...new Set(files.map((f) => f.extId))];
225
+ if (claimants.length < 2)
226
+ continue;
227
+ const creates = files.filter((f) => f.intent === "create");
228
+ if (creates.length > 1) {
229
+ out.push(`${key} — CREATED by ${[...new Set(creates.map((c) => c.extId))].join(" and ")}; a component is created once`);
230
+ continue;
231
+ }
232
+ if (files.some((f) => !f.intent)) {
233
+ out.push(`${key} — claimed by ${claimants.join(" and ")}, and no entry declares an intent, so this cannot be told apart from two creates`);
234
+ }
235
+ }
236
+ return out;
237
+ }
238
+ /**
239
+ * Rebuild `<session>/authored/<wave>` from the files the requirements authored, and report what
240
+ * landed there.
241
+ *
242
+ * Refusals come back as data — `{ok: false, code, message}` with the codes the tool used — so the
243
+ * calling tool renders them through its own `fail()`. A malformed sessionId still throws
244
+ * `WorkspaceError`, exactly as every other workspace read does.
245
+ *
246
+ * Partial success is deliberate: a wave whose components are only partly authored assembles what it
247
+ * has and NAMES the rest in `missing`. The refusals are reserved for the states where continuing
248
+ * would deploy something nobody chose.
249
+ */
250
+ export async function assembleWave(input) {
251
+ const { sessionId, wave, components } = input;
252
+ if (!WAVE_RE.test(wave)) {
253
+ return { ok: false, code: "INVALID_WAVE", message: `wave must match ${WAVE_RE} (it becomes a directory name).` };
254
+ }
255
+ const root = sessionDir(sessionId);
256
+ const extIds = await requirementExtIds(sessionId);
257
+ if (extIds.length === 0) {
258
+ return { ok: false, code: "NO_REQUIREMENTS", message: `Session ${sessionId} has no imported requirements.` };
259
+ }
260
+ const index = await indexOwnership(sessionId, extIds);
261
+ const wanted = components.map((c) => ({ ...c, key: componentKey(c.typeName, c.fullName) }));
262
+ const wantedKeys = [...new Set(wanted.map((w) => w.key))];
263
+ // Ownership first: a cross-requirement double claim is wrong regardless of which file this
264
+ // particular wave would have taken, and saying so beats reporting the ambiguity it causes.
265
+ const conflicting = ownershipConflicts(wantedKeys, index.bound);
266
+ if (conflicting.length > 0) {
267
+ return {
268
+ ok: false,
269
+ code: "OWNERSHIP_CONFLICT",
270
+ message: [
271
+ "Two requirements claim the same component, and this is not a create-then-update pair:",
272
+ ...conflicting.map((c) => ` ${c}`),
273
+ "",
274
+ "A component is CREATED once. If the second requirement CHANGES what the first creates, say so:",
275
+ 'give that entry intent: "update" and its own authoredFile, and the two become ordered revisions',
276
+ "of one component. Otherwise fix the ownership in plan.json — one requirement owns it, the other",
277
+ "lists it under dependsOn."
278
+ ].join("\n")
279
+ };
280
+ }
281
+ // Then the per-wave binding. Each requested component must resolve to exactly ONE file: the wave
282
+ // tree is walked by component key and walkSourceTree merges two files for one key silently
283
+ // (sourceTree.ts:100-115), so a second file here does not become a second component — it becomes
284
+ // whichever half the walker happened to read last.
285
+ const planned = [];
286
+ const doubled = [];
287
+ const missing = [];
288
+ const blockedByAnswer = [];
289
+ const orphanFiles = [];
290
+ for (const w of wanted) {
291
+ const files = index.bound.get(w.key) ?? [];
292
+ const candidates = w.path ? files.filter((f) => normRel(f.rel) === normRel(w.path)) : files;
293
+ if (candidates.length > 1) {
294
+ doubled.push(`${w.key} — ${candidates.map(describeFile).join(", ")}`);
295
+ continue;
296
+ }
297
+ const hit = candidates[0];
298
+ if (!hit) {
299
+ // The blocker gate reads the same discriminator: a wave naming one revision is told about
300
+ // THAT revision's blocker, not about whichever entry for the key happened to be provisional.
301
+ const held = index.provisional.get(w.key) ?? [];
302
+ const heldHere = w.path ? held.filter((h) => normRel(h.rel) === normRel(w.path)) : held;
303
+ if (heldHere.length > 0) {
304
+ blockedByAnswer.push(`${w.key} — authored by ${heldHere.map((h) => h.note).join(", ")}, still awaiting the operator's answer`);
305
+ continue;
306
+ }
307
+ if (w.path) {
308
+ missing.push({
309
+ key: w.key,
310
+ reason: files.length > 0
311
+ ? `no plan entry binds ${normRel(w.path)} — this key's authored files are ${files.map(describeFile).join(", ")}`
312
+ : `this wave asks for the revision at ${normRel(w.path)}, but no plan.json entry for this component records an authoredFile`
313
+ });
314
+ continue;
315
+ }
316
+ const orphan = index.diskAll.get(w.key);
317
+ if (orphan) {
318
+ // A FILE EXISTS and no plan.json claims the component. Assembling it would deploy fine and
319
+ // then strand the writeback: Configuration Item ownership is derived from plan.json, and
320
+ // campfire_write_config_items refuses the WHOLE batch on an unmapped row — so the cost lands
321
+ // after every wave is live in the org, where the fix is archaeology. Refusing here costs one
322
+ // edit to a plan file.
323
+ orphanFiles.push(`${w.key} — file at requirements/${orphan.extId}/authored/${orphan.rel}, but ${orphan.extId}'s plan.json does not list it in components[]`);
324
+ continue;
325
+ }
326
+ const plannedBy = index.notAuthored.get(w.key);
327
+ missing.push({
328
+ key: w.key,
329
+ reason: plannedBy
330
+ ? `${plannedBy}'s plan.json lists this component but no file for it exists under requirements/${plannedBy}/authored/ — the analyzer left it unauthored (blocked or not deployable), or it still has to be written`
331
+ : "no plan.json in this session claims this component — check the typeName/fullName spelling, or that the analyzer for its requirement has run"
332
+ });
333
+ continue;
334
+ }
335
+ // CONTENT-FILE SIBLING: in source format a content-file type is a PAIR — Name.email-meta.xml
336
+ // beside Name.email (same for .resource, Document bodies). The plan records only the -meta.xml
337
+ // as authoredFile, and copying just that half made direct_apply refuse the wave with "provide a
338
+ // content value" while the analyzer's correctly-authored body sat one directory away
339
+ // (live-caught SM301 w2; the supervisor hand-copied the bodies, which the next re-assemble would
340
+ // then have dropped again).
341
+ const destRel = destinationFor(hit.typeName, hit.fullName, hit.rel);
342
+ const copy = { ...hit, destRel };
343
+ if (hit.src.endsWith("-meta.xml") && destRel.endsWith("-meta.xml")) {
344
+ const sibling = hit.src.slice(0, -"-meta.xml".length);
345
+ if (fileExists(sibling)) {
346
+ copy.contentSrc = sibling;
347
+ copy.destContentRel = destRel.slice(0, -"-meta.xml".length);
348
+ }
349
+ }
350
+ planned.push(copy);
351
+ }
352
+ if (doubled.length > 0) {
353
+ return {
354
+ ok: false,
355
+ code: "COMPONENT_AUTHORED_TWICE",
356
+ message: [
357
+ "More than one authored file is bound to the same component in ONE wave. A wave carries at most",
358
+ "one revision of a component — the wave tree is read back by component key, and two files for one",
359
+ "key merge silently rather than becoming two components.",
360
+ ...doubled.map((d) => ` ${d}`),
361
+ "",
362
+ "If these are two states of the component (an object re-emitted to carry a compactLayoutAssignment,",
363
+ 'a field whose values a later requirement adjusts), declare them as two revisions: one entry per',
364
+ 'file in plan.json, each with its own authoredFile and an intent of "create" then "update".',
365
+ "plan_waves then orders them into different waves and names the bound path on each wave entry.",
366
+ "If they are the same state, keep one entry."
367
+ ].join("\n")
368
+ };
369
+ }
370
+ const dest = path.join(root, "authored", wave);
371
+ // REFUSE BEFORE WRITING. These two refusals used to sit below the prune-and-copy, faithfully
372
+ // ported from the old assemble tool — where the tree was advisory. Under direct_apply the tree
373
+ // IS what a `{sessionId, wave}` call deploys, so a refusal that has already rebuilt the tree
374
+ // hands the next shortcut a wave minus its refused component. Everything below this line may
375
+ // mutate the tree; everything that can say no has already said it.
376
+ if (blockedByAnswer.length > 0) {
377
+ return {
378
+ ok: false,
379
+ code: "COMPONENT_STILL_PROVISIONAL",
380
+ message: [
381
+ `${blockedByAnswer.length} component(s) in this wave are authored but PROVISIONAL — the operator has not answered the blocker they depend on:`,
382
+ ...blockedByAnswer.map((b) => ` ${b}`),
383
+ "",
384
+ "Refusing to assemble. The file exists and has been linted, which is the point — but deploying",
385
+ "it would ship a design nobody approved.",
386
+ "",
387
+ "Once the operator answers: resolve_blocker {sessionId, extId, blockerId, answer, promote: [\"<key>\"]}",
388
+ "to confirm the authored variant, or edit the file first if they chose differently, or",
389
+ "drop: [\"<key>\"] if the answer eliminated the component."
390
+ ].join("\n")
391
+ };
392
+ }
393
+ if (orphanFiles.length > 0) {
394
+ return {
395
+ ok: false,
396
+ code: "COMPONENT_NOT_OWNED",
397
+ message: [
398
+ `${orphanFiles.length} component(s) in this wave have an authored file that NO requirement's plan.json claims:`,
399
+ ...orphanFiles.map((o) => ` ${o}`),
400
+ "",
401
+ "Refusing to assemble. These would deploy correctly and then fail the Campfire writeback,",
402
+ "which derives Configuration Item ownership from plan.json and rejects the whole batch on an",
403
+ "unmapped row — so the error would surface only after every wave is already live in the org.",
404
+ "",
405
+ "Add each component to the owning requirement's plan.json components[] (with its",
406
+ "authoredFile), then assemble again."
407
+ ].join("\n")
408
+ };
409
+ }
410
+ // RECONCILE, don't merely add. The wave directory is exactly what direct_apply deploys, so a file
411
+ // left behind from a previous assemble is a component nobody approved going to the org.
412
+ // Live-caught CF75: the operator dropped a FlexiPage, the wave was re-assembled, and the stale
413
+ // flexipages/ directory survived — direct_lint still linted 7 components and still flagged it. It
414
+ // was noticed only because a count looked wrong.
415
+ //
416
+ // The keep-set is keyed per BOUND FILE, not one path per key: with revisions, the same key can be
417
+ // assembled from a different file next call, and a keep-set that remembered only "this key's
418
+ // path" would happily delete the file the wave actually binds.
419
+ //
420
+ // This now runs on EVERY call, where it used to run only under `overwrite`. The tree is a build
421
+ // artifact — nothing in it is authored, so there is nothing in it to protect. A wave-specific fix
422
+ // belongs in the revision's own authored file, which is what lints and what plan.json binds.
423
+ const keep = new Set();
424
+ for (const p of planned) {
425
+ keep.add(p.destRel);
426
+ if (p.destContentRel)
427
+ keep.add(p.destContentRel);
428
+ }
429
+ const pruned = [];
430
+ const walk = async (dir) => {
431
+ const out = [];
432
+ for (const e of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
433
+ const full = path.join(dir, e.name);
434
+ if (e.isDirectory())
435
+ out.push(...(await walk(full)));
436
+ else
437
+ out.push(normRel(path.relative(dest, full)));
438
+ }
439
+ return out;
440
+ };
441
+ if (fileExists(dest)) {
442
+ for (const rel of await walk(dest)) {
443
+ if (keep.has(rel))
444
+ continue;
445
+ await fs.rm(path.join(dest, ...rel.split("/")), { force: true }).catch(() => { });
446
+ pruned.push(rel);
447
+ }
448
+ }
449
+ const copied = [];
450
+ const unreadable = [];
451
+ for (const p of planned) {
452
+ const target = path.join(dest, ...p.destRel.split("/"));
453
+ try {
454
+ await fs.mkdir(path.dirname(target), { recursive: true });
455
+ // Copy, not move: the analyzer's output stays intact, so a re-assemble after a botched edit is
456
+ // always possible.
457
+ await fs.copyFile(p.src, target);
458
+ copied.push(`${p.destRel} (${p.extId})`);
459
+ if (p.contentSrc && p.destContentRel) {
460
+ await fs.copyFile(p.contentSrc, path.join(dest, ...p.destContentRel.split("/")));
461
+ copied.push(`${p.destContentRel} (${p.extId}, content body)`);
462
+ }
463
+ }
464
+ catch (error) {
465
+ unreadable.push(`${p.key}: ${error instanceof Error ? error.message : String(error)}`);
466
+ }
467
+ }
468
+ if (copied.length === 0) {
469
+ return {
470
+ ok: false,
471
+ code: "NOTHING_TO_ASSEMBLE",
472
+ message: [
473
+ `None of the ${wanted.length} requested component(s) had authored files.`,
474
+ "Either the analyzers have not run, or every component in this wave is blocked and you",
475
+ "must author it yourself. Nothing was written.",
476
+ ...missing.slice(0, 10).map((m) => ` ${m.key} — ${m.reason}`)
477
+ ].join("\n")
478
+ };
479
+ }
480
+ // An approved wave is a SCOPE the operator consented to, and "assembled N of M" used to return
481
+ // as a success — direct_apply then linted, checked and DEPLOYED the subset in the same call, so
482
+ // the report's "author these yourself" advice arrived after the deploy it was advising about,
483
+ // and the org held part of a wave the operator approved whole. Partial delivery is a decision,
484
+ // not a default: it needs the caller to say so.
485
+ if ((missing.length > 0 || unreadable.length > 0) && !input.ackIncomplete) {
486
+ await fs.rm(dest, { recursive: true, force: true });
487
+ // Sliced the same way NOTHING_TO_ASSEMBLE's list above is, for the same reason: a wave missing
488
+ // hundreds of components should not paste hundreds of lines into a refusal. The headline already
489
+ // states both counts, so nothing is silently hidden — `(first 10 shown)` just says the list below
490
+ // is not the whole story when it isn't.
491
+ const sliced = missing.length > 10 || unreadable.length > 10;
492
+ return {
493
+ ok: false,
494
+ code: "WAVE_INCOMPLETE",
495
+ message: [
496
+ `${copied.length} of ${wanted.length} requested component(s) have authored files — the wave the operator approved is not all here${sliced ? " (first 10 shown)" : ""}:`,
497
+ ...missing.slice(0, 10).map((m) => ` MISSING ${m.key} — ${m.reason}`),
498
+ ...unreadable.slice(0, 10).map((u) => ` UNREADABLE ${u}`),
499
+ "",
500
+ "Nothing was written, linted or deployed. Author the missing components and call again — or,",
501
+ "if the operator has agreed to deliver this wave in parts, pass `ackIncompleteWave: true` to",
502
+ "deploy only the assembled subset."
503
+ ].join("\n")
504
+ };
505
+ }
506
+ // Scoped to THIS wave. The recovery scan walks every requirement in the session, so reporting all
507
+ // of it repeated the same nags on every call regardless of what each wave contained — noise that
508
+ // trains the reader to skip the block that matters.
509
+ const recoveredHere = index.recovered.filter((r) => wantedKeys.some((k) => r.startsWith(`${k} (`)));
510
+ // No trailing "now run direct_lint": assembly is a step INSIDE lint and apply, so the caller is
511
+ // already the next step. The caller frames this block in its own report.
512
+ const report = [
513
+ `assembled ${copied.length} file(s) into ${dest}`,
514
+ ...copied.map((c) => ` ${c}`),
515
+ ...(missing.length > 0
516
+ ? [
517
+ "",
518
+ `${missing.length} component(s) in this wave have NO authored file — author these yourself before linting:`,
519
+ ...missing.map((m) => ` ${m.key} — ${m.reason}`)
520
+ ]
521
+ : []),
522
+ ...(recoveredHere.length > 0
523
+ ? [
524
+ "",
525
+ `${recoveredHere.length} component(s) in THIS wave had a file on disk that plan.json did not record. Assembled`,
526
+ "from the file, but plan.json is what Configuration Item OWNERSHIP is derived from, so",
527
+ "record authoredFile on each or the writeback will attribute them to no requirement:",
528
+ ...recoveredHere.map((r) => ` ${r}`)
529
+ ]
530
+ : []),
531
+ ...(pruned.length > 0
532
+ ? [
533
+ "",
534
+ `REMOVED ${pruned.length} file(s) that are not in this wave's component list — a leftover`,
535
+ "here would have deployed a component you did not approve:",
536
+ ...pruned.map((p) => ` ${p}`)
537
+ ]
538
+ : []),
539
+ ...(unreadable.length > 0 ? ["", "COULD NOT COPY:", ...unreadable.map((u) => ` ${u}`)] : []),
540
+ ...(index.badPaths.length > 0
541
+ ? ["", "IGNORED unsafe authoredFile path(s) — a plan may be malformed:", ...index.badPaths.map((b) => ` ${b}`)]
542
+ : [])
543
+ ].join("\n");
544
+ return {
545
+ ok: true,
546
+ wave,
547
+ dir: dest,
548
+ copied,
549
+ missing,
550
+ pruned,
551
+ recoveredFromDisk: recoveredHere,
552
+ unreadable,
553
+ rejectedPaths: index.badPaths,
554
+ report
555
+ };
556
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Matched means: something was actually compared (`matched > 0`) and nothing disagreed.
3
+ *
4
+ * Two failures shaped this, in opposite directions:
5
+ *
6
+ * - Summing `item.matched` counts FACETS, not components — a field with nine compared facets
7
+ * contributes nine — while the denominator counts components. Six clean waves reported
8
+ * "1800.0% — 432 of 24 components verified".
9
+ * - Filtering on `item.found` breaks DELETES, where success IS absence. applyLoop records a
10
+ * removed component as `matched: 1, mismatches: [], found: false`, so three cleanly deleted
11
+ * components scored "0.0% — 0 of 3 verified" beside a green DIFF_VERIFIED.
12
+ *
13
+ * `matched > 0 && mismatches.length === 0` is true for a clean edit and a clean delete alike, and
14
+ * false for a component that failed to land: compareIntent returns `matched: 0` with an "(absent)"
15
+ * mismatch when an edit is missing from the post-image.
16
+ */
17
+ export function matchedComponentCount(intent) {
18
+ return intent.filter((item) => item.matched > 0 && item.mismatches.length === 0).length;
19
+ }