@riddledc/riddle-proof 0.8.3 → 0.8.4

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 (49) hide show
  1. package/dist/adapters/openclaw.js +4 -4
  2. package/dist/advanced/engine-harness.cjs +33 -11
  3. package/dist/advanced/engine-harness.js +4 -4
  4. package/dist/advanced/index.cjs +33 -11
  5. package/dist/advanced/index.d.cts +1 -1
  6. package/dist/advanced/index.d.ts +1 -1
  7. package/dist/advanced/index.js +6 -6
  8. package/dist/advanced/proof-run-engine.cjs +28 -8
  9. package/dist/advanced/proof-run-engine.d.cts +1 -1
  10. package/dist/advanced/proof-run-engine.d.ts +1 -1
  11. package/dist/advanced/proof-run-engine.js +1 -1
  12. package/dist/advanced/runner.js +4 -4
  13. package/dist/checkpoint.cjs +3 -1
  14. package/dist/checkpoint.js +1 -1
  15. package/dist/{chunk-SKIAZTQ7.js → chunk-4FOHZ7JG.js} +3 -1
  16. package/dist/{chunk-U4FUFBSH.js → chunk-FMOYUYH2.js} +1 -1
  17. package/dist/{chunk-YB5ACBZE.js → chunk-IP64JLLR.js} +5 -5
  18. package/dist/{chunk-OIFHYMHP.js → chunk-LZFCIHDT.js} +2 -2
  19. package/dist/{chunk-TZ3YMCDM.js → chunk-P22V26PS.js} +28 -8
  20. package/dist/{chunk-SMBZT46I.js → chunk-RBWSCU6V.js} +1 -1
  21. package/dist/{chunk-ZX45XGDJ.js → chunk-UIJ7X63P.js} +1 -1
  22. package/dist/{chunk-TNCDVE5O.js → chunk-YZUVEJ5B.js} +1 -1
  23. package/dist/cli/index.js +5 -5
  24. package/dist/cli.cjs +33 -11
  25. package/dist/cli.js +5 -5
  26. package/dist/engine-harness.cjs +33 -11
  27. package/dist/engine-harness.js +4 -4
  28. package/dist/index.cjs +33 -11
  29. package/dist/index.js +5 -5
  30. package/dist/openclaw.js +4 -4
  31. package/dist/{proof-run-engine-Rkd_hXB-.d.cts → proof-run-engine-B7DCPzpK.d.cts} +3 -3
  32. package/dist/{proof-run-engine-DxWW1VX1.d.ts → proof-run-engine-BomAcXhA.d.ts} +3 -3
  33. package/dist/proof-run-engine.cjs +28 -8
  34. package/dist/proof-run-engine.d.cts +1 -1
  35. package/dist/proof-run-engine.d.ts +1 -1
  36. package/dist/proof-run-engine.js +1 -1
  37. package/dist/run-card.js +2 -2
  38. package/dist/runner.js +4 -4
  39. package/dist/spec/checkpoint.cjs +3 -1
  40. package/dist/spec/checkpoint.js +1 -1
  41. package/dist/spec/index.cjs +3 -1
  42. package/dist/spec/index.js +3 -3
  43. package/dist/spec/run-card.js +2 -2
  44. package/dist/spec/state.js +3 -3
  45. package/dist/state.js +3 -3
  46. package/package.json +1 -1
  47. package/runtime/lib/util.py +57 -0
  48. package/runtime/lib/verify.py +128 -5
  49. package/runtime/tests/recon_verify_smoke.py +116 -0
package/dist/runner.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  runRiddleProof
3
- } from "./chunk-ZX45XGDJ.js";
4
- import "./chunk-TNCDVE5O.js";
5
- import "./chunk-U4FUFBSH.js";
3
+ } from "./chunk-UIJ7X63P.js";
4
+ import "./chunk-YZUVEJ5B.js";
5
+ import "./chunk-FMOYUYH2.js";
6
6
  import "./chunk-RV6LK7HU.js";
7
- import "./chunk-SKIAZTQ7.js";
7
+ import "./chunk-4FOHZ7JG.js";
8
8
  import "./chunk-VY4Y5U57.js";
9
9
  import "./chunk-MLKGABMK.js";
