@trac3er/oh-my-god 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/build/lib/control_plane/service.py +40 -1
- package/build/lib/hooks/firewall.py +119 -4
- package/build/lib/hooks/shadow_manager.py +111 -2
- package/build/lib/hooks/stop_dispatcher.py +108 -4
- package/build/lib/runtime/claim_judge.py +90 -0
- package/build/lib/runtime/context_engine.py +19 -4
- package/build/lib/runtime/contract_compiler.py +249 -27
- package/build/lib/runtime/forge_agents.py +36 -4
- package/build/lib/runtime/forge_contracts.py +44 -0
- package/build/lib/runtime/interaction_journal.py +318 -42
- package/build/lib/runtime/mutation_gate.py +57 -11
- package/build/lib/runtime/omg_mcp_server.py +14 -4
- package/build/lib/runtime/proof_gate.py +79 -0
- package/build/lib/runtime/release_run_coordinator.py +339 -0
- package/build/lib/runtime/rollback_manifest.py +136 -0
- package/build/lib/runtime/router_critics.py +50 -0
- package/build/lib/runtime/runtime_contracts.py +47 -0
- package/build/lib/runtime/session_health.py +12 -1
- package/build/lib/runtime/team_router.py +91 -7
- package/build/lib/runtime/test_intent_lock.py +102 -0
- package/build/lib/runtime/tool_plan_gate.py +136 -19
- package/control_plane/service.py +40 -1
- package/docs/proof.md +8 -0
- package/docs/release-checklist.md +8 -0
- package/hooks/firewall.py +119 -4
- package/hooks/shadow_manager.py +111 -2
- package/hooks/stop_dispatcher.py +108 -4
- package/hud/omg-hud.mjs +14 -3
- package/lab/pipeline.py +2 -0
- package/package.json +1 -1
- package/plugins/advanced/plugin.json +1 -1
- package/plugins/core/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/runtime/adoption.py +1 -1
- package/runtime/claim_judge.py +90 -0
- package/runtime/context_engine.py +19 -4
- package/runtime/contract_compiler.py +249 -27
- package/runtime/forge_agents.py +36 -4
- package/runtime/forge_contracts.py +44 -0
- package/runtime/interaction_journal.py +318 -42
- package/runtime/mutation_gate.py +57 -11
- package/runtime/omg_mcp_server.py +14 -4
- package/runtime/proof_gate.py +79 -0
- package/runtime/release_run_coordinator.py +339 -0
- package/runtime/rollback_manifest.py +136 -0
- package/runtime/router_critics.py +50 -0
- package/runtime/runtime_contracts.py +47 -0
- package/runtime/session_health.py +12 -1
- package/runtime/team_router.py +91 -7
- package/runtime/test_intent_lock.py +102 -0
- package/runtime/tool_plan_gate.py +136 -19
- package/scripts/omg.py +44 -2
- package/scripts/prepare-release-proof-fixtures.py +102 -7
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "OMG - Oh-My-God for Claude Code and supported agent hosts",
|
|
9
|
-
"version": "2.1.
|
|
9
|
+
"version": "2.1.1",
|
|
10
10
|
"homepage": "https://github.com/trac3er00/OMG",
|
|
11
11
|
"repository": "https://github.com/trac3er00/OMG"
|
|
12
12
|
},
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
{
|
|
15
15
|
"name": "omg",
|
|
16
16
|
"description": "OMG plugin layer for Claude Code and supported agent hosts with native setup, orchestration, and interop.",
|
|
17
|
-
"version": "2.1.
|
|
17
|
+
"version": "2.1.1",
|
|
18
18
|
"source": "./",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "trac3er00"
|
|
@@ -32,5 +32,5 @@
|
|
|
32
32
|
]
|
|
33
33
|
}
|
|
34
34
|
],
|
|
35
|
-
"version": "2.1.
|
|
35
|
+
"version": "2.1.1"
|
|
36
36
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 2.1.1 - 2026-03-08
|
|
6
|
+
|
|
7
|
+
- shipped strict TDD-or-die enforcement: locked-tests-first mutation lifecycle with hard gate via `OMG_TDD_GATE_STRICT`, stop-dispatcher lock enforcement, waiver evidence requirement, and release-blocking proof chain
|
|
8
|
+
- delivered granular per-interaction undo/rollback: `restore_shadow_entry` in shadow manager, rollback manifest schema with compensating actions for reversible external side effects, and `omg undo` CLI wired end-to-end
|
|
9
|
+
- added live session health monitoring and HUD freshness: `compute_session_health()` and `DefenseState.update()` called after real mutations and verifications, 5-minute HUD staleness threshold with `[STALE]` badge, and MCP `get_session_health` tool with `/session_health` control-plane route
|
|
10
|
+
- persisted and enforced defense-council verdicts: `run_critics()` result no longer silently discarded, council findings flow into routing, tool-plan gate, claim judge, and release blocking; new `evidence_completeness` critic added
|
|
11
|
+
- hardened Forge starter: strict domain/specialist validation, proof-backed starter evidence, labs-only release proof integration
|
|
12
|
+
- added canonical run-scoped release coordinator (`runtime/release_run_coordinator.py`) as single authority for `run_id` lifecycle across verification, journaling, health, council, and rollback artifacts
|
|
13
|
+
- expanded runtime state contracts for `session_health`, `council_verdicts`, `rollback_manifest`, and `release_run` as first-class versioned schemas
|
|
14
|
+
- all new hard gates are feature-flagged permissive by default (`OMG_TDD_GATE_STRICT=1`, `OMG_RUN_COORDINATOR_STRICT=1`, `OMG_PROOF_CHAIN_STRICT=1`)
|
|
15
|
+
|
|
5
16
|
## 2.1.0 - 2026-03-08
|
|
6
17
|
|
|
7
18
|
- promoted the execution-primitives and browser-surface wave into the v2.1.0 release train
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
import os
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
9
10
|
from hooks.policy_engine import (
|
|
@@ -20,6 +21,7 @@ from runtime.guide_assert import guide_assert
|
|
|
20
21
|
from runtime.dispatcher import dispatch_runtime
|
|
21
22
|
from runtime.claim_judge import judge_claims
|
|
22
23
|
from runtime.mutation_gate import check_mutation_allowed
|
|
24
|
+
from runtime.runtime_contracts import read_run_state
|
|
23
25
|
from runtime.security_check import run_security_check
|
|
24
26
|
from runtime.test_intent_lock import lock_intent, verify_intent
|
|
25
27
|
|
|
@@ -275,15 +277,24 @@ class ControlPlaneService:
|
|
|
275
277
|
file_path = payload.get("file_path")
|
|
276
278
|
lock_id = payload.get("lock_id")
|
|
277
279
|
exemption = payload.get("exemption")
|
|
280
|
+
command = payload.get("command")
|
|
281
|
+
run_id = payload.get("run_id")
|
|
282
|
+
metadata = payload.get("metadata")
|
|
278
283
|
|
|
279
284
|
if not isinstance(tool, str) or not tool.strip():
|
|
280
285
|
raise ValueError("tool is required")
|
|
281
|
-
if not isinstance(file_path, str) or not file_path.strip():
|
|
286
|
+
if not isinstance(file_path, str) or (tool != "Bash" and not file_path.strip()):
|
|
282
287
|
raise ValueError("file_path is required")
|
|
283
288
|
if lock_id is not None and not isinstance(lock_id, str):
|
|
284
289
|
raise ValueError("lock_id must be a string when provided")
|
|
285
290
|
if exemption is not None and not isinstance(exemption, str):
|
|
286
291
|
raise ValueError("exemption must be a string when provided")
|
|
292
|
+
if command is not None and not isinstance(command, str):
|
|
293
|
+
raise ValueError("command must be a string when provided")
|
|
294
|
+
if run_id is not None and not isinstance(run_id, str):
|
|
295
|
+
raise ValueError("run_id must be a string when provided")
|
|
296
|
+
if metadata is not None and not isinstance(metadata, dict):
|
|
297
|
+
raise ValueError("metadata must be an object when provided")
|
|
287
298
|
|
|
288
299
|
result = check_mutation_allowed(
|
|
289
300
|
tool=tool,
|
|
@@ -291,9 +302,37 @@ class ControlPlaneService:
|
|
|
291
302
|
project_dir=self.project_dir,
|
|
292
303
|
lock_id=lock_id,
|
|
293
304
|
exemption=exemption,
|
|
305
|
+
command=command,
|
|
306
|
+
run_id=run_id,
|
|
307
|
+
metadata=metadata,
|
|
294
308
|
)
|
|
295
309
|
return 200, result
|
|
296
310
|
|
|
311
|
+
def session_health(self, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]:
|
|
312
|
+
run_id = payload.get("run_id")
|
|
313
|
+
if isinstance(run_id, str) and run_id.strip():
|
|
314
|
+
state = read_run_state(self.project_dir, "session_health", run_id.strip())
|
|
315
|
+
if state is None:
|
|
316
|
+
return 404, {"status": "error", "message": f"No session health for run_id: {run_id}"}
|
|
317
|
+
return 200, dict(state)
|
|
318
|
+
|
|
319
|
+
health_dir = Path(self.project_dir) / ".omg" / "state" / "session_health"
|
|
320
|
+
if not health_dir.exists():
|
|
321
|
+
return 404, {"status": "error", "message": "No session health state found"}
|
|
322
|
+
|
|
323
|
+
candidates = sorted(
|
|
324
|
+
(f for f in health_dir.iterdir() if f.suffix == ".json" and not f.name.endswith(".tmp")),
|
|
325
|
+
key=lambda p: p.stat().st_mtime,
|
|
326
|
+
)
|
|
327
|
+
if not candidates:
|
|
328
|
+
return 404, {"status": "error", "message": "No session health state found"}
|
|
329
|
+
|
|
330
|
+
latest_run_id = candidates[-1].stem
|
|
331
|
+
state = read_run_state(self.project_dir, "session_health", latest_run_id)
|
|
332
|
+
if state is None:
|
|
333
|
+
return 404, {"status": "error", "message": "Failed to read session health state"}
|
|
334
|
+
return 200, dict(state)
|
|
335
|
+
|
|
297
336
|
def scoreboard_baseline(self) -> tuple[int, dict[str, Any]]:
|
|
298
337
|
return 200, {
|
|
299
338
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
@@ -7,23 +7,105 @@ one centralized decision model.
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
9
|
import sys
|
|
10
|
+
from pathlib import Path
|
|
10
11
|
|
|
11
12
|
HOOKS_DIR = os.path.dirname(__file__)
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
PROJECT_ROOT = os.path.dirname(HOOKS_DIR)
|
|
14
|
+
for path in (HOOKS_DIR, PROJECT_ROOT):
|
|
15
|
+
if path not in sys.path:
|
|
16
|
+
sys.path.insert(0, path)
|
|
14
17
|
|
|
15
|
-
from _common import setup_crash_handler, json_input, deny_decision, is_bypass_mode
|
|
18
|
+
from _common import setup_crash_handler, json_input, deny_decision, is_bypass_mode, get_project_dir # pyright: ignore[reportImplicitRelativeImport]
|
|
16
19
|
|
|
17
20
|
# Fail-closed: deny on crash (security hook)
|
|
18
21
|
setup_crash_handler("firewall", fail_closed=True)
|
|
19
22
|
|
|
20
23
|
try:
|
|
21
|
-
from policy_engine import evaluate_bash_command, to_pretool_hook_output
|
|
24
|
+
from policy_engine import evaluate_bash_command, to_pretool_hook_output # pyright: ignore[reportImplicitRelativeImport]
|
|
25
|
+
from runtime.mutation_gate import check_mutation_allowed
|
|
26
|
+
from runtime.tool_plan_gate import journal_mutation_bash
|
|
22
27
|
except Exception as _import_err:
|
|
23
28
|
print(f"OMG firewall: policy_engine import failed: {_import_err}", file=sys.stderr)
|
|
24
29
|
deny_decision(f"OMG firewall crash: policy_engine import failed: {_import_err}. Denying for safety.")
|
|
25
30
|
sys.exit(0)
|
|
26
31
|
|
|
32
|
+
|
|
33
|
+
def _enrich_risk_context(decision, payload: dict[str, object]):
|
|
34
|
+
try:
|
|
35
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
|
36
|
+
run_id = _resolve_run_id(payload)
|
|
37
|
+
|
|
38
|
+
suffixes: list[str] = []
|
|
39
|
+
defense_risk = _read_defense_risk(project_dir)
|
|
40
|
+
if defense_risk:
|
|
41
|
+
suffixes.append(f"defense risk={defense_risk}")
|
|
42
|
+
|
|
43
|
+
council_signal = _read_council_signal(project_dir, run_id)
|
|
44
|
+
if council_signal:
|
|
45
|
+
suffixes.append(council_signal)
|
|
46
|
+
|
|
47
|
+
if suffixes:
|
|
48
|
+
decision.reason = f"{decision.reason} [{'; '.join(suffixes)}]".strip()
|
|
49
|
+
except Exception:
|
|
50
|
+
return decision
|
|
51
|
+
return decision
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolve_run_id(payload: dict[str, object]) -> str:
|
|
55
|
+
if isinstance(payload, dict):
|
|
56
|
+
run_id = payload.get("run_id")
|
|
57
|
+
if isinstance(run_id, str) and run_id.strip():
|
|
58
|
+
return run_id.strip()
|
|
59
|
+
tool_input = payload.get("tool_input")
|
|
60
|
+
if isinstance(tool_input, dict):
|
|
61
|
+
metadata = tool_input.get("metadata")
|
|
62
|
+
if isinstance(metadata, dict):
|
|
63
|
+
metadata_run_id = metadata.get("run_id")
|
|
64
|
+
if isinstance(metadata_run_id, str) and metadata_run_id.strip():
|
|
65
|
+
return metadata_run_id.strip()
|
|
66
|
+
env_run_id = os.environ.get("OMG_RUN_ID", "")
|
|
67
|
+
return env_run_id.strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_defense_risk(project_dir: str) -> str:
|
|
71
|
+
path = Path(project_dir) / ".omg" / "state" / "defense_state" / "current.json"
|
|
72
|
+
if not path.exists():
|
|
73
|
+
return ""
|
|
74
|
+
try:
|
|
75
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
76
|
+
except (OSError, json.JSONDecodeError):
|
|
77
|
+
return ""
|
|
78
|
+
if not isinstance(payload, dict):
|
|
79
|
+
return ""
|
|
80
|
+
return str(payload.get("risk_level", "")).strip().lower()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _read_council_signal(project_dir: str, run_id: str) -> str:
|
|
84
|
+
if not run_id:
|
|
85
|
+
return ""
|
|
86
|
+
path = Path(project_dir) / ".omg" / "state" / "council_verdicts" / f"{run_id}.json"
|
|
87
|
+
if not path.exists():
|
|
88
|
+
return ""
|
|
89
|
+
try:
|
|
90
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
91
|
+
except (OSError, json.JSONDecodeError):
|
|
92
|
+
return ""
|
|
93
|
+
if not isinstance(payload, dict):
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
verdicts = payload.get("verdicts")
|
|
97
|
+
if not isinstance(verdicts, dict):
|
|
98
|
+
return ""
|
|
99
|
+
evidence = verdicts.get("evidence_completeness")
|
|
100
|
+
if not isinstance(evidence, dict):
|
|
101
|
+
return ""
|
|
102
|
+
verdict = str(evidence.get("verdict", "")).strip().lower()
|
|
103
|
+
findings = evidence.get("findings")
|
|
104
|
+
findings_count = len(findings) if isinstance(findings, list) else 0
|
|
105
|
+
if not verdict:
|
|
106
|
+
return ""
|
|
107
|
+
return f"council evidence={verdict} findings={findings_count}"
|
|
108
|
+
|
|
27
109
|
data = json_input()
|
|
28
110
|
|
|
29
111
|
tool = data.get("tool_name", "")
|
|
@@ -35,12 +117,45 @@ if not cmd:
|
|
|
35
117
|
sys.exit(0)
|
|
36
118
|
|
|
37
119
|
decision = evaluate_bash_command(cmd)
|
|
120
|
+
decision = _enrich_risk_context(decision, data)
|
|
121
|
+
|
|
122
|
+
tool_input = data.get("tool_input")
|
|
123
|
+
metadata = tool_input.get("metadata") if isinstance(tool_input, dict) else None
|
|
124
|
+
lock_id = tool_input.get("lock_id") if isinstance(tool_input, dict) else None
|
|
125
|
+
if not isinstance(lock_id, str) and isinstance(metadata, dict):
|
|
126
|
+
lock_id = metadata.get("lock_id")
|
|
127
|
+
run_id = _resolve_run_id(data)
|
|
128
|
+
|
|
129
|
+
gate_result = check_mutation_allowed(
|
|
130
|
+
tool="Bash",
|
|
131
|
+
file_path=cmd,
|
|
132
|
+
project_dir=get_project_dir(),
|
|
133
|
+
lock_id=lock_id if isinstance(lock_id, str) else None,
|
|
134
|
+
command=cmd,
|
|
135
|
+
run_id=run_id or None,
|
|
136
|
+
metadata=metadata if isinstance(metadata, dict) else None,
|
|
137
|
+
)
|
|
138
|
+
is_mutation_capable = str(gate_result.get("reason", "")) != "tool is read-only for mutation gate"
|
|
139
|
+
if is_mutation_capable and gate_result.get("status") == "blocked":
|
|
140
|
+
deny_decision(str(gate_result.get("reason", "mutation denied by test intent lock gate")))
|
|
141
|
+
sys.exit(0)
|
|
38
142
|
|
|
39
143
|
# In bypass-permission mode, only enforce hard denials (critical safety).
|
|
40
144
|
# Skip "ask" decisions so the user is not prompted for confirmation.
|
|
41
145
|
if is_bypass_mode(data) and decision.action != "deny":
|
|
42
146
|
sys.exit(0)
|
|
43
147
|
|
|
148
|
+
if decision.action == "allow" and is_mutation_capable:
|
|
149
|
+
try:
|
|
150
|
+
journal_mutation_bash(
|
|
151
|
+
project_dir=get_project_dir(),
|
|
152
|
+
command=cmd,
|
|
153
|
+
run_id=run_id or None,
|
|
154
|
+
metadata=metadata if isinstance(metadata, dict) else None,
|
|
155
|
+
)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
44
159
|
out = to_pretool_hook_output(decision)
|
|
45
160
|
if out:
|
|
46
161
|
json.dump(out, sys.stdout)
|
|
@@ -146,7 +146,13 @@ def map_shadow_path(project_dir: str, run_id: str, file_path: str) -> str:
|
|
|
146
146
|
return os.path.join(_shadow_root(project_dir), run_id, "overlay", rel)
|
|
147
147
|
|
|
148
148
|
|
|
149
|
-
def record_shadow_write(
|
|
149
|
+
def record_shadow_write(
|
|
150
|
+
project_dir: str,
|
|
151
|
+
run_id: str,
|
|
152
|
+
file_path: str,
|
|
153
|
+
source: str = "tool",
|
|
154
|
+
step_id: str | None = None,
|
|
155
|
+
) -> dict[str, Any]:
|
|
150
156
|
run_id = _validated_run_id(run_id)
|
|
151
157
|
run_dir = os.path.join(_shadow_root(project_dir), run_id)
|
|
152
158
|
os.makedirs(run_dir, exist_ok=True)
|
|
@@ -155,7 +161,8 @@ def record_shadow_write(project_dir: str, run_id: str, file_path: str, source: s
|
|
|
155
161
|
os.makedirs(os.path.dirname(shadow_path), exist_ok=True)
|
|
156
162
|
|
|
157
163
|
abs_file = file_path if os.path.isabs(file_path) else os.path.join(project_dir, file_path)
|
|
158
|
-
|
|
164
|
+
file_existed_before = os.path.exists(abs_file)
|
|
165
|
+
if file_existed_before:
|
|
159
166
|
shutil.copy2(abs_file, shadow_path)
|
|
160
167
|
file_hash = _hash_file(abs_file)
|
|
161
168
|
else:
|
|
@@ -169,7 +176,10 @@ def record_shadow_write(project_dir: str, run_id: str, file_path: str, source: s
|
|
|
169
176
|
"recorded_at": _utc_now(),
|
|
170
177
|
"source": source,
|
|
171
178
|
"sha256": file_hash,
|
|
179
|
+
"file_existed_before": file_existed_before,
|
|
172
180
|
}
|
|
181
|
+
if step_id:
|
|
182
|
+
entry["step_id"] = step_id
|
|
173
183
|
|
|
174
184
|
files = manifest.get("files", [])
|
|
175
185
|
# Replace existing entry for same file.
|
|
@@ -181,6 +191,72 @@ def record_shadow_write(project_dir: str, run_id: str, file_path: str, source: s
|
|
|
181
191
|
return entry
|
|
182
192
|
|
|
183
193
|
|
|
194
|
+
def restore_shadow_entry(project_dir: str, run_id: str, step_id: str) -> dict[str, Any]:
|
|
195
|
+
run_id = _validated_run_id(run_id)
|
|
196
|
+
run_dir = os.path.join(_shadow_root(project_dir), run_id)
|
|
197
|
+
manifest = _load_manifest(run_dir)
|
|
198
|
+
entries = manifest.get("files", [])
|
|
199
|
+
|
|
200
|
+
restored: list[str] = []
|
|
201
|
+
failed: list[dict[str, str]] = []
|
|
202
|
+
|
|
203
|
+
selected_entries: list[dict[str, Any]] = []
|
|
204
|
+
for item in entries:
|
|
205
|
+
if not isinstance(item, dict):
|
|
206
|
+
continue
|
|
207
|
+
item_step_id = str(item.get("step_id", "")).strip()
|
|
208
|
+
if item_step_id == step_id:
|
|
209
|
+
selected_entries.append(item)
|
|
210
|
+
|
|
211
|
+
if not selected_entries and isinstance(entries, list) and len(entries) == 1:
|
|
212
|
+
single = entries[0]
|
|
213
|
+
if isinstance(single, dict):
|
|
214
|
+
selected_entries = [single]
|
|
215
|
+
|
|
216
|
+
if not selected_entries:
|
|
217
|
+
return {"restored": restored, "failed": failed, "reason": "step not found"}
|
|
218
|
+
|
|
219
|
+
for item in selected_entries:
|
|
220
|
+
rel = str(item.get("file", "")).strip()
|
|
221
|
+
shadow_rel = str(item.get("shadow_file", "")).strip()
|
|
222
|
+
file_existed_before = bool(item.get("file_existed_before", True))
|
|
223
|
+
if not rel:
|
|
224
|
+
failed.append({"file": "", "reason": "missing file path"})
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
dst = os.path.join(project_dir, rel)
|
|
228
|
+
src = os.path.join(run_dir, shadow_rel) if shadow_rel else ""
|
|
229
|
+
|
|
230
|
+
if src and os.path.exists(src):
|
|
231
|
+
try:
|
|
232
|
+
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
|
233
|
+
shutil.copy2(src, dst)
|
|
234
|
+
restored.append(rel)
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
failed.append({"file": rel, "reason": f"copy failed: {exc}"})
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
if not file_existed_before:
|
|
240
|
+
try:
|
|
241
|
+
if os.path.exists(dst):
|
|
242
|
+
os.remove(dst)
|
|
243
|
+
restored.append(rel)
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
failed.append({"file": rel, "reason": f"delete failed: {exc}"})
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
failed.append({"file": rel, "reason": "shadow snapshot missing"})
|
|
249
|
+
|
|
250
|
+
manifest["status"] = "restored"
|
|
251
|
+
manifest["restored_at"] = _utc_now()
|
|
252
|
+
_save_manifest(run_dir, manifest)
|
|
253
|
+
|
|
254
|
+
reason = "shadow restore applied" if restored and not failed else "shadow restore partial"
|
|
255
|
+
if not restored and failed:
|
|
256
|
+
reason = "shadow restore failed"
|
|
257
|
+
return {"restored": restored, "failed": failed, "reason": reason}
|
|
258
|
+
|
|
259
|
+
|
|
184
260
|
def create_evidence_pack(
|
|
185
261
|
project_dir: str,
|
|
186
262
|
run_id: str,
|
|
@@ -321,6 +397,34 @@ def drop_shadow(project_dir: str, run_id: str) -> dict[str, Any]:
|
|
|
321
397
|
return {"run_id": run_id, "dropped": True}
|
|
322
398
|
|
|
323
399
|
|
|
400
|
+
def auto_journal_mutation(
|
|
401
|
+
project_dir: str,
|
|
402
|
+
tool: str,
|
|
403
|
+
file_path: str,
|
|
404
|
+
run_id: str,
|
|
405
|
+
) -> dict[str, Any] | None:
|
|
406
|
+
try:
|
|
407
|
+
from runtime.interaction_journal import InteractionJournal
|
|
408
|
+
from runtime.release_run_coordinator import resolve_current_run_id
|
|
409
|
+
except ImportError:
|
|
410
|
+
return None
|
|
411
|
+
|
|
412
|
+
canonical_run_id = resolve_current_run_id(project_dir) or run_id
|
|
413
|
+
shadow_manifest = os.path.join(
|
|
414
|
+
_shadow_root(project_dir), run_id, "manifest.json"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
journal = InteractionJournal(project_dir)
|
|
418
|
+
return journal.record_step(
|
|
419
|
+
tool.lower(),
|
|
420
|
+
{
|
|
421
|
+
"file_path": file_path,
|
|
422
|
+
"run_id": canonical_run_id,
|
|
423
|
+
"shadow_manifest": shadow_manifest,
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
324
428
|
def _handle_post_tool_use(payload: dict[str, Any]) -> None:
|
|
325
429
|
tool = payload.get("tool_name", "")
|
|
326
430
|
if tool not in ("Write", "Edit", "MultiEdit"):
|
|
@@ -343,6 +447,11 @@ def _handle_post_tool_use(payload: dict[str, Any]) -> None:
|
|
|
343
447
|
run_id = begin_shadow_run(project_dir, metadata={"source": "post-tool-use"})
|
|
344
448
|
record_shadow_write(project_dir, run_id, file_path)
|
|
345
449
|
|
|
450
|
+
try:
|
|
451
|
+
auto_journal_mutation(project_dir, tool, file_path, run_id)
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
|
|
346
455
|
|
|
347
456
|
def _main() -> int:
|
|
348
457
|
# Early-exit: skip all work if shadow/evidence mode is not enabled
|
|
@@ -9,10 +9,12 @@ import subprocess
|
|
|
9
9
|
import sys
|
|
10
10
|
import time
|
|
11
11
|
from datetime import datetime, timedelta, timezone
|
|
12
|
+
import warnings
|
|
12
13
|
|
|
13
14
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
15
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
|
14
16
|
|
|
15
|
-
from _common import ( # noqa: E402
|
|
17
|
+
from hooks._common import ( # noqa: E402
|
|
16
18
|
atomic_json_write,
|
|
17
19
|
block_decision,
|
|
18
20
|
check_performance_budget,
|
|
@@ -27,7 +29,10 @@ from _common import ( # noqa: E402
|
|
|
27
29
|
should_skip_stop_hooks,
|
|
28
30
|
STOP_CHECK_MAX_MS,
|
|
29
31
|
)
|
|
30
|
-
from state_migration import resolve_state_file # noqa: E402
|
|
32
|
+
from hooks.state_migration import resolve_state_file # noqa: E402
|
|
33
|
+
|
|
34
|
+
from runtime.release_run_coordinator import resolve_current_run_id # noqa: E402
|
|
35
|
+
from runtime import test_intent_lock # noqa: E402
|
|
31
36
|
|
|
32
37
|
|
|
33
38
|
setup_crash_handler("stop_dispatcher")
|
|
@@ -132,7 +137,7 @@ def _is_internal_control_path(file_path: str) -> bool:
|
|
|
132
137
|
|
|
133
138
|
|
|
134
139
|
try:
|
|
135
|
-
from shadow_manager import has_recent_evidence # type: ignore
|
|
140
|
+
from hooks.shadow_manager import has_recent_evidence # type: ignore
|
|
136
141
|
except Exception: # intentional: optional feature — shadow_manager may not exist
|
|
137
142
|
has_recent_evidence = None
|
|
138
143
|
|
|
@@ -621,6 +626,104 @@ def check_bare_done(data, project_dir):
|
|
|
621
626
|
|
|
622
627
|
return []
|
|
623
628
|
|
|
629
|
+
|
|
630
|
+
def _proof_chain_strict_enabled() -> bool:
|
|
631
|
+
return os.environ.get("OMG_PROOF_CHAIN_STRICT", "0").strip() == "1"
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _load_test_delta_from_evidence(project_dir: str, run_id: str | None) -> dict[str, object]:
|
|
635
|
+
evidence_dir = os.path.join(project_dir, ".omg", "evidence")
|
|
636
|
+
if not os.path.isdir(evidence_dir):
|
|
637
|
+
return {}
|
|
638
|
+
|
|
639
|
+
candidates = sorted(
|
|
640
|
+
[
|
|
641
|
+
os.path.join(evidence_dir, name)
|
|
642
|
+
for name in os.listdir(evidence_dir)
|
|
643
|
+
if name.endswith(".json")
|
|
644
|
+
],
|
|
645
|
+
key=os.path.getmtime,
|
|
646
|
+
reverse=True,
|
|
647
|
+
)
|
|
648
|
+
for path in candidates:
|
|
649
|
+
try:
|
|
650
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as handle:
|
|
651
|
+
payload = json.load(handle)
|
|
652
|
+
except (OSError, json.JSONDecodeError):
|
|
653
|
+
continue
|
|
654
|
+
if not isinstance(payload, dict):
|
|
655
|
+
continue
|
|
656
|
+
if payload.get("schema") != "EvidencePack":
|
|
657
|
+
continue
|
|
658
|
+
payload_run_id = str(payload.get("run_id", "")).strip()
|
|
659
|
+
if run_id and payload_run_id and payload_run_id != run_id:
|
|
660
|
+
continue
|
|
661
|
+
test_delta = payload.get("test_delta")
|
|
662
|
+
if isinstance(test_delta, dict):
|
|
663
|
+
return test_delta
|
|
664
|
+
return {}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _has_waiver_artifact(delta_summary: dict[str, object]) -> bool:
|
|
668
|
+
waiver = delta_summary.get("waiver_artifact")
|
|
669
|
+
if isinstance(waiver, dict):
|
|
670
|
+
for field in ("artifact_path", "path", "id", "reason"):
|
|
671
|
+
if str(waiver.get(field, "")).strip():
|
|
672
|
+
return True
|
|
673
|
+
return False
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _has_weakened_or_drift(delta_summary: dict[str, object]) -> bool:
|
|
677
|
+
flags = delta_summary.get("flags")
|
|
678
|
+
if not isinstance(flags, list):
|
|
679
|
+
return False
|
|
680
|
+
risk_flags = {
|
|
681
|
+
"weakened_assertions",
|
|
682
|
+
"tests_mismatch",
|
|
683
|
+
"selector_drift",
|
|
684
|
+
"removed_touched_area_coverage",
|
|
685
|
+
"integration_to_mock_downgrade",
|
|
686
|
+
"snapshot_only_refresh",
|
|
687
|
+
}
|
|
688
|
+
normalized = {str(item).strip().lower() for item in flags if str(item).strip()}
|
|
689
|
+
return bool(normalized & risk_flags)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def check_tdd_proof_chain(data, project_dir):
|
|
693
|
+
if not get_feature_flag("tdd_proof_chain", True):
|
|
694
|
+
return []
|
|
695
|
+
|
|
696
|
+
context = data["_stop_ctx"]
|
|
697
|
+
if not context.get("has_source_writes", False):
|
|
698
|
+
return []
|
|
699
|
+
|
|
700
|
+
run_id = resolve_current_run_id(project_dir=project_dir)
|
|
701
|
+
lock_verdict = test_intent_lock.verify_lock(project_dir, run_id=run_id)
|
|
702
|
+
delta_summary = data.get("_test_delta") if isinstance(data.get("_test_delta"), dict) else {}
|
|
703
|
+
if not delta_summary:
|
|
704
|
+
delta_summary = _load_test_delta_from_evidence(project_dir, run_id)
|
|
705
|
+
|
|
706
|
+
lock_missing = str(lock_verdict.get("status", "")).strip() != "ok"
|
|
707
|
+
weakened_without_waiver = _has_weakened_or_drift(delta_summary) and not _has_waiver_artifact(delta_summary)
|
|
708
|
+
if not lock_missing and not weakened_without_waiver:
|
|
709
|
+
return []
|
|
710
|
+
|
|
711
|
+
strict_mode = _proof_chain_strict_enabled()
|
|
712
|
+
if strict_mode:
|
|
713
|
+
return [json.dumps({"status": "blocked", "reason": "tdd_proof_chain_incomplete"}, sort_keys=True)]
|
|
714
|
+
|
|
715
|
+
warnings.warn(
|
|
716
|
+
"tdd_proof_chain_incomplete_permissive",
|
|
717
|
+
RuntimeWarning,
|
|
718
|
+
stacklevel=2,
|
|
719
|
+
)
|
|
720
|
+
advisories = data.setdefault("_stop_advisories", [])
|
|
721
|
+
advisories.append(
|
|
722
|
+
"[OMG advisory] tdd proof chain incomplete: active lock evidence or waiver artifact is missing. "
|
|
723
|
+
"Set OMG_PROOF_CHAIN_STRICT=1 to hard-block completion."
|
|
724
|
+
)
|
|
725
|
+
return []
|
|
726
|
+
|
|
624
727
|
def check_simplifier(data, project_dir):
|
|
625
728
|
"""CHECK 7: Code simplifier — advisory only, never blocks."""
|
|
626
729
|
if not get_feature_flag("simplifier", True):
|
|
@@ -832,7 +935,7 @@ def check_scope_drift(project_dir):
|
|
|
832
935
|
|
|
833
936
|
|
|
834
937
|
|
|
835
|
-
def check_todo_continuation(data: dict) -> dict | None:
|
|
938
|
+
def check_todo_continuation(data: dict[str, object]) -> dict[str, str] | None:
|
|
836
939
|
"""Check if agent should continue due to incomplete todos.
|
|
837
940
|
Returns a dict with continuation response if idle, None otherwise.
|
|
838
941
|
Budget: STOP_CHECK_MAX_MS (15s)
|
|
@@ -911,6 +1014,7 @@ def main():
|
|
|
911
1014
|
check_diff_budget,
|
|
912
1015
|
check_recent_failures,
|
|
913
1016
|
check_test_execution,
|
|
1017
|
+
check_tdd_proof_chain,
|
|
914
1018
|
check_test_validator_coverage,
|
|
915
1019
|
check_false_fix,
|
|
916
1020
|
check_write_failures,
|