@riddledc/riddle-proof 0.5.11 → 0.5.13

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 CHANGED
@@ -91,6 +91,13 @@ Set `RIDDLE_PROOF_WORKTREE_ROOT` to choose an explicit location. Set
91
91
  `RIDDLE_PROOF_USE_WORKSPACE_WORKTREE_ROOT=1` to keep the previous behavior of
92
92
  placing proof worktrees next to the active repository.
93
93
 
94
+ When local scratch storage is low, setup prunes stale
95
+ `riddle-proof-*` worktrees from the scratch root before creating the next run.
96
+ This preserves the dependency cache for speed while avoiding old failed runs
97
+ filling `/tmp`. Set `RIDDLE_PROOF_KEEP_SCRATCH_WORKTREES=1` to disable that
98
+ cleanup for debugging, or tune the low-space threshold with
99
+ `RIDDLE_PROOF_MIN_SCRATCH_FREE_MB`.
100
+
94
101
  ## Capture Diagnostics
95
102
 
96
103
  `@riddledc/riddle-proof/diagnostics` standardizes the evidence contract around
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riddledc/riddle-proof",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "Reusable Riddle Proof contracts and helpers for evidence-backed agent changes.",
5
5
  "license": "MIT",
6
6
  "author": "RiddleDC",
@@ -783,6 +783,25 @@ elif plan_history:
783
783
  'changes': diff_plan(plan_history[-1], current_plan),
784
784
  'reason': 'Resumed recon with the current state inputs.',
785
785
  })
786
+
787
+ capture_hint = s.get('capture_hint') if isinstance(s.get('capture_hint'), dict) else {}
788
+ selected_hint = capture_hint.get('selected') if isinstance(capture_hint.get('selected'), dict) else {}
789
+ if capture_hint.get('applied') and selected_hint:
790
+ fallback_changes = {}
791
+ hinted_path = str(selected_hint.get('server_path') or '').strip()
792
+ hinted_selector = str(selected_hint.get('wait_for_selector') or '').strip()
793
+ if hinted_path and current_plan.get('target_path') != hinted_path:
794
+ fallback_changes['server_path'] = {'from': hinted_path, 'to': current_plan.get('target_path')}
795
+ if hinted_selector and current_plan.get('wait_for_selector') != hinted_selector:
796
+ fallback_changes['wait_for_selector'] = {'from': hinted_selector, 'to': current_plan.get('wait_for_selector')}
797
+ if fallback_changes:
798
+ capture_hint['fallback_triggered'] = True
799
+ capture_hint['fallback_reason'] = (
800
+ str(previous_assessment.get('decision') or '').strip() if has_previous_assessment else 'plan_refined'
801
+ ) or 'plan_refined'
802
+ capture_hint['fallback_changes'] = fallback_changes
803
+ s['capture_hint'] = capture_hint
804
+
786
805
  plan_history.append(current_plan)
787
806
 
788
807
  summary_bits = []
@@ -9,6 +9,7 @@ local temp storage by default:
9
9
  import json, subprocess as sp, os, sys, shutil, time, tempfile
10
10
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
11
  from util import load_state, save_state, git, shell_quote
12
+ from util import apply_capture_hint
12
13
 
13
14
  s = load_state()
14
15
  repo = s['repo']
@@ -170,6 +171,113 @@ def resolve_worktree_root(repo_dir):
170
171
  return os.path.join(tempfile.gettempdir(), '.riddle-proof-worktrees')
171
172
 
172
173
 