10
10
  export {
@@ -121,7 +121,7 @@ function responseSchemaForAuthorPacket() {
121
121
  summary: { type: "string" },
122
122
  payload: {
123
123
  type: "object",
124
- description: "For decision=author_packet, provide the proof packet itself or {author_packet:{...}} with proof_plan and capture_script."
124
+ description: "For decision=author_packet, provide the proof packet itself or {author_packet:{...}} with proof_plan, capture_script, and refined_inputs.expected_terminal_path when the proof changes route, query, or hash."
125
125
  },
126
126
  reasons: { type: "array", items: { type: "string" } },
127
127
  continue_with_stage: { type: "string", enum: ["author", "recon"] },
@@ -428,9 +428,11 @@ function buildAuthorCheckpointPacket(input) {
428
428
  repo: input.request.repo || fullState.repo,
429
429
  branch: input.request.branch || fullState.branch,
430
430
  verification_mode: input.request.verification_mode || fullState.verification_mode,
431
+ success_criteria: fullState.success_criteria,
431
432
  reference: input.request.reference || fullState.reference,
432
433
  server_path: fullState.server_path,
433
434
  wait_for_selector: fullState.wait_for_selector,
435
+ route_expectation: jsonCloneRecord(fullState.route_expectation),
434
436
  author_summary: fullState.author_summary,
435
437
  author_request: jsonCloneRecord(authorRequest),
436
438
  recon_baseline_understanding: jsonCloneRecord(fullState.recon_baseline_understanding),
@@ -13,7 +13,7 @@ import {
13
13
  normalizeCheckpointResponse,
14
14
  proofContractFromAuthorCheckpointResponse,
15
15
  statePathsForRunState
16
- } from "../chunk-SKIAZTQ7.js";
16
+ } from "../chunk-4FOHZ7JG.js";
17
17
  import "../chunk-VY4Y5U57.js";
18
18
  import "../chunk-MLKGABMK.js";
19
19
  export {
@@ -344,7 +344,7 @@ function responseSchemaForAuthorPacket() {
344
344
  summary: { type: "string" },
345
345
  payload: {
346
346
  type: "object",
347
- description: "For decision=author_packet, provide the proof packet itself or {author_packet:{...}} with proof_plan and capture_script."
347
+ description: "For decision=author_packet, provide the proof packet itself or {author_packet:{...}} with proof_plan, capture_script, and refined_inputs.expected_terminal_path when the proof changes route, query, or hash."
348
348
  },
349
349
  reasons: { type: "array", items: { type: "string" } },
350
350
  continue_with_stage: { type: "string", enum: ["author", "recon"] },
@@ -651,9 +651,11 @@ function buildAuthorCheckpointPacket(input) {
651
651
  repo: input.request.repo || fullState.repo,
652
652
  branch: input.request.branch || fullState.branch,
653
653
  verification_mode: input.request.verification_mode || fullState.verification_mode,
654
+ success_criteria: fullState.success_criteria,
654
655
  reference: input.request.reference || fullState.reference,
655
656
  server_path: fullState.server_path,
656
657
  wait_for_selector: fullState.wait_for_selector,
658
+ route_expectation: jsonCloneRecord(fullState.route_expectation),
657
659
  author_summary: fullState.author_summary,
658
660
  author_request: jsonCloneRecord(authorRequest),
659
661
  recon_baseline_understanding: jsonCloneRecord(fullState.recon_baseline_understanding),
@@ -10,11 +10,11 @@ import {
10
10
  normalizePrLifecycleState,
11
11
  normalizeRunParams,
12
12
  setRunStatus
13
- } from "../chunk-TNCDVE5O.js";
13
+ } from "../chunk-YZUVEJ5B.js";
14
14
  import {
15
15
  RIDDLE_PROOF_RUN_CARD_VERSION,
16
16
  createRiddleProofRunCard
17
- } from "../chunk-U4FUFBSH.js";
17
+ } from "../chunk-FMOYUYH2.js";
18
18
  import {
19
19
  RIDDLE_PROOF_CHECKPOINT_PACKET_VERSION,
20
20
  RIDDLE_PROOF_CHECKPOINT_RESPONSE_VERSION,
@@ -30,7 +30,7 @@ import {
30
30
  normalizeCheckpointResponse,
31
31
  proofContractFromAuthorCheckpointResponse,
32
32
  statePathsForRunState
33
- } from "../chunk-SKIAZTQ7.js";
33
+ } from "../chunk-4FOHZ7JG.js";
34
34
  import {
35
35
  applyTerminalMetadata,
36
36
  compactRecord,
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  RIDDLE_PROOF_RUN_CARD_VERSION,
3
3
  createRiddleProofRunCard
4
- } from "../chunk-U4FUFBSH.js";
5
- import "../chunk-SKIAZTQ7.js";
4
+ } from "../chunk-FMOYUYH2.js";
5
+ import "../chunk-4FOHZ7JG.js";
6
6
  import "../chunk-VY4Y5U57.js";
7
7
  import "../chunk-MLKGABMK.js";
8
8
  export {
@@ -9,9 +9,9 @@ import {
9
9
  normalizePrLifecycleState,
10
10
  normalizeRunParams,
11
11
  setRunStatus
12
- } from "../chunk-TNCDVE5O.js";
13
- import "../chunk-U4FUFBSH.js";
14
- import "../chunk-SKIAZTQ7.js";
12
+ } from "../chunk-YZUVEJ5B.js";
13
+ import "../chunk-FMOYUYH2.js";
14
+ import "../chunk-4FOHZ7JG.js";
15
15
  import "../chunk-VY4Y5U57.js";
16
16
  import "../chunk-MLKGABMK.js";
17
17
  export {
package/dist/state.js CHANGED
@@ -9,9 +9,9 @@ import {
9
9
  normalizePrLifecycleState,
10
10
  normalizeRunParams,
11
11
  setRunStatus
12
- } from "./chunk-TNCDVE5O.js";
13
- import "./chunk-U4FUFBSH.js";
14
- import "./chunk-SKIAZTQ7.js";
12
+ } from "./chunk-YZUVEJ5B.js";
13
+ import "./chunk-FMOYUYH2.js";
14
+ import "./chunk-4FOHZ7JG.js";
15
15
  import "./chunk-VY4Y5U57.js";
16
16
  import "./chunk-MLKGABMK.js";
17
17
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riddledc/riddle-proof",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Reusable Riddle Proof contracts and helpers for evidence-backed agent changes.",
5
5
  "license": "MIT",
6
6
  "author": "RiddleDC",
@@ -710,6 +710,60 @@ def invoke(tool, args, timeout=180):
710
710
  return {'ok': False, 'error': r.stdout[:300], 'stderr': r.stderr[:300]}
711
711
 
712
712
 
713
+ def compact_error_text(value, depth=0):
714
+ if depth > 4 or value is None:
715
+ return ''
716
+ if isinstance(value, str):
717
+ return value
718
+ if isinstance(value, (int, float, bool)):
719
+ return str(value)
720
+ if isinstance(value, list):
721
+ return '\n'.join(compact_error_text(item, depth + 1) for item in value[:12])
722
+ if isinstance(value, dict):
723
+ parts = []
724
+ priority_keys = (
725
+ 'error', 'message', 'script_error', 'stderr', 'stdout', 'raw',
726
+ 'details', 'result', 'capture', 'proof', '_proof_json',
727
+ )
728
+ for key in priority_keys:
729
+ if key in value:
730
+ text = compact_error_text(value.get(key), depth + 1)
731
+ if text:
732
+ parts.append(text)
733
+ if not parts:
734
+ for item in list(value.values())[:12]:
735
+ text = compact_error_text(item, depth + 1)
736
+ if text:
737
+ parts.append(text)
738
+ return '\n'.join(parts)
739
+ return ''
740
+
741
+
742
+ def non_retryable_riddle_script_error(result):
743
+ text = compact_error_text(result).lower()
744
+ if not text:
745
+ return False
746
+ playwright_timeout = (
747
+ 'timeout' in text
748
+ and (
749
+ 'locator.' in text
750
+ or 'page.waitforurl' in text
751
+ or 'page.waitforselector' in text
752
+ or 'waiting for selector' in text
753
+ or 'waiting for navigation' in text
754
+ or 'getbyrole' in text
755
+ or 'getbytext' in text
756
+ or 'scrollintoviewifneeded' in text
757
+ )
758
+ )
759
+ deterministic_playwright_error = (
760
+ 'strict mode violation' in text
761
+ or 'selector_not_found' in text
762
+ or 'index_out_of_range' in text
763
+ )
764
+ return playwright_timeout or deterministic_playwright_error
765
+
766
+
713
767
  def invoke_retry(tool, args, retries=3, timeout=180):
714
768
  """Call an OpenClaw tool with automatic retries on failure."""
715
769
  last_result = None
@@ -720,6 +774,9 @@ def invoke_retry(tool, args, retries=3, timeout=180):
720
774
  if result.get('ok') or result.get('outputs') or result.get('screenshots'):
721
775
  return result
722
776
  print(f'invoke_retry({tool}) attempt {attempt}/{retries} failed: {str(result.get("error", "no output"))[:200]}')
777
+ if tool == 'riddle_script' and non_retryable_riddle_script_error(result):
778
+ print('invoke_retry(riddle_script) stopping early for deterministic Playwright script error')
779
+ return result
723
780
  if attempt < retries:
724
781
  import time
725
782
  time.sleep(5)
@@ -1845,16 +1845,17 @@ def normalize_observed_path(value):
1845
1845
  raw = str(value or '').strip()
1846
1846
  if not raw:
1847
1847
  return ''
1848
- parsed = urlparse(raw.split('#', 1)[0])
1848
+ parsed = urlparse(raw)
1849
1849
  path = parsed.path or ''
1850
1850
  query = parsed.query or ''
1851
+ fragment = parsed.fragment or ''
1851
1852
  if not path.startswith('/'):
1852
1853
  path = '/' + path.lstrip('/')
1853
1854
  parts = path.split('/')
1854
1855
  if len(parts) >= 4 and parts[1] == 's':
1855
1856
  path = '/' + '/'.join(parts[3:])
1856
1857
  path = path.rstrip('/') or '/'
1857
- return path + (('?' + query) if query else '')
1858
+ return path + (('?' + query) if query else '') + (('#' + fragment) if fragment else '')
1858
1859
 
1859
1860
 
1860
1861
  def observed_location_from_page_state(page_state):
@@ -1862,10 +1863,13 @@ def observed_location_from_page_state(page_state):
1862
1863
  return ''
1863
1864
  pathname = str(page_state.get('pathname') or '').strip()
1864
1865
  search = str(page_state.get('search') or '').strip()
1866
+ hash_value = str(page_state.get('hash') or page_state.get('fragment') or '').strip()
1865
1867
  if search and not search.startswith('?'):
1866
1868
  search = '?' + search
1869
+ if hash_value and not hash_value.startswith('#'):
1870
+ hash_value = '#' + hash_value
1867
1871
  if pathname:
1868
- return pathname + search
1872
+ return pathname + search + hash_value
1869
1873
  return str(page_state.get('href') or '').strip()
1870
1874
 
1871
1875
 
@@ -1889,9 +1893,29 @@ def route_matches_expected(expected_path, observed_path):
1889
1893
  if pair not in remaining:
1890
1894
  return False
1891
1895
  remaining.remove(pair)
1896
+ if expected_parsed.fragment and observed_parsed.fragment != expected_parsed.fragment:
1897
+ return False
1892
1898
  return True
1893
1899
 
1894
1900
 
1901
+ def route_parts(value):
1902
+ normalized = normalize_observed_path(value)
1903
+ if not normalized:
1904
+ return {
1905
+ 'href': '',
1906
+ 'pathname': '',
1907
+ 'query': '',
1908
+ 'hash': '',
1909
+ }
1910
+ parsed = urlparse(normalized)
1911
+ return {
1912
+ 'href': normalized,
1913
+ 'pathname': parsed.path.rstrip('/') or '/',
1914
+ 'query': parsed.query or '',
1915
+ 'hash': ('#' + parsed.fragment) if parsed.fragment else '',
1916
+ }
1917
+
1918
+
1895
1919
  EXPLICIT_TERMINAL_PATH_KEYS = (
1896
1920
  'expected_terminal_path', 'expectedTerminalPath',
1897
1921
  'expected_terminal_route', 'expectedTerminalRoute',
@@ -1980,6 +2004,16 @@ def terminal_path_from_record(record, depth=0):
1980
2004
  return ''
1981
2005
 
1982
2006
 
2007
+ def terminal_path_from_text(value):
2008
+ if not isinstance(value, str):
2009
+ return ''
2010
+ for match in re.findall(r"""['"`](/[^'"`\s]+[?#][^'"`\s]*)['"`]""", value):
2011
+ candidate = path_candidate(match)
2012
+ if candidate:
2013
+ return candidate
2014
+ return ''
2015
+
2016
+
1983
2017
  def interaction_assertions_pass(value):
1984
2018
  for record in proof_evidence_records(value):
1985
2019
  if any(record.get(key) is False for key in (
@@ -2039,29 +2073,58 @@ def interaction_terminal_path_from_state(state):
2039
2073
  candidate = terminal_path_from_record(state.get(key))
2040
2074
  if candidate:
2041
2075
  return candidate, key
2076
+ for key in (
2077
+ 'expected_terminal_path',
2078
+ 'expected_after_path',
2079
+ 'capture_script',
2080
+ 'proof_plan',
2081
+ 'success_criteria',
2082
+ 'change_request',
2083
+ ):
2084
+ candidate = path_candidate(state.get(key)) or terminal_path_from_text(state.get(key))
2085
+ if candidate:
2086
+ return candidate, key
2042
2087
  return '', ''
2043
2088
 
2044
2089
 
2045
2090
  def expected_path_for_verify(state, start_path, proof_evidence):
2046
2091
  mode = normalized_verification_mode(state.get('verification_mode'))
2047
2092
  normalized_start = normalize_observed_path(start_path) or '/'
2093
+ start_parts = route_parts(normalized_start)
2048
2094
  if mode not in INTERACTION_MODES:
2049
2095
  return normalized_start, {
2050
2096
  'mode': mode,
2051
2097
  'source': 'recon_start_path',
2052
2098
  'start_path': normalized_start,
2053
2099
  'expected_path': normalized_start,
2100
+ 'start_pathname': start_parts['pathname'],
2101
+ 'start_query': start_parts['query'],
2102
+ 'start_hash': start_parts['hash'],
2103
+ 'expected_pathname': start_parts['pathname'],
2104
+ 'expected_query': start_parts['query'],
2105
+ 'expected_hash': start_parts['hash'],
2054
2106
  }
2055
2107
  candidate, source = interaction_terminal_path_from_state(state)
2056
2108
  if not candidate:
2057
2109
  candidate, source = interaction_terminal_path_from_evidence(proof_evidence)
2058
2110
  expected = candidate or normalized_start
2111
+ expected_parts = route_parts(expected)
2059
2112
  return expected, {
2060
2113
  'mode': mode,
2061
2114
  'source': source or 'recon_start_path',
2062
2115
  'start_path': normalized_start,
2063
2116
  'expected_path': expected,
2064
2117
  'terminal_path': expected if expected != normalized_start else '',
2118
+ 'start_pathname': start_parts['pathname'],
2119
+ 'start_query': start_parts['query'],
2120
+ 'start_hash': start_parts['hash'],
2121
+ 'expected_href': expected_parts['href'],
2122
+ 'expected_pathname': expected_parts['pathname'],
2123
+ 'expected_query': expected_parts['query'],
2124
+ 'expected_hash': expected_parts['hash'],
2125
+ 'terminal_pathname': expected_parts['pathname'] if expected != normalized_start else '',
2126
+ 'terminal_query': expected_parts['query'] if expected != normalized_start else '',
2127
+ 'terminal_hash': expected_parts['hash'] if expected != normalized_start else '',
2065
2128
  }
2066
2129
 
2067
2130
 
@@ -2391,6 +2454,19 @@ def build_capture_retry_decision(after_observation, required_baseline_present, p
2391
2454
  'reasons': reasons,
2392
2455
  }
2393
2456
 
2457
+ details = after_observation.get('details') if isinstance(after_observation.get('details'), dict) else {}
2458
+ route_mismatch = None
2459
+ reason_text = str(after_observation.get('reason') or '')
2460
+ if 'wrong route' in reason_text:
2461
+ expected = details.get('expected_path') or ''
2462
+ observed = details.get('observed_path_raw') or details.get('observed_path') or ''
2463
+ if expected or observed:
2464
+ route_mismatch = {
2465
+ 'field': 'route',
2466
+ 'expected_path': expected,
2467
+ 'observed_after_path': observed,
2468
+ }
2469
+
2394
2470
  if proof_evidence_blocker:
2395
2471
  reasons.append(proof_evidence_blocker)
2396
2472
  decision = 'missing_proof_evidence'
@@ -2399,19 +2475,36 @@ def build_capture_retry_decision(after_observation, required_baseline_present, p
2399
2475
  reasons.append('The capture reached usable page context, but the proof evidence explicitly failed its own required audio gate.')
2400
2476
  else:
2401
2477
  reasons.append('The capture reached usable page context, but the proof script did not emit the structured evidence required for this verification mode.')
2478
+ if route_mismatch:
2479
+ reasons.append(
2480
+ 'Route mismatch also present: expected after capture path ' +
2481
+ (route_mismatch.get('expected_path') or '(unknown)') +
2482
+ ', observed ' +
2483
+ (route_mismatch.get('observed_after_path') or '(unknown)') +
2484
+ '.'
2485
+ )
2402
2486
  reasons.append('Return to author so the capture script can expose passing proof evidence before verify asks for a supervising-agent judgment.')
2487
+ summary = proof_evidence_blocker
2488
+ if route_mismatch:
2489
+ summary += (
2490
+ ' Route mismatch: expected ' +
2491
+ (route_mismatch.get('expected_path') or '(unknown)') +
2492
+ ', got ' +
2493
+ (route_mismatch.get('observed_after_path') or '(unknown)') +
2494
+ '.'
2495
+ )
2403
2496
  return {
2404
2497
  'decision': decision,
2405
- 'summary': proof_evidence_blocker,
2498
+ 'summary': summary,
2406
2499
  'recommended_stage': 'author',
2407
2500
  'continue_with_stage': 'author',
2408
2501
  'reasons': reasons,
2502
+ 'mismatch': route_mismatch,
2409
2503
  }
2410
2504
 
2411
2505
  reason = after_observation.get('reason') or 'after capture is not usable yet'
2412
2506
  reasons.append('The after evidence is not usable yet: ' + reason)
2413
2507
  recommended_stage = 'recon' if 'wrong route' in reason else 'author'
2414
- details = after_observation.get('details') if isinstance(after_observation.get('details'), dict) else {}
2415
2508
  error_messages = [
2416
2509
  str(item).strip()
2417
2510
  for item in (details.get('capture_error_messages') or [])
@@ -2528,6 +2621,9 @@ def build_semantic_context(state, results, after_observation, expected_path):
2528
2621
  after_semantic = semantic_observation('after', after_observation)
2529
2622
  expected_start_path = state.get('expected_start_path') or expected_path
2530
2623
  route_expectation = state.get('route_expectation') if isinstance(state.get('route_expectation'), dict) else {}
2624
+ expected_parts = route_parts(expected_path)
2625
+ start_parts = route_parts(expected_start_path)
2626
+ after_parts = route_parts(after_semantic.get('observed_path_raw') or after_semantic.get('observed_path') or '')
2531
2627
  return {
2532
2628
  'expected_path': expected_path,
2533
2629
  'expected_start_path': expected_start_path,
@@ -2540,10 +2636,20 @@ def build_semantic_context(state, results, after_observation, expected_path):
2540
2636
  'expected_after_path': expected_path,
2541
2637
  'expected_start_path': expected_start_path,
2542
2638
  'expected_terminal_path': route_expectation.get('terminal_path') or '',
2639
+ 'expected_pathname': expected_parts['pathname'],
2640
+ 'expected_query': expected_parts['query'],
2641
+ 'expected_hash': expected_parts['hash'],
2642
+ 'expected_start_pathname': start_parts['pathname'],
2643
+ 'expected_start_query': start_parts['query'],
2644
+ 'expected_start_hash': start_parts['hash'],
2645
+ 'expected_terminal_query': route_expectation.get('terminal_query') or route_expectation.get('expected_query') or '',
2646
+ 'expected_terminal_hash': route_expectation.get('terminal_hash') or route_expectation.get('expected_hash') or '',
2543
2647
  'expectation_source': route_expectation.get('source') or '',
2544
2648
  'before_observed_path': before_semantic.get('observed_path') or before.get('path') or '',
2545
2649
  'prod_observed_path': prod_semantic.get('observed_path') or prod.get('path') or '',
2546
2650
  'after_observed_path': after_semantic.get('observed_path') or '',
2651
+ 'after_observed_query': after_parts['query'],
2652
+ 'after_observed_hash': after_parts['hash'],
2547
2653
  },
2548
2654
  'before': before_semantic,
2549
2655
  'prod': prod_semantic,
@@ -3051,6 +3157,15 @@ if expected_start_path and expected_start_path != expected_path:
3051
3157
  summary_lines.append('Expected terminal proof path: ' + expected_path)
3052
3158
  else:
3053
3159
  summary_lines.append('Expected proof path from recon: ' + expected_path)
3160
+ route_expected_query = (s.get('route_expectation') or {}).get('expected_query') or ''
3161
+ route_expected_hash = (s.get('route_expectation') or {}).get('expected_hash') or ''
3162
+ if route_expected_query or route_expected_hash:
3163
+ summary_lines.append(
3164
+ 'Expected terminal query/hash: ' +
3165
+ ('?' + route_expected_query if route_expected_query else '(none)') +
3166
+ ' ' +
3167
+ (route_expected_hash if route_expected_hash else '(none)')
3168
+ )
3054
3169
  summary_lines.append('After observation: ' + after_observation['reason'])
3055
3170
  supporting = results['after'].get('supporting_artifacts') or {}
3056
3171
  if supporting.get('has_structured_payload'):
@@ -3064,6 +3179,14 @@ if supporting.get('has_structured_payload'):
3064
3179
  summary_lines.append('Structured after evidence: ' + ('; '.join(basis) if basis else 'present'))
3065
3180
  observed_path = (after_observation.get('details') or {}).get('observed_path') or expected_path
3066
3181
  summary_lines.append('Observed after path: ' + observed_path)
3182
+ observed_parts = route_parts((after_observation.get('details') or {}).get('observed_path_raw') or observed_path)
3183
+ if observed_parts.get('query') or observed_parts.get('hash') or route_expected_query or route_expected_hash:
3184
+ summary_lines.append(
3185
+ 'Observed after query/hash: ' +
3186
+ ('?' + observed_parts.get('query') if observed_parts.get('query') else '(none)') +
3187
+ ' ' +
3188
+ (observed_parts.get('hash') if observed_parts.get('hash') else '(none)')
3189
+ )
3067
3190
  details = after_observation.get('details') or {}
3068
3191
  if details.get('headings'):
3069
3192
  summary_lines.append('Visible headings: ' + '; '.join(str(item) for item in details.get('headings', [])[:6]))
@@ -249,6 +249,47 @@ class FakeRiddle:
249
249
  'largeVisibleElements': [{'tag': 'button', 'text': 'Buy Now'}],
250
250
  }),
251
251
  }
252
+ if 'clickedSkipHashNavigation' in script:
253
+ page_state = {
254
+ 'bodyTextLength': 180,
255
+ 'visibleTextSample': 'Riddle Proof homepage main content',
256
+ 'interactiveElements': 4,
257
+ 'visibleInteractiveElements': 4,
258
+ 'pathname': '/',
259
+ 'search': '',
260
+ 'hash': '#main-content',
261
+ 'title': 'Riddle',
262
+ 'buttons': ['Start Free'],
263
+ 'headings': ['Riddle Proof'],
264
+ 'links': [{'text': 'Skip to main content', 'href': '#main-content'}],
265
+ 'canvasCount': 0,
266
+ 'largeVisibleElements': [{'tag': 'main', 'text': 'Riddle Proof'}],
267
+ }
268
+ proof_evidence = {
269
+ 'before': {'href': 'https://riddledc.com/'},
270
+ 'action': 'clicked Skip to main content',
271
+ 'after': {'href': 'https://riddledc.com/#main-content'},
272
+ 'assertions': {
273
+ 'startedOnHome': True,
274
+ 'hashPreserved': True,
275
+ 'mainContentFocused': True,
276
+ },
277
+ }
278
+ return {
279
+ 'ok': True,
280
+ 'screenshots': [{'url': 'https://cdn.example.com/hash-after.png'}],
281
+ 'outputs': [{'name': 'after-hash.png', 'url': 'https://cdn.example.com/hash-after.png'}],
282
+ 'result': {'pageState': page_state, 'proofEvidence': proof_evidence},
283
+ 'console': [
284
+ 'RIDDLE_PROOF_STATE:' + json.dumps(page_state),
285
+ 'RIDDLE_PROOF_EVIDENCE:' + json.dumps(proof_evidence),
286
+ ],
287
+ 'visual_diff': {
288
+ 'diffPercentage': 1.2,
289
+ 'differentPixels': 12000,
290
+ 'totalPixels': 972000,
291
+ },
292
+ }
252
293
  if 'clickedProofNavigation' in script:
253
294
  page_state = {
254
295
  'bodyTextLength': 180,
@@ -1376,6 +1417,34 @@ def run_project_build_retries_after_clean_failure():
1376
1417
  shutil.rmtree(tempdir)
1377
1418
 
1378
1419
 
1420
+ def run_invoke_retry_stops_on_playwright_locator_timeout():
1421
+ util = load_module('util_retry_timeout', UTIL_PATH)
1422
+ calls = []
1423
+ original_invoke = util.invoke
1424
+ original_sleep = util.time.sleep
1425
+
1426
+ def fake_invoke(tool, args, timeout=180):
1427
+ calls.append({'tool': tool, 'args': args, 'timeout': timeout})
1428
+ return {
1429
+ 'ok': False,
1430
+ 'error': 'locator.scrollIntoViewIfNeeded: Timeout 30000ms exceeded',
1431
+ }
1432
+
1433
+ try:
1434
+ util.invoke = fake_invoke
1435
+ util.time.sleep = lambda _seconds: None
1436
+ result = util.invoke_retry('riddle_script', {'script': 'await page.locator("a").click();'}, retries=3, timeout=60)
1437
+ assert result['ok'] is False
1438
+ assert len(calls) == 1, calls
1439
+ calls.clear()
1440
+ generic = util.invoke_retry('riddle_preview', {'directory': '/tmp/nope'}, retries=3, timeout=60)
1441
+ assert generic['ok'] is False
1442
+ assert len(calls) == 3, calls
1443
+ finally:
1444
+ util.invoke = original_invoke
1445
+ util.time.sleep = original_sleep
1446
+
1447
+
1379
1448
  def run_implement_records_detection_when_changes_missing():
1380
1449
  tempdir = Path(tempfile.mkdtemp(prefix='riddle-proof-implement-missing-'))
1381
1450
  state_path = tempdir / 'state.json'
@@ -2381,6 +2450,51 @@ def run_verify_interaction_reverse_terminal_route_from_proof_evidence():
2381
2450
  shutil.rmtree(tempdir, ignore_errors=True)
2382
2451
 
2383
2452
 
2453
+ def run_verify_interaction_hash_terminal_route_from_proof_evidence():
2454
+ tempdir = Path(tempfile.mkdtemp(prefix='riddle-proof-interaction-hash-'))
2455
+ state_path = tempdir / 'state.json'
2456
+ try:
2457
+ state = base_state(tempdir, reference='before')
2458
+ state.update({
2459
+ 'recon_status': 'ready_for_proof_plan',
2460
+ 'author_status': 'ready',
2461
+ 'proof_plan_status': 'ready',
2462
+ 'implementation_status': 'changes_detected',
2463
+ 'verification_mode': 'interaction',
2464
+ 'server_path': '/',
2465
+ 'before_cdn': 'https://cdn.example.com/before-home.png',
2466
+ 'proof_plan': 'Start at /, click the skip link, and verify the terminal /#main-content route.',
2467
+ 'capture_script': "clickedSkipHashNavigation(); await saveScreenshot('after-hash');",
2468
+ 'recon_results': {
2469
+ 'baselines': {'before': {'path': '/', 'url': 'https://cdn.example.com/before-home.png'}},
2470
+ },
2471
+ })
2472
+ write_state(state_path, state)
2473
+ os.environ['RIDDLE_PROOF_STATE_FILE'] = str(state_path)
2474
+
2475
+ fake = FakeRiddle()
2476
+ load_util_with_fake(fake)
2477
+ load_module('verify_interaction_hash_terminal_route', VERIFY_PATH)
2478
+ after_verify = json.loads(state_path.read_text())
2479
+
2480
+ assert after_verify['verify_status'] == 'evidence_captured'
2481
+ assert after_verify['route_expectation']['expected_path'] == '/#main-content'
2482
+ assert after_verify['route_expectation']['expected_hash'] == '#main-content'
2483
+ route = after_verify['proof_assessment_request']['semantic_context']['route']
2484
+ assert route['expected_after_path'] == '/#main-content'
2485
+ assert route['expected_terminal_hash'] == '#main-content'
2486
+ assert route['after_observed_path'] == '/#main-content'
2487
+ assert route['after_observed_hash'] == '#main-content'
2488
+ assert 'wrong route' not in after_verify['verify_results']['after']['observation']['reason']
2489
+ return {
2490
+ 'ok': True,
2491
+ 'expected_path': after_verify['route_expectation']['expected_path'],
2492
+ 'after_observed_hash': route['after_observed_hash'],
2493
+ }
2494
+ finally:
2495
+ shutil.rmtree(tempdir, ignore_errors=True)
2496
+
2497
+
2384
2498
  def run_verify_capture_retry_surfaces_script_timeout():
2385
2499
  tempdir = Path(tempfile.mkdtemp(prefix='riddle-proof-capture-timeout-'))
2386
2500
  state_path = tempdir / 'state.json'
@@ -2778,6 +2892,7 @@ if __name__ == '__main__':
2778
2892
  'capture_diagnostics_redaction': run_capture_diagnostics_redact_sensitive_values(),
2779
2893
  'apply_auth_context': run_apply_auth_context_passes_supported_auth_payloads(),
2780
2894
  'run_project_build_retries_after_clean_failure': run_project_build_retries_after_clean_failure(),
2895
+ 'invoke_retry_stops_on_playwright_locator_timeout': run_invoke_retry_stops_on_playwright_locator_timeout(),
2781
2896
  'implement_records_detection_when_changes_missing': run_implement_records_detection_when_changes_missing(),
2782
2897
  'implement_ignores_tool_noise_when_detecting_changes': run_implement_ignores_tool_noise_when_detecting_changes(),
2783
2898
  'verify_quality_ignores_proof_telemetry_console_text': run_verify_quality_ignores_proof_telemetry_console_text(),
@@ -2798,6 +2913,7 @@ if __name__ == '__main__':
2798
2913
  'remote_audit_verify_uses_default_capture_script': run_remote_audit_verify_uses_default_capture_script(),
2799
2914
  'verify_interaction_terminal_route_from_proof_evidence': run_verify_interaction_terminal_route_from_proof_evidence(),
2800
2915
  'verify_interaction_reverse_terminal_route_from_proof_evidence': run_verify_interaction_reverse_terminal_route_from_proof_evidence(),
2916
+ 'verify_interaction_hash_terminal_route_from_proof_evidence': run_verify_interaction_hash_terminal_route_from_proof_evidence(),
2801
2917
  'verify_capture_retry_surfaces_script_timeout': run_verify_capture_retry_surfaces_script_timeout(),
2802
2918
  'missing_baseline_guard': run_verify_missing_baseline(),
2803
2919
  'ship_supervisor_gate': run_ship_missing_supervisor_gate(),