@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.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +11 -0
  4. package/build/lib/control_plane/service.py +40 -1
  5. package/build/lib/hooks/firewall.py +119 -4
  6. package/build/lib/hooks/shadow_manager.py +111 -2
  7. package/build/lib/hooks/stop_dispatcher.py +108 -4
  8. package/build/lib/runtime/claim_judge.py +90 -0
  9. package/build/lib/runtime/context_engine.py +19 -4
  10. package/build/lib/runtime/contract_compiler.py +249 -27
  11. package/build/lib/runtime/forge_agents.py +36 -4
  12. package/build/lib/runtime/forge_contracts.py +44 -0
  13. package/build/lib/runtime/interaction_journal.py +318 -42
  14. package/build/lib/runtime/mutation_gate.py +57 -11
  15. package/build/lib/runtime/omg_mcp_server.py +14 -4
  16. package/build/lib/runtime/proof_gate.py +79 -0
  17. package/build/lib/runtime/release_run_coordinator.py +339 -0
  18. package/build/lib/runtime/rollback_manifest.py +136 -0
  19. package/build/lib/runtime/router_critics.py +50 -0
  20. package/build/lib/runtime/runtime_contracts.py +47 -0
  21. package/build/lib/runtime/session_health.py +12 -1
  22. package/build/lib/runtime/team_router.py +91 -7
  23. package/build/lib/runtime/test_intent_lock.py +102 -0
  24. package/build/lib/runtime/tool_plan_gate.py +136 -19
  25. package/control_plane/service.py +40 -1
  26. package/docs/proof.md +8 -0
  27. package/docs/release-checklist.md +8 -0
  28. package/hooks/firewall.py +119 -4
  29. package/hooks/shadow_manager.py +111 -2
  30. package/hooks/stop_dispatcher.py +108 -4
  31. package/hud/omg-hud.mjs +14 -3
  32. package/lab/pipeline.py +2 -0
  33. package/package.json +1 -1
  34. package/plugins/advanced/plugin.json +1 -1
  35. package/plugins/core/plugin.json +1 -1
  36. package/pyproject.toml +1 -1
  37. package/runtime/adoption.py +1 -1
  38. package/runtime/claim_judge.py +90 -0
  39. package/runtime/context_engine.py +19 -4
  40. package/runtime/contract_compiler.py +249 -27
  41. package/runtime/forge_agents.py +36 -4
  42. package/runtime/forge_contracts.py +44 -0
  43. package/runtime/interaction_journal.py +318 -42
  44. package/runtime/mutation_gate.py +57 -11
  45. package/runtime/omg_mcp_server.py +14 -4
  46. package/runtime/proof_gate.py +79 -0
  47. package/runtime/release_run_coordinator.py +339 -0
  48. package/runtime/rollback_manifest.py +136 -0
  49. package/runtime/router_critics.py +50 -0
  50. package/runtime/runtime_contracts.py +47 -0
  51. package/runtime/session_health.py +12 -1
  52. package/runtime/team_router.py +91 -7
  53. package/runtime/test_intent_lock.py +102 -0
  54. package/runtime/tool_plan_gate.py +136 -19
  55. package/scripts/omg.py +44 -2
  56. 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.0",
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.0",
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.0"
35
+ "version": "2.1.1"
36
36
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omg",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "OMG plugin layer for Claude Code with native setup, orchestration, and interop.",
5
5
  "author": {
6
6
  "name": "trac3er00"
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
- if HOOKS_DIR not in sys.path:
13
- sys.path.insert(0, HOOKS_DIR)
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(project_dir: str, run_id: str, file_path: str, source: str = "tool") -> dict[str, Any]:
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
- if os.path.exists(abs_file):
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,