174
+ def env_flag(name, default=False):
175
+ raw = os.environ.get(name, '').strip().lower()
176
+ if raw in ('1', 'true', 'yes', 'on'):
177
+ return True
178
+ if raw in ('0', 'false', 'no', 'off'):
179
+ return False
180
+ return default
181
+
182
+
183
+ def env_int(name, default):
184
+ raw = os.environ.get(name, '').strip()
185
+ try:
186
+ value = int(raw)
187
+ except Exception:
188
+ return default
189
+ return value if value > 0 else default
190
+
191
+
192
+ def disk_free_bytes(path):
193
+ probe = path
194
+ while probe and not os.path.exists(probe):
195
+ parent = os.path.dirname(probe)
196
+ if parent == probe:
197
+ break
198
+ probe = parent
199
+ try:
200
+ return shutil.disk_usage(probe or tempfile.gettempdir()).free
201
+ except Exception:
202
+ return 0
203
+
204
+
205
+ def prune_scratch_worktrees(worktree_root, keep_dirs, repo_dir):
206
+ report = {
207
+ 'requested': True,
208
+ 'worktree_root': worktree_root,
209
+ 'removed': [],
210
+ 'errors': [],
211
+ }
212
+ if env_flag('RIDDLE_PROOF_KEEP_SCRATCH_WORKTREES', False):
213
+ report['skipped'] = 'RIDDLE_PROOF_KEEP_SCRATCH_WORKTREES'
214
+ return report
215
+ if not worktree_root:
216
+ report['skipped'] = 'missing_worktree_root'
217
+ return report
218
+
219
+ root = os.path.abspath(os.path.expanduser(worktree_root))
220
+ temp_root = os.path.abspath(tempfile.gettempdir())
221
+ if root in ('/', temp_root) or not root.endswith('.riddle-proof-worktrees'):
222
+ report['skipped'] = 'unsafe_worktree_root'
223
+ return report
224
+ if not os.path.isdir(root):
225
+ report['skipped'] = 'worktree_root_missing'
226
+ return report
227
+
228
+ min_free_bytes = env_int('RIDDLE_PROOF_MIN_SCRATCH_FREE_MB', 2048) * 1024 * 1024
229
+ free_before = disk_free_bytes(root)
230
+ report['free_before_bytes'] = free_before
231
+ report['min_free_bytes'] = min_free_bytes
232
+ if free_before >= min_free_bytes:
233
+ report['skipped'] = 'enough_free_space'
234
+ return report
235
+
236
+ keep = set(os.path.abspath(os.path.expanduser(p)) for p in keep_dirs if p)
237
+ candidates = []
238
+ for name in os.listdir(root):
239
+ if not name.startswith('riddle-proof-'):
240
+ continue
241
+ path = os.path.join(root, name)
242
+ resolved = os.path.abspath(path)
243
+ if resolved in keep or not os.path.isdir(path):
244
+ continue
245
+ try:
246
+ mtime = os.path.getmtime(path)
247
+ except Exception:
248
+ mtime = 0
249
+ candidates.append((mtime, path))
250
+ candidates.sort()
251
+
252
+ for _, path in candidates:
253
+ if disk_free_bytes(root) >= min_free_bytes:
254
+ break
255
+ removed_by_git = False
256
+ git_error = ''
257
+ if repo_dir and os.path.exists(os.path.join(repo_dir, '.git')):
258
+ remove_result = sp.run(
259
+ 'git worktree remove --force ' + shell_quote(path),
260
+ shell=True,
261
+ cwd=repo_dir,
262
+ capture_output=True,
263
+ text=True,
264
+ )
265
+ removed_by_git = remove_result.returncode == 0
266
+ if remove_result.returncode != 0 and os.path.exists(path):
267
+ git_error = (remove_result.stderr or remove_result.stdout or '')[:300]
268
+ if os.path.exists(path):
269
+ shutil.rmtree(path, ignore_errors=True)
270
+ if not os.path.exists(path):
271
+ report['removed'].append({'path': path, 'via': 'git' if removed_by_git else 'filesystem'})
272
+ elif git_error:
273
+ report['errors'].append({'path': path, 'git_error': git_error})
274
+
275
+ if repo_dir and os.path.exists(os.path.join(repo_dir, '.git')):
276
+ git('git worktree prune', repo_dir)
277
+ report['free_after_bytes'] = disk_free_bytes(root)
278
+ return report
279
+
280
+
173
281
  def cleanup_legacy_branch_worktrees(repo_dir, branch_name):
174
282
  if not repo_dir or not os.path.exists(os.path.join(repo_dir, '.git')):
175
283
  return
@@ -367,9 +475,17 @@ s['branch'] = branch
367
475
  s['target_branch'] = target_branch
368
476
  s['ship_target_branch'] = target_branch
369
477
  s['worktree_root'] = WORKTREE_ROOT
478
+ capture_hint = apply_capture_hint(s)
479
+ if capture_hint and capture_hint.get('applied_fields'):
480
+ print('Applied last-good capture hint: ' + ', '.join(capture_hint.get('applied_fields') or []))
370
481
  save_state(s)
371
482
  print('Prepared workspace via ' + setup.get('source', 'workspace_core') + ': ' + repo_dir)
