@riddledc/riddle-proof 0.5.40 → 0.5.42

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.
@@ -34,6 +34,7 @@ __export(checkpoint_exports, {
34
34
  RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION: () => RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION,
35
35
  authorPacketPayloadFromCheckpointResponse: () => authorPacketPayloadFromCheckpointResponse,
36
36
  buildAuthorCheckpointPacket: () => buildAuthorCheckpointPacket,
37
+ checkpointResponseIdentity: () => checkpointResponseIdentity,
37
38
  checkpointSummaryFromState: () => checkpointSummaryFromState,
38
39
  isDuplicateCheckpointResponse: () => isDuplicateCheckpointResponse,
39
40
  normalizeCheckpointResponse: () => normalizeCheckpointResponse,
@@ -77,6 +78,14 @@ function jsonCloneValue(value) {
77
78
  return value;
78
79
  }
79
80
  }
81
+ function stableJson(value) {
82
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
83
+ const record = recordValue(value);
84
+ if (record) {
85
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
86
+ }
87
+ return JSON.stringify(value);
88
+ }
80
89
  function compactText(value, limit = 1600) {
81
90
  const text = nonEmptyString(value);
82
91
  if (!text) return void 0;
@@ -234,6 +243,7 @@ function checkpointSummaryFromState(state, engineStatePath) {
234
243
  const history = state.checkpoint_history || [];
235
244
  const packets = history.filter((entry) => entry.packet);
236
245
  const responses = history.filter((entry) => entry.response);
246
+ const duplicateResponses = (state.events || []).filter((event) => event.kind === "checkpoint.response.duplicate");
237
247
  const latestPacketEntry = [...history].reverse().find((entry) => entry.packet);
238
248
  const latestResponseEntry = [...history].reverse().find((entry) => entry.response);
239
249
  const latestPacket = state.checkpoint_packet || latestPacketEntry?.packet;
@@ -245,6 +255,7 @@ function checkpointSummaryFromState(state, engineStatePath) {
245
255
  pending: Boolean(state.checkpoint_packet),
246
256
  packet_count: packets.length,
247
257
  response_count: responses.length,
258
+ duplicate_response_count: duplicateResponses.length,
248
259
  latest_checkpoint: state.checkpoint_packet?.checkpoint || latestResponse?.checkpoint || state.last_checkpoint || null,
249
260
  latest_stage: state.checkpoint_packet?.stage || latestResponse?.continue_with_stage || state.current_stage || null,
250
261
  latest_kind: state.checkpoint_packet?.kind || latestPacket?.kind || null,
@@ -260,9 +271,22 @@ function checkpointSummaryFromState(state, engineStatePath) {
260
271
  });
261
272
  }
262
273
  function isDuplicateCheckpointResponse(state, response) {
263
- const latestResponse = [...state.checkpoint_history || []].reverse().find((entry) => entry.response)?.response;
264
- if (!latestResponse) return false;
265
- return latestResponse.run_id === response.run_id && latestResponse.checkpoint === response.checkpoint && latestResponse.resume_token === response.resume_token && latestResponse.decision === response.decision;
274
+ const identity = checkpointResponseIdentity(response);
275
+ return (state.checkpoint_history || []).some((entry) => entry.response ? checkpointResponseIdentity(entry.response) === identity : false);
276
+ }
277
+ function checkpointResponseIdentity(response) {
278
+ const logicalResponse = compactRecord({
279
+ run_id: response.run_id,
280
+ checkpoint: response.checkpoint,
281
+ resume_token: response.resume_token,
282
+ decision: response.decision,
283
+ summary: response.summary,
284
+ payload: response.payload,
285
+ reasons: response.reasons,
286
+ continue_with_stage: response.continue_with_stage,
287
+ source: response.source
288
+ });
289
+ return import_node_crypto.default.createHash("sha256").update(stableJson(logicalResponse)).digest("hex").slice(0, 24);
266
290
  }
267
291
  function authorPacketPayloadFromCheckpointResponse(response) {
268
292
  if (response.decision !== "author_packet") return null;
@@ -310,6 +334,7 @@ function proofContractFromAuthorCheckpointResponse(response, packet, payload) {
310
334
  RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION,
311
335
  authorPacketPayloadFromCheckpointResponse,
312
336
  buildAuthorCheckpointPacket,
337
+ checkpointResponseIdentity,
313
338
  checkpointSummaryFromState,
314
339
  isDuplicateCheckpointResponse,
315
340
  normalizeCheckpointResponse,
@@ -20,7 +20,8 @@ declare function buildAuthorCheckpointPacket(input: {
20
20
  declare function normalizeCheckpointResponse(value: unknown): RiddleProofCheckpointResponse | null;
21
21
  declare function checkpointSummaryFromState(state: RiddleProofRunState, engineStatePath?: string | null): RiddleProofCheckpointSummary;
22
22
  declare function isDuplicateCheckpointResponse(state: RiddleProofRunState, response: RiddleProofCheckpointResponse): boolean;
23
+ declare function checkpointResponseIdentity(response: RiddleProofCheckpointResponse): string;
23
24
  declare function authorPacketPayloadFromCheckpointResponse(response: RiddleProofCheckpointResponse): Record<string, unknown> | null;
24
25
  declare function proofContractFromAuthorCheckpointResponse(response: RiddleProofCheckpointResponse, packet: RiddleProofCheckpointPacket, payload: Record<string, unknown>): RiddleProofProofContract;
25
26
 
26
- export { RIDDLE_PROOF_CHECKPOINT_PACKET_VERSION, RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION, authorPacketPayloadFromCheckpointResponse, buildAuthorCheckpointPacket, checkpointSummaryFromState, isDuplicateCheckpointResponse, normalizeCheckpointResponse, proofContractFromAuthorCheckpointResponse, statePathsForRunState };
27
+ export { RIDDLE_PROOF_CHECKPOINT_PACKET_VERSION, RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION, authorPacketPayloadFromCheckpointResponse, buildAuthorCheckpointPacket, checkpointResponseIdentity, checkpointSummaryFromState, isDuplicateCheckpointResponse, normalizeCheckpointResponse, proofContractFromAuthorCheckpointResponse, statePathsForRunState };
@@ -20,7 +20,8 @@ declare function buildAuthorCheckpointPacket(input: {
20
20
  declare function normalizeCheckpointResponse(value: unknown): RiddleProofCheckpointResponse | null;
21
21
  declare function checkpointSummaryFromState(state: RiddleProofRunState, engineStatePath?: string | null): RiddleProofCheckpointSummary;
22
22
  declare function isDuplicateCheckpointResponse(state: RiddleProofRunState, response: RiddleProofCheckpointResponse): boolean;
23
+ declare function checkpointResponseIdentity(response: RiddleProofCheckpointResponse): string;
23
24
  declare function authorPacketPayloadFromCheckpointResponse(response: RiddleProofCheckpointResponse): Record<string, unknown> | null;
24
25
  declare function proofContractFromAuthorCheckpointResponse(response: RiddleProofCheckpointResponse, packet: RiddleProofCheckpointPacket, payload: Record<string, unknown>): RiddleProofProofContract;
25
26
 
26
- export { RIDDLE_PROOF_CHECKPOINT_PACKET_VERSION, RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION, authorPacketPayloadFromCheckpointResponse, buildAuthorCheckpointPacket, checkpointSummaryFromState, isDuplicateCheckpointResponse, normalizeCheckpointResponse, proofContractFromAuthorCheckpointResponse, statePathsForRunState };
27
+ export { RIDDLE_PROOF_CHECKPOINT_PACKET_VERSION, RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION, authorPacketPayloadFromCheckpointResponse, buildAuthorCheckpointPacket, checkpointResponseIdentity, checkpointSummaryFromState, isDuplicateCheckpointResponse, normalizeCheckpointResponse, proofContractFromAuthorCheckpointResponse, statePathsForRunState };
@@ -3,18 +3,20 @@ import {
3
3
  RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION,
4
4
  authorPacketPayloadFromCheckpointResponse,
5
5
  buildAuthorCheckpointPacket,
6
+ checkpointResponseIdentity,
6
7
  checkpointSummaryFromState,
7
8
  isDuplicateCheckpointResponse,
8
9
  normalizeCheckpointResponse,
9
10
  proofContractFromAuthorCheckpointResponse,
10
11
  statePathsForRunState
11
- } from "./chunk-5LVCGDSD.js";
12
+ } from "./chunk-RI25RGQP.js";
12
13
  import "./chunk-W7VTDN4T.js";
13
14
  export {
14
15
  RIDDLE_PROOF_CHECKPOINT_PACKET_VERSION,
15
16
  RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION,
16
17
  authorPacketPayloadFromCheckpointResponse,
17
18
  buildAuthorCheckpointPacket,
19
+ checkpointResponseIdentity,
18
20
  checkpointSummaryFromState,
19
21
  isDuplicateCheckpointResponse,
20
22
  normalizeCheckpointResponse,
@@ -28,6 +28,14 @@ function jsonCloneValue(value) {
28
28
  return value;
29
29
  }
30
30
  }
31
+ function stableJson(value) {
32
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
33
+ const record = recordValue(value);
34
+ if (record) {
35
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
36
+ }
37
+ return JSON.stringify(value);
38
+ }
31
39
  function compactText(value, limit = 1600) {
32
40
  const text = nonEmptyString(value);
33
41
  if (!text) return void 0;
@@ -185,6 +193,7 @@ function checkpointSummaryFromState(state, engineStatePath) {
185
193
  const history = state.checkpoint_history || [];
186
194
  const packets = history.filter((entry) => entry.packet);
187
195
  const responses = history.filter((entry) => entry.response);
196
+ const duplicateResponses = (state.events || []).filter((event) => event.kind === "checkpoint.response.duplicate");
188
197
  const latestPacketEntry = [...history].reverse().find((entry) => entry.packet);
189
198
  const latestResponseEntry = [...history].reverse().find((entry) => entry.response);
190
199
  const latestPacket = state.checkpoint_packet || latestPacketEntry?.packet;
@@ -196,6 +205,7 @@ function checkpointSummaryFromState(state, engineStatePath) {
196
205
  pending: Boolean(state.checkpoint_packet),
197
206
  packet_count: packets.length,
198
207
  response_count: responses.length,
208
+ duplicate_response_count: duplicateResponses.length,
199
209
  latest_checkpoint: state.checkpoint_packet?.checkpoint || latestResponse?.checkpoint || state.last_checkpoint || null,
200
210
  latest_stage: state.checkpoint_packet?.stage || latestResponse?.continue_with_stage || state.current_stage || null,
201
211
  latest_kind: state.checkpoint_packet?.kind || latestPacket?.kind || null,
@@ -211,9 +221,22 @@ function checkpointSummaryFromState(state, engineStatePath) {
211
221
  });
212
222
  }
213
223
  function isDuplicateCheckpointResponse(state, response) {
214
- const latestResponse = [...state.checkpoint_history || []].reverse().find((entry) => entry.response)?.response;
215
- if (!latestResponse) return false;
216
- return latestResponse.run_id === response.run_id && latestResponse.checkpoint === response.checkpoint && latestResponse.resume_token === response.resume_token && latestResponse.decision === response.decision;
224
+ const identity = checkpointResponseIdentity(response);
225
+ return (state.checkpoint_history || []).some((entry) => entry.response ? checkpointResponseIdentity(entry.response) === identity : false);
226
+ }
227
+ function checkpointResponseIdentity(response) {
228
+ const logicalResponse = compactRecord({
229
+ run_id: response.run_id,
230
+ checkpoint: response.checkpoint,
231
+ resume_token: response.resume_token,
232
+ decision: response.decision,
233
+ summary: response.summary,
234
+ payload: response.payload,
235
+ reasons: response.reasons,
236
+ continue_with_stage: response.continue_with_stage,
237
+ source: response.source
238
+ });
239
+ return crypto.createHash("sha256").update(stableJson(logicalResponse)).digest("hex").slice(0, 24);
217
240
  }
218
241
  function authorPacketPayloadFromCheckpointResponse(response) {
219
242
  if (response.decision !== "author_packet") return null;
@@ -264,6 +287,7 @@ export {
264
287
  normalizeCheckpointResponse,
265
288
  checkpointSummaryFromState,
266
289
  isDuplicateCheckpointResponse,
290
+ checkpointResponseIdentity,
267
291
  authorPacketPayloadFromCheckpointResponse,
268
292
  proofContractFromAuthorCheckpointResponse
269
293
  };
@@ -371,7 +371,8 @@ function visualDeltaShipGateReason(state = {}) {
371
371
  if (visualDelta.status === "measured" && visualDelta.passed === true) return null;
372
372
  const status = String(visualDelta.status || "missing");
373
373
  if (status === "unmeasured") {
374
- return "visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof";
374
+ const reason2 = String(visualDelta.reason || "").trim();
375
+ return reason2 ? `visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof: ${reason2}` : "visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof";
375
376
  }
376
377
  if (status === "measured" && visualDelta.passed === false) {
377
378
  return "visual_delta.status=measured but visual_delta.passed=false blocks ready_to_ship for visual/UI proof";
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  authorPacketPayloadFromCheckpointResponse,
3
3
  buildAuthorCheckpointPacket,
4
+ checkpointResponseIdentity,
4
5
  checkpointSummaryFromState,
5
6
  isDuplicateCheckpointResponse,
6
7
  normalizeCheckpointResponse,
7
8
  proofContractFromAuthorCheckpointResponse,
8
9
  statePathsForRunState
9
- } from "./chunk-5LVCGDSD.js";
10
+ } from "./chunk-RI25RGQP.js";
10
11
  import {
11
12
  appendRunEvent,
12
13
  appendStageHeartbeat,
@@ -25,7 +26,7 @@ import {
25
26
  } from "./chunk-W7VTDN4T.js";
26
27
  import {
27
28
  visualDeltaShipGateReason
28
- } from "./chunk-4YCWZVBN.js";
29
+ } from "./chunk-U4VNN2CT.js";
29
30
 
30
31
  // src/engine-harness.ts
31
32
  import { execFileSync } from "child_process";
@@ -497,20 +498,35 @@ function checkpointResponseContinuation(state, value) {
497
498
  }
498
499
  };
499
500
  }
500
- if (!packet) {
501
- if (isDuplicateCheckpointResponse(state, response)) {
502
- return {
503
- blocker: {
504
- code: "checkpoint_response_duplicate",
505
- checkpoint: response.checkpoint,
506
- message: "Checkpoint response was already accepted and there is no pending checkpoint packet to resume.",
507
- details: {
508
- response,
509
- checkpoint_summary: checkpointSummaryFromState(state)
510
- }
501
+ if (isDuplicateCheckpointResponse(state, response)) {
502
+ const stage = packet?.stage || state.current_stage || "author";
503
+ recordEvent(state, {
504
+ kind: "checkpoint.response.duplicate",
505
+ checkpoint: response.checkpoint,
506
+ stage,
507
+ summary: "Duplicate checkpoint response ignored.",
508
+ details: compactRecord({
509
+ decision: response.decision,
510
+ resume_token: response.resume_token,
511
+ response_identity: checkpointResponseIdentity(response)
512
+ })
513
+ });
514
+ return {
515
+ blocker: {
516
+ code: "checkpoint_response_duplicate",
517
+ checkpoint: response.checkpoint,
518
+ message: "Checkpoint response was already accepted for this run/checkpoint/resume token and was not applied again.",
519
+ details: {
520
+ stage,
521
+ duplicate: true,
522
+ response,
523
+ response_identity: checkpointResponseIdentity(response),
524
+ checkpoint_summary: checkpointSummaryFromState(state)
511
525
  }
512
- };
513
- }
526
+ }
527
+ };
528
+ }
529
+ if (!packet) {
514
530
  return {
515
531
  blocker: {
516
532
  code: "checkpoint_response_without_packet",
@@ -379,7 +379,8 @@ function visualDeltaShipGateReason(state = {}) {
379
379
  if (visualDelta.status === "measured" && visualDelta.passed === true) return null;
380
380
  const status = String(visualDelta.status || "missing");
381
381
  if (status === "unmeasured") {
382
- return "visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof";
382
+ const reason2 = String(visualDelta.reason || "").trim();
383
+ return reason2 ? `visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof: ${reason2}` : "visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof";
383
384
  }
384
385
  if (status === "measured" && visualDelta.passed === false) {
385
386
  return "visual_delta.status=measured but visual_delta.passed=false blocks ready_to_ship for visual/UI proof";
@@ -3163,6 +3164,14 @@ function jsonCloneValue(value) {
3163
3164
  return value;
3164
3165
  }
3165
3166
  }
3167
+ function stableJson(value) {
3168
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
3169
+ const record = recordValue(value);
3170
+ if (record) {
3171
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
3172
+ }
3173
+ return JSON.stringify(value);
3174
+ }
3166
3175
  function compactText(value, limit = 1600) {
3167
3176
  const text = nonEmptyString(value);
3168
3177
  if (!text) return void 0;
@@ -3320,6 +3329,7 @@ function checkpointSummaryFromState(state, engineStatePath2) {
3320
3329
  const history = state.checkpoint_history || [];
3321
3330
  const packets = history.filter((entry) => entry.packet);
3322
3331
  const responses = history.filter((entry) => entry.response);
3332
+ const duplicateResponses = (state.events || []).filter((event) => event.kind === "checkpoint.response.duplicate");
3323
3333
  const latestPacketEntry = [...history].reverse().find((entry) => entry.packet);
3324
3334
  const latestResponseEntry = [...history].reverse().find((entry) => entry.response);
3325
3335
  const latestPacket = state.checkpoint_packet || latestPacketEntry?.packet;
@@ -3331,6 +3341,7 @@ function checkpointSummaryFromState(state, engineStatePath2) {
3331
3341
  pending: Boolean(state.checkpoint_packet),
3332
3342
  packet_count: packets.length,
3333
3343
  response_count: responses.length,
3344
+ duplicate_response_count: duplicateResponses.length,
3334
3345
  latest_checkpoint: state.checkpoint_packet?.checkpoint || latestResponse?.checkpoint || state.last_checkpoint || null,
3335
3346
  latest_stage: state.checkpoint_packet?.stage || latestResponse?.continue_with_stage || state.current_stage || null,
3336
3347
  latest_kind: state.checkpoint_packet?.kind || latestPacket?.kind || null,
@@ -3346,9 +3357,22 @@ function checkpointSummaryFromState(state, engineStatePath2) {
3346
3357
  });
3347
3358
  }
3348
3359
  function isDuplicateCheckpointResponse(state, response) {
3349
- const latestResponse = [...state.checkpoint_history || []].reverse().find((entry) => entry.response)?.response;
3350
- if (!latestResponse) return false;
3351
- return latestResponse.run_id === response.run_id && latestResponse.checkpoint === response.checkpoint && latestResponse.resume_token === response.resume_token && latestResponse.decision === response.decision;
3360
+ const identity = checkpointResponseIdentity(response);
3361
+ return (state.checkpoint_history || []).some((entry) => entry.response ? checkpointResponseIdentity(entry.response) === identity : false);
3362
+ }
3363
+ function checkpointResponseIdentity(response) {
3364
+ const logicalResponse = compactRecord({
3365
+ run_id: response.run_id,
3366
+ checkpoint: response.checkpoint,
3367
+ resume_token: response.resume_token,
3368
+ decision: response.decision,
3369
+ summary: response.summary,
3370
+ payload: response.payload,
3371
+ reasons: response.reasons,
3372
+ continue_with_stage: response.continue_with_stage,
3373
+ source: response.source
3374
+ });
3375
+ return import_node_crypto.default.createHash("sha256").update(stableJson(logicalResponse)).digest("hex").slice(0, 24);
3352
3376
  }
3353
3377
  function authorPacketPayloadFromCheckpointResponse(response) {
3354
3378
  if (response.decision !== "author_packet") return null;
@@ -3851,20 +3875,35 @@ function checkpointResponseContinuation(state, value) {
3851
3875
  }
3852
3876
  };
3853
3877
  }
3854
- if (!packet) {
3855
- if (isDuplicateCheckpointResponse(state, response)) {
3856
- return {
3857
- blocker: {
3858
- code: "checkpoint_response_duplicate",
3859
- checkpoint: response.checkpoint,
3860
- message: "Checkpoint response was already accepted and there is no pending checkpoint packet to resume.",
3861
- details: {
3862
- response,
3863
- checkpoint_summary: checkpointSummaryFromState(state)
3864
- }
3878
+ if (isDuplicateCheckpointResponse(state, response)) {
3879
+ const stage = packet?.stage || state.current_stage || "author";
3880
+ recordEvent(state, {
3881
+ kind: "checkpoint.response.duplicate",
3882
+ checkpoint: response.checkpoint,
3883
+ stage,
3884
+ summary: "Duplicate checkpoint response ignored.",
3885
+ details: compactRecord({
3886
+ decision: response.decision,
3887
+ resume_token: response.resume_token,
3888
+ response_identity: checkpointResponseIdentity(response)
3889
+ })
3890
+ });
3891
+ return {
3892
+ blocker: {
3893
+ code: "checkpoint_response_duplicate",
3894
+ checkpoint: response.checkpoint,
3895
+ message: "Checkpoint response was already accepted for this run/checkpoint/resume token and was not applied again.",
3896
+ details: {
3897
+ stage,
3898
+ duplicate: true,
3899
+ response,
3900
+ response_identity: checkpointResponseIdentity(response),
3901
+ checkpoint_summary: checkpointSummaryFromState(state)
3865
3902
  }
3866
- };
3867
- }
3903
+ }
3904
+ };
3905
+ }
3906
+ if (!packet) {
3868
3907
  return {
3869
3908
  blocker: {
3870
3909
  code: "checkpoint_response_without_packet",
@@ -2,11 +2,11 @@ import {
2
2
  createDisabledRiddleProofAgentAdapter,
3
3
  readRiddleProofRunStatus,
4
4
  runRiddleProofEngineHarness
5
- } from "./chunk-COFOLRUW.js";
6
- import "./chunk-5LVCGDSD.js";
5
+ } from "./chunk-VSLU6XNI.js";
6
+ import "./chunk-RI25RGQP.js";
7
7
  import "./chunk-7S7O3NKF.js";
8
8
  import "./chunk-W7VTDN4T.js";
9
- import "./chunk-4YCWZVBN.js";
9
+ import "./chunk-U4VNN2CT.js";
10
10
  export {
11
11
  createDisabledRiddleProofAgentAdapter,
12
12
  readRiddleProofRunStatus,
package/dist/index.cjs CHANGED
@@ -379,7 +379,8 @@ function visualDeltaShipGateReason(state = {}) {
379
379
  if (visualDelta.status === "measured" && visualDelta.passed === true) return null;
380
380
  const status = String(visualDelta.status || "missing");
381
381
  if (status === "unmeasured") {
382
- return "visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof";
382
+ const reason2 = String(visualDelta.reason || "").trim();
383
+ return reason2 ? `visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof: ${reason2}` : "visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof";
383
384
  }
384
385
  if (status === "measured" && visualDelta.passed === false) {
385
386
  return "visual_delta.status=measured but visual_delta.passed=false blocks ready_to_ship for visual/UI proof";
@@ -2751,6 +2752,7 @@ __export(index_exports, {
2751
2752
  authorPacketPayloadFromCheckpointResponse: () => authorPacketPayloadFromCheckpointResponse,
2752
2753
  buildAuthorCheckpointPacket: () => buildAuthorCheckpointPacket,
2753
2754
  buildVisualProofSession: () => buildVisualProofSession,
2755
+ checkpointResponseIdentity: () => checkpointResponseIdentity,
2754
2756
  checkpointSummaryFromState: () => checkpointSummaryFromState,
2755
2757
  compactRecord: () => compactRecord,
2756
2758
  compareVisualProofSessionFingerprint: () => compareVisualProofSessionFingerprint,
@@ -3254,6 +3256,14 @@ function jsonCloneValue(value) {
3254
3256
  return value;
3255
3257
  }
3256
3258
  }
3259
+ function stableJson(value) {
3260
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
3261
+ const record = recordValue(value);
3262
+ if (record) {
3263
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
3264
+ }
3265
+ return JSON.stringify(value);
3266
+ }
3257
3267
  function compactText(value, limit = 1600) {
3258
3268
  const text = nonEmptyString(value);
3259
3269
  if (!text) return void 0;
@@ -3411,6 +3421,7 @@ function checkpointSummaryFromState(state, engineStatePath2) {
3411
3421
  const history = state.checkpoint_history || [];
3412
3422
  const packets = history.filter((entry) => entry.packet);
3413
3423
  const responses = history.filter((entry) => entry.response);
3424
+ const duplicateResponses = (state.events || []).filter((event) => event.kind === "checkpoint.response.duplicate");
3414
3425
  const latestPacketEntry = [...history].reverse().find((entry) => entry.packet);
3415
3426
  const latestResponseEntry = [...history].reverse().find((entry) => entry.response);
3416
3427
  const latestPacket = state.checkpoint_packet || latestPacketEntry?.packet;
@@ -3422,6 +3433,7 @@ function checkpointSummaryFromState(state, engineStatePath2) {
3422
3433
  pending: Boolean(state.checkpoint_packet),
3423
3434
  packet_count: packets.length,
3424
3435
  response_count: responses.length,
3436
+ duplicate_response_count: duplicateResponses.length,
3425
3437
  latest_checkpoint: state.checkpoint_packet?.checkpoint || latestResponse?.checkpoint || state.last_checkpoint || null,
3426
3438
  latest_stage: state.checkpoint_packet?.stage || latestResponse?.continue_with_stage || state.current_stage || null,
3427
3439
  latest_kind: state.checkpoint_packet?.kind || latestPacket?.kind || null,
@@ -3437,9 +3449,22 @@ function checkpointSummaryFromState(state, engineStatePath2) {
3437
3449
  });
3438
3450
  }
3439
3451
  function isDuplicateCheckpointResponse(state, response) {
3440
- const latestResponse = [...state.checkpoint_history || []].reverse().find((entry) => entry.response)?.response;
3441
- if (!latestResponse) return false;
3442
- return latestResponse.run_id === response.run_id && latestResponse.checkpoint === response.checkpoint && latestResponse.resume_token === response.resume_token && latestResponse.decision === response.decision;
3452
+ const identity = checkpointResponseIdentity(response);
3453
+ return (state.checkpoint_history || []).some((entry) => entry.response ? checkpointResponseIdentity(entry.response) === identity : false);
3454
+ }
3455
+ function checkpointResponseIdentity(response) {
3456
+ const logicalResponse = compactRecord({
3457
+ run_id: response.run_id,
3458
+ checkpoint: response.checkpoint,
3459
+ resume_token: response.resume_token,
3460
+ decision: response.decision,
3461
+ summary: response.summary,
3462
+ payload: response.payload,
3463
+ reasons: response.reasons,
3464
+ continue_with_stage: response.continue_with_stage,
3465
+ source: response.source
3466
+ });
3467
+ return import_node_crypto.default.createHash("sha256").update(stableJson(logicalResponse)).digest("hex").slice(0, 24);
3443
3468
  }
3444
3469
  function authorPacketPayloadFromCheckpointResponse(response) {
3445
3470
  if (response.decision !== "author_packet") return null;
@@ -4428,20 +4453,35 @@ function checkpointResponseContinuation(state, value) {
4428
4453
  }
4429
4454
  };
4430
4455
  }
4431
- if (!packet) {
4432
- if (isDuplicateCheckpointResponse(state, response)) {
4433
- return {
4434
- blocker: {
4435
- code: "checkpoint_response_duplicate",
4436
- checkpoint: response.checkpoint,
4437
- message: "Checkpoint response was already accepted and there is no pending checkpoint packet to resume.",
4438
- details: {
4439
- response,
4440
- checkpoint_summary: checkpointSummaryFromState(state)
4441
- }
4456
+ if (isDuplicateCheckpointResponse(state, response)) {
4457
+ const stage = packet?.stage || state.current_stage || "author";
4458
+ recordEvent(state, {
4459
+ kind: "checkpoint.response.duplicate",
4460
+ checkpoint: response.checkpoint,
4461
+ stage,
4462
+ summary: "Duplicate checkpoint response ignored.",
4463
+ details: compactRecord({
4464
+ decision: response.decision,
4465
+ resume_token: response.resume_token,
4466
+ response_identity: checkpointResponseIdentity(response)
4467
+ })
4468
+ });
4469
+ return {
4470
+ blocker: {
4471
+ code: "checkpoint_response_duplicate",
4472
+ checkpoint: response.checkpoint,
4473
+ message: "Checkpoint response was already accepted for this run/checkpoint/resume token and was not applied again.",
4474
+ details: {
4475
+ stage,
4476
+ duplicate: true,
4477
+ response,
4478
+ response_identity: checkpointResponseIdentity(response),
4479
+ checkpoint_summary: checkpointSummaryFromState(state)
4442
4480
  }
4443
- };
4444
- }
4481
+ }
4482
+ };
4483
+ }
4484
+ if (!packet) {
4445
4485
  return {
4446
4486
  blocker: {
4447
4487
  code: "checkpoint_response_without_packet",
@@ -5187,12 +5227,12 @@ function hashString(value) {
5187
5227
  if (!text) return void 0;
5188
5228
  return (0, import_node_crypto4.createHash)("sha256").update(text).digest("hex");
5189
5229
  }
5190
- function stableJson(value) {
5230
+ function stableJson2(value) {
5191
5231
  if (value === void 0) return "null";
5192
5232
  if (value === null || typeof value !== "object") return JSON.stringify(value);
5193
- if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
5233
+ if (Array.isArray(value)) return `[${value.map((item) => stableJson2(item)).join(",")}]`;
5194
5234
  const record = value;
5195
- return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
5235
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson2(record[key])}`).join(",")}}`;
5196
5236
  }
5197
5237
  function withoutUndefined(record) {
5198
5238
  for (const key of Object.keys(record)) {
@@ -5218,7 +5258,7 @@ function visualSessionFingerprintBasis(input) {
5218
5258
  }
5219
5259
  function visualSessionFingerprint(input) {
5220
5260
  const basis = input.version === RIDDLE_PROOF_VISUAL_SESSION_FINGERPRINT_VERSION ? input : visualSessionFingerprintBasis(input);
5221
- return (0, import_node_crypto4.createHash)("sha256").update(stableJson(basis)).digest("hex");
5261
+ return (0, import_node_crypto4.createHash)("sha256").update(stableJson2(basis)).digest("hex");
5222
5262
  }
5223
5263
  function buildVisualProofSession(input) {
5224
5264
  const basis = visualSessionFingerprintBasis(input);
@@ -5279,7 +5319,7 @@ function compareVisualProofSessionFingerprint(parent, input) {
5279
5319
  for (const key of keys) {
5280
5320
  const expectedValue = expectedRecord[key];
5281
5321
  const actualValue = actualRecord[key];
5282
- if (stableJson(expectedValue) !== stableJson(actualValue)) {
5322
+ if (stableJson2(expectedValue) !== stableJson2(actualValue)) {
5283
5323
  mismatches.push({ key, expected: expectedValue, actual: actualValue });
5284
5324
  }
5285
5325
  }
@@ -5661,6 +5701,7 @@ function parseJson(value) {
5661
5701
  authorPacketPayloadFromCheckpointResponse,
5662
5702
  buildAuthorCheckpointPacket,
5663
5703
  buildVisualProofSession,
5704
+ checkpointResponseIdentity,
5664
5705
  checkpointSummaryFromState,
5665
5706
  compactRecord,
5666
5707
  compareVisualProofSessionFingerprint,
package/dist/index.d.cts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { EvidenceArtifact, EvidenceReference, ImplementationAdapter, ImplementationAdapterInput, ImplementationAdapterResult, IntegrationContext, JsonObject, JsonPrimitive, JsonValue, JudgeAdapter, NotificationAdapter, PreflightAdapter, PreflightAdapterInput, PreflightAdapterResult, ProofAdapter, ProofAdapterInput, ProofAdapterResult, RiddleProofArtifactRole, RiddleProofAssessment, RiddleProofBlocker, RiddleProofCheckpointArtifact, RiddleProofCheckpointPacket, RiddleProofCheckpointResponse, RiddleProofCheckpointRole, RiddleProofCheckpointRoutingHint, RiddleProofCheckpointSummary, RiddleProofCheckpointVisibility, RiddleProofDecision, RiddleProofEvent, RiddleProofEvidenceBundle, RiddleProofPrLifecycleState, RiddleProofPrLifecycleStatus, RiddleProofProofContract, RiddleProofRunParams, RiddleProofRunResult, RiddleProofRunState, RiddleProofRunStatusSnapshot, RiddleProofStage, RiddleProofStatePaths, RiddleProofStatus, RiddleProofTerminalMetadata, RiddleProofVerificationMode, RiddleProofVisualSession, RiddleProofVisualSessionFingerprintBasis, SetupAdapter, SetupAdapterInput, SetupAdapterResult, ShipAdapter } from './types.cjs';
2
2
  export { TerminalMetadataInput, applyTerminalMetadata, compactRecord, createRunResult, isSuccessfulStatus, isTerminalStatus, nonEmptyString, normalizeTerminalMetadata, recordValue } from './result.cjs';
3
3
  export { CreateRunStateInput, RIDDLE_PROOF_RUN_STATE_VERSION, RunEventInput, appendRunEvent, appendStageHeartbeat, applyPrLifecycleState, createRunState, createRunStatusSnapshot, normalizeIntegrationContext, normalizePrLifecycleState, normalizeRunParams, setRunStatus } from './state.cjs';
4
- export { RIDDLE_PROOF_CHECKPOINT_PACKET_VERSION, RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION, authorPacketPayloadFromCheckpointResponse, buildAuthorCheckpointPacket, checkpointSummaryFromState, isDuplicateCheckpointResponse, normalizeCheckpointResponse, proofContractFromAuthorCheckpointResponse, statePathsForRunState } from './checkpoint.cjs';
4
+ export { RIDDLE_PROOF_CHECKPOINT_PACKET_VERSION, RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION, authorPacketPayloadFromCheckpointResponse, buildAuthorCheckpointPacket, checkpointResponseIdentity, checkpointSummaryFromState, isDuplicateCheckpointResponse, normalizeCheckpointResponse, proofContractFromAuthorCheckpointResponse, statePathsForRunState } from './checkpoint.cjs';
5
5
  export { RiddleProofRunnerAdapters, RunRiddleProofInput, runRiddleProof } from './runner.cjs';
6
6
  export { RiddleProofAgentAdapter, RiddleProofAgentPayload, RiddleProofCheckpointMode, RiddleProofEngine, RiddleProofEngineHarnessConfig, RiddleProofEngineHarnessContext, RiddleProofEngineResult, RiddleProofShipMode, RiddleProofWorkflowParams, RunRiddleProofEngineHarnessInput, createDisabledRiddleProofAgentAdapter, readRiddleProofRunStatus, runRiddleProofEngineHarness } from './engine-harness.cjs';
7
7
  export { CreateCaptureDiagnosticInput, DEFAULT_DIAGNOSTIC_ARRAY_LIMIT, DEFAULT_DIAGNOSTIC_HISTORY_LIMIT, DEFAULT_DIAGNOSTIC_STRING_LIMIT, RIDDLE_PROOF_CAPTURE_DIAGNOSTIC_VERSION, RiddleProofArtifactSummary, RiddleProofCaptureArtifactSummary, RiddleProofCaptureDiagnostic, RiddleProofDiagnosticRedactionOptions, appendCaptureDiagnostic, createCaptureDiagnostic, redactForProofDiagnostics, summarizeCaptureArtifacts } from './diagnostics.cjs';
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { EvidenceArtifact, EvidenceReference, ImplementationAdapter, ImplementationAdapterInput, ImplementationAdapterResult, IntegrationContext, JsonObject, JsonPrimitive, JsonValue, JudgeAdapter, NotificationAdapter, PreflightAdapter, PreflightAdapterInput, PreflightAdapterResult, ProofAdapter, ProofAdapterInput, ProofAdapterResult, RiddleProofArtifactRole, RiddleProofAssessment, RiddleProofBlocker, RiddleProofCheckpointArtifact, RiddleProofCheckpointPacket, RiddleProofCheckpointResponse, RiddleProofCheckpointRole, RiddleProofCheckpointRoutingHint, RiddleProofCheckpointSummary, RiddleProofCheckpointVisibility, RiddleProofDecision, RiddleProofEvent, RiddleProofEvidenceBundle, RiddleProofPrLifecycleState, RiddleProofPrLifecycleStatus, RiddleProofProofContract, RiddleProofRunParams, RiddleProofRunResult, RiddleProofRunState, RiddleProofRunStatusSnapshot, RiddleProofStage, RiddleProofStatePaths, RiddleProofStatus, RiddleProofTerminalMetadata, RiddleProofVerificationMode, RiddleProofVisualSession, RiddleProofVisualSessionFingerprintBasis, SetupAdapter, SetupAdapterInput, SetupAdapterResult, ShipAdapter } from './types.js';
2
2
  export { TerminalMetadataInput, applyTerminalMetadata, compactRecord, createRunResult, isSuccessfulStatus, isTerminalStatus, nonEmptyString, normalizeTerminalMetadata, recordValue } from './result.js';
3
3
  export { CreateRunStateInput, RIDDLE_PROOF_RUN_STATE_VERSION, RunEventInput, appendRunEvent, appendStageHeartbeat, applyPrLifecycleState, createRunState, createRunStatusSnapshot, normalizeIntegrationContext, normalizePrLifecycleState, normalizeRunParams, setRunStatus } from './state.js';
4
- export { RIDDLE_PROOF_CHECKPOINT_PACKET_VERSION, RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION, authorPacketPayloadFromCheckpointResponse, buildAuthorCheckpointPacket, checkpointSummaryFromState, isDuplicateCheckpointResponse, normalizeCheckpointResponse, proofContractFromAuthorCheckpointResponse, statePathsForRunState } from './checkpoint.js';
4
+ export { RIDDLE_PROOF_CHECKPOINT_PACKET_VERSION, RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION, authorPacketPayloadFromCheckpointResponse, buildAuthorCheckpointPacket, checkpointResponseIdentity, checkpointSummaryFromState, isDuplicateCheckpointResponse, normalizeCheckpointResponse, proofContractFromAuthorCheckpointResponse, statePathsForRunState } from './checkpoint.js';
5
5
  export { RiddleProofRunnerAdapters, RunRiddleProofInput, runRiddleProof } from './runner.js';
6
6
  export { RiddleProofAgentAdapter, RiddleProofAgentPayload, RiddleProofCheckpointMode, RiddleProofEngine, RiddleProofEngineHarnessConfig, RiddleProofEngineHarnessContext, RiddleProofEngineResult, RiddleProofShipMode, RiddleProofWorkflowParams, RunRiddleProofEngineHarnessInput, createDisabledRiddleProofAgentAdapter, readRiddleProofRunStatus, runRiddleProofEngineHarness } from './engine-harness.js';
7
7
  export { CreateCaptureDiagnosticInput, DEFAULT_DIAGNOSTIC_ARRAY_LIMIT, DEFAULT_DIAGNOSTIC_HISTORY_LIMIT, DEFAULT_DIAGNOSTIC_STRING_LIMIT, RIDDLE_PROOF_CAPTURE_DIAGNOSTIC_VERSION, RiddleProofArtifactSummary, RiddleProofCaptureArtifactSummary, RiddleProofCaptureDiagnostic, RiddleProofDiagnosticRedactionOptions, appendCaptureDiagnostic, createCaptureDiagnostic, redactForProofDiagnostics, summarizeCaptureArtifacts } from './diagnostics.js';
package/dist/index.js CHANGED
@@ -25,18 +25,19 @@ import {
25
25
  createDisabledRiddleProofAgentAdapter,
26
26
  readRiddleProofRunStatus,
27
27
  runRiddleProofEngineHarness
28
- } from "./chunk-COFOLRUW.js";
28
+ } from "./chunk-VSLU6XNI.js";
29
29
  import {
30
30
  RIDDLE_PROOF_CHECKPOINT_PACKET_VERSION,
31
31
  RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION,
32
32
  authorPacketPayloadFromCheckpointResponse,
33
33
  buildAuthorCheckpointPacket,
34
+ checkpointResponseIdentity,
34
35
  checkpointSummaryFromState,
35
36
  isDuplicateCheckpointResponse,
36
37
  normalizeCheckpointResponse,
37
38
  proofContractFromAuthorCheckpointResponse,
38
39
  statePathsForRunState
39
- } from "./chunk-5LVCGDSD.js";
40
+ } from "./chunk-RI25RGQP.js";
40
41
  import {
41
42
  RIDDLE_PROOF_RUN_STATE_VERSION,
42
43
  appendRunEvent,
@@ -66,7 +67,7 @@ import {
66
67
  extractPlayabilityEvidence,
67
68
  isRiddleProofPlayabilityMode
68
69
  } from "./chunk-NSWT3VSV.js";
69
- import "./chunk-4YCWZVBN.js";
70
+ import "./chunk-U4VNN2CT.js";
70
71
  export {
71
72
  DEFAULT_DIAGNOSTIC_ARRAY_LIMIT,
72
73
  DEFAULT_DIAGNOSTIC_HISTORY_LIMIT,
@@ -88,6 +89,7 @@ export {
88
89
  authorPacketPayloadFromCheckpointResponse,
89
90
  buildAuthorCheckpointPacket,
90
91
  buildVisualProofSession,
92
+ checkpointResponseIdentity,
91
93
  checkpointSummaryFromState,
92
94
  compactRecord,
93
95
  compareVisualProofSessionFingerprint,
@@ -430,7 +430,8 @@ function visualDeltaShipGateReason(state = {}) {
430
430
  if (visualDelta.status === "measured" && visualDelta.passed === true) return null;
431
431
  const status = String(visualDelta.status || "missing");
432
432
  if (status === "unmeasured") {
433
- return "visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof";
433
+ const reason2 = String(visualDelta.reason || "").trim();
434
+ return reason2 ? `visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof: ${reason2}` : "visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof";
434
435
  }
435
436
  if (status === "measured" && visualDelta.passed === false) {
436
437
  return "visual_delta.status=measured but visual_delta.passed=false blocks ready_to_ship for visual/UI proof";
@@ -24,7 +24,7 @@ import {
24
24
  visualDeltaShipGateReason,
25
25
  workflowFile,
26
26
  writeState
27
- } from "./chunk-4YCWZVBN.js";
27
+ } from "./chunk-U4VNN2CT.js";
28
28
  export {
29
29
  BUNDLED_RIDDLE_PROOF_DIR,
30
30
  CHECKPOINT_CONTRACT_VERSION,
@@ -412,7 +412,8 @@ function visualDeltaShipGateReason(state = {}) {
412
412
  if (visualDelta.status === "measured" && visualDelta.passed === true) return null;
413
413
  const status = String(visualDelta.status || "missing");
414
414
  if (status === "unmeasured") {
415
- return "visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof";
415
+ const reason2 = String(visualDelta.reason || "").trim();
416
+ return reason2 ? `visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof: ${reason2}` : "visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof";
416
417
  }
417
418
  if (status === "measured" && visualDelta.passed === false) {
418
419
  return "visual_delta.status=measured but visual_delta.passed=false blocks ready_to_ship for visual/UI proof";
@@ -14,7 +14,7 @@ import {
14
14
  validateShipGate,
15
15
  workflowFile,
16
16
  writeState
17
- } from "./chunk-4YCWZVBN.js";
17
+ } from "./chunk-U4VNN2CT.js";
18
18
 
19
19
  // src/proof-run-engine.ts
20
20
  import { execFileSync } from "child_process";
package/dist/types.d.cts CHANGED
@@ -114,6 +114,7 @@ interface RiddleProofCheckpointSummary {
114
114
  pending: boolean;
115
115
  packet_count: number;
116
116
  response_count: number;
117
+ duplicate_response_count?: number;
117
118
  latest_checkpoint?: string | null;
118
119
  latest_stage?: RiddleProofStage | null;
119
120
  latest_kind?: string | null;
package/dist/types.d.ts CHANGED
@@ -114,6 +114,7 @@ interface RiddleProofCheckpointSummary {
114
114
  pending: boolean;
115
115
  packet_count: number;
116
116
  response_count: number;
117
+ duplicate_response_count?: number;
117
118
  latest_checkpoint?: string | null;
118
119
  latest_stage?: RiddleProofStage | null;
119
120
  latest_kind?: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riddledc/riddle-proof",
3
- "version": "0.5.40",
3
+ "version": "0.5.42",
4
4
  "description": "Reusable Riddle Proof contracts and helpers for evidence-backed agent changes.",
5
5
  "license": "MIT",
6
6
  "author": "RiddleDC",
@@ -33,6 +33,7 @@ import subprocess as sp
33
33
  MAX_RECON_ATTEMPTS = 4
34
34
  MIN_BODY_TEXT_LENGTH = 50
35
35
  MIN_INTERACTIVE_ELEMENTS = 1
36
+ MIN_CANVAS_AREA = 50000
36
37
  HYDRATION_WAIT_MS = 1500
37
38
  PAGE_STATE_PREFIX = 'RIDDLE_PROOF_STATE:'
38
39
  PROOF_EVIDENCE_PREFIX = 'RIDDLE_PROOF_EVIDENCE:'
@@ -423,6 +424,45 @@ def has_enriched_page_state(page_state):
423
424
  )
424
425
 
425
426
 
427
+ def numeric_value(value):
428
+ if isinstance(value, bool) or value is None:
429
+ return None
430
+ if isinstance(value, (int, float)):
431
+ return float(value)
432
+ if isinstance(value, str):
433
+ text = value.strip().rstrip('%').replace(',', '')
434
+ if not text:
435
+ return None
436
+ try:
437
+ return float(text)
438
+ except Exception:
439
+ return None
440
+ return None
441
+
442
+
443
+ def canvas_capture_signal(page_state):
444
+ if not isinstance(page_state, dict):
445
+ return {
446
+ 'canvas_ready': False,
447
+ 'canvas_count': 0,
448
+ 'large_canvas_area': 0,
449
+ }
450
+ large_canvas_area = 0
451
+ for item in list_value(page_state.get('largeVisibleElements')):
452
+ if not isinstance(item, dict) or item.get('tag') != 'canvas':
453
+ continue
454
+ area = numeric_value(item.get('area')) or 0
455
+ if area > large_canvas_area:
456
+ large_canvas_area = area
457
+ canvas_count = int(numeric_value(page_state.get('canvasCount')) or 0)
458
+ return {
459
+ 'canvas_ready': bool(canvas_count > 0 and large_canvas_area >= MIN_CANVAS_AREA),
460
+ 'canvas_count': canvas_count,
461
+ 'large_canvas_area': int(large_canvas_area),
462
+ 'min_canvas_area': MIN_CANVAS_AREA,
463
+ }
464
+
465
+
426
466
  def normalize_observed_path(value):
427
467
  raw = str(value or '').strip()
428
468
  if not raw:
@@ -538,6 +578,9 @@ def evaluate_capture_quality(payload, expected_path):
538
578
  'buttons': [],
539
579
  'links': [],
540
580
  'canvas_count': 0,
581
+ 'canvas_capture_ready': False,
582
+ 'large_canvas_area': 0,
583
+ 'min_canvas_area': MIN_CANVAS_AREA,
541
584
  'large_visible_elements': [],
542
585
  'semantic_anchor_count': 0,
543
586
  'capture_error_messages': [],
@@ -572,6 +615,12 @@ def evaluate_capture_quality(payload, expected_path):
572
615
  'large_visible_elements': list_value(page_state.get('largeVisibleElements'))[:10],
573
616
  'semantic_anchor_count': semantic_anchor_count(page_state),
574
617
  })
618
+ canvas_signal = canvas_capture_signal(page_state)
619
+ details.update({
620
+ 'canvas_capture_ready': canvas_signal['canvas_ready'],
621
+ 'large_canvas_area': canvas_signal['large_canvas_area'],
622
+ 'min_canvas_area': canvas_signal['min_canvas_area'],
623
+ })
575
624
  else:
576
625
  details.update({
577
626
  'body_text_length': MIN_BODY_TEXT_LENGTH + 100,
@@ -596,11 +645,20 @@ def evaluate_capture_quality(payload, expected_path):
596
645
  details['capture_error_messages'].append(str(proof_json.get('script_error'))[:500])
597
646
 
598
647
  reasons = []
599
- if details['body_text_length'] < MIN_BODY_TEXT_LENGTH:
648
+ canvas_ready = bool(details.get('canvas_capture_ready'))
649
+ body_text_ready = details['body_text_length'] >= MIN_BODY_TEXT_LENGTH or canvas_ready
650
+ interactive_ready = details['interactive_elements'] >= MIN_INTERACTIVE_ELEMENTS or canvas_ready
651
+ semantic_ready = (not has_enriched_page_state(page_state)) or details['semantic_anchor_count'] >= 1 or canvas_ready
652
+ details['body_text_ready'] = body_text_ready
653
+ details['interactive_ready'] = interactive_ready
654
+ details['semantic_ready'] = semantic_ready
655
+ details['canvas_capture_override'] = canvas_ready
656
+
657
+ if not body_text_ready:
600
658
  reasons.append(f'blank/near-blank page (text length: {details["body_text_length"]})')
601
- if details['interactive_elements'] < MIN_INTERACTIVE_ELEMENTS:
659
+ if not interactive_ready:
602
660
  reasons.append(f'not interactive enough ({details["interactive_elements"]} interactive elements)')
603
- if has_enriched_page_state(page_state) and details['semantic_anchor_count'] < 1:
661
+ if has_enriched_page_state(page_state) and not semantic_ready:
604
662
  reasons.append('no visible semantic UI anchors in page capture')
605
663
  if details['has_errors']:
606
664
  reasons.append('page has console/runtime errors')
@@ -610,11 +668,10 @@ def evaluate_capture_quality(payload, expected_path):
610
668
  raw_observed = details.get('observed_path_raw') or details.get('observed_path') or observed_path
611
669
  reasons.append(f'wrong route: expected {expected_path}, got {raw_observed}')
612
670
 
613
- semantic_ready = (not has_enriched_page_state(page_state)) or details['semantic_anchor_count'] >= 1
614
671
  telemetry_ready = (
615
672
  details['has_screenshot']
616
- and details['body_text_length'] >= MIN_BODY_TEXT_LENGTH
617
- and details['interactive_elements'] >= MIN_INTERACTIVE_ELEMENTS
673
+ and body_text_ready
674
+ and interactive_ready
618
675
  and semantic_ready
619
676
  and not details['has_errors']
620
677
  )
@@ -352,7 +352,9 @@ def visual_delta_ship_blocker(state):
352
352
  return ''
353
353
  status = str(visual_delta.get('status') or 'missing')
354
354
  if status == 'unmeasured':
355
- return 'visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof'
355
+ reason = str(visual_delta.get('reason') or '').strip()
356
+ detail = f': {reason}' if reason else ''
357
+ return 'visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof' + detail
356
358
  if status == 'measured' and visual_delta.get('passed') is False:
357
359
  return 'visual_delta.status=measured but visual_delta.passed=false blocks ready_to_ship for visual/UI proof'
358
360
  reason = str(visual_delta.get('reason') or '').strip()
@@ -33,6 +33,7 @@ import subprocess as sp
33
33
 
34
34
  MIN_BODY_TEXT_LENGTH = 50
35
35
  MIN_INTERACTIVE_ELEMENTS = 1
36
+ MIN_CANVAS_AREA = 50000
36
37
  HYDRATION_WAIT_MS = 1500
37
38
  PAGE_STATE_PREFIX = 'RIDDLE_PROOF_STATE:'
38
39
  PROOF_EVIDENCE_PREFIX = 'RIDDLE_PROOF_EVIDENCE:'
@@ -800,6 +801,8 @@ def extract_visual_delta(payload):
800
801
  result = payload.get('result') if isinstance(payload, dict) else {}
801
802
  proof_json = payload.get('_proof_json') if isinstance(payload, dict) else {}
802
803
  proof_evidence = extract_proof_evidence(payload)
804
+ screenshot_url = extract_screenshot_url(payload)
805
+ artifact_summary = summarize_capture_artifacts(payload)
803
806
  candidates = [
804
807
  payload if isinstance(payload, dict) else {},
805
808
  result if isinstance(result, dict) else {},
@@ -837,6 +840,23 @@ def extract_visual_delta(payload):
837
840
  percent = (changed_pixels / total_pixels) * 100
838
841
 
839
842
  if percent is None and changed_pixels is None:
843
+ has_artifacts = bool(
844
+ screenshot_url
845
+ or artifact_summary.get('outputs')
846
+ or artifact_summary.get('screenshots')
847
+ or artifact_summary.get('artifact_json')
848
+ )
849
+ reason = 'No measured before/after visual delta was found in proof evidence.'
850
+ if screenshot_url:
851
+ reason = (
852
+ 'After screenshot artifact is present, but no measured before/after visual delta metrics were emitted; '
853
+ 'the comparator did not run or did not publish change_percent/changed_pixels.'
854
+ )
855
+ elif has_artifacts:
856
+ reason = (
857
+ 'Capture artifacts are present, but no measured before/after visual delta metrics were emitted; '
858
+ 'the comparator did not run or did not publish change_percent/changed_pixels.'
859
+ )
840
860
  return {
841
861
  'status': 'unmeasured',
842
862
  'passed': None,
@@ -845,7 +865,19 @@ def extract_visual_delta(payload):
845
865
  'total_pixels': int(total_pixels) if total_pixels else None,
846
866
  'min_change_percent': MIN_VISUAL_DELTA_PERCENT,
847
867
  'min_changed_pixels': MIN_VISUAL_CHANGED_PIXELS,
848
- 'reason': 'No measured before/after visual delta was found in proof evidence.',
868
+ 'reason': reason,
869
+ 'diagnostic': {
870
+ 'after_screenshot_present': bool(screenshot_url),
871
+ 'proof_evidence_present': proof_evidence is not None,
872
+ 'artifact_output_count': len(artifact_summary.get('outputs') or []),
873
+ 'artifact_screenshot_count': len(artifact_summary.get('screenshots') or []),
874
+ 'artifact_json': list(artifact_summary.get('artifact_json') or []),
875
+ 'expected_metric_keys': {
876
+ 'percent': sorted(VISUAL_DELTA_PERCENT_KEYS),
877
+ 'changed_pixels': sorted(VISUAL_CHANGED_PIXEL_KEYS),
878
+ 'total_pixels': sorted(VISUAL_TOTAL_PIXEL_KEYS),
879
+ },
880
+ },
849
881
  }
850
882
 
851
883
  percent_pass = percent is not None and percent >= MIN_VISUAL_DELTA_PERCENT
@@ -891,7 +923,8 @@ def visual_delta_blocker_for_mode(verification_mode, visual_delta):
891
923
  status = str(visual_delta.get('status') or 'missing')
892
924
  reason = str(visual_delta.get('reason') or '').strip()
893
925
  if status == 'unmeasured':
894
- return 'visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof; capture a measured before/after visual delta or choose needs_richer_proof.'
926
+ detail = f' Reason: {reason}' if reason else ''
927
+ return 'visual_delta.status=unmeasured blocks ready_to_ship for visual/UI proof; capture a measured before/after visual delta or choose needs_richer_proof.' + detail
895
928
  if status == 'measured' and isinstance(visual_delta, dict) and visual_delta.get('passed') is False:
896
929
  return 'visual_delta.status=measured but visual_delta.passed=false blocks ready_to_ship for visual/UI proof; the measured change did not clear the threshold.'
897
930
  if reason:
@@ -926,6 +959,29 @@ def has_enriched_page_state(page_state):
926
959
  )
927
960
 
928
961
 
962
+ def canvas_capture_signal(page_state):
963
+ if not isinstance(page_state, dict):
964
+ return {
965
+ 'canvas_ready': False,
966
+ 'canvas_count': 0,
967
+ 'large_canvas_area': 0,
968
+ }
969
+ large_canvas_area = 0
970
+ for item in list_value(page_state.get('largeVisibleElements')):
971
+ if not isinstance(item, dict) or item.get('tag') != 'canvas':
972
+ continue
973
+ area = metric_number(item.get('area')) or 0
974
+ if area > large_canvas_area:
975
+ large_canvas_area = area
976
+ canvas_count = int(metric_number(page_state.get('canvasCount')) or 0)
977
+ return {
978
+ 'canvas_ready': bool(canvas_count > 0 and large_canvas_area >= MIN_CANVAS_AREA),
979
+ 'canvas_count': canvas_count,
980
+ 'large_canvas_area': int(large_canvas_area),
981
+ 'min_canvas_area': MIN_CANVAS_AREA,
982
+ }
983
+
984
+
929
985
  def normalize_observed_path(value):
930
986
  raw = str(value or '').strip()
931
987
  if not raw:
@@ -1135,6 +1191,7 @@ def evaluate_capture_quality(payload, expected_path, verification_mode='proof'):
1135
1191
  mode = normalized_verification_mode(verification_mode)
1136
1192
  supporting = collect_supporting_artifacts(payload)
1137
1193
  structured_ready = bool(supporting.get('has_structured_payload'))
1194
+ playability_ready = mode in PLAYABILITY_MODES and bool(supporting.get('playability_ready'))
1138
1195
  screenshot_required = screenshot_required_for_mode(mode)
1139
1196
  details = {
1140
1197
  'verification_mode': mode,
@@ -1143,6 +1200,10 @@ def evaluate_capture_quality(payload, expected_path, verification_mode='proof'):
1143
1200
  'screenshot_required': screenshot_required,
1144
1201
  'structured_evidence_present': structured_ready,
1145
1202
  'proof_evidence_present': bool(supporting.get('proof_evidence_present')),
1203
+ 'playability_ready': playability_ready,
1204
+ 'canvas_capture_ready': False,
1205
+ 'large_canvas_area': 0,
1206
+ 'min_canvas_area': MIN_CANVAS_AREA,
1146
1207
  'proof_evidence_sample': supporting.get('proof_evidence_sample', ''),
1147
1208
  'body_text_length': 0,
1148
1209
  'interactive_elements': 0,
@@ -1183,6 +1244,12 @@ def evaluate_capture_quality(payload, expected_path, verification_mode='proof'):
1183
1244
  'large_visible_elements': list_value(page_state.get('largeVisibleElements'))[:10],
1184
1245
  'semantic_anchor_count': semantic_anchor_count(page_state),
1185
1246
  })
1247
+ canvas_signal = canvas_capture_signal(page_state)
1248
+ details.update({
1249
+ 'canvas_capture_ready': canvas_signal['canvas_ready'],
1250
+ 'large_canvas_area': canvas_signal['large_canvas_area'],
1251
+ 'min_canvas_area': canvas_signal['min_canvas_area'],
1252
+ })
1186
1253
  elif screenshot_url:
1187
1254
  details.update({
1188
1255
  'body_text_length': MIN_BODY_TEXT_LENGTH + 100,
@@ -1220,11 +1287,20 @@ def evaluate_capture_quality(payload, expected_path, verification_mode='proof'):
1220
1287
  reasons.append('no screenshot or structured proof evidence in capture')
1221
1288
 
1222
1289
  should_enforce_visual_readiness = screenshot_required or (details['has_screenshot'] and not structured_ready)
1223
- if should_enforce_visual_readiness and details['body_text_length'] < MIN_BODY_TEXT_LENGTH:
1290
+ canvas_ready = bool(details.get('canvas_capture_ready'))
1291
+ body_text_ready = details['body_text_length'] >= MIN_BODY_TEXT_LENGTH or canvas_ready or playability_ready
1292
+ interactive_ready = details['interactive_elements'] >= MIN_INTERACTIVE_ELEMENTS or canvas_ready or playability_ready
1293
+ semantic_ready = (not has_enriched_page_state(page_state)) or details['semantic_anchor_count'] >= 1 or canvas_ready or playability_ready
1294
+ details['body_text_ready'] = body_text_ready
1295
+ details['interactive_ready'] = interactive_ready
1296
+ details['semantic_ready'] = semantic_ready
1297
+ details['canvas_or_playability_override'] = bool(should_enforce_visual_readiness and (canvas_ready or playability_ready))
1298
+
1299
+ if should_enforce_visual_readiness and not body_text_ready:
1224
1300
  reasons.append(f'blank/near-blank page (text length: {details["body_text_length"]})')
1225
- if should_enforce_visual_readiness and details['interactive_elements'] < MIN_INTERACTIVE_ELEMENTS:
1301
+ if should_enforce_visual_readiness and not interactive_ready:
1226
1302
  reasons.append(f'not interactive enough ({details["interactive_elements"]} interactive elements)')
1227
- if should_enforce_visual_readiness and has_enriched_page_state(page_state) and details['semantic_anchor_count'] < 1:
1303
+ if should_enforce_visual_readiness and has_enriched_page_state(page_state) and not semantic_ready:
1228
1304
  reasons.append('no visible semantic UI anchors in page capture')
1229
1305
  if details['has_errors']:
1230
1306
  reasons.append('page has console/runtime errors')
@@ -1234,15 +1310,14 @@ def evaluate_capture_quality(payload, expected_path, verification_mode='proof'):
1234
1310
  raw_observed = details.get('observed_path_raw') or details.get('observed_path') or observed_path
1235
1311
  reasons.append(f'wrong route: expected {expected_path}, got {raw_observed}')
1236
1312
 
1237
- semantic_ready = (not has_enriched_page_state(page_state)) or details['semantic_anchor_count'] >= 1
1238
1313
  visual_ready = (
1239
1314
  details['has_screenshot']
1240
- and details['body_text_length'] >= MIN_BODY_TEXT_LENGTH
1241
- and details['interactive_elements'] >= MIN_INTERACTIVE_ELEMENTS
1315
+ and body_text_ready
1316
+ and interactive_ready
1242
1317
  and semantic_ready
1243
1318
  and not details['has_errors']
1244
1319
  )
1245
- telemetry_ready = (visual_ready or structured_ready) and not details['has_errors']
1320
+ telemetry_ready = (visual_ready or structured_ready or playability_ready) and not details['has_errors']
1246
1321
 
1247
1322
  return {
1248
1323
  'valid': len(reasons) == 0 and telemetry_ready,
@@ -566,8 +566,106 @@ def run_verify_quality_ignores_proof_telemetry_console_text():
566
566
  })
567
567
  assert unmeasured_delta['status'] == 'unmeasured'
568
568
  assert unmeasured_delta['passed'] is None
569
+ assert unmeasured_delta['diagnostic']['after_screenshot_present'] is True
570
+ assert 'After screenshot artifact is present' in unmeasured_delta['reason']
571
+
572
+ canvas_payload = {
573
+ 'bodyTextLength': 7,
574
+ 'visibleTextSample': 'Luge',
575
+ 'interactiveElements': 1,
576
+ 'visibleInteractiveElements': 1,
577
+ 'pathname': '/games/luge-run',
578
+ 'title': 'Luge Run',
579
+ 'headings': [],
580
+ 'buttons': [],
581
+ 'links': [],
582
+ 'canvasCount': 1,
583
+ 'largeVisibleElements': [{'tag': 'canvas', 'text': '', 'area': 420000}],
584
+ }
585
+ canvas_quality = namespace['evaluate_capture_quality']({
586
+ 'ok': True,
587
+ 'screenshots': [{'name': 'after-proof.png', 'url': 'https://cdn.example.com/after-proof.png'}],
588
+ 'outputs': [{'name': 'after-proof.png', 'url': 'https://cdn.example.com/after-proof.png'}],
589
+ 'console': ['RIDDLE_PROOF_STATE:' + json.dumps(canvas_payload)],
590
+ }, '/games/luge-run', 'visual')
591
+ assert canvas_quality['valid'] is True, canvas_quality
592
+ assert canvas_quality['details']['canvas_capture_ready'] is True
593
+ assert canvas_quality['details']['body_text_ready'] is True
594
+ assert 'blank/near-blank' not in canvas_quality['reason']
595
+
596
+ playability_payload = {
597
+ **canvas_payload,
598
+ 'interactiveElements': 0,
599
+ 'visibleInteractiveElements': 0,
600
+ 'largeVisibleElements': [],
601
+ }
602
+ playability_evidence = {
603
+ 'input_events': [{'type': 'pointerdown'}],
604
+ 'state_delta': {'changed': True, 'changed_keys': ['distance']},
605
+ 'canvas_delta': {'changed_pixels': 18000},
606
+ 'time_delta_ms': 1300,
607
+ }
608
+ playable_quality = namespace['evaluate_capture_quality']({
609
+ 'ok': True,
610
+ 'screenshots': [{'name': 'after-proof.png', 'url': 'https://cdn.example.com/after-proof.png'}],
611
+ 'outputs': [{'name': 'after-proof.png', 'url': 'https://cdn.example.com/after-proof.png'}],
612
+ 'console': [
613
+ 'RIDDLE_PROOF_STATE:' + json.dumps(playability_payload),
614
+ 'RIDDLE_PROOF_EVIDENCE:' + json.dumps({'playability': playability_evidence}),
615
+ ],
616
+ }, '/games/luge-run', 'playable')
617
+ assert playable_quality['valid'] is True, playable_quality
618
+ assert playable_quality['details']['playability_ready'] is True
619
+ assert playable_quality['details']['interactive_ready'] is True
569
620
 
570
- return {'ok': True, 'telemetry_valid': quality['valid'], 'weak_delta_passed': weak_delta['passed']}
621
+ return {
622
+ 'ok': True,
623
+ 'telemetry_valid': quality['valid'],
624
+ 'weak_delta_passed': weak_delta['passed'],
625
+ 'canvas_valid': canvas_quality['valid'],
626
+ 'playable_valid': playable_quality['valid'],
627
+ }
628
+
629
+
630
+ def run_recon_quality_accepts_canvas_first_routes():
631
+ tempdir = Path(tempfile.mkdtemp(prefix='riddle-proof-recon-canvas-quality-'))
632
+ state_path = tempdir / 'state.json'
633
+ try:
634
+ write_state(state_path, {
635
+ 'after_worktree': str(tempdir),
636
+ 'before_worktree': str(tempdir),
637
+ })
638
+ with temporary_env(RIDDLE_PROOF_STATE_FILE=str(state_path)):
639
+ sys.modules.pop('util', None)
640
+ source = RECON_PATH.read_text()
641
+ helpers_source = source.split('\ndef clean_next_cache', 1)[0]
642
+ namespace = {'__file__': str(RECON_PATH)}
643
+ exec(compile(helpers_source, str(RECON_PATH), 'exec'), namespace)
644
+ canvas_payload = {
645
+ 'bodyTextLength': 4,
646
+ 'visibleTextSample': 'Game',
647
+ 'interactiveElements': 1,
648
+ 'visibleInteractiveElements': 1,
649
+ 'pathname': '/games/luge-run',
650
+ 'title': 'Luge Run',
651
+ 'headings': [],
652
+ 'buttons': [],
653
+ 'links': [],
654
+ 'canvasCount': 1,
655
+ 'largeVisibleElements': [{'tag': 'canvas', 'text': '', 'area': 420000}],
656
+ }
657
+ quality = namespace['evaluate_capture_quality']({
658
+ 'ok': True,
659
+ 'screenshots': [{'name': 'before.png', 'url': 'https://cdn.example.com/before.png'}],
660
+ 'outputs': [{'name': 'before.png', 'url': 'https://cdn.example.com/before.png'}],
661
+ 'console': ['RIDDLE_PROOF_STATE:' + json.dumps(canvas_payload)],
662
+ }, '/games/luge-run')
663
+ assert quality['valid'] is True, quality
664
+ assert quality['details']['canvas_capture_ready'] is True
665
+ assert 'blank/near-blank' not in quality['reason']
666
+ return {'ok': True, 'valid': quality['valid']}
667
+ finally:
668
+ shutil.rmtree(tempdir, ignore_errors=True)
571
669
 
572
670
 
573
671
  def load_util_with_fake(fake: FakeRiddle):
@@ -1992,6 +2090,7 @@ if __name__ == '__main__':
1992
2090
  'implement_records_detection_when_changes_missing': run_implement_records_detection_when_changes_missing(),
1993
2091
  'implement_ignores_tool_noise_when_detecting_changes': run_implement_ignores_tool_noise_when_detecting_changes(),
1994
2092
  'verify_quality_ignores_proof_telemetry_console_text': run_verify_quality_ignores_proof_telemetry_console_text(),
2093
+ 'recon_quality_accepts_canvas_first_routes': run_recon_quality_accepts_canvas_first_routes(),
1995
2094
  'recon_then_author_request': run_recon_then_author_request(),
1996
2095
  'recon_preserves_query_route': run_recon_preserves_query_route(),
1997
2096
  'recon_route_literal_preference': run_recon_prefers_route_literals_over_import_paths(),