fable-engine 1.3.1__py3-none-any.whl

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 (104) hide show
  1. fable_compressor.py +356 -0
  2. fable_engine/__init__.py +1 -0
  3. fable_engine/actions/__init__.py +291 -0
  4. fable_engine/actions/cas.py +182 -0
  5. fable_engine/actions/deliberation.py +523 -0
  6. fable_engine/actions/fleet.py +807 -0
  7. fable_engine/actions/lifecycle.py +298 -0
  8. fable_engine/actions/scrapers.py +116 -0
  9. fable_engine/actions/system3.py +815 -0
  10. fable_engine/browser.py +824 -0
  11. fable_engine/cas.py +974 -0
  12. fable_engine/fable_session.json +510 -0
  13. fable_engine/guards.py +283 -0
  14. fable_engine/schema.py +714 -0
  15. fable_engine/scrapers/__init__.py +32 -0
  16. fable_engine/scrapers/arxiv.py +115 -0
  17. fable_engine/scrapers/base.py +386 -0
  18. fable_engine/scrapers/github.py +129 -0
  19. fable_engine/scrapers/reddit.py +154 -0
  20. fable_engine/scrapers/web.py +120 -0
  21. fable_engine/scrapers/x.py +125 -0
  22. fable_engine/scrapers/youtube.py +132 -0
  23. fable_engine/server.py +414 -0
  24. fable_engine/session.py +1819 -0
  25. fable_engine/test_server.py +1362 -0
  26. fable_engine/updater.py +541 -0
  27. fable_engine-1.3.1.dist-info/LICENSE +22 -0
  28. fable_engine-1.3.1.dist-info/METADATA +173 -0
  29. fable_engine-1.3.1.dist-info/RECORD +104 -0
  30. fable_engine-1.3.1.dist-info/WHEEL +5 -0
  31. fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
  32. fable_engine-1.3.1.dist-info/top_level.txt +6 -0
  33. fable_mode/__init__.py +3 -0
  34. fable_mode/__main__.py +4 -0
  35. fable_mode/adapters.py +1014 -0
  36. fable_mode/installer.py +553 -0
  37. fable_mode/launcher.py +437 -0
  38. fable_mode/manifest.py +142 -0
  39. fable_mode/resources.json +114 -0
  40. fable_mode/safety.py +103 -0
  41. fable_mode_entry.py +10 -0
  42. fable_v2/__init__.py +146 -0
  43. fable_v2/adapters.py +151 -0
  44. fable_v2/coder_fleet/__init__.py +100 -0
  45. fable_v2/coder_fleet/ast_tools.py +158 -0
  46. fable_v2/coder_fleet/compute.py +199 -0
  47. fable_v2/coder_fleet/design_engine.py +1316 -0
  48. fable_v2/coder_fleet/diagnostics.py +293 -0
  49. fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
  50. fable_v2/coder_fleet/mock_auditor.py +306 -0
  51. fable_v2/coder_fleet/mutation.py +216 -0
  52. fable_v2/coder_fleet/property_oracle.py +260 -0
  53. fable_v2/coder_fleet/receipt_attestor.py +122 -0
  54. fable_v2/coder_fleet/red_team_swarm.py +908 -0
  55. fable_v2/coder_fleet/test_harness.py +198 -0
  56. fable_v2/coder_fleet/vector_engine.py +1287 -0
  57. fable_v2/coder_fleet/visual.py +357 -0
  58. fable_v2/coder_fleet/workspace.py +153 -0
  59. fable_v2/cortical/__init__.py +20 -0
  60. fable_v2/cortical/plasticity_engine.py +992 -0
  61. fable_v2/execution_broker.py +811 -0
  62. fable_v2/proof_engine.py +1141 -0
  63. fable_v2/protocol.py +485 -0
  64. fable_v2/runtime.py +1010 -0
  65. fable_v2/system3/__init__.py +204 -0
  66. fable_v2/system3/causal.py +558 -0
  67. fable_v2/system3/dialectical.py +577 -0
  68. fable_v2/system3/evolution.py +503 -0
  69. fable_v2/system3/executive.py +338 -0
  70. fable_v2/system3/free_energy.py +479 -0
  71. fable_v2/system3/hyperbolic.py +555 -0
  72. fable_v2/system3/induction.py +336 -0
  73. fable_v2/system3/kripke.py +548 -0
  74. fable_v2/system3/oracle.py +745 -0
  75. fable_v2/verifiers.py +72 -0
  76. tests/__init__.py +1 -0
  77. tests/test_anti_loop_circuit_breaker.py +64 -0
  78. tests/test_auto_updater.py +407 -0
  79. tests/test_coder_fleet.py +535 -0
  80. tests/test_delegation_compiler.py +54 -0
  81. tests/test_descriptor_boundaries.py +126 -0
  82. tests/test_design_engine.py +603 -0
  83. tests/test_epistemic_evidence_validator.py +66 -0
  84. tests/test_execution_broker.py +233 -0
  85. tests/test_fable_v2.py +406 -0
  86. tests/test_fleet_transitions.py +116 -0
  87. tests/test_fsm_redteam_evolution.py +406 -0
  88. tests/test_goal_rubric_and_pipeline.py +367 -0
  89. tests/test_hebbian_plasticity.py +585 -0
  90. tests/test_packaging_runtime.py +194 -0
  91. tests/test_proof_engine.py +259 -0
  92. tests/test_red_team_swarm.py +645 -0
  93. tests/test_redteam_remediation.py +169 -0
  94. tests/test_registration_transaction.py +375 -0
  95. tests/test_requested_regressions.py +467 -0
  96. tests/test_scrapers.py +370 -0
  97. tests/test_server_actions.py +93 -0
  98. tests/test_server_frontier_actions.py +269 -0
  99. tests/test_server_protocol.py +88 -0
  100. tests/test_stealth_browser.py +970 -0
  101. tests/test_system3.py +381 -0
  102. tests/test_system3_deep_integration.py +385 -0
  103. tests/test_system3_frontier.py +436 -0
  104. tests/test_vector_engine.py +608 -0
