@kontourai/survey 2.5.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 (38) hide show
  1. package/README.md +6 -1
  2. package/dist/examples/calibrated-auto-accept.d.ts +22 -15
  3. package/dist/examples/calibrated-auto-accept.js +40 -36
  4. package/dist/src/agent-utterance.d.ts +87 -11
  5. package/dist/src/agent-utterance.js +135 -44
  6. package/dist/src/calibration.d.ts +48 -21
  7. package/dist/src/calibration.js +72 -33
  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 +22 -0
  11. package/dist/src/extraction-envelope.js +25 -4
  12. package/dist/src/index.d.ts +9 -8
  13. package/dist/src/index.js +2 -2
  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 +219 -279
  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/review-presentation.d.ts +44 -0
  24. package/dist/src/review-workbench/review-presentation.js +49 -0
  25. package/dist/src/review-workbench/review-queue-session.js +8 -1
  26. package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
  27. package/dist/src/review-workbench/review-session-replay.js +77 -0
  28. package/dist/src/review-workbench/review-workbench.d.ts +8 -4
  29. package/dist/src/review-workbench/review-workbench.js +19 -7
  30. package/dist/src/review-workbench/server-review-session.d.ts +3 -1
  31. package/dist/src/review-workbench/server-review-session.js +1 -0
  32. package/dist/src/reviewed-candidate-resolution.js +13 -7
  33. package/dist/src/schema-mapping.d.ts +23 -0
  34. package/dist/src/schema-mapping.js +30 -20
  35. package/dist/src/to-surface.d.ts +30 -6
  36. package/dist/src/to-surface.js +196 -18
  37. package/dist/src/types.d.ts +39 -0
  38. package/package.json +8 -4
