algo-cli-runtime 0.14.0__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.
- algo_cli/__init__.py +3 -0
- algo_cli/__main__.py +7 -0
- algo_cli/_internal/__init__.py +12 -0
- algo_cli/_internal/policy_chain.py +259 -0
- algo_cli/action_registry.py +1047 -0
- algo_cli/agent_blocks.py +550 -0
- algo_cli/agent_pipeline.py +1457 -0
- algo_cli/agent_threads.py +308 -0
- algo_cli/animations.py +316 -0
- algo_cli/cache_admission.py +209 -0
- algo_cli/capability_mask.py +66 -0
- algo_cli/chat_protocol.py +116 -0
- algo_cli/chatgpt_auth.py +510 -0
- algo_cli/chatgpt_client.py +657 -0
- algo_cli/code_rag.py +479 -0
- algo_cli/config.py +651 -0
- algo_cli/context_budget.py +679 -0
- algo_cli/credential_helpers.py +315 -0
- algo_cli/deliberation.py +29 -0
- algo_cli/display.py +1470 -0
- algo_cli/evals/__init__.py +21 -0
- algo_cli/evals/algorithm_effectiveness.py +560 -0
- algo_cli/evals/competitive_harness_rating.py +702 -0
- algo_cli/evals/cot_quality.py +220 -0
- algo_cli/evals/harness_retrieval_benchmark.py +401 -0
- algo_cli/evals/performance_regression.py +136 -0
- algo_cli/evals/scorecard_grading.py +308 -0
- algo_cli/evals/session_distribution.py +84 -0
- algo_cli/execution_guardrails.py +806 -0
- algo_cli/extensions_manifest.py +84 -0
- algo_cli/git_evidence.py +227 -0
- algo_cli/google_workspace.py +407 -0
- algo_cli/google_workspace_auth.py +523 -0
- algo_cli/harness.py +2587 -0
- algo_cli/identity.py +557 -0
- algo_cli/index_compute_lab.py +228 -0
- algo_cli/inference_harness.py +70 -0
- algo_cli/intelligence/__init__.py +1103 -0
- algo_cli/intelligence/acrobat_config.py +307 -0
- algo_cli/intelligence/acrobat_manifests.py +338 -0
- algo_cli/intelligence/acrobat_models.py +195 -0
- algo_cli/intelligence/acrobat_pipeline.py +295 -0
- algo_cli/intelligence/acrobat_runtime.py +302 -0
- algo_cli/intelligence/acrobat_security.py +261 -0
- algo_cli/intelligence/acrobat_workflows.py +226 -0
- algo_cli/intelligence/actionability.py +165 -0
- algo_cli/intelligence/adversarial_audit.py +136 -0
- algo_cli/intelligence/agent_arena.py +92 -0
- algo_cli/intelligence/agent_benchmark.py +236 -0
- algo_cli/intelligence/agent_runtime.py +171 -0
- algo_cli/intelligence/agents_as_tools.py +70 -0
- algo_cli/intelligence/artifact_binding.py +80 -0
- algo_cli/intelligence/autonomous_engineer.py +1976 -0
- algo_cli/intelligence/backpressure.py +99 -0
- algo_cli/intelligence/bloom_filter.py +186 -0
- algo_cli/intelligence/bonferroni.py +66 -0
- algo_cli/intelligence/boundary_compaction.py +98 -0
- algo_cli/intelligence/catalog_verifier.py +172 -0
- algo_cli/intelligence/cavecrew.py +118 -0
- algo_cli/intelligence/changelog.py +176 -0
- algo_cli/intelligence/checkpoint_resume.py +92 -0
- algo_cli/intelligence/circuit_breaker.py +88 -0
- algo_cli/intelligence/clarification_gate.py +101 -0
- algo_cli/intelligence/code_graph.py +180 -0
- algo_cli/intelligence/coderank.py +97 -0
- algo_cli/intelligence/consistent_hash.py +150 -0
- algo_cli/intelligence/consortium_synthesis.py +139 -0
- algo_cli/intelligence/construction/__init__.py +241 -0
- algo_cli/intelligence/construction/common.py +273 -0
- algo_cli/intelligence/construction/documents.py +496 -0
- algo_cli/intelligence/construction/labor_units.py +1395 -0
- algo_cli/intelligence/construction/payments.py +470 -0
- algo_cli/intelligence/construction/risk.py +784 -0
- algo_cli/intelligence/content_extractor.py +132 -0
- algo_cli/intelligence/context_adaptive.py +102 -0
- algo_cli/intelligence/context_ops.py +95 -0
- algo_cli/intelligence/count_min.py +145 -0
- algo_cli/intelligence/cow_state.py +103 -0
- algo_cli/intelligence/critic_loop.py +119 -0
- algo_cli/intelligence/cross_source.py +113 -0
- algo_cli/intelligence/daemon_mode.py +99 -0
- algo_cli/intelligence/dag_orchestration.py +151 -0
- algo_cli/intelligence/deep_research.py +155 -0
- algo_cli/intelligence/degenerate_detector.py +78 -0
- algo_cli/intelligence/delta_report.py +92 -0
- algo_cli/intelligence/discovery_event_log.py +92 -0
- algo_cli/intelligence/document_ingest.py +298 -0
- algo_cli/intelligence/dual_layer_validate.py +151 -0
- algo_cli/intelligence/echo_fidelity.py +73 -0
- algo_cli/intelligence/ema_tuning.py +104 -0
- algo_cli/intelligence/event_log.py +92 -0
- algo_cli/intelligence/evidence_graph.py +114 -0
- algo_cli/intelligence/extension_host.py +162 -0
- algo_cli/intelligence/extension_manifest.py +115 -0
- algo_cli/intelligence/falsification_suite.py +178 -0
- algo_cli/intelligence/finance/__init__.py +169 -0
- algo_cli/intelligence/finance/anomalies.py +135 -0
- algo_cli/intelligence/finance/ap_ar.py +351 -0
- algo_cli/intelligence/finance/cash.py +162 -0
- algo_cli/intelligence/finance/close.py +332 -0
- algo_cli/intelligence/finance/common.py +244 -0
- algo_cli/intelligence/finance/construction.py +135 -0
- algo_cli/intelligence/finance/controls.py +172 -0
- algo_cli/intelligence/finance/evidence.py +119 -0
- algo_cli/intelligence/finance/exceptions.py +157 -0
- algo_cli/intelligence/finance/reconciliations.py +254 -0
- algo_cli/intelligence/finance/revenue.py +109 -0
- algo_cli/intelligence/finance/tax.py +74 -0
- algo_cli/intelligence/finance/workpapers.py +111 -0
- algo_cli/intelligence/finding_record.py +120 -0
- algo_cli/intelligence/flow_dag.py +267 -0
- algo_cli/intelligence/gatherer.py +223 -0
- algo_cli/intelligence/golden_master.py +98 -0
- algo_cli/intelligence/graph_rag.py +195 -0
- algo_cli/intelligence/group_chat.py +143 -0
- algo_cli/intelligence/hash_dedup.py +145 -0
- algo_cli/intelligence/hyperloglog.py +128 -0
- algo_cli/intelligence/incremental_index.py +316 -0
- algo_cli/intelligence/index_store.py +16 -0
- algo_cli/intelligence/iteration_plan.py +133 -0
- algo_cli/intelligence/kernel_plugins.py +167 -0
- algo_cli/intelligence/lesson_catalog.py +135 -0
- algo_cli/intelligence/llm_fallback.py +169 -0
- algo_cli/intelligence/log2_histogram.py +267 -0
- algo_cli/intelligence/lsp_integration.py +147 -0
- algo_cli/intelligence/memory_evolution.py +117 -0
- algo_cli/intelligence/minhash_lsh.py +182 -0
- algo_cli/intelligence/multi_model_score.py +174 -0
- algo_cli/intelligence/multi_tier_grade.py +211 -0
- algo_cli/intelligence/negative_controls.py +113 -0
- algo_cli/intelligence/numeric_clamp.py +63 -0
- algo_cli/intelligence/occ_editor.py +66 -0
- algo_cli/intelligence/output_normalize.py +112 -0
- algo_cli/intelligence/parallel_delegation.py +98 -0
- algo_cli/intelligence/parallel_fanout.py +104 -0
- algo_cli/intelligence/permission_modes.py +105 -0
- algo_cli/intelligence/pre_push_gate.py +68 -0
- algo_cli/intelligence/prefetch.py +171 -0
- algo_cli/intelligence/process_framework.py +217 -0
- algo_cli/intelligence/project_graph.py +387 -0
- algo_cli/intelligence/query_expansion.py +146 -0
- algo_cli/intelligence/ralph_loop.py +117 -0
- algo_cli/intelligence/rate_limiter.py +153 -0
- algo_cli/intelligence/refactor_transaction.py +94 -0
- algo_cli/intelligence/research_workspace.py +108 -0
- algo_cli/intelligence/retraction_ledger.py +72 -0
- algo_cli/intelligence/saga_pattern.py +88 -0
- algo_cli/intelligence/session_fork.py +100 -0
- algo_cli/intelligence/shadow_editor.py +67 -0
- algo_cli/intelligence/shell_session.py +213 -0
- algo_cli/intelligence/source_registry.py +143 -0
- algo_cli/intelligence/spawn_scales.py +99 -0
- algo_cli/intelligence/stat_stability.py +104 -0
- algo_cli/intelligence/structural_validator.py +148 -0
- algo_cli/intelligence/subagent_spawner.py +111 -0
- algo_cli/intelligence/symmetric_verify.py +70 -0
- algo_cli/intelligence/task_classifier.py +129 -0
- algo_cli/intelligence/team_execution.py +122 -0
- algo_cli/intelligence/tiered_access.py +121 -0
- algo_cli/intelligence/utility_registry.py +159 -0
- algo_cli/intuition_engine.py +560 -0
- algo_cli/intuition_injector.py +82 -0
- algo_cli/kernels/__init__.py +5 -0
- algo_cli/kernels/manifest.py +763 -0
- algo_cli/main.py +3903 -0
- algo_cli/memory_candidates.py +541 -0
- algo_cli/memory_echo_veil.py +394 -0
- algo_cli/memory_runtime.py +112 -0
- algo_cli/model_info.py +548 -0
- algo_cli/model_profile.py +160 -0
- algo_cli/model_routing.py +74 -0
- algo_cli/oneshot.py +331 -0
- algo_cli/perf_telemetry.py +389 -0
- algo_cli/plugins.py +245 -0
- algo_cli/private_event_store.py +654 -0
- algo_cli/quantization/__init__.py +24 -0
- algo_cli/quantization/lloyd_max.py +98 -0
- algo_cli/quantization/turbo_quant.py +308 -0
- algo_cli/reasoning/__init__.py +46 -0
- algo_cli/reasoning/combinatorial.py +356 -0
- algo_cli/reasoning/graph_of_thought.py +297 -0
- algo_cli/reasoning/mcts.py +220 -0
- algo_cli/reasoning/neuro_symbolic.py +250 -0
- algo_cli/reasoning/react.py +246 -0
- algo_cli/reasoning/reflexion.py +225 -0
- algo_cli/reasoning/tree_of_thought.py +241 -0
- algo_cli/reasoning_bridge.py +150 -0
- algo_cli/reconciliation.py +284 -0
- algo_cli/reflex.py +385 -0
- algo_cli/resources/docs/ALGO.md +13958 -0
- algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
- algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
- algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
- algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
- algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
- algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
- algo_cli/resources/docs/main-split-map.md +35 -0
- algo_cli/resources/docs/privacy-and-context.md +48 -0
- algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
- algo_cli/resources/skills/README.md +26 -0
- algo_cli/resources/skills/algo-cli.md +59 -0
- algo_cli/resources/skills/edit-file-precision.md +49 -0
- algo_cli/resources/skills/harness-search-first.md +47 -0
- algo_cli/resources/skills/memory-recall-ritual.md +51 -0
- algo_cli/resources/skills/qol-algorithms.md +224 -0
- algo_cli/resources/skills/smart-error-recovery.md +56 -0
- algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
- algo_cli/retrieval_algorithms.py +127 -0
- algo_cli/runtime_qos.py +236 -0
- algo_cli/runtime_services.py +320 -0
- algo_cli/session_commands.py +95 -0
- algo_cli/session_mode.py +113 -0
- algo_cli/skills.py +430 -0
- algo_cli/slash_dispatch.py +1265 -0
- algo_cli/small_context.py +206 -0
- algo_cli/spawn_budget.py +89 -0
- algo_cli/task_ledger.py +84 -0
- algo_cli/task_router.py +197 -0
- algo_cli/tool_context.py +94 -0
- algo_cli/tool_contract.py +99 -0
- algo_cli/tool_policy.py +357 -0
- algo_cli/tool_runtime.py +647 -0
- algo_cli/tools.py +3056 -0
- algo_cli/url_scheme.py +174 -0
- algo_cli/verify.py +154 -0
- algo_cli/version_manifest.py +178 -0
- algo_cli/vision_screenshot_verify.py +76 -0
- algo_cli/workspace_resolver.py +68 -0
- algo_cli/x_account.py +209 -0
- algo_cli/xai_auth.py +374 -0
- algo_cli/xai_client.py +600 -0
- algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
- algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
- algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
- algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
- algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
- ollama_cli/__init__.py +67 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Token Bucket Rate Limiter — smooth traffic shaping.
|
|
2
|
+
|
|
3
|
+
A token bucket fills at a fixed rate (tokens per second) up to a maximum
|
|
4
|
+
capacity (burst size). Each request consumes one or more tokens. If
|
|
5
|
+
insufficient tokens are available, the request is denied (or delayed).
|
|
6
|
+
|
|
7
|
+
Harness use:
|
|
8
|
+
- Throttle Ollama API calls to avoid rate limits
|
|
9
|
+
- Throttle embedding batches
|
|
10
|
+
- Throttle web fetches
|
|
11
|
+
- Throttle harness indexing rate
|
|
12
|
+
|
|
13
|
+
The leaky bucket variant (output rate constant) is also provided.
|
|
14
|
+
|
|
15
|
+
Operations:
|
|
16
|
+
- try_acquire(tokens=1): non-blocking, returns True/False
|
|
17
|
+
- acquire(tokens=1): blocking, sleeps until tokens available
|
|
18
|
+
- wait_time(tokens=1): returns seconds to wait
|
|
19
|
+
|
|
20
|
+
Properties:
|
|
21
|
+
- Allows bursts up to capacity
|
|
22
|
+
- Long-term rate is capped at refill_rate
|
|
23
|
+
- O(1) per operation
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import time
|
|
28
|
+
from collections import deque
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TokenBucket:
|
|
33
|
+
"""Token bucket rate limiter.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
capacity: Maximum tokens the bucket can hold (burst size).
|
|
37
|
+
refill_rate: Tokens added per second (sustained rate).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, capacity: float, refill_rate: float) -> None:
|
|
41
|
+
if capacity <= 0:
|
|
42
|
+
raise ValueError("capacity must be positive")
|
|
43
|
+
if refill_rate <= 0:
|
|
44
|
+
raise ValueError("refill_rate must be positive")
|
|
45
|
+
self.capacity = float(capacity)
|
|
46
|
+
self.refill_rate = float(refill_rate)
|
|
47
|
+
self._tokens = float(capacity) # start full
|
|
48
|
+
self._last_refill = time.monotonic()
|
|
49
|
+
|
|
50
|
+
def _refill(self) -> None:
|
|
51
|
+
"""Add tokens based on elapsed time since last refill."""
|
|
52
|
+
now = time.monotonic()
|
|
53
|
+
elapsed = now - self._last_refill
|
|
54
|
+
self._tokens = min(self.capacity, self._tokens + elapsed * self.refill_rate)
|
|
55
|
+
self._last_refill = now
|
|
56
|
+
|
|
57
|
+
def try_acquire(self, tokens: float = 1.0) -> bool:
|
|
58
|
+
"""Non-blocking attempt to acquire tokens.
|
|
59
|
+
|
|
60
|
+
Returns True if tokens were acquired, False if insufficient.
|
|
61
|
+
"""
|
|
62
|
+
self._refill()
|
|
63
|
+
if self._tokens >= tokens:
|
|
64
|
+
self._tokens -= tokens
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
def wait_time(self, tokens: float = 1.0) -> float:
|
|
69
|
+
"""Return seconds to wait until tokens are available."""
|
|
70
|
+
self._refill()
|
|
71
|
+
if self._tokens >= tokens:
|
|
72
|
+
return 0.0
|
|
73
|
+
needed = tokens - self._tokens
|
|
74
|
+
return needed / self.refill_rate
|
|
75
|
+
|
|
76
|
+
def acquire(self, tokens: float = 1.0, timeout: float | None = None) -> bool:
|
|
77
|
+
"""Blocking acquire — sleeps until tokens are available.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
tokens: Number of tokens to acquire.
|
|
81
|
+
timeout: Maximum seconds to wait. None = wait forever.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if acquired, False if timed out.
|
|
85
|
+
"""
|
|
86
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
87
|
+
while True:
|
|
88
|
+
wait = self.wait_time(tokens)
|
|
89
|
+
if wait == 0.0:
|
|
90
|
+
self._refill()
|
|
91
|
+
self._tokens -= tokens
|
|
92
|
+
return True
|
|
93
|
+
if deadline is not None and time.monotonic() + wait > deadline:
|
|
94
|
+
return False
|
|
95
|
+
time.sleep(wait)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def available_tokens(self) -> float:
|
|
99
|
+
"""Current token count (after refilling)."""
|
|
100
|
+
self._refill()
|
|
101
|
+
return self._tokens
|
|
102
|
+
|
|
103
|
+
def stats(self) -> dict[str, Any]:
|
|
104
|
+
return {
|
|
105
|
+
"capacity": self.capacity,
|
|
106
|
+
"refill_rate": self.refill_rate,
|
|
107
|
+
"available_tokens": round(self.available_tokens, 2),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SlidingWindowCounter:
|
|
112
|
+
"""Sliding window rate limiter — counts requests in a time window.
|
|
113
|
+
|
|
114
|
+
Uses a deque of timestamps, pruning entries older than the window.
|
|
115
|
+
Simple and accurate for small windows.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
window_seconds: Size of the sliding window.
|
|
119
|
+
max_requests: Maximum requests allowed in the window.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self, window_seconds: float, max_requests: int) -> None:
|
|
123
|
+
self.window_seconds = window_seconds
|
|
124
|
+
self.max_requests = max_requests
|
|
125
|
+
self._timestamps: deque[float] = deque()
|
|
126
|
+
|
|
127
|
+
def _prune(self, now: float) -> None:
|
|
128
|
+
"""Remove timestamps outside the window."""
|
|
129
|
+
cutoff = now - self.window_seconds
|
|
130
|
+
while self._timestamps and self._timestamps[0] <= cutoff:
|
|
131
|
+
self._timestamps.popleft()
|
|
132
|
+
|
|
133
|
+
def try_acquire(self) -> bool:
|
|
134
|
+
"""Non-blocking attempt to make a request."""
|
|
135
|
+
now = time.monotonic()
|
|
136
|
+
self._prune(now)
|
|
137
|
+
if len(self._timestamps) < self.max_requests:
|
|
138
|
+
self._timestamps.append(now)
|
|
139
|
+
return True
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
def current_count(self) -> int:
|
|
143
|
+
"""Number of requests in the current window."""
|
|
144
|
+
self._prune(time.monotonic())
|
|
145
|
+
return len(self._timestamps)
|
|
146
|
+
|
|
147
|
+
def stats(self) -> dict[str, Any]:
|
|
148
|
+
return {
|
|
149
|
+
"window_seconds": self.window_seconds,
|
|
150
|
+
"max_requests": self.max_requests,
|
|
151
|
+
"current_count": self.current_count(),
|
|
152
|
+
"remaining": max(0, self.max_requests - self.current_count()),
|
|
153
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""B83. Refactor Transaction with Savepoint/Rollback.
|
|
2
|
+
|
|
3
|
+
Atomic refactoring with savepoints and rollback.
|
|
4
|
+
Source: CCASP pattern.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum, auto
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TransactionStatus(Enum):
|
|
14
|
+
ACTIVE = auto()
|
|
15
|
+
COMMITTED = auto()
|
|
16
|
+
ROLLED_BACK = auto()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Savepoint:
|
|
21
|
+
name: str
|
|
22
|
+
file_snapshots: dict[str, str] = field(default_factory=dict) # path → content
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class TransactionResult:
|
|
27
|
+
status: TransactionStatus
|
|
28
|
+
savepoints: list[str] = field(default_factory=list)
|
|
29
|
+
files_modified: list[str] = field(default_factory=list)
|
|
30
|
+
error: str = ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RefactorTransaction:
|
|
34
|
+
"""Atomic refactoring with savepoint/rollback for multi-file changes."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, workspace: Path) -> None:
|
|
37
|
+
self._workspace = workspace
|
|
38
|
+
self._savepoints: list[Savepoint] = []
|
|
39
|
+
self._modified: set[str] = set()
|
|
40
|
+
self._status: TransactionStatus = TransactionStatus.ACTIVE
|
|
41
|
+
|
|
42
|
+
def savepoint(self, name: str, files: list[str]) -> Savepoint:
|
|
43
|
+
"""Create a savepoint with snapshots of the given files."""
|
|
44
|
+
if self._status != TransactionStatus.ACTIVE:
|
|
45
|
+
raise RuntimeError("Transaction not active")
|
|
46
|
+
|
|
47
|
+
sp = Savepoint(name=name)
|
|
48
|
+
for filepath in files:
|
|
49
|
+
try:
|
|
50
|
+
content = Path(filepath).read_text(encoding="utf-8")
|
|
51
|
+
sp.file_snapshots[filepath] = content
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
sp.file_snapshots[filepath] = ""
|
|
54
|
+
self._savepoints.append(sp)
|
|
55
|
+
return sp
|
|
56
|
+
|
|
57
|
+
def rollback_to(self, savepoint_name: str) -> None:
|
|
58
|
+
"""Rollback to a named savepoint."""
|
|
59
|
+
sp = next((s for s in self._savepoints if s.name == savepoint_name), None)
|
|
60
|
+
if not sp:
|
|
61
|
+
raise KeyError(f"Savepoint '{savepoint_name}' not found")
|
|
62
|
+
|
|
63
|
+
for filepath, content in sp.file_snapshots.items():
|
|
64
|
+
Path(filepath).write_text(content, encoding="utf-8")
|
|
65
|
+
|
|
66
|
+
# Remove savepoints after this one
|
|
67
|
+
idx = self._savepoints.index(sp)
|
|
68
|
+
self._savepoints = self._savepoints[:idx + 1]
|
|
69
|
+
|
|
70
|
+
def rollback_all(self) -> None:
|
|
71
|
+
"""Rollback to the first savepoint (undo everything)."""
|
|
72
|
+
if self._savepoints:
|
|
73
|
+
self.rollback_to(self._savepoints[0].name)
|
|
74
|
+
self._status = TransactionStatus.ROLLED_BACK
|
|
75
|
+
|
|
76
|
+
def commit(self) -> TransactionResult:
|
|
77
|
+
"""Commit the transaction — changes are permanent."""
|
|
78
|
+
self._status = TransactionStatus.COMMITTED
|
|
79
|
+
return TransactionResult(
|
|
80
|
+
status=self._status,
|
|
81
|
+
savepoints=[sp.name for sp in self._savepoints],
|
|
82
|
+
files_modified=list(self._modified),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def record_modification(self, filepath: str) -> None:
|
|
86
|
+
self._modified.add(filepath)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def status(self) -> TransactionStatus:
|
|
90
|
+
return self._status
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def savepoint_names(self) -> list[str]:
|
|
94
|
+
return [sp.name for sp in self._savepoints]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""B68. Virtual File System for Research Artifacts.
|
|
2
|
+
|
|
3
|
+
Branch, persist, rejoin research investigations.
|
|
4
|
+
Source: Morgana pattern.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ResearchArtifact:
|
|
14
|
+
id: str
|
|
15
|
+
name: str
|
|
16
|
+
content: str
|
|
17
|
+
artifact_type: str = "text" # text, outline, finding, source, draft
|
|
18
|
+
created_at: float = field(default_factory=time.time)
|
|
19
|
+
tags: list[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ResearchBranch:
|
|
24
|
+
id: str
|
|
25
|
+
parent_id: str | None = None
|
|
26
|
+
name: str = ""
|
|
27
|
+
artifacts: dict[str, ResearchArtifact] = field(default_factory=dict)
|
|
28
|
+
created_at: float = field(default_factory=time.time)
|
|
29
|
+
status: str = "active" # active, merged, abandoned
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ResearchWorkspace:
|
|
33
|
+
"""Virtual file system for research artifacts with branching."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self._branches: dict[str, ResearchBranch] = {}
|
|
37
|
+
self._counter = 0
|
|
38
|
+
# Create root branch
|
|
39
|
+
self.create_branch("main", name="Main Research")
|
|
40
|
+
|
|
41
|
+
def create_branch(self, branch_id: str, parent_id: str | None = None,
|
|
42
|
+
name: str = "") -> ResearchBranch:
|
|
43
|
+
branch = ResearchBranch(id=branch_id, parent_id=parent_id, name=name)
|
|
44
|
+
self._branches[branch_id] = branch
|
|
45
|
+
return branch
|
|
46
|
+
|
|
47
|
+
def add_artifact(self, branch_id: str, artifact: ResearchArtifact) -> None:
|
|
48
|
+
branch = self._branches.get(branch_id)
|
|
49
|
+
if not branch:
|
|
50
|
+
raise KeyError(f"Branch {branch_id} not found")
|
|
51
|
+
branch.artifacts[artifact.id] = artifact
|
|
52
|
+
|
|
53
|
+
def get_artifact(self, branch_id: str, artifact_id: str) -> ResearchArtifact | None:
|
|
54
|
+
branch = self._branches.get(branch_id)
|
|
55
|
+
if not branch:
|
|
56
|
+
return None
|
|
57
|
+
if artifact_id in branch.artifacts:
|
|
58
|
+
return branch.artifacts[artifact_id]
|
|
59
|
+
# Walk parent chain
|
|
60
|
+
if branch.parent_id:
|
|
61
|
+
return self.get_artifact(branch.parent_id, artifact_id)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def list_artifacts(self, branch_id: str, include_parent: bool = True) -> list[ResearchArtifact]:
|
|
65
|
+
branch = self._branches.get(branch_id)
|
|
66
|
+
if not branch:
|
|
67
|
+
return []
|
|
68
|
+
artifacts = list(branch.artifacts.values())
|
|
69
|
+
if include_parent and branch.parent_id:
|
|
70
|
+
artifacts.extend(self.list_artifacts(branch.parent_id, include_parent=True))
|
|
71
|
+
return artifacts
|
|
72
|
+
|
|
73
|
+
def merge(self, source_id: str, target_id: str) -> int:
|
|
74
|
+
"""Merge artifacts from source branch into target. Returns count merged."""
|
|
75
|
+
source = self._branches.get(source_id)
|
|
76
|
+
target = self._branches.get(target_id)
|
|
77
|
+
if not source or not target:
|
|
78
|
+
return 0
|
|
79
|
+
count = 0
|
|
80
|
+
for aid, artifact in source.artifacts.items():
|
|
81
|
+
if aid not in target.artifacts:
|
|
82
|
+
target.artifacts[aid] = artifact
|
|
83
|
+
count += 1
|
|
84
|
+
source.status = "merged"
|
|
85
|
+
return count
|
|
86
|
+
|
|
87
|
+
def search(self, branch_id: str, query: str) -> list[ResearchArtifact]:
|
|
88
|
+
"""Search artifacts by name, content, or tags."""
|
|
89
|
+
artifacts = self.list_artifacts(branch_id)
|
|
90
|
+
query_lower = query.lower()
|
|
91
|
+
return [
|
|
92
|
+
a for a in artifacts
|
|
93
|
+
if query_lower in a.name.lower()
|
|
94
|
+
or query_lower in a.content.lower()
|
|
95
|
+
or any(query_lower in t.lower() for t in a.tags)
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
def new_artifact_id(self) -> str:
|
|
99
|
+
self._counter += 1
|
|
100
|
+
return f"art_{self._counter}"
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def branch_count(self) -> int:
|
|
104
|
+
return len(self._branches)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def artifact_count(self) -> int:
|
|
108
|
+
return sum(len(b.artifacts) for b in self._branches.values())
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""H3 — Retraction Ledger.
|
|
2
|
+
|
|
3
|
+
Append-only retraction records, never silent deletes.
|
|
4
|
+
Mined from T3MP3ST INTEGRITY_LEDGER.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RetractionEntry:
|
|
15
|
+
"""A single retraction record."""
|
|
16
|
+
|
|
17
|
+
id: str
|
|
18
|
+
target_id: str
|
|
19
|
+
reason: str
|
|
20
|
+
retracted_at: float = field(default_factory=time.time)
|
|
21
|
+
retracted_by: str = ""
|
|
22
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, Any]:
|
|
25
|
+
return {
|
|
26
|
+
"id": self.id,
|
|
27
|
+
"target_id": self.target_id,
|
|
28
|
+
"reason": self.reason,
|
|
29
|
+
"retracted_at": self.retracted_at,
|
|
30
|
+
"retracted_by": self.retracted_by,
|
|
31
|
+
"metadata": dict(self.metadata),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RetractionLedger:
|
|
36
|
+
"""Append-only ledger — entries can never be deleted, only added."""
|
|
37
|
+
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._entries: list[RetractionEntry] = []
|
|
40
|
+
|
|
41
|
+
def add(
|
|
42
|
+
self,
|
|
43
|
+
target_id: str,
|
|
44
|
+
reason: str,
|
|
45
|
+
retracted_by: str = "",
|
|
46
|
+
metadata: dict[str, Any] | None = None,
|
|
47
|
+
) -> RetractionEntry:
|
|
48
|
+
entry = RetractionEntry(
|
|
49
|
+
id=f"retraction-{len(self._entries) + 1:06d}",
|
|
50
|
+
target_id=target_id,
|
|
51
|
+
reason=reason,
|
|
52
|
+
retracted_by=retracted_by,
|
|
53
|
+
metadata=metadata or {},
|
|
54
|
+
)
|
|
55
|
+
self._entries.append(entry)
|
|
56
|
+
return entry
|
|
57
|
+
|
|
58
|
+
def is_retracted(self, target_id: str) -> bool:
|
|
59
|
+
return any(e.target_id == target_id for e in self._entries)
|
|
60
|
+
|
|
61
|
+
def get_retractions(self, target_id: str) -> list[RetractionEntry]:
|
|
62
|
+
return [e for e in self._entries if e.target_id == target_id]
|
|
63
|
+
|
|
64
|
+
def all(self) -> list[RetractionEntry]:
|
|
65
|
+
return list(self._entries)
|
|
66
|
+
|
|
67
|
+
def count(self) -> int:
|
|
68
|
+
return len(self._entries)
|
|
69
|
+
|
|
70
|
+
def delete(self, id: str) -> bool:
|
|
71
|
+
"""Always returns False — retractions are permanent."""
|
|
72
|
+
return False
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""B74. Saga Pattern for Multi-Agent Compensation.
|
|
2
|
+
|
|
3
|
+
Each step defines a compensating action. On failure, execute
|
|
4
|
+
compensations in reverse order.
|
|
5
|
+
Source: Claudient pattern.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum, auto
|
|
11
|
+
from typing import Callable, Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SagaStepStatus(Enum):
|
|
15
|
+
PENDING = auto()
|
|
16
|
+
RUNNING = auto()
|
|
17
|
+
DONE = auto()
|
|
18
|
+
FAILED = auto()
|
|
19
|
+
COMPENSATED = auto()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SagaStep:
|
|
24
|
+
name: str
|
|
25
|
+
execute: Callable[[], Any]
|
|
26
|
+
compensate: Callable[[], Any] | None = None
|
|
27
|
+
status: SagaStepStatus = SagaStepStatus.PENDING
|
|
28
|
+
result: Any = None
|
|
29
|
+
error: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SagaResult:
|
|
34
|
+
success: bool
|
|
35
|
+
completed_steps: list[str] = field(default_factory=list)
|
|
36
|
+
compensated_steps: list[str] = field(default_factory=list)
|
|
37
|
+
failed_step: str | None = None
|
|
38
|
+
error: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SagaOrchestrator:
|
|
42
|
+
"""Execute multi-step operations with automatic rollback."""
|
|
43
|
+
|
|
44
|
+
def __init__(self) -> None:
|
|
45
|
+
self._steps: list[SagaStep] = []
|
|
46
|
+
|
|
47
|
+
def add_step(self, step: SagaStep) -> None:
|
|
48
|
+
self._steps.append(step)
|
|
49
|
+
|
|
50
|
+
def execute(self) -> SagaResult:
|
|
51
|
+
"""Execute all steps. On failure, compensate in reverse."""
|
|
52
|
+
result = SagaResult(success=True)
|
|
53
|
+
|
|
54
|
+
for step in self._steps:
|
|
55
|
+
step.status = SagaStepStatus.RUNNING
|
|
56
|
+
try:
|
|
57
|
+
step.result = step.execute()
|
|
58
|
+
step.status = SagaStepStatus.DONE
|
|
59
|
+
result.completed_steps.append(step.name)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
step.error = str(e)
|
|
62
|
+
step.status = SagaStepStatus.FAILED
|
|
63
|
+
result.success = False
|
|
64
|
+
result.failed_step = step.name
|
|
65
|
+
result.error = str(e)
|
|
66
|
+
|
|
67
|
+
# Compensate in reverse
|
|
68
|
+
self._compensate(result)
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
def _compensate(self, result: SagaResult) -> None:
|
|
74
|
+
"""Run compensations in reverse order."""
|
|
75
|
+
for step in reversed(self._steps):
|
|
76
|
+
if step.status != SagaStepStatus.DONE:
|
|
77
|
+
continue
|
|
78
|
+
if step.compensate:
|
|
79
|
+
try:
|
|
80
|
+
step.compensate()
|
|
81
|
+
step.status = SagaStepStatus.COMPENSATED
|
|
82
|
+
result.compensated_steps.append(step.name)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass # compensation failed — log but continue
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def steps(self) -> list[SagaStep]:
|
|
88
|
+
return list(self._steps)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""B52. Session Forking + Compaction Circuit Breaker.
|
|
2
|
+
|
|
3
|
+
Fork sessions with parent pointers (no message duplication).
|
|
4
|
+
Compaction circuit breaker prevents infinite compaction loops.
|
|
5
|
+
Source: aloop pattern.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Message:
|
|
15
|
+
role: str # "user", "assistant", "tool"
|
|
16
|
+
content: str
|
|
17
|
+
tool_call_id: str | None = None
|
|
18
|
+
timestamp: float = field(default_factory=time.time)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SessionFork:
|
|
23
|
+
id: str
|
|
24
|
+
parent_id: str | None = None
|
|
25
|
+
messages: list[Message] = field(default_factory=list)
|
|
26
|
+
fork_point: int = 0 # index in parent's messages where fork occurred
|
|
27
|
+
depth: int = 0
|
|
28
|
+
created_at: float = field(default_factory=time.time)
|
|
29
|
+
materialized: bool = False
|
|
30
|
+
|
|
31
|
+
MAX_FORK_DEPTH = 10
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SessionStore:
|
|
35
|
+
"""Manage sessions with fork support and compaction circuit breaker."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, max_compaction_failures: int = 3) -> None:
|
|
38
|
+
self._sessions: dict[str, SessionFork] = {}
|
|
39
|
+
self._compaction_failures: dict[str, int] = {}
|
|
40
|
+
self._max_compaction_failures = max_compaction_failures
|
|
41
|
+
|
|
42
|
+
def create(self, session_id: str, messages: list[Message] | None = None) -> SessionFork:
|
|
43
|
+
s = SessionFork(id=session_id, messages=list(messages or []))
|
|
44
|
+
self._sessions[session_id] = s
|
|
45
|
+
return s
|
|
46
|
+
|
|
47
|
+
def fork(
|
|
48
|
+
self,
|
|
49
|
+
parent_id: str,
|
|
50
|
+
fork_id: str,
|
|
51
|
+
fork_point: int | None = None,
|
|
52
|
+
) -> SessionFork:
|
|
53
|
+
parent = self._sessions.get(parent_id)
|
|
54
|
+
if parent is None:
|
|
55
|
+
raise KeyError(f"Parent session {parent_id} not found")
|
|
56
|
+
if parent.depth >= SessionFork.MAX_FORK_DEPTH:
|
|
57
|
+
raise ValueError(f"Max fork depth ({SessionFork.MAX_FORK_DEPTH}) exceeded")
|
|
58
|
+
|
|
59
|
+
fp = fork_point if fork_point is not None else len(parent.messages)
|
|
60
|
+
child = SessionFork(
|
|
61
|
+
id=fork_id,
|
|
62
|
+
parent_id=parent_id,
|
|
63
|
+
fork_point=fp,
|
|
64
|
+
depth=parent.depth + 1,
|
|
65
|
+
)
|
|
66
|
+
# Child does NOT copy messages — uses parent pointer
|
|
67
|
+
self._sessions[fork_id] = child
|
|
68
|
+
return child
|
|
69
|
+
|
|
70
|
+
def get_messages(self, session_id: str) -> list[Message]:
|
|
71
|
+
"""Resolve messages by walking parent chain."""
|
|
72
|
+
s = self._sessions.get(session_id)
|
|
73
|
+
if s is None:
|
|
74
|
+
return []
|
|
75
|
+
if s.parent_id is None:
|
|
76
|
+
return list(s.messages)
|
|
77
|
+
parent_msgs = self.get_messages(s.parent_id)[: s.fork_point]
|
|
78
|
+
return parent_msgs + list(s.messages)
|
|
79
|
+
|
|
80
|
+
def materialize(self, session_id: str) -> list[Message]:
|
|
81
|
+
"""Flatten fork chain into a single message list."""
|
|
82
|
+
msgs = self.get_messages(session_id)
|
|
83
|
+
s = self._sessions[session_id]
|
|
84
|
+
s.materialized = True
|
|
85
|
+
s.messages = list(msgs)
|
|
86
|
+
s.parent_id = None
|
|
87
|
+
return msgs
|
|
88
|
+
|
|
89
|
+
def can_compact(self, session_id: str) -> bool:
|
|
90
|
+
"""Circuit breaker: prevent compaction if too many failures."""
|
|
91
|
+
return self._compaction_failures.get(session_id, 0) < self._max_compaction_failures
|
|
92
|
+
|
|
93
|
+
def record_compaction_failure(self, session_id: str) -> None:
|
|
94
|
+
self._compaction_failures[session_id] = self._compaction_failures.get(session_id, 0) + 1
|
|
95
|
+
|
|
96
|
+
def reset_compaction(self, session_id: str) -> None:
|
|
97
|
+
self._compaction_failures.pop(session_id, None)
|
|
98
|
+
|
|
99
|
+
def list_forks(self, parent_id: str) -> list[str]:
|
|
100
|
+
return [sid for sid, s in self._sessions.items() if s.parent_id == parent_id]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""B79. Shadow Editor: LSP Diff Validation.
|
|
2
|
+
|
|
3
|
+
Diff LSP diagnostics before and after each edit to catch introduced errors.
|
|
4
|
+
Source: Pathfinder pattern.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class LSPDiagnostic:
|
|
14
|
+
severity: str
|
|
15
|
+
message: str
|
|
16
|
+
line: int
|
|
17
|
+
col: int
|
|
18
|
+
source: str = ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class EditValidation:
|
|
23
|
+
safe: bool
|
|
24
|
+
introduced: list[LSPDiagnostic] = field(default_factory=list)
|
|
25
|
+
resolved: list[LSPDiagnostic] = field(default_factory=list)
|
|
26
|
+
unchanged: list[LSPDiagnostic] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ShadowEditor:
|
|
30
|
+
"""Validate edits against LSP before writing to disk."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, get_diagnostics_fn: Callable[[str, str], list[LSPDiagnostic]] | None = None) -> None:
|
|
33
|
+
self._get_diagnostics = get_diagnostics_fn or self._noop_diagnostics
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def _noop_diagnostics(filepath: str, content: str) -> list[LSPDiagnostic]:
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
def validate_edit(self, filepath: str, old_content: str, new_content: str) -> EditValidation:
|
|
40
|
+
"""Validate an edit by comparing diagnostics before and after."""
|
|
41
|
+
before = self._get_diagnostics(filepath, old_content)
|
|
42
|
+
after = self._get_diagnostics(filepath, new_content)
|
|
43
|
+
|
|
44
|
+
introduced = self._diff_new(before, after)
|
|
45
|
+
resolved = self._diff_new(after, before)
|
|
46
|
+
unchanged = self._intersect(before, after)
|
|
47
|
+
|
|
48
|
+
return EditValidation(
|
|
49
|
+
safe=len([d for d in introduced if d.severity == "error"]) == 0,
|
|
50
|
+
introduced=introduced,
|
|
51
|
+
resolved=resolved,
|
|
52
|
+
unchanged=unchanged,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def _diff_new(self, before: list[LSPDiagnostic], after: list[LSPDiagnostic]) -> list[LSPDiagnostic]:
|
|
56
|
+
"""Find diagnostics in 'after' that weren't in 'before'."""
|
|
57
|
+
before_keys = {(d.severity, d.message, d.line) for d in before}
|
|
58
|
+
return [d for d in after if (d.severity, d.message, d.line) not in before_keys]
|
|
59
|
+
|
|
60
|
+
def _intersect(self, before: list[LSPDiagnostic], after: list[LSPDiagnostic]) -> list[LSPDiagnostic]:
|
|
61
|
+
"""Find diagnostics present in both."""
|
|
62
|
+
after_keys = {(d.severity, d.message, d.line) for d in after}
|
|
63
|
+
return [d for d in before if (d.severity, d.message, d.line) in after_keys]
|
|
64
|
+
|
|
65
|
+
def should_block(self, validation: EditValidation) -> bool:
|
|
66
|
+
"""Should the edit be blocked?"""
|
|
67
|
+
return any(d.severity == "error" for d in validation.introduced)
|