@kontourai/survey 3.0.0 → 5.0.0

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 (46) hide show
  1. package/README.md +4 -0
  2. package/dist/examples/calibrated-auto-accept.d.ts +22 -15
  3. package/dist/examples/calibrated-auto-accept.js +40 -36
  4. package/dist/examples/review-workbench/server-apply-consumer.js +5 -0
  5. package/dist/src/calibration.d.ts +48 -21
  6. package/dist/src/calibration.js +72 -33
  7. package/dist/src/canonical-reviewed-trust-input.js +73 -34
  8. package/dist/src/console/review-console-server.d.ts +3 -1
  9. package/dist/src/console/review-console-server.js +203 -50
  10. package/dist/src/extraction-envelope.d.ts +81 -3
  11. package/dist/src/extraction-envelope.js +183 -24
  12. package/dist/src/index.d.ts +9 -8
  13. package/dist/src/index.js +3 -3
  14. package/dist/src/inquiry-mapping.d.ts +15 -1
  15. package/dist/src/inquiry-mapping.js +10 -2
  16. package/dist/src/mcp/review-mcp.js +112 -90
  17. package/dist/src/producer-profile.d.ts +41 -2
  18. package/dist/src/producer-profile.js +29 -2
  19. package/dist/src/review-session-file.d.ts +64 -0
  20. package/dist/src/review-session-file.js +320 -0
  21. package/dist/src/review-workbench/edited-value.d.ts +70 -0
  22. package/dist/src/review-workbench/edited-value.js +147 -0
  23. package/dist/src/review-workbench/extraction-inspector.d.ts +15 -1
  24. package/dist/src/review-workbench/extraction-inspector.js +55 -19
  25. package/dist/src/review-workbench/queue-binding.js +1 -1
  26. package/dist/src/review-workbench/review-presentation.d.ts +46 -1
  27. package/dist/src/review-workbench/review-presentation.js +72 -1
  28. package/dist/src/review-workbench/review-queue-session.d.ts +19 -5
  29. package/dist/src/review-workbench/review-queue-session.js +54 -14
  30. package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
  31. package/dist/src/review-workbench/review-session-replay.js +82 -3
  32. package/dist/src/review-workbench/review-workbench-css.generated.js +2 -0
  33. package/dist/src/review-workbench/review-workbench.css +2 -0
  34. package/dist/src/review-workbench/review-workbench.d.ts +22 -11
  35. package/dist/src/review-workbench/review-workbench.js +91 -26
  36. package/dist/src/review-workbench/review-workbench.standalone.css +2 -0
  37. package/dist/src/review-workbench/server-review-session.d.ts +3 -1
  38. package/dist/src/review-workbench/server-review-session.js +1 -0
  39. package/dist/src/reviewed-candidate-resolution.js +13 -7
  40. package/dist/src/schema-mapping.d.ts +23 -0
  41. package/dist/src/schema-mapping.js +30 -20
  42. package/dist/src/surface-reviewed-extraction.js +4 -0
  43. package/dist/src/to-surface.d.ts +30 -6
  44. package/dist/src/to-surface.js +312 -18
  45. package/dist/src/types.d.ts +44 -1
  46. package/package.json +5 -4
@@ -1,10 +1,11 @@
1
- import { readFile, writeFile, rename } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { resolve, dirname } from "node:path";
3
3
  import { McpServer } from "@modelcontextprotocol/server";
4
4
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
5
5
  import { z } from "zod";
