@riddledc/riddle-proof 0.5.36 → 0.5.38

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.
@@ -1,9 +1,9 @@
1
1
  """Setup: create worktrees, install deps, validate args, write state.
2
2
 
3
- Idempotent — safe to re-run. Creates per-run worktrees under the active
4
- local temp storage by default:
5
- /tmp/.riddle-proof-worktrees/riddle-proof-<run_id>-before
6
- /tmp/.riddle-proof-worktrees/riddle-proof-<run_id>-after
3
+ Idempotent — safe to re-run. Creates per-run worktrees under disk-backed
4
+ scratch storage by default:
5
+ /var/tmp/riddle-proof/.riddle-proof-worktrees/riddle-proof-<run_id>-before
6
+ /var/tmp/riddle-proof/.riddle-proof-worktrees/riddle-proof-<run_id>-after
7
7
  """
8
8
 
9
9
  import json, subprocess as sp, os, sys, shutil, time, tempfile
@@ -24,6 +24,7 @@ SAFE_RUN_ID = ''.join(ch if ch.isalnum() or ch in ('-', '_') else '-' for ch in
24
24
 
25
25
  AFTER_WORKTREE_BRANCH = 'riddle-proof/' + SAFE_RUN_ID + '-after'
26
26
  LEGACY_WORKTREE_DIRS = ('/tmp/riddle-proof-before', '/tmp/riddle-proof-after')
27
+ DEFAULT_SCRATCH_ROOT = '/var/tmp/riddle-proof'
27
28
 
28
29
  if branch.startswith('riddle-proof/'):
29
30
  raise SystemExit(
@@ -158,17 +159,26 @@ def compatible_reuse_source(source_dir, target_dirs):
158
159
 
159
160
 
160
161
  def resolve_worktree_root(repo_dir):
161
- configured = (s.get('worktree_root') or os.environ.get('RIDDLE_PROOF_WORKTREE_ROOT') or '').strip()
162
+ configured = (os.environ.get('RIDDLE_PROOF_WORKTREE_ROOT') or '').strip()
162
163
  if configured:
163
164
  return os.path.abspath(os.path.expanduser(configured))
165
+ state_worktree_root = (s.get('worktree_root') or '').strip()
166
+ legacy_tmp_root = os.path.abspath(os.path.join(tempfile.gettempdir(), '.riddle-proof-worktrees'))
167
+ if state_worktree_root and os.path.abspath(os.path.expanduser(state_worktree_root)) != legacy_tmp_root:
168
+ return os.path.abspath(os.path.expanduser(state_worktree_root))
164
169
  if os.environ.get('RIDDLE_PROOF_USE_WORKSPACE_WORKTREE_ROOT', '').strip().lower() in ('1', 'true', 'yes'):
165
170
  repo_parent = os.path.dirname(os.path.abspath(repo_dir))
166
171
  return os.path.join(repo_parent, '.riddle-proof-worktrees')
167
172
 
168
- # Proof worktrees are scratch data. Keep them on local temp storage by
169
- # default so dependency cache materialization does not crawl across EFS or
170
- # other shared workspace filesystems.
171
- return os.path.join(tempfile.gettempdir(), '.riddle-proof-worktrees')
173
+ # Proof worktrees and dependency caches are large generated data. Keep
174
+ # them on disk-backed scratch storage by default so tmpfs /tmp remains
175
+ # available for small state files and short-lived artifacts.
176
+ scratch_root = (s.get('scratch_root') or os.environ.get('RIDDLE_PROOF_SCRATCH_ROOT') or '').strip()
177
+ if scratch_root:
178
+ return os.path.join(os.path.abspath(os.path.expanduser(scratch_root)), '.riddle-proof-worktrees')
179
+ if os.environ.get('RIDDLE_PROOF_USE_TMP_SCRATCH', '').strip().lower() in ('1', 'true', 'yes'):
180
+ return os.path.join(tempfile.gettempdir(), 'riddle-proof', '.riddle-proof-worktrees')
181
+ return os.path.join(DEFAULT_SCRATCH_ROOT, '.riddle-proof-worktrees')
172
182
 
173
183
 
174
184
  def env_flag(name, default=False):
@@ -202,6 +212,33 @@ def disk_free_bytes(path):
202
212
  return 0
203
213
 
204
214
 
215
+ def disk_snapshot(path):
216
+ probe = path
217
+ while probe and not os.path.exists(probe):
218
+ parent = os.path.dirname(probe)
219
+ if parent == probe:
220
+ break
221
+ probe = parent
222
+ try:
223
+ usage = shutil.disk_usage(probe or tempfile.gettempdir())
224
+ except Exception as exc:
225
+ return {
226
+ 'path': path,
227
+ 'probe_path': probe or tempfile.gettempdir(),
228
+ 'error': str(exc)[:200],
229
+ }
230
+ used = usage.total - usage.free
231
+ percent_used = round((used / usage.total) * 100, 1) if usage.total else 0
232
+ return {
233
+ 'path': path,
234
+ 'probe_path': probe or tempfile.gettempdir(),
235
+ 'total_bytes': usage.total,
236
+ 'used_bytes': used,
237
+ 'free_bytes': usage.free,
238
+ 'percent_used': percent_used,
239
+ }
240
+
241
+
205
242
  def prune_scratch_worktrees(worktree_root, keep_dirs, repo_dir):
206
243
  report = {
207
244
  'requested': True,
@@ -475,16 +512,24 @@ s['branch'] = branch
475
512
  s['target_branch'] = target_branch
476
513
  s['ship_target_branch'] = target_branch
477
514
  s['worktree_root'] = WORKTREE_ROOT
515
+ s['scratch_root'] = os.path.dirname(WORKTREE_ROOT)
516
+ try:
517
+ cache_result = workspace_core('dependency-cache-root', {'projectDir': AFTER_DIR}, timeout=30)
518
+ s['dependency_cache_root'] = cache_result.get('cacheRoot') or ''
519
+ except Exception as exc:
520
+ s['dependency_cache_root_error'] = str(exc)[:200]
478
521
  capture_hint = apply_capture_hint(s)
479
522
  if capture_hint and capture_hint.get('applied_fields'):
480
523
  print('Applied last-good capture hint: ' + ', '.join(capture_hint.get('applied_fields') or []))
481
524
  save_state(s)
482
525
  print('Prepared workspace via ' + setup.get('source', 'workspace_core') + ': ' + repo_dir)
483
526
  os.makedirs(WORKTREE_ROOT, exist_ok=True)
527
+ s['scratch_disk_before_cleanup'] = disk_snapshot(WORKTREE_ROOT)
484
528
  scratch_cleanup = prune_scratch_worktrees(WORKTREE_ROOT, (BEFORE_DIR, AFTER_DIR), repo_dir)
485
529
  if scratch_cleanup.get('removed') or scratch_cleanup.get('errors'):
486
530
  print('Scratch cleanup: removed ' + str(len(scratch_cleanup.get('removed') or [])) + ' stale proof worktree(s)')
487
531
  s['scratch_cleanup'] = scratch_cleanup
532
+ s['scratch_disk_after_cleanup'] = disk_snapshot(WORKTREE_ROOT)
488
533
  save_state(s)
489
534
  cleanup_legacy_branch_worktrees(repo_dir, base_branch)
490
535
 
@@ -605,6 +650,7 @@ s['dependency_install'] = {
605
650
  'before': before_dep_status,
606
651
  'after': after_dep_status,
607
652
  }
653
+ s['scratch_disk_after_setup'] = disk_snapshot(WORKTREE_ROOT)
608
654
  if not (s.get('capture_script') or '').strip():
609
655
  s['proof_plan_status'] = 'pending_recon'
610
656
  save_state(s)
@@ -47,6 +47,7 @@ VISUAL_FIRST_MODES = {
47
47
  'visual', 'render', 'interaction', 'ui', 'layout', 'screenshot',
48
48
  'canvas', 'animation',
49
49
  }
50
+ PLAYABILITY_MODES = {'playable', 'gameplay', 'game'}
50
51
  PROOF_EVIDENCE_REQUIRED_MODES = {'audio'}
51
52
  MIN_VISUAL_DELTA_PERCENT = 0.5
52
53
  MIN_VISUAL_CHANGED_PIXELS = 5000
@@ -77,11 +78,13 @@ def normalized_verification_mode(value):
77
78
 
78
79
 
79
80
  def proof_evidence_required_for_mode(verification_mode):
80
- return normalized_verification_mode(verification_mode) in PROOF_EVIDENCE_REQUIRED_MODES
81
+ mode = normalized_verification_mode(verification_mode)
82
+ return mode in PROOF_EVIDENCE_REQUIRED_MODES or mode in PLAYABILITY_MODES
81
83
 
82
84
 
83
85
  def screenshot_required_for_mode(verification_mode):
84
- return normalized_verification_mode(verification_mode) in VISUAL_FIRST_MODES
86
+ mode = normalized_verification_mode(verification_mode)
87
+ return mode in VISUAL_FIRST_MODES or mode in PLAYABILITY_MODES
85
88
 
86
89
 
87
90
  def auto_screenshot_for_mode(verification_mode):
@@ -408,6 +411,306 @@ def extract_proof_evidence(payload):
408
411
  return evidence
409
412
 
410
413
 
414
+ PLAYABILITY_EVIDENCE_VERSION = 'riddle-proof.playability.v1'
415
+ PLAYABILITY_ASSESSMENT_VERSION = 'riddle-proof.playability.assessment.v1'
416
+ PLAYABILITY_CONTAINER_KEYS = (
417
+ 'playability', 'playability_evidence', 'playabilityEvidence',
418
+ 'playable', 'gameplay', 'gameplay_evidence', 'gameplayEvidence',
419
+ )
420
+ PLAYABILITY_INPUT_KEYS = (
421
+ 'inputAccepted', 'inputObserved', 'inputReceived', 'controlsWorked',
422
+ 'keyboardWorked', 'pointerWorked', 'touchWorked', 'steeringInputAccepted',
423
+ 'userInputObserved',
424
+ )
425
+ PLAYABILITY_STATE_KEYS = (
426
+ 'stateChanged', 'gameStateChanged', 'playStarted', 'simulationAdvanced',
427
+ 'distanceAdvanced', 'scoreChanged', 'hudChanged', 'positionChanged',
428
+ 'speedChanged',
429
+ )
430
+ PLAYABILITY_MOTION_KEYS = (
431
+ 'motionObserved', 'visualMotion', 'canvasChanged', 'pixelChanged',
432
+ 'playfieldMoved', 'playfieldPixelsChanged', 'nonHudPixelsChanged',
433
+ 'animationAdvanced', 'frameChanged', 'framesChanged',
434
+ )
435
+ PLAYABILITY_TIME_KEYS = (
436
+ 'timeProgressed', 'clockAdvanced', 'animationAdvanced',
437
+ 'simulationAdvanced', 'playStarted',
438
+ )
439
+ PLAYABILITY_PERCENT_KEYS = (
440
+ 'changed_percent', 'change_percent', 'percent_changed', 'diff_percent',
441
+ 'motion_percent', 'pixel_change_percent',
442
+ )
443
+ PLAYABILITY_RATIO_KEYS = (
444
+ 'changed_ratio', 'change_ratio', 'diff_ratio', 'motion_ratio',
445
+ 'pixel_change_ratio',
446
+ )
447
+ PLAYABILITY_PIXEL_KEYS = (
448
+ 'changed_pixels', 'changed_pixel_count', 'diff_pixels', 'motion_pixels',
449
+ 'pixel_delta', 'changedPixels',
450
+ )
451
+ PLAYABILITY_AVG_DELTA_KEYS = (
452
+ 'average_delta', 'avg_delta', 'mean_delta', 'avg_abs_delta',
453
+ 'mean_abs_delta',
454
+ )
455
+ PLAYABILITY_TIME_DELTA_KEYS = (
456
+ 'time_delta_ms', 'elapsed_ms', 'duration_ms', 'sample_duration_ms',
457
+ 'animation_delta_ms',
458
+ )
459
+ PLAYABILITY_THRESHOLDS = {
460
+ 'min_changed_percent': 0.5,
461
+ 'min_changed_pixels': 1000,
462
+ 'min_average_delta': 1,
463
+ 'min_time_delta_ms': 250,
464
+ }
465
+
466
+
467
+ def _numeric(value):
468
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
469
+ return float(value)
470
+ if isinstance(value, str) and value.strip():
471
+ try:
472
+ return float(value)
473
+ except Exception:
474
+ return None
475
+ return None
476
+
477
+
478
+ def _numeric_from_keys(record, keys):
479
+ if not isinstance(record, dict):
480
+ return None
481
+ for key in keys:
482
+ value = _numeric(record.get(key))
483
+ if value is not None:
484
+ return value
485
+ return None
486
+
487
+
488
+ def _true_for_any_key(record, keys):
489
+ return isinstance(record, dict) and any(record.get(key) is True for key in keys)
490
+
491
+
492
+ def _percent_from(record):
493
+ percent = _numeric_from_keys(record, PLAYABILITY_PERCENT_KEYS)
494
+ if percent is not None:
495
+ return percent
496
+ ratio = _numeric_from_keys(record, PLAYABILITY_RATIO_KEYS)
497
+ if ratio is not None:
498
+ return ratio * 100 if ratio <= 1 else ratio
499
+ return None
500
+
501
+
502
+ def _parse_json_if_possible(value):
503
+ if not isinstance(value, str):
504
+ return None
505
+ text = value.strip()
506
+ if not text or text[0] not in '[{':
507
+ return None
508
+ try:
509
+ return json.loads(text)
510
+ except Exception:
511
+ return None
512
+
513
+
514
+ def _has_playability_shape(record):
515
+ if not isinstance(record, dict):
516
+ return False
517
+ if record.get('version') == PLAYABILITY_EVIDENCE_VERSION:
518
+ return True
519
+ shape_keys = (
520
+ 'input_events', 'state_delta', 'pixel_delta', 'canvas_delta',
521
+ 'motion_delta', 'playfield_delta', 'time_delta_ms',
522
+ )
523
+ if any(key in record for key in shape_keys):
524
+ return True
525
+ assertions = record.get('assertions') if isinstance(record.get('assertions'), dict) else record
526
+ return any(
527
+ key in assertions
528
+ for key in (
529
+ PLAYABILITY_INPUT_KEYS
530
+ + PLAYABILITY_STATE_KEYS
531
+ + PLAYABILITY_MOTION_KEYS
532
+ + PLAYABILITY_TIME_KEYS
533
+ )
534
+ )
535
+
536
+
537
+ def extract_playability_evidence(value, seen=None, depth=0):
538
+ if depth > 6 or value is None:
539
+ return None
540
+ if seen is None:
541
+ seen = set()
542
+ parsed = _parse_json_if_possible(value)
543
+ if parsed is not None:
544
+ return extract_playability_evidence(parsed, seen, depth + 1)
545
+ if isinstance(value, list):
546
+ ident = id(value)
547
+ if ident in seen:
548
+ return None
549
+ seen.add(ident)
550
+ for item in value:
551
+ found = extract_playability_evidence(item, seen, depth + 1)
552
+ if found is not None:
553
+ return found
554
+ return None
555
+ if not isinstance(value, dict):
556
+ return None
557
+ ident = id(value)
558
+ if ident in seen:
559
+ return None
560
+ seen.add(ident)
561
+ if _has_playability_shape(value):
562
+ return value
563
+ for key in PLAYABILITY_CONTAINER_KEYS:
564
+ if key in value:
565
+ found = extract_playability_evidence(value.get(key), seen, depth + 1)
566
+ if found is not None:
567
+ return found
568
+ if isinstance(value.get(key), bool):
569
+ return {'assertions': {key: value.get(key)}}
570
+ for item in value.values():
571
+ found = extract_playability_evidence(item, seen, depth + 1)
572
+ if found is not None:
573
+ return found
574
+ return None
575
+
576
+
577
+ def assess_playability_evidence(value):
578
+ evidence = extract_playability_evidence(value)
579
+ metrics = {}
580
+ required = {
581
+ 'input': True,
582
+ 'state_change': True,
583
+ 'motion': True,
584
+ 'time_progression': True,
585
+ }
586
+ if not isinstance(evidence, dict):
587
+ return {
588
+ 'version': PLAYABILITY_ASSESSMENT_VERSION,
589
+ 'evidence_present': False,
590
+ 'passed': False,
591
+ 'input_observed': False,
592
+ 'state_changed': False,
593
+ 'motion_observed': False,
594
+ 'time_progressed': False,
595
+ 'concerns': ['playability evidence is missing'],
596
+ 'metrics': metrics,
597
+ 'thresholds': dict(PLAYABILITY_THRESHOLDS),
598
+ 'required': required,
599
+ 'evidence_keys': [],
600
+ }
601
+
602
+ assertions = evidence.get('assertions') if isinstance(evidence.get('assertions'), dict) else evidence
603
+ input_events = []
604
+ for key in ('input_events', 'inputs', 'interactions'):
605
+ if isinstance(evidence.get(key), list):
606
+ input_events.extend(evidence.get(key))
607
+ input_count = _numeric_from_keys(evidence, ('input_event_count', 'input_count', 'interaction_count'))
608
+ input_observed = bool(
609
+ _true_for_any_key(assertions, PLAYABILITY_INPUT_KEYS)
610
+ or input_events
611
+ or (input_count is not None and input_count > 0)
612
+ )
613
+
614
+ state_delta = evidence.get('state_delta') if isinstance(evidence.get('state_delta'), dict) else evidence.get('stateDelta')
615
+ changed_keys = []
616
+ if isinstance(state_delta, dict):
617
+ raw_keys = state_delta.get('changed_keys') or state_delta.get('changedKeys') or []
618
+ if isinstance(raw_keys, list):
619
+ changed_keys = raw_keys
620
+ state_numeric_delta = _numeric_from_keys(evidence, (
621
+ 'distance_delta', 'score_delta', 'position_delta', 'speed_delta', 'hud_delta',
622
+ ))
623
+ if changed_keys:
624
+ metrics['state_changed_keys'] = changed_keys
625
+ if state_numeric_delta is not None:
626
+ metrics['state_numeric_delta'] = state_numeric_delta
627
+ state_changed = bool(
628
+ _true_for_any_key(assertions, PLAYABILITY_STATE_KEYS)
629
+ or (isinstance(state_delta, dict) and state_delta.get('changed') is True)
630
+ or changed_keys
631
+ or (state_numeric_delta is not None and abs(state_numeric_delta) > 0)
632
+ )
633
+
634
+ motion_observed = _true_for_any_key(assertions, PLAYABILITY_MOTION_KEYS)
635
+ if not motion_observed:
636
+ for source in (
637
+ evidence.get('playfield_delta'), evidence.get('playfieldDelta'),
638
+ evidence.get('non_hud_delta'), evidence.get('nonHudDelta'),
639
+ evidence.get('pixel_delta'), evidence.get('pixelDelta'),
640
+ evidence.get('canvas_delta'), evidence.get('canvasDelta'),
641
+ evidence.get('motion_delta'), evidence.get('motionDelta'),
642
+ evidence.get('visual_delta'), evidence.get('visualDelta'),
643
+ evidence,
644
+ ):
645
+ if not isinstance(source, dict):
646
+ continue
647
+ percent = _percent_from(source)
648
+ pixels = _numeric_from_keys(source, PLAYABILITY_PIXEL_KEYS)
649
+ average_delta = _numeric_from_keys(source, PLAYABILITY_AVG_DELTA_KEYS)
650
+ if percent is not None:
651
+ metrics['changed_percent'] = percent
652
+ if pixels is not None:
653
+ metrics['changed_pixels'] = pixels
654
+ if average_delta is not None:
655
+ metrics['average_delta'] = average_delta
656
+ motion_observed = bool(
657
+ (percent is not None and percent >= PLAYABILITY_THRESHOLDS['min_changed_percent'])
658
+ or (pixels is not None and pixels >= PLAYABILITY_THRESHOLDS['min_changed_pixels'])
659
+ or (average_delta is not None and average_delta >= PLAYABILITY_THRESHOLDS['min_average_delta'])
660
+ )
661
+ if motion_observed:
662
+ break
663
+
664
+ time_delta = _numeric_from_keys(evidence, PLAYABILITY_TIME_DELTA_KEYS)
665
+ if time_delta is None and isinstance(state_delta, dict):
666
+ time_delta = _numeric_from_keys(state_delta, PLAYABILITY_TIME_DELTA_KEYS)
667
+ if time_delta is not None:
668
+ metrics['time_delta_ms'] = time_delta
669
+ time_progressed = bool(
670
+ _true_for_any_key(assertions, PLAYABILITY_TIME_KEYS)
671
+ or (time_delta is not None and time_delta >= PLAYABILITY_THRESHOLDS['min_time_delta_ms'])
672
+ )
673
+
674
+ explicit_failure = bool(
675
+ evidence.get('passed') is False
676
+ or evidence.get('playable') is False
677
+ or assertions.get('playabilityPassed') is False
678
+ or any(assertions.get(key) is False for key in (
679
+ PLAYABILITY_INPUT_KEYS
680
+ + PLAYABILITY_STATE_KEYS
681
+ + PLAYABILITY_MOTION_KEYS
682
+ + PLAYABILITY_TIME_KEYS
683
+ ))
684
+ )
685
+
686
+ concerns = []
687
+ if not input_observed:
688
+ concerns.append('no accepted player input was observed')
689
+ if not state_changed:
690
+ concerns.append('game state did not measurably change')
691
+ if not motion_observed:
692
+ concerns.append('playfield/canvas pixels did not measurably change')
693
+ if not time_progressed:
694
+ concerns.append('play time or animation time did not measurably progress')
695
+ if explicit_failure:
696
+ concerns.append('playability evidence includes an explicit failed assertion')
697
+
698
+ return {
699
+ 'version': PLAYABILITY_ASSESSMENT_VERSION,
700
+ 'evidence_present': True,
701
+ 'passed': bool(input_observed and state_changed and motion_observed and time_progressed and not explicit_failure),
702
+ 'input_observed': input_observed,
703
+ 'state_changed': state_changed,
704
+ 'motion_observed': motion_observed,
705
+ 'time_progressed': time_progressed,
706
+ 'concerns': concerns,
707
+ 'metrics': metrics,
708
+ 'thresholds': dict(PLAYABILITY_THRESHOLDS),
709
+ 'required': required,
710
+ 'evidence_keys': list(evidence.keys()),
711
+ }
712
+
713
+
411
714
  def first_failed_proof_evidence(value):
412
715
  if isinstance(value, dict):
413
716
  if value.get('proof_evidence_present') is False:
@@ -698,6 +1001,7 @@ def collect_supporting_artifacts(payload):
698
1001
  structured_result_keys = [k for k in result_keys if k not in ('pageState', 'page_state')]
699
1002
  console_entries = payload.get('console') or []
700
1003
  proof_evidence = extract_proof_evidence(payload)
1004
+ playability_assessment = assess_playability_evidence(proof_evidence)
701
1005
 
702
1006
  return {
703
1007
  'image_outputs': image_outputs,
@@ -708,6 +1012,9 @@ def collect_supporting_artifacts(payload):
708
1012
  'console_entries': len(console_entries),
709
1013
  'proof_evidence_present': proof_evidence is not None,
710
1014
  'proof_evidence_sample': compact_value(proof_evidence) if proof_evidence is not None else '',
1015
+ 'playability_evidence_present': bool(playability_assessment.get('evidence_present')),
1016
+ 'playability_ready': bool(playability_assessment.get('passed')),
1017
+ 'playability_assessment': playability_assessment,
711
1018
  'has_structured_payload': bool(data_outputs or structured_result_keys or proof_evidence is not None),
712
1019
  }
713
1020
 
@@ -721,6 +1028,7 @@ def artifact_contract_for_mode(verification_mode):
721
1028
  'route_semantics': True,
722
1029
  'screenshot': screenshot_required_for_mode(mode),
723
1030
  'proof_evidence': proof_evidence_required_for_mode(mode),
1031
+ 'playability': mode in PLAYABILITY_MODES,
724
1032
  'visual_delta': visual_delta_applies(mode),
725
1033
  },
726
1034
  'preferred': {
@@ -748,6 +1056,8 @@ def artifact_production_summary(payload, supporting):
748
1056
  'console_entries': int(supporting.get('console_entries') or 0),
749
1057
  'structured_result_keys': list(supporting.get('structured_result_keys') or []),
750
1058
  'proof_evidence_present': bool(supporting.get('proof_evidence_present')),
1059
+ 'playability_evidence_present': bool(supporting.get('playability_evidence_present')),
1060
+ 'playability_ready': bool(supporting.get('playability_ready')),
751
1061
  'has_structured_payload': bool(supporting.get('has_structured_payload')),
752
1062
  }
753
1063
 
@@ -768,6 +1078,7 @@ def artifact_signal_availability(state, after_observation, supporting, visual_de
768
1078
  ),
769
1079
  'structured_payload': bool(supporting.get('has_structured_payload')),
770
1080
  'proof_evidence': bool(supporting.get('proof_evidence_present')),
1081
+ 'playability': bool(supporting.get('playability_ready')),
771
1082
  'visual_delta': visual_delta_passes_ship_gate(visual_delta),
772
1083
  'console_summary': bool(supporting.get('console_entries')),
773
1084
  'json_artifacts': bool(supporting.get('data_outputs')),
@@ -799,6 +1110,8 @@ def artifact_usage_summary(state, after_observation, supporting, visual_delta, r
799
1110
  capture_quality.append('structured_payload')
800
1111
  if available.get('proof_evidence'):
801
1112
  capture_quality.append('proof_evidence')
1113
+ if available.get('playability'):
1114
+ capture_quality.append('playability')
802
1115
  if available.get('visual_delta'):
803
1116
  capture_quality.append('visual_delta')
804
1117
 
@@ -1048,6 +1361,8 @@ def build_semantic_context(state, results, after_observation, expected_path):
1048
1361
  def build_evidence_bundle(state, results, after_payload, after_observation, required_baseline_present, expected_path):
1049
1362
  supporting = collect_supporting_artifacts(after_payload)
1050
1363
  proof_evidence = extract_proof_evidence(after_payload)
1364
+ playability_assessment = assess_playability_evidence(proof_evidence)
1365
+ playability_evidence = extract_playability_evidence(proof_evidence)
1051
1366
  visual_delta = (
1052
1367
  extract_visual_delta(after_payload)
1053
1368
  if visual_delta_applies(state.get('verification_mode'))
@@ -1089,6 +1404,7 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1089
1404
  },
1090
1405
  evidence={
1091
1406
  'visual_delta': visual_delta,
1407
+ 'playability_assessment': playability_assessment,
1092
1408
  'semantic_context': semantic_context,
1093
1409
  'artifact_contract': artifact_contract,
1094
1410
  'artifact_usage': artifact_usage,
@@ -1110,10 +1426,14 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1110
1426
  'observation': after_observation,
1111
1427
  'supporting_artifacts': supporting,
1112
1428
  'proof_evidence': proof_evidence,
1429
+ 'playability_evidence': playability_evidence,
1430
+ 'playability_assessment': playability_assessment,
1113
1431
  'proof_evidence_sample': compact_value(proof_evidence) if proof_evidence is not None else '',
1114
1432
  'visual_delta': visual_delta,
1115
1433
  },
1116
1434
  'proof_evidence': proof_evidence,
1435
+ 'playability_evidence': playability_evidence,
1436
+ 'playability_assessment': playability_assessment,
1117
1437
  'proof_evidence_sample': compact_value(proof_evidence) if proof_evidence is not None else '',
1118
1438
  'success_criteria': (state.get('success_criteria') or '').strip(),
1119
1439
  'assertions': state.get('parsed_assertions') or None,
@@ -1135,6 +1455,8 @@ def build_supervisor_assessment_request(state, payload, after_observation, requi
1135
1455
  evidence_basis.append('screenshots')
1136
1456
  if supporting['has_structured_payload']:
1137
1457
  evidence_basis.append('structured-artifacts')
1458
+ if supporting.get('playability_ready'):
1459
+ evidence_basis.append('playability')
1138
1460
  visual_delta = ((evidence_bundle or {}).get('after') or {}).get('visual_delta') or {}
1139
1461
  if visual_delta.get('status') == 'measured':
1140
1462
  evidence_basis.append('visual-delta')
@@ -1167,6 +1489,14 @@ def build_supervisor_assessment_request(state, payload, after_observation, requi
1167
1489
  evidence_bundle['artifact_usage'] = artifact_usage
1168
1490
  visual_delta_blocker = visual_delta_blocker_for_mode(verification_mode, visual_delta)
1169
1491
  hard_blockers = [visual_delta_blocker] if visual_delta_blocker else []
1492
+ if verification_mode in PLAYABILITY_MODES and not supporting.get('playability_ready'):
1493
+ assessment = supporting.get('playability_assessment') or {}
1494
+ concerns = assessment.get('concerns') if isinstance(assessment, dict) else []
1495
+ detail = '; '.join(str(item) for item in concerns[:4]) if isinstance(concerns, list) else ''
1496
+ hard_blockers.append(
1497
+ 'playability evidence blocks ready_to_ship for playable/gameplay proof'
1498
+ + (f': {detail}' if detail else '.')
1499
+ )
1170
1500
 
1171
1501
  return {
1172
1502
  'status': 'needs_supervising_agent_assessment',
@@ -1191,6 +1521,7 @@ def build_supervisor_assessment_request(state, payload, after_observation, requi
1191
1521
  'Use semantic_context.route plus headings/buttons/text anchors to ground route and content judgment before treating a screenshot as wrong-route.',
1192
1522
  'For visual/UI modes, use screenshots plus after_observation.details.visible_text_sample, headings, buttons, links, canvas_count, and large_visible_elements to explain what the proof actually shows.',
1193
1523
  'For visual/UI polish, capture success is not proof. If visual_delta.status is unmeasured, missing, not_applicable, or measured with passed=false, choose needs_implementation or needs_richer_proof instead of ready_to_ship.',
1524
+ 'For playable/gameplay proof, screenshots are supporting evidence only. Do not mark ready_to_ship unless playability_assessment.passed is true and the proof shows accepted input, state/time progression, and playfield/canvas pixel motion.',
1194
1525
  'For data/audio/log/metrics/custom modes, judge the structured evidence bundle and proof_evidence_sample directly; screenshots are optional supporting context.',
1195
1526
  'The summary must name the concrete change, the target route/UI, what changed in after evidence, and why the stop condition is satisfied.',
1196
1527
  'Only set escalation_target=human when you conclude the workflow has hit a real wall or is not converging.',
@@ -6,9 +6,6 @@ import {
6
6
  normalizeRunParams,
7
7
  setRunStatus
8
8
  } from "./chunk-OASB3CYU.js";
9
- import {
10
- visualDeltaShipGateReason
11
- } from "./chunk-4YCWZVBN.js";
12
9
  import {
13
10
  applyTerminalMetadata,
14
11
  compactRecord,
@@ -17,6 +14,9 @@ import {
17
14
  normalizeTerminalMetadata,
18
15
  recordValue
19
16
  } from "./chunk-J2MERROF.js";
17
+ import {
18
+ visualDeltaShipGateReason
19
+ } from "./chunk-4YCWZVBN.js";
20
20
 
21
21
  // src/engine-harness.ts
22
22
  import { execFileSync } from "child_process";