@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,1048 @@
1
+ """Recon: capture baseline evidence with bounded, agent-guided replanning.
2
+
3
+ Recon now follows an explicit loop:
4
+ 1. derive the current capture plan from persisted state
5
+ 2. capture the requested baselines once with that plan
6
+ 3. evaluate the observation packet
7
+ 4. either finalize recon or checkpoint for the calling agent to choose the next plan
8
+
9
+ The calling agent owns the planner step between attempts by resuming the workflow
10
+ with updated state inputs such as server_path or wait_for_selector.
11
+ """
12
+
13
+ import json, os, re, sys
14
+ from urllib.parse import urlparse
15
+
16
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17
+ from util import ( # noqa: E402
18
+ append_capture_diagnostic,
19
+ apply_auth_context,
20
+ build_capture_script,
21
+ capture_static_preview,
22
+ enrich_capture_payload,
23
+ has_auth_context,
24
+ invoke_retry,
25
+ load_state,
26
+ prepare_server_preview,
27
+ save_state,
28
+ should_use_static_preview,
29
+ summarize_capture_artifacts,
30
+ )
31
+ import subprocess as sp
32
+
33
+ MAX_RECON_ATTEMPTS = 4
34
+ MIN_BODY_TEXT_LENGTH = 50
35
+ MIN_INTERACTIVE_ELEMENTS = 1
36
+ HYDRATION_WAIT_MS = 1500
37
+ PAGE_STATE_PREFIX = 'RIDDLE_PROOF_STATE:'
38
+ PROOF_EVIDENCE_PREFIX = 'RIDDLE_PROOF_EVIDENCE:'
39
+
40
+ s = load_state()
41
+ after_dir = (s.get('after_worktree') or '').strip()
42
+ before_dir = (s.get('before_worktree') or '').strip()
43
+ if not after_dir or not os.path.exists(after_dir):
44
+ raise SystemExit('after_worktree not found. Run setup first.')
45
+
46
+
47
+ def run(cmd, cwd, timeout=30):
48
+ return sp.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=timeout)
49
+
50
+
51
+ def read_json(path):
52
+ if not os.path.exists(path):
53
+ return None
54
+ try:
55
+ with open(path) as f:
56
+ return json.load(f)
57
+ except Exception:
58
+ return None
59
+
60
+
61
+ def detect_framework(pkg):
62
+ deps = {}
63
+ for key in ('dependencies', 'devDependencies'):
64
+ deps.update(pkg.get(key, {}))
65
+ if 'next' in deps:
66
+ return 'next'
67
+ if 'react-router-dom' in deps or 'react-router' in deps:
68
+ return 'react-router'
69
+ if 'vite' in deps:
70
+ return 'vite'
71
+ if 'react' in deps:
72
+ return 'react'
73
+ return 'unknown'
74
+
75
+
76
+ def extract_tokens(change_request):
77
+ stop = {
78
+ 'the', 'and', 'with', 'that', 'this', 'have', 'more', 'mainly', 'into',
79
+ 'from', 'your', 'then', 'than', 'just', 'make', 'need', 'want', 'test',
80
+ 'end', 'proof', 'run', 'tweak', 'fix', 'issue', 'page', 'view'
81
+ }
82
+ out = []
83
+ for word in re.findall(r'[a-z0-9]+', (change_request or '').lower()):
84
+ if len(word) < 4 or word in stop or word in out:
85
+ continue
86
+ out.append(word)
87
+ return out[:6]
88
+
89
+
90
+ EXCLUDED_DIRS = {
91
+ '.git', '.next', '.turbo', '.cache', '.vercel', 'coverage', 'dist', 'build',
92
+ 'node_modules', 'out', 'storybook-static', 'vendor',
93
+ }
94
+ SOURCE_EXTENSIONS = {
95
+ '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte',
96
+ '.html', '.css', '.scss', '.mdx',
97
+ }
98
+ SOURCE_ROOTS = ('src', 'app', 'pages', 'components', 'routes')
99
+ ROUTE_LITERAL_PATTERNS = [
100
+ re.compile(r'\b(?:path|to|href)\s*=\s*\{\s*["\']([^"\']+)["\']\s*\}'),
101
+ re.compile(r'\b(?:path|to|href)\s*=\s*["\']([^"\']+)["\']'),
102
+ re.compile(r'\b(?:path|to|href)\s*:\s*["\']([^"\']+)["\']'),
103
+ re.compile(r'\bnavigate\s*\(\s*["\']([^"\']+)["\']'),
104
+ ]
105
+
106
+
107
+ def iter_source_lines(root, max_files=500, max_bytes=512 * 1024):
108
+ roots = []
109
+ for name in SOURCE_ROOTS:
110
+ probe = os.path.join(root, name)
111
+ if os.path.exists(probe):
112
+ roots.append(probe)
113
+ if not roots:
114
+ roots = [root]
115
+
116
+ seen = set()
117
+ files_seen = 0
118
+ for start in roots:
119
+ for dirpath, dirnames, filenames in os.walk(start):
120
+ dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS and not d.startswith('.')]
121
+ for filename in filenames:
122
+ if files_seen >= max_files:
123
+ return
124
+ _, ext = os.path.splitext(filename)
125
+ if ext not in SOURCE_EXTENSIONS and not filename.lower().startswith(('app.', 'routes')):
126
+ continue
127
+ probe = os.path.join(dirpath, filename)
128
+ if probe in seen:
129
+ continue
130
+ seen.add(probe)
131
+ try:
132
+ if os.path.getsize(probe) > max_bytes:
133
+ continue
134
+ with open(probe, errors='ignore') as handle:
135
+ for line_no, line in enumerate(handle, 1):
136
+ yield os.path.relpath(probe, root), line_no, line.rstrip()
137
+ files_seen += 1
138
+ except Exception:
139
+ continue
140
+
141
+
142
+ def normalize_route_literal(candidate):
143
+ item = (candidate or '').strip()
144
+ if not item or item.startswith(('http://', 'https://', '#', 'mailto:', 'tel:', '//')):
145
+ return ''
146
+ if not item.startswith('/'):
147
+ return ''
148
+ return item
149
+
150
+
151
+ def extract_route_literals(line):
152
+ routes = []
153
+ for pattern in ROUTE_LITERAL_PATTERNS:
154
+ for match in pattern.findall(line or ''):
155
+ route = normalize_route_literal(match)
156
+ if route and route not in routes:
157
+ routes.append(route)
158
+ return routes
159
+
160
+
161
+ def collect_route_hints(root):
162
+ hints = []
163
+ route_marker_re = re.compile(r'createBrowserRouter|createRoutesFromElements|<Route\b|\bpath\s*:')
164
+ for rel_path, line_no, line in iter_source_lines(root):
165
+ route_literals = extract_route_literals(line)
166
+ if not (route_literals or route_marker_re.search(line)):
167
+ continue
168
+ hint = f'{rel_path}:{line_no}:{line.strip()[:240]}'
169
+ if hint not in hints:
170
+ hints.append(hint)
171
+ if len(hints) >= 12:
172
+ return hints
173
+ return hints
174
+
175
+
176
+ def collect_keyword_hits(root, tokens):
177
+ hits = []
178
+ for token in tokens:
179
+ token_lower = token.lower()
180
+ token_hits = 0
181
+ for rel_path, line_no, line in iter_source_lines(root):
182
+ if token_lower not in line.lower():
183
+ continue
184
+ hit = f'{rel_path}:{line_no}:{line.strip()[:240]}'
185
+ if hit not in hits:
186
+ hits.append(hit)
187
+ token_hits += 1
188
+ if len(hits) >= 18:
189
+ return hits
190
+ if token_hits >= 6:
191
+ break
192
+ return hits
193
+
194
+
195
+ def route_candidates(route_hints, prod_url=''):
196
+ candidates = []
197
+
198
+ def add(candidate, reason):
199
+ item = (candidate or '').strip()
200
+ if not item or not item.startswith('/'):
201
+ return
202
+ if item.startswith(('/src/', '/app/', '/pages/', '/components/', '/routes/', '/assets/', '/public/')):
203
+ return
204
+ if any(item.endswith(ext) for ext in ('.js', '.jsx', '.ts', '.tsx', '.css', '.scss', '.png', '.jpg', '.svg', '.json')):
205
+ return
206
+ if item not in [x['path'] for x in candidates]:
207
+ candidates.append({'path': item, 'reason': reason})
208
+
209
+ parsed_prod = urlparse((prod_url or '').strip())
210
+ if parsed_prod.path and parsed_prod.path.strip() and parsed_prod.path != '/':
211
+ add(parsed_prod.path, 'prod_url path')
212
+
213
+ for hint in route_hints:
214
+ code_fragment = hint.split(':', 2)[-1]
215
+ for route in extract_route_literals(code_fragment):
216
+ add(route, 'route literal')
217
+ if len(candidates) >= 8:
218
+ return candidates
219
+
220
+ add('/', 'fallback root')
221
+ return candidates
222
+
223
+
224
+ def score_route_candidate(path, tokens):
225
+ if not tokens:
226
+ return 0
227
+ path_lower = (path or '').lower()
228
+ compact_path = re.sub(r'[^a-z0-9]+', '', path_lower)
229
+ score = 0
230
+ for token in tokens:
231
+ token_lower = token.lower()
232
+ compact_token = re.sub(r'[^a-z0-9]+', '', token_lower)
233
+ if not compact_token:
234
+ continue
235
+ if token_lower in path_lower or compact_token in compact_path:
236
+ score += 4
237
+ elif compact_path in compact_token and len(compact_path) > 4:
238
+ score += 2
239
+ if path == '/':
240
+ score -= 1
241
+ return score
242
+
243
+
244
+ def choose_target_path(explicit_path, prod_url, route_hints, tokens=None, explicit_source=''):
245
+ explicit = (explicit_path or '').strip()
246
+ explicit_source = (explicit_source or '').strip()
247
+ is_meaningful_explicit = bool(explicit) and (explicit != '/' or explicit_source in ('user', 'tool_param', 'supervising_agent'))
248
+ if is_meaningful_explicit:
249
+ return explicit if explicit.startswith('/') else '/' + explicit
250
+ candidates = route_candidates(route_hints, prod_url)
251
+ if candidates:
252
+ scored = sorted(
253
+ candidates,
254
+ key=lambda item: (score_route_candidate(item.get('path', ''), tokens or []), -candidates.index(item)),
255
+ reverse=True,
256
+ )
257
+ if scored and score_route_candidate(scored[0].get('path', ''), tokens or []) > 0:
258
+ return scored[0]['path']
259
+ non_root = [candidate for candidate in candidates if candidate.get('path') != '/']
260
+ if len(non_root) == 1:
261
+ return non_root[0]['path']
262
+ for candidate in candidates:
263
+ if candidate.get('path') == '/':
264
+ return '/'
265
+ return candidates[0]['path']
266
+ return '/'
267
+
268
+
269
+ def detect_static_reason(project_dir, state):
270
+ return should_use_static_preview(project_dir, state) if state.get('mode', 'server') == 'server' else ''
271
+
272
+
273
+ def extract_screenshot_url(payload, preferred_label=''):
274
+ preferred_names = []
275
+ label = (preferred_label or '').strip()
276
+ if label:
277
+ preferred_names = [
278
+ label,
279
+ label + '.png',
280
+ label + '.jpg',
281
+ label + '.jpeg',
282
+ label + '.webp',
283
+ ]
284
+ outputs = payload.get('outputs') or []
285
+ for item in outputs:
286
+ name = item.get('name', '')
287
+ if name in preferred_names and 'error' not in name:
288
+ return item.get('url', '')
289
+ for item in outputs:
290
+ name = item.get('name', '')
291
+ if name.endswith(('.png', '.jpg', '.jpeg', '.webp')) and 'error' not in name:
292
+ return item.get('url', '')
293
+ screenshots = payload.get('screenshots') or []
294
+ for item in screenshots:
295
+ name = item.get('name', '')
296
+ if name in preferred_names and 'error' not in name:
297
+ return item.get('url', '')
298
+ if screenshots:
299
+ return screenshots[0].get('url', '')
300
+ return ''
301
+
302
+
303
+ def iter_console_messages(console):
304
+ if isinstance(console, list):
305
+ for entry in console:
306
+ if isinstance(entry, str):
307
+ yield entry
308
+ elif isinstance(entry, dict):
309
+ text = entry.get('text') or entry.get('message') or ''
310
+ if isinstance(text, str):
311
+ yield text
312
+ return
313
+
314
+ if isinstance(console, dict):
315
+ entries = console.get('entries') or {}
316
+ if isinstance(entries, dict):
317
+ for bucket in ('log', 'info', 'warn', 'error'):
318
+ values = entries.get(bucket) or []
319
+ if not isinstance(values, list):
320
+ continue
321
+ for entry in values:
322
+ if isinstance(entry, str):
323
+ yield entry
324
+ elif isinstance(entry, dict):
325
+ text = entry.get('text') or entry.get('message') or ''
326
+ if isinstance(text, str):
327
+ yield text
328
+
329
+
330
+ def is_proof_telemetry_console_message(text):
331
+ return isinstance(text, str) and (
332
+ text.startswith(PAGE_STATE_PREFIX)
333
+ or text.startswith(PROOF_EVIDENCE_PREFIX)
334
+ )
335
+
336
+
337
+ def extract_page_state(payload):
338
+ for text in iter_console_messages(payload.get('console') or []):
339
+ if isinstance(text, str) and text.startswith(PAGE_STATE_PREFIX):
340
+ try:
341
+ return json.loads(text[len(PAGE_STATE_PREFIX):])
342
+ except Exception:
343
+ continue
344
+ result = payload.get('result') or {}
345
+ if isinstance(result, dict):
346
+ page_state = result.get('pageState')
347
+ if isinstance(page_state, dict):
348
+ return page_state
349
+ return None
350
+
351
+
352
+ def list_value(value):
353
+ return value if isinstance(value, list) else []
354
+
355
+
356
+ def semantic_anchor_count(page_state):
357
+ if not isinstance(page_state, dict):
358
+ return 0
359
+ headings = [item for item in list_value(page_state.get('headings')) if str(item).strip()]
360
+ buttons = [item for item in list_value(page_state.get('buttons')) if str(item).strip()]
361
+ links = [
362
+ item for item in list_value(page_state.get('links'))
363
+ if isinstance(item, dict) and (str(item.get('text') or '').strip() or str(item.get('href') or '').strip())
364
+ ]
365
+ large = [
366
+ item for item in list_value(page_state.get('largeVisibleElements'))
367
+ if isinstance(item, dict) and (str(item.get('text') or '').strip() or item.get('tag') == 'canvas')
368
+ ]
369
+ return len(headings) + len(buttons) + len(links) + len(large) + int(page_state.get('canvasCount') or 0)
370
+
371
+
372
+ def has_enriched_page_state(page_state):
373
+ return isinstance(page_state, dict) and any(
374
+ key in page_state
375
+ for key in ('visibleTextSample', 'headings', 'buttons', 'links', 'canvasCount', 'largeVisibleElements')
376
+ )
377
+
378
+
379
+ def normalize_observed_path(value):
380
+ path = (value or '').strip()
381
+ if not path:
382
+ return ''
383
+ path = path.split('?', 1)[0].split('#', 1)[0]
384
+ if not path.startswith('/'):
385
+ parsed = urlparse(path)
386
+ path = parsed.path or path
387
+ parts = path.split('/')
388
+ if len(parts) >= 4 and parts[1] == 's':
389
+ path = '/' + '/'.join(parts[3:])
390
+ return path.rstrip('/') or '/'
391
+
392
+
393
+ def build_probe_capture_script(base_script='', screenshot_label=''):
394
+ pieces = []
395
+ script = (base_script or '').strip()
396
+ if script:
397
+ pieces.append(script.rstrip(';') + ';')
398
+ pieces.extend([
399
+ f'await page.waitForTimeout({HYDRATION_WAIT_MS});',
400
+ 'const pageState = await page.evaluate(() => {',
401
+ ' const textOf = (el) => ((el && el.innerText) || (el && el.textContent) || "").replace(/\\s+/g, " ").trim();',
402
+ ' const isVisible = (el) => {',
403
+ ' if (!el || !el.getBoundingClientRect) return false;',
404
+ ' const rect = el.getBoundingClientRect();',
405
+ ' const style = window.getComputedStyle(el);',
406
+ ' return rect.width > 1 && rect.height > 1 && style.visibility !== "hidden" && style.display !== "none";',
407
+ ' };',
408
+ ' const textList = (selector, limit) => Array.from(document.querySelectorAll(selector)).filter(isVisible).map((el) => textOf(el).slice(0, 160)).filter(Boolean).slice(0, limit);',
409
+ ' const links = Array.from(document.querySelectorAll("a[href]")).filter(isVisible).map((el) => ({ text: textOf(el).slice(0, 120), href: el.getAttribute("href") || "" })).filter((item) => item.text || item.href).slice(0, 12);',
410
+ ' const largeVisibleElements = Array.from(document.body ? document.body.querySelectorAll("main, section, article, [role=main], canvas, button, a, h1, h2, h3, [data-testid], [class], [id]") : []).filter(isVisible).map((el) => {',
411
+ ' const rect = el.getBoundingClientRect();',
412
+ ' const className = typeof el.className === "string" ? el.className : "";',
413
+ ' return { tag: el.tagName.toLowerCase(), id: el.id || "", className: className.slice(0, 120), text: textOf(el).slice(0, 120), area: Math.round(rect.width * rect.height) };',
414
+ ' }).sort((a, b) => b.area - a.area).slice(0, 10);',
415
+ ' const visibleText = document.body ? textOf(document.body) : "";',
416
+ ' return {',
417
+ ' bodyTextLength: visibleText.length,',
418
+ ' visibleTextSample: visibleText.slice(0, 800),',
419
+ ' interactiveElements: document.querySelectorAll("button, input, [role=button], canvas, a[href]").length,',
420
+ ' visibleInteractiveElements: Array.from(document.querySelectorAll("button, input, [role=button], canvas, a[href]")).filter(isVisible).length,',
421
+ ' headings: textList("h1, h2, [role=heading]", 8),',
422
+ ' buttons: textList("button, [role=button]", 12),',
423
+ ' links,',
424
+ ' canvasCount: document.querySelectorAll("canvas").length,',
425
+ ' largeVisibleElements,',
426
+ ' pathname: window.location.pathname,',
427
+ ' title: document.title,',
428
+ ' };',
429
+ '});',
430
+ 'console.log(' + json.dumps(PAGE_STATE_PREFIX) + ' + JSON.stringify(pageState));',
431
+ ])
432
+ label = (screenshot_label or '').strip()
433
+ if label and 'saveScreenshot' not in script:
434
+ pieces.append('await saveScreenshot(' + json.dumps(label) + ');')
435
+ pieces.append('return { pageState };')
436
+ return ' '.join(pieces)
437
+
438
+
439
+ def evaluate_capture_quality(payload, expected_path):
440
+ payload = enrich_capture_payload(payload)
441
+ details = {
442
+ 'has_screenshot': False,
443
+ 'body_text_length': 0,
444
+ 'interactive_elements': 0,
445
+ 'visible_interactive_elements': 0,
446
+ 'has_errors': False,
447
+ 'observed_path': '',
448
+ 'observed_path_raw': '',
449
+ 'title': '',
450
+ 'visible_text_sample': '',
451
+ 'headings': [],
452
+ 'buttons': [],
453
+ 'links': [],
454
+ 'canvas_count': 0,
455
+ 'large_visible_elements': [],
456
+ 'semantic_anchor_count': 0,
457
+ 'capture_error_messages': [],
458
+ 'artifact_summary': summarize_capture_artifacts(payload),
459
+ }
460
+
461
+ screenshot_url = extract_screenshot_url(payload)
462
+ details['has_screenshot'] = bool(screenshot_url)
463
+ if not screenshot_url:
464
+ return {
465
+ 'valid': False,
466
+ 'reason': 'no screenshot in capture',
467
+ 'telemetry_ready': False,
468
+ 'details': details,
469
+ }
470
+
471
+ page_state = extract_page_state(payload)
472
+ if isinstance(page_state, dict):
473
+ raw_observed_path = page_state.get('pathname', '')
474
+ details.update({
475
+ 'body_text_length': page_state.get('bodyTextLength', 0),
476
+ 'interactive_elements': page_state.get('interactiveElements', 0),
477
+ 'visible_interactive_elements': page_state.get('visibleInteractiveElements', page_state.get('interactiveElements', 0)),
478
+ 'observed_path': normalize_observed_path(raw_observed_path),
479
+ 'observed_path_raw': raw_observed_path,
480
+ 'title': page_state.get('title', ''),
481
+ 'visible_text_sample': page_state.get('visibleTextSample', ''),
482
+ 'headings': list_value(page_state.get('headings'))[:8],
483
+ 'buttons': list_value(page_state.get('buttons'))[:12],
484
+ 'links': list_value(page_state.get('links'))[:12],
485
+ 'canvas_count': page_state.get('canvasCount', 0),
486
+ 'large_visible_elements': list_value(page_state.get('largeVisibleElements'))[:10],
487
+ 'semantic_anchor_count': semantic_anchor_count(page_state),
488
+ })
489
+ else:
490
+ details.update({
491
+ 'body_text_length': MIN_BODY_TEXT_LENGTH + 100,
492
+ 'interactive_elements': MIN_INTERACTIVE_ELEMENTS + 1,
493
+ 'visible_interactive_elements': MIN_INTERACTIVE_ELEMENTS + 1,
494
+ 'observed_path': expected_path,
495
+ 'observed_path_raw': expected_path,
496
+ })
497
+
498
+ console = payload.get('console') or []
499
+ for text in iter_console_messages(console):
500
+ if is_proof_telemetry_console_message(text):
501
+ continue
502
+ if isinstance(text, str) and ('error' in text.lower() or 'failed' in text.lower()):
503
+ details['has_errors'] = True
504
+ if len(details['capture_error_messages']) < 3:
505
+ details['capture_error_messages'].append(text[:500])
506
+ break
507
+ proof_json = payload.get('_proof_json') or {}
508
+ if isinstance(proof_json, dict) and proof_json.get('script_error'):
509
+ details['has_errors'] = True
510
+ details['capture_error_messages'].append(str(proof_json.get('script_error'))[:500])
511
+
512
+ reasons = []
513
+ if details['body_text_length'] < MIN_BODY_TEXT_LENGTH:
514
+ reasons.append(f'blank/near-blank page (text length: {details["body_text_length"]})')
515
+ if details['interactive_elements'] < MIN_INTERACTIVE_ELEMENTS:
516
+ reasons.append(f'not interactive enough ({details["interactive_elements"]} interactive elements)')
517
+ if has_enriched_page_state(page_state) and details['semantic_anchor_count'] < 1:
518
+ reasons.append('no visible semantic UI anchors in page capture')
519
+ if details['has_errors']:
520
+ reasons.append('page has console/runtime errors')
521
+
522
+ observed_path = normalize_observed_path(details.get('observed_path'))
523
+ normalized_expected = (expected_path or '').rstrip('/') or '/'
524
+ if expected_path and observed_path and observed_path != normalized_expected:
525
+ raw_observed = details.get('observed_path_raw') or details.get('observed_path') or observed_path
526
+ reasons.append(f'wrong route: expected {expected_path}, got {raw_observed}')
527
+
528
+ semantic_ready = (not has_enriched_page_state(page_state)) or details['semantic_anchor_count'] >= 1
529
+ telemetry_ready = (
530
+ details['has_screenshot']
531
+ and details['body_text_length'] >= MIN_BODY_TEXT_LENGTH
532
+ and details['interactive_elements'] >= MIN_INTERACTIVE_ELEMENTS
533
+ and semantic_ready
534
+ and not details['has_errors']
535
+ )
536
+
537
+ return {
538
+ 'valid': len(reasons) == 0,
539
+ 'reason': '; '.join(reasons) if reasons else 'ok',
540
+ 'telemetry_ready': telemetry_ready,
541
+ 'details': details,
542
+ }
543
+
544
+
545
+ def clean_next_cache(project_dir):
546
+ if os.path.exists(os.path.join(project_dir, '.next')):
547
+ sp.run('rm -rf .next', shell=True, cwd=project_dir, capture_output=True)
548
+
549
+
550
+ def build_project(project_dir, label):
551
+ build_cmd = s.get('build_command', 'npm run build')
552
+ print('Building ' + label + ' workspace...')
553
+ result = sp.run(build_cmd, shell=True, cwd=project_dir, capture_output=True, text=True, timeout=600)
554
+ if result.returncode != 0:
555
+ raise SystemExit(label.capitalize() + ' build failed during recon: ' + result.stderr[:500])
556
+ return {
557
+ 'stdout': result.stdout[-500:],
558
+ 'stderr': result.stderr[-500:],
559
+ 'command': build_cmd,
560
+ }
561
+
562
+
563
+ def capture_workspace_baseline(project_dir, label, plan, capture_script=''):
564
+ clean_next_cache(project_dir)
565
+ build_meta = build_project(project_dir, label)
566
+ static_reason = detect_static_reason(project_dir, s)
567
+ wait_for_selector = (plan.get('wait_for_selector') or '').strip()
568
+ target_path = plan.get('target_path') or '/'
569
+
570
+ if s.get('mode', 'server') == 'server' and not static_reason:
571
+ build_dir, server_command, server_exclude = prepare_server_preview(project_dir, s)
572
+ server_args = {
573
+ 'directory': build_dir,
574
+ 'image': s['server_image'],
575
+ 'command': server_command,
576
+ 'port': int(s['server_port']),
577
+ 'wait_until': 'domcontentloaded',
578
+ 'readiness_timeout': 180,
579
+ 'timeout': 300,
580
+ 'env': {'PORT': str(s['server_port']), 'HOSTNAME': '0.0.0.0'},
581
+ 'exclude': server_exclude,
582
+ 'path': target_path,
583
+ 'readiness_path': '/' if has_auth_context(s) else target_path,
584
+ 'script': build_probe_capture_script(capture_script, label),
585
+ }
586
+ if s.get('color_scheme'):
587
+ server_args['color_scheme'] = s['color_scheme']
588
+ if wait_for_selector:
589
+ server_args['wait_for_selector'] = wait_for_selector
590
+ apply_auth_context(s, server_args)
591
+
592
+ shot = invoke_retry('riddle_server_preview', server_args, retries=2, timeout=420)
593
+ append_capture_diagnostic(s, label, 'riddle_server_preview', server_args, shot)
594
+ return {
595
+ 'source': label + '_worktree',
596
+ 'mode': 'server',
597
+ 'path': target_path,
598
+ 'capture_url': target_path,
599
+ 'url': extract_screenshot_url(shot, label),
600
+ 'static_fallback_reason': '',
601
+ 'build': build_meta,
602
+ 'raw': shot,
603
+ }
604
+
605
+ state_for_capture = dict(s)
606
+ state_for_capture['wait_for_selector'] = wait_for_selector
607
+ if static_reason:
608
+ print('Recon capture (' + label + ') using static preview fallback: ' + static_reason)
609
+ capture = capture_static_preview(state_for_capture, project_dir, label, build_probe_capture_script(capture_script, label), timeout=300, target_path=target_path)
610
+ raw = (capture.get('raw') or {}).get('capture') or {}
611
+ append_capture_diagnostic(
612
+ s,
613
+ label,
614
+ 'riddle_static_preview',
615
+ {'target_path': target_path, 'static_fallback_reason': static_reason},
616
+ raw,
617
+ )
618
+ preview_id_key = label + '_preview_id'
619
+ if capture.get('preview_id'):
620
+ s[preview_id_key] = capture.get('preview_id', '')
621
+ return {
622
+ 'source': label + '_worktree',
623
+ 'mode': 'static' if s.get('mode', 'server') == 'static' else 'server-with-static-fallback',
624
+ 'path': target_path,
625
+ 'capture_url': capture.get('capture_url', target_path),
626
+ 'url': capture.get('url', '') or extract_screenshot_url(raw, label),
627
+ 'static_fallback_reason': static_reason,
628
+ 'build': build_meta,
629
+ 'raw': raw,
630
+ }
631
+
632
+
633
+ def capture_prod_baseline(prod_url, plan, capture_script=''):
634
+ target_url = (prod_url or '').strip()
635
+ if not target_url:
636
+ raise SystemExit('Requested prod baseline in recon, but prod_url is missing.')
637
+ wait_for_selector = (plan.get('wait_for_selector') or '').strip()
638
+ script = build_capture_script(target_url, build_probe_capture_script(capture_script, 'prod'), 'prod', wait_for_selector)
639
+ args = {'script': script, 'timeout_sec': 60}
640
+ apply_auth_context(s, args)
641
+ print('Recon capture (prod) at ' + target_url)
642
+ shot = invoke_retry('riddle_script', args, retries=3, timeout=180)
643
+ append_capture_diagnostic(s, 'prod', 'riddle_script', args, shot)
644
+ return {
645
+ 'source': 'prod_url',
646
+ 'mode': 'remote',
647
+ 'path': urlparse(target_url).path or (plan.get('target_path') or '/'),
648
+ 'capture_url': target_url,
649
+ 'url': extract_screenshot_url(shot, 'prod'),
650
+ 'static_fallback_reason': '',
651
+ 'raw': shot,
652
+ }
653
+
654
+
655
+ def baseline_record(capture, observation):
656
+ return {
657
+ 'source': capture.get('source'),
658
+ 'mode': capture.get('mode'),
659
+ 'path': capture.get('path'),
660
+ 'capture_url': capture.get('capture_url'),
661
+ 'url': capture.get('url'),
662
+ 'static_fallback_reason': capture.get('static_fallback_reason', ''),
663
+ 'artifact_summary': summarize_capture_artifacts(capture.get('raw') or {}),
664
+ 'observation': observation,
665
+ }
666
+
667
+
668
+ def build_observation_packet(label, expected_path, capture=None, error=''):
669
+ if error:
670
+ return {
671
+ 'label': label,
672
+ 'ok': False,
673
+ 'reason': error,
674
+ 'telemetry_ready': False,
675
+ 'capture_url': capture.get('capture_url') if capture else '',
676
+ 'url': capture.get('url') if capture else '',
677
+ 'details': {'error': error},
678
+ }
679
+
680
+ payload = (capture or {}).get('raw') or {}
681
+ quality = evaluate_capture_quality(payload, expected_path)
682
+ return {
683
+ 'label': label,
684
+ 'ok': bool((capture or {}).get('url')) and quality['valid'],
685
+ 'reason': quality['reason'],
686
+ 'telemetry_ready': quality['telemetry_ready'],
687
+ 'capture_url': (capture or {}).get('capture_url', ''),
688
+ 'url': (capture or {}).get('url', ''),
689
+ 'details': quality['details'],
690
+ }
691
+
692
+
693
+ def clear_saved_baselines(state):
694
+ state['before_cdn'] = ''
695
+ state['prod_cdn'] = ''
696
+
697
+
698
+ def diff_plan(previous_plan, current_plan):
699
+ changes = {}
700
+ for key in ('target_path', 'wait_for_selector', 'reference'):
701
+ if (previous_plan or {}).get(key) != (current_plan or {}).get(key):
702
+ changes[key] = {
703
+ 'from': (previous_plan or {}).get(key),
704
+ 'to': (current_plan or {}).get(key),
705
+ }
706
+ return changes
707
+
708
+
709
+ pkg = read_json(os.path.join(after_dir, 'package.json')) or {}
710
+ scripts = pkg.get('scripts') or {}
711
+ git_status = run('git status --short', after_dir, timeout=20).stdout.strip().splitlines()
712
+ tokens = extract_tokens(s.get('change_request', ''))
713
+ route_hints = collect_route_hints(after_dir)
714
+ keyword_hits = collect_keyword_hits(after_dir, tokens)
715
+ requested_reference = s.get('requested_reference') or s.get('reference', 'before')
716
+ route_options = route_candidates(route_hints, s.get('prod_url', ''))
717
+ server_path_source = s.get('server_path_source') or ''
718
+ initial_target_path = choose_target_path(s.get('server_path', ''), s.get('prod_url', ''), route_hints, tokens, server_path_source)
719
+ selected_route = next((item for item in route_options if item.get('path') == initial_target_path), None)
720
+ initial_hypothesis = {
721
+ 'target_path': initial_target_path,
722
+ 'path_source': 'state.server_path' if (s.get('server_path') or '').strip() and (s.get('server_path') != '/' or server_path_source) else ((selected_route or {}).get('reason') or 'fallback root'),
723
+ 'reference': requested_reference,
724
+ 'mode': s.get('mode', 'server'),
725
+ 'wait_for_selector': (s.get('wait_for_selector') or '').strip(),
726
+ 'route_candidates': route_options[:6],
727
+ 'notes': [
728
+ 'Recon captures one bounded attempt at a time, then checkpoints for the calling agent to pick the next plan.',
729
+ 'Verify should reuse the baseline established here instead of rediscovering route and state late.',
730
+ ],
731
+ }
732
+
733
+ existing_recon = s.get('recon_results') or {}
734
+ attempt_history = list(existing_recon.get('attempt_history') or [])
735
+ plan_history = list(existing_recon.get('plan_history') or [])
736
+ decision_history = list(existing_recon.get('decision_history') or [])
737
+ max_attempts = int(existing_recon.get('max_attempts') or MAX_RECON_ATTEMPTS)
738
+ if max_attempts < 1 or max_attempts > MAX_RECON_ATTEMPTS:
739
+ max_attempts = MAX_RECON_ATTEMPTS
740
+
741
+ previous_assessment = s.get('recon_assessment') or {}
742
+ assessment_source = str(previous_assessment.get('source') or s.get('recon_assessment_source') or '').strip().lower()
743
+ has_previous_assessment = bool(previous_assessment.get('decision')) and assessment_source in ('supervising_agent', 'supervisor')
744
+ attempt_num = len(attempt_history) + 1
745
+
746
+ current_plan = {
747
+ 'attempt': attempt_num,
748
+ 'planner': 'supervising_agent' if has_previous_assessment else 'workflow_hypothesis',
749
+ 'planner_kind': 'supervising_agent' if has_previous_assessment else 'agent_guided_recon',
750
+ 'target_path': initial_target_path,
751
+ 'path_source': initial_hypothesis['path_source'],
752
+ 'wait_for_selector': (s.get('wait_for_selector') or '').strip(),
753
+ 'selector_source': 'state.wait_for_selector' if (s.get('wait_for_selector') or '').strip() else 'none',
754
+ 'reference': requested_reference,
755
+ 'route_candidates': route_options[:6],
756
+ 'keyword_hits': keyword_hits[:10],
757
+ }
758
+
759
+ if has_previous_assessment:
760
+ refined = previous_assessment.get('refined_inputs') or {}
761
+ decision_history.append({
762
+ 'attempt': attempt_num,
763
+ 'planner': 'supervising_agent',
764
+ 'based_on_attempt': len(attempt_history),
765
+ 'decision': previous_assessment.get('decision'),
766
+ 'summary': previous_assessment.get('summary') or '',
767
+ 'continue_with_stage': previous_assessment.get('continue_with_stage') or previous_assessment.get('recommended_stage') or '',
768
+ 'escalation_target': previous_assessment.get('escalation_target') or 'agent',
769
+ 'confidence': previous_assessment.get('confidence') or '',
770
+ 'baseline_understanding': previous_assessment.get('baseline_understanding') or {},
771
+ 'refined_inputs': {
772
+ 'server_path': refined.get('server_path') or current_plan['target_path'],
773
+ 'wait_for_selector': refined.get('wait_for_selector') or current_plan['wait_for_selector'],
774
+ 'reference': refined.get('reference') or requested_reference,
775
+ },
776
+ 'reasons': previous_assessment.get('reasons') or [],
777
+ })
778
+ elif plan_history:
779
+ decision_history.append({
780
+ 'attempt': attempt_num,
781
+ 'planner': 'calling_agent',
782
+ 'based_on_attempt': len(plan_history),
783
+ 'changes': diff_plan(plan_history[-1], current_plan),
784
+ 'reason': 'Resumed recon with the current state inputs.',
785
+ })
786
+ plan_history.append(current_plan)
787
+
788
+ summary_bits = []
789
+ if detect_framework(pkg) != 'unknown':
790
+ summary_bits.append('framework=' + detect_framework(pkg))
791
+ if route_hints:
792
+ summary_bits.append('route hints found')
793
+ if keyword_hits:
794
+ summary_bits.append('change-request keyword hits found')
795
+ summary_bits.append('attempt=' + str(attempt_num) + '/' + str(max_attempts))
796
+ summary_bits.append('plan path=' + current_plan['target_path'])
797
+ if current_plan['wait_for_selector']:
798
+ summary_bits.append('selector=' + current_plan['wait_for_selector'])
799
+ if attempt_num > max_attempts:
800
+ summary_bits.append('attempt budget advisory exceeded')
801
+
802
+ recon_results = {
803
+ 'workspace': {
804
+ 'repo_dir': s.get('repo_dir'),
805
+ 'before_worktree': before_dir or None,
806
+ 'after_worktree': after_dir,
807
+ 'reference': s.get('reference', 'before'),
808
+ 'requested_reference': requested_reference,
809
+ },
810
+ 'app': {
811
+ 'name': pkg.get('name') or s.get('repo_short'),
812
+ 'framework': detect_framework(pkg),
813
+ 'scripts': {k: scripts.get(k) for k in ('dev', 'build', 'start', 'test') if scripts.get(k)},
814
+ },
815
+ 'route_hints': route_hints,
816
+ 'keyword_hits': keyword_hits,
817
+ 'git_status': git_status[:20],
818
+ 'hypothesis': initial_hypothesis,
819
+ 'status': 'needs_supervisor_judgment',
820
+ 'max_attempts': max_attempts,
821
+ 'current_plan': current_plan,
822
+ 'plan_history': plan_history,
823
+ 'decision_history': decision_history,
824
+ 'attempt_history': attempt_history,
825
+ 'baselines': {},
826
+ 'observations': {},
827
+ }
828
+
829
+ required_baselines = []
830
+ if requested_reference in ('before', 'both'):
831
+ required_baselines.append('before')
832
+ if requested_reference in ('prod', 'both') and (s.get('prod_url') or '').strip():
833
+ required_baselines.append('prod')
834
+ elif requested_reference in ('prod', 'both') and not (s.get('prod_url') or '').strip():
835
+ summary_bits.append('prod baseline deferred until prod_url exists')
836
+
837
+ attempt_observations = {}
838
+ attempt_captured_baselines = {}
839
+ clear_saved_baselines(s)
840
+ s['recon_assessment'] = {}
841
+ s['recon_assessment_source'] = None
842
+
843
+ for label in required_baselines:
844
+ try:
845
+ if label == 'before':
846
+ if not before_dir or not os.path.exists(before_dir):
847
+ raise SystemExit('before_worktree not found but recon baseline requires reference=' + requested_reference)
848
+ capture = capture_workspace_baseline(before_dir, 'before', current_plan, capture_script='')
849
+ expected_path = current_plan['target_path']
850
+ else:
851
+ capture = capture_prod_baseline(s.get('prod_url', ''), current_plan, capture_script='')
852
+ expected_path = urlparse(s.get('prod_url', '')).path or current_plan['target_path']
853
+ observation = build_observation_packet(label, expected_path, capture=capture)
854
+ except SystemExit:
855
+ raise
856
+ except Exception as exc:
857
+ capture = {}
858
+ observation = build_observation_packet(label, current_plan['target_path'], capture=capture, error='exception: ' + str(exc)[:180])
859
+
860
+ attempt_observations[label] = observation
861
+ if capture.get('url'):
862
+ attempt_captured_baselines[label] = baseline_record(capture, observation)
863
+
864
+ attempt_result = 'captured_candidates' if all((attempt_captured_baselines.get(label) or {}).get('url') for label in required_baselines) else 'partial_capture'
865
+ attempt_record = {
866
+ 'attempt': attempt_num,
867
+ 'plan': current_plan,
868
+ 'observations': attempt_observations,
869
+ 'captured_baselines': attempt_captured_baselines,
870
+ 'result': attempt_result,
871
+ }
872
+ attempt_history.append(attempt_record)
873
+ recon_results['attempt_history'] = attempt_history
874
+ recon_results['baselines'] = {}
875
+ recon_results['status'] = 'needs_supervisor_judgment'
876
+ recon_results['selected_attempt'] = {}
877
+ recon_results['observations'] = {
878
+ 'baseline_keys': sorted(attempt_captured_baselines.keys()),
879
+ 'attempts_used': len(attempt_history),
880
+ 'attempts_remaining': max(0, max_attempts - len(attempt_history)),
881
+ 'attempt_budget_advisory': len(attempt_history) >= max_attempts,
882
+ 'latest_result': attempt_result,
883
+ 'latest_attempt': attempt_record,
884
+ 'observed_target_path': current_plan['target_path'],
885
+ }
886
+
887
+ s['recon_results'] = recon_results
888
+ s['recon_hypothesis'] = initial_hypothesis
889
+ s['stage'] = 'recon'
890
+ s['recon_status'] = 'needs_supervisor_judgment'
891
+ s['author_status'] = 'needs_recon_judgment'
892
+ s['proof_plan_status'] = 'needs_recon_judgment'
893
+
894
+ summary_bits.append('captured candidate baselines' if attempt_captured_baselines else 'baseline capture incomplete')
895
+ if len(attempt_history) >= max_attempts:
896
+ summary_bits.append('supervising agent should decide whether recon is converging or stuck')
897
+
898
+ instructions = [
899
+ 'Inspect the latest recon observation packet, route hints, and captured screenshot URLs together.',
900
+ 'Judge whether the latest before/prod baseline is trustworthy enough to anchor verify.',
901
+ 'Do not approve recon just because telemetry_ready is true or a screenshot URL exists.',
902
+ 'Use details.visible_text_sample, headings, buttons, links, canvas_count, and large_visible_elements to describe what the baseline visibly contains.',
903
+ 'Reject baselines that look like only an app shell, banner, blank route, loading screen, error page, or the wrong feature even if the capture technically has text.',
904
+ 'For routed apps, prefer explicit Route/Link/href/navigate path literals over component import paths.',
905
+ 'If the baseline is wrong or weak, choose retry_recon with refined server_path and/or wait_for_selector.',
906
+ 'If the baseline is trustworthy, choose ready_for_author so the wrapper can promote it and continue into proof authoring.',
907
+ 'Before choosing ready_for_author, write a concrete baseline_understanding that names the observed before state, the target UI, the requested change, the proof focus, and the stop condition.',
908
+ 'Only choose recon_stuck with escalation_target=human when you conclude the recon loop is genuinely blocked or not converging.',
909
+ ]
910
+ if requested_reference in ('prod', 'both') and not (s.get('prod_url') or '').strip():
911
+ instructions.append('Prod comparison is still deferred until prod_url is available.')
912
+ if len(attempt_history) >= max_attempts:
913
+ instructions.append('The original recon attempt budget is now advisory only. Retry only if the new plan is materially better; otherwise declare recon_stuck.')
914
+
915
+ author_request = {
916
+ 'goal': s.get('change_request', ''),
917
+ 'success_criteria': s.get('success_criteria', ''),
918
+ 'verification_mode': s.get('verification_mode', 'proof'),
919
+ 'reference': requested_reference,
920
+ 'prod_url_known': bool((s.get('prod_url') or '').strip()),
921
+ 'workspace': {
922
+ 'after_worktree': after_dir,
923
+ 'before_worktree': before_dir or None,
924
+ },
925
+ 'hypothesis': initial_hypothesis,
926
+ 'current_plan': current_plan,
927
+ 'observed_baselines': attempt_captured_baselines,
928
+ 'latest_attempt': attempt_record,
929
+ 'route_hints': route_hints[:8],
930
+ 'keyword_hits': keyword_hits[:10],
931
+ 'plan_history': plan_history,
932
+ 'decision_history': decision_history,
933
+ 'required_outputs': [
934
+ 'proof_plan',
935
+ 'capture_script',
936
+ 'optional server_path',
937
+ 'optional wait_for_selector',
938
+ ],
939
+ 'available_inputs': {
940
+ 'proof_plan': bool((s.get('proof_plan') or '').strip()),
941
+ 'capture_script': bool((s.get('capture_script') or '').strip()),
942
+ 'server_path': s.get('server_path') or '',
943
+ 'wait_for_selector': s.get('wait_for_selector') or '',
944
+ },
945
+ 'instructions': [
946
+ 'Use the supervising-agent-approved recon path and baselines instead of rediscovering context in verify.',
947
+ 'Write the final Playwright capture_script for verify only after recon is explicitly approved.',
948
+ 'Do not rely on verify to rediscover the right route or baseline context.',
949
+ ],
950
+ }
951
+ s['author_request'] = author_request
952
+ s['proof_plan_request'] = author_request
953
+
954
+ recon_assessment_request = {
955
+ 'status': 'needs_supervising_agent_assessment',
956
+ 'goal': s.get('change_request', ''),
957
+ 'success_criteria': s.get('success_criteria', ''),
958
+ 'verification_mode': s.get('verification_mode', 'proof'),
959
+ 'reference': requested_reference,
960
+ 'attempt': attempt_num,
961
+ 'max_attempts': max_attempts,
962
+ 'attempts_used': len(attempt_history),
963
+ 'attempts_remaining': max(0, max_attempts - len(attempt_history)),
964
+ 'attempt_budget_advisory': len(attempt_history) >= max_attempts,
965
+ 'current_plan': current_plan,
966
+ 'latest_attempt': attempt_record,
967
+ 'observed_baselines': attempt_captured_baselines,
968
+ 'candidate_paths': route_options[:6],
969
+ 'route_hints': route_hints[:8],
970
+ 'keyword_hits': keyword_hits[:10],
971
+ 'fields_agent_may_update': ['recon_assessment_json', 'server_path', 'wait_for_selector', 'reference'],
972
+ 'instructions': instructions,
973
+ 'quality_gate': {
974
+ 'ready_for_author_requires': [
975
+ 'baseline screenshot exists',
976
+ 'observed route matches the intended user-facing route',
977
+ 'visible page content is specific to the requested change, not merely an app shell or banner',
978
+ 'the supervising agent can summarize what is visible in the baseline from structured pageState and/or screenshot inspection',
979
+ 'the supervising agent has written baseline_understanding before proof authoring or implementation begins',
980
+ ],
981
+ 'retry_recon_when': [
982
+ 'the path looks like a source/component import rather than an app route',
983
+ 'the baseline is blank, mostly shell chrome, loading-only, or generic landing content',
984
+ 'visible text/elements do not support the target feature or requested change',
985
+ ],
986
+ },
987
+ 'response_schema': {
988
+ 'decision': 'retry_recon | ready_for_author | recon_stuck',
989
+ 'summary': 'string',
990
+ 'baseline_understanding': {
991
+ 'reference': 'before | prod | both | unknown',
992
+ 'target_route': 'string',
993
+ 'before_evidence_url': 'string',
994
+ 'visible_before_state': 'string',
995
+ 'relevant_elements': ['string'],
996
+ 'requested_change': 'string',
997
+ 'proof_focus': 'string',
998
+ 'stop_condition': 'string',
999
+ 'quality_risks': ['string'],
1000
+ },
1001
+ 'continue_with_stage': 'recon | author',
1002
+ 'escalation_target': 'agent | human',
1003
+ 'refined_inputs': {
1004
+ 'server_path': 'string',
1005
+ 'wait_for_selector': 'string',
1006
+ 'reference': 'string',
1007
+ },
1008
+ 'reasons': ['string'],
1009
+ 'confidence': 'high | medium | low',
1010
+ 'source': 'supervising_agent',
1011
+ },
1012
+ }
1013
+ s['recon_assessment_request'] = recon_assessment_request
1014
+ s['recon_decision_request'] = recon_assessment_request
1015
+ s['recon_summary'] = '; '.join(summary_bits)
1016
+ save_state(s)
1017
+
1018
+ print('RECON RESULTS')
1019
+ print('=' * 50)
1020
+ print('Workspace ready: ' + str(bool(s.get('workspace_ready'))))
1021
+ print('Framework: ' + recon_results['app']['framework'])
1022
+ if recon_results['app']['scripts']:
1023
+ print('Scripts: ' + ', '.join(sorted(recon_results['app']['scripts'].keys())))
1024
+ print('Current plan path: ' + current_plan['target_path'])
1025
+ print('Current plan selector: ' + (current_plan['wait_for_selector'] or '(none)'))
1026
+ print('Recon status: ' + s.get('recon_status', 'unknown'))
1027
+ if route_hints:
1028
+ print('Route hints:')
1029
+ for line in route_hints[:8]:
1030
+ print(' ' + line)
1031
+ if keyword_hits:
1032
+ print('Keyword hits:')
1033
+ for line in keyword_hits[:10]:
1034
+ print(' ' + line)
1035
+ for label, observation in attempt_observations.items():
1036
+ print(label.capitalize() + ' observation: ' + observation.get('reason', 'unknown'))
1037
+ if observation.get('url'):
1038
+ print(' Screenshot: ' + observation.get('url', ''))
1039
+ print('Proof plan status: ' + s.get('proof_plan_status', 'unknown'))
1040
+ print(json.dumps({
1041
+ 'ok': True,
1042
+ 'recon_status': s.get('recon_status', 'unknown'),
1043
+ 'proof_plan_status': s.get('proof_plan_status', 'unknown'),
1044
+ 'recon_assessment_request': s.get('recon_assessment_request', {}),
1045
+ 'recon_decision_request': s.get('recon_decision_request', {}),
1046
+ 'proof_plan_request': s.get('proof_plan_request', {}),
1047
+ 'baselines': recon_results['baselines'],
1048
+ }, indent=2))