372
483
  os.makedirs(WORKTREE_ROOT, exist_ok=True)
484
+ scratch_cleanup = prune_scratch_worktrees(WORKTREE_ROOT, (BEFORE_DIR, AFTER_DIR), repo_dir)
485
+ if scratch_cleanup.get('removed') or scratch_cleanup.get('errors'):
486
+ print('Scratch cleanup: removed ' + str(len(scratch_cleanup.get('removed') or [])) + ' stale proof worktree(s)')
487
+ s['scratch_cleanup'] = scratch_cleanup
488
+ save_state(s)
373
489
  cleanup_legacy_branch_worktrees(repo_dir, base_branch)
374
490
 
375
491
  # Clean any stale worktrees for this run and the legacy fixed paths
@@ -1,6 +1,6 @@
1
1
  """Shared helpers for Riddle Proof pipeline."""
2
2
 
3
- import json, subprocess as sp, os, shlex, time
3
+ import hashlib, json, os, re, shlex, subprocess as sp, tempfile, time
4
4
  from urllib.parse import urljoin
5
5
  from urllib.request import urlopen
6
6
 
@@ -18,6 +18,8 @@ CAPTURE_ARTIFACT_JSON_LIMIT = 256 * 1024
18
18
  _JSON_ARTIFACT_CACHE = {}
19
19
  CAPTURE_DIAGNOSTIC_VERSION = 'riddle-proof.capture-diagnostic.v1'
20
20
  DEBUG_STRING_LIMIT = 2000
