@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.
- package/README.md +6 -6
- package/dist/chunk-3MHFLQKG.js +853 -0
- package/dist/{chunk-LVP22WE4.js → chunk-5GZZZ6JA.js} +5 -1
- package/dist/engine-harness.cjs +2505 -22
- package/dist/engine-harness.js +1 -1
- package/dist/index.cjs +2512 -29
- package/dist/index.js +2 -2
- package/dist/openclaw.cjs +1 -1
- package/dist/openclaw.js +1 -1
- package/dist/proof-run-core.cjs +909 -0
- package/dist/proof-run-core.d.cts +280 -0
- package/dist/proof-run-core.d.ts +280 -0
- package/dist/proof-run-core.js +48 -0
- package/dist/proof-run-engine.cjs +2499 -0
- package/dist/proof-run-engine.d.cts +677 -0
- package/dist/proof-run-engine.d.ts +677 -0
- package/dist/proof-run-engine.js +1649 -0
- package/lib/workspace-core.mjs +391 -0
- package/package.json +15 -3
- package/runtime/lib/author.py +343 -0
- package/runtime/lib/implement.py +63 -0
- package/runtime/lib/preflight.py +246 -0
- package/runtime/lib/recon.py +1048 -0
- package/runtime/lib/riddle_core_call.mjs +151 -0
- package/runtime/lib/setup.py +387 -0
- package/runtime/lib/ship.py +834 -0
- package/runtime/lib/util.py +673 -0
- package/runtime/lib/verify.py +1223 -0
- package/runtime/pipelines/riddle-proof-author.lobster +28 -0
- package/runtime/pipelines/riddle-proof-implement.lobster +26 -0
- package/runtime/pipelines/riddle-proof-recon.lobster +79 -0
- package/runtime/pipelines/riddle-proof-setup.lobster +141 -0
- package/runtime/pipelines/riddle-proof-ship.lobster +36 -0
- package/runtime/pipelines/riddle-proof-verify.lobster +74 -0
- 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))
|