@riddledc/riddle-proof 0.5.0 → 0.5.2

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 (35) hide show
  1. package/README.md +6 -6
  2. package/dist/chunk-3MHFLQKG.js +853 -0
  3. package/dist/{chunk-LVP22WE4.js → chunk-5GZZZ6JA.js} +5 -1
  4. package/dist/engine-harness.cjs +2505 -22
  5. package/dist/engine-harness.js +1 -1
  6. package/dist/index.cjs +2512 -29
  7. package/dist/index.js +2 -2
  8. package/dist/openclaw.cjs +1 -1
  9. package/dist/openclaw.js +1 -1
  10. package/dist/proof-run-core.cjs +909 -0
  11. package/dist/proof-run-core.d.cts +280 -0
  12. package/dist/proof-run-core.d.ts +280 -0
  13. package/dist/proof-run-core.js +48 -0
  14. package/dist/proof-run-engine.cjs +2499 -0
  15. package/dist/proof-run-engine.d.cts +677 -0
  16. package/dist/proof-run-engine.d.ts +677 -0
  17. package/dist/proof-run-engine.js +1649 -0
  18. package/lib/workspace-core.mjs +391 -0
  19. package/package.json +15 -3
  20. package/runtime/lib/author.py +343 -0
  21. package/runtime/lib/implement.py +63 -0
  22. package/runtime/lib/preflight.py +246 -0
  23. package/runtime/lib/recon.py +1048 -0
  24. package/runtime/lib/riddle_core_call.mjs +151 -0
  25. package/runtime/lib/setup.py +387 -0
  26. package/runtime/lib/ship.py +834 -0
  27. package/runtime/lib/util.py +673 -0
  28. package/runtime/lib/verify.py +1223 -0
  29. package/runtime/pipelines/riddle-proof-author.lobster +28 -0
  30. package/runtime/pipelines/riddle-proof-implement.lobster +26 -0
  31. package/runtime/pipelines/riddle-proof-recon.lobster +79 -0
  32. package/runtime/pipelines/riddle-proof-setup.lobster +141 -0
  33. package/runtime/pipelines/riddle-proof-ship.lobster +36 -0
  34. package/runtime/pipelines/riddle-proof-verify.lobster +74 -0
  35. package/runtime/tests/recon_verify_smoke.py +1198 -0