21
+ CAPTURE_HINT_CACHE_VERSION = 'riddle-proof.capture-hints.v1'
22
+ CAPTURE_HINT_CACHE_LIMIT = 12
21
23
  SENSITIVE_KEY_FRAGMENTS = (
22
24
  'authorization',
23
25
  'apikey',
@@ -29,6 +31,14 @@ SENSITIVE_KEY_FRAGMENTS = (
29
31
  'secret',
30
32
  'token',
31
33
  )
34
+ HINT_TOKEN_STOPWORDS = {
35
+ 'about', 'after', 'agent', 'agents', 'around', 'before', 'browser', 'change',
36
+ 'changes', 'clarify', 'component', 'copy', 'debug', 'default', 'evidence',
37
+ 'flow', 'homepage', 'improve', 'just', 'main', 'make', 'need', 'normal',
38
+ 'page', 'proof', 'report', 'results', 'review', 'run', 'screen', 'script',
39
+ 'small', 'status', 'text', 'tiny', 'update', 'user', 'verify', 'visible',
40
+ 'workflow',
41
+ }
32
42
 
33
43
 
34
44
  def load_state():
@@ -41,6 +51,206 @@ def save_state(s):
41
51
  json.dump(s, f, indent=2)
42
52
 
43
53
 
54
+ def request_shape_tokens(state, limit=8):
55
+ haystack = ' '.join([
56
+ str(state.get('change_request') or ''),
57
+ str(state.get('context') or ''),
58
+ str(state.get('success_criteria') or ''),
59
+ ]).lower()
60
+ tokens = []
61
+ for word in re.findall(r'[a-z0-9]+', haystack):
62
+ if len(word) < 4 or word in HINT_TOKEN_STOPWORDS or word in tokens:
63
+ continue
64
+ tokens.append(word)
65
+ if len(tokens) >= limit:
66
+ break
67
+ return tokens
68
+
69
+
70
+ def capture_hint_cache_path(state):
71
+ repo_key = str(state.get('repo') or state.get('repo_dir') or '').strip()
72
+ if not repo_key:
73
+ return ''
74
+ digest = hashlib.sha1(repo_key.encode('utf-8')).hexdigest()
75
+ return os.path.join(tempfile.gettempdir(), '.riddle-proof-capture-hints', digest + '.json')
76
+
77
+
78
+ def load_capture_hint_cache(state):
79
+ cache_path = capture_hint_cache_path(state)
80
+ if not cache_path or not os.path.exists(cache_path):
81
+ return ({'version': CAPTURE_HINT_CACHE_VERSION, 'hints': []}, cache_path)
82
+ try:
83
+ with open(cache_path) as f:
84
+ payload = json.load(f)
85
+ except Exception:
86
+ return ({'version': CAPTURE_HINT_CACHE_VERSION, 'hints': []}, cache_path)
87
+ if not isinstance(payload, dict):
88
+ return ({'version': CAPTURE_HINT_CACHE_VERSION, 'hints': []}, cache_path)
89
+ hints = payload.get('hints') if isinstance(payload.get('hints'), list) else []
90
+ payload['version'] = CAPTURE_HINT_CACHE_VERSION
91
+ payload['hints'] = hints
92
+ return payload, cache_path
93
+
94
+
95
+ def select_capture_hint(state):
96
+ payload, cache_path = load_capture_hint_cache(state)
97
+ hints = payload.get('hints') or []
98
+ current_tokens = request_shape_tokens(state)
99
+ current_mode = str(state.get('verification_mode') or '').strip().lower()
100
+ scored = []
101
+ for hint in hints:
102
+ if not isinstance(hint, dict):
103
+ continue
104
+ server_path = str(hint.get('server_path') or '').strip()
105
+ wait_for_selector = str(hint.get('wait_for_selector') or '').strip()
106
+ if not server_path and not wait_for_selector:
107
+ continue
108
+ hint_mode = str(hint.get('verification_mode') or '').strip().lower()
109
+ hint_tokens = [
110
+ str(item).strip().lower()
111
+ for item in (hint.get('request_tokens') or [])
112
+ if str(item).strip()
113
+ ]
114
+ matched_tokens = [token for token in current_tokens if token in hint_tokens]
115
+ score = len(matched_tokens) * 3
116
+ if current_mode and hint_mode == current_mode:
117
+ score += 2
118
+ if score <= 0:
119
+ continue
120
+ scored.append({
121
+ 'score': score,
122
+ 'matched_tokens': matched_tokens,
123
+ 'selection_reason': 'token_overlap_and_mode' if matched_tokens and current_mode and hint_mode == current_mode else (
124
+ 'token_overlap' if matched_tokens else 'verification_mode_match'
125
+ ),
126
+ 'hint': hint,
127
+ })
128
+
129
+ if not scored:
130
+ return None
131
+
132
+ scored.sort(
133
+ key=lambda item: (
134
+ int(item['score']),
135
+ str(item['hint'].get('saved_at') or ''),
136
+ ),
137
+ reverse=True,
138
+ )
139
+ selected = scored[0]
140
+ return {
141
+ 'cache_path': cache_path,
142
+ 'available_count': len(hints),
143
+ 'score': selected['score'],
144
+ 'matched_tokens': selected['matched_tokens'],
145
+ 'selection_reason': selected['selection_reason'],
146
+ 'hint': selected['hint'],
147
+ }
148
+
149
+
150
+ def apply_capture_hint(state):
151
+ selected = select_capture_hint(state)
152
+ if not selected:
153
+ return None
154
+
155
+ hint = selected['hint']
156
+ applied_fields = []
157
+ server_path = str(hint.get('server_path') or '').strip()
158
+ wait_for_selector = str(hint.get('wait_for_selector') or '').strip()
159
+
160
+ if server_path and not str(state.get('server_path') or '').strip():
161
+ state['server_path'] = server_path
162
+ state['server_path_source'] = 'hint_cache'
163
+ applied_fields.append('server_path')
164
+ if wait_for_selector and not str(state.get('wait_for_selector') or '').strip():
165
+ state['wait_for_selector'] = wait_for_selector
166
+ state['wait_for_selector_source'] = 'hint_cache'
167
+ applied_fields.append('wait_for_selector')
168
+
169
+ if not applied_fields:
170
+ return None
171
+
172
+ state['capture_hint'] = {
173
+ 'source': 'hint_cache',
174
+ 'cache_path': selected['cache_path'],
175
+ 'applied': True,
176
+ 'applied_fields': applied_fields,
177
+ 'matched_tokens': selected['matched_tokens'],
178
+ 'selection_reason': selected['selection_reason'],
179
+ 'available_count': selected['available_count'],
180
+ 'selected': {
181
+ 'saved_at': str(hint.get('saved_at') or ''),
182
+ 'verification_mode': str(hint.get('verification_mode') or ''),
183
+ 'server_path': server_path,
184
+ 'wait_for_selector': wait_for_selector,
185
+ 'observed_path': str(hint.get('observed_path') or ''),
186
+ 'proof_profile_name': str(hint.get('proof_profile_name') or ''),
187
+ 'request_tokens': hint.get('request_tokens') or [],
188
+ },
189
+ 'fallback_triggered': False,
190
+ }
191
+ return state['capture_hint']
192
+
193
+
194
+ def record_successful_capture_hint(state, server_path='', wait_for_selector='', observed_path='', source_stage='verify', success_signal=''):
195
+ server_path = str(server_path or '').strip()
196
+ wait_for_selector = str(wait_for_selector or '').strip()
197
+ if not server_path and not wait_for_selector:
198
+ return {'status': 'skipped_missing_inputs'}
199
+
200
+ payload, cache_path = load_capture_hint_cache(state)
201
+ hints = payload.get('hints') or []
202
+ request_tokens = request_shape_tokens(state)
203
+ entry = {
204
+ 'saved_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
205
+ 'verification_mode': str(state.get('verification_mode') or '').strip().lower(),
206
+ 'request_tokens': request_tokens,
207
+ 'request_sample': compact_debug_value(str(state.get('change_request') or '')[:240]),
208
+ 'server_path': server_path,
209
+ 'wait_for_selector': wait_for_selector,
210
+ 'observed_path': str(observed_path or '').strip(),
211
+ 'proof_profile_name': str(((state.get('proof_profile') or {}).get('name')) or '').strip(),
212
+ 'source_stage': str(source_stage or '').strip(),
213
+ 'success_signal': str(success_signal or '').strip(),
214
+ }
215
+
216
+ deduped = []
217
+ for hint in hints:
218
+ if not isinstance(hint, dict):
219
+ continue
220
+ if (
221
+ str(hint.get('verification_mode') or '').strip().lower() == entry['verification_mode']
222
+ and str(hint.get('server_path') or '').strip() == entry['server_path']
223
+ and str(hint.get('wait_for_selector') or '').strip() == entry['wait_for_selector']
224
+ and list(hint.get('request_tokens') or []) == entry['request_tokens']
225
+ ):
226
+ continue
227
+ deduped.append(hint)
228
+
229
+ next_payload = {
230
+ 'version': CAPTURE_HINT_CACHE_VERSION,
231
+ 'repo': str(state.get('repo') or state.get('repo_dir') or '').strip(),
232
+ 'updated_at': entry['saved_at'],
233
+ 'hints': [entry] + deduped[:CAPTURE_HINT_CACHE_LIMIT - 1],
234
+ }
235
+ try:
236
+ os.makedirs(os.path.dirname(cache_path), exist_ok=True)
237
+ with open(cache_path, 'w') as f:
238
+ json.dump(next_payload, f, indent=2)
239
+ except Exception as exc:
240
+ return {
241
+ 'status': 'error',
242
+ 'cache_path': cache_path,
243
+ 'error': str(exc)[:240],
244
+ }
245
+
246
+ return {
247
+ 'status': 'saved',
248
+ 'cache_path': cache_path,
249
+ 'entry': entry,
250
+ 'hint_count': len(next_payload['hints']),
251
+ }
252
+
253
+
44
254
  def compact_debug_value(value, limit=DEBUG_STRING_LIMIT):
45
255
  if isinstance(value, str) and len(value) > limit:
46
256
  return value[:limit] + '... [truncated]'
@@ -19,6 +19,7 @@ from util import (
19
19
  invoke_retry,
20
20
  load_state,
21
21
  prepare_server_preview,
22
+ record_successful_capture_hint,
22
23
  save_state,
23
24
  should_use_static_preview,
24
25
  summarize_capture_artifacts,
@@ -1139,6 +1140,16 @@ if proof_evidence_required_for_mode(s.get('verification_mode')):
1139
1140
 
1140
1141
  has_good_evidence = required_baseline_present and after_observation.get('valid') and not proof_evidence_blocker
1141
1142
 
1143
+ if has_good_evidence:
1144
+ s['capture_hint_saved'] = record_successful_capture_hint(
1145
+ s,
1146
+ server_path=expected_path or s.get('server_path') or '/',
1147
+ wait_for_selector=s.get('wait_for_selector') or '',
1148
+ observed_path=observed_path,
1149
+ source_stage='verify',
1150
+ success_signal='evidence_captured',
1151
+ )
1152
+
1142
1153
  if has_good_evidence:
1143
1154
  supervisor_request = build_supervisor_assessment_request(s, after_payload, after_observation, required_baseline_present, expected_path, evidence_bundle)
1144
1155
  s['verify_status'] = 'evidence_captured'