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,493 @@
1
+ import path from "node:path";
2
+ import { randomBytes } from "node:crypto";
3
+ import { REPO_ROOT, harnessVersion } from "./paths.js";
4
+ import { currentBatchContext } from "./telemetryContext.js";
5
+ import { TelemetryGrantStore, TelemetrySpool } from "./telemetrySpool.js";
6
+ // THE BETA FEEDBACK CHANNEL — what the run was LIKE, from both voices in it.
7
+ //
8
+ // The ledger records what the system DID. It has no channel for where the model got tripped up
9
+ // (a tool failure it worked around, a doc that misled it, two instructions that conflicted) or how
10
+ // the operator experienced the batch. Both currently evaporate at session end, or survive only as
11
+ // prose buried in a transcript blob nobody queries — so the same tripwire gets rediscovered one
12
+ // support conversation at a time. See docs/plans/2026-08-03-005-beta-feedback-channel.md.
13
+ //
14
+ // §9 added two more rows to the same table and the same outbox: the operator's estimate of what the
15
+ // batch would have cost them BY HAND (asked before execution, so it is a counterfactual rather than
16
+ // hindsight) and the sampled relational answer. Both are operator-voiced and both are their own
17
+ // kind — see `FeedbackKind` for why they are not extra columns on the close-time row.
18
+ //
19
+ // WHY IT IS NOT AN EVENT. Feedback is prose. `convex/lib/events.ts` allowlists every payload key and
20
+ // admits none, and widening PAYLOAD_KEYS to carry a friction report would end the shape-only
21
+ // property of the event stream for every consumer of it, permanently, to answer one question.
22
+ // `transcripts` set the precedent, `parcels` extended it, `operatorQa` made it three: content gets
23
+ // its own route, its own table and a stated reason. This is the fourth, and the event stream is
24
+ // untouched.
25
+ //
26
+ // A FEEDBACK ROW IS A SELF-REPORT. A model that misdiagnosed a failure will misdescribe it
27
+ // fluently. Ground truth stays with the transcript and the event stream; batchId is what keeps every
28
+ // row joinable to both, and the trip-up board is meant to be read BESIDE the failure board, never
29
+ // instead of it.
30
+ //
31
+ // FAIL-OPEN, like every other reporting path here: nothing in this module may fail, delay or mask a
32
+ // delivery. Every entry point returns a result instead of throwing.
33
+ /** Bumped when the wire shape changes; the ledger refuses a version it does not know. */
34
+ export const FEEDBACK_WIRE_VERSION = 1;
35
+ export const FEEDBACK_ROUTE = "/api/feedback/v1";
36
+ /**
37
+ * OFF UNLESS THE INSTALL SAYS OTHERWISE — the one place this family of switches is inverted.
38
+ *
39
+ * `CC_MCP_TRANSCRIPT_UPLOAD`, `CC_MCP_PARCEL_UPLOAD` and `CC_MCP_OPERATOR_QA_UPLOAD` all treat an
40
+ * unset variable as ON, because each of them ships something every install should be sending. This
41
+ * one is a BETA capability: it adds two tools and a section to the operating contract, and an
42
+ * install that never opted in must look exactly like one that predates the feature.
43
+ *
44
+ * `in2 setup` writes `CC_MCP_BETA=1` into the `claude mcp add` registration env (src/cli.ts), so
45
+ * the flag travels with the install and survives every npm update — flipping the eventual default
46
+ * is one setup change, not a server release.
47
+ *
48
+ * The DISABLED set is the same generous one transcript upload uses, for the same reason: an operator
49
+ * who reaches for `CC_MCP_BETA=false` to turn this off must actually turn it off.
50
+ */
51
+ const DISABLED_VALUES = new Set(["0", "false", "off", "no"]);
52
+ export function betaEnabled(env = process.env) {
53
+ const raw = env.CC_MCP_BETA?.trim().toLowerCase();
54
+ if (raw === undefined || raw === "")
55
+ return false;
56
+ return !DISABLED_VALUES.has(raw);
57
+ }
58
+ /**
59
+ * OFF BY DEFAULT — the test-corpus authoring tools, which no operator delivery ever calls.
60
+ *
61
+ * `create_campfire_requirements` and `mirror_requirements` exist to stand up and clone the CF-series
62
+ * MASTER CORPORA we run the product against: authoring a new requirement set, then mirroring it
63
+ * under a fresh token so two runs differ only by the build under test. Neither has a role in
64
+ * delivering a real client's batch — a consultant session never authors requirements, it reads the
65
+ * ones Campfire sent.
66
+ *
67
+ * Registering them anyway is not free. A tool definition is resident in EVERY context the server is
68
+ * connected to, before turn one, and these two are ~1.5 KB of description plus their schemas — paid
69
+ * by every delivery session and every analyzer subagent fanned out from one, all of which are
70
+ * forbidden to write to the consultant org in the first place.
71
+ *
72
+ * Same shape and same disabled-value set as `betaEnabled` above, and the same reason for the
73
+ * inversion: a capability an install did not ask for must be indistinguishable from one that
74
+ * predates it. Set `CC_MCP_CORPUS_TOOLS=1` on the registration when the install IS a test harness.
75
+ * The write gate is unchanged and still applies on top — this decides only whether the model is
76
+ * told the tools exist.
77
+ */
78
+ export function corpusToolsEnabled(env = process.env) {
79
+ const raw = env.CC_MCP_CORPUS_TOOLS?.trim().toLowerCase();
80
+ if (raw === undefined || raw === "")
81
+ return false;
82
+ return !DISABLED_VALUES.has(raw);
83
+ }
84
+ /**
85
+ * Whether anything may LEAVE the machine on this channel.
86
+ *
87
+ * `CC_MCP_TELEMETRY=0` takes the feedback channel with it, and that is stated here rather than left
88
+ * to be true by accident. It would in fact be true by accident — with telemetry off there is no
89
+ * batch grant, and the grant is the only thing that can attribute an upload — but "one switch that
90
+ * stops all egress" is the behaviour an operator reaching for that variable expects, and an
91
+ * expectation that holds only as a side effect of the credential plumbing is one refactor away from
92
+ * being false.
93
+ *
94
+ * Note what this does NOT gate: the tools themselves, and the contract section, are gated on
95
+ * `betaEnabled` alone. A telemetry-disabled install in beta can still record feedback; it simply
96
+ * spools it and never ships it, and `flushFeedback` says so.
97
+ */
98
+ export function feedbackUploadEnabled(env = process.env) {
99
+ if (env.CC_MCP_TELEMETRY === "0")
100
+ return false;
101
+ return betaEnabled(env);
102
+ }
103
+ let announced = false;
104
+ /** Test seam — the once-guard is module state, and a suite asserting the line needs it reset. */
105
+ export function resetBetaAnnouncementForTests() {
106
+ announced = false;
107
+ }
108
+ const defaultLog = (line) => void process.stderr.write(`[cc-mcp] ${line}\n`);
109
+ /**
110
+ * Say it out loud at startup, once, whenever beta mode is on.
111
+ *
112
+ * Modelled on `transcriptUpload.announce`, and for the same reason: a machine in beta mode has two
113
+ * extra tools and an extra section of instructions, and must never be mistakable for one that does
114
+ * not. This is also the only disclosure that the model will be filing reports at all — the operator
115
+ * consented to telemetry and transcripts, not to a channel they cannot see being written to.
116
+ */
117
+ export function announceBeta(opts = {}) {
118
+ if (announced)
119
+ return;
120
+ const env = opts.env ?? process.env;
121
+ if (!betaEnabled(env))
122
+ return;
123
+ announced = true;
124
+ const write = opts.log ?? defaultLog;
125
+ if (!feedbackUploadEnabled(env)) {
126
+ write("BETA MODE IS ON (CC_MCP_BETA) — the feedback tools are registered, but CC_MCP_TELEMETRY=0 means nothing leaves this machine. " +
127
+ "Anything recorded stays in the local outbox.");
128
+ return;
129
+ }
130
+ write("BETA MODE IS ON (CC_MCP_BETA). This session can send IN2 two extra things: the model's own reports on where it got " +
131
+ "tripped up — tool errors, doc gaps, conflicting instructions, never your org's content — and, if you answer them, your " +
132
+ `ratings and your own words about how the batch went. Both go to ${opts.endpoint ?? "the configured endpoint"}. ` +
133
+ "Set CC_MCP_BETA=0 to turn the whole channel off, or CC_MCP_TELEMETRY=0 to stop everything leaving this machine.");
134
+ }
135
+ /** Same host, same grant, different route — derived exactly as the other three endpoints are. */
136
+ export function feedbackEndpointFrom(telemetryEndpoint) {
137
+ try {
138
+ const url = new URL(telemetryEndpoint);
139
+ url.pathname = FEEDBACK_ROUTE;
140
+ return url.toString();
141
+ }
142
+ catch {
143
+ return telemetryEndpoint.replace(/\/api\/telemetry\/v\d+$/, FEEDBACK_ROUTE);
144
+ }
145
+ }
146
+ /**
147
+ * THE LEDGER'S FIELD CAPS, MIRRORED — same contract as `operatorQa.WIRE_LIMITS`.
148
+ *
149
+ * Every number here is the number in `apps/campfire-launch/convex/feedback.ts`, which is the
150
+ * validator that actually refuses. Mirroring them moves the refusal to where the report is WRITTEN
151
+ * — a zod error naming the field, at the moment the model wrote it — instead of to a 400 during
152
+ * batch close with no attempt left. `betaFeedbackLimits.test.ts` reads both files and fails if they
153
+ * drift apart.
154
+ */
155
+ export const FEEDBACK_LIMITS = {
156
+ title: 200,
157
+ body: 4_000,
158
+ citation: 2_000,
159
+ issue: 1_000,
160
+ wentWell: 300,
161
+ tool: 80,
162
+ stage: 60,
163
+ role: 60,
164
+ errorCode: 80,
165
+ additionalRemarks: 4_000
166
+ };
167
+ /** Ledger counterparts: MAX_FRICTION_POINTS / MAX_WENT_WELL / MAX_REPORTS in convex/feedback.ts. */
168
+ export const FEEDBACK_COUNTS = {
169
+ frictionPoints: 25,
170
+ wentWell: 25,
171
+ reportsPerRequest: 50
172
+ };
173
+ /**
174
+ * THE TWO NUMERIC BOUNDS ON THE §9.1 ESTIMATE ROW — kept out of `FEEDBACK_LIMITS` on purpose.
175
+ *
176
+ * Everything in that block is a STRING-LENGTH cap on prose; these two bound an integer's VALUE. A
177
+ * reader who found `requirementCount: 1_000` sitting beside `title: 200` would reasonably conclude a
178
+ * requirement count is truncated at a thousand characters. Ledger counterparts are
179
+ * MAX_ESTIMATE_MINUTES / MAX_REQUIREMENT_COUNT, pinned by `betaFeedbackLimits.test.ts` like the rest.
180
+ */
181
+ export const FEEDBACK_BOUNDS = {
182
+ estimateMinutes: 100_000,
183
+ requirementCount: 1_000
184
+ };
185
+ /** Mirrors MAX_BODY_BYTES in convex/feedback.ts, which measures raw request text and answers 413. */
186
+ const MAX_REQUEST_BYTES = 1024 * 1024;
187
+ /**
188
+ * A NARROW MECHANICAL BACKSTOP — not a secret scanner, and not the primary control.
189
+ *
190
+ * The primary control is the instruction: the tool description tells the model to replace
191
+ * secret-shaped values with `[REDACTED]`, stated at the point of writing because that is where it
192
+ * gets read. This exists because that control has a known failure mode with a known cost — quoted
193
+ * error text has carried a live activation code before, which is why the transcript table is marked
194
+ * sensitive — and because one format here is minted by this system and therefore recognisable
195
+ * without guessing: the activation code is bare `crypto.randomBytes(32).toString("base64url")`
196
+ * (campfire-launch/src/crypto.ts), exactly 43 base64url characters; the batch grant credential is
197
+ * the same 43 behind a `tgr_` prefix (campfire-launch/src/db/convexLedger.ts), 47 token characters
198
+ * in a row that the bare rule can never see — which is the Authorization bearer, i.e. exactly what
199
+ * a quoted HTTP error carries.
200
+ *
201
+ * The bounds are deliberately tight — exactly 43, with no adjacent token character — so it cannot
202
+ * eat things a citation needs. A 40-char git sha and a 64-char sha256 both fail the length test, and
203
+ * that is the point: over-redacting a citation would break the one property the report shape exists
204
+ * to guarantee.
205
+ */
206
+ const MINTED_TOKEN_RE = /(?<![A-Za-z0-9_-])(?:tgr_)?[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])/g;
207
+ export function redactSecrets(text) {
208
+ return text.replace(MINTED_TOKEN_RE, "[REDACTED]");
209
+ }
210
+ /** Every free-text field on a record, run through the backstop above. */
211
+ export function redactRecord(record) {
212
+ const s = (value) => (value === undefined ? undefined : redactSecrets(value));
213
+ return {
214
+ ...record,
215
+ ...(record.title !== undefined ? { title: s(record.title) } : {}),
216
+ ...(record.body !== undefined ? { body: s(record.body) } : {}),
217
+ ...(record.additionalRemarks !== undefined ? { additionalRemarks: s(record.additionalRemarks) } : {}),
218
+ ...(record.tool !== undefined ? { tool: s(record.tool) } : {}),
219
+ ...(record.errorCode !== undefined ? { errorCode: s(record.errorCode) } : {}),
220
+ ...(record.stage !== undefined ? { stage: s(record.stage) } : {}),
221
+ ...(record.role !== undefined ? { role: s(record.role) } : {}),
222
+ ...(record.frictionPoints
223
+ ? {
224
+ frictionPoints: record.frictionPoints.map((point) => ({
225
+ ...point,
226
+ citation: redactSecrets(point.citation),
227
+ issue: redactSecrets(point.issue)
228
+ }))
229
+ }
230
+ : {}),
231
+ ...(record.wentWell ? { wentWell: record.wentWell.map(redactSecrets) } : {})
232
+ };
233
+ }
234
+ export function newFeedbackId() {
235
+ return `fbk_${randomBytes(12).toString("hex")}`;
236
+ }
237
+ let sharedSpool;
238
+ /** The process-wide feedback outbox, beside the telemetry and parcel ones. */
239
+ export function defaultFeedbackSpool(runId = "feedback") {
240
+ if (sharedSpool !== undefined)
241
+ return sharedSpool;
242
+ try {
243
+ sharedSpool = new TelemetrySpool({
244
+ dir: process.env.CC_MCP_FEEDBACK_OUTBOX?.trim() || path.join(REPO_ROOT, ".local", "cc-mcp", "feedback-outbox"),
245
+ runId
246
+ });
247
+ }
248
+ catch {
249
+ sharedSpool = null;
250
+ }
251
+ return sharedSpool;
252
+ }
253
+ /** Test seam: the outbox is process-wide, and a suite pointing it elsewhere needs it re-resolved. */
254
+ export function resetFeedbackSpoolForTests() {
255
+ sharedSpool = undefined;
256
+ }
257
+ /**
258
+ * Put one record in the outbox. NEVER THROWS — this is called from a tool handler.
259
+ *
260
+ * ATTRIBUTION IS THE WHOLE JOB HERE, and it is the reason a record without a batch is refused
261
+ * rather than held. A feedback row's value is that it joins to the run it describes; a row spooled
262
+ * with no batch would either sit until the caps retired it or — the version to actively avoid —
263
+ * later be attributed to whichever batch happened to be bound next, which is the mis-attribution
264
+ * `parcels.defaultResolveGrant` exists to prevent. Same rule, stated the same way.
265
+ */
266
+ export function recordFeedback(record, opts = {}) {
267
+ try {
268
+ const batchId = opts.batchId ?? currentBatchContext()?.batchId ?? null;
269
+ if (!batchId) {
270
+ return {
271
+ recorded: false,
272
+ unattributed: true,
273
+ reason: "no batch is bound to this run yet, and a feedback row with no batch cannot be joined to the run it describes — activate a session first"
274
+ };
275
+ }
276
+ const spool = opts.spool === undefined ? defaultFeedbackSpool() : opts.spool;
277
+ if (!spool)
278
+ return { recorded: false, reason: "feedback outbox unavailable on this machine" };
279
+ const stamped = redactRecord({
280
+ ...record,
281
+ ...(record.skillVersion === undefined && harnessVersion() ? { skillVersion: harnessVersion() } : {})
282
+ });
283
+ const appended = spool.append({ batchId, event: stamped });
284
+ return appended
285
+ ? { recorded: true, reportId: stamped.eventId }
286
+ : { recorded: false, reason: "feedback outbox could not be written" };
287
+ }
288
+ catch (error) {
289
+ return { recorded: false, reason: error instanceof Error ? error.message : String(error) };
290
+ }
291
+ }
292
+ /**
293
+ * The grant for a batch, from the durable store first — the same two rules as `parcels`.
294
+ *
295
+ * A spooled report outlives the run that made it, so it resolves THAT batch's grant and never
296
+ * whichever batch happens to be bound now. Borrowing the current one would land a report from one
297
+ * engagement in the ledger attributed to another.
298
+ */
299
+ export function defaultResolveFeedbackGrant(batchId) {
300
+ if (!batchId)
301
+ return null;
302
+ try {
303
+ const stored = new TelemetryGrantStore(path.join(REPO_ROOT, ".local", "cc-mcp", "telemetry-grants")).get(batchId);
304
+ if (stored?.credential && stored.endpoint) {
305
+ return { endpoint: feedbackEndpointFrom(stored.endpoint), credential: stored.credential };
306
+ }
307
+ // The live context covers the window before a grant reaches disk — same batch only.
308
+ const live = currentBatchContext();
309
+ if (live?.credential && live.endpoint && live.batchId === batchId) {
310
+ return { endpoint: feedbackEndpointFrom(live.endpoint), credential: live.credential };
311
+ }
312
+ }
313
+ catch {
314
+ // A grant store that cannot be read is a reason to retry later, never to fail a caller.
315
+ }
316
+ return null;
317
+ }
318
+ function envelope(reports) {
319
+ return JSON.stringify({
320
+ schemaVersion: FEEDBACK_WIRE_VERSION,
321
+ reports: reports.map(({ eventId, ...rest }) => ({ reportId: eventId, ...rest }))
322
+ });
323
+ }
324
+ /**
325
+ * Chunks bounded by BOTH caps the ledger enforces — 50 reports, and a megabyte of request text.
326
+ *
327
+ * A single record larger than the whole budget still gets its own chunk rather than being dropped
328
+ * here: it will be refused on its own, which costs exactly that record, and being refused with the
329
+ * ledger's own message names the field a client and server disagree about.
330
+ */
331
+ function planChunks(records) {
332
+ const overhead = Buffer.byteLength(envelope([]), "utf8");
333
+ const chunks = [];
334
+ let current = [];
335
+ let bytes = overhead;
336
+ for (const record of records) {
337
+ const cost = Buffer.byteLength(JSON.stringify(record), "utf8") + 1;
338
+ if (current.length > 0 && (current.length >= FEEDBACK_COUNTS.reportsPerRequest || bytes + cost > MAX_REQUEST_BYTES)) {
339
+ chunks.push(current);
340
+ current = [];
341
+ bytes = overhead;
342
+ }
343
+ current.push(record);
344
+ bytes += cost;
345
+ }
346
+ if (current.length > 0)
347
+ chunks.push(current);
348
+ return chunks;
349
+ }
350
+ /**
351
+ * Ship a batch's records under its grant. Never throws; every failure is a described result.
352
+ *
353
+ * A PAYLOAD REFUSAL RE-SENDS ONE AT A TIME, for the same reason the Q&A uploader does: the ledger
354
+ * validates a request whole and answers 400 for the first field it dislikes, so one malformed record
355
+ * would otherwise discard the 49 good ones travelling with it — silently, at close, with no second
356
+ * attempt. The retry is deliberately NOT made for network errors or 5xx: those are not
357
+ * record-specific, and re-sending 50 records one at a time against a dead endpoint only makes a slow
358
+ * close slower.
359
+ */
360
+ export async function uploadFeedback(opts) {
361
+ const fetchFn = opts.fetchFn ?? fetch;
362
+ const result = { uploaded: [], rejected: [], retained: [] };
363
+ const post = async (chunk) => {
364
+ try {
365
+ const res = await fetchFn(opts.endpoint, {
366
+ method: "POST",
367
+ headers: { "content-type": "application/json", authorization: `Bearer ${opts.credential}` },
368
+ body: envelope(chunk),
369
+ signal: AbortSignal.timeout(30_000)
370
+ });
371
+ if (res.ok)
372
+ return { ok: true };
373
+ const detail = res.status === 400 ? `: ${(await res.text()).slice(0, 200)}` : "";
374
+ return { ok: false, status: res.status, reason: `HTTP ${res.status}${detail}` };
375
+ }
376
+ catch (error) {
377
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
378
+ }
379
+ };
380
+ /** The statuses that mean "this payload", and so the ones worth re-trying one at a time. */
381
+ const payloadRefusal = (status) => status === 400 || status === 413;
382
+ for (const chunk of planChunks(opts.records)) {
383
+ const sent = await post(chunk);
384
+ if (sent.ok) {
385
+ result.uploaded.push(...chunk.map((record) => record.eventId));
386
+ continue;
387
+ }
388
+ if (chunk.length > 1 && payloadRefusal(sent.status)) {
389
+ for (const record of chunk) {
390
+ const retry = await post([record]);
391
+ if (retry.ok)
392
+ result.uploaded.push(record.eventId);
393
+ else if (payloadRefusal(retry.status)) {
394
+ result.rejected.push(record.eventId);
395
+ result.reason ??= `report ${record.eventId}: ${retry.reason ?? "refused"}`;
396
+ }
397
+ else {
398
+ result.retained.push(record.eventId);
399
+ result.reason ??= retry.reason;
400
+ }
401
+ }
402
+ continue;
403
+ }
404
+ // 401 is RETAINED on purpose, exactly as parcels retains it: the grant may simply not have been
405
+ // redeemed on this host yet, and the outbox's own caps bound how long that can go on.
406
+ if (payloadRefusal(sent.status)) {
407
+ result.rejected.push(...chunk.map((record) => record.eventId));
408
+ result.reason ??= sent.reason;
409
+ }
410
+ else {
411
+ result.retained.push(...chunk.map((record) => record.eventId));
412
+ result.reason ??= sent.reason;
413
+ }
414
+ }
415
+ return result;
416
+ }
417
+ const emptyFlush = () => ({ uploaded: 0, retained: 0, rejected: 0, outboxDropped: 0, notes: [] });
418
+ /**
419
+ * Ship everything spooled that has a usable grant — INCLUDING BATCHES THIS RUN NEVER TOUCHED.
420
+ *
421
+ * This is what makes the channel durable rather than merely persistent. A run that records feedback
422
+ * and is then killed — the client SIGTERMs the server 2 s after closing stdin, and no signal handler
423
+ * gets more time than that (see telemetryContext.registerBatchCloser) — leaves its reports on disk.
424
+ * A LATER run, with the same install and the same grant store, flushes them. `pendingBatchIds` is
425
+ * why: the flush iterates every batch in the outbox, not just the one currently bound.
426
+ */
427
+ export async function flushFeedback(opts = {}) {
428
+ const summary = emptyFlush();
429
+ const env = opts.env ?? process.env;
430
+ if (!betaEnabled(env))
431
+ return summary;
432
+ const spool = opts.spool === undefined ? defaultFeedbackSpool() : opts.spool;
433
+ if (!spool)
434
+ return summary;
435
+ if (!feedbackUploadEnabled(env)) {
436
+ const waiting = spool.stats().pendingEvents;
437
+ if (waiting > 0) {
438
+ summary.retained = waiting;
439
+ summary.notes.push(`${waiting} feedback record(s) stay on this machine — CC_MCP_TELEMETRY=0 stops all egress, and that covers this channel.`);
440
+ }
441
+ return summary;
442
+ }
443
+ const resolve = opts.resolveGrant ?? defaultResolveFeedbackGrant;
444
+ let batchIds;
445
+ try {
446
+ batchIds = spool.pendingBatchIds();
447
+ }
448
+ catch {
449
+ return summary;
450
+ }
451
+ for (const batchId of batchIds) {
452
+ const records = spool.readBatch(opts.limit ?? 200, batchId).map((entry) => entry.event);
453
+ if (records.length === 0)
454
+ continue;
455
+ let grant = null;
456
+ try {
457
+ grant = resolve(batchId);
458
+ }
459
+ catch {
460
+ grant = null;
461
+ }
462
+ if (!grant) {
463
+ summary.retained += records.length;
464
+ continue;
465
+ }
466
+ const result = await uploadFeedback({
467
+ endpoint: grant.endpoint,
468
+ credential: grant.credential,
469
+ records,
470
+ ...(opts.fetchFn ? { fetchFn: opts.fetchFn } : {})
471
+ });
472
+ // Retired together, but only the rejected ones count as lost — the same distinction the
473
+ // telemetry outbox draws, so delivery health reports the gap instead of under-counting.
474
+ spool.acknowledge([...result.uploaded, ...result.rejected], result.rejected.length);
475
+ summary.uploaded += result.uploaded.length;
476
+ summary.rejected += result.rejected.length;
477
+ summary.retained += result.retained.length;
478
+ if (result.rejected.length > 0) {
479
+ summary.notes.push(`${result.rejected.length} feedback record(s) were REFUSED by IN2 and dropped from the outbox (${result.reason ?? "no reason given"}).`);
480
+ }
481
+ }
482
+ if (summary.uploaded > 0)
483
+ summary.notes.push(`beta feedback uploaded: ${summary.uploaded} record(s)`);
484
+ if (summary.retained > 0) {
485
+ summary.notes.push(`${summary.retained} feedback record(s) still queued — they stay in the outbox and go out on a later run under the same batch's grant.`);
486
+ }
487
+ const stats = spool.stats();
488
+ summary.outboxDropped = stats.droppedEvents;
489
+ if (stats.droppedEvents > 0) {
490
+ summary.notes.push(`${stats.droppedEvents} feedback record(s) were dropped by the outbox's size/age caps and did NOT reach IN2.`);
491
+ }
492
+ return summary;
493
+ }