@@ -0,0 +1,834 @@
1
+ """Ship: commit, create PR, post proof artifacts, wait for CI, mark ready, cleanup."""
2
+
3
+ import json, subprocess as sp, time, os, sys, re
4
+ import urllib.error
5
+ import urllib.request
6
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
7
+ from util import load_state, save_state, invoke, git
8
+
9
+
10
+ DISCORD_API = 'https://discord.com/api/v10'
11
+ SHIP_NOISE_PATHS = ('.codex', '.oc-smoke')
12
+
13
+
14
+ def read_json_file(path):
15
+ try:
16
+ with open(path) as f:
17
+ return json.load(f)
18
+ except:
19
+ return {}
20
+
21
+
22
+ def openclaw_config_paths():
23
+ paths = []
24
+ if os.environ.get('OPENCLAW_CONFIG'):
25
+ paths.append(os.environ['OPENCLAW_CONFIG'])
26
+ if os.environ.get('OPENCLAW_HOME'):
27
+ paths.append(os.path.join(os.environ['OPENCLAW_HOME'], 'openclaw.json'))
28
+ paths.append(os.path.expanduser('~/.openclaw/openclaw.json'))
29
+ paths.append('/root/.openclaw/openclaw.json')
30
+ return paths
31
+
32
+
33
+ def resolve_discord_bot_token():
34
+ for path in openclaw_config_paths():
35
+ cfg = read_json_file(path)
36
+ token = (((cfg.get('channels') or {}).get('discord') or {}).get('token') or '').strip()
37
+ if token:
38
+ return token
39
+ if os.environ.get('DISCORD_BOT_TOKEN'):
40
+ return os.environ['DISCORD_BOT_TOKEN'].strip()
41
+ return ''
42
+
43
+
44
+ def status_path(line):
45
+ text = str(line or '')
46
+ if len(text) < 4:
47
+ return ''
48
+ path = text[3:].strip()
49
+ if ' -> ' in path:
50
+ path = path.split(' -> ', 1)[-1].strip()
51
+ return path.strip('"')
52
+
53
+
54
+ def is_ship_noise_path(path):
55
+ normalized = str(path or '').strip().lstrip('./')
56
+ return any(normalized == noise or normalized.startswith(noise + '/') for noise in SHIP_NOISE_PATHS)
57
+
58
+
59
+ def committable_status_lines(status_stdout):
60
+ return [
61
+ line for line in str(status_stdout or '').splitlines()
62
+ if line.strip()
63
+ and not is_ship_noise_path(status_path(line))
64
+ ]
65
+
66
+
67
+ def stage_committable_changes(repo_dir):
68
+ git('git add -A -- .', repo_dir)
69
+ for path in SHIP_NOISE_PATHS:
70
+ sp.run(['git', 'reset', '--quiet', '--', path], cwd=repo_dir, capture_output=True, text=True)
71
+ staged = sp.run(['git', 'diff', '--cached', '--name-only'], cwd=repo_dir, capture_output=True, text=True, timeout=30)
72
+ return [
73
+ line.strip() for line in staged.stdout.splitlines()
74
+ if line.strip() and not is_ship_noise_path(line.strip())
75
+ ]
76
+
77
+
78
+ def git_stdout(args, repo_dir, timeout=30):
79
+ result = sp.run(['git'] + args, cwd=repo_dir, capture_output=True, text=True, timeout=timeout)
80
+ if result.returncode != 0:
81
+ raise SystemExit('git ' + ' '.join(args) + ' failed: ' + result.stderr[:300])
82
+ return result.stdout.strip()
83
+
84
+
85
+ def remote_branch_head(repo_dir, branch):
86
+ result = sp.run(['git', 'ls-remote', 'origin', 'refs/heads/' + branch], cwd=repo_dir, capture_output=True, text=True, timeout=60)
87
+ if result.returncode != 0:
88
+ raise SystemExit('Failed to inspect remote branch head: ' + result.stderr[:300])
89
+ line = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ''
90
+ return line.split()[0] if line else ''
91
+
92
+
93
+ def fetch_remote_branch(repo_dir, branch):
94
+ return sp.run(
95
+ ['git', 'fetch', 'origin', '+refs/heads/' + branch + ':refs/remotes/origin/' + branch],
96
+ cwd=repo_dir,
97
+ capture_output=True,
98
+ text=True,
99
+ timeout=90,
100
+ )
101
+
102
+
103
+ def push_existing_pr_branch(repo_dir, branch, push_target):
104
+ push = sp.run(
105
+ ['git', 'push', 'origin', push_target, '--force-with-lease=refs/heads/' + branch],
106
+ cwd=repo_dir,
107
+ capture_output=True,
108
+ text=True,
109
+ timeout=120,
110
+ )
111
+ if push.returncode == 0:
112
+ return push, {'mode': 'force-with-lease', 'reconciled': False}
113
+
114
+ stderr = push.stderr or ''
115
+ if 'non-fast-forward' not in stderr and 'stale info' not in stderr and 'fetch first' not in stderr:
116
+ return push, {'mode': 'force-with-lease', 'reconciled': False}
117
+
118
+ fetch = fetch_remote_branch(repo_dir, branch)
119
+ if fetch.returncode != 0:
120
+ return push, {
121
+ 'mode': 'force-with-lease',
122
+ 'reconciled': False,
123
+ 'fetch_error': fetch.stderr[:300],
124
+ }
125
+ expected_remote_head = remote_branch_head(repo_dir, branch)
126
+ if not expected_remote_head:
127
+ return push, {'mode': 'force-with-lease', 'reconciled': False}
128
+
129
+ retry = sp.run(
130
+ ['git', 'push', 'origin', push_target, '--force-with-lease=refs/heads/' + branch + ':' + expected_remote_head],
131
+ cwd=repo_dir,
132
+ capture_output=True,
133
+ text=True,
134
+ timeout=120,
135
+ )
136
+ return retry, {
137
+ 'mode': 'force-with-lease-reconciled',
138
+ 'reconciled': retry.returncode == 0,
139
+ 'previous_remote_head': expected_remote_head,
140
+ }
141
+
142
+
143
+ def record_ship_head(state, repo_dir, branch, push_info):
144
+ local_head = git_stdout(['rev-parse', 'HEAD'], repo_dir)
145
+ remote_head = remote_branch_head(repo_dir, branch)
146
+ state['ship_commit'] = local_head
147
+ state['ship_remote_head'] = remote_head
148
+ state['ship_push'] = push_info
149
+ save_state(state)
150
+ if remote_head != local_head:
151
+ raise SystemExit(
152
+ 'Ship exact-commit check failed: local verified commit '
153
+ + local_head
154
+ + ' but remote '
155
+ + branch
156
+ + ' is '
157
+ + (remote_head or '(missing)')
158
+ )
159
+ return {'local_head': local_head, 'remote_head': remote_head}
160
+
161
+
162
+ def truthy(value):
163
+ return str(value or '').strip().lower() in ('1', 'true', 'yes', 'y', 'on')
164
+
165
+
166
+ def is_temp_proof_branch(branch):
167
+ return str(branch or '').strip().startswith('riddle-proof/')
168
+
169
+
170
+ def generated_ship_branch(state):
171
+ source = state.get('change_request') or state.get('commit_message') or 'proof-change'
172
+ slug = re.sub(r'[^a-z0-9]+', '-', str(source).lower())[:42].strip('-') or 'proof-change'
173
+ token = re.sub(r'[^a-z0-9]+', '', str(state.get('run_id') or '').lower())[-6:]
174
+ if not token:
175
+ token = str(int(time.time()))[-6:]
176
+ return 'agent/openclaw/' + slug + '-' + token
177
+
178
+
179
+ def pr_head_branch(repo_dir, pr_ref):
180
+ ref = str(pr_ref or '').strip()
181
+ if not ref:
182
+ return ''
183
+ result = sp.run(
184
+ ['gh', 'pr', 'view', ref, '--json', 'headRefName', '--jq', '.headRefName'],
185
+ cwd=repo_dir,
186
+ capture_output=True,
187
+ text=True,
188
+ timeout=60,
189
+ )
190
+ if result.returncode != 0:
191
+ return ''
192
+ return result.stdout.strip()
193
+
194
+
195
+ def resolve_ship_branch(state, repo_dir):
196
+ branch = str(state.get('ship_target_branch') or state.get('target_branch') or state.get('branch') or '').strip()
197
+ pr_ref = str(state.get('pr_number') or state.get('pr_url') or '').strip()
198
+ if pr_ref:
199
+ pr_branch = pr_head_branch(repo_dir, pr_ref)
200
+ if pr_branch:
201
+ branch = pr_branch
202
+ after_branch = str(state.get('after_worktree_branch') or '').strip()
203
+ if is_temp_proof_branch(branch) or (after_branch and branch == after_branch):
204
+ if not pr_ref:
205
+ original_branch = branch
206
+ branch = generated_ship_branch(state)
207
+ state['ship_branch_recovered_from'] = original_branch
208
+ state['ship_branch_recovery_reason'] = 'temporary_proof_branch_without_pr'
209
+ else:
210
+ raise SystemExit(
211
+ 'Refusing to ship to temporary proof branch: '
212
+ + (branch or '(empty)')
213
+ + '. Expected the real PR head branch; pr_url='
214
+ + str(state.get('pr_url') or '(none)')
215
+ )
216
+ if is_temp_proof_branch(branch):
217
+ raise SystemExit(
218
+ 'Refusing to ship to temporary proof branch: '
219
+ + (branch or '(empty)')
220
+ + '. Expected the real PR head branch; pr_url='
221
+ + str(state.get('pr_url') or '(none)')
222
+ )
223
+ if not branch:
224
+ raise SystemExit('No target branch available for ship.')
225
+ state['branch'] = branch
226
+ state['target_branch'] = branch
227
+ state['ship_target_branch'] = branch
228
+ save_state(state)
229
+ return branch
230
+
231
+
232
+ def compact_lines(lines, limit=1900):
233
+ text = '\n'.join([line for line in lines if line]).strip()
234
+ if len(text) <= limit:
235
+ return text
236
+ return text[:limit - 20].rstrip() + '\n...'
237
+
238
+
239
+ def first_url_from_command_output(*parts):
240
+ for part in parts:
241
+ for line in str(part or '').splitlines():
242
+ text = line.strip()
243
+ if text.startswith('http://') or text.startswith('https://'):
244
+ return text
245
+ return ''
246
+
247
+
248
+ def build_ship_report(state, marked_ready=None):
249
+ branch = state.get('target_branch') or state.get('branch') or ''
250
+ if marked_ready is None:
251
+ marked_ready = state.get('marked_ready')
252
+ return {
253
+ 'pr_url': state.get('pr_url', ''),
254
+ 'pr_branch': branch,
255
+ 'branch': branch,
256
+ 'shipped_commit': state.get('ship_commit', ''),
257
+ 'ship_remote_head': state.get('ship_remote_head', ''),
258
+ 'marked_ready': bool(marked_ready),
259
+ 'left_draft': bool(state.get('left_draft')),
260
+ 'ci_status': state.get('ci_status', ''),
261
+ 'proof_comment_url': state.get('proof_comment_url', ''),
262
+ 'proof_assessment_comment_url': state.get('proof_assessment_comment_url', ''),
263
+ 'before_artifact_url': state.get('before_cdn', ''),
264
+ 'prod_artifact_url': state.get('prod_cdn', ''),
265
+ 'after_artifact_url': state.get('after_cdn', ''),
266
+ }
267
+
268
+
269
+ def record_ship_report(state, marked_ready=None):
270
+ state['ship_report'] = build_ship_report(state, marked_ready)
271
+ save_state(state)
272
+ return state['ship_report']
273
+
274
+
275
+ def proof_assessment_is_ready(state):
276
+ assessment = state.get('proof_assessment') or {}
277
+ source = str(assessment.get('source') or state.get('proof_assessment_source') or '').strip().lower()
278
+ return source in ('supervising_agent', 'supervisor') and assessment.get('decision') == 'ready_to_ship'
279
+
280
+
281
+ def effective_merge_recommendation(state):
282
+ if proof_assessment_is_ready(state):
283
+ return 'ready_to_ship (supervising-agent proof assessment)'
284
+ return state.get('merge_recommendation') or 'manual review required'
285
+
286
+
287
+ def after_evidence_bundle(state):
288
+ bundle = state.get('evidence_bundle') or {}
289
+ if not isinstance(bundle, dict):
290
+ return {}
291
+ after = bundle.get('after') or {}
292
+ return after if isinstance(after, dict) else {}
293
+
294
+
295
+ def state_has_after_evidence(state):
296
+ if (state.get('after_cdn') or '').strip():
297
+ return True
298
+ after = after_evidence_bundle(state)
299
+ observation = after.get('observation') or {}
300
+ supporting = after.get('supporting_artifacts') or {}
301
+ if not isinstance(observation, dict) or not isinstance(supporting, dict):
302
+ return False
303
+ return bool(
304
+ observation.get('valid')
305
+ and (
306
+ supporting.get('has_structured_payload')
307
+ or supporting.get('proof_evidence_present')
308
+ or observation.get('telemetry_ready')
309
+ )
310
+ )
311
+
312
+
313
+ def evidence_bundle_text(state):
314
+ bundle = state.get('evidence_bundle') or {}
315
+ if not isinstance(bundle, dict):
316
+ return ''
317
+ after = after_evidence_bundle(state)
318
+ observation = after.get('observation') or {}
319
+ supporting = after.get('supporting_artifacts') or {}
320
+ if not isinstance(observation, dict):
321
+ observation = {}
322
+ if not isinstance(supporting, dict):
323
+ supporting = {}
324
+
325
+ lines = [
326
+ 'Verification mode: ' + str(bundle.get('verification_mode') or state.get('verification_mode') or 'proof'),
327
+ 'Expected path: ' + str(bundle.get('expected_path') or ''),
328
+ 'After observation: ' + str(observation.get('reason') or 'unknown'),
329
+ ]
330
+ semantic_context = bundle.get('semantic_context') or {}
331
+ if isinstance(semantic_context, dict):
332
+ route = semantic_context.get('route') or {}
333
+ after_semantic = semantic_context.get('after') or {}
334
+ if isinstance(route, dict):
335
+ route_bits = []
336
+ if route.get('before_observed_path'):
337
+ route_bits.append('before=' + str(route.get('before_observed_path')))
338
+ if route.get('prod_observed_path'):
339
+ route_bits.append('prod=' + str(route.get('prod_observed_path')))
340
+ if route.get('after_observed_path'):
341
+ route_bits.append('after=' + str(route.get('after_observed_path')))
342
+ if route_bits:
343
+ lines.append('Observed routes: ' + ', '.join(route_bits))
344
+ if isinstance(after_semantic, dict):
345
+ headings = after_semantic.get('headings') or []
346
+ buttons = after_semantic.get('buttons') or []
347
+ if headings:
348
+ lines.append('After headings: ' + '; '.join(str(item) for item in headings[:6]))
349
+ if buttons:
350
+ lines.append('After buttons: ' + '; '.join(str(item) for item in buttons[:8]))
351
+ basis = []
352
+ if supporting.get('image_outputs'):
353
+ basis.append('images=' + ', '.join(str((item or {}).get('name') or '') for item in supporting.get('image_outputs', [])[:8]))
354
+ if supporting.get('data_outputs'):
355
+ basis.append('data=' + ', '.join(str((item or {}).get('name') or '') for item in supporting.get('data_outputs', [])[:8]))
356
+ if supporting.get('structured_result_keys'):
357
+ basis.append('result_keys=' + ', '.join(str(item) for item in supporting.get('structured_result_keys', [])[:8]))
358
+ if supporting.get('proof_evidence_present'):
359
+ basis.append('proofEvidence=yes')
360
+ if basis:
361
+ lines.append('Evidence basis: ' + '; '.join(basis))
362
+ visual_delta = after.get('visual_delta') or {}
363
+ if isinstance(visual_delta, dict) and visual_delta.get('status') and visual_delta.get('status') != 'not_applicable':
364
+ try:
365
+ lines.append('Visual delta: ' + json.dumps(visual_delta, sort_keys=True)[:900])
366
+ except Exception:
367
+ lines.append('Visual delta: ' + str(visual_delta)[:900])
368
+ sample = str(supporting.get('proof_evidence_sample') or '').strip()
369
+ if sample:
370
+ lines.append('Proof evidence sample: ' + sample[:1200])
371
+ return '\n'.join([line for line in lines if line.strip()])
372
+
373
+
374
+ def proof_assessment_text(state):
375
+ assessment = state.get('proof_assessment') or {}
376
+ if not assessment:
377
+ return ''
378
+ lines = [
379
+ 'Decision: ' + str(assessment.get('decision') or 'unknown'),
380
+ ]
381
+ summary = str(assessment.get('summary') or '').strip()
382
+ if summary:
383
+ lines.append('Summary: ' + summary)
384
+ reasons = assessment.get('reasons') or []
385
+ if reasons:
386
+ lines.append('Reasons:')
387
+ for reason in reasons:
388
+ lines.append('- ' + str(reason))
389
+ return '\n'.join(lines)
390
+
391
+
392
+ def discord_message_target(state):
393
+ parent_channel_id = str(state.get('discord_channel') or '').strip()
394
+ thread_id = str(state.get('discord_thread_id') or '').strip()
395
+ source_message_id = str(state.get('discord_message_id') or '').strip()
396
+ target_channel_id = thread_id or parent_channel_id
397
+ if not target_channel_id:
398
+ return {
399
+ 'ok': False,
400
+ 'reason': 'no discord_channel or discord_thread_id in state',
401
+ 'parent_channel_id': parent_channel_id,
402
+ 'thread_id': thread_id,
403
+ 'source_message_id': source_message_id,
404
+ }
405
+
406
+ target = {
407
+ 'ok': True,
408
+ 'target_channel_id': target_channel_id,
409
+ 'parent_channel_id': parent_channel_id,
410
+ 'thread_id': thread_id,
411
+ 'source_message_id': source_message_id,
412
+ 'discord_source_url': str(state.get('discord_source_url') or '').strip(),
413
+ }
414
+ if source_message_id and parent_channel_id and not thread_id:
415
+ target['message_reference'] = {
416
+ 'message_id': source_message_id,
417
+ 'channel_id': parent_channel_id,
418
+ 'fail_if_not_exists': False,
419
+ }
420
+ return target
421
+
422
+
423
+ def post_discord_ready_message(state, marked_ready):
424
+ target = discord_message_target(state)
425
+ if not target.get('ok'):
426
+ return {'ok': False, 'skipped': True, **target}
427
+ target_channel_id = target['target_channel_id']
428
+
429
+ previous = state.get('discord_notification') or {}
430
+ if previous.get('ok') and previous.get('pr_url') == state.get('pr_url'):
431
+ return {'ok': True, 'skipped': True, 'reason': 'already sent', 'message_id': previous.get('message_id', '')}
432
+
433
+ token = resolve_discord_bot_token()
434
+ if not token:
435
+ return {'ok': False, 'skipped': True, 'reason': 'no Discord bot token available'}
436
+
437
+ ci_status = str(state.get('ci_status') or '').strip()
438
+ if state.get('left_draft'):
439
+ ci_line = 'Proof passed; PR was intentionally left draft.'
440
+ elif marked_ready and ci_status == 'no_checks':
441
+ ci_line = 'No CI checks were found; proof passed and the PR was marked ready.'
442
+ elif marked_ready:
443
+ ci_line = 'CI passed and the PR was marked ready.'
444
+ else:
445
+ ci_line = 'CI was not confirmed green yet; review the PR checks before merging.'
446
+ proof_bits = []
447
+ if state.get('before_cdn'):
448
+ proof_bits.append('before: ' + state['before_cdn'])
449
+ if state.get('prod_cdn'):
450
+ proof_bits.append('prod: ' + state['prod_cdn'])
451
+ if state.get('after_cdn'):
452
+ proof_bits.append('after: ' + state['after_cdn'])
453
+ elif state_has_after_evidence(state):
454
+ proof_bits.append('after: structured evidence bundle')
455
+
456
+ lines = [
457
+ 'Proofed change is ready for review.',
458
+ 'PR: ' + state.get('pr_url', ''),
459
+ 'Status: ' + ci_line,
460
+ 'Change: ' + state.get('change_request', ''),
461
+ 'Proof assessment: ' + effective_merge_recommendation(state),
462
+ ]
463
+ if proof_bits:
464
+ lines.append('Proof: ' + ' | '.join(proof_bits))
465
+ if state.get('proof_summary'):
466
+ lines.append('Proof summary: ' + state['proof_summary'])
467
+
468
+ payload_obj = {'content': compact_lines(lines)}
469
+ if target.get('message_reference'):
470
+ payload_obj['message_reference'] = target['message_reference']
471
+ payload = json.dumps(payload_obj).encode('utf-8')
472
+ req = urllib.request.Request(
473
+ DISCORD_API + '/channels/' + target_channel_id + '/messages',
474
+ data=payload,
475
+ headers={
476
+ 'Authorization': 'Bot ' + token,
477
+ 'Content-Type': 'application/json',
478
+ 'User-Agent': 'DiscordBot (https://openclaw.dev, 1.0)',
479
+ },
480
+ method='POST',
481
+ )
482
+ try:
483
+ with urllib.request.urlopen(req, timeout=20) as res:
484
+ body = res.read().decode('utf-8')
485
+ data = json.loads(body) if body else {}
486
+ return {
487
+ 'ok': 200 <= res.status < 300,
488
+ 'status': res.status,
489
+ 'channel_id': target_channel_id,
490
+ 'parent_channel_id': target.get('parent_channel_id', ''),
491
+ 'thread_id': target.get('thread_id', ''),
492
+ 'source_message_id': target.get('source_message_id', ''),
493
+ 'message_id': data.get('id', ''),
494
+ 'pr_url': state.get('pr_url', ''),
495
+ }
496
+ except urllib.error.HTTPError as e:
497
+ body = e.read().decode('utf-8', errors='replace')[:500]
498
+ return {'ok': False, 'status': e.code, 'channel_id': target_channel_id, 'thread_id': target.get('thread_id', ''), 'error': body}
499
+ except Exception as e:
500
+ return {'ok': False, 'status': 0, 'channel_id': target_channel_id, 'thread_id': target.get('thread_id', ''), 'error': str(e)[:500]}
501
+
502
+
503
+ def record_discord_notification(state, marked_ready):
504
+ discord_notification = post_discord_ready_message(state, marked_ready)
505
+ state['discord_notification'] = discord_notification
506
+ save_state(state)
507
+ if discord_notification.get('ok') and not discord_notification.get('skipped'):
508
+ print('Discord notification posted: ' + discord_notification.get('message_id', ''))
509
+ elif discord_notification.get('skipped'):
510
+ print('Discord notification skipped: ' + discord_notification.get('reason', 'unknown'))
511
+ else:
512
+ print('Warning: Discord notification failed: ' + str(discord_notification.get('error') or discord_notification.get('reason') or discord_notification.get('status') or 'unknown')[:200])
513
+ return discord_notification
514
+
515
+
516
+ def post_assessment_comment_if_needed(state, repo_dir, pr_num):
517
+ if state.get('proof_assessment_comment_posted'):
518
+ return {'ok': True, 'skipped': True, 'reason': 'already posted'}
519
+ assessment_text = proof_assessment_text(state)
520
+ if not assessment_text:
521
+ return {'ok': False, 'skipped': True, 'reason': 'no proof assessment text'}
522
+ if not pr_num:
523
+ return {'ok': False, 'skipped': True, 'reason': 'no PR number'}
524
+
525
+ body = '## Riddle Proof - Supervising Assessment\n\n'
526
+ body += 'The supervising agent judged this proof ready to ship.\n\n'
527
+ body += '```\n' + assessment_text + '\n```\n'
528
+ comment = sp.run(['gh', 'pr', 'comment', pr_num, '--body', body],
529
+ cwd=repo_dir, capture_output=True, text=True, timeout=90)
530
+ result = {'ok': comment.returncode == 0}
531
+ if comment.returncode == 0:
532
+ state['proof_assessment_comment_posted'] = True
533
+ url = first_url_from_command_output(comment.stdout, comment.stderr)
534
+ if url:
535
+ result['url'] = url
536
+ state['proof_assessment_comment_url'] = url
537
+ print('Supervising proof assessment comment posted.')
538
+ else:
539
+ result['error'] = comment.stderr[:300]
540
+ print('Warning: supervising proof assessment comment failed: ' + comment.stderr[:200])
541
+ state['proof_assessment_comment'] = result
542
+ save_state(state)
543
+ return result
544
+
545
+
546
+ s = load_state()
547
+
548
+ before_cdn = s.get('before_cdn', '')
549
+ prod_cdn = s.get('prod_cdn', '')
550
+ after_cdn = s.get('after_cdn', '')
551
+ reference = s.get('requested_reference') or s.get('reference', 'before')
552
+ prod_url = (s.get('prod_url') or '').strip()
553
+ proof_assessment = s.get('proof_assessment') or {}
554
+ proof_source = str(proof_assessment.get('source') or s.get('proof_assessment_source') or '').strip().lower()
555
+ if not state_has_after_evidence(s):
556
+ raise SystemExit('No after evidence in state. Run verify first.')
557
+ if s.get('verify_status') != 'evidence_captured':
558
+ raise SystemExit('verify_status must be evidence_captured before ship.')
559
+ if reference in ('before', 'both') and not before_cdn:
560
+ raise SystemExit('before_cdn is required before ship. Run recon/verify again and preserve the approved baseline.')
561
+ if reference in ('prod', 'both'):
562
+ if not prod_url:
563
+ raise SystemExit('prod_url is required when reference=' + reference + ' before ship.')
564
+ if not prod_cdn:
565
+ raise SystemExit('prod_cdn is required before ship. Run recon/verify again and preserve the approved prod baseline.')
566
+ if proof_source not in ('supervising_agent', 'supervisor') or proof_assessment.get('decision') != 'ready_to_ship':
567
+ raise SystemExit('Supervising-agent proof_assessment.decision=ready_to_ship is required before ship.')
568
+
569
+ s['merge_recommendation'] = effective_merge_recommendation(s)
570
+ s['proof_decision'] = proof_assessment.get('decision')
571
+ save_state(s)
572
+
573
+ repo_dir = s['repo_dir']
574
+ existing_notification = s.get('discord_notification') or {}
575
+ existing_after_dir = (s.get('after_worktree') or s.get('repo_dir') or '').strip()
576
+ if s.get('finalized') and s.get('pr_url') and existing_after_dir and not os.path.exists(existing_after_dir):
577
+ marked_ready = bool(s.get('marked_ready'))
578
+ pr_num = s.get('pr_number') or s.get('pr_url', '').rstrip('/').split('/')[-1]
579
+ post_assessment_comment_if_needed(s, repo_dir, pr_num)
580
+ if not existing_notification.get('ok'):
581
+ record_discord_notification(s, marked_ready)
582
+ else:
583
+ print('Discord notification already posted: ' + str(existing_notification.get('message_id') or 'yes'))
584
+ s['stage'] = 'ship'
585
+ s['active_checkpoint'] = 'ship_review'
586
+ report = record_ship_report(s, marked_ready)
587
+ save_state(s)
588
+ print('Ship already finalized; synced final ship side effects without worktree.')
589
+ print(json.dumps({
590
+ 'ok': True,
591
+ 'pr_url': s.get('pr_url', ''),
592
+ 'pr_branch': report.get('pr_branch', ''),
593
+ 'shipped_commit': report.get('shipped_commit', ''),
594
+ 'marked_ready': marked_ready,
595
+ 'left_draft': bool(s.get('left_draft')),
596
+ 'ci_status': s.get('ci_status', ''),
597
+ 'proof_comment_url': report.get('proof_comment_url', ''),
598
+ 'before_artifact_url': report.get('before_artifact_url', ''),
599
+ 'after_artifact_url': report.get('after_artifact_url', ''),
600
+ 'finalized_retry': True,
601
+ 'proof_assessment_comment_posted': bool(s.get('proof_assessment_comment_posted')),
602
+ 'discord_notification': s.get('discord_notification'),
603
+ 'ship_report': report,
604
+ }))
605
+ raise SystemExit(0)
606
+
607
+ after_dir = s.get('after_worktree', '').strip() or repo_dir
608
+ branch = resolve_ship_branch(s, repo_dir)
609
+ push_target = 'HEAD:refs/heads/' + branch
610
+ reviewer = s.get('reviewer', 'davisdiehl')
611
+ leave_draft = truthy(s.get('leave_draft'))
612
+ s['left_draft'] = False
613
+ s['ci_status'] = ''
614
+ save_state(s)
615
+
616
+ # Commit and push from after worktree
617
+ st = git('git status --porcelain', after_dir)
618
+ lines = committable_status_lines(st.stdout)
619
+
620
+ if lines:
621
+ staged_paths = stage_committable_changes(after_dir)
622
+ if not staged_paths:
623
+ print('Only ship-noise paths changed; skipping commit.')
624
+ if s.get('pr_url'):
625
+ if staged_paths:
626
+ git('git commit --amend --no-edit', after_dir)
627
+ push, push_info = push_existing_pr_branch(after_dir, branch, push_target)
628
+ else:
629
+ if staged_paths:
630
+ git('git commit -m ' + json.dumps(s['commit_message']), after_dir)
631
+ push = sp.run(
632
+ ['git', 'push', 'origin', push_target],
633
+ cwd=after_dir,
634
+ capture_output=True,
635
+ text=True,
636
+ )
637
+ push_info = {'mode': 'normal', 'reconciled': False}
638
+ if push.returncode != 0:
639
+ raise SystemExit('Failed to push branch: ' + push.stderr[:300])
640
+ pushed = record_ship_head(s, after_dir, branch, push_info)
641
+ print('Committed and pushed verified commit: ' + pushed['local_head'])
642
+ else:
643
+ if s.get('pr_url'):
644
+ push, push_info = push_existing_pr_branch(after_dir, branch, push_target)
645
+ else:
646
+ push = sp.run(
647
+ ['git', 'push', 'origin', push_target],
648
+ cwd=after_dir,
649
+ capture_output=True,
650
+ text=True,
651
+ )
652
+ push_info = {'mode': 'normal', 'reconciled': False}
653
+ if push.returncode != 0:
654
+ raise SystemExit('Failed to push branch: ' + push.stderr[:300])
655
+ pushed = record_ship_head(s, after_dir, branch, push_info)
656
+ print('No uncommitted changes. Branch pushed at verified commit: ' + pushed['local_head'])
657
+
658
+ # Create PR if needed
659
+ if not s.get('pr_url'):
660
+ q = sp.run('gh pr list --head "' + branch + '" --json url,number -q ".[0]"',
661
+ shell=True, cwd=repo_dir, capture_output=True, text=True)
662
+ pr_url = ''
663
+ if q.stdout.strip():
664
+ try:
665
+ pr = json.loads(q.stdout.strip())
666
+ pr_url = pr.get('url', '')
667
+ except:
668
+ pass
669
+ if not pr_url:
670
+ c = sp.run('gh pr create --draft --title ' + json.dumps(s['commit_message']) +
671
+ ' --body ' + json.dumps(s['change_request']) + ' --base main' +
672
+ ' --head ' + branch,
673
+ shell=True, cwd=repo_dir, capture_output=True, text=True)
674
+ pr_url = c.stdout.strip().splitlines()[-1].strip() if c.returncode == 0 else ''
675
+ s['pr_url'] = pr_url
676
+ s['pr_number'] = pr_url.rstrip('/').split('/')[-1] if pr_url else ''
677
+ save_state(s)
678
+ print('PR: ' + pr_url)
679
+
680
+ pr_num = s.get('pr_number', '')
681
+ if not pr_num:
682
+ raise SystemExit('No PR created. Check gh auth.')
683
+
684
+ # Post proof comment on PR
685
+ body = '## Riddle Proof — Proof of Fix\n\n'
686
+ body += '**Goal:** ' + s.get('change_request', '') + '\n\n'
687
+ if s.get('success_criteria'):
688
+ body += '**Success criteria:** ' + s['success_criteria'] + '\n\n'
689
+ body += '**Verification mode:** ' + s.get('verification_mode', 'proof') + '\n\n'
690
+ body += '**Merge recommendation:** ' + effective_merge_recommendation(s) + '\n\n'
691
+ if before_cdn:
692
+ body += '### Before\n![' + 'before' + '](' + before_cdn + ')\n\n'
693
+ if prod_cdn:
694
+ body += '### Prod\n![' + 'prod' + '](' + prod_cdn + ')\n\n'
695
+ if after_cdn:
696
+ body += '### After\n![' + 'after' + '](' + after_cdn + ')\n\n'
697
+ else:
698
+ body += '### After evidence\nNo after screenshot was captured for this verification mode; structured evidence is summarized below.\n\n'
699
+ bundle_text = evidence_bundle_text(s)
700
+ if bundle_text:
701
+ body += '### Evidence bundle\n```\n' + bundle_text + '\n```\n\n'
702
+ assessment_text = proof_assessment_text(s)
703
+ if assessment_text:
704
+ body += '### Supervising proof assessment\n```\n' + assessment_text + '\n```\n\n'
705
+ body += '### Proof summary\n```\n' + (s.get('proof_summary') or 'No summary') + '\n```\n\n'
706
+ body += '### Assertion status\n' + s.get('assertion_status', 'unknown') + '\n\n'
707
+ notes = s.get('evidence_notes') or []
708
+ if notes:
709
+ body += '### Review notes\n'
710
+ for note in notes:
711
+ body += '- ' + note + '\n'
712
+ body += '\n'
713
+ body += '---\n*Evidence captured by [Riddle Proof](https://riddledc.com)*\n'
714
+
715
+ comment = sp.run(['gh', 'pr', 'comment', pr_num, '--body', body],
716
+ cwd=repo_dir, capture_output=True, text=True, timeout=90)
717
+ if comment.returncode != 0:
718
+ print('Warning: PR comment failed: ' + comment.stderr[:200])
719
+ else:
720
+ url = first_url_from_command_output(comment.stdout, comment.stderr)
721
+ s['proof_comment_posted'] = True
722
+ if url:
723
+ s['proof_comment_url'] = url
724
+ s['proof_assessment_comment_posted'] = True
725
+ save_state(s)
726
+
727
+ # Wait for CI, then mark ready + assign reviewer unless explicitly held draft.
728
+ marked_ready = False
729
+ if leave_draft:
730
+ s['left_draft'] = True
731
+ s['ci_status'] = 'left_draft'
732
+ save_state(s)
733
+ print('PR left draft because leave_draft=true.')
734
+ else:
735
+ for attempt in range(30):
736
+ checks = sp.run(['gh', 'pr', 'checks', pr_num, '--json', 'state'],
737
+ cwd=repo_dir, capture_output=True, text=True, timeout=30)
738
+ if checks.returncode == 0:
739
+ try:
740
+ states = json.loads(checks.stdout)
741
+ if not states:
742
+ s['ci_status'] = 'no_checks'
743
+ save_state(s)
744
+ r = sp.run(['gh', 'pr', 'ready', pr_num],
745
+ cwd=repo_dir, capture_output=True, text=True, timeout=30)
746
+ if r.returncode == 0:
747
+ marked_ready = True
748
+ sp.run(['gh', 'pr', 'edit', pr_num, '--add-reviewer', reviewer],
749
+ cwd=repo_dir, capture_output=True, text=True, timeout=30)
750
+ break
751
+ all_done = all(c.get('state') in ('SUCCESS', 'NEUTRAL', 'SKIPPED') for c in states)
752
+ any_fail = any(c.get('state') == 'FAILURE' for c in states)
753
+ if any_fail:
754
+ s['ci_status'] = 'failed'
755
+ save_state(s)
756
+ print('CI failed. Not marking ready.')
757
+ break
758
+ if all_done:
759
+ s['ci_status'] = 'passed'
760
+ save_state(s)
761
+ r = sp.run(['gh', 'pr', 'ready', pr_num],
762
+ cwd=repo_dir, capture_output=True, text=True, timeout=30)
763
+ if r.returncode == 0:
764
+ marked_ready = True
765
+ sp.run(['gh', 'pr', 'edit', pr_num, '--add-reviewer', reviewer],
766
+ cwd=repo_dir, capture_output=True, text=True, timeout=30)
767
+ break
768
+ except:
769
+ pass
770
+ else:
771
+ no_checks_text = (checks.stderr + checks.stdout).lower()
772
+ if 'no checks' in no_checks_text or 'no check' in no_checks_text:
773
+ s['ci_status'] = 'no_checks'
774
+ save_state(s)
775
+ r = sp.run(['gh', 'pr', 'ready', pr_num],
776
+ cwd=repo_dir, capture_output=True, text=True, timeout=30)
777
+ if r.returncode == 0:
778
+ marked_ready = True
779
+ sp.run(['gh', 'pr', 'edit', pr_num, '--add-reviewer', reviewer],
780
+ cwd=repo_dir, capture_output=True, text=True, timeout=30)
781
+ break
782
+ time.sleep(10)
783
+
784
+ if not marked_ready and not leave_draft:
785
+ print('Warning: could not mark PR ready after CI poll')
786
+
787
+ record_discord_notification(s, marked_ready)
788
+
789
+ # Clean up
790
+ for pid_key in ('before_preview_id', 'after_preview_id'):
791
+ pid = s.get(pid_key, '')
792
+ if pid:
793
+ invoke('riddle_preview_delete', {'id': pid}, timeout=30)
794
+ print('Cleaned up preview: ' + pid)
795
+
796
+ for wt_key in ('before_worktree', 'after_worktree'):
797
+ wt = s.get(wt_key, '').strip()
798
+ if wt and os.path.exists(wt):
799
+ sp.run('git worktree remove --force ' + wt, shell=True, cwd=repo_dir, capture_output=True)
800
+ print('Cleaned up worktree: ' + wt)
801
+ sp.run('git worktree prune', shell=True, cwd=repo_dir, capture_output=True)
802
+
803
+ after_worktree_branch = s.get('after_worktree_branch', '').strip()
804
+ if after_worktree_branch:
805
+ sp.run(['git', 'branch', '-D', after_worktree_branch], cwd=repo_dir, capture_output=True, text=True)
806
+
807
+ s['finalized'] = True
808
+ s['marked_ready'] = marked_ready
809
+ s['left_draft'] = bool(s.get('left_draft'))
810
+ s['stage'] = 'ship'
811
+ s['after_worktree_branch'] = ''
812
+ report = record_ship_report(s, marked_ready)
813
+ save_state(s)
814
+
815
+ print()
816
+ print('PR: ' + s.get('pr_url', ''))
817
+ print('Proof comment posted: yes')
818
+ if report.get('proof_comment_url'):
819
+ print('Proof comment URL: ' + report.get('proof_comment_url', ''))
820
+ print('Marked ready: ' + str(marked_ready))
821
+ print(json.dumps({
822
+ 'ok': True,
823
+ 'pr_url': s.get('pr_url', ''),
824
+ 'pr_branch': report.get('pr_branch', ''),
825
+ 'shipped_commit': report.get('shipped_commit', ''),
826
+ 'marked_ready': marked_ready,
827
+ 'left_draft': bool(s.get('left_draft')),
828
+ 'ci_status': s.get('ci_status', ''),
829
+ 'proof_comment_url': report.get('proof_comment_url', ''),
830
+ 'before_artifact_url': report.get('before_artifact_url', ''),
831
+ 'prod_artifact_url': report.get('prod_artifact_url', ''),
832
+ 'after_artifact_url': report.get('after_artifact_url', ''),
833
+ 'ship_report': report,
834
+ }))