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,462 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { questionsDir } from "./workspace.js";
5
+ import { transcriptUploadEnabled } from "./transcriptUpload.js";
6
+ // OPERATOR Q&A UPLOAD — what each question card asked, and what the operator answered.
7
+ //
8
+ // THE GAP THIS CLOSES. `operator.question_presented` carries `{questionCount}`, which can say a run
9
+ // asked four questions and never which four, nor what came back. The question text lived only in
10
+ // this machine's round files and the answers only in plan.json blockers, so "what did users get
11
+ // asked and what did they say" — across installs, the question a product team actually has — could
12
+ // not be answered from the ledger at all.
13
+ //
14
+ // WHY THIS IS NOT A TELEMETRY EVENT. The event stream is shape-only by contract
15
+ // (`convex/lib/events.ts` allowlists every payload key and admits no prose), and a question card is
16
+ // prose: it is written in the operator's language about their business. Widening that allowlist to
17
+ // carry it would end the shape-only property for every consumer of the stream, permanently. So this
18
+ // takes the route the two existing content channels took — its own endpoint, its own table, its own
19
+ // stated reason — and the shape-only event is left exactly as it was.
20
+ //
21
+ // WHY THE DEFAULT IS HONEST. A card is relayed into the Claude Code chat and answered there, so
22
+ // both halves are ALREADY inside the transcript this install uploads by default, under a consent
23
+ // paragraph that names "the full text of the Claude Code session for that batch, which includes
24
+ // anything either of us pastes into the chat". This route sends no category of content that was not
25
+ // already leaving the machine; it makes an already-uploaded fact queryable rather than buried in a
26
+ // 20 MB blob.
27
+ //
28
+ // That argument is CONDITIONAL, and the condition is enforced below: with transcript upload off,
29
+ // this is off too. An install that asked for "the timings and outcomes without the session text"
30
+ // must not have the session's questions and answers arrive by a different door.
31
+ //
32
+ // FAIL-OPEN, like every other reporting path here: nothing in this module may fail, delay or mask a
33
+ // delivery. Every entry point returns notes instead of throwing.
34
+ /** Bumped when the ROUND FILE layout changes. v1 files are read identically — see readQuestionRounds. */
35
+ export const QUESTIONS_SCHEMA_VERSION = "cc-operator-questions-v2";
36
+ /** Bumped when the WIRE shape changes; the ledger refuses a version it does not know. */
37
+ export const OPERATOR_QA_WIRE_VERSION = 1;
38
+ /**
39
+ * THE LEDGER'S FIELD CAPS, MIRRORED.
40
+ *
41
+ * Every number here is the same number as `LIMITS` in `apps/campfire-launch/convex/operatorQa.ts`,
42
+ * which is the validator that actually refuses an over-long field — and refuses it with a 400 for
43
+ * the whole request, at batch close, when the run is over and there is no later attempt.
44
+ *
45
+ * Mirroring them on this side moves that refusal to where the card is COMPOSED: `ask_operator`
46
+ * rejects a 3,000-character option description with a zod error naming the field, at the moment the
47
+ * model wrote it, when re-composing costs one tool call. Before this the client schemas said
48
+ * `z.string().min(1)` and the tool description advertised the field as uncapped, so the first news
49
+ * of a limit arrived as a lost round.
50
+ *
51
+ * `operatorQaLimits.test.ts` reads both files and fails if these drift apart.
52
+ */
53
+ export const WIRE_LIMITS = {
54
+ questionId: 64,
55
+ header: 24,
56
+ question: 4_000,
57
+ label: 120,
58
+ description: 2_000,
59
+ freeText: 4_000
60
+ };
61
+ /**
62
+ * The CARD's own caps, deliberately stricter than the wire.
63
+ *
64
+ * These are composition rules, not storage limits: a 60-character label is what keeps an option
65
+ * readable as a verdict, and a 12-character header is what fits the chip. The wire would accept
66
+ * twice each. The cross-app test asserts they stay INSIDE `WIRE_LIMITS` — a card that composes must
67
+ * always be a card that uploads.
68
+ */
69
+ export const CARD_LIMITS = {
70
+ header: 12,
71
+ label: 60
72
+ };
73
+ /** Ledger counterparts: MAX_QUESTIONS / MAX_OPTIONS / MAX_CHOSEN in convex/operatorQa.ts. */
74
+ export const WIRE_COUNTS = {
75
+ questionsPerRound: 8,
76
+ optionsPerQuestion: 8,
77
+ chosenPerAnswer: 8
78
+ };
79
+ /** The ledger accepts 25 rounds per request (MAX_ROUNDS). A session with more still delivers all. */
80
+ const ROUNDS_PER_REQUEST = 25;
81
+ /**
82
+ * Mirrors MAX_BODY_BYTES in convex/operatorQa.ts, which measures the raw request text and answers
83
+ * 413 above it. Counting rounds alone was not enough: 25 rounds of 8 questions at the field caps
84
+ * above is far over a megabyte, so a pathological session could build a chunk the ledger refuses
85
+ * whole for a reason no round of it is individually guilty of.
86
+ */
87
+ const MAX_REQUEST_BYTES = 1024 * 1024;
88
+ /**
89
+ * The stable address of one question, `r3q2` — round 3, question 2.
90
+ *
91
+ * DERIVED from position rather than minted, which is what makes the file-format bump a non-event: a
92
+ * v1 round file, written before ids existed, yields exactly the ids a v2 file would. Nothing has to
93
+ * migrate and no reader has to branch on version.
94
+ */
95
+ export function questionIdFor(round, index) {
96
+ return `r${round}q${index + 1}`;
97
+ }
98
+ /** Every question in a round, with its id filled in whether or not the file recorded one. */
99
+ export function withQuestionIds(round) {
100
+ return round.questions.map((question, index) => ({
101
+ ...question,
102
+ questionId: question.questionId ?? questionIdFor(round.round, index)
103
+ }));
104
+ }
105
+ /** ANSWERED once every question has one; PARTIALLY_ANSWERED once at least one does. */
106
+ export function statusFor(questions) {
107
+ const answered = questions.filter((question) => question.answer !== undefined).length;
108
+ if (answered === 0)
109
+ return "PENDING";
110
+ return answered === questions.length ? "ANSWERED" : "PARTIALLY_ANSWERED";
111
+ }
112
+ const DISABLED_VALUES = new Set(["0", "false", "off", "no"]);
113
+ /**
114
+ * ON BY DEFAULT, and gated on the superset.
115
+ *
116
+ * Two switches, and the order matters. `CC_MCP_TRANSCRIPT_UPLOAD=0` turns this off because the
117
+ * consent this rides on is the transcript's: the questions and answers are part of the session text
118
+ * that install declined to send, and routing them out separately would make that decline meaningless.
119
+ * `CC_MCP_OPERATOR_QA_UPLOAD=0` is the narrower one, for an install content to ship the raw
120
+ * transcript but not a queryable table of what its operator was asked.
121
+ *
122
+ * `CC_MCP_TELEMETRY=0` covers both without appearing here: with no telemetry there is no batch
123
+ * grant, and the grant is the only way an upload can be attributed to anything.
124
+ */
125
+ export function operatorQaUploadEnabled(env = process.env) {
126
+ if (!transcriptUploadEnabled(env))
127
+ return false;
128
+ const raw = env.CC_MCP_OPERATOR_QA_UPLOAD?.trim().toLowerCase();
129
+ return raw === undefined || raw === "" ? true : !DISABLED_VALUES.has(raw);
130
+ }
131
+ let announced = false;
132
+ /** Test seam — the once-guard is module state, and a suite asserting the line needs it reset. */
133
+ export function resetOperatorQaAnnouncementForTests() {
134
+ announced = false;
135
+ }
136
+ const defaultLog = (line) => void process.stderr.write(`[cc-mcp] ${line}\n`);
137
+ /**
138
+ * Say it at startup, in the operator's language, whichever way the switches are set.
139
+ *
140
+ * The transcript announcement already says the whole session leaves, so this line is not the first
141
+ * disclosure of the content — it is the disclosure that the content is also stored in a form
142
+ * somebody can query, which is a different thing to know and the reason the narrow opt-out exists.
143
+ */
144
+ export function announceOperatorQa(opts = {}) {
145
+ if (announced)
146
+ return;
147
+ announced = true;
148
+ const env = opts.env ?? process.env;
149
+ const write = opts.log ?? defaultLog;
150
+ if (!transcriptUploadEnabled(env)) {
151
+ // Deliberately named as a consequence rather than as a second setting to find: an operator who
152
+ // set the transcript variable has already made this decision and should be told it took effect.
153
+ write("operator question cards are NOT being uploaded either — CC_MCP_TRANSCRIPT_UPLOAD=0 covers them, because they are part of the session text.");
154
+ return;
155
+ }
156
+ if (!operatorQaUploadEnabled(env)) {
157
+ write("operator question cards are NOT being uploaded (CC_MCP_OPERATOR_QA_UPLOAD=0). They stay on this machine; the session transcript is unaffected.");
158
+ return;
159
+ }
160
+ write("OPERATOR QUESTION CARDS ARE UPLOADED (the default). Every question you are asked, its options, and the answer you give " +
161
+ `are sent to ${opts.endpoint ?? "the configured endpoint"} as records IN2 can search — the same words the session transcript already carries. ` +
162
+ "Set CC_MCP_OPERATOR_QA_UPLOAD=0 to send the transcript without them, or CC_MCP_TRANSCRIPT_UPLOAD=0 to send neither.");
163
+ }
164
+ /** Same host, same grant, different route — derived exactly as the transcript endpoint is. */
165
+ export function operatorQaEndpointFrom(telemetryEndpoint) {
166
+ try {
167
+ const url = new URL(telemetryEndpoint);
168
+ url.pathname = "/api/operator-qa/v1";
169
+ return url.toString();
170
+ }
171
+ catch {
172
+ return telemetryEndpoint.replace(/\/api\/telemetry\/v\d+$/, "/api/operator-qa/v1");
173
+ }
174
+ }
175
+ /**
176
+ * Every round this session recorded, in asked order.
177
+ *
178
+ * Tolerant by construction: an unreadable or malformed round file is skipped rather than failing the
179
+ * sweep, because one bad file must not cost the rounds beside it — this runs at batch close, after
180
+ * the work, and there is no later attempt.
181
+ */
182
+ export async function readQuestionRounds(sessionId) {
183
+ const dir = questionsDir(sessionId);
184
+ if (!existsSync(dir))
185
+ return [];
186
+ let names;
187
+ try {
188
+ names = await fs.readdir(dir);
189
+ }
190
+ catch {
191
+ return [];
192
+ }
193
+ const rounds = [];
194
+ for (const name of names.sort()) {
195
+ if (!name.endsWith(".json"))
196
+ continue;
197
+ try {
198
+ const parsed = JSON.parse(await fs.readFile(path.join(dir, name), "utf8"));
199
+ if (!parsed || typeof parsed !== "object")
200
+ continue;
201
+ if (!Number.isSafeInteger(parsed.round) || parsed.round < 1)
202
+ continue;
203
+ if (!Array.isArray(parsed.questions) || parsed.questions.length === 0)
204
+ continue;
205
+ rounds.push(parsed);
206
+ }
207
+ catch {
208
+ // A half-written or hand-edited round file is skipped, not fatal.
209
+ }
210
+ }
211
+ return rounds.sort((a, b) => a.round - b.round);
212
+ }
213
+ /** The wire shape for one round — the local file with its ids materialized and nothing else added. */
214
+ export function wireRound(round) {
215
+ return {
216
+ round: round.round,
217
+ askedAt: round.askedAt,
218
+ ...(round.answeredAt ? { answeredAt: round.answeredAt } : {}),
219
+ questions: withQuestionIds(round).map((question) => ({
220
+ questionId: question.questionId,
221
+ ...(question.header ? { header: question.header } : {}),
222
+ question: question.question,
223
+ ...(question.multiSelect ? { multiSelect: true } : {}),
224
+ options: (question.options ?? []).map((option) => ({
225
+ label: option.label,
226
+ ...(option.description ? { description: option.description } : {}),
227
+ ...(option.recommended ? { recommended: true } : {})
228
+ })),
229
+ ...(question.answer
230
+ ? {
231
+ answer: {
232
+ chosenLabels: question.answer.chosenLabels ?? [],
233
+ ...(question.answer.freeText ? { freeText: question.answer.freeText } : {}),
234
+ ...(question.answer.answeredAt ? { answeredAt: question.answer.answeredAt } : {})
235
+ }
236
+ }
237
+ : {})
238
+ }))
239
+ };
240
+ }
241
+ /**
242
+ * The first way this round would breach the ledger's caps, or null if it would not.
243
+ *
244
+ * Checked HERE so a length can never cost a request. The zod schemas on `ask_operator` already stop
245
+ * an over-long field being written, but a round file can also be hand-edited, restored from an older
246
+ * version, or written by a build whose caps differ from the ledger it happens to be pointed at — and
247
+ * at batch close the only remedy left is to not send the round rather than to lose the ones beside it.
248
+ */
249
+ export function roundLimitProblem(round) {
250
+ const questions = round.questions ?? [];
251
+ if (questions.length > WIRE_COUNTS.questionsPerRound) {
252
+ return `it has ${questions.length} questions, over the limit of ${WIRE_COUNTS.questionsPerRound}`;
253
+ }
254
+ const tooLong = (value, max, field) => value !== undefined && value.length > max ? `${field} is ${value.length} characters, over the limit of ${max}` : null;
255
+ for (const [index, question] of questions.entries()) {
256
+ const at = `question ${index + 1}`;
257
+ const options = question.options ?? [];
258
+ if (options.length > WIRE_COUNTS.optionsPerQuestion) {
259
+ return `${at} has ${options.length} options, over the limit of ${WIRE_COUNTS.optionsPerQuestion}`;
260
+ }
261
+ const problem = tooLong(question.questionId, WIRE_LIMITS.questionId, `${at} id`) ??
262
+ tooLong(question.header, WIRE_LIMITS.header, `${at} header`) ??
263
+ tooLong(question.question, WIRE_LIMITS.question, `${at} text`);
264
+ if (problem)
265
+ return problem;
266
+ for (const [optionIndex, option] of options.entries()) {
267
+ const optionAt = `${at} option ${optionIndex + 1}`;
268
+ const optionProblem = tooLong(option.label, WIRE_LIMITS.label, `${optionAt} label`) ??
269
+ tooLong(option.description, WIRE_LIMITS.description, `${optionAt} description`);
270
+ if (optionProblem)
271
+ return optionProblem;
272
+ }
273
+ const answer = question.answer;
274
+ if (!answer)
275
+ continue;
276
+ const chosen = answer.chosenLabels ?? [];
277
+ if (chosen.length > WIRE_COUNTS.chosenPerAnswer) {
278
+ return `${at} records ${chosen.length} chosen labels, over the limit of ${WIRE_COUNTS.chosenPerAnswer}`;
279
+ }
280
+ for (const [labelIndex, label] of chosen.entries()) {
281
+ const labelProblem = tooLong(label, WIRE_LIMITS.label, `${at} chosen label ${labelIndex + 1}`);
282
+ if (labelProblem)
283
+ return labelProblem;
284
+ }
285
+ const freeTextProblem = tooLong(answer.freeText, WIRE_LIMITS.freeText, `${at} free text`);
286
+ if (freeTextProblem)
287
+ return freeTextProblem;
288
+ }
289
+ return null;
290
+ }
291
+ function envelope(rounds) {
292
+ return JSON.stringify({ schemaVersion: OPERATOR_QA_WIRE_VERSION, rounds });
293
+ }
294
+ /**
295
+ * Chunks bounded by BOTH caps the ledger enforces — 25 rounds, and a megabyte of request text.
296
+ *
297
+ * A single round larger than the whole budget still gets its own chunk rather than being dropped
298
+ * here: it will be refused on its own, which costs exactly one round, and being refused with the
299
+ * ledger's own message is more use than a guess made locally.
300
+ */
301
+ function planChunks(rounds) {
302
+ const overhead = Buffer.byteLength(envelope([]), "utf8");
303
+ const chunks = [];
304
+ let current = [];
305
+ let bytes = overhead;
306
+ for (const round of rounds) {
307
+ const cost = round.bytes + 1; // the comma that will separate it from the previous round
308
+ if (current.length > 0 && (current.length >= ROUNDS_PER_REQUEST || bytes + cost > MAX_REQUEST_BYTES)) {
309
+ chunks.push(current);
310
+ current = [];
311
+ bytes = overhead;
312
+ }
313
+ current.push(round);
314
+ bytes += cost;
315
+ }
316
+ if (current.length > 0)
317
+ chunks.push(current);
318
+ return chunks;
319
+ }
320
+ /**
321
+ * Ship the rounds. The grant is the same batch-scoped credential telemetry uses, so a Q&A record can
322
+ * only ever be attributed to the batch whose credential sent it — the ledger derives identity from
323
+ * the grant and ignores anything the client claims.
324
+ *
325
+ * Chunked at the ledger's per-request round cap AND its body-size cap, so a pathological session
326
+ * delivers every round rather than being refused whole. Chunks are independent: rows are keyed on
327
+ * (batch, round), so a chunk that fails costs only its own rounds.
328
+ *
329
+ * ONE BAD ROUND MUST NOT COST ITS CHUNKMATES. The ledger validates a request whole and answers 400
330
+ * for the first field it dislikes, so a single malformed round used to discard the 24 answered ones
331
+ * travelling with it — silently, at batch close, with no second attempt. A payload refusal now
332
+ * re-sends the chunk one round at a time, which turns "25 rounds lost" into "the one round the
333
+ * ledger actually refused". The retry is deliberately NOT made for network errors or 5xx: those are
334
+ * not round-specific, and re-sending 25 rounds one at a time against a dead endpoint would only make
335
+ * a slow close slower.
336
+ */
337
+ export async function uploadOperatorQa(opts) {
338
+ const fetchFn = opts.fetchFn ?? fetch;
339
+ let uploaded = 0;
340
+ let failed = 0;
341
+ let reason;
342
+ // Length-refused before anything is sent, so a 400 for a field size cannot happen at all.
343
+ const sendable = [];
344
+ for (const round of opts.rounds) {
345
+ const problem = roundLimitProblem(round);
346
+ if (problem !== null) {
347
+ failed += 1;
348
+ reason ??= `round ${round.round} exceeds the ledger's limits: ${problem}`;
349
+ continue;
350
+ }
351
+ const body = wireRound(round);
352
+ sendable.push({ round: round.round, body, bytes: Buffer.byteLength(JSON.stringify(body), "utf8") });
353
+ }
354
+ const post = async (chunk) => {
355
+ try {
356
+ const res = await fetchFn(opts.endpoint, {
357
+ method: "POST",
358
+ headers: {
359
+ "content-type": "application/json",
360
+ authorization: `Bearer ${opts.credential}`
361
+ },
362
+ body: envelope(chunk.map((round) => round.body)),
363
+ signal: AbortSignal.timeout(30_000)
364
+ });
365
+ if (res.ok)
366
+ return { ok: true };
367
+ // The ledger's own message names the field it refused, which is the only useful thing to
368
+ // print when a client and a server disagree about a shape.
369
+ const detail = res.status === 400 ? `: ${(await res.text()).slice(0, 200)}` : "";
370
+ return { ok: false, status: res.status, reason: `HTTP ${res.status}${detail}` };
371
+ }
372
+ catch (error) {
373
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
374
+ }
375
+ };
376
+ /** The statuses that mean "this payload", and so the ones worth re-trying a round at a time. */
377
+ const payloadRefusal = (status) => status === 400 || status === 413;
378
+ for (const chunk of planChunks(sendable)) {
379
+ const sent = await post(chunk);
380
+ if (sent.ok) {
381
+ uploaded += chunk.length;
382
+ continue;
383
+ }
384
+ if (chunk.length === 1 || !payloadRefusal(sent.status)) {
385
+ failed += chunk.length;
386
+ reason ??= sent.reason;
387
+ continue;
388
+ }
389
+ // Deliberately no chunk-level reason recorded here: the per-round attempt below produces the
390
+ // message naming the round that is actually at fault, which is the one worth reporting.
391
+ for (const round of chunk) {
392
+ const retry = await post([round]);
393
+ if (retry.ok) {
394
+ uploaded += 1;
395
+ continue;
396
+ }
397
+ failed += 1;
398
+ reason ??= `round ${round.round}: ${retry.reason ?? "unknown error"}`;
399
+ }
400
+ }
401
+ return { uploaded, failed, ...(reason ? { reason } : {}) };
402
+ }
403
+ /**
404
+ * THE UNDERCOUNT WARNING — said mechanically, from the round files, not left to a prompt.
405
+ *
406
+ * A question round is closed by `record_operator_answers`. But an operator's answer ALSO reaches the
407
+ * work through `resolve_blocker`, which records it on requirements/<extId>/plan.json with no link
408
+ * back to the round it came from — and through the chat, which records it nowhere at all. So a run
409
+ * whose operator answered every question and delivered successfully can still close with rounds
410
+ * marked PENDING, and it is then indistinguishable from a run that stopped dead at the gate. The
411
+ * ledger reads `answeredCount: 0` as exactly that meaningful "stopped at the gate" state.
412
+ *
413
+ * There is no cheap way to link the two records — one card question can settle three requirements or
414
+ * none — so the honest move is to stop the reading being certain. Naming the open rounds at close
415
+ * costs nothing and puts the doubt where the number is read.
416
+ */
417
+ export function openRoundCaveat(open) {
418
+ if (open.length === 0)
419
+ return [];
420
+ const numbers = open.map((round) => round.round).join(", ");
421
+ return [
422
+ `${open.length} question round(s) still open at close (${open.length === 1 ? "round" : "rounds"} ${numbers}) — ` +
423
+ "if the operator answered through resolve_blocker or in the chat, this batch's Q&A record undercounts what they were asked and told. " +
424
+ "record_operator_answers is what puts the answer on the round."
425
+ ];
426
+ }
427
+ /**
428
+ * The batch-close step: read what this session asked, ship it, and say what happened.
429
+ *
430
+ * Returns notes rather than throwing, and reports "nothing to send" as plainly as a failure — a run
431
+ * that asked no questions and a run whose upload silently did nothing must not look the same.
432
+ */
433
+ export async function shipOperatorQa(opts) {
434
+ if (!operatorQaUploadEnabled(opts.env ?? process.env))
435
+ return [];
436
+ if (!opts.sessionId)
437
+ return ["operator Q&A upload skipped: no session bound to this run"];
438
+ if (!opts.endpoint || !opts.credential) {
439
+ return ["operator Q&A upload skipped: no batch grant on this install yet"];
440
+ }
441
+ const rounds = await readQuestionRounds(opts.sessionId);
442
+ if (rounds.length === 0)
443
+ return ["operator Q&A: no question rounds were recorded for this session"];
444
+ const open = rounds.filter((round) => statusFor(round.questions) === "PENDING");
445
+ const result = await uploadOperatorQa({
446
+ endpoint: opts.endpoint,
447
+ credential: opts.credential,
448
+ rounds,
449
+ ...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {})
450
+ });
451
+ const notes = [];
452
+ if (result.uploaded > 0) {
453
+ notes.push(`operator Q&A uploaded: ${result.uploaded} question round(s)` +
454
+ (open.length > 0 ? `, ${open.length} of them with no answer recorded` : ""));
455
+ }
456
+ if (result.failed > 0) {
457
+ notes.push(`operator Q&A upload failed for ${result.failed} round(s): ${result.reason ?? "unknown error"}`);
458
+ }
459
+ // Last, and independent of how the upload went: the subject is the RECORD, not the transfer.
460
+ notes.push(...openRoundCaveat(open));
461
+ return notes;
462
+ }
@@ -0,0 +1,178 @@
1
+ // WHERE A WAVE'S ORG TIME ACTUALLY GOES — measured, per phase, in the ledger.
2
+ //
3
+ // THE QUESTION. A Windows run was observed taking up to ~8 minutes per wave against a Mac's ~34s
4
+ // median (.audit/timing-profile.md). Nothing in the ledger could apportion that, so the cause was
5
+ // argued from anecdote.
6
+ //
7
+ // THE PREMISE THAT WAS WRONG, and why this file is not what it first was. The obvious hypothesis is
8
+ // "Windows spawns the `sf` CLI many times and each spawn is expensive". It does not: the whole
9
+ // direct tier spawns `sf` exactly THREE times per wave (retrieve, deploy, destructive deploy — all
10
+ // in applyLoop.ts), `orgRepo.ts` spawns `git` only, and every projection read goes through jsforce
11
+ // via @salesforce/core, already batched at the API cap of 10 with 5 in flight
12
+ // (orgread/salesforceOrgReader.ts). Both "batch the reads" targets were already optimal. A meter
13
+ // that counted only sf spawns would therefore measure the three cheapest events in the wave and
14
+ // miss the ~50 jsforce round-trips a projection actually makes.
15
+ //
16
+ // So this counts BOTH KINDS, tagged by kind, and the remaining hypotheses are the ones the numbers
17
+ // can now separate:
18
+ // - per-call network latency (jsforce round-trips, steady state)
19
+ // - process startup — node boot, CLI load, and on Windows an sf.cmd shim resolved to its JS entry
20
+ // and run under this process's node (see core/spawn.ts)
21
+ // - on-access AV scanning, which taxes process start and file I/O rather than the network
22
+ //
23
+ // FIRST CALL VS STEADY STATE is split for exactly that reason. A cost that lands entirely on the
24
+ // first call is startup — connection setup, module load, an AV scan of a freshly touched binary. A
25
+ // cost spread evenly across every call is latency. One number cannot tell those apart; two can, and
26
+ // it is the difference between "the org is far away" and "this machine is slow to start things",
27
+ // which have opposite fixes.
28
+ //
29
+ // WHY A MODULE-LEVEL METER AND NOT A PARAMETER THREADED THROUGH. The calls happen across five
30
+ // modules, several behind fail-open helpers with no result channel to carry a measurement back on
31
+ // (`retrievePreImageSource` returns ok/error; the snapshot readers return envelopes). Threading a
32
+ // counter through all of them to serve one telemetry field would put an observability concern into
33
+ // every signature on the read and deploy paths. The meter sits where the calls already funnel and
34
+ // the apply loop reads it.
35
+ //
36
+ // SHAPE ONLY, and structurally so: a phase LABEL from this codebase's own closed vocabulary, a
37
+ // kind, counts and durations. No argv, no SOQL, no component names, no org alias — which matters
38
+ // because the wave event is validated against a shape-only allowlist on ingest
39
+ // (campfire-launch/convex/lib/events.ts), and argv and SOQL are where org content lives.
40
+ //
41
+ // PROCESS-WIDE, NOT REQUEST-SCOPED. One MCP server serves one session's waves serially, so a
42
+ // process-global meter reset at the start of each wave measures that wave. Stated rather than
43
+ // assumed: `resetOrgCallMeter` is what makes the reading a wave's and not a run's.
44
+ /**
45
+ * Where a call is filed when no phase is open.
46
+ *
47
+ * Auth checks, the org gate and session binding all reach the org outside any wave phase. They are
48
+ * counted rather than dropped, because a meter whose phases do not sum to its total quietly
49
+ * under-reports the very cost it exists to expose.
50
+ */
51
+ const UNPHASED = "other";
52
+ // Keyed on a global symbol for the same reason telemetryContext.ts is: this module can legitimately
53
+ // be loaded more than once in a process (a lazy import from the apply loop, a static one from a
54
+ // tool), and two module instances holding two meters would silently halve every count.
55
+ const KEY = Symbol.for("in2.org.call.meter.v1");
56
+ const globals = globalThis;
57
+ const state = (globals[KEY] ??= {
58
+ count: 0,
59
+ totalMs: 0,
60
+ cliCount: 0,
61
+ cliMs: 0,
62
+ apiCount: 0,
63
+ apiMs: 0,
64
+ firstCallMs: null,
65
+ byPhase: new Map(),
66
+ phase: null
67
+ });
68
+ /**
69
+ * Clear the meter. Called at the start of each wave so a reading measures ONE wave.
70
+ *
71
+ * `firstCallMs` is cleared too, deliberately: it means "first call of this WAVE", which is the
72
+ * comparison the ledger can actually make — first against steady state, within one wave, on one
73
+ * machine. A process-lifetime first call would be one number per run with nothing to compare it to.
74
+ */
75
+ export function resetOrgCallMeter() {
76
+ state.count = 0;
77
+ state.totalMs = 0;
78
+ state.cliCount = 0;
79
+ state.cliMs = 0;
80
+ state.apiCount = 0;
81
+ state.apiMs = 0;
82
+ state.firstCallMs = null;
83
+ state.byPhase.clear();
84
+ state.phase = null;
85
+ }
86
+ /**
87
+ * Record one org call.
88
+ *
89
+ * `durationMs` is full wall clock — for a CLI child that is launch plus work, for an API call the
90
+ * round-trip. Splitting launch from work is not available from outside the child, and inventing the
91
+ * split would be a fabricated number rather than a measured one; the first-call/steady-state
92
+ * comparison is what stands in for it.
93
+ */
94
+ export function recordOrgCall(kind, durationMs) {
95
+ const ms = Number.isFinite(durationMs) && durationMs > 0 ? Math.round(durationMs) : 0;
96
+ state.count += 1;
97
+ state.totalMs += ms;
98
+ if (kind === "cli") {
99
+ state.cliCount += 1;
100
+ state.cliMs += ms;
101
+ }
102
+ else {
103
+ state.apiCount += 1;
104
+ state.apiMs += ms;
105
+ }
106
+ if (state.firstCallMs === null)
107
+ state.firstCallMs = ms;
108
+ const key = state.phase ?? UNPHASED;
109
+ const phase = state.byPhase.get(key) ?? { count: 0, totalMs: 0 };
110
+ phase.count += 1;
111
+ phase.totalMs += ms;
112
+ state.byPhase.set(key, phase);
113
+ }
114
+ /**
115
+ * Attribute every org call inside `fn` to `label`.
116
+ *
117
+ * Restores the ENCLOSING phase rather than clearing to null, and in a `finally`, so a nested phase
118
+ * and a throwing one both leave the outer attribution intact. Getting that wrong would file the
119
+ * rest of a wave under `other` after the first inner failure — the failure mode that makes a meter
120
+ * worse than no meter, because it still looks like a measurement.
121
+ */
122
+ export async function orgCallPhase(label, fn) {
123
+ const previous = state.phase;
124
+ state.phase = label;
125
+ try {
126
+ return await fn();
127
+ }
128
+ finally {
129
+ state.phase = previous;
130
+ }
131
+ }
132
+ /** Time `fn` as one API round-trip, whatever it returns or throws. */
133
+ export async function meteredApiCall(fn) {
134
+ const started = Date.now();
135
+ try {
136
+ return await fn();
137
+ }
138
+ finally {
139
+ // A failed round-trip still cost the wall clock the operator waited through. Counting only
140
+ // successes would under-report exactly the runs that hurt.
141
+ recordOrgCall("api", Date.now() - started);
142
+ }
143
+ }
144
+ /** The meter as plain data. */
145
+ export function readOrgCallMeter() {
146
+ return {
147
+ count: state.count,
148
+ totalMs: state.totalMs,
149
+ cliCount: state.cliCount,
150
+ cliMs: state.cliMs,
151
+ apiCount: state.apiCount,
152
+ apiMs: state.apiMs,
153
+ firstCallMs: state.firstCallMs,
154
+ byPhase: Object.fromEntries([...state.byPhase].map(([key, value]) => [key, { ...value }]))
155
+ };
156
+ }
157
+ /**
158
+ * The meter as the wave event's payload fragment — spread into the emit, absent when there is
159
+ * nothing to say.
160
+ *
161
+ * A wave that never reached the org (LINT_BLOCKED) legitimately made no calls, and an absent key is
162
+ * how the rest of this payload says "not applicable". Zeroes would be equally true and would make a
163
+ * blocked wave indistinguishable from an install running an older build that sends no meter.
164
+ */
165
+ export function orgCallMeterSnapshot() {
166
+ if (state.count === 0)
167
+ return {};
168
+ return {
169
+ orgCallCount: state.count,
170
+ orgCallMs: state.totalMs,
171
+ orgCliCount: state.cliCount,
172
+ orgCliMs: state.cliMs,
173
+ orgApiCount: state.apiCount,
174
+ orgApiMs: state.apiMs,
175
+ ...(state.firstCallMs !== null ? { orgFirstCallMs: state.firstCallMs } : {}),
176
+ orgCallsByPhase: Object.fromEntries([...state.byPhase].map(([key, value]) => [key, { ...value }]))
177
+ };
178
+ }