6
- import { buildReviewSessionEvents, currentReviewItem, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
6
+ import { buildReviewSessionEvents, currentReviewItem, decisionSelectsNoCandidate, deriveQueueRowStatus, nextUnresolvedItemName, reviewSessionSummary, workbenchDecisionDefinitions, } from "../review-workbench/review-workbench.js";
7
7
  import { createServerReviewSessionRecord, currentSessionState, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
8
+ import { appendReviewSessionEvents, readReviewSessionFile, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
8
9
  const SESSION_NAME = "mcp-review-session";
9
10
  const UI_RESOURCE_URI_META_KEY = "ui/resourceUri";
10
11
  const UI_CAPABILITY_EXTENSION = "io.modelcontextprotocol/ui";
@@ -19,13 +20,7 @@ const MCP_DECISION_MAP = {
19
20
  "could-not-confirm": "could-not-confirm",
20
21
  };
21
22
  async function readSessionFile(path) {
22
- const raw = await readFile(path, "utf8");
23
- return JSON.parse(raw);
24
- }
25
- async function writeSessionFileAtomic(path, content) {
26
- const tmp = `${path}.tmp`;
27
- await writeFile(tmp, JSON.stringify(content, null, 2), "utf8");
28
- await rename(tmp, path);
23
+ return readReviewSessionFile(path);
29
24
  }
30
25
  // ---- Queue helpers -------------------------------------------------------
31
26
  function queueSummaryText(snapshot, events) {
@@ -58,7 +53,7 @@ function itemDetailText(item, snapshot, events) {
58
53
  const decision = current.decisionsByItemName[item.metadata.name];
59
54
  const note = current.notesByItemName[item.metadata.name];
60
55
  const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
61
- const proposedCandidate = item.spec.candidates.find((c) => c.role === "proposed");
56
+ const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
62
57
  const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v);
63
58
  const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "unknown";
64
59
  const lines = [
@@ -74,10 +69,15 @@ function itemDetailText(item, snapshot, events) {
74
69
  ` source: ${currentCandidate?.source?.sourceRef ?? "none"}`,
75
70
  ...(currentCandidate?.locator?.excerpt ? [` excerpt: ${currentCandidate.locator.excerpt}`] : []),
76
71
  ``,
77
- `Proposed value: ${valueStr(proposedCandidate?.value ?? "(none)")}`,
78
- ` confidence: ${confStr(proposedCandidate?.extraction?.confidence ?? proposedCandidate?.confidence)}`,
79
- ` source: ${proposedCandidate?.source?.sourceRef ?? "none"}`,
80
- ...(proposedCandidate?.locator?.excerpt ? [` excerpt: ${proposedCandidate.locator.excerpt}`] : []),
72
+ ...(proposedCandidates.length > 1
73
+ ? [`Conflict: ${proposedCandidates.length} proposed values. Accept is refused; reject them all or use could-not-confirm with a reason.`]
74
+ : []),
75
+ ...(proposedCandidates.length === 0 ? [`Proposed value: (none)`] : proposedCandidates.flatMap((candidate) => [
76
+ `Proposed value: ${valueStr(candidate.value)}`,
77
+ ` confidence: ${confStr(candidate.extraction?.confidence ?? candidate.confidence)}`,
78
+ ` source: ${candidate.source?.sourceRef ?? "none"}`,
79
+ ...(candidate.locator?.excerpt ? [` excerpt: ${candidate.locator.excerpt}`] : []),
80
+ ])),
81
81
  ];
82
82
  if (item.spec.rationale) {
83
83
  lines.push(``, `Rationale: ${item.spec.rationale}`);
@@ -104,19 +104,32 @@ function buildReviewCardHtml(item, snapshot, events) {
104
104
  const total = current.items.length;
105
105
  const resolved = total - summary.unresolved;
106
106
  const currentCandidate = item.spec.candidates.find((c) => c.role === "current");
107
- const proposedCandidate = item.spec.candidates.find((c) => c.role === "proposed");
107
+ const proposedCandidates = item.spec.candidates.filter((c) => c.role === "proposed");
108
+ // Several proposed values are a conflict: every value is shown, and accept
109
+ // (which names a role, not a value) is not offered.
110
+ const conflict = proposedCandidates.length > 1;
108
111
  const decision = current.decisionsByItemName[item.metadata.name];
109
112
  const status = deriveQueueRowStatus(item, current);
110
113
  const valueStr = (v) => typeof v === "string" ? v : JSON.stringify(v, null, 2);
111
114
  const confStr = (c) => c !== undefined ? `${Math.round(c * 100)}%` : "—";
112
115
  const currentValue = valueStr(currentCandidate?.value ?? "—");
113
- const proposedValue = valueStr(proposedCandidate?.value ?? "—");
114
116
  const currentConf = confStr(currentCandidate?.extraction?.confidence ?? currentCandidate?.confidence);
115
- const proposedConf = confStr(proposedCandidate?.extraction?.confidence ?? proposedCandidate?.confidence);
116
117
  const currentSource = currentCandidate?.source?.sourceRef ?? "—";
117
- const proposedSource = proposedCandidate?.source?.sourceRef ?? "—";
118
118
  const currentExcerpt = currentCandidate?.locator?.excerpt ?? "";
119
- const proposedExcerpt = proposedCandidate?.locator?.excerpt ?? "";
119
+ const proposedCard = (candidate, label) => {
120
+ const value = valueStr(candidate?.value ?? "—");
121
+ const excerpt = candidate?.locator?.excerpt ?? "";
122
+ return `<div class="card is-proposed">
123
+ <div class="card-label">${escapeHtml(label)}</div>
124
+ <div class="value">${value.includes("\n") ? `<pre>${escapeHtml(value)}</pre>` : escapeHtml(value)}</div>
125
+ <div class="conf">confidence ${confStr(candidate?.extraction?.confidence ?? candidate?.confidence)}</div>
126
+ <div class="source-ref">${escapeHtml(candidate?.source?.sourceRef ?? "—")}</div>
127
+ ${excerpt ? `<div class="excerpt">${escapeHtml(excerpt)}</div>` : ""}
128
+ </div>`;
129
+ };
130
+ const proposedCards = conflict
131
+ ? proposedCandidates.map((candidate, index) => proposedCard(candidate, `Proposed ${index + 1} of ${proposedCandidates.length}`)).join("\n ")
132
+ : proposedCard(proposedCandidates[0], "Proposed");
120
133
  const itemNameJson = escapeJsonInHtml(item.metadata.name);
121
134
  const decisionBadge = decision
122
135
  ? `<span class="badge badge-${decision === "accept-proposed" ? "accept" : decision === "reject-proposed" ? "reject" : "hold"}">${escapeHtml(workbenchDecisionDefinitions[decision].label)}</span>`
@@ -205,6 +218,7 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
205
218
  <div class="meta">
206
219
  <span>${escapeHtml(item.metadata.name)}</span>
207
220
  ${decisionBadge}
221
+ ${conflict ? `<span class="badge badge-hold" id="conflict-badge">Conflict: ${proposedCandidates.length} values</span>` : ""}
208
222
  <span class="progress">${resolved}/${total} resolved</span>
209
223
  </div>
210
224
 
@@ -216,14 +230,9 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
216
230
  <div class="source-ref">${escapeHtml(currentSource)}</div>
217
231
  ${currentExcerpt ? `<div class="excerpt">${escapeHtml(currentExcerpt)}</div>` : ""}
218
232
  </div>
219
- <div class="card is-proposed">
220
- <div class="card-label">Proposed</div>
221
- <div class="value">${proposedValue.includes("\n") ? `<pre>${escapeHtml(proposedValue)}</pre>` : escapeHtml(proposedValue)}</div>
222
- <div class="conf">confidence ${proposedConf}</div>
223
- <div class="source-ref">${escapeHtml(proposedSource)}</div>
224
- ${proposedExcerpt ? `<div class="excerpt">${escapeHtml(proposedExcerpt)}</div>` : ""}
225
- </div>
233
+ ${proposedCards}
226
234
  </div>
235
+ ${conflict ? `<p class="feedback" id="conflict-note">${proposedCandidates.length} different values were proposed. This card cannot choose one of them yet: reject them all, or use Could not confirm with a reason.</p>` : ""}
227
236
 
228
237
  <div class="divider"></div>
229
238
 
@@ -231,9 +240,9 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
231
240
  <textarea class="note-input" id="note" placeholder="Add a rationale for this decision...">${escapeHtml(current.notesByItemName[item.metadata.name] ?? "")}</textarea>
232
241
 
233
242
  <div class="btn-row">
234
- <button class="btn btn-accept${decision === "accept-proposed" ? " active" : ""}" id="btn-accept">Accept proposed</button>
235
- <button class="btn btn-hold${decision === "keep-current" ? " active" : ""}" id="btn-hold">Hold / Keep current</button>
236
- <button class="btn btn-reject${decision === "reject-proposed" ? " active" : ""}" id="btn-reject">Reject proposed</button>
243
+ ${conflict ? "" : `<button class="btn btn-accept${decision === "accept-proposed" ? " active" : ""}" id="btn-accept">Accept proposed</button>`}
244
+ ${currentCandidate ? `<button class="btn btn-hold${decision === "keep-current" ? " active" : ""}" id="btn-hold">Hold / Keep current</button>` : ""}
245
+ <button class="btn btn-reject${decision === "reject-proposed" ? " active" : ""}" id="btn-reject">${conflict ? "Reject all values" : "Reject proposed"}</button>
237
246
  <button class="btn btn-unconfirmed${decision === "could-not-confirm" ? " active" : ""}" id="btn-unconfirmed">Could not confirm</button>
238
247
  </div>
239
248
  <div class="feedback" id="feedback"></div>
@@ -264,8 +273,10 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
264
273
  return true;
265
274
  }
266
275
 
267
- document.getElementById('btn-accept').addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
268
- document.getElementById('btn-hold').addEventListener('click', function () { postDecision('hold'); document.getElementById('feedback').textContent = 'Submitting hold…'; });
276
+ var acceptButton = document.getElementById('btn-accept');
277
+ if (acceptButton) acceptButton.addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
278
+ var holdButton = document.getElementById('btn-hold');
279
+ if (holdButton) holdButton.addEventListener('click', function () { postDecision('hold'); document.getElementById('feedback').textContent = 'Submitting hold…'; });
269
280
  document.getElementById('btn-reject').addEventListener('click', function () { postDecision('reject'); document.getElementById('feedback').textContent = 'Submitting reject…'; });
270
281
  document.getElementById('btn-unconfirmed').addEventListener('click', function () { if (postDecision('could-not-confirm')) document.getElementById('feedback').textContent = 'Submitting could not confirm…'; });
271
282
 
@@ -353,72 +364,83 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
353
364
  if (wbDecision === "could-not-confirm" && !note?.trim()) {
354
365
  throw new DomainError("survey_review_decide requires a non-empty reason for could-not-confirm");
355
366
  }
356
- const file = await readSessionFile(options.sessionPath);
357
- const { snapshot, events } = file;
358
- const current = currentSessionState(snapshot, events);
359
- const item = current.items.find((i) => i.metadata.name === itemName);
360
- if (!item) {
361
- throw new DomainError(`Unknown review item: ${itemName}`);
362
- }
363
- const existingDecision = current.decisionsByItemName[item.metadata.name];
364
- if (existingDecision) {
365
- throw new DomainError(`Item ${itemName} already has a decision: ${existingDecision}. Use a new session to re-decide.`);
366
- }
367
- // Build the updated session state with the decision
368
- const sessionWithDecision = {
369
- ...current,
370
- decisionsByItemName: {
371
- ...current.decisionsByItemName,
372
- [itemName]: wbDecision,
373
- },
374
- ...(note !== undefined
375
- ? {
376
- notesByItemName: {
377
- ...current.notesByItemName,
378
- [itemName]: note,
379
- },
380
- }
381
- : {}),
382
- ...(attemptEvidenceIds?.length
383
- ? {
384
- attemptEvidenceIdsByItemName: {
385
- ...current.attemptEvidenceIdsByItemName,
386
- [itemName]: [...attemptEvidenceIds],
387
- },
388
- }
389
- : {}),
390
- };
391
- // Use the server session APIs for apply-path validation
392
- const record = createServerReviewSessionRecord({
393
- sessionName: SESSION_NAME,
394
- snapshot,
395
- eventCount: events.length,
396
- updatedAt: new Date(),
397
- });
398
- const newEvents = buildReviewSessionEvents(sessionWithDecision, SESSION_NAME);
399
- const applyResult = deriveServerReviewSessionApplyResult({
400
- record,
401
- events: newEvents,
402
- requiredResolvedItems: "none",
367
+ // Read, validate and write inside the shared session lock so a concurrent
368
+ // decide or console save cannot interleave and drop this decision (#281).
369
+ const { snapshot, sessionWithDecision, newEvents } = await updateReviewSessionFile(options.sessionPath, (file) => {
370
+ const { snapshot, events } = file;
371
+ const current = currentSessionState(snapshot, events);
372
+ const item = current.items.find((i) => i.metadata.name === itemName);
373
+ if (!item) {
374
+ throw new DomainError(`Unknown review item: ${itemName}`);
375
+ }
376
+ const existingDecision = current.decisionsByItemName[item.metadata.name];
377
+ if (existingDecision) {
378
+ throw new DomainError(`Item ${itemName} already has a decision: ${existingDecision}. Use a new session to re-decide.`);
379
+ }
380
+ // Build the updated session state with the decision
381
+ const sessionWithDecision = {
382
+ ...current,
383
+ decisionsByItemName: {
384
+ ...current.decisionsByItemName,
385
+ [itemName]: wbDecision,
386
+ },
387
+ ...(note !== undefined
388
+ ? {
389
+ notesByItemName: {
390
+ ...current.notesByItemName,
391
+ [itemName]: note,
392
+ },
393
+ }
394
+ : {}),
395
+ ...(attemptEvidenceIds?.length
396
+ ? {
397
+ attemptEvidenceIdsByItemName: {
398
+ ...current.attemptEvidenceIdsByItemName,
399
+ [itemName]: [...attemptEvidenceIds],
400
+ },
401
+ }
402
+ : {}),
403
+ };
404
+ // Append only this decision's events (its note, then the decision) to the
405
+ // stored log. Regenerating the whole log from state would erase earlier
406
+ // reversals and note changes recorded by the console (#281).
407
+ const sessionName = storedReviewSessionName(file, SESSION_NAME);
408
+ const decisionEvents = buildReviewSessionEvents(sessionWithDecision, sessionName).filter((event) => event.spec.reviewItemName === itemName
409
+ && (event.spec.eventType === "decision-changed"
410
+ || event.spec.eventType === "decision-submitted"
411
+ || (event.spec.eventType === "note-changed" && note !== undefined)));
412
+ const newEvents = appendReviewSessionEvents(file, decisionEvents);
413
+ // Use the server session APIs for apply-path validation
414
+ const record = createServerReviewSessionRecord({
415
+ sessionName,
416
+ snapshot,
417
+ eventCount: events.length,
418
+ updatedAt: new Date(),
419
+ });
420
+ const applyResult = deriveServerReviewSessionApplyResult({
421
+ record,
422
+ events: newEvents,
423
+ requiredResolvedItems: "none",
424
+ });
425
+ if (!applyResult.ok) {
426
+ throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
427
+ }
428
+ const updatedFile = {
429
+ session: file.session,
430
+ snapshot,
431
+ events: newEvents,
432
+ };
433
+ return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents } };
403
434
  });
404
- if (!applyResult.ok) {
405
- throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
406
- }
407
- // Persist atomically
408
- const updatedFile = {
409
- session: file.session,
410
- snapshot,
411
- events: newEvents,
412
- };
413
- await writeSessionFileAtomic(options.sessionPath, updatedFile);
414
435
  // Summarize the result
415
436
  const updatedItem = sessionWithDecision.items.find((i) => i.metadata.name === itemName);
416
437
  const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents) : `Item: ${itemName}`;
417
438
  const remainingText = queueSummaryText(snapshot, newEvents);
418
439
  const definition = workbenchDecisionDefinitions[wbDecision];
440
+ const conflictRejected = wbDecision === "reject-proposed" && updatedItem !== undefined && decisionSelectsNoCandidate(updatedItem, wbDecision);
419
441
  const text = [
420
- `Decision recorded: ${definition.label}`,
421
- `Effect: ${definition.effect}`,
442
+ `Decision recorded: ${conflictRejected ? "Reject all values" : definition.label}`,
443
+ `Effect: ${conflictRejected ? "Every proposed value is rejected; none becomes the claim's value." : definition.effect}`,
422
444
  "",
423
445
  itemText,
424
446
  "",
@@ -129,6 +129,20 @@ export declare const AUTO_ACCEPT_WITHIN_COMFORT_ZONE: true;
129
129
  * candidate's evidence gets passed into that decision.
130
130
  */
131
131
  export declare function meetsAutoAcceptThreshold(confidence: number, minConfidence: number): boolean;
132
+ /**
133
+ * Refuse an auto-accept policy threshold that cannot express a comfort zone:
134
+ * `minConfidence` must be a finite number in (0, 1]. A threshold of 0 (or
135
+ * below) would accept every proposal, and one above 1 accepts only
136
+ * out-of-range self-reports, so either makes `withinComfortZone: true` a
137
+ * false statement. Throws `RangeError`.
138
+ */
139
+ export declare function assertValidAutoAcceptThreshold(minConfidence: number): void;
140
+ /**
141
+ * Whether a proposal's self-reported confidence is usable by the auto-accept
142
+ * gate: a finite number in [0, 1]. Anything else (7, -5, NaN) is never
143
+ * auto-accepted; the proposal stays in human review.
144
+ */
145
+ export declare function isAutoAcceptConfidenceInRange(confidence: number): boolean;
132
146
  /**
133
147
  * The accepted-candidate-shaped evidence `evaluateAutoAccept` decides over.
134
148
  * Deliberately narrow: only the fields the auto-accept policy itself reads,
@@ -157,6 +171,18 @@ export interface AutoAcceptEvidence {
157
171
  */
158
172
  proposedAt?: string;
159
173
  }
174
+ /**
175
+ * A proposal the auto-accept policy refused because its self-reported
176
+ * confidence is not a finite number in [0, 1]. The proposal is left for human
177
+ * review; the warning records why it was not auto-accepted.
178
+ */
179
+ export interface AutoAcceptWarning {
180
+ code: "confidence-out-of-range";
181
+ /** The refused proposal's id. */
182
+ proposalId: string;
183
+ /** The out-of-range confidence exactly as reported. */
184
+ confidence: number;
185
+ }
160
186
  /** The auto-accept policy `evaluateAutoAccept` gates against. */
161
187
  export interface AutoAcceptPolicy {
162
188
  /** Minimum confidence (inclusive) a proposal must clear to auto-accept. */
@@ -164,8 +190,17 @@ export interface AutoAcceptPolicy {
164
190
  }
165
191
  /** The unified auto-accept decision `evaluateAutoAccept` returns. */
166
192
  export interface AutoAcceptDecision {
167
- /** `true` iff there is no conflict and `evidence.confidence` clears `policy.minConfidence`. */
193
+ /**
194
+ * `true` iff there is no conflict, `evidence.confidence` is a finite number
195
+ * in [0, 1], and it clears `policy.minConfidence`.
196
+ */
168
197
  accepted: boolean;
198
+ /**
199
+ * Set when the proposal was refused because its self-reported confidence
200
+ * is not a finite number in [0, 1]. The proposal is not auto-accepted and
201
+ * stays in human review.
202
+ */
203
+ warning?: "confidence-out-of-range";
169
204
  /** The confidence value that was gated on (== `evidence.confidence`). */
170
205
  confidence: number;
171
206
  /** Composed rationale — always computed; callers only use it when `accepted`. */
@@ -192,7 +227,11 @@ export interface AutoAcceptDecision {
192
227
  * back to `fallbackTimestamp` (and reporting which source was used via
193
228
  * `reviewedAtSource`) when a profile's evidence carries no timestamp of
194
229
  * its own.
195
- * 4. Always report `actor: AUTO_ACCEPT_ACTOR` and
230
+ * 4. Refuse out-of-range inputs: throw `RangeError` unless
231
+ * `policy.minConfidence` is a finite number in (0, 1], and never accept a
232
+ * proposal whose confidence is not a finite number in [0, 1] (reported via
233
+ * `warning`). This is what keeps `withinComfortZone: true` truthful.
234
+ * 5. Always report `actor: AUTO_ACCEPT_ACTOR` and
196
235
  * `withinComfortZone: AUTO_ACCEPT_WITHIN_COMFORT_ZONE` (ADR 0003 §4:
197
236
  * auto-accept only ever yields "assumed" with the comfort-zone posture).
198
237
  *
@@ -119,6 +119,26 @@ export const AUTO_ACCEPT_WITHIN_COMFORT_ZONE = true;
119
119
  export function meetsAutoAcceptThreshold(confidence, minConfidence) {
120
120
  return confidence >= minConfidence;
121
121
  }
122
+ /**
123
+ * Refuse an auto-accept policy threshold that cannot express a comfort zone:
124
+ * `minConfidence` must be a finite number in (0, 1]. A threshold of 0 (or
125
+ * below) would accept every proposal, and one above 1 accepts only
126
+ * out-of-range self-reports, so either makes `withinComfortZone: true` a
127
+ * false statement. Throws `RangeError`.
128
+ */
129
+ export function assertValidAutoAcceptThreshold(minConfidence) {
130
+ if (typeof minConfidence !== "number" || !Number.isFinite(minConfidence) || minConfidence <= 0 || minConfidence > 1) {
131
+ throw new RangeError(`Auto-accept minConfidence must be a finite number in (0, 1]; received ${String(minConfidence)}.`);
132
+ }
133
+ }
134
+ /**
135
+ * Whether a proposal's self-reported confidence is usable by the auto-accept
136
+ * gate: a finite number in [0, 1]. Anything else (7, -5, NaN) is never
137
+ * auto-accepted; the proposal stays in human review.
138
+ */
139
+ export function isAutoAcceptConfidenceInRange(confidence) {
140
+ return typeof confidence === "number" && Number.isFinite(confidence) && confidence >= 0 && confidence <= 1;
141
+ }
122
142
  /**
123
143
  * The one core auto-accept policy decision every Producer Profile delegates
124
144
  * to, per the owner-accepted semantics recorded in
@@ -132,7 +152,11 @@ export function meetsAutoAcceptThreshold(confidence, minConfidence) {
132
152
  * back to `fallbackTimestamp` (and reporting which source was used via
133
153
  * `reviewedAtSource`) when a profile's evidence carries no timestamp of
134
154
  * its own.
135
- * 4. Always report `actor: AUTO_ACCEPT_ACTOR` and
155
+ * 4. Refuse out-of-range inputs: throw `RangeError` unless
156
+ * `policy.minConfidence` is a finite number in (0, 1], and never accept a
157
+ * proposal whose confidence is not a finite number in [0, 1] (reported via
158
+ * `warning`). This is what keeps `withinComfortZone: true` truthful.
159
+ * 5. Always report `actor: AUTO_ACCEPT_ACTOR` and
136
160
  * `withinComfortZone: AUTO_ACCEPT_WITHIN_COMFORT_ZONE` (ADR 0003 §4:
137
161
  * auto-accept only ever yields "assumed" with the comfort-zone posture).
138
162
  *
@@ -142,7 +166,9 @@ export function meetsAutoAcceptThreshold(confidence, minConfidence) {
142
166
  * inline `ReviewOutcome`) from this decision's fields.
143
167
  */
144
168
  export function evaluateAutoAccept(evidence, hasConflict, policy, fallbackTimestamp) {
145
- const accepted = !hasConflict && meetsAutoAcceptThreshold(evidence.confidence, policy.minConfidence);
169
+ assertValidAutoAcceptThreshold(policy.minConfidence);
170
+ const inRange = isAutoAcceptConfidenceInRange(evidence.confidence);
171
+ const accepted = !hasConflict && inRange && meetsAutoAcceptThreshold(evidence.confidence, policy.minConfidence);
146
172
  const rationale = `Auto-accepted: confidence ${evidence.confidence} >= threshold ${policy.minConfidence}.` +
147
173
  (evidence.rationale !== undefined ? ` ${evidence.rationale}` : "");
148
174
  const reviewedAt = evidence.proposedAt ?? fallbackTimestamp;
@@ -154,5 +180,6 @@ export function evaluateAutoAccept(evidence, hasConflict, policy, fallbackTimest
154
180
  reviewedAtSource: evidence.proposedAt !== undefined ? "proposedAt" : "fallback",
155
181
  actor: AUTO_ACCEPT_ACTOR,
156
182
  withinComfortZone: AUTO_ACCEPT_WITHIN_COMFORT_ZONE,
183
+ ...(inRange ? {} : { warning: "confidence-out-of-range" }),
157
184
  };
158
185
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Shared session-file persistence for the local review writers
3
+ * (`survey-review-console` and `survey-review-mcp`).
4
+ *
5
+ * Every write goes through {@link updateReviewSessionFile}, which takes one
6
+ * exclusive lock file next to the session, re-reads the session inside the
7
+ * lock, applies the caller's change, writes a uniquely named temp file and
8
+ * renames it into place. Both writers must use this helper: a lock that only
9
+ * one of them takes still races with the other (kontourai/survey#281).
10
+ */
11
+ import type { ReviewQueueSessionState } from "./review-workbench/review-queue-session.js";
12
+ import type { ReviewSessionEvent } from "./review-resource.js";
13
+ export interface ReviewSessionFileContent {
14
+ readonly session: unknown;
15
+ readonly snapshot: ReviewQueueSessionState;
16
+ readonly events: readonly ReviewSessionEvent[];
17
+ }
18
+ export interface ReviewSessionFileLockOptions {
19
+ /** How long to wait for a busy lock before failing. Defaults to 10s. */
20
+ readonly timeoutMs?: number;
21
+ /** A lock older than this is treated as abandoned. Defaults to 30s. */
22
+ readonly staleMs?: number;
23
+ }
24
+ export declare class ReviewSessionFileLockTimeoutError extends Error {
25
+ constructor(lockPath: string, timeoutMs: number);
26
+ }
27
+ export declare function reviewSessionLockPath(sessionPath: string): string;
28
+ export declare function readReviewSessionFile<T extends ReviewSessionFileContent = ReviewSessionFileContent>(sessionPath: string): Promise<T>;
29
+ /**
30
+ * Opaque revision token for an event log: a digest of its serialized form.
31
+ * An event count is not enough, because the log is regenerated from session
32
+ * state and a changed decision keeps the count while changing the content.
33
+ */
34
+ export declare function reviewSessionRevision(events: readonly ReviewSessionEvent[]): string;
35
+ /**
36
+ * Acquire the exclusive session lock. Resolves with a release function.
37
+ * Exported so tests can hold the lock and observe that writers wait for it.
38
+ */
39
+ export declare function acquireReviewSessionFileLock(sessionPath: string, options?: ReviewSessionFileLockOptions): Promise<() => Promise<void>>;
40
+ /**
41
+ * Locked read-modify-write of a session file. `mutate` receives the content
42
+ * read inside the lock and returns the content to write, or `undefined` to
43
+ * leave the file untouched. Whatever `mutate` throws propagates after the lock
44
+ * is released.
45
+ */
46
+ export declare function updateReviewSessionFile<T extends ReviewSessionFileContent, R>(sessionPath: string, mutate: (current: T) => Promise<{
47
+ readonly next?: T;
48
+ readonly result: R;
49
+ }> | {
50
+ readonly next?: T;
51
+ readonly result: R;
52
+ }, options?: ReviewSessionFileLockOptions): Promise<R>;
53
+ /**
54
+ * The session name the stored log is recorded under: the name its events
55
+ * already carry, else the stored ReviewSession's name, else `fallback` (the
56
+ * writer's own default). Appended events are renamed into it so one log never mixes names.
57
+ */
58
+ export declare function storedReviewSessionName(content: ReviewSessionFileContent, fallback?: string): string;
59
+ /**
60
+ * Append events to the stored log, renumbering them after the stored events
61
+ * and renaming them into the stored session. The stored log is never
62
+ * rewritten, so decision reversals and note changes stay on record.
63
+ */
64
+ export declare function appendReviewSessionEvents(content: ReviewSessionFileContent, appended: readonly ReviewSessionEvent[]): ReviewSessionEvent[];