@@ -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[];
@@ -0,0 +1,320 @@
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 { execFile } from "node:child_process";
12
+ import { createHash, randomUUID } from "node:crypto";
13
+ import { open, readdir, readFile, rename, rm, stat } from "node:fs/promises";
14
+ import { basename, dirname, join } from "node:path";
15
+ import { defaultReviewSessionName } from "./review-workbench/review-queue-session.js";
16
+ export class ReviewSessionFileLockTimeoutError extends Error {
17
+ constructor(lockPath, timeoutMs) {
18
+ super(`Timed out after ${timeoutMs}ms waiting for session lock ${lockPath}`);
19
+ this.name = "ReviewSessionFileLockTimeoutError";
20
+ }
21
+ }
22
+ const DEFAULT_TIMEOUT_MS = 10_000;
23
+ const DEFAULT_STALE_MS = 30_000;
24
+ /** A lock file that is still empty this long after creation was left by an acquirer that died between create and write. */
25
+ const EMPTY_LOCK_STALE_MS = 1_000;
26
+ /** The break mutex is held for a few milliseconds; older means its holder died mid-break. */
27
+ const BREAK_MUTEX_STALE_MS = 5_000;
28
+ const RETRY_MIN_MS = 10;
29
+ const RETRY_MAX_MS = 50;
30
+ export function reviewSessionLockPath(sessionPath) {
31
+ return `${sessionPath}.lock`;
32
+ }
33
+ export async function readReviewSessionFile(sessionPath) {
34
+ return JSON.parse(await readFile(sessionPath, "utf8"));
35
+ }
36
+ /**
37
+ * Opaque revision token for an event log: a digest of its serialized form.
38
+ * An event count is not enough, because the log is regenerated from session
39
+ * state and a changed decision keeps the count while changing the content.
40
+ */
41
+ export function reviewSessionRevision(events) {
42
+ return createHash("sha256").update(JSON.stringify(events)).digest("hex").slice(0, 32);
43
+ }
44
+ function isPidAlive(pid) {
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ }
49
+ catch (error) {
50
+ return error.code === "EPERM";
51
+ }
52
+ }
53
+ /**
54
+ * An identity for the process currently running as `pid`: its start time, as
55
+ * the OS reports it. A pid can be reused after its holder dies, so a live pid
56
+ * only proves the holder is alive when the start time also matches the one
57
+ * the holder recorded (kontourai/survey#298). Linux reads `/proc/<pid>/stat`
58
+ * field 22 (start time in clock ticks since boot); elsewhere `ps -o lstart=`
59
+ * (one-second resolution), rendered in UTC with the C locale so every reader
60
+ * spells the same instant the same way whatever its own TZ or locale. Resolves `undefined` when the platform offers
61
+ * neither or the process is gone, which leaves the pid-only rule in force.
62
+ */
63
+ async function processStartIdentity(pid) {
64
+ if (process.platform === "linux") {
65
+ try {
66
+ const procStat = await readFile(`/proc/${pid}/stat`, "utf8");
67
+ // Fields after the parenthesized command name, which may contain spaces.
68
+ const fields = procStat.slice(procStat.lastIndexOf(")") + 2).split(" ");
69
+ const startTicks = fields[19];
70
+ return startTicks ? `linux-starttime:${startTicks}` : undefined;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ if (process.platform === "win32")
77
+ return undefined;
78
+ return new Promise((resolveIdentity) => {
79
+ execFile("ps", ["-o", "lstart=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: 2_000 }, (error, stdout) => {
80
+ const started = error ? "" : stdout.trim().replace(/\s+/g, " ");
81
+ resolveIdentity(started ? `ps-lstart:${started}` : undefined);
82
+ });
83
+ });
84
+ }
85
+ let ownStartIdentity;
86
+ /**
87
+ * A lock is stale only when we can show its holder is gone, never merely
88
+ * because it is old. Liveness is authoritative whenever the lock carries a
89
+ * usable pid: a holder that is alive but slow (age past `staleMs`) is NOT
90
+ * stale — age alone used to be enough to break it, which let a live-but-slow
91
+ * holder's lock be removed out from under it (kontourai/survey#281 review:
92
+ * 135 double-hold events in 60 rounds of the reviewer's stress test). We
93
+ * deliberately do not add a hard age ceiling that overrides a live pid: with
94
+ * the default timeoutMs (10s) well under the default staleMs (30s), a waiter
95
+ * simply times out with {@link ReviewSessionFileLockTimeoutError} against a
96
+ * live holder that never releases in time, which is a loud, safe failure
97
+ * mode rather than a silent double-hold. A genuinely wedged holder (hung
98
+ * forever) requires an operator to remove the lock file by hand; that is the
99
+ * accepted cost of "never remove a live lock" actually holding.
100
+ *
101
+ * The age rule is kept only as the fallback for a lock we cannot judge by
102
+ * liveness: no pid field (unknown-PID); a live pid whose current start time
103
+ * differs from the one the holder recorded, i.e. the holder died and its pid
104
+ * was reused by an unrelated process (kontourai/survey#298); or, since the lock carries no host
105
+ * identity, a pid from another host/container sharing this volume (`isPidAlive`
106
+ * is meaningless there, in either direction — the residual this repo has
107
+ * always accepted for that case).
108
+ */
109
+ async function lockIsStale(lockPath, staleMs) {
110
+ try {
111
+ const info = await stat(lockPath);
112
+ const age = Date.now() - info.mtimeMs;
113
+ const raw = await readFile(lockPath, "utf8");
114
+ if (raw.trim() === "")
115
+ return age > EMPTY_LOCK_STALE_MS;
116
+ const holder = JSON.parse(raw);
117
+ if (typeof holder.pid === "number") {
118
+ if (!isPidAlive(holder.pid))
119
+ return true;
120
+ if (typeof holder.startIdentity !== "string")
121
+ return false;
122
+ const current = await processStartIdentity(holder.pid);
123
+ // Unknown now (or never recorded): trust liveness, as before.
124
+ if (current === undefined || current === holder.startIdentity)
125
+ return false;
126
+ }
127
+ return age > staleMs;
128
+ }
129
+ catch {
130
+ // Vanished (released) or half-written by a live acquirer: not stale.
131
+ return false;
132
+ }
133
+ }
134
+ /**
135
+ * Remove a stale lock without ever removing a live one.
136
+ *
137
+ * Breakers serialize on a separate `<lock>.break` mutex and re-judge the lock
138
+ * inside it. While a breaker holds the mutex, the lock file can only change if
139
+ * its owner releases it, and {@link lockIsStale} only calls a lock stale once
140
+ * its holder is provably dead (or, lacking a usable pid, sufficiently old), so
141
+ * the file the breaker removes is the one it judged stale. The earlier
142
+ * rename-aside scheme let two waiters that both judged the same lock stale
143
+ * each remove it, the second removing the first's fresh lock
144
+ * (kontourai/survey#281 review). A holder that is merely alive-but-slow is
145
+ * never broken, by construction, no matter its age (see {@link lockIsStale}).
146
+ * Residual (accepted): a breaker that dies inside the few-millisecond break
147
+ * section leaves a mutex that is removed after BREAK_MUTEX_STALE_MS, and pid
148
+ * liveness means nothing across hosts or containers that share the session
149
+ * volume (the age rule is the only signal available there, and it is a
150
+ * pre-existing limitation, not one this fix introduces).
151
+ */
152
+ async function breakStaleLock(lockPath, staleMs) {
153
+ const breakPath = `${lockPath}.break`;
154
+ try {
155
+ await (await open(breakPath, "wx")).close();
156
+ }
157
+ catch (error) {
158
+ if (error.code !== "EEXIST")
159
+ throw error;
160
+ try {
161
+ if (Date.now() - (await stat(breakPath)).mtimeMs > BREAK_MUTEX_STALE_MS) {
162
+ await rm(breakPath, { force: true });
163
+ }
164
+ }
165
+ catch {
166
+ // Released meanwhile.
167
+ }
168
+ return;
169
+ }
170
+ try {
171
+ if (await lockIsStale(lockPath, staleMs)) {
172
+ await rm(lockPath, { force: true });
173
+ }
174
+ }
175
+ finally {
176
+ await rm(breakPath, { force: true });
177
+ }
178
+ }
179
+ /**
180
+ * Acquire the exclusive session lock. Resolves with a release function.
181
+ * Exported so tests can hold the lock and observe that writers wait for it.
182
+ */
183
+ export async function acquireReviewSessionFileLock(sessionPath, options = {}) {
184
+ const lockPath = reviewSessionLockPath(sessionPath);
185
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
186
+ const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
187
+ const deadline = Date.now() + timeoutMs;
188
+ const token = randomUUID();
189
+ ownStartIdentity ??= processStartIdentity(process.pid);
190
+ const startIdentity = await ownStartIdentity;
191
+ for (;;) {
192
+ try {
193
+ const handle = await open(lockPath, "wx");
194
+ try {
195
+ await handle.writeFile(JSON.stringify({ pid: process.pid, ...(startIdentity ? { startIdentity } : {}), token, acquiredAt: new Date().toISOString() }));
196
+ }
197
+ finally {
198
+ await handle.close();
199
+ }
200
+ return async () => {
201
+ // Only remove the lock if it is still ours (a stale-lock breaker may
202
+ // have replaced it after we were presumed dead).
203
+ try {
204
+ const holder = JSON.parse(await readFile(lockPath, "utf8"));
205
+ if (holder.token === token)
206
+ await rm(lockPath, { force: true });
207
+ }
208
+ catch {
209
+ // Already gone.
210
+ }
211
+ };
212
+ }
213
+ catch (error) {
214
+ if (error.code !== "EEXIST")
215
+ throw error;
216
+ }
217
+ if (await lockIsStale(lockPath, staleMs)) {
218
+ await breakStaleLock(lockPath, staleMs);
219
+ continue;
220
+ }
221
+ if (Date.now() >= deadline) {
222
+ throw new ReviewSessionFileLockTimeoutError(lockPath, timeoutMs);
223
+ }
224
+ const delay = RETRY_MIN_MS + Math.floor(Math.random() * (RETRY_MAX_MS - RETRY_MIN_MS));
225
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, delay));
226
+ }
227
+ }
228
+ function tempFilePattern(sessionPath) {
229
+ const escaped = basename(sessionPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
230
+ return new RegExp(`^${escaped}\\.\\d+\\.[0-9a-f-]{36}\\.tmp$`);
231
+ }
232
+ /**
233
+ * Remove temp files a crashed writer left between write and rename. Called
234
+ * only while holding the lock, when no other writer can have a temp file in
235
+ * flight, so every match is an orphan.
236
+ */
237
+ async function removeOrphanTempFiles(sessionPath) {
238
+ const pattern = tempFilePattern(sessionPath);
239
+ let entries;
240
+ try {
241
+ entries = await readdir(dirname(sessionPath));
242
+ }
243
+ catch {
244
+ return;
245
+ }
246
+ await Promise.all(entries.filter((entry) => pattern.test(entry)).map((entry) => rm(join(dirname(sessionPath), entry), { force: true })));
247
+ }
248
+ async function writeReviewSessionFileAtomic(sessionPath, content) {
249
+ const tmp = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;
250
+ try {
251
+ const handle = await open(tmp, "wx");
252
+ try {
253
+ await handle.writeFile(JSON.stringify(content, null, 2), "utf8");
254
+ // Durable before it becomes visible: a crash after the rename must not
255
+ // leave a renamed but empty or partial session file.
256
+ await handle.sync();
257
+ }
258
+ finally {
259
+ await handle.close();
260
+ }
261
+ await rename(tmp, sessionPath);
262
+ }
263
+ catch (error) {
264
+ await rm(tmp, { force: true });
265
+ throw error;
266
+ }
267
+ }
268
+ /**
269
+ * Locked read-modify-write of a session file. `mutate` receives the content
270
+ * read inside the lock and returns the content to write, or `undefined` to
271
+ * leave the file untouched. Whatever `mutate` throws propagates after the lock
272
+ * is released.
273
+ */
274
+ export async function updateReviewSessionFile(sessionPath, mutate, options = {}) {
275
+ const release = await acquireReviewSessionFileLock(sessionPath, options);
276
+ try {
277
+ await removeOrphanTempFiles(sessionPath);
278
+ const current = await readReviewSessionFile(sessionPath);
279
+ const { next, result } = await mutate(current);
280
+ if (next !== undefined) {
281
+ await writeReviewSessionFileAtomic(sessionPath, next);
282
+ }
283
+ return result;
284
+ }
285
+ finally {
286
+ await release();
287
+ }
288
+ }
289
+ /**
290
+ * The session name the stored log is recorded under: the name its events
291
+ * already carry, else the stored ReviewSession's name, else `fallback` (the
292
+ * writer's own default). Appended events are renamed into it so one log never mixes names.
293
+ */
294
+ export function storedReviewSessionName(content, fallback = defaultReviewSessionName) {
295
+ const fromEvents = content.events[0]?.spec.sessionName;
296
+ if (fromEvents)
297
+ return fromEvents;
298
+ const session = content.session;
299
+ return typeof session?.metadata?.name === "string" ? session.metadata.name : fallback;
300
+ }
301
+ /**
302
+ * Append events to the stored log, renumbering them after the stored events
303
+ * and renaming them into the stored session. The stored log is never
304
+ * rewritten, so decision reversals and note changes stay on record.
305
+ */
306
+ export function appendReviewSessionEvents(content, appended) {
307
+ const sessionName = storedReviewSessionName(content);
308
+ const start = content.events.length;
309
+ const renumbered = [...appended]
310
+ .sort((left, right) => left.spec.sequence - right.spec.sequence)
311
+ .map((event, index) => {
312
+ const sequence = start + index + 1;
313
+ return {
314
+ ...event,
315
+ metadata: { ...event.metadata, name: `${sessionName}-${String(sequence).padStart(4, "0")}-${event.spec.eventType}` },
316
+ spec: { ...event.spec, sessionName, sequence },
317
+ };
318
+ });
319
+ return [...content.events, ...renumbered];
320
+ }
@@ -0,0 +1,70 @@
1
+ import type { ReviewItem, ReviewValueDescriptor } from "../review-resource.js";
2
+ /**
3
+ * Result of checking an inline edit (`data.workbenchEditedValue` on an
4
+ * `accept-proposed` decision event) against the ReviewItem it edits.
5
+ *
6
+ * - `ok: true` — the edit is allowed; `value` is the value to store. When
7
+ * `convertedFromText` is true, the stored edit was descriptor-typed text
8
+ * written by an older workbench (for example `"42"` on a number field) and
9
+ * `value` is its typed form (`42`).
10
+ * - `ok: false` — the edit must be refused: the item is not editable, or the
11
+ * value does not satisfy the item's `valueDescriptor`.
12
+ */
13
+ export type EditedValueCheck = {
14
+ readonly ok: true;
15
+ readonly value: unknown;
16
+ readonly convertedFromText: boolean;
17
+ } | {
18
+ readonly ok: false;
19
+ readonly code: "edited-value-not-editable" | "edited-value-type-mismatch";
20
+ readonly message: string;
21
+ };
22
+ /**
23
+ * True for a `YYYY-MM-DD` string naming a real calendar day. The Y/M/D must
24
+ * round-trip through a UTC date, so an impossible day such as `2026-02-31`
25
+ * (which `Date.parse` silently rolls over to March) is refused.
26
+ */
27
+ export declare function isIsoCalendarDate(value: string): boolean;
28
+ /**
29
+ * Parses number-field editor text, or returns `undefined` when it is not a
30
+ * plain decimal or would not survive storage as a JSON number exactly.
31
+ *
32
+ * Integer-only text (no `.` and no exponent) is checked by
33
+ * `Number.isSafeInteger` instead of the digit count: every safe integer
34
+ * (|value| ≤ 2^53−1, i.e. up to 9007199254740991, 16 digits) round-trips
35
+ * through a JSON number exactly, so the 15-significant-digit rule would
36
+ * wrongly refuse `9007199254740991` while accepting some 15-digit values
37
+ * that are actually less precise. `9007199254740993` is refused: it is
38
+ * outside the safe range and `Number(...)` rounds it to `9007199254740992`,
39
+ * a different integer than the text named.
40
+ *
41
+ * A fractional value (has `.` or an exponent) keeps the 15-significant-digit
42
+ * rule: `Number.isSafeInteger` does not apply to it, and IEEE 754 doubles
43
+ * only guarantee exactness up to 15 significant decimal digits, so anything
44
+ * longer is refused rather than silently rounded. A value that overflows to
45
+ * Infinity is refused. `-0` is stored as `0` (JSON has no negative zero).
46
+ */
47
+ export declare function parsePlainDecimal(text: string): number | undefined;
48
+ /**
49
+ * Converts a reviewer's editor text to the JSON type the item's descriptor
50
+ * declares, using the same parsing rules the workbench's `validateProposedValue`
51
+ * checks. Returns `undefined` when the text does not parse for that type. With
52
+ * no descriptor, or a type that has no single-line form (string, array, object),
53
+ * the text is returned unchanged.
54
+ */
55
+ export declare function editedValueFromEditorText(descriptor: ReviewValueDescriptor | undefined, text: string): unknown;
56
+ /**
57
+ * Checks an inline edit carried by an `accept-proposed` decision against the
58
+ * item's `editable` flag and `valueDescriptor`. This is the server-side
59
+ * counterpart of the browser editor's validation (kontourai/survey#278): the
60
+ * apply boundary must not trust an edit the workbench would never have let a
61
+ * reviewer make.
62
+ *
63
+ * Legacy sessions: workbenches before this check stored every edit as the
64
+ * editor's text, so a number or boolean edit arrives as `"42"` / `"true"`. Such
65
+ * text (and date/enum text with surrounding whitespace, which the old editor
66
+ * accepted and stored untrimmed) is converted to its typed value when it parses
67
+ * cleanly under the workbench's own rules (`convertedFromText: true`, reported as a warning by the
68
+ * apply derivation); text that does not parse is refused.
69
+ */
70
+ export declare function checkEditedValueForItem(item: ReviewItem, value: unknown): EditedValueCheck;
@@ -0,0 +1,147 @@
1
+ const isoDatePattern = /^(\d{4})-(\d{2})-(\d{2})$/;
2
+ /**
3
+ * True for a `YYYY-MM-DD` string naming a real calendar day. The Y/M/D must
4
+ * round-trip through a UTC date, so an impossible day such as `2026-02-31`
5
+ * (which `Date.parse` silently rolls over to March) is refused.
6
+ */
7
+ export function isIsoCalendarDate(value) {
8
+ const match = isoDatePattern.exec(value);
9
+ if (!match)
10
+ return false;
11
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
12
+ const date = new Date(Date.UTC(year, month - 1, day));
13
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
14
+ }
15
+ // Plain decimal only: optional minus, no leading zeros, optional fraction and
16
+ // exponent. Refuses hex/binary/octal forms, "Infinity", "+1", ".5" and "007".
17
+ const plainDecimalPattern = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/;
18
+ /** A JSON number keeps at most 15 significant decimal digits exactly (IEEE 754 double). */
19
+ const maxSignificantDigits = 15;
20
+ /**
21
+ * Parses number-field editor text, or returns `undefined` when it is not a
22
+ * plain decimal or would not survive storage as a JSON number exactly.
23
+ *
24
+ * Integer-only text (no `.` and no exponent) is checked by
25
+ * `Number.isSafeInteger` instead of the digit count: every safe integer
26
+ * (|value| ≤ 2^53−1, i.e. up to 9007199254740991, 16 digits) round-trips
27
+ * through a JSON number exactly, so the 15-significant-digit rule would
28
+ * wrongly refuse `9007199254740991` while accepting some 15-digit values
29
+ * that are actually less precise. `9007199254740993` is refused: it is
30
+ * outside the safe range and `Number(...)` rounds it to `9007199254740992`,
31
+ * a different integer than the text named.
32
+ *
33
+ * A fractional value (has `.` or an exponent) keeps the 15-significant-digit
34
+ * rule: `Number.isSafeInteger` does not apply to it, and IEEE 754 doubles
35
+ * only guarantee exactness up to 15 significant decimal digits, so anything
36
+ * longer is refused rather than silently rounded. A value that overflows to
37
+ * Infinity is refused. `-0` is stored as `0` (JSON has no negative zero).
38
+ */
39
+ export function parsePlainDecimal(text) {
40
+ const match = plainDecimalPattern.exec(text);
41
+ if (!match)
42
+ return undefined;
43
+ const isIntegerOnly = match[2] === undefined && match[3] === undefined;
44
+ if (isIntegerOnly) {
45
+ const parsed = Number(text);
46
+ if (!Number.isSafeInteger(parsed))
47
+ return undefined;
48
+ return parsed === 0 ? 0 : parsed;
49
+ }
50
+ const digits = `${match[1]}${(match[2] ?? "").slice(1)}`.replace(/^0+/, "").replace(/0+$/, "");
51
+ if (digits.length > maxSignificantDigits)
52
+ return undefined;
53
+ const parsed = Number(text);
54
+ if (!Number.isFinite(parsed))
55
+ return undefined;
56
+ return parsed === 0 ? 0 : parsed;
57
+ }
58
+ /**
59
+ * Converts a reviewer's editor text to the JSON type the item's descriptor
60
+ * declares, using the same parsing rules the workbench's `validateProposedValue`
61
+ * checks. Returns `undefined` when the text does not parse for that type. With
62
+ * no descriptor, or a type that has no single-line form (string, array, object),
63
+ * the text is returned unchanged.
64
+ */
65
+ export function editedValueFromEditorText(descriptor, text) {
66
+ if (!descriptor)
67
+ return text;
68
+ const trimmed = text.trim();
69
+ switch (descriptor.type) {
70
+ case "number":
71
+ return parsePlainDecimal(trimmed);
72
+ case "boolean":
73
+ return trimmed === "true" ? true : trimmed === "false" ? false : undefined;
74
+ case "date":
75
+ return isIsoCalendarDate(trimmed) ? trimmed : undefined;
76
+ case "enum": {
77
+ const allowed = descriptor.enumValues ?? [];
78
+ return allowed.length === 0 || allowed.includes(trimmed) ? trimmed : undefined;
79
+ }
80
+ default:
81
+ return text;
82
+ }
83
+ }
84
+ function valueMatchesDescriptor(descriptor, value) {
85
+ switch (descriptor.type) {
86
+ case "number":
87
+ return typeof value === "number" && Number.isFinite(value);
88
+ case "boolean":
89
+ return typeof value === "boolean";
90
+ case "date":
91
+ return typeof value === "string" && isIsoCalendarDate(value);
92
+ case "enum": {
93
+ if (typeof value !== "string")
94
+ return false;
95
+ const allowed = descriptor.enumValues ?? [];
96
+ return allowed.length === 0 || allowed.includes(value);
97
+ }
98
+ case "string":
99
+ return typeof value === "string";
100
+ default:
101
+ // array/object: the workbench has no typed editor for these, so Survey
102
+ // declares no constraint on the edit's shape.
103
+ return true;
104
+ }
105
+ }
106
+ /**
107
+ * Checks an inline edit carried by an `accept-proposed` decision against the
108
+ * item's `editable` flag and `valueDescriptor`. This is the server-side
109
+ * counterpart of the browser editor's validation (kontourai/survey#278): the
110
+ * apply boundary must not trust an edit the workbench would never have let a
111
+ * reviewer make.
112
+ *
113
+ * Legacy sessions: workbenches before this check stored every edit as the
114
+ * editor's text, so a number or boolean edit arrives as `"42"` / `"true"`. Such
115
+ * text (and date/enum text with surrounding whitespace, which the old editor
116
+ * accepted and stored untrimmed) is converted to its typed value when it parses
117
+ * cleanly under the workbench's own rules (`convertedFromText: true`, reported as a warning by the
118
+ * apply derivation); text that does not parse is refused.
119
+ */
120
+ export function checkEditedValueForItem(item, value) {
121
+ const itemName = item.metadata.name;
122
+ if (item.spec.editable === false) {
123
+ return {
124
+ ok: false,
125
+ code: "edited-value-not-editable",
126
+ message: `ReviewItem ${itemName} is not editable (spec.editable: false), but the decision carries an edited value.`,
127
+ };
128
+ }
129
+ const descriptor = item.spec.valueDescriptor;
130
+ if (!descriptor || valueMatchesDescriptor(descriptor, value)) {
131
+ return { ok: true, value, convertedFromText: false };
132
+ }
133
+ if (typeof value === "string") {
134
+ const converted = editedValueFromEditorText(descriptor, value);
135
+ if (converted !== undefined) {
136
+ return { ok: true, value: converted, convertedFromText: true };
137
+ }
138
+ }
139
+ const allowed = descriptor.type === "enum" && descriptor.enumValues?.length
140
+ ? ` (one of: ${descriptor.enumValues.join(", ")})`
141
+ : "";
142
+ return {
143
+ ok: false,
144
+ code: "edited-value-type-mismatch",
145
+ message: `ReviewItem ${itemName} declares value type ${descriptor.type}${allowed}, but the decision carries edited value ${JSON.stringify(value)}.`,
146
+ };
147
+ }