@kontourai/survey 3.0.0 → 4.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 (37) hide show
  1. package/README.md +4 -0
  2. package/dist/example-data/public-directory-review-resource.d.ts +3 -3
  3. package/dist/examples/calibrated-auto-accept.d.ts +22 -15
  4. package/dist/examples/calibrated-auto-accept.js +40 -36
  5. package/dist/src/calibration.d.ts +48 -21
  6. package/dist/src/calibration.js +72 -33
  7. package/dist/src/console/review-console-server.d.ts +3 -1
  8. package/dist/src/console/review-console-server.js +203 -50
  9. package/dist/src/extraction-envelope.d.ts +22 -0
  10. package/dist/src/extraction-envelope.js +25 -4
  11. package/dist/src/index.d.ts +8 -7
  12. package/dist/src/index.js +2 -2
  13. package/dist/src/inquiry-mapping.d.ts +15 -1
  14. package/dist/src/inquiry-mapping.js +10 -2
  15. package/dist/src/mcp/review-mcp.js +70 -65
  16. package/dist/src/producer-profile.d.ts +41 -2
  17. package/dist/src/producer-profile.js +29 -2
  18. package/dist/src/review-session-file.d.ts +64 -0
  19. package/dist/src/review-session-file.js +320 -0
  20. package/dist/src/review-workbench/edited-value.d.ts +70 -0
  21. package/dist/src/review-workbench/edited-value.js +147 -0
  22. package/dist/src/review-workbench/review-presentation.d.ts +44 -0
  23. package/dist/src/review-workbench/review-presentation.js +49 -0
  24. package/dist/src/review-workbench/review-queue-session.js +8 -1
  25. package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
  26. package/dist/src/review-workbench/review-session-replay.js +77 -0
  27. package/dist/src/review-workbench/review-workbench.d.ts +8 -4
  28. package/dist/src/review-workbench/review-workbench.js +19 -7
  29. package/dist/src/review-workbench/server-review-session.d.ts +3 -1
  30. package/dist/src/review-workbench/server-review-session.js +1 -0
  31. package/dist/src/reviewed-candidate-resolution.js +13 -7
  32. package/dist/src/schema-mapping.d.ts +23 -0
  33. package/dist/src/schema-mapping.js +30 -20
  34. package/dist/src/to-surface.d.ts +30 -6
  35. package/dist/src/to-surface.js +196 -18
  36. package/dist/src/types.d.ts +39 -0
  37. 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
6
  import { buildReviewSessionEvents, currentReviewItem, 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) {
@@ -353,64 +348,74 @@ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, optio
353
348
  if (wbDecision === "could-not-confirm" && !note?.trim()) {
354
349
  throw new DomainError("survey_review_decide requires a non-empty reason for could-not-confirm");
355
350
  }
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",
351
+ // Read, validate and write inside the shared session lock so a concurrent
352
+ // decide or console save cannot interleave and drop this decision (#281).
353
+ const { snapshot, sessionWithDecision, newEvents } = await updateReviewSessionFile(options.sessionPath, (file) => {
354
+ const { snapshot, events } = file;
355
+ const current = currentSessionState(snapshot, events);
356
+ const item = current.items.find((i) => i.metadata.name === itemName);
357
+ if (!item) {
358
+ throw new DomainError(`Unknown review item: ${itemName}`);
359
+ }
360
+ const existingDecision = current.decisionsByItemName[item.metadata.name];
361
+ if (existingDecision) {
362
+ throw new DomainError(`Item ${itemName} already has a decision: ${existingDecision}. Use a new session to re-decide.`);
363
+ }
364
+ // Build the updated session state with the decision
365
+ const sessionWithDecision = {
366
+ ...current,
367
+ decisionsByItemName: {
368
+ ...current.decisionsByItemName,
369
+ [itemName]: wbDecision,
370
+ },
371
+ ...(note !== undefined
372
+ ? {
373
+ notesByItemName: {
374
+ ...current.notesByItemName,
375
+ [itemName]: note,
376
+ },
377
+ }
378
+ : {}),
379
+ ...(attemptEvidenceIds?.length
380
+ ? {
381
+ attemptEvidenceIdsByItemName: {
382
+ ...current.attemptEvidenceIdsByItemName,
383
+ [itemName]: [...attemptEvidenceIds],
384
+ },
385
+ }
386
+ : {}),
387
+ };
388
+ // Append only this decision's events (its note, then the decision) to the
389
+ // stored log. Regenerating the whole log from state would erase earlier
390
+ // reversals and note changes recorded by the console (#281).
391
+ const sessionName = storedReviewSessionName(file, SESSION_NAME);
392
+ const decisionEvents = buildReviewSessionEvents(sessionWithDecision, sessionName).filter((event) => event.spec.reviewItemName === itemName
393
+ && (event.spec.eventType === "decision-changed"
394
+ || event.spec.eventType === "decision-submitted"
395
+ || (event.spec.eventType === "note-changed" && note !== undefined)));
396
+ const newEvents = appendReviewSessionEvents(file, decisionEvents);
397
+ // Use the server session APIs for apply-path validation
398
+ const record = createServerReviewSessionRecord({
399
+ sessionName,
400
+ snapshot,
401
+ eventCount: events.length,
402
+ updatedAt: new Date(),
403
+ });
404
+ const applyResult = deriveServerReviewSessionApplyResult({
405
+ record,
406
+ events: newEvents,
407
+ requiredResolvedItems: "none",
408
+ });
409
+ if (!applyResult.ok) {
410
+ throw new DomainError(`Decision validation failed: ${applyResult.issues.map((issue) => "message" in issue ? issue.message : String(issue)).join("; ")}`);
411
+ }
412
+ const updatedFile = {
413
+ session: file.session,
414
+ snapshot,
415
+ events: newEvents,
416
+ };
417
+ return { next: updatedFile, result: { snapshot, sessionWithDecision, newEvents } };
403
418
  });
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
419
  // Summarize the result
415
420
  const updatedItem = sessionWithDecision.items.find((i) => i.metadata.name === itemName);
416
421
  const itemText = updatedItem ? itemDetailText(updatedItem, snapshot, newEvents) : `Item: ${itemName}`;
@@ -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[];