@@ -0,0 +1,1819 @@
1
+ """
2
+ Fable-Mode Cognitive Session Engine and State Machine.
3
+ Implements:
4
+ - FableSession: Invariant-checked, auditable System 2 deliberation session
5
+ - SessionState: Finite state machine with validated transitions
6
+ - Cross-process atomic session locking and persistence
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import collections
12
+ import copy
13
+ import hashlib
14
+ import hmac
15
+ import json
16
+ import logging
17
+ import math
18
+ import os
19
+ import re
20
+ import secrets
21
+ import stat
22
+ import sys
23
+ import tempfile
24
+ import threading
25
+ import time
26
+ from contextlib import contextmanager
27
+ from enum import Enum
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List, Optional, Tuple, Union
30
+
31
+ logger = logging.getLogger("fable-engine.session")
32
+
33
+ BASE_DIR = Path(__file__).resolve().parent
34
+ if str(BASE_DIR.parent) not in sys.path:
35
+ sys.path.insert(0, str(BASE_DIR.parent))
36
+
37
+ from fable_engine.cas import (
38
+ DATA_DIR,
39
+ _assert_private_path,
40
+ _open_directory_nofollow,
41
+ )
42
+ from fable_engine.guards import GLOBAL_VELOCITY_PROFILER
43
+
44
+ _GLOBAL_RED_TEAM_SWARM = None
45
+ _GLOBAL_PLASTICITY_ENGINE = None
46
+
47
+ def get_red_team_swarm():
48
+ global _GLOBAL_RED_TEAM_SWARM
49
+ if _GLOBAL_RED_TEAM_SWARM is None:
50
+ from fable_v2.coder_fleet import RedTeamSwarm
51
+ _GLOBAL_RED_TEAM_SWARM = RedTeamSwarm(plasticity_engine=get_plasticity_engine())
52
+ return _GLOBAL_RED_TEAM_SWARM
53
+
54
+ def get_plasticity_engine():
55
+ global _GLOBAL_PLASTICITY_ENGINE
56
+ if _GLOBAL_PLASTICITY_ENGINE is None:
57
+ from fable_v2.cortical import HebbianPlasticityEngine
58
+ _GLOBAL_PLASTICITY_ENGINE = HebbianPlasticityEngine(cortex_dir=DATA_DIR / "cortex")
59
+ return _GLOBAL_PLASTICITY_ENGINE
60
+
61
+ get_default_plasticity_engine = get_plasticity_engine
62
+
63
+ def __getattr__(name: str) -> Any:
64
+ if name == "GLOBAL_RED_TEAM_SWARM":
65
+ return get_red_team_swarm()
66
+ if name == "GLOBAL_PLASTICITY_ENGINE":
67
+ return get_plasticity_engine()
68
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
69
+
70
+ try:
71
+ from fable_engine.updater import AutoUpdater
72
+ except ImportError:
73
+ try:
74
+ from updater import AutoUpdater
75
+ except ImportError:
76
+ AutoUpdater = None
77
+
78
+ SESSIONS_DIR = DATA_DIR / "sessions"
79
+ _assert_private_path(SESSIONS_DIR)
80
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
81
+ _assert_private_path(SESSIONS_DIR)
82
+ if SESSIONS_DIR.is_symlink() or not SESSIONS_DIR.is_dir():
83
+ raise RuntimeError("SESSIONS_DIR must be a real directory")
84
+ os.chmod(SESSIONS_DIR, 0o700)
85
+
86
+ PHASES = [
87
+ "Phase 1: Epistemic Grounding & Live Research",
88
+ "Phase 2: Invariant Specification & Blueprint",
89
+ "Phase 3: Adversarial Red-Teaming & Falsification",
90
+ "Phase 4: Subagent Fleet Delegation",
91
+ "Phase 5: Multi-Tier Verification & Gatekeeping",
92
+ "Phase 6: Final Walkthrough & Reporting"
93
+ ]
94
+
95
+ PHASE_INDEX_MAP = {phase: i + 1 for i, phase in enumerate(PHASES)}
96
+
97
+ MIN_TIME_BUDGET_MINUTES = 2.0
98
+ MAX_TIME_BUDGET_MINUTES = 7 * 24 * 60
99
+ MAX_REMEDIATION_ATTEMPTS = 5
100
+ MAX_REMEDIATION_SECONDS = 900.0
101
+ RED_TEAM_ATTACK_VECTORS = (
102
+ "chaos_environment",
103
+ "byzantine_payload",
104
+ "concurrency_race",
105
+ "resource_exhaustion",
106
+ "state_invariant",
107
+ )
108
+ FORCE_UNLOCK_ENV = "FABLE_FORCE_UNLOCK_TOKEN"
109
+ SESSION_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
110
+
111
+ SILENT_DELIBERATION_REMINDER = (
112
+ "\n\n> [!IMPORTANT]\n"
113
+ "> 🛑 **SILENT-DELIBERATION ACTIVE (Zero-Chat Lockout)**: Do NOT emit conversational responses "
114
+ "or prompt the user while time-lock is active. Continue internal tool-reasoning, terminal benchmarks "
115
+ "(`run_command`), artifact authoring, and rethink-refine cycles until the authority deadline elapses."
116
+ )
117
+
118
+ def _validate_time_budget(value: Any, field_name: str = "time_budget_minutes") -> float:
119
+ """Validate a duration before it can influence an execution deadline."""
120
+ try:
121
+ minutes = float(value)
122
+ except (TypeError, ValueError) as exc:
123
+ raise ValueError(f"Invalid {field_name}: expected a finite number of minutes.") from exc
124
+ if not math.isfinite(minutes) or not (MIN_TIME_BUDGET_MINUTES <= minutes <= MAX_TIME_BUDGET_MINUTES):
125
+ raise ValueError(
126
+ f"Invalid {field_name}: minimum allowed time budget is {MIN_TIME_BUDGET_MINUTES} minutes "
127
+ f"(must be between {MIN_TIME_BUDGET_MINUTES} and {MAX_TIME_BUDGET_MINUTES} finite minutes to ensure adequate epistemic grounding and deliberation). "
128
+ f"Provided: {minutes} minutes."
129
+ )
130
+ return minutes
131
+
132
+
133
+ def _validate_session_name(name: str) -> str:
134
+ """Keep session persistence inside SESSIONS_DIR and make identifiers portable."""
135
+ clean_name = (name or "").strip()
136
+ if not SESSION_NAME_PATTERN.fullmatch(clean_name):
137
+ raise ValueError(
138
+ "Invalid session_name: use 1-128 letters, numbers, '.', '_' or '-' "
139
+ "and do not include path separators."
140
+ )
141
+ return clean_name
142
+
143
+
144
+
145
+ class SessionState(str, Enum):
146
+ INIT = "INIT"
147
+ DEEPTHINK_TIMELOCK = "DEEPTHINK_TIMELOCK"
148
+ IMPLEMENTATION = "IMPLEMENTATION"
149
+ RED_TEAM_GATE = "RED_TEAM_GATE"
150
+ ARBITRATION = "ARBITRATION"
151
+ REMEDIATION_REQUIRED = "REMEDIATION_REQUIRED"
152
+ ESCALATION_UNRESOLVED_BREAKAGES = "ESCALATION_UNRESOLVED_BREAKAGES"
153
+ SEALED = "SEALED"
154
+ EVOLVED = "EVOLVED"
155
+
156
+
157
+ VALID_TRANSITIONS = {
158
+ SessionState.INIT: {SessionState.DEEPTHINK_TIMELOCK},
159
+ SessionState.DEEPTHINK_TIMELOCK: {SessionState.IMPLEMENTATION},
160
+ SessionState.IMPLEMENTATION: {SessionState.RED_TEAM_GATE},
161
+ SessionState.RED_TEAM_GATE: {SessionState.ARBITRATION, SessionState.REMEDIATION_REQUIRED},
162
+ SessionState.ARBITRATION: {SessionState.REMEDIATION_REQUIRED, SessionState.SEALED},
163
+ SessionState.REMEDIATION_REQUIRED: {
164
+ SessionState.ARBITRATION,
165
+ SessionState.SEALED,
166
+ SessionState.REMEDIATION_REQUIRED,
167
+ SessionState.ESCALATION_UNRESOLVED_BREAKAGES,
168
+ },
169
+ SessionState.ESCALATION_UNRESOLVED_BREAKAGES: {SessionState.ESCALATION_UNRESOLVED_BREAKAGES},
170
+ SessionState.SEALED: {SessionState.EVOLVED},
171
+ SessionState.EVOLVED: {SessionState.EVOLVED},
172
+ }
173
+
174
+
175
+ class FableSession:
176
+ """Represents an active Fable reasoning & pacing session."""
177
+
178
+ def __init__(
179
+ self,
180
+ session_name: str,
181
+ objective: str,
182
+ time_budget_minutes: float,
183
+ session_id: Optional[str] = None,
184
+ start_time: Optional[float] = None,
185
+ wall_clock: Optional[Any] = None,
186
+ monotonic_clock: Optional[Any] = None
187
+ ):
188
+ self.session_name = _validate_session_name(session_name)
189
+ self.objective = objective
190
+ self._wall_clock = wall_clock or time.time
191
+ self._monotonic_clock = monotonic_clock or time.monotonic
192
+ self.start_time = start_time if start_time is not None else self._wall_clock()
193
+ self.session_id = session_id or f"fable_{session_name}_{int(self.start_time)}"
194
+
195
+ # The authority budget is immutable after session creation. A separate
196
+ # pacing timer may be shortened by the agent, but it can never grant
197
+ # execution permission or move this outer deadline earlier.
198
+ self.time_budget_minutes = _validate_time_budget(time_budget_minutes)
199
+ self.time_budget_seconds = self.time_budget_minutes * 60.0
200
+ self._authority_deadline_wall = self.start_time + self.time_budget_seconds
201
+ self._authority_deadline_monotonic = self._monotonic_clock() + self.time_budget_seconds
202
+
203
+ self.pacing_budget_minutes = self.time_budget_minutes
204
+ self.pacing_budget_seconds = self.time_budget_seconds
205
+ self._pacing_started_wall = self.start_time
206
+ self._pacing_started_monotonic = self._authority_deadline_monotonic - self.time_budget_seconds
207
+ self._pacing_deadline_wall = self._authority_deadline_wall
208
+ self._pacing_deadline_monotonic = self._authority_deadline_monotonic
209
+
210
+ self.active_phase = PHASES[0]
211
+ self.execution_locked = True
212
+ self.can_execute_code = False
213
+
214
+ self.current_state = SessionState.INIT
215
+ self.iteration_count = 0
216
+ self.remediation_attempt_count = 0
217
+ self.remediation_started_at: Optional[float] = None
218
+ self.active_breakages: List[Dict[str, Any]] = []
219
+ self.remediation_history: List[Dict[str, Any]] = []
220
+ self._timer_set = False
221
+
222
+ self.epistemic_ledger: List[Dict[str, Any]] = []
223
+ self.invariants: List[Dict[str, Any]] = []
224
+ self.refinement_cycles: List[Dict[str, Any]] = []
225
+ self.file_changes: List[Dict[str, Any]] = []
226
+ self.visual_mockups: Dict[str, Any] = {"mockups": [], "selected_concept": None}
227
+ self.proof_receipts: List[Dict[str, Any]] = []
228
+ self.goal_rubrics: List[Dict[str, Any]] = []
229
+ self.automation_pipelines: List[Dict[str, Any]] = []
230
+ self.breakage_reports: List[Dict[str, Any]] = []
231
+ self.phase_history: List[Dict[str, Any]] = [
232
+ {
233
+ "phase": self.active_phase,
234
+ "entered_at": self.start_time,
235
+ "summary": "Session initialized"
236
+ }
237
+ ]
238
+ self.unlock_details: Optional[Dict[str, Any]] = None
239
+ self._restored_untrusted = False
240
+ self._sealing_authorized = False
241
+ self._red_team_receipt_key = secrets.token_bytes(32)
242
+ self._reviewed_change_id: Optional[str] = None
243
+
244
+ # System 3 Meta-Cognitive State
245
+ self.system3_causal_graphs: List[Dict[str, Any]] = []
246
+ self.system3_syntheses: List[Dict[str, Any]] = []
247
+ self.system3_gene_pools: List[Dict[str, Any]] = []
248
+ self.system3_axioms: List[Dict[str, Any]] = []
249
+ self.system3_reflections: List[Dict[str, Any]] = []
250
+ self.system3_orchestrations: List[Dict[str, Any]] = []
251
+ self.system3_hyperbolic_embeddings: List[Dict[str, Any]] = []
252
+ self.system3_kripke_verifications: List[Dict[str, Any]] = []
253
+ self.system3_active_inferences: List[Dict[str, Any]] = []
254
+ self.system3_proof_oracle_verifications: List[Dict[str, Any]] = []
255
+ self.active_free_energy: Optional[Dict[str, Any]] = None
256
+ self.active_kripke_safety: Optional[Dict[str, Any]] = None
257
+ self.active_biases: List[Dict[str, Any]] = []
258
+ self._disk_mtime: float = 0.0
259
+
260
+ # Unified Fable V2 Runtime instance
261
+ try:
262
+ from fable_v2.runtime import new_run
263
+ from fable_v2.protocol import TaskSpec
264
+ task = TaskSpec(
265
+ task_id=f"task_{self.session_id}",
266
+ objective=self.objective or "Fable Mode Deliberation",
267
+ definition_of_done=("Epistemic evidence verified",),
268
+ )
269
+ self.fable_run: Optional[Any] = new_run(session_id=self.session_id, task=task)
270
+ except Exception:
271
+ self.fable_run = None
272
+
273
+ # Autonomous Silent Self-Updater Trigger
274
+ if (
275
+ AutoUpdater is not None
276
+ and not os.environ.get("FABLE_DISABLE_AUTO_UPDATE")
277
+ and not os.environ.get("PYTEST_CURRENT_TEST")
278
+ and not os.environ.get("GITHUB_ACTIONS")
279
+ and not os.environ.get("CI")
280
+ ):
281
+ try:
282
+ AutoUpdater().trigger_silent_background_update()
283
+ except Exception as e:
284
+ logger.debug(f"Silent auto-updater background trigger failed on session init: {e}")
285
+
286
+ @property
287
+ def pacing_deadline_time(self) -> float:
288
+ """Wall-clock representation of the internal pacing deadline."""
289
+ return self._pacing_deadline_wall
290
+
291
+ @property
292
+ def deadline_time(self) -> float:
293
+ """Read-only wall-clock representation of the authority deadline."""
294
+ return self._authority_deadline_wall
295
+
296
+ def transition_to(self, new_state: Union[SessionState, str], rationale: str = "") -> SessionState:
297
+ """Transitions the FSM to a new state after validating strict state machine invariants.
298
+
299
+ Valid transitions:
300
+ - INIT -> DEEPTHINK_TIMELOCK (requires set_timer and/or set_goal_rubric)
301
+ - DEEPTHINK_TIMELOCK -> IMPLEMENTATION (requires execution unlocked)
302
+ - IMPLEMENTATION -> RED_TEAM_GATE (requires code written / file changes logged)
303
+ - RED_TEAM_GATE -> ARBITRATION
304
+ - ARBITRATION -> REMEDIATION_REQUIRED (if broken_count > 0)
305
+ - ARBITRATION -> SEALED (if broken_count == 0)
306
+ - REMEDIATION_REQUIRED -> ARBITRATION
307
+ - REMEDIATION_REQUIRED -> SEALED (when remediation verification succeeds with 0 breakages)
308
+ - SEALED -> EVOLVED (upon evolve_cortex consolidation)
309
+
310
+ Attempting any illegal jump (e.g. INIT directly to SEALED or IMPLEMENTATION) must raise ValueError.
311
+ """
312
+ if isinstance(new_state, str):
313
+ try:
314
+ target_state = SessionState(new_state)
315
+ except ValueError:
316
+ raise ValueError(f"Invalid SessionState: '{new_state}'. Allowed states: {[s.value for s in SessionState]}")
317
+ else:
318
+ target_state = new_state
319
+
320
+ allowed = VALID_TRANSITIONS.get(self.current_state, set())
321
+ if target_state not in allowed and target_state != self.current_state:
322
+ raise ValueError(
323
+ f"Illegal state transition from {self.current_state.value} to {target_state.value}. "
324
+ f"Allowed transitions from {self.current_state.value}: {[s.value for s in allowed]}"
325
+ )
326
+
327
+ if (
328
+ target_state == SessionState.SEALED
329
+ and self.current_state != SessionState.SEALED
330
+ and not self._sealing_authorized
331
+ ):
332
+ raise ValueError(
333
+ "Transition to SEALED requires a clean report validated by record_breakage_report()."
334
+ )
335
+
336
+ if self.current_state == SessionState.INIT and target_state == SessionState.DEEPTHINK_TIMELOCK:
337
+ timer_set = getattr(self, "_timer_set", False) or (self.pacing_budget_minutes != self.time_budget_minutes)
338
+ rubric_set = len(self.goal_rubrics) > 0
339
+ rationale_ok = any(k in rationale.lower() for k in ("timer", "rubric", "deepthink", "timelock", "set_timer", "set_goal_rubric"))
340
+ if not (timer_set or rubric_set or rationale_ok):
341
+ raise ValueError("Transition from INIT to DEEPTHINK_TIMELOCK requires set_timer and/or set_goal_rubric.")
342
+
343
+ elif self.current_state == SessionState.DEEPTHINK_TIMELOCK and target_state == SessionState.IMPLEMENTATION:
344
+ if self.execution_locked or not self.can_execute_code:
345
+ raise ValueError("Transition from DEEPTHINK_TIMELOCK to IMPLEMENTATION requires execution unlocked.")
346
+
347
+ elif self.current_state == SessionState.IMPLEMENTATION and target_state == SessionState.RED_TEAM_GATE:
348
+ has_changes = len(self.file_changes) > 0 or any(k in rationale.lower() for k in ("code", "file", "implement", "change", "written", "edit"))
349
+ if not has_changes:
350
+ raise ValueError("Transition from IMPLEMENTATION to RED_TEAM_GATE requires code written / file changes logged.")
351
+
352
+ elif self.current_state == SessionState.ARBITRATION and target_state == SessionState.SEALED:
353
+ if len(self.active_breakages) > 0:
354
+ raise ValueError(f"Transition from ARBITRATION to SEALED rejected: {len(self.active_breakages)} breakages remain unresolved.")
355
+
356
+ elif self.current_state == SessionState.REMEDIATION_REQUIRED and target_state == SessionState.SEALED:
357
+ if len(self.active_breakages) > 0:
358
+ raise ValueError(f"Transition from REMEDIATION_REQUIRED to SEALED rejected: {len(self.active_breakages)} breakages remain unresolved.")
359
+
360
+ self.current_state = target_state
361
+ return self.current_state
362
+
363
+ def set_timer(self, time_budget_minutes: float) -> Dict[str, Any]:
364
+ """Set an agent pacing timer without changing the authority deadline.
365
+
366
+ This is deliberately a sub-timer: an agent may choose to pace itself
367
+ for 20 minutes inside an 80-minute session, but expiry of this timer
368
+ never unlocks execution. Only the immutable outer deadline can do that.
369
+ """
370
+ pacing_minutes = _validate_time_budget(time_budget_minutes)
371
+ self.pacing_budget_minutes = pacing_minutes
372
+ self.pacing_budget_seconds = pacing_minutes * 60.0
373
+ now_wall = self._wall_clock()
374
+ now_monotonic = self._monotonic_clock()
375
+ self._pacing_started_wall = now_wall
376
+ self._pacing_started_monotonic = now_monotonic
377
+ self._pacing_deadline_wall = min(
378
+ self.deadline_time,
379
+ now_wall + self.pacing_budget_seconds
380
+ )
381
+ self._pacing_deadline_monotonic = min(
382
+ self._authority_deadline_monotonic,
383
+ now_monotonic + self.pacing_budget_seconds
384
+ )
385
+ self._timer_set = True
386
+ if self.current_state == SessionState.INIT:
387
+ self.transition_to(SessionState.DEEPTHINK_TIMELOCK, "Pacing timer configured")
388
+ return self.get_telemetry()
389
+
390
+ def _authority_remaining_seconds(self) -> float:
391
+ """Use monotonic time while the process is alive to resist clock rollback."""
392
+ return self._authority_deadline_monotonic - self._monotonic_clock()
393
+
394
+ def _pacing_remaining_seconds(self) -> float:
395
+ return self._pacing_deadline_monotonic - self._monotonic_clock()
396
+
397
+ def _gate_report(self) -> Dict[str, Any]:
398
+ """Return auditable gate state instead of relying on raw item counts."""
399
+ proven_items = [i for i in self.epistemic_ledger if i.get("tag") == "PROVEN" and not i.get("_restored_untrusted")]
400
+ proven_with_evidence = [i for i in proven_items if str(i.get("evidence", "")).strip()]
401
+ invariants_with_proof = [
402
+ inv for inv in self.invariants if not inv.get("_restored_untrusted") and str(inv.get("proof_or_rationale", "")).strip()
403
+ ]
404
+ # Restored phase is reset to Phase 1; subsequent in-process
405
+ # transitions are legitimate fresh gates.
406
+ phase_index = PHASE_INDEX_MAP.get(self.active_phase, 1)
407
+ checks = {
408
+ "two_proven_evidence_items": len(proven_with_evidence) >= 2,
409
+ "one_proved_invariant": len(invariants_with_proof) >= 1,
410
+ "adversarial_phase_reached": phase_index >= 3,
411
+ }
412
+ return {
413
+ "ready": all(checks.values()),
414
+ "checks": checks,
415
+ "proven_with_evidence": len(proven_with_evidence),
416
+ "invariants_with_proof": len(invariants_with_proof),
417
+ }
418
+
419
+ def get_telemetry(self) -> Dict[str, Any]:
420
+ """Calculates runtime authority, pacing, and cognitive-gate telemetry."""
421
+ now = self._wall_clock()
422
+ now_monotonic = self._monotonic_clock()
423
+ elapsed_seconds = max(0.0, now - self.start_time)
424
+ pacing_elapsed_seconds = max(0.0, now_monotonic - self._pacing_started_monotonic)
425
+ authority_remaining = self._authority_remaining_seconds()
426
+ pacing_remaining = self._pacing_remaining_seconds()
427
+ pacing_ratio = pacing_elapsed_seconds / self.pacing_budget_seconds
428
+
429
+ proven_count = sum(1 for item in self.epistemic_ledger if item.get("tag") == "PROVEN")
430
+ hypothesis_count = sum(1 for item in self.epistemic_ledger if item.get("tag") == "HYPOTHESIS")
431
+ unknown_count = sum(1 for item in self.epistemic_ledger if item.get("tag") == "UNKNOWN")
432
+
433
+ return {
434
+ "session_name": self.session_name,
435
+ "session_id": self.session_id,
436
+ "objective": self.objective,
437
+ "start_time": self.start_time,
438
+ "time_budget_minutes": self.time_budget_minutes,
439
+ "time_budget_seconds": self.time_budget_seconds,
440
+ "deadline_time": self.deadline_time,
441
+ "authority_deadline_time": self.deadline_time,
442
+ "elapsed_seconds": round(elapsed_seconds, 2),
443
+ "remaining_seconds": round(authority_remaining, 2),
444
+ "elapsed_formatted": self._format_duration(elapsed_seconds),
445
+ "remaining_formatted": self._format_duration(max(0.0, authority_remaining)),
446
+ "authority_remaining_seconds": round(authority_remaining, 2),
447
+ "authority_remaining_formatted": self._format_duration(max(0.0, authority_remaining)),
448
+ "pacing_budget_minutes": self.pacing_budget_minutes,
449
+ "pacing_started_time": self._pacing_started_wall,
450
+ "pacing_deadline_time": self._pacing_deadline_wall,
451
+ "pacing_remaining_seconds": round(pacing_remaining, 2),
452
+ "pacing_remaining_formatted": self._format_duration(max(0.0, pacing_remaining)),
453
+ "pacing_ratio": round(pacing_ratio, 4),
454
+ "pacing_percentage": f"{pacing_ratio * 100.0:.1f}%",
455
+ "active_phase": self.active_phase,
456
+ "phase_index": PHASE_INDEX_MAP.get(self.active_phase, 1),
457
+ "total_phases": len(PHASES),
458
+ "execution_locked": self.execution_locked,
459
+ "can_execute_code": self.can_execute_code,
460
+ "silent_deliberation_active": self.execution_locked,
461
+ "current_state": self.current_state.value if isinstance(self.current_state, SessionState) else str(self.current_state),
462
+ "iteration_count": self.iteration_count,
463
+ "remediation_attempt_count": self.remediation_attempt_count,
464
+ "remediation_started_at": self.remediation_started_at,
465
+ "active_breakages_count": len(self.active_breakages),
466
+ "active_breakages": self.active_breakages,
467
+ "remediation_history": self.remediation_history,
468
+ "epistemic_counts": {
469
+ "proven": proven_count,
470
+ "hypothesis": hypothesis_count,
471
+ "unknown": unknown_count,
472
+ "total": len(self.epistemic_ledger)
473
+ },
474
+ "invariants_count": len(self.invariants),
475
+ "refinement_count": len(self.refinement_cycles),
476
+ "refinement_cycles": self.refinement_cycles,
477
+ "file_changes_count": len(self.file_changes),
478
+ "visual_mockups": self.visual_mockups,
479
+ "proof_receipts_count": len(self.proof_receipts),
480
+ "goal_rubrics_count": len(self.goal_rubrics),
481
+ "automation_pipelines_count": len(self.automation_pipelines),
482
+ "latest_goal_rubric": self.goal_rubrics[-1] if self.goal_rubrics else None,
483
+ "velocity_profile": GLOBAL_VELOCITY_PROFILER.get_velocity_profile(),
484
+ "cognitive_gates": self._gate_report(),
485
+ "unlock_details": self.unlock_details,
486
+ "system3_cognitive_state": {
487
+ "free_energy_f": self.active_free_energy.get("variational_free_energy_f", 1.25) if self.active_free_energy else 1.25,
488
+ "complexity_kl": self.active_free_energy.get("complexity_kl", 0.35) if self.active_free_energy else 0.35,
489
+ "accuracy_log_likelihood": self.active_free_energy.get("accuracy_log_likelihood", -0.90) if self.active_free_energy else -0.90,
490
+ "kripke_safety_invariant": "AG(safe) -> True" if (self.active_kripke_safety.get("is_satisfied", True) if self.active_kripke_safety else True) else "AG(safe) -> VIOLATED",
491
+ "kripke_safety_verified": self.active_kripke_safety.get("is_satisfied", True) if self.active_kripke_safety else True,
492
+ "active_biases_count": len(self.active_biases),
493
+ "active_biases": self.active_biases,
494
+ "contradiction_density": round(sum(len(s.get("resolved_contradictions", [])) for s in self.system3_syntheses) / max(1, len(self.system3_syntheses)), 2) if self.system3_syntheses else 0.0,
495
+ "hyperbolic_metric": {
496
+ "embeddings_count": len(self.system3_hyperbolic_embeddings),
497
+ "curvature": 1.0,
498
+ "status": "CONVERGED_POINCARE_BALL" if self.system3_hyperbolic_embeddings else "INITIALIZED",
499
+ },
500
+ },
501
+ "system3_counts": {
502
+ "causal_graphs": len(self.system3_causal_graphs),
503
+ "syntheses": len(self.system3_syntheses),
504
+ "gene_pools": len(self.system3_gene_pools),
505
+ "axioms": len(self.system3_axioms),
506
+ "reflections": len(self.system3_reflections),
507
+ "orchestrations": len(self.system3_orchestrations),
508
+ "hyperbolic_embeddings": len(self.system3_hyperbolic_embeddings),
509
+ "kripke_verifications": len(self.system3_kripke_verifications),
510
+ "active_inferences": len(self.system3_active_inferences),
511
+ "proof_oracle_verifications": len(self.system3_proof_oracle_verifications),
512
+ }
513
+ }
514
+
515
+ @staticmethod
516
+ def _format_duration(seconds: float) -> str:
517
+ sec = int(abs(seconds))
518
+ hours = sec // 3600
519
+ mins = (sec % 3600) // 60
520
+ s = sec % 60
521
+ if hours > 0:
522
+ return f"{hours}h {mins}m {s}s"
523
+ elif mins > 0:
524
+ return f"{mins}m {s}s"
525
+ else:
526
+ return f"{s}s"
527
+
528
+ def advance_phase(self, next_phase: str, phase_summary: str) -> Dict[str, Any]:
529
+ """Advances the session to the requested phase and records history."""
530
+ if any(term in next_phase.upper() for term in ("SEALED", "EVOLVED")):
531
+ raise ValueError(
532
+ "Invalid phase transition: Cannot advance directly to SEALED or EVOLVED via advance_phase."
533
+ )
534
+
535
+ matched_phase = None
536
+ for p in PHASES:
537
+ if next_phase.strip().lower() == p.lower() or next_phase.strip().lower() in p.lower():
538
+ matched_phase = p
539
+ break
540
+
541
+ if not matched_phase:
542
+ valid_list = "\n".join([f"- {p}" for p in PHASES])
543
+ raise ValueError(
544
+ f"Invalid phase '{next_phase}'. Must be one of:\n{valid_list}"
545
+ )
546
+
547
+ current_phase_idx = PHASE_INDEX_MAP.get(self.active_phase, 1)
548
+ target_phase_idx = PHASE_INDEX_MAP[matched_phase]
549
+ if target_phase_idx != current_phase_idx + 1:
550
+ raise ValueError(
551
+ f"Invalid phase transition: move one phase at a time from "
552
+ f"Phase {current_phase_idx} to Phase {current_phase_idx + 1}."
553
+ )
554
+
555
+ if target_phase_idx >= 4 and self.current_state in (SessionState.INIT, SessionState.DEEPTHINK_TIMELOCK):
556
+ raise ValueError(
557
+ f"Cannot advance to {matched_phase}: Implementation gate not reached (current state: {self.current_state.value}). Execution must be unlocked."
558
+ )
559
+ if target_phase_idx >= 5 and self.current_state == SessionState.IMPLEMENTATION:
560
+ raise ValueError(
561
+ f"Cannot advance to {matched_phase}: Red team gate and arbitration have not been completed (current state: {self.current_state.value}). Skipping red team gates is strictly prohibited."
562
+ )
563
+
564
+ now = self._wall_clock()
565
+ self.active_phase = matched_phase
566
+ self.phase_history.append({
567
+ "phase": matched_phase,
568
+ "entered_at": now,
569
+ "summary": phase_summary
570
+ })
571
+
572
+ # Run System 3 Executive bias detection & reflection
573
+ from fable_v2.system3 import (
574
+ CognitiveBiasDetector,
575
+ ActiveInferenceEngine,
576
+ Policy,
577
+ create_default_architecture_pomdp,
578
+ KripkeStructure,
579
+ KripkeModelChecker,
580
+ )
581
+ detector = CognitiveBiasDetector()
582
+ findings = detector.audit_session({
583
+ "epistemic_ledger": self.epistemic_ledger,
584
+ "refinement_cycles": self.refinement_cycles,
585
+ "phase_history": self.phase_history,
586
+ "invariants": self.invariants,
587
+ })
588
+ self.active_biases = [f.to_dict() for f in findings]
589
+ if findings:
590
+ self.system3_reflections.append({
591
+ "phase": matched_phase,
592
+ "findings": self.active_biases,
593
+ "timestamp": now,
594
+ })
595
+
596
+ # Update live Free Energy F
597
+ pomdp_model = create_default_architecture_pomdp()
598
+ fe_engine = ActiveInferenceEngine(pomdp_model)
599
+ proven_count = sum(1 for i in self.epistemic_ledger if i.get("tag") == "PROVEN")
600
+ obs = "HIGH_THROUGHPUT_CLEAN" if proven_count >= 2 else "LOCK_CONTENTION_WARN"
601
+ fe_policies = [Policy(policy_id=f"p_{act}", actions=[act]) for act in pomdp_model.actions]
602
+ fe_report = fe_engine.select_action(obs, fe_policies)
603
+ self.active_free_energy = {
604
+ "variational_free_energy_f": round(fe_report.variational_free_energy_f, 4),
605
+ "complexity_kl": round(fe_report.complexity_kl, 4),
606
+ "accuracy_log_likelihood": round(fe_report.accuracy_log_likelihood, 4),
607
+ "selected_action": fe_report.selected_action,
608
+ "observation": obs,
609
+ "phase": matched_phase,
610
+ "timestamp": now,
611
+ }
612
+ self.system3_active_inferences.append(self.active_free_energy)
613
+
614
+ # Update Kripke Safety Invariant
615
+ kripke = KripkeStructure()
616
+ kripke.add_world("w0", propositions={"entered", "safe"})
617
+ kripke.add_world("w_phase", propositions={f"phase_{target_phase_idx}", "safe"})
618
+ kripke.add_transition("w0", "w_phase")
619
+ kripke.add_transition("w_phase", "w_phase")
620
+ checker = KripkeModelChecker(kripke)
621
+ k_res = checker.check("AG(safe)", "w0")
622
+ self.active_kripke_safety = {
623
+ "formula": "AG(safe)",
624
+ "is_satisfied": k_res.is_satisfied,
625
+ "active_phase": matched_phase,
626
+ }
627
+
628
+ return self.get_telemetry()
629
+
630
+ def log_epistemic_item(self, tag: str, claim: str, evidence: Optional[str] = None) -> Dict[str, Any]:
631
+ """Logs an epistemic fact/hypothesis/unknown with structured tracking and evidence validation."""
632
+ tag_upper = tag.strip().upper()
633
+ if tag_upper not in ("PROVEN", "HYPOTHESIS", "UNKNOWN"):
634
+ raise ValueError(f"Invalid epistemic tag '{tag}'. Must be 'PROVEN', 'HYPOTHESIS', or 'UNKNOWN'.")
635
+
636
+ if not claim or not claim.strip():
637
+ raise ValueError("Claim description cannot be empty.")
638
+
639
+ proof_receipt = None
640
+ from fable_v2.proof_engine import DeterministicProofValidator
641
+ validator = DeterministicProofValidator()
642
+ if tag_upper == "PROVEN":
643
+ if validator.is_tautological(claim):
644
+ raise ValueError(
645
+ f"Invalid claim '{claim}': PROVEN claims must not be tautological or generic (e.g. 'tested', 'it works'). "
646
+ f"State a substantive, testable system property or measurement."
647
+ )
648
+ if not str(evidence or "").strip():
649
+ raise ValueError("PROVEN claims require concrete evidence (file, command output, test, or URL).")
650
+ valid, reason, rcpt = validator.validate_proven_claim(claim, str(evidence))
651
+ if not valid:
652
+ raise ValueError(f"Epistemic Evidence Validation Failed: {reason}")
653
+ proof_receipt = rcpt
654
+ if rcpt:
655
+ self.proof_receipts.append(rcpt)
656
+
657
+ item_id = f"epi_{len(self.epistemic_ledger) + 1:03d}"
658
+ item = {
659
+ "id": item_id,
660
+ "tag": tag_upper,
661
+ "claim": claim.strip(),
662
+ "evidence": (evidence or "").strip(),
663
+ "proof_receipt": proof_receipt,
664
+ "timestamp": self._wall_clock(),
665
+ "phase": self.active_phase
666
+ }
667
+ self.epistemic_ledger.append(item)
668
+ return item
669
+
670
+ def record_invariant(
671
+ self,
672
+ invariant_name: str,
673
+ formal_statement: str,
674
+ proof_or_rationale: str,
675
+ domain: str = "architecture"
676
+ ) -> Dict[str, Any]:
677
+ """Records a domain invariant with formal statement and proof."""
678
+ dom_clean = domain.strip().lower()
679
+ if dom_clean not in ("architecture", "design", "coding"):
680
+ dom_clean = "architecture"
681
+
682
+ from fable_v2.proof_engine import DeterministicProofValidator
683
+ validator = DeterministicProofValidator()
684
+ valid, reason, rcpt = validator.validate_invariant(invariant_name, formal_statement, proof_or_rationale)
685
+ if not valid:
686
+ raise ValueError(f"Invariant Validation Failed: {reason}")
687
+
688
+ inv_id = f"inv_{len(self.invariants) + 1:03d}"
689
+ inv = {
690
+ "id": inv_id,
691
+ "name": invariant_name.strip(),
692
+ "domain": dom_clean,
693
+ "formal_statement": formal_statement.strip(),
694
+ "proof_or_rationale": (proof_or_rationale or "").strip(),
695
+ "proof_receipt": rcpt,
696
+ "timestamp": self._wall_clock(),
697
+ "phase": self.active_phase
698
+ }
699
+ if rcpt:
700
+ self.proof_receipts.append(rcpt)
701
+ self.invariants.append(inv)
702
+ return inv
703
+
704
+ def track_file_change(
705
+ self,
706
+ file_path: str,
707
+ change_type: str,
708
+ diff_summary: str,
709
+ rationale: Optional[str] = None,
710
+ affected_invariants: Optional[Union[List[str], str]] = None
711
+ ) -> Dict[str, Any]:
712
+ """Tracks file mutations (modified, created, deleted, slated) with automatic SHA256 hashing."""
713
+ if not file_path or not str(file_path).strip():
714
+ raise ValueError("file_path cannot be empty.")
715
+ c_type = str(change_type).strip().lower()
716
+ if c_type not in ("modified", "created", "deleted", "slated"):
717
+ raise ValueError(f"Invalid change_type '{change_type}'. Must be 'modified', 'created', 'deleted', or 'slated'.")
718
+ if not diff_summary or not str(diff_summary).strip():
719
+ raise ValueError("diff_summary cannot be empty.")
720
+
721
+ p = Path(file_path)
722
+ if not p.is_absolute():
723
+ p = Path.cwd() / p
724
+ sha256 = None
725
+ if p.is_file():
726
+ try:
727
+ sha256 = hashlib.sha256(p.read_bytes()).hexdigest()
728
+ except Exception:
729
+ sha256 = None
730
+
731
+ invariants_list: List[str] = []
732
+ if affected_invariants:
733
+ if isinstance(affected_invariants, list):
734
+ invariants_list = [str(x) for x in affected_invariants]
735
+ else:
736
+ invariants_list = [str(affected_invariants)]
737
+
738
+ entry = {
739
+ "file_path": str(file_path).strip(),
740
+ "change_type": c_type,
741
+ "diff_summary": str(diff_summary).strip(),
742
+ "rationale": (rationale or "").strip(),
743
+ "affected_invariants": invariants_list,
744
+ "sha256": sha256,
745
+ "timestamp": self._wall_clock(),
746
+ "phase": self.active_phase,
747
+ }
748
+ self.file_changes.append(entry)
749
+ return entry
750
+
751
+ def record_visual_mockups(
752
+ self,
753
+ mockups: Union[List[Dict[str, Any]], str],
754
+ selected_concept: Optional[str] = None
755
+ ) -> Dict[str, Any]:
756
+ """Records visual mockup concepts, palettes, typography, and layout coordinate data."""
757
+ if isinstance(mockups, str):
758
+ try:
759
+ parsed_mockups = json.loads(mockups)
760
+ except Exception as e:
761
+ raise ValueError(f"Failed to parse mockups JSON string: {e}")
762
+ elif isinstance(mockups, list):
763
+ parsed_mockups = mockups
764
+ else:
765
+ raise ValueError(f"mockups must be a list of concept dictionaries, got {type(mockups).__name__}")
766
+
767
+ if not isinstance(parsed_mockups, list) or len(parsed_mockups) == 0:
768
+ raise ValueError("mockups list cannot be empty. Provide 5-6 architectural concept mockups.")
769
+
770
+ self.visual_mockups = {
771
+ "mockups": parsed_mockups,
772
+ "selected_concept": (selected_concept or "").strip() if selected_concept else (parsed_mockups[0].get("concept_name") if parsed_mockups else None),
773
+ "recorded_at": self._wall_clock(),
774
+ "phase": self.active_phase,
775
+ }
776
+ return self.visual_mockups
777
+
778
+ def set_goal_rubric(
779
+ self,
780
+ task_objective: str,
781
+ criteria: Union[List[Dict[str, Any]], str],
782
+ target_score: float = 0.95,
783
+ rubric_id: Optional[str] = None,
784
+ metadata: Optional[Dict[str, Any]] = None
785
+ ) -> Dict[str, Any]:
786
+ """Registers a goal scoring rubric contract with target score threshold (default >= 0.95)."""
787
+ if not task_objective or not str(task_objective).strip():
788
+ task_objective = self.objective or "Task Objective"
789
+
790
+ target_score_val = float(target_score)
791
+ if not 0.0 <= target_score_val <= 1.0:
792
+ raise ValueError(f"target_score must be between 0.0 and 1.0, got {target_score_val}")
793
+
794
+ parsed_items: List[Dict[str, Any]] = []
795
+ raw_items: Any = criteria
796
+ if isinstance(raw_items, str):
797
+ try:
798
+ raw_items = json.loads(raw_items)
799
+ except Exception:
800
+ lines = [l.strip() for l in raw_items.splitlines() if l.strip()]
801
+ raw_items = [{"pointer_id": f"PTR-{idx+1:02d}", "description": l} for idx, l in enumerate(lines)]
802
+
803
+ if isinstance(raw_items, dict):
804
+ dict_items = []
805
+ for k, v in raw_items.items():
806
+ if isinstance(v, dict):
807
+ item_d = dict(v)
808
+ item_d.setdefault("pointer_id", k)
809
+ dict_items.append(item_d)
810
+ else:
811
+ dict_items.append({"pointer_id": k, "description": str(v)})
812
+ raw_items = dict_items
813
+
814
+ if not isinstance(raw_items, list) or not raw_items:
815
+ raise ValueError("criteria must be a non-empty list of criteria items/pointers or JSON string.")
816
+
817
+ for idx, item in enumerate(raw_items):
818
+ if isinstance(item, dict):
819
+ p_id = str(item.get("pointer_id") or f"PTR-{idx+1:02d}").strip()
820
+ desc = str(item.get("description") or item.get("desc") or item.get("name") or p_id).strip()
821
+ weight = float(item.get("weight", 1.0))
822
+ verifier = str(item.get("verifier_command", "")).strip()
823
+ satisfied = bool(item.get("satisfied", False))
824
+ score = float(item.get("score", 1.0 if satisfied else 0.0))
825
+ receipt_id = str(item.get("evidence_receipt_id", "")).strip()
826
+ meta = dict(item.get("metadata") or {})
827
+ else:
828
+ p_id = f"PTR-{idx+1:02d}"
829
+ desc = str(item).strip()
830
+ weight = 1.0
831
+ verifier = ""
832
+ satisfied = False
833
+ score = 0.0
834
+ receipt_id = ""
835
+ meta = {}
836
+
837
+ evidence_type = str(
838
+ (item.get("evidence_type") if isinstance(item, dict) else "")
839
+ or meta.get("evidence_type")
840
+ or ("verifier" if verifier else "")
841
+ ).strip()
842
+ evidence_phase = str(
843
+ (item.get("evidence_phase") if isinstance(item, dict) else "")
844
+ or meta.get("evidence_phase")
845
+ or (self.active_phase if verifier else "")
846
+ ).strip()
847
+ parsed_item = {
848
+ "pointer_id": p_id,
849
+ "description": desc,
850
+ "weight": max(0.0, weight),
851
+ "verifier_command": verifier,
852
+ "satisfied": satisfied,
853
+ "score": max(0.0, min(1.0, score)),
854
+ "evidence_receipt_id": receipt_id,
855
+ "evidence_type": evidence_type,
856
+ "evidence_phase": evidence_phase,
857
+ "metadata": meta
858
+ }
859
+ self._annotate_rubric_item_trust(parsed_item)
860
+ parsed_items.append(parsed_item)
861
+
862
+ total_weight = sum(it["weight"] for it in parsed_items)
863
+ if total_weight > 0:
864
+ weighted_sum = sum(it["score"] * it["weight"] for it in parsed_items)
865
+ current_score = round(weighted_sum / total_weight, 4)
866
+ else:
867
+ current_score = 0.0
868
+
869
+ has_untrusted_items = any(
870
+ bool(it.get("satisfied"))
871
+ and (it.get("metadata") or {}).get("evidence_trust") == "untrusted"
872
+ for it in parsed_items
873
+ )
874
+ status = "achieved" if current_score >= target_score_val and not has_untrusted_items else "pending"
875
+ r_id = (rubric_id or f"rubric_{self.session_name}_{len(self.goal_rubrics) + 1}").strip()
876
+
877
+ rubric_entry = {
878
+ "rubric_id": r_id,
879
+ "session_id": self.session_id,
880
+ "task_objective": str(task_objective).strip(),
881
+ "target_score": target_score_val,
882
+ "items": parsed_items,
883
+ "current_score": current_score,
884
+ "status": status,
885
+ "metadata": metadata or {},
886
+ "created_phase": self.active_phase,
887
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self._wall_clock()))
888
+ }
889
+
890
+ existing_idx = next((i for i, r in enumerate(self.goal_rubrics) if r.get("rubric_id") == r_id), None)
891
+ if existing_idx is not None:
892
+ self.goal_rubrics[existing_idx] = rubric_entry
893
+ else:
894
+ self.goal_rubrics.append(rubric_entry)
895
+
896
+ if self.current_state == SessionState.INIT:
897
+ self.transition_to(SessionState.DEEPTHINK_TIMELOCK, "Goal rubric registered")
898
+
899
+ return rubric_entry
900
+
901
+ def evaluate_goal_rubric(
902
+ self,
903
+ rubric_id: Optional[str] = None,
904
+ item_evaluations: Optional[Union[List[Dict[str, Any]], Dict[str, Any], str]] = None
905
+ ) -> Dict[str, Any]:
906
+ """Evaluates rubric criteria items, updates scores, and determines goal attainment status."""
907
+ if not self.goal_rubrics:
908
+ raise ValueError("No goal rubric registered in this session. Call set_goal_rubric first.")
909
+
910
+ rubric: Optional[Dict[str, Any]] = None
911
+ if rubric_id:
912
+ rubric = next((r for r in self.goal_rubrics if r.get("rubric_id") == str(rubric_id).strip()), None)
913
+ if not rubric:
914
+ raise ValueError(f"Rubric with id '{rubric_id}' not found.")
915
+ else:
916
+ rubric = self.goal_rubrics[-1]
917
+
918
+ evals_list: List[Dict[str, Any]] = []
919
+ if item_evaluations is not None:
920
+ raw_evals = item_evaluations
921
+ if isinstance(raw_evals, str):
922
+ try:
923
+ raw_evals = json.loads(raw_evals)
924
+ except Exception:
925
+ raw_evals = []
926
+ if isinstance(raw_evals, dict):
927
+ for k, v in raw_evals.items():
928
+ if isinstance(v, dict):
929
+ ed = dict(v)
930
+ ed.setdefault("pointer_id", k)
931
+ evals_list.append(ed)
932
+ elif isinstance(v, (int, float)):
933
+ evals_list.append({"pointer_id": k, "score": float(v), "satisfied": float(v) >= 1.0})
934
+ elif isinstance(v, bool):
935
+ evals_list.append({"pointer_id": k, "satisfied": v, "score": 1.0 if v else 0.0})
936
+ elif isinstance(raw_evals, list):
937
+ evals_list = [dict(x) if isinstance(x, dict) else {"pointer_id": str(x), "satisfied": True, "score": 1.0} for x in raw_evals]
938
+
939
+ candidate = copy.deepcopy(rubric)
940
+ for ev in evals_list:
941
+ p_id = str(ev.get("pointer_id", "")).strip()
942
+ for it in candidate["items"]:
943
+ if it.get("pointer_id") == p_id:
944
+ if "satisfied" in ev:
945
+ it["satisfied"] = bool(ev["satisfied"])
946
+ if "score" in ev:
947
+ it["score"] = max(0.0, min(1.0, float(ev["score"])))
948
+ elif "satisfied" in ev:
949
+ it["score"] = 1.0 if it["satisfied"] else 0.0
950
+ if "evidence_receipt_id" in ev:
951
+ it["evidence_receipt_id"] = str(ev["evidence_receipt_id"]).strip()
952
+ if "verifier_command" in ev:
953
+ it["verifier_command"] = str(ev["verifier_command"]).strip()
954
+ if it["verifier_command"] and not str(it.get("evidence_type", "")).strip():
955
+ it["evidence_type"] = "verifier"
956
+ if it["verifier_command"] and not str(it.get("evidence_phase", "")).strip():
957
+ it["evidence_phase"] = self.active_phase
958
+ if "evidence_type" in ev:
959
+ it["evidence_type"] = str(ev["evidence_type"]).strip()
960
+ if "evidence_phase" in ev:
961
+ it["evidence_phase"] = str(ev["evidence_phase"]).strip()
962
+ if "metadata" in ev and isinstance(ev["metadata"], dict):
963
+ it.setdefault("metadata", {}).update(ev["metadata"])
964
+
965
+ for item in candidate["items"]:
966
+ self._annotate_rubric_item_trust(item)
967
+
968
+ total_weight = sum(it.get("weight", 1.0) for it in candidate["items"])
969
+ if total_weight > 0:
970
+ weighted_sum = sum(
971
+ float(it.get("score", 1.0 if it.get("satisfied") else 0.0))
972
+ * float(it.get("weight", 1.0))
973
+ for it in candidate["items"]
974
+ )
975
+ current_score = round(weighted_sum / total_weight, 4)
976
+ else:
977
+ current_score = 0.0
978
+
979
+ candidate["current_score"] = current_score
980
+ target_score = float(candidate.get("target_score", 0.95))
981
+ has_untrusted_items = any(
982
+ bool(it.get("satisfied"))
983
+ and (it.get("metadata") or {}).get("evidence_trust") == "untrusted"
984
+ for it in candidate["items"]
985
+ )
986
+ candidate["status"] = (
987
+ "achieved" if current_score >= target_score and not has_untrusted_items else "in_progress"
988
+ )
989
+ candidate["last_evaluated_at"] = time.strftime(
990
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(self._wall_clock())
991
+ )
992
+ rubric.clear()
993
+ rubric.update(candidate)
994
+ return rubric
995
+
996
+ def get_goal_rubric(self, rubric_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
997
+ """Returns the specified goal rubric or the latest active rubric."""
998
+ if not self.goal_rubrics:
999
+ return None
1000
+ if rubric_id:
1001
+ return next((r for r in self.goal_rubrics if r.get("rubric_id") == str(rubric_id).strip()), None)
1002
+ return self.goal_rubrics[-1]
1003
+
1004
+ def register_automation_pipeline(
1005
+ self,
1006
+ name: str,
1007
+ pipeline_type: str = "closed_loop",
1008
+ generator_command: str = "",
1009
+ evaluator_command: str = "",
1010
+ target_threshold: float = 0.95,
1011
+ max_iterations: int = 10,
1012
+ metadata: Optional[Dict[str, Any]] = None
1013
+ ) -> Dict[str, Any]:
1014
+ """Registers an autonomous pipeline loop spec (generate -> evaluate -> iterate)."""
1015
+ if not name or not str(name).strip():
1016
+ raise ValueError("Pipeline 'name' cannot be empty.")
1017
+
1018
+ pipe_id = f"pipeline_{self.session_name}_{len(self.automation_pipelines) + 1}"
1019
+ spec = {
1020
+ "pipeline_id": pipe_id,
1021
+ "session_id": self.session_id,
1022
+ "name": str(name).strip(),
1023
+ "pipeline_type": (pipeline_type or "closed_loop").strip(),
1024
+ "generator_command": (generator_command or "").strip(),
1025
+ "evaluator_command": (evaluator_command or "").strip(),
1026
+ "target_threshold": max(0.0, min(1.0, float(target_threshold))),
1027
+ "max_iterations": max(1, int(max_iterations)),
1028
+ "status": "active",
1029
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self._wall_clock())),
1030
+ "metadata": metadata or {}
1031
+ }
1032
+ self.automation_pipelines.append(spec)
1033
+ return spec
1034
+
1035
+ @staticmethod
1036
+ def _canonical_receipt_payload(value: Dict[str, Any]) -> bytes:
1037
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
1038
+
1039
+ def _attack_vector_results(self, report_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
1040
+ supplied = report_data.get("attack_vector_results")
1041
+ if isinstance(supplied, dict):
1042
+ return {str(key): dict(value) for key, value in supplied.items() if isinstance(value, dict)}
1043
+ results = {vector: {"passed": True, "finding_count": 0} for vector in RED_TEAM_ATTACK_VECTORS}
1044
+ for finding in report_data.get("findings", []):
1045
+ vector = finding.get("vector", "") if isinstance(finding, dict) else getattr(finding, "vector", "")
1046
+ vector = getattr(vector, "value", vector)
1047
+ if vector in results:
1048
+ results[vector]["finding_count"] += 1
1049
+ if bool(finding.get("broken", False) if isinstance(finding, dict) else getattr(finding, "broken", False)):
1050
+ results[vector]["passed"] = False
1051
+ return results
1052
+
1053
+ def issue_red_team_receipt(
1054
+ self,
1055
+ report_data: Dict[str, Any],
1056
+ reviewed_change_id: str,
1057
+ origin: str = "red_team_swarm",
1058
+ ) -> Dict[str, Any]:
1059
+ """Issue a process-local attestation for a complete, clean Red-Team report."""
1060
+ if int(report_data.get("broken_count", 0)) != 0:
1061
+ raise ValueError("Only a zero-breakage report can receive a red-team receipt.")
1062
+ change_id = str(reviewed_change_id or "").strip()
1063
+ if not change_id or len(change_id) > 256:
1064
+ raise ValueError("A bounded immutable reviewed_change_id is required.")
1065
+ vectors = self._attack_vector_results(report_data)
1066
+ if set(vectors) != set(RED_TEAM_ATTACK_VECTORS) or any(
1067
+ result.get("passed") is not True for result in vectors.values()
1068
+ ):
1069
+ raise ValueError("A clean receipt requires passing results for all five attack vectors.")
1070
+ issued_at = float(self._wall_clock())
1071
+ report_body = {
1072
+ key: value
1073
+ for key, value in report_data.items()
1074
+ if key not in ("red_team_receipt", "receipt", "attestation")
1075
+ }
1076
+ receipt = {
1077
+ "receipt_type": "red_team_remediation",
1078
+ "receipt_id": f"rt_rcpt_{secrets.token_hex(12)}",
1079
+ "session_id": self.session_id,
1080
+ "report_id": str(report_data.get("report_id", "")).strip(),
1081
+ "report_origin": str(origin).strip(),
1082
+ "reviewed_change_id": change_id,
1083
+ "attack_vector_results": vectors,
1084
+ "issued_at": issued_at,
1085
+ "expires_at": issued_at + MAX_REMEDIATION_SECONDS,
1086
+ "report_digest": hashlib.sha256(self._canonical_receipt_payload(report_body)).hexdigest(),
1087
+ }
1088
+ receipt["signature"] = hmac.new(
1089
+ self._red_team_receipt_key,
1090
+ self._canonical_receipt_payload(receipt),
1091
+ hashlib.sha256,
1092
+ ).hexdigest()
1093
+ return receipt
1094
+
1095
+ def _validate_red_team_receipt(self, report_data: Dict[str, Any]) -> None:
1096
+ receipt = report_data.get("red_team_receipt")
1097
+ if not isinstance(receipt, dict):
1098
+ raise ValueError("Zero-breakage reports require an authenticated red-team receipt.")
1099
+ required = {
1100
+ "receipt_type", "receipt_id", "session_id", "report_id", "report_origin",
1101
+ "reviewed_change_id", "attack_vector_results", "issued_at", "expires_at",
1102
+ "report_digest", "signature",
1103
+ }
1104
+ if not required.issubset(receipt):
1105
+ raise ValueError("Red-team receipt is incomplete.")
1106
+ if receipt.get("receipt_type") != "red_team_remediation" or receipt.get("session_id") != self.session_id:
1107
+ raise ValueError("Red-team receipt is not bound to this session.")
1108
+ if receipt.get("report_id") != str(report_data.get("report_id", "")).strip():
1109
+ raise ValueError("Red-team receipt is not bound to this report.")
1110
+ if receipt.get("report_origin") != "red_team_swarm" or report_data.get("report_origin") != "red_team_swarm":
1111
+ raise ValueError("Report origin is not an attested Red-Team origin.")
1112
+ now = float(self._wall_clock())
1113
+ issued_at = float(receipt["issued_at"])
1114
+ expires_at = float(receipt["expires_at"])
1115
+ if issued_at > now or expires_at < now or expires_at - issued_at > MAX_REMEDIATION_SECONDS:
1116
+ raise ValueError("Red-team receipt has invalid validity bounds.")
1117
+ vectors = receipt.get("attack_vector_results")
1118
+ if not isinstance(vectors, dict) or set(vectors) != set(RED_TEAM_ATTACK_VECTORS):
1119
+ raise ValueError("Red-team receipt must contain all five attack-vector results.")
1120
+ if any(not isinstance(value, dict) or value.get("passed") is not True for value in vectors.values()):
1121
+ raise ValueError("Red-team receipt does not attest five passing attack vectors.")
1122
+ if self._attack_vector_results(report_data) != vectors:
1123
+ raise ValueError("Red-team receipt attack-vector results do not match the report.")
1124
+ unsigned = {key: value for key, value in receipt.items() if key != "signature"}
1125
+ expected_signature = hmac.new(
1126
+ self._red_team_receipt_key,
1127
+ self._canonical_receipt_payload(unsigned),
1128
+ hashlib.sha256,
1129
+ ).hexdigest()
1130
+ if not hmac.compare_digest(expected_signature, str(receipt.get("signature", ""))):
1131
+ raise ValueError("Red-team receipt authentication failed.")
1132
+ report_body = {
1133
+ key: value
1134
+ for key, value in report_data.items()
1135
+ if key not in ("red_team_receipt", "receipt", "attestation")
1136
+ }
1137
+ expected_digest = hashlib.sha256(self._canonical_receipt_payload(report_body)).hexdigest()
1138
+ if not hmac.compare_digest(expected_digest, str(receipt.get("report_digest", ""))):
1139
+ raise ValueError("Red-team receipt report binding failed.")
1140
+ if str(report_data.get("reviewed_change_id", "")).strip() != str(receipt["reviewed_change_id"]).strip():
1141
+ raise ValueError("Red-team receipt reviewed-change binding failed.")
1142
+
1143
+ def _fresh_stage_records(
1144
+ self,
1145
+ ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
1146
+ return (
1147
+ [item for item in self.epistemic_ledger if not item.get("_restored_untrusted")],
1148
+ [item for item in self.refinement_cycles if not item.get("_restored_untrusted")],
1149
+ [item for item in self.file_changes if not item.get("_restored_untrusted")],
1150
+ [item for item in self.goal_rubrics if not item.get("_restored_untrusted")],
1151
+ )
1152
+
1153
+ def derive_reviewed_change_id(self) -> str:
1154
+ file_changes = self._fresh_stage_records()[2]
1155
+ if not file_changes:
1156
+ return ""
1157
+ payload = json.dumps(file_changes, sort_keys=True, separators=(",", ":"))
1158
+ return "chg_" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
1159
+
1160
+ def _validate_satisfied_rubric_item(self, item: Dict[str, Any]) -> None:
1161
+ if not item.get("satisfied"):
1162
+ return
1163
+ receipt_id = str(item.get("evidence_receipt_id", "")).strip()
1164
+
1165
+ def is_validated_receipt(receipt: Any) -> bool:
1166
+ if (
1167
+ not isinstance(receipt, dict)
1168
+ or receipt.get("_restored_untrusted")
1169
+ or str(receipt.get("receipt_id", "")) != receipt_id
1170
+ ):
1171
+ return False
1172
+ if "verified" in receipt or "passed" in receipt:
1173
+ return receipt.get("verified") is True or receipt.get("passed") is True
1174
+ proof_fields = {
1175
+ "claim", "proof_type", "target_resource", "sha256_digest",
1176
+ "verified_at", "verifier_details",
1177
+ }
1178
+ return proof_fields.issubset(receipt)
1179
+
1180
+ valid_receipt = bool(receipt_id) and any(
1181
+ is_validated_receipt(receipt)
1182
+ for receipt in self.proof_receipts
1183
+ )
1184
+ if not valid_receipt:
1185
+ raise ValueError(
1186
+ "Satisfied rubric criteria require a validated evidence receipt; "
1187
+ "a verifier command alone is not execution evidence."
1188
+ )
1189
+
1190
+ def _annotate_rubric_item_trust(self, item: Dict[str, Any]) -> bool:
1191
+ metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {}
1192
+ item["metadata"] = metadata
1193
+ metadata.pop("evidence_trust", None)
1194
+ if not item.get("satisfied"):
1195
+ return True
1196
+ try:
1197
+ self._validate_satisfied_rubric_item(item)
1198
+ except ValueError:
1199
+ metadata["evidence_trust"] = "untrusted"
1200
+ return False
1201
+ metadata["evidence_trust"] = "validated"
1202
+ return True
1203
+
1204
+ def _validate_clean_stage_evidence(self) -> None:
1205
+ epistemic, refinements, files, rubrics = self._fresh_stage_records()
1206
+ proven = [
1207
+ item for item in epistemic
1208
+ if item.get("tag") == "PROVEN" and str(item.get("evidence", "")).strip()
1209
+ ]
1210
+ if len(proven) < 2 or not refinements or not files:
1211
+ raise ValueError("Clean report lacks current-process epistemic, refinement, and file-change evidence.")
1212
+ for rubric in rubrics:
1213
+ target_score = float(rubric.get("target_score", 0.95))
1214
+ if target_score <= 0:
1215
+ continue
1216
+ if rubric.get("status") == "achieved" or float(rubric.get("current_score", 0.0)) >= target_score:
1217
+ satisfied_items = [item for item in rubric.get("items", []) if item.get("satisfied")]
1218
+ if not satisfied_items:
1219
+ continue
1220
+ for item in satisfied_items:
1221
+ self._validate_satisfied_rubric_item(item)
1222
+ return
1223
+ raise ValueError("Clean report lacks an achieved rubric with validated criteria evidence.")
1224
+
1225
+ def _validate_breakage_report(self, report_data: Dict[str, Any]) -> int:
1226
+ if not isinstance(report_data, dict):
1227
+ raise ValueError("Breakage report must be a dictionary.")
1228
+ findings = report_data.get("findings", [])
1229
+ if not isinstance(findings, list):
1230
+ raise ValueError("Breakage report findings must be a list.")
1231
+ broken_count = int(report_data.get("broken_count", 0))
1232
+ total_probes = int(report_data.get("total_probes", len(findings)))
1233
+ actual_broken = sum(
1234
+ 1 for finding in findings
1235
+ if bool(finding.get("broken", True) if isinstance(finding, dict) else getattr(finding, "broken", True))
1236
+ )
1237
+ if broken_count < 0 or total_probes < len(findings) or broken_count != actual_broken:
1238
+ raise ValueError("Breakage report counts do not match its findings.")
1239
+ if bool(report_data.get("passed", broken_count == 0)) != (broken_count == 0):
1240
+ raise ValueError("Breakage report passed flag does not match broken_count.")
1241
+ if broken_count == 0:
1242
+ self._validate_red_team_receipt(report_data)
1243
+ self._validate_clean_stage_evidence()
1244
+ return broken_count
1245
+
1246
+ def record_breakage_report(self, report_data: Dict[str, Any]) -> Dict[str, Any]:
1247
+ """Validate and record a report, enforcing durable remediation bounds."""
1248
+ if self.current_state in (
1249
+ SessionState.INIT,
1250
+ SessionState.DEEPTHINK_TIMELOCK,
1251
+ SessionState.SEALED,
1252
+ SessionState.EVOLVED,
1253
+ ):
1254
+ raise ValueError(f"Breakage report cannot be recorded from {self.current_state.value}.")
1255
+ if self.current_state == SessionState.ESCALATION_UNRESOLVED_BREAKAGES:
1256
+ raise ValueError("Session is already escalated for unresolved breakages.")
1257
+
1258
+ broken_count = self._validate_breakage_report(report_data)
1259
+ now = self._wall_clock()
1260
+ if broken_count == 0:
1261
+ if self.current_state not in (
1262
+ SessionState.RED_TEAM_GATE,
1263
+ SessionState.ARBITRATION,
1264
+ SessionState.REMEDIATION_REQUIRED,
1265
+ ):
1266
+ raise ValueError(f"Clean report cannot be recorded from {self.current_state.value}.")
1267
+ self.breakage_reports.append(dict(report_data))
1268
+ self._reviewed_change_id = str(report_data.get("reviewed_change_id", "")).strip()
1269
+ self.active_breakages = []
1270
+ if self.current_state == SessionState.RED_TEAM_GATE:
1271
+ self.transition_to(SessionState.ARBITRATION, "Arbitration of clean report")
1272
+ self._sealing_authorized = True
1273
+ try:
1274
+ self.transition_to(SessionState.SEALED, "Zero breakages verified")
1275
+ finally:
1276
+ self._sealing_authorized = False
1277
+ return report_data
1278
+
1279
+ if self.current_state == SessionState.IMPLEMENTATION:
1280
+ self.transition_to(SessionState.RED_TEAM_GATE)
1281
+
1282
+ started_at = self.remediation_started_at if self.remediation_started_at is not None else now
1283
+ attempt = self.remediation_attempt_count + 1
1284
+ elapsed = max(0.0, now - started_at)
1285
+ active: List[Dict[str, Any]] = []
1286
+ for finding in report_data.get("findings", []):
1287
+ if bool(finding.get("broken", True) if isinstance(finding, dict) else getattr(finding, "broken", True)):
1288
+ active.append({
1289
+ "scenario_id": finding.get("scenario_id", "") if isinstance(finding, dict) else getattr(finding, "scenario_id", ""),
1290
+ "hypothesis": finding.get("hypothesis", "") if isinstance(finding, dict) else getattr(finding, "hypothesis", ""),
1291
+ "reproduction_code": finding.get("reproduction_code", "") if isinstance(finding, dict) else getattr(finding, "reproduction_code", ""),
1292
+ "severity": finding.get("severity", "MEDIUM") if isinstance(finding, dict) else getattr(finding, "severity", "MEDIUM"),
1293
+ "error_message": finding.get("error_message", "") if isinstance(finding, dict) else getattr(finding, "error_message", ""),
1294
+ "vector": finding.get("vector", "") if isinstance(finding, dict) else getattr(finding, "vector", ""),
1295
+ })
1296
+
1297
+ escalated = attempt >= MAX_REMEDIATION_ATTEMPTS or elapsed >= MAX_REMEDIATION_SECONDS
1298
+ if escalated:
1299
+ for finding in active:
1300
+ tag = "HYPOTHESIS" if str(finding.get("hypothesis", "")).strip() else "UNKNOWN"
1301
+ finding["epistemic_status"] = tag
1302
+ finding["human_arbitration_required"] = True
1303
+ ledger_item = self.log_epistemic_item(
1304
+ tag,
1305
+ str(finding.get("hypothesis") or finding.get("scenario_id") or "Unresolved Red-Team breakage"),
1306
+ evidence=str(finding.get("error_message") or finding.get("reproduction_code") or ""),
1307
+ )
1308
+ finding["epistemic_ledger_item_id"] = ledger_item["id"]
1309
+
1310
+ self.breakage_reports.append(dict(report_data))
1311
+ self.remediation_attempt_count = attempt
1312
+ self.remediation_started_at = started_at
1313
+ self.iteration_count += 1
1314
+ self.active_breakages = active
1315
+ self.remediation_history.append({
1316
+ "iteration": self.iteration_count,
1317
+ "attempt": attempt,
1318
+ "report_id": report_data.get("report_id"),
1319
+ "broken_count": broken_count,
1320
+ "timestamp": now,
1321
+ "breakages": list(active),
1322
+ "remediation_directives": report_data.get("remediation_directives", []),
1323
+ })
1324
+ if self.current_state == SessionState.RED_TEAM_GATE:
1325
+ self.transition_to(SessionState.ARBITRATION, "Arbitration of breakages")
1326
+ if self.current_state == SessionState.ARBITRATION:
1327
+ self.transition_to(SessionState.REMEDIATION_REQUIRED, f"{broken_count} breakages detected")
1328
+ if escalated:
1329
+ self.transition_to(
1330
+ SessionState.ESCALATION_UNRESOLVED_BREAKAGES,
1331
+ "Remediation bounds exceeded; human architecture arbitration required",
1332
+ )
1333
+ elif self.current_state != SessionState.REMEDIATION_REQUIRED:
1334
+ self.transition_to(SessionState.REMEDIATION_REQUIRED, f"{broken_count} breakages detected")
1335
+ return report_data
1336
+
1337
+ def log_refinement_cycle(
1338
+ self,
1339
+ refinement_type: str,
1340
+ focus_area: str,
1341
+ critique_or_bottleneck: str,
1342
+ architectural_refinement: str,
1343
+ terminal_probe_results: Optional[str] = None,
1344
+ artifact_path: Optional[str] = None
1345
+ ) -> Dict[str, Any]:
1346
+ """Logs a structured rethink-refine cycle to continuously deepen cognitive quality."""
1347
+ if not refinement_type or not str(refinement_type).strip():
1348
+ raise ValueError("Refinement type cannot be empty.")
1349
+ if not focus_area or not str(focus_area).strip():
1350
+ raise ValueError("Focus area cannot be empty.")
1351
+ if not critique_or_bottleneck or not str(critique_or_bottleneck).strip():
1352
+ raise ValueError("Critique or bottleneck cannot be empty.")
1353
+ if not architectural_refinement or not str(architectural_refinement).strip():
1354
+ raise ValueError("Architectural refinement cannot be empty.")
1355
+
1356
+ cycle_num = len(self.refinement_cycles) + 1
1357
+ entry = {
1358
+ "cycle_number": cycle_num,
1359
+ "refinement_type": str(refinement_type).strip(),
1360
+ "focus_area": str(focus_area).strip(),
1361
+ "critique_or_bottleneck": str(critique_or_bottleneck).strip(),
1362
+ "architectural_refinement": str(architectural_refinement).strip(),
1363
+ "terminal_probe_results": (terminal_probe_results or "").strip() if terminal_probe_results else None,
1364
+ "artifact_path": (artifact_path or "").strip() if artifact_path else None,
1365
+ "timestamp": self._wall_clock(),
1366
+ "phase": self.active_phase
1367
+ }
1368
+ self.refinement_cycles.append(entry)
1369
+
1370
+ # Update Session Active Free Energy F
1371
+ from fable_v2.system3 import ActiveInferenceEngine, create_default_architecture_pomdp
1372
+ fe_engine = ActiveInferenceEngine(create_default_architecture_pomdp())
1373
+ obs = "HIGH_THROUGHPUT_CLEAN" if terminal_probe_results and any(
1374
+ kw in terminal_probe_results.lower() for kw in ("pass", "ok", "success")
1375
+ ) else "LOCK_CONTENTION_WARN"
1376
+ f_val, comp, acc = fe_engine.update_beliefs(obs)
1377
+ self.active_free_energy = {
1378
+ "variational_free_energy_f": round(f_val, 4),
1379
+ "complexity_kl": round(comp, 4),
1380
+ "accuracy_log_likelihood": round(acc, 4),
1381
+ "observation": obs,
1382
+ "cycle_number": cycle_num,
1383
+ "timestamp": self._wall_clock(),
1384
+ }
1385
+
1386
+ # Update Causal DAG nodes (initialize default DAG if none exists)
1387
+ if not self.system3_causal_graphs:
1388
+ self.system3_causal_graphs.append({
1389
+ "dag": {"name": f"Session_{self.session_name}_DAG", "nodes": [], "edges": []},
1390
+ "nodes": [],
1391
+ "edges": [],
1392
+ "topological_order": [],
1393
+ "timestamp": self._wall_clock(),
1394
+ })
1395
+ if self.system3_causal_graphs:
1396
+ causal_node_id = f"refine_cycle_{cycle_num}"
1397
+ causal_node_data = {
1398
+ "node_id": causal_node_id,
1399
+ "name": f"Refinement {cycle_num}: {focus_area}",
1400
+ "node_type": "INTERVENTION",
1401
+ "value": 1.0,
1402
+ "parents": [f"refine_cycle_{cycle_num - 1}"] if cycle_num > 1 else [],
1403
+ "metadata": {
1404
+ "refinement_type": refinement_type,
1405
+ "focus_area": focus_area,
1406
+ "critique": critique_or_bottleneck,
1407
+ }
1408
+ }
1409
+ latest_graph = self.system3_causal_graphs[-1]
1410
+ if "nodes" in latest_graph and isinstance(latest_graph["nodes"], list):
1411
+ if not any(n.get("node_id") == causal_node_id for n in latest_graph["nodes"] if isinstance(n, dict)):
1412
+ latest_graph["nodes"].append(causal_node_data)
1413
+ inner_dag = latest_graph.get("dag", latest_graph)
1414
+ nodes = inner_dag.setdefault("nodes", [])
1415
+ if isinstance(nodes, list):
1416
+ if not any(n.get("node_id") == causal_node_id for n in nodes if isinstance(n, dict)):
1417
+ nodes.append(causal_node_data)
1418
+ elif isinstance(nodes, dict):
1419
+ nodes[causal_node_id] = causal_node_data
1420
+ if cycle_num > 1:
1421
+ prev_id = f"refine_cycle_{cycle_num - 1}"
1422
+ edge_data = {"source": prev_id, "target": causal_node_id, "weight": 1.0, "mechanism": "refinement_evolution"}
1423
+ if "edges" in latest_graph and isinstance(latest_graph["edges"], list):
1424
+ latest_graph["edges"].append(edge_data)
1425
+ edges = inner_dag.setdefault("edges", [])
1426
+ if isinstance(edges, list):
1427
+ edges.append(edge_data)
1428
+
1429
+ return entry
1430
+
1431
+ @staticmethod
1432
+ def _force_override_authorized(token: Optional[str]) -> bool:
1433
+ """Allow emergency override only through an out-of-band secret.
1434
+
1435
+ The old implementation accepted a public hard-coded string, which
1436
+ allowed any model with tool access to self-authorize an early unlock.
1437
+ """
1438
+ configured = os.environ.get(FORCE_UNLOCK_ENV)
1439
+ if not configured or not token:
1440
+ return False
1441
+ try:
1442
+ return hmac.compare_digest(str(token), configured)
1443
+ except TypeError:
1444
+ return False
1445
+
1446
+ def unlock_execution(self, rationale: str, force_override_token: Optional[str] = None) -> Dict[str, Any]:
1447
+ """
1448
+ Anti-Rush Lockout & Hard Time-Lock Validator:
1449
+ Ensures cognitive rigor and pacing compliance before code execution is permitted:
1450
+ 1. Immutable authority deadline has elapsed. An internal pacing timer is never sufficient.
1451
+ 2. At least 2 evidence-backed PROVEN items are recorded.
1452
+ 3. At least 1 formal invariant includes a proof or rationale.
1453
+ 4. Active phase is at least Phase 3 (Phase 3, 4, 5, or 6).
1454
+ 5. Anti-Idle check: at least 1 refinement cycle per 5 minutes of budget (minimum 2 cycles).
1455
+
1456
+ Emergency overrides are intentionally not exposed through the MCP
1457
+ tool schema. A host application may configure an out-of-band secret
1458
+ for direct administrative use, but the model cannot self-authorize it.
1459
+ """
1460
+ now = self._wall_clock()
1461
+ force_override_used = self._force_override_authorized(force_override_token)
1462
+ remaining_sec = self._authority_remaining_seconds()
1463
+ if remaining_sec > 0 and not force_override_used:
1464
+ rem_formatted = self._format_duration(remaining_sec)
1465
+ raise PermissionError(
1466
+ f"🛑 HARD TIME-LOCK VIOLATION: Execution unlock rejected! The immutable "
1467
+ f"{self.time_budget_minutes}m authority budget has not elapsed yet "
1468
+ f"(Remaining: {rem_formatted} / {remaining_sec:.1f}s). An internal pacing "
1469
+ f"timer cannot unlock execution. Continue the Rethink-Refine Cognitive Loop."
1470
+ )
1471
+
1472
+ gate_report = self._gate_report()
1473
+ proven_items = [i for i in self.epistemic_ledger if i.get("tag") == "PROVEN" and not i.get("_restored_untrusted")]
1474
+ errors: List[str] = []
1475
+ if not gate_report["checks"]["two_proven_evidence_items"]:
1476
+ errors.append(
1477
+ f"Requires at least 2 [PROVEN] items with evidence "
1478
+ f"(currently {gate_report['proven_with_evidence']})."
1479
+ )
1480
+ if not gate_report["checks"]["one_proved_invariant"]:
1481
+ errors.append("Requires at least 1 formal Invariant with a proof or rationale.")
1482
+ if not gate_report["checks"]["adversarial_phase_reached"]:
1483
+ errors.append(
1484
+ f"Requires phase progression to at least Phase 3: Adversarial Red-Teaming & Falsification "
1485
+ f"(currently in {self.active_phase})."
1486
+ )
1487
+
1488
+ # Anti-idle check: require minimum refinement cycles (at least 1 cycle per 5 minutes of budget, min 2 cycles)
1489
+ min_refinements = max(2, math.ceil(self.time_budget_minutes / 5.0))
1490
+ valid_refinements = [r for r in self.refinement_cycles if not r.get("_restored_untrusted")]
1491
+ if len(valid_refinements) < min_refinements:
1492
+ errors.append(
1493
+ f"Anti-Idle Requirement Not Satisfied: Requires at least {min_refinements} rethink-refine cycles for a "
1494
+ f"{self.time_budget_minutes}m budget (currently {len(valid_refinements)} recorded). "
1495
+ f"Continuous cognitive refinement is mandatory during deliberation."
1496
+ )
1497
+
1498
+ if errors:
1499
+ reasons = "\n".join([f" - {e}" for e in errors])
1500
+ raise PermissionError(
1501
+ f"🛑 Anti-Rush Lockout Active! Execution unlock denied due to missing cognitive gates:\n{reasons}\n\n"
1502
+ f"Please log required proven facts, formal invariants, refinement cycles, and advance to Phase 3+ before unlocking."
1503
+ )
1504
+
1505
+ self.execution_locked = False
1506
+ self.can_execute_code = True
1507
+ if self.current_state == SessionState.INIT:
1508
+ self.transition_to(SessionState.DEEPTHINK_TIMELOCK, "Pre-unlock transition to DEEPTHINK_TIMELOCK")
1509
+ if self.current_state == SessionState.DEEPTHINK_TIMELOCK:
1510
+ self.transition_to(SessionState.IMPLEMENTATION, rationale)
1511
+
1512
+ self.unlock_details = {
1513
+ "unlocked_at": now,
1514
+ "rationale": rationale.strip(),
1515
+ "proven_count": len(proven_items),
1516
+ "invariants_count": len(self.invariants),
1517
+ "refinement_cycles_count": len(self.refinement_cycles),
1518
+ "phase": self.active_phase,
1519
+ "force_override_used": force_override_used,
1520
+ "authority_deadline_elapsed": remaining_sec <= 0
1521
+ }
1522
+
1523
+ return {
1524
+ "status": "UNLOCKED",
1525
+ "execution_locked": False,
1526
+ "can_execute_code": True,
1527
+ "unlock_details": self.unlock_details
1528
+ }
1529
+
1530
+ def to_dict(self) -> Dict[str, Any]:
1531
+ """Serializes session to dictionary."""
1532
+ return {
1533
+ "version": "1.3.0",
1534
+ "session_name": self.session_name,
1535
+ "session_id": self.session_id,
1536
+ "objective": self.objective,
1537
+ "start_time": self.start_time,
1538
+ "time_budget_minutes": self.time_budget_minutes,
1539
+ "time_budget_seconds": self.time_budget_seconds,
1540
+ "authority_deadline_time": self.deadline_time,
1541
+ "deadline_time": self.deadline_time,
1542
+ "pacing_budget_minutes": self.pacing_budget_minutes,
1543
+ "pacing_budget_seconds": self.pacing_budget_seconds,
1544
+ "pacing_started_time": self._pacing_started_wall,
1545
+ "pacing_deadline_time": self._pacing_deadline_wall,
1546
+ "active_phase": self.active_phase,
1547
+ "execution_locked": self.execution_locked,
1548
+ "can_execute_code": self.can_execute_code,
1549
+ "current_state": self.current_state.value if isinstance(self.current_state, SessionState) else str(self.current_state),
1550
+ "iteration_count": self.iteration_count,
1551
+ "remediation_attempt_count": self.remediation_attempt_count,
1552
+ "remediation_started_at": self.remediation_started_at,
1553
+ "reviewed_change_id": self._reviewed_change_id,
1554
+ "active_breakages": self.active_breakages,
1555
+ "remediation_history": self.remediation_history,
1556
+ "epistemic_ledger": self.epistemic_ledger,
1557
+ "invariants": self.invariants,
1558
+ "refinement_cycles": self.refinement_cycles,
1559
+ "file_changes": self.file_changes,
1560
+ "visual_mockups": self.visual_mockups,
1561
+ "proof_receipts": self.proof_receipts,
1562
+ "goal_rubrics": self.goal_rubrics,
1563
+ "automation_pipelines": self.automation_pipelines,
1564
+ "breakage_reports": self.breakage_reports,
1565
+ "phase_history": self.phase_history,
1566
+ "unlock_details": self.unlock_details,
1567
+ "system3_causal_graphs": self.system3_causal_graphs,
1568
+ "system3_syntheses": self.system3_syntheses,
1569
+ "system3_gene_pools": self.system3_gene_pools,
1570
+ "system3_axioms": self.system3_axioms,
1571
+ "system3_reflections": self.system3_reflections,
1572
+ "system3_orchestrations": self.system3_orchestrations,
1573
+ "system3_hyperbolic_embeddings": self.system3_hyperbolic_embeddings,
1574
+ "system3_kripke_verifications": self.system3_kripke_verifications,
1575
+ "system3_active_inferences": self.system3_active_inferences,
1576
+ "system3_proof_oracle_verifications": self.system3_proof_oracle_verifications,
1577
+ "active_free_energy": self.active_free_energy,
1578
+ "active_kripke_safety": self.active_kripke_safety,
1579
+ "active_biases": self.active_biases,
1580
+ "fable_run": self.fable_run.to_dict() if getattr(self, "fable_run", None) else None,
1581
+ }
1582
+
1583
+ @classmethod
1584
+ def from_dict(cls, data: Dict[str, Any]) -> "FableSession":
1585
+ """Restore data without importing persisted execution authority.
1586
+
1587
+ Persistence is an interchange format, not an authority token. The
1588
+ deadline, phase, evidence, invariants, and unlock flags are all treated
1589
+ as untrusted; a restored process starts a fresh locked authority clock.
1590
+ Historical data remains visible, but cannot satisfy fresh gates.
1591
+ """
1592
+ budget = data.get("time_budget_minutes", 60.0)
1593
+ session = cls(session_name=data["session_name"], objective=data.get("objective", ""),
1594
+ time_budget_minutes=budget, session_id=data.get("session_id"))
1595
+ session._restored_untrusted = True
1596
+ state_str = data.get("current_state", SessionState.INIT.value)
1597
+ try:
1598
+ restored_state = SessionState(state_str)
1599
+ except ValueError:
1600
+ restored_state = SessionState.INIT
1601
+ if restored_state in (SessionState.SEALED, SessionState.EVOLVED):
1602
+ restored_state = SessionState.INIT
1603
+ session.current_state = restored_state
1604
+ session.iteration_count = int(data.get("iteration_count", 0))
1605
+ session.remediation_attempt_count = int(data.get("remediation_attempt_count", 0))
1606
+ session.remediation_started_at = data.get("remediation_started_at")
1607
+ session._reviewed_change_id = data.get("reviewed_change_id")
1608
+ session.active_breakages = list(data.get("active_breakages", []))
1609
+ session.remediation_history = list(data.get("remediation_history", []))
1610
+ session.epistemic_ledger = [dict(item, _restored_untrusted=True) for item in data.get("epistemic_ledger", []) if isinstance(item, dict)]
1611
+ session.invariants = [dict(item, _restored_untrusted=True) for item in data.get("invariants", []) if isinstance(item, dict)]
1612
+ session.refinement_cycles = [dict(item, _restored_untrusted=True) for item in data.get("refinement_cycles", []) if isinstance(item, dict)]
1613
+ session.file_changes = [
1614
+ dict(item, _restored_untrusted=True)
1615
+ for item in data.get("file_changes", [])
1616
+ if isinstance(item, dict)
1617
+ ]
1618
+ session.visual_mockups = data.get("visual_mockups", {"mockups": [], "selected_concept": None})
1619
+ session.proof_receipts = [
1620
+ dict(item, _restored_untrusted=True)
1621
+ for item in data.get("proof_receipts", [])
1622
+ if isinstance(item, dict)
1623
+ ]
1624
+ session.goal_rubrics = [
1625
+ dict(item, _restored_untrusted=True)
1626
+ for item in data.get("goal_rubrics", [])
1627
+ if isinstance(item, dict)
1628
+ ]
1629
+ session.automation_pipelines = data.get("automation_pipelines", [])
1630
+ session.breakage_reports = data.get("breakage_reports", [])
1631
+ session.active_phase = PHASES[0]
1632
+ session.phase_history = [{"phase": PHASES[0], "entered_at": session.start_time,
1633
+ "summary": "Restored in safe locked state; fresh gates required"}]
1634
+ session.execution_locked = True
1635
+ session.can_execute_code = False
1636
+ session.unlock_details = None
1637
+ session.system3_causal_graphs = data.get("system3_causal_graphs", [])
1638
+ session.system3_syntheses = data.get("system3_syntheses", [])
1639
+ session.system3_gene_pools = data.get("system3_gene_pools", [])
1640
+ session.system3_axioms = data.get("system3_axioms", [])
1641
+ session.system3_reflections = data.get("system3_reflections", [])
1642
+ session.system3_orchestrations = data.get("system3_orchestrations", [])
1643
+ session.system3_hyperbolic_embeddings = data.get("system3_hyperbolic_embeddings", [])
1644
+ session.system3_kripke_verifications = data.get("system3_kripke_verifications", [])
1645
+ session.system3_active_inferences = data.get("system3_active_inferences", [])
1646
+ session.system3_proof_oracle_verifications = data.get("system3_proof_oracle_verifications", [])
1647
+ session.active_free_energy = data.get("active_free_energy")
1648
+ session.active_kripke_safety = data.get("active_kripke_safety")
1649
+ session.active_biases = data.get("active_biases", [])
1650
+ fable_run_data = data.get("fable_run")
1651
+ if fable_run_data and isinstance(fable_run_data, dict):
1652
+ try:
1653
+ from fable_v2.runtime import FableRun
1654
+ session.fable_run = FableRun.from_dict(fable_run_data)
1655
+ except Exception:
1656
+ pass
1657
+ return session
1658
+
1659
+ def save(self, target_path: Optional[Path] = None) -> Path:
1660
+ """Atomically save a session using a unique no-follow temporary file and cross-process lock."""
1661
+ path = Path(target_path or (SESSIONS_DIR / f"{self.session_name}.json")).expanduser().absolute()
1662
+ with session_file_lock(path):
1663
+ parent = path.parent
1664
+ _assert_private_path(parent)
1665
+ parent.mkdir(parents=True, exist_ok=True)
1666
+ _assert_private_path(parent)
1667
+ if parent.is_symlink() or not parent.is_dir() or path.is_symlink():
1668
+ raise OSError("session path or parent is a symlink/reparse point")
1669
+ os.chmod(parent, 0o700)
1670
+ if os.name == "posix" and hasattr(os, "O_NOFOLLOW"):
1671
+ parent_fd = _open_directory_nofollow(parent, create=True)
1672
+ temp_name = f".{path.name}.{os.getpid()}-{os.urandom(8).hex()}.tmp"
1673
+ fd = None
1674
+ try:
1675
+ fd = os.open(temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent_fd)
1676
+ os.fchmod(fd, 0o600)
1677
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
1678
+ fd = None
1679
+ json.dump(self.to_dict(), handle, indent=2)
1680
+ handle.flush()
1681
+ os.fsync(handle.fileno())
1682
+ os.replace(temp_name, path.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
1683
+ try:
1684
+ os.fsync(parent_fd)
1685
+ except OSError:
1686
+ # Linux O_PATH directory descriptors are suitable for
1687
+ # descriptor-relative publication but not fsync targets.
1688
+ pass
1689
+ finally:
1690
+ if fd is not None:
1691
+ os.close(fd)
1692
+ try:
1693
+ os.unlink(temp_name, dir_fd=parent_fd)
1694
+ except OSError:
1695
+ pass
1696
+ os.close(parent_fd)
1697
+ else:
1698
+ fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(parent))
1699
+ temp_path = Path(temporary)
1700
+ try:
1701
+ if hasattr(os, "fchmod"):
1702
+ os.fchmod(fd, 0o600)
1703
+ else:
1704
+ os.chmod(temporary, 0o600)
1705
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
1706
+ json.dump(self.to_dict(), handle, indent=2)
1707
+ handle.flush()
1708
+ os.fsync(handle.fileno())
1709
+ os.replace(temp_path, path)
1710
+ finally:
1711
+ if temp_path.exists() or temp_path.is_symlink():
1712
+ temp_path.unlink()
1713
+ try:
1714
+ self._disk_mtime = path.stat().st_mtime
1715
+ except OSError:
1716
+ pass
1717
+ logger.info(f"Fable session '{self.session_name}' saved to {path}")
1718
+ return path
1719
+
1720
+
1721
+ @contextmanager
1722
+ def session_file_lock(session_path: Path, timeout: float = 5.0):
1723
+ """Cross-process file lock using msvcrt on Windows and fcntl on POSIX."""
1724
+ lock_file = session_path.parent / f".{session_path.name}.lock"
1725
+ start = time.time()
1726
+ handle = None
1727
+ try:
1728
+ handle = open(lock_file, "a+b")
1729
+ acquired = False
1730
+ while time.time() - start < timeout:
1731
+ try:
1732
+ if os.name == "nt":
1733
+ import msvcrt
1734
+ handle.seek(0)
1735
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
1736
+ else:
1737
+ import fcntl
1738
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
1739
+ acquired = True
1740
+ break
1741
+ except (OSError, BlockingIOError):
1742
+ time.sleep(0.02)
1743
+ if not acquired:
1744
+ logger.warning(f"Session lock acquisition timed out for {session_path}")
1745
+ yield
1746
+ finally:
1747
+ if handle is not None:
1748
+ try:
1749
+ if os.name == "nt":
1750
+ import msvcrt
1751
+ handle.seek(0)
1752
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
1753
+ else:
1754
+ import fcntl
1755
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
1756
+ except OSError:
1757
+ pass
1758
+ try:
1759
+ handle.close()
1760
+ except OSError:
1761
+ pass
1762
+
1763
+
1764
+ # In-Memory Active Sessions Table
1765
+ ACTIVE_SESSIONS: Dict[str, FableSession] = {}
1766
+
1767
+
1768
+ def _safe_session_file(path: Path) -> None:
1769
+ _assert_private_path(path.parent)
1770
+ try:
1771
+ st = path.lstat()
1772
+ except FileNotFoundError:
1773
+ raise ValueError(f"session file does not exist: {path.name}")
1774
+ attrs = int(getattr(st, "st_file_attributes", 0))
1775
+ if (attrs & 0x400 or stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode)
1776
+ or st.st_nlink != 1 or (os.name != "nt" and stat.S_IMODE(st.st_mode) & 0o077)):
1777
+ raise ValueError("session file must be a private regular non-hardlinked file")
1778
+
1779
+
1780
+ def get_or_load_session(session_name: str) -> FableSession:
1781
+ """Retrieves session from memory or loads from disk if exists, checking mtime for cross-process synchronization."""
1782
+ clean_name = _validate_session_name(session_name)
1783
+ file_path = SESSIONS_DIR / f"{clean_name}.json"
1784
+
1785
+ if clean_name in ACTIVE_SESSIONS:
1786
+ session = ACTIVE_SESSIONS[clean_name]
1787
+ if file_path.exists():
1788
+ try:
1789
+ disk_mtime = file_path.stat().st_mtime
1790
+ if disk_mtime > getattr(session, "_disk_mtime", 0.0):
1791
+ with session_file_lock(file_path):
1792
+ _safe_session_file(file_path)
1793
+ with open(file_path, "r", encoding="utf-8") as f:
1794
+ data = json.load(f)
1795
+ session = FableSession.from_dict(data)
1796
+ session._disk_mtime = disk_mtime
1797
+ ACTIVE_SESSIONS[clean_name] = session
1798
+ except Exception as exc:
1799
+ logger.warning(f"Failed to check disk mtime reload for session {clean_name}: {exc}")
1800
+ return session
1801
+
1802
+ if file_path.exists() or file_path.is_symlink():
1803
+ try:
1804
+ with session_file_lock(file_path):
1805
+ _safe_session_file(file_path)
1806
+ with open(file_path, "r", encoding="utf-8") as f:
1807
+ data = json.load(f)
1808
+ session = FableSession.from_dict(data)
1809
+ try:
1810
+ session._disk_mtime = file_path.stat().st_mtime
1811
+ except OSError:
1812
+ pass
1813
+ ACTIVE_SESSIONS[clean_name] = session
1814
+ return session
1815
+ except Exception as e:
1816
+ logger.error(f"Failed to load session file {file_path}: {e}")
1817
+ raise RuntimeError(f"Corrupt or unreadable session file for '{clean_name}': {e}")
1818
+
1819
+ raise ValueError(f"Session '{clean_name}' does not exist. Call 'create_session' first.")