"""Stable CLI diagnostics. Never infer that a failed operation had no effects.""" import json import subprocess import sys ERRORS = { 'E1001': ('Invalid command or arguments', [ 'Read the command contract with help ; quote pipe-delimited payloads.', 'Use -- before a positional payload starting with minus. commands lists supported operations.']), 'E2001': ('Required local file or executable is missing', [ 'Run doctor and inspect the missing path. A Git clone excludes local receipts, binaries and assets.', 'Use the configured workspace; install-native/install-ui update an existing installation, not a new one.']), 'E2002': ('Installed files differ from their ownership receipt', [ 'Run verify; inspect the named file and its receipt/backup before changing anything.', 'For a Steam executable update, inspect reconcile-game-update; unsupported builds remain blocked.', 'Restore or deliberately integrate the changed file. Do not bypass ownership checks by rewriting hashes.']), 'E2006': ('Game update compatibility is not established', [ 'Read help reconcile-game-update. Only an exact reviewed executable/dependency profile can be reconciled.', 'Do not rewrite expected hashes, restore an old Steam executable, or use install-native to bypass a mismatch.', 'If a transaction was interrupted, inspect its retained artifacts and use its hash-guarded rollback.']), 'E2003': ('The required game process or window is unavailable', [ 'Check whether the game is running. launch requests startup; wait for the actual game window before attach.', 'For a closed-game operation, stop the session first. Do not attach during the startup splash.']), 'E2004': ('The recorded session cannot be used as requested', [ 'Read status; it is historical and does not prove liveness.', 'After a new game process starts, attach explicitly. If already attached, use call snapshot instead of attaching again.']), 'E2005': ('Another CLI command holds the session lock', [ 'Let the current command finish. Only one agent should drive a live experiment.', 'Do not remove the lock file: the operating-system lock releases when its owner exits.']), 'E3001': ('A previous mutation has an uncertain outcome', [ 'Read status, call snapshot, call events and relevant artifacts before deciding what happened.', 'Use recover only after reconciling state. It clears the block; it does not undo or replay anything.']), 'E3002': ('The world is not ready for this bounded action', [ 'Use wait-ready after loading, then call pause and call snapshot.', 'Use top-level input/advance only when ready and devkit_paused are true.']), 'E3003': ('Completion or transport acknowledgement could not be verified', [ 'The action may have executed. Inspect status, call snapshot, call events and call ui as appropriate; do not replay blindly.', 'If the session is uncertain, reconcile state before recover. See guide recovery.']), 'E3004': ('The engine rejected a request', [ 'Read the original result and the command contract. Reobserve readiness, actor generation or UI revision as relevant.', 'A partial spawn failure may have created actors. Inspect before cleanup or another spawn; do not assume rejection means no effects.']), 'E4001': ('UI selection or actionability check failed', [ 'Inspect ui-tree (with the intended --root); choose one exact label/name/path and check visibility/enabled state.', 'Obtain fresh references after UI transitions. Do not weaken strict selection or replay an unverified dispatch.']), 'E4002': ('UI input was delivered but its postcondition was not verified', [ 'Inspect result.delivery, call ui and ui-tree to determine the actual screen state.', 'Do not repeat the dispatch automatically; the first action was delivered.']), 'E5001': ('Script compilation or its rollback failed', [ 'Read result errors/compile and the referenced artifacts for original compiler file/line diagnostics.', 'Check rollback.ok or rollback_compiled. If rollback is unverified, restore a known source and cold restart before experimenting.', 'recompile alone does not restore source; stage only deploys the automation bridge.']), 'E6001': ('The game stopped but the save-file audit found changes', [ 'Compare current saves with artifacts//saves-before.json and preserve the audit evidence.', 'No save was automatically restored. Investigate before starting another experiment.']), 'E7001': ('Experimental precondition or trace identity failed', [ 'Inspect requested and observed state. Stop dependent trial actions; do not silently change the requested fixture.', 'Reload the exact save for a fresh trial, preserving the failed result and its evidence.']), 'E8001': ('Presentation timing provider is unavailable', [ 'Read the provider reason. Build presentation/build.py with the game closed, then perf-launch with the active Proton directory.', 'Wait for the real window, attach and perf-status. Missing data is not zero cost; see guide performance.']), 'E8002': ('Presentation capture is incomplete or its experiment failed', [ 'Keep the raw trace and report in the returned folder. Inspect errors, stop_reason, drops and window_validity.', 'Do not relabel partial data as a clean comparison. perf-stop is idempotent; it never replays a game action.']), 'E9101': ('Persistent debugger owns this session', [ 'Use debug status and debug operation ID to reconnect. Legacy commands cannot bypass the owner.', 'If the owner died, use debug recover; never remove the lock or attach over its native receipt.']), 'E9102': ('Debugger is busy with an accepted operation', [ 'Query debug operation ID or request debug cancel ID. Cancellation still needs a verified completion.', 'No competing action was queued or sent. Submit the next action only after the active operation ends.']), 'E9103': ('Operation identity conflicts with an earlier submission', [ 'The same operation ID was used with different arguments. The second request was not dispatched.', 'Look up the original result. Use a new ID only for an intentionally new action.']), 'E9104': ('Debugger outcome is unresolved', [ 'Inspect debug status, operation ID and fresh world/events. Never replay input or clear uncertainty blindly.', 'Explicit debug close is allowed over historical/compound uncertainty only when the live native delivery receipt is certain; it does not repair the unknown operation.', 'Recovery preserves unknown outcomes. If native mutation delivery cannot be reconciled, close the exact game process externally, then debug recover audits and restores settings.']), 'E9105': ('Debugger observation or storage budget was reached', [ 'Inspect the retained operation and journal. Missing samples/events are not zero events.', 'Use smaller finite operations or explicit retained evidence export; nothing is silently deleted. Cleanup reserve is separate.']), 'E9106': ('Worktree source differs from the live owner', [ 'Use the unchanged owning checkout or close its session before testing different source.', 'Status, operation lookup, cancellation and close remain available. Do not edit/remove owning source mid-session.', 'Live recovery needs the original source; after confirmed external game closure, current source may perform retained-receipt audits/restoration.']), 'E9001': ('Operation failed', [ 'Inspect the detail, command contract and local artifacts. Do not infer that the operation had no side effects.', 'Use doctor for setup checks and guide recovery for uncertain runtime state.']), } class DiagnosticError(RuntimeError): def __init__(self, code, detail, evidence=None): super().__init__(detail) self.code = code self.evidence = evidence class PostconditionError(TimeoutError): """Preserve delivery evidence while retaining TimeoutError compatibility.""" code = 'E4002' def __init__(self, detail, evidence): super().__init__(detail) self.evidence = evidence def classify(error): if hasattr(error, 'code'): return error.code detail = str(error).lower() if detail.startswith(('compatibility:', 'unsupported_game_build:')): return 'E2006' if 'session_owned:' in detail or 'previous_owner_recovery_required' in detail: return 'E9101' if 'startup_recovery_required:' in detail: return 'E2003' if 'crossbow_binding_' in detail: return 'E3002' if detail.startswith('busy:'): return 'E9102' if 'operation_id_conflict:' in detail: return 'E9103' if detail.startswith('uncertain:'): return 'E9104' if any(s in detail for s in ('journal_quota:', 'storage_failed:', 'operation_observation_budget:')): return 'E9105' if any(s in detail for s in ('owner_source_mismatch:', 'owner_source_changed:')): return 'E9106' if isinstance(error, FileNotFoundError): return 'E2004' if 'native-session.json' in detail else 'E2001' if isinstance(error, BlockingIOError): return 'E2005' if 'uncertain outcome' in detail: return 'E3001' if 'already attached' in detail or 'recorded game ended' in detail: return 'E2004' if any(s in detail for s in ('differs from', 'changed outside', 'refusing overwrite')): return 'E2002' if any(s in detail for s in ('game window', 'witcher process', 'process identity changed', 'game is running', 'close the game', 'close witcher')): return 'E2003' if 'gameplay is not ready' in detail or 'pause the game before' in detail: return 'E3002' if any(s in detail for s in ('strict ui', 'ui target', 'ui tree', 'ui snapshot')) or detail.startswith('ui_'): return 'E4001' if isinstance(error, (TimeoutError, ConnectionError, subprocess.TimeoutExpired)) or any(s in detail for s in ('acknowledgement', 'response identity', 'generation changed', 'unexpected tick')): return 'E3003' # Runtime ValueErrors can be malformed responses, not necessarily bad user input. return 'E9001' ENGINE_HINTS = { 'crossbow_not_selected': 'Inspect --view crossbow; explicitly select the equipped crossbow with debug quickslot crossbow and retain its restore receipt.', 'crossbow_not_equipped': 'No equipped crossbow was found. Selection does not equip or add inventory; use a suitable protected fixture or separately supported equipment setup.', 'crossbow_requires_on_foot': 'This input is qualified only on-foot, not boat/horse/swimming contexts. Do not bypass the binding/context guard.', 'crossbow_entity_unavailable': 'The equipped crossbow entity is unavailable. Inspect crossbow state; selection alone is not proof of readiness.', 'crossbow_release_pending': 'Run explicit bounded neutral steps and inspect crossbow state until the native release/idle fence clears; do not force selection or another shot.', 'quickslot_release_pending': 'The prior crossbow action has not passed its native neutral/idle fence. Use bounded neutral steps or discard the world through the documented load/close workflow.', 'quickslot_item_action_busy': 'Do not change selection through a pending/active item action. Inspect crossbow state and let normal bounded neutral processing settle.', 'quickslot_prior_not_restorable': 'The original selection is neither the exact empty sentinel nor a valid equipped item. No selection was changed.', 'quickslot_prior_item_changed': 'The captured prior item/slot no longer matches. Do not guess by item name; use a hash-verified fixture reload or unsaved closure.', 'player_not_loaded': 'Use top-level load with a filename from call saves, then wait-ready.', 'requires_devkit_pause': 'Run call pause, then inspect call snapshot before a bounded action.', 'save_not_found': 'Run call saves and use an exact returned filename with top-level load.', 'stale_world_generation': 'Obtain references again in the current world: actor IDs from snapshot/profile, entity IDs from encounters. Their ID namespaces are separate.', 'stale_encounter_revision': 'A newer discovery scan replaced this page cache. Use its generation/revision/next, or begin a fresh encounters scan.', 'entity_unavailable': 'This inspection entity no longer exists or was never registered. Discover it again; do not substitute an actor ID.', 'combat_batch_actor_limit': 'Select at most 24 actors plus Geralt. Split observations into separately tick-stamped batches.', 'sampler_already_active': 'Inspect the previous arm acknowledgement. Collect its revision with sample_page and disarm using sample_stop before beginning a new bounded sample.', 'sampler_requires_pause': 'Use call pause, then sample. Arming does not advance the simulation.', 'stale_sample_revision': 'A later arm replaced the sample buffer. Read the current arm receipt; previous samples must be recovered from saved evidence, not replayed.', 'actor_unavailable': 'Read nearby actors again; the actor may have despawned or left the observed world.', 'no_intro_menu': 'Inspect call ui; skip_intro only applies to supported startup/recap movies.', 'cutscene_active': 'Wait for the cutscene to end and observe readiness before altering the world.', 'spawn_limit': 'Inspect existing test actors; cleanup removes owned spawns and also clears devkit pause/time scale.', 'template_not_found': 'Use a template present in this installed game; changing level/count cannot fix a missing template.', 'no_safe_navigation_spot': 'Choose coordinates near valid navigation in the current region; inspect position first.', 'session_mismatch': 'Inspect status and the running process; do not reuse a session token from another process.', 'duplicate_or_old_request': 'Inspect session/request identity and concurrent drivers; do not replay the request.', 'stale_ui_reference': 'Run ui-tree again; raw node references belong to the most recent scan revision.', } def engine_error(reason, result): code = 'E4001' if reason.startswith('ui_') or reason == 'stale_ui_reference' else 'E3004' if reason in ('player_not_loaded', 'requires_devkit_pause') or reason.startswith('crossbow_'): code = 'E3002' error = DiagnosticError(code, reason, result) error.hint = ENGINE_HINTS.get(reason) return error def rejected(result): """Return a diagnostic for failed results, including nested engine/UI errors.""" if result.get('experimental_validity') is False: return DiagnosticError('E7001', result.get('reason', 'Experimental precondition failed.'), result) if result.get('save_files_unchanged') is False: return DiagnosticError('E6001', 'Save files differ from attach-time hashes.', result) nested = result.get('data') if isinstance(nested, dict) and nested.get('error'): return engine_error(str(nested['error']), result) execution = result.get('result') if isinstance(execution, dict): failure = rejected(execution) if failure: failure.evidence = result return failure if result.get('ok'): return None startup = result.get('startup') or {} if startup.get('failure'): failure = startup['failure'] error = DiagnosticError(failure['code'], failure['detail'], result) error.hint = startup.get('recovery') return error if result.get('provider') == 'PathPresent': if 'available' in result:return DiagnosticError('E8001',result.get('reason','Provider not qualified'),result) return DiagnosticError('E8002',', '.join(result.get('errors',[])) or str(result.get('window_validity')),result) if any(k in result for k in ('compile', 'errors', 'rollback_compiled')): return DiagnosticError('E5001', 'Compilation failed; inspect compiler and rollback evidence.', result) if 'request' in result and 'data' in result: return engine_error(str(result['data']), result) if 'checks' in result: failed = [c for c in result['checks'] if not c['ok']] detail = '\n'.join(c['name'] + ': ' + c['detail'] for c in failed) return DiagnosticError(failed[0].get('code', 'E2001') if failed else 'E9001', 'Local setup checks failed:\n' + detail, result) operation=result.get('operation') or {} detail=str(result.get('error') or operation.get('error') or 'Operation returned ok=false.') rejection=(operation.get('error_evidence') or {}).get('native_rejection') or {} if operation.get('status')=='rejected' and rejection.get('audited_no_action_guard') is True: reply=rejection.get('reply') or {} if reply.get('ok') is False and isinstance(reply.get('data'),str): return engine_error(reply['data'],result) return DiagnosticError('E9104' if operation.get('status')=='uncertain' else classify(RuntimeError(detail)), detail, result) def compiler_locations(value): """Index actual compiler packets without dropping their original representation.""" found = [] if isinstance(value, dict): for packet in value.get('errors', []): if isinstance(packet, list) and len(packet) >= 5 and packet[:2] == ['ScriptCompiler', 'error']: found.append(dict(file=packet[3], line=packet[2], message=packet[4])) for key in ('compile', 'rollback', 'startup', 'compiler'): found.extend(compiler_locations(value.get(key))) return found def payload(error, command, context=None): code = classify(error) title, recovery = ERRORS[code] recovery = ([error.hint] if getattr(error, 'hint', None) else []) + recovery diagnostic = dict(code=code, summary=title, detail=str(error), command=command, context=context or {}, recovery=recovery, help=['help ' + command, 'explain ' + code, 'guide recovery']) result = dict(ok=False, error=str(error), diagnostic=diagnostic) evidence = getattr(error, 'evidence', None) if evidence is not None: result['result'] = evidence locations = compiler_locations(evidence) if locations: diagnostic['locations'] = locations return result def emit(error, command, context=None): result = payload(error, command, context) d = result['diagnostic'] print(json.dumps(result)) print(f"error[{d['code']}]: {d['summary']}\n --> {command}\n |\n = {d['detail']}", file=sys.stderr) for location in d.get('locations', [])[:10]: print(f" --> {location['file']}:{location['line']}\n = {location['message']}", file=sys.stderr) for hint in d['recovery']: print(' help: ' + hint, file=sys.stderr) print(' docs: w3dev explain ' + d['code'], file=sys.stderr) if 'result' in result: print(' evidence: preserved in stdout JSON under result', file=sys.stderr) return 2 if d['code'] == 'E1001' else 1