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,318 @@
1
+ import { z } from "zod";
2
+ import { fail, internalError, ok } from "../core/result.js";
3
+ import { TELEMETRY_STAGES } from "../core/telemetry.js";
4
+ import { FEEDBACK_BOUNDS, FEEDBACK_COUNTS, FEEDBACK_LIMITS, betaEnabled, newFeedbackId, recordFeedback } from "../core/betaFeedback.js";
5
+ // THE BETA FEEDBACK TOOLS — registered ONLY when `CC_MCP_BETA` is on.
6
+ //
7
+ // Two tools, two voices: the model reporting where it got tripped up, and the operator's own words
8
+ // about how the batch went. See core/betaFeedback.ts for the channel, and
9
+ // docs/plans/2026-08-03-005-beta-feedback-channel.md for why each argument is the shape it is.
10
+ //
11
+ // NEITHER TOOL ACCEPTS IDENTITY. There is no batchId, sessionId, installId or serverVersion
12
+ // argument on either one, and that is not an omission to be helpfully corrected later: the server
13
+ // attaches all of it from the batch grant, so a report can only ever be attributed to the batch
14
+ // whose credential ships it. Every other content route in this system holds the same line.
15
+ /**
16
+ * THE FROZEN OPERATOR QUESTIONS.
17
+ *
18
+ * Longitudinal comparability depends on never editing these. Two rating questions and one open one,
19
+ * asked in this order, on every batch while in beta: CES first because effort is the headline metric
20
+ * (the product's pitch is less friction than the manual process, so the effort trend IS the pitch,
21
+ * measured), CSAT second because outcome quality is a separate thing — a batch can deliver perfectly
22
+ * and still have been painful, and the split is what makes that visible.
23
+ *
24
+ * `frozenQuestions.test.ts` pins every string here verbatim, so editing one breaks a named test
25
+ * rather than silently splitting the trend line in two.
26
+ */
27
+ export const OPERATOR_QUESTIONS = {
28
+ ease: "How easy was it to get this batch delivered?",
29
+ satisfaction: "How satisfied are you with what was delivered?",
30
+ open: "What almost stopped you, or what would you change?",
31
+ manualEstimate: "If you were implementing these N requirements by hand, how long would it take you?",
32
+ pmf: "How would you feel if you could no longer use IN2?"
33
+ };
34
+ /**
35
+ * THE FROZEN OPTION LABELS — four per card, and the number four is not an aesthetic choice.
36
+ *
37
+ * An AskUserQuestion card holds AT MOST FOUR OPTIONS, and every operator question in this channel is
38
+ * delivered as a card. So the rating scales are 4-point forced-choice — no neutral midpoint, stored
39
+ * 1-4 — rather than the 1-5 first drafted against a chat-typed answer. Forced choice is a real
40
+ * property and not a consolation: with four points the operator must lean, and a beta cohort small
41
+ * enough to count individually produces no useful signal from a pile of 3-out-of-5s.
42
+ *
43
+ * THE NUMBER TRAVELS IN THE LABEL (`Very easy (4)`) so the stored integer and the words the operator
44
+ * tapped can never be reconstructed apart. A label of "Very easy" alone would leave the direction of
45
+ * the scale living only in a describe() string that no stored row carries.
46
+ *
47
+ * Highest first, in both scales, because a card reads top-down and the two must not disagree with
48
+ * each other about which end is good.
49
+ */
50
+ export const OPERATOR_OPTIONS = {
51
+ ease: ["Very easy (4)", "Easy (3)", "Difficult (2)", "Very difficult (1)"],
52
+ satisfaction: ["Very satisfied (4)", "Satisfied (3)", "Dissatisfied (2)", "Very dissatisfied (1)"],
53
+ /**
54
+ * Added 2026-08-07 — the only question here that had none, which is why it could gain some.
55
+ *
56
+ * QA round 2: *"what almost stopped you or what would you change, that one felt weird not being a
57
+ * multiple choice kind of thing."* He was right about the asymmetry: every other card in this
58
+ * channel is answered by TAPPING, and this one alone demanded typing at the end of a long run —
59
+ * the moment with the least appetite for composition.
60
+ *
61
+ * ADDING OPTIONS FORKS NO BUCKET. The freeze protects against a RE-WORDED label splitting a series
62
+ * that rows already carry; `open` has no stored labels to split, and its question string is
63
+ * unchanged. So this is the one edit in the four QA raised that costs nothing in comparability.
64
+ *
65
+ * THEY ARE A PROMPT, NOT A REPLACEMENT. A tap says which bucket and never why, and the why is what
66
+ * a beta reads this question for — so "Other" remains the verbatim path and `openAnswer` still
67
+ * stores their words. The four are the failure modes QA itself surfaced (fatigue, duration,
68
+ * opacity about what changed) plus the honest "nothing", which has to be tappable or a clean run
69
+ * gets pushed into inventing a complaint — the operator-side twin of `ranCleanly`.
70
+ */
71
+ open: [
72
+ "Nothing — it went smoothly",
73
+ "Too many questions to answer",
74
+ "It took longer than I expected",
75
+ "I couldn't tell what it had actually changed"
76
+ ],
77
+ /** §9.1 — buckets, not a free number, because a bucket is one tap and a number is a negotiation. */
78
+ manualEstimate: ["Under 30 minutes", "30–60 minutes", "1–3 hours", "More than 3 hours"],
79
+ /** §9.2 — the canonical Sean Ellis wording. Categorical, exactly four, card-native as it stands. */
80
+ pmf: ["Very disappointed", "Somewhat disappointed", "Not disappointed", "I no longer use IN2"]
81
+ };
82
+ /** The stored value for each rating label, so the mapping lives beside the labels it belongs to. */
83
+ export const RATING_SCALE = { min: 1, max: 4 };
84
+ /** How the two rating questions are put on a card: one line, both ends of the scale visible. */
85
+ const cardLine = (n, question, options) => ` ${n}. "${question}" — one card, options ${options.map((o) => `\`${o}\``).join(" / ")}\n`;
86
+ const bounded = (max) => z.string().trim().min(1).max(max);
87
+ const severity = z
88
+ .enum(["blocked", "worked_around", "cosmetic"])
89
+ .describe("blocked = it stopped the run until a human or a workaround intervened; worked_around = it cost time or a detour but the run continued; cosmetic = wrong or confusing, no cost to the outcome.");
90
+ const stage = z
91
+ .enum(TELEMETRY_STAGES)
92
+ .describe(`Where in the run it happened. THIS IS THE SAME VOCABULARY THE TIMING DATA USES, so your report joins the durations already recorded for that phase — do not invent a phase name. One of: ${TELEMETRY_STAGES.join(", ")}.`);
93
+ const frictionPoint = z
94
+ .object({
95
+ severity,
96
+ stage: stage.optional(),
97
+ tool: bounded(FEEDBACK_LIMITS.tool).optional().describe("The tool involved, when there is one."),
98
+ citation: bounded(FEEDBACK_LIMITS.citation).describe("REQUIRED, and it must be a QUOTE: the exact error text, the doc passage, or the instruction line. Not a description of one. A friction point without a citation is the feedback equivalent of \"docs unclear\" and cannot be acted on. Replace any credential, activation code or token inside the quote with [REDACTED]."),
99
+ issue: bounded(FEEDBACK_LIMITS.issue).describe("What was wrong about it, in one or two sentences.")
100
+ })
101
+ .strict();
102
+ /** The record every path here produces, minus the fields the outbox and the grant fill in. */
103
+ function draft(kind, source) {
104
+ return { eventId: newFeedbackId(), kind, source, createdAt: new Date().toISOString() };
105
+ }
106
+ function relay(result, what) {
107
+ if (result.recorded) {
108
+ return ok(`${what} recorded (${result.reportId}). It ships with this batch's other evidence at close.`, {
109
+ recorded: true,
110
+ reportId: result.reportId
111
+ });
112
+ }
113
+ if (result.unattributed) {
114
+ return fail("NO_BATCH_BOUND", result.reason ?? "no batch is bound to this run");
115
+ }
116
+ // Deliberately NOT an error result. This channel is fail-open by contract: a feedback tool that
117
+ // returned isError would put a reporting failure in front of the model mid-delivery, which is the
118
+ // one thing this must never cost.
119
+ return ok(`${what} could NOT be recorded (${result.reason ?? "unknown reason"}). The delivery is unaffected.`, {
120
+ recorded: false,
121
+ reason: result.reason
122
+ });
123
+ }
124
+ export function registerFeedbackTools(server, env = process.env) {
125
+ // The flag IS the registration. Subagent gating then comes free: analyzer skills condition on tool
126
+ // availability ("if report_feedback is among your tools…"), so tool presence is the flag and no
127
+ // per-install skill generation is needed.
128
+ if (!betaEnabled(env))
129
+ return;
130
+ server.registerTool("report_feedback", {
131
+ description: "RECORD IN THE IN2 DIAGNOSTICS QUEUE WHERE THIS RUN TRIPPED YOU UP. Beta only. Two ways to call it, and both are expected of every run.\n" +
132
+ "1. IN THE MOMENT, any time during the run: you hit a tool failure, an unexpected result, a doc that misled you, two instructions that conflicted, or a workflow that fought you. Call it THEN — a stumble reported while the exact error text is still in your context beats end-of-session recall, which reconstructs.\n" +
133
+ "2. AT CLOSE, on EVERY run, pass `wrapUp` instead: the run's own one-bit summary plus what actually happened. File it after the final writebacks, before `close_batch`.\n" +
134
+ "EVERY REPORT MUST CITE. Quote the specific error text, doc passage or instruction line you are complaining about — not a description of it. An uncited report is unactionable and will be read as noise.\n" +
135
+ "THREE GUARDRAILS, all absolute. (a) SYSTEM BEHAVIOUR ONLY: tool names, error codes, doc gaps, instruction conflicts, what you did about it — never the client org's content, and never the operator's words (those are theirs to give through record_operator_feedback). (b) REDACT SECRETS ALWAYS: replace any credential, activation code, session token or API key with [REDACTED], including inside quoted error text — quoted errors have carried live activation codes before. (c) NEVER NAME OR RECOMMEND A PERSON: this channel is the whole reporting path — if it is unavailable, summarize the IN2 error code for the operator and stop there; the delivery and `close_batch` continue; never name or recommend an individual, another MCP, or an external contact for an IN2 failure.\n" +
136
+ "A CLEAN PACKAGE WITH EMPTY LISTS IS A VALID AND VALUABLE REPORT; NEVER INVENT FRICTION. The clean-run wrap-up is the denominator — without it the trend can count failures but can never show the improving ratio. If nothing went wrong, say so with `ranCleanly: true` and an empty `frictionPoints`. Manufacturing a finding to look useful corrupts the only dataset that can tell whether this product is getting better.\n" +
137
+ "Identity is attached by the server from this batch's grant; there is nothing to name and nothing to get wrong.",
138
+ inputSchema: {
139
+ category: z
140
+ .enum(["tool_failure", "unexpected_result", "docs_gap", "instruction_conflict", "workflow_friction", "other"])
141
+ .optional()
142
+ .describe("What KIND of problem. Required for an in-the-moment report; omit when sending `wrapUp`."),
143
+ severity: severity.optional().describe("Required for an in-the-moment report; omit when sending `wrapUp`."),
144
+ stage: stage.optional(),
145
+ tool: bounded(FEEDBACK_LIMITS.tool).optional().describe("The tool involved, when there is one."),
146
+ errorCode: bounded(FEEDBACK_LIMITS.errorCode)
147
+ .optional()
148
+ .describe("The error code the tool actually returned (ORG_NOT_BOUND, LINT_BLOCKED, …), verbatim. It is the third leg of the fingerprint that groups your report with the same trip-up reported by other installs, so include it whenever there was one."),
149
+ role: bounded(FEEDBACK_LIMITS.role)
150
+ .optional()
151
+ .describe("Which agent is speaking: \"supervisor\", \"analyzer\", or another short label if neither fits."),
152
+ title: bounded(FEEDBACK_LIMITS.title).optional().describe("One line. Required for an in-the-moment report."),
153
+ body: bounded(FEEDBACK_LIMITS.body)
154
+ .optional()
155
+ .describe("What happened, expected vs actual, and what you did about it — QUOTING the error text, doc passage or instruction line. Required for an in-the-moment report."),
156
+ wrapUp: z
157
+ .object({
158
+ ranCleanly: z
159
+ .boolean()
160
+ .describe("The run's own one-bit summary: did this delivery go through without you having to work around anything?"),
161
+ frictionPoints: z
162
+ .array(frictionPoint)
163
+ .max(FEEDBACK_COUNTS.frictionPoints)
164
+ .describe("Everything that tripped you up, each one cited. AN EMPTY ARRAY IS CORRECT AND EXPECTED for a clean run."),
165
+ wentWell: z
166
+ .array(bounded(FEEDBACK_LIMITS.wentWell))
167
+ .max(FEEDBACK_COUNTS.wentWell)
168
+ .describe("Concrete things that worked, as one-liners — \"direct_lint caught the missing parent before the deploy\", not \"the tools were good\". This is what tells the developers which behaviours to protect across releases; friction without positives is a bug tracker, not an experience channel. An empty array is valid.")
169
+ })
170
+ .strict()
171
+ .optional()
172
+ .describe("The at-close package. Send this INSTEAD of category/severity/title/body, once per run, on every run.")
173
+ }
174
+ }, async (args) => {
175
+ try {
176
+ const { wrapUp, category, severity: sev, title, body, stage: at, tool, errorCode, role } = args;
177
+ const common = {
178
+ ...(at ? { stage: at } : {}),
179
+ ...(tool ? { tool } : {}),
180
+ ...(errorCode ? { errorCode } : {}),
181
+ ...(role ? { role } : {})
182
+ };
183
+ if (wrapUp) {
184
+ // Refused rather than merged. A call carrying both shapes is a model that has misread the
185
+ // tool, and quietly filing one of the two would hide that from it — and from the record.
186
+ if (category || sev || title || body) {
187
+ return fail("FEEDBACK_SHAPE_CONFLICT", "Send either an in-the-moment report (category, severity, title, body) or the at-close wrapUp package, not both in one call. File the wrap-up on its own.");
188
+ }
189
+ return relay(recordFeedback({
190
+ ...draft("wrap_up", "agent"),
191
+ ...common,
192
+ ranCleanly: wrapUp.ranCleanly,
193
+ frictionPoints: wrapUp.frictionPoints,
194
+ wentWell: wrapUp.wentWell
195
+ }), "Run wrap-up");
196
+ }
197
+ if (!category || !sev || !title || !body) {
198
+ return fail("FEEDBACK_INCOMPLETE", "An in-the-moment report needs category, severity, title and body — and the body must quote the specific error text, doc passage or instruction line. Alternatively send the at-close `wrapUp` package.");
199
+ }
200
+ return relay(recordFeedback({ ...draft("report", "agent"), ...common, category, severity: sev, title, body }), "Feedback");
201
+ }
202
+ catch (error) {
203
+ return internalError(error);
204
+ }
205
+ });
206
+ server.registerTool("record_operator_feedback", {
207
+ description: "RECORD WHAT THE OPERATOR SAID ABOUT THIS BATCH, IN THEIR OWN WORDS. Beta only. This tool has THREE shapes, asked at different moments; send exactly one of them per call.\n" +
208
+ "ASK AS ASKUSERQUESTION CARDS, ONE CARD PER RATING QUESTION. Never paste these as a form, never put them in prose, and never reword them. The operator answers by TAPPING an option; free text rides the card's built-in \"Other\", which is where an exact figure or an unlisted answer goes.\n" +
209
+ "AT CLOSE — ONCE per batch, AFTER the final Campfire writebacks and `close_batch`, the completed-value moment when they can see what they got:\n" +
210
+ cardLine(1, OPERATOR_QUESTIONS.ease, OPERATOR_OPTIONS.ease) +
211
+ cardLine(2, OPERATOR_QUESTIONS.satisfaction, OPERATOR_OPTIONS.satisfaction) +
212
+ cardLine(3, OPERATOR_QUESTIONS.open, OPERATOR_OPTIONS.open) +
213
+ " On that third card the options are a PROMPT and \"Other\" is the real answer: if they tap an option, send that label as `openAnswer`; if they write, send their words. Either way it is what they gave you, never your gloss of it.\n" +
214
+ " Pass `easeRating` and `satisfactionRating` as the NUMBER IN THE LABEL THEY TAPPED (1-4, 4 = best), and `openAnswer` as what they wrote.\n" +
215
+ `BEFORE THE FIRST DEPLOY — one card, immediately after they approve the execution plan: "${OPERATOR_QUESTIONS.manualEstimate}" with N replaced by the real requirement count, options ${OPERATOR_OPTIONS.manualEstimate.map((o) => `\`${o}\``).join(" / ")}, an exact figure through "Other". Send it as \`manualEstimate\`. ASK IT THERE AND NOWHERE ELSE — ASK IT PLAINLY, AND NEVER EXPLAIN TO THEM WHY IT IS ASKED THERE: the reason is a research-design argument, the operator did not ask for it, and narrating it is what made this card land as strange in QA.\n` +
216
+ `WHEN THE ACTIVATION SAID IT IS DUE — and only then — one further card at close: "${OPERATOR_QUESTIONS.pmf}", options ${OPERATOR_OPTIONS.pmf.map((o) => `\`${o}\``).join(" / ")}. Send it as \`pmfAnswer\`. If \`pmfDue\` was not set on this session, DO NOT ASK — the cadence is decided server-side.\n` +
217
+ "RECORD THEIR ACTUAL WORDS, NEVER A PARAPHRASE. `openAnswer` is what they wrote, verbatim, including how they said it. A summary of an operator's answer is your voice, not theirs, and it is worthless for the thing this exists to do. If they said more than one thing, put the rest in `additionalRemarks`, also verbatim.\n" +
218
+ "DECLINING IS FINE AND IS ALSO DATA. If they would rather not answer, do not press — call this with `declined: true` and nothing else. A recorded skip is a real answer about how much appetite there was at the end of that run; a silently unasked question is not.\n" +
219
+ "Identity is attached by the server from this batch's grant. Do not send any.",
220
+ inputSchema: {
221
+ easeRating: z
222
+ .number()
223
+ .int()
224
+ .min(RATING_SCALE.min)
225
+ .max(RATING_SCALE.max)
226
+ .optional()
227
+ .describe(`Their answer to "${OPERATOR_QUESTIONS.ease}" — the number in the option label they tapped: ${OPERATOR_OPTIONS.ease.join(", ")}.`),
228
+ satisfactionRating: z
229
+ .number()
230
+ .int()
231
+ .min(RATING_SCALE.min)
232
+ .max(RATING_SCALE.max)
233
+ .optional()
234
+ .describe(`Their answer to "${OPERATOR_QUESTIONS.satisfaction}" — the number in the option label they tapped: ${OPERATOR_OPTIONS.satisfaction.join(", ")}.`),
235
+ openAnswer: bounded(FEEDBACK_LIMITS.body)
236
+ .optional()
237
+ .describe(`Their answer to "${OPERATOR_QUESTIONS.open}", VERBATIM — their words, not your summary of them.`),
238
+ additionalRemarks: bounded(FEEDBACK_LIMITS.additionalRemarks)
239
+ .optional()
240
+ .describe("Anything else they said about the run, also verbatim."),
241
+ manualEstimate: z
242
+ .object({
243
+ bucket: z
244
+ .enum(OPERATOR_OPTIONS.manualEstimate)
245
+ .describe("The option they tapped, verbatim."),
246
+ exactMinutes: z
247
+ .number()
248
+ .int()
249
+ .min(1)
250
+ .max(FEEDBACK_BOUNDS.estimateMinutes)
251
+ .optional()
252
+ .describe("Only when they gave a figure through \"Other\" — in MINUTES. Still send the bucket their figure falls in; the bucket is what the trend is built on and the figure sharpens one batch."),
253
+ requirementCount: z
254
+ .number()
255
+ .int()
256
+ .min(1)
257
+ .max(FEEDBACK_BOUNDS.requirementCount)
258
+ .describe("How many requirements the card actually named. Without it the answer cannot be normalised per requirement, which is the only way two batches compare.")
259
+ })
260
+ .strict()
261
+ .optional()
262
+ .describe(`The pre-execution answer to "${OPERATOR_QUESTIONS.manualEstimate}". Send it ALONE, immediately after plan approval and before the first deploy.`),
263
+ pmfAnswer: z
264
+ .enum(OPERATOR_OPTIONS.pmf)
265
+ .optional()
266
+ .describe(`Their answer to "${OPERATOR_QUESTIONS.pmf}", verbatim. ONLY when this session was activated with the due flag set — otherwise do not ask and do not send.`),
267
+ declined: z
268
+ .boolean()
269
+ .optional()
270
+ .describe("True when they chose not to answer. Send it alone; a declined ask is recorded as a skip.")
271
+ }
272
+ }, async ({ easeRating, satisfactionRating, openAnswer, additionalRemarks, manualEstimate, pmfAnswer, declined }) => {
273
+ try {
274
+ // THE THREE SHAPES ARE EXCLUSIVE, and a mixed call is refused rather than split — the same
275
+ // rule `report_feedback` applies to `wrapUp`. They are asked at different moments (the
276
+ // estimate before the first deploy, the rest at close), so a call carrying two of them is a
277
+ // model that has collapsed the timing this design exists to keep apart, and filing both
278
+ // would hide that from it and put a pre-registered counterfactual in the hindsight window.
279
+ const shapes = [
280
+ manualEstimate !== undefined ? "manualEstimate" : null,
281
+ pmfAnswer !== undefined ? "pmfAnswer" : null,
282
+ easeRating !== undefined || satisfactionRating !== undefined || openAnswer !== undefined || declined
283
+ ? "the close ratings"
284
+ : null
285
+ ].filter(Boolean);
286
+ if (shapes.length > 1) {
287
+ return fail("FEEDBACK_SHAPE_CONFLICT", `Send one shape per call, not ${shapes.join(" + ")}. The manual-time estimate is asked BEFORE the first deploy and the rest at close, so they are separate calls by design.`);
288
+ }
289
+ if (manualEstimate) {
290
+ return relay(recordFeedback({
291
+ ...draft("manual_estimate", "operator"),
292
+ estimateBucket: manualEstimate.bucket,
293
+ ...(manualEstimate.exactMinutes !== undefined ? { estimateMinutes: manualEstimate.exactMinutes } : {}),
294
+ requirementCount: manualEstimate.requirementCount
295
+ }), "Manual-time estimate");
296
+ }
297
+ if (pmfAnswer !== undefined) {
298
+ return relay(recordFeedback({ ...draft("pmf", "operator"), pmfAnswer }), "PMF answer");
299
+ }
300
+ if (declined) {
301
+ return relay(recordFeedback({ ...draft("operator", "operator"), declined: true }), "Declined feedback ask");
302
+ }
303
+ if (easeRating === undefined && satisfactionRating === undefined && openAnswer === undefined) {
304
+ return fail("FEEDBACK_EMPTY", "Nothing to record. Pass what the operator gave you — any of easeRating, satisfactionRating, openAnswer, manualEstimate or pmfAnswer — or `declined: true` if they chose not to answer.");
305
+ }
306
+ return relay(recordFeedback({
307
+ ...draft("operator", "operator"),
308
+ ...(easeRating !== undefined ? { easeRating } : {}),
309
+ ...(satisfactionRating !== undefined ? { satisfactionRating } : {}),
310
+ ...(openAnswer !== undefined ? { body: openAnswer } : {}),
311
+ ...(additionalRemarks !== undefined ? { additionalRemarks } : {})
312
+ }), "Operator feedback");
313
+ }
314
+ catch (error) {
315
+ return internalError(error);
316
+ }
317
+ });
318
+ }
@@ -0,0 +1,287 @@
1
+ import path from "node:path";
2
+ import { z } from "zod";
3
+ import { isoNow } from "../core/ids.js";
4
+ import { fail, internalError, ok } from "../core/result.js";
5
+ import { WorkspaceError, fileExists, questionsDir, readJson, writeJsonAtomic } from "../core/workspace.js";
6
+ import { CARD_LIMITS, QUESTIONS_SCHEMA_VERSION, WIRE_LIMITS, questionIdFor, statusFor, withQuestionIds } from "../core/operatorQa.js";
7
+ import { loadSession } from "./session.js";
8
+ import { emitDomainTelemetryEvent } from "../core/telemetryContext.js";
9
+ // The consolidated operator-question surface, mirroring Claude Code's native
10
+ // AskUserQuestion fields. The tool PERSISTS the card as a durable session
11
+ // artifact and returns a rendered form for the harness to relay. Inside Claude
12
+ // Code the supervisor mirrors the same card through the native AskUserQuestion
13
+ // UI; on headless surfaces the persisted card IS the UI contract.
14
+ //
15
+ // THE ANSWER HALF. Until `record_operator_answers` this file recorded questions and nothing else:
16
+ // the round file was written `PENDING` and no code path ever advanced it, so the status field was
17
+ // dead state, a resumed session could not tell an open card from a settled one, and — the reason it
18
+ // was noticed — what the operator actually CHOSE existed nowhere a reader could reach it. Answers
19
+ // still reach the plan through `resolve_blocker` (that is what makes an authored file deployable),
20
+ // but a plan blocker is a per-requirement edit, not a record of the card: one card question can
21
+ // settle three requirements or none, so it can neither be found from a blocker nor reconstructed
22
+ // from one. The two are complementary and both are required — `record_operator_answers` says what
23
+ // was answered, `resolve_blocker` says what that answer changed.
24
+ //
25
+ // Everything recorded here is shipped at batch close by `core/operatorQa.ts`; the telemetry event
26
+ // on the way past stays shape-only (counts, never text), exactly as `operator.question_presented`
27
+ // always has.
28
+ // EVERY CAP HERE IS THE LEDGER'S. `WIRE_LIMITS` mirrors `LIMITS` in
29
+ // `apps/campfire-launch/convex/operatorQa.ts`; `CARD_LIMITS` are the stricter composition rules that
30
+ // keep a card readable. These three fields were `z.string().min(1)` with no maximum while the ledger
31
+ // capped them, so the model could compose a card that was accepted here and then refused — as a 400
32
+ // for the whole request — at batch close, after the run, with no second attempt.
33
+ const OptionSchema = z
34
+ .object({
35
+ label: z
36
+ .string()
37
+ .min(1)
38
+ .max(CARD_LIMITS.label)
39
+ .describe(`A short verdict ("OWD Private, keep the rule"), max ${CARD_LIMITS.label} chars — the reasoning goes in \`description\`.`),
40
+ description: z
41
+ .string()
42
+ .min(1)
43
+ .max(WIRE_LIMITS.description)
44
+ .describe(`What choosing this means, incl. trade-offs. Max ${WIRE_LIMITS.description} chars.`),
45
+ recommended: z.boolean().optional()
46
+ })
47
+ .strict();
48
+ const QuestionSchema = z
49
+ .object({
50
+ header: z.string().min(1).max(CARD_LIMITS.header).describe(`Chip label, max ${CARD_LIMITS.header} chars.`),
51
+ question: z.string().min(1).max(WIRE_LIMITS.question).describe(`Max ${WIRE_LIMITS.question} chars.`),
52
+ multiSelect: z.boolean().optional(),
53
+ options: z.array(OptionSchema).min(2).max(4)
54
+ })
55
+ .strict();
56
+ const AnswerSchema = z
57
+ .object({
58
+ questionId: z
59
+ .string()
60
+ .min(1)
61
+ .describe("Which question, as `ask_operator` returned it — r<round>q<n>, e.g. r1q2."),
62
+ chosenLabels: z
63
+ .array(z.string().min(1).max(WIRE_LIMITS.label))
64
+ .min(1)
65
+ .max(4)
66
+ .optional()
67
+ .describe("The option label(s) the operator picked, VERBATIM as the card offered them. One unless the question was multi-select."),
68
+ freeText: z
69
+ .string()
70
+ .min(1)
71
+ .max(WIRE_LIMITS.freeText)
72
+ .optional()
73
+ .describe(`What the operator said INSTEAD of picking an option — the 'Other' answer. Use this rather than inventing a label: chosenLabels must match the card, so an answer that was not on it belongs here. Max ${WIRE_LIMITS.freeText} chars.`)
74
+ })
75
+ .strict();
76
+ function roundFile(sessionId, round) {
77
+ return path.join(questionsDir(sessionId), `${String(round).padStart(4, "0")}.json`);
78
+ }
79
+ export function registerOperatorTools(server) {
80
+ server.registerTool("ask_operator", {
81
+ description: "Persist the consolidated operator question card (the ONE question round per phase). Refuses on `RECOMMENDED_OPTION_REQUIRED`. " +
82
+ "CARDS SPEAK THE OPERATOR'S LANGUAGE: Salesforce vocabulary and business consequences, never harness internals — no raw verdict names, no wave/lint/checkOnly/projection words, no file paths; mechanism belongs in option descriptions, one clause at most. See reference/operator-voice.md. " +
83
+ "Returns the rendered card AND a stable id per question (r<round>q<n>); relay the card to the operator (in Claude Code, mirror it through the native AskUserQuestion UI) and STOP — never continue past an unanswered card, and never end a working turn with prose instead of a card. " +
84
+ "ASK ONLY WHAT CHANGES THE WORK. A question whose recommended answer you would proceed with anyway is an ASSUMPTION — state it in the approval card as an assumption in force, do not spend a round-trip on it. Every round costs real operator wait. The test is LIKELY DISSENT, never the text's silence: card it only when a reasonable operator might pick the other option (business meaning, data risk, anything irreversible). Display cosmetics, a spec reading where both readings hold every real value, a platform default the requirement does not contradict — assumptions, all of them. \"It deviates from the literal text\" is not by itself a reason to ask. " +
85
+ "Once answered: `record_operator_answers` with the ids this call returned, then `resolve_blocker` to record the consequence on plan.json — writeback is built from the plan, so an answer that never reaches it leaves the question reading as open.",
86
+ inputSchema: {
87
+ sessionId: z.string(),
88
+ round: z
89
+ .number()
90
+ .int()
91
+ .positive()
92
+ .describe("Question round number within the session (1 = the consolidated round, 2 = a narrow corrective re-ask, ...)."),
93
+ questions: z.array(QuestionSchema).min(1).max(4)
94
+ }
95
+ }, async ({ sessionId, round, questions }) => {
96
+ try {
97
+ await loadSession(sessionId);
98
+ for (const [index, question] of questions.entries()) {
99
+ const recommended = question.options.filter((option) => option.recommended === true);
100
+ if (recommended.length !== 1) {
101
+ return fail("RECOMMENDED_OPTION_REQUIRED", `questions[${index}] has ${recommended.length} recommended options; exactly one is required — the operator's default action is confirming your recommendation.`);
102
+ }
103
+ }
104
+ const file = roundFile(sessionId, round);
105
+ if (fileExists(file)) {
106
+ // A ROUND NUMBER IS WRITE-ONCE. Everything below this line overwrites the round file
107
+ // wholesale, so any existing round must refuse rather than fall through — a settled round
108
+ // that fell through lost the operator's recorded choices for good, since the upload happens
109
+ // at close and reads whatever the file says by then.
110
+ //
111
+ // Derived, not trusted: a v1 file recorded no `status`, and a stored one that disagrees
112
+ // with its own questions must not be what decides whether an answer can be destroyed.
113
+ const existing = await readJson(file);
114
+ const status = existing.status ?? statusFor(existing.questions ?? []);
115
+ if (status === "PENDING") {
116
+ return fail("ROUND_ALREADY_PENDING", `Question round ${round} is already pending at ${file}; do not re-ask — wait for the operator, then record what they said with record_operator_answers.`);
117
+ }
118
+ return fail("ROUND_ALREADY_ANSWERED", `Question round ${round} is ${status} at ${file} — re-asking it would overwrite what the operator already chose. ` +
119
+ `To ask again, use a NEW round number (round ${round + 1}); a corrective re-ask is a new round, never an edit of a settled one.`);
120
+ }
121
+ // The id is DERIVED from position (questionIdFor), not minted, so it is stable across a
122
+ // re-read and identical to what a round file written before ids existed would address.
123
+ const persisted = questions.map((question, index) => ({
124
+ ...question,
125
+ questionId: questionIdFor(round, index)
126
+ }));
127
+ await writeJsonAtomic(file, {
128
+ schemaVersion: QUESTIONS_SCHEMA_VERSION,
129
+ round,
130
+ askedAt: isoNow(),
131
+ status: "PENDING",
132
+ questions: persisted
133
+ });
134
+ emitDomainTelemetryEvent("operator.question_presented", { questionCount: questions.length }, { stage: "operator-gate", outcome: "presented" });
135
+ const rendered = questions
136
+ .map((question, index) => {
137
+ const options = question.options
138
+ .map((option, optionIndex) => ` ${optionIndex + 1}. ${option.label}${option.recommended ? " (Recommended)" : ""} — ${option.description}`)
139
+ .join("\n");
140
+ return `${index + 1}/${questions.length} [${question.header}] ${question.question}${question.multiSelect ? " (multi-select)" : ""}\n${options}`;
141
+ })
142
+ .join("\n\n");
143
+ // The ids sit BELOW the card rather than inside it: the block above is relayed verbatim to a
144
+ // non-technical operator, and `r1q2` means nothing to them.
145
+ const ids = persisted
146
+ .map((question, index) => ` ${question.questionId} = ${index + 1}/${questions.length} [${question.header}]`)
147
+ .join("\n");
148
+ return ok(`question round ${round} persisted -> ${file}\nRelay this card to the operator and STOP:\n\n${rendered}\n\n` +
149
+ `When they answer, call record_operator_answers {sessionId, round: ${round}, answers: [...]} using these ids:\n${ids}`, {
150
+ file,
151
+ round,
152
+ questionCount: questions.length,
153
+ questionIds: persisted.map((question) => question.questionId)
154
+ });
155
+ }
156
+ catch (error) {
157
+ if (error instanceof WorkspaceError)
158
+ return fail(error.code, error.message);
159
+ return internalError(error);
160
+ }
161
+ });
162
+ server.registerTool("record_operator_answers", {
163
+ title: "Record what the operator answered on a question card",
164
+ description: "Close a question round with what the operator actually said. Call it as soon as they answer, BEFORE acting on the answer and before resolve_blocker — it is what turns a card from open to settled, and the only record of WHICH option they took. One call may answer several questions or one; refuses on `UNKNOWN_OPTION` and `QUESTION_ALREADY_ANSWERED`. " +
165
+ "This does not change the plan — `resolve_blocker` still records the consequence on requirements/<extId>/plan.json, and both are required: this says what was ANSWERED, that says what it CHANGED.",
166
+ inputSchema: {
167
+ sessionId: z.string(),
168
+ round: z.number().int().positive().describe("The round being answered, as passed to ask_operator."),
169
+ answers: z.array(AnswerSchema).min(1).max(4)
170
+ }
171
+ }, async ({ sessionId, round, answers }) => {
172
+ try {
173
+ await loadSession(sessionId);
174
+ const file = roundFile(sessionId, round);
175
+ if (!fileExists(file)) {
176
+ return fail("QUESTION_ROUND_NOT_FOUND", `Session ${sessionId} has no question round ${round}. Rounds are created by ask_operator; check the round number it returned.`);
177
+ }
178
+ const stored = await readJson(file);
179
+ const questions = withQuestionIds(stored);
180
+ const known = new Map(questions.map((question) => [question.questionId, question]));
181
+ // RESOLVE EVERY ANSWER BEFORE WRITING ANYTHING. Each refusal below promises the round was
182
+ // left untouched; addressing first is what makes that true of the in-memory document too,
183
+ // so a half-recorded round cannot exist even transiently. Same discipline as resolve_blocker.
184
+ const seen = new Set();
185
+ const resolved = [];
186
+ for (const answer of answers) {
187
+ const question = known.get(answer.questionId);
188
+ if (!question) {
189
+ return fail("QUESTION_NOT_FOUND", `Round ${round} has no question "${answer.questionId}". Present: ${questions.map((q) => `${q.questionId} [${q.header}]`).join(", ")}. Nothing was recorded.`);
190
+ }
191
+ if (seen.has(answer.questionId)) {
192
+ return fail("DUPLICATE_ANSWER", `${answer.questionId} is answered twice in one call, so this call would record an answer it cannot keep. Nothing was recorded.`);
193
+ }
194
+ seen.add(answer.questionId);
195
+ if (question.answer) {
196
+ return fail("QUESTION_ALREADY_ANSWERED", `Round ${round} question ${answer.questionId} is already answered: ${describeAnswer(question)}. Recording a second answer would lose the first; ask a corrective round instead. Nothing was recorded.`);
197
+ }
198
+ if (!answer.chosenLabels?.length && !answer.freeText) {
199
+ return fail("ANSWER_REQUIRED", `${answer.questionId} carries neither chosenLabels nor freeText, which records that something was answered and nothing about what. Nothing was recorded.`);
200
+ }
201
+ const labels = new Set((question.options ?? []).map((option) => option.label));
202
+ const unknownLabel = (answer.chosenLabels ?? []).find((label) => !labels.has(label));
203
+ if (unknownLabel !== undefined) {
204
+ return fail("UNKNOWN_OPTION", `${answer.questionId} was answered "${unknownLabel}", which the card did not offer. Options: ${[...labels].join(" | ")}. If the operator said something else, pass it as freeText — chosenLabels must match the card or the record stops being queryable. Nothing was recorded.`);
205
+ }
206
+ if (!question.multiSelect && (answer.chosenLabels?.length ?? 0) > 1) {
207
+ return fail("SINGLE_SELECT_QUESTION", `${answer.questionId} was not multi-select, so it cannot carry ${answer.chosenLabels.length} chosen options. Nothing was recorded.`);
208
+ }
209
+ resolved.push({ index: questions.indexOf(question), answer });
210
+ }
211
+ const at = isoNow();
212
+ const updated = questions.map((question) => ({ ...question }));
213
+ for (const { index, answer } of resolved) {
214
+ updated[index] = {
215
+ ...updated[index],
216
+ answer: {
217
+ chosenLabels: answer.chosenLabels ?? [],
218
+ ...(answer.freeText ? { freeText: answer.freeText } : {}),
219
+ answeredAt: at
220
+ }
221
+ };
222
+ }
223
+ const status = statusFor(updated);
224
+ await writeJsonAtomic(file, {
225
+ ...stored,
226
+ schemaVersion: QUESTIONS_SCHEMA_VERSION,
227
+ round,
228
+ questions: updated,
229
+ status,
230
+ // Stamped only when the round is complete: a half-answered round has no answered time, and
231
+ // claiming one would make an open gate look settled to every later reader.
232
+ ...(status === "ANSWERED" ? { answeredAt: at } : {})
233
+ });
234
+ // SHAPE ONLY, deliberately. The counts ride the event stream, which stays free of prose; the
235
+ // text goes out at batch close by its own route. `recommendedCount` is the one worth having
236
+ // centrally — CF74's "18 of 19 answered with the recommendation" was a hand count off one
237
+ // run's local files, and it is the number that says whether a card round was worth its wait.
238
+ const answered = updated.filter((question) => question.answer !== undefined);
239
+ emitDomainTelemetryEvent("operator.answers_recorded", {
240
+ round,
241
+ questionCount: updated.length,
242
+ answeredCount: answered.length,
243
+ freeTextCount: answered.filter((question) => question.answer?.freeText !== undefined).length,
244
+ recommendedCount: answered.filter((question) => tookRecommendation(question)).length
245
+ }, { stage: "operator-gate", outcome: status === "ANSWERED" ? "answered" : "partial" });
246
+ const open = updated.length - answered.length;
247
+ return ok([
248
+ `round ${round} recorded ${resolved.length} answer(s) -> ${file}`,
249
+ ...resolved.map(({ index }) => ` ${updated[index].questionId} [${updated[index].header}] ${describeAnswer(updated[index])}`),
250
+ open > 0
251
+ ? ` ${open} question(s) in this round still unanswered.`
252
+ : " every question in this round is answered.",
253
+ "",
254
+ "Now record what the answer CHANGED: resolve_blocker on each affected requirement, or re-run",
255
+ "that analyzer when the answer changes which components exist."
256
+ ].join("\n"), {
257
+ round,
258
+ status,
259
+ recorded: resolved.length,
260
+ answeredCount: answered.length,
261
+ openCount: open
262
+ });
263
+ }
264
+ catch (error) {
265
+ if (error instanceof WorkspaceError)
266
+ return fail(error.code, error.message);
267
+ return internalError(error);
268
+ }
269
+ });
270
+ }
271
+ /** Did the operator simply confirm the recommendation? Exactly one choice, and it is the recommended one. */
272
+ function tookRecommendation(question) {
273
+ const recommended = (question.options ?? []).find((option) => option.recommended === true);
274
+ const chosen = question.answer?.chosenLabels ?? [];
275
+ return recommended !== undefined && chosen.length === 1 && chosen[0] === recommended.label;
276
+ }
277
+ function describeAnswer(question) {
278
+ const answer = question.answer;
279
+ if (!answer)
280
+ return "(unanswered)";
281
+ const parts = [];
282
+ if (answer.chosenLabels.length > 0)
283
+ parts.push(answer.chosenLabels.join(" + "));
284
+ if (answer.freeText)
285
+ parts.push(`free text: ${answer.freeText}`);
286
+ return parts.join("; ");
287
+ }