constraintloop 0.1.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.
@@ -0,0 +1,335 @@
1
+ """Normalize agent hook payloads into one constraint lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from constraintloop.config import ContractError, load_contract
12
+ from constraintloop.engine import ConstraintEngine, blocking_results, format_summary
13
+ from constraintloop.loops import run_cycle
14
+ from constraintloop.models import LoopState, Phase, Verdict
15
+ from constraintloop.state import advisory_acknowledgment_reason, load_session, save_session
16
+
17
+
18
+ def handle_hook(
19
+ project_root: Path,
20
+ adapter: str,
21
+ event: str,
22
+ payload: dict[str, Any],
23
+ ) -> dict[str, Any]:
24
+ session_id = str(
25
+ payload.get("session_id")
26
+ or payload.get("sessionId")
27
+ or payload.get("conversation_id")
28
+ or "default"
29
+ )
30
+ state = load_session(project_root, session_id)
31
+
32
+ if event == "user-prompt":
33
+ prompt = payload.get("prompt") or payload.get("user_prompt")
34
+ if not prompt and isinstance(payload.get("input"), str):
35
+ prompt = payload["input"]
36
+ if isinstance(prompt, str) and prompt.strip():
37
+ if _is_hook_feedback(prompt):
38
+ state["last_hook_feedback"] = prompt.strip()[-8000:]
39
+ save_session(project_root, session_id, state)
40
+ return _context_response(
41
+ adapter, event, "ConstraintLoop captured evaluator feedback."
42
+ )
43
+ state["goal"] = prompt.strip()[-8000:]
44
+ save_session(project_root, session_id, state)
45
+ return _context_response(adapter, event, "ConstraintLoop captured the task goal.")
46
+
47
+ if event == "pre-tool":
48
+ serialized = json.dumps(
49
+ payload.get("tool_input", payload.get("toolInput", payload)),
50
+ sort_keys=True,
51
+ )
52
+ if _protected_mutation(payload, serialized):
53
+ return _deny_response(
54
+ adapter,
55
+ "Agent writes to protected quality policy or creates a local exception. "
56
+ "Ask the human to make this change outside the agent session.",
57
+ )
58
+ return {}
59
+
60
+ try:
61
+ contract, _ = load_contract(project_root)
62
+ except ContractError as exc:
63
+ if event == "stop":
64
+ return _block_response(adapter, str(exc))
65
+ return _context_response(adapter, event, str(exc))
66
+
67
+ if event == "session-start":
68
+ required = [
69
+ constraint_id
70
+ for constraint_id, spec in contract.constraints.items()
71
+ if spec.enabled and spec.enforcement.value == "required"
72
+ ]
73
+ return _context_response(
74
+ adapter,
75
+ event,
76
+ "ConstraintLoop completion contract is active. Required gates: "
77
+ + (", ".join(required) if required else "none")
78
+ + ". Do not edit the contract or create waivers.",
79
+ )
80
+
81
+ if event == "post-tool":
82
+ record = ConstraintEngine(
83
+ project_root,
84
+ contract,
85
+ goal=state.get("goal"),
86
+ agent_adapter=adapter,
87
+ ).run(Phase.CHANGE)
88
+ if record.results:
89
+ return _context_response(adapter, event, format_summary(record))
90
+ return {}
91
+
92
+ if event == "pre-compact":
93
+ return _context_response(
94
+ adapter,
95
+ event,
96
+ "ConstraintLoop remains authoritative at completion. Run or repair all required "
97
+ "stop gates before claiming the task is complete.",
98
+ )
99
+
100
+ if event != "stop":
101
+ return {}
102
+
103
+ record = ConstraintEngine(
104
+ project_root,
105
+ contract,
106
+ goal=state.get("goal"),
107
+ agent_adapter=adapter,
108
+ ).run(Phase.STOP)
109
+ failures = blocking_results(record)
110
+ completion_loop = next(
111
+ (loop_name for loop_name, config in contract.loops.items() if config.phase == Phase.STOP),
112
+ None,
113
+ )
114
+ cycle = (
115
+ run_cycle(
116
+ project_root,
117
+ contract,
118
+ completion_loop,
119
+ record=record,
120
+ goal=state.get("goal"),
121
+ agent_adapter=adapter,
122
+ )
123
+ if completion_loop is not None
124
+ else None
125
+ )
126
+ advisories = [
127
+ result
128
+ for result in record.results
129
+ if not result.blocks
130
+ and result.verdict
131
+ not in {
132
+ Verdict.PASS,
133
+ Verdict.SKIPPED,
134
+ Verdict.WAIVED,
135
+ }
136
+ ]
137
+ if not failures:
138
+ state["attempts"] = 0
139
+ state.pop("failed_snapshot", None)
140
+ if advisories:
141
+ snapshot = _result_snapshot(advisories)
142
+ summary = format_summary(record, include_output=True)
143
+ if state.get("advisory_feedback_snapshot") != snapshot:
144
+ state["advisory_feedback_snapshot"] = snapshot
145
+ save_session(project_root, session_id, state)
146
+ return _block_response(
147
+ adapter,
148
+ summary
149
+ + "\nAdvisory feedback requires an agent disposition. Address it until fresh "
150
+ "evidence passes, or record why no change is appropriate with "
151
+ '`constraintloop acknowledge CONSTRAINT --reason "..."`, then try '
152
+ "completion again.",
153
+ )
154
+ reasons = {
155
+ result.constraint_id: advisory_acknowledgment_reason(project_root, result)
156
+ for result in advisories
157
+ }
158
+ missing = [constraint_id for constraint_id, reason in reasons.items() if not reason]
159
+ if missing:
160
+ return _block_response(
161
+ adapter,
162
+ summary
163
+ + "\nAdvisory feedback was delivered but has no snapshot-bound disposition "
164
+ f"for: {', '.join(missing)}. Address it or explicitly acknowledge it.",
165
+ )
166
+ save_session(project_root, session_id, state)
167
+ return _allow_response(
168
+ adapter,
169
+ summary
170
+ + "\nAdvisory feedback was explicitly acknowledged for this exact evidence: "
171
+ + "; ".join(
172
+ f"{constraint_id}: {reason}" for constraint_id, reason in reasons.items()
173
+ ),
174
+ )
175
+ state.pop("advisory_feedback_snapshot", None)
176
+ save_session(project_root, session_id, state)
177
+ return _allow_response(adapter, format_summary(record, include_output=True))
178
+
179
+ if cycle is not None:
180
+ summary = format_summary(record, include_output=True)
181
+ detail = (
182
+ f"\nLoop {cycle.loop}: {cycle.state.value}; "
183
+ f"repair attempt {cycle.repair_attempt}. {cycle.next_action}"
184
+ )
185
+ if cycle.state in {LoopState.REPAIR, LoopState.WAITING}:
186
+ return _block_response(adapter, summary + detail)
187
+ return _human_required_response(adapter, summary + detail)
188
+
189
+ snapshot = _result_snapshot(failures)
190
+ attempts = int(state.get("attempts", 0)) + 1 if state.get("failed_snapshot") == snapshot else 1
191
+ state["attempts"] = attempts
192
+ state["failed_snapshot"] = snapshot
193
+ save_session(project_root, session_id, state)
194
+ summary = format_summary(record, include_output=True)
195
+ if attempts <= contract.settings.max_auto_retries:
196
+ return _block_response(
197
+ adapter,
198
+ summary + f"\nRepair the failures and try again "
199
+ f"({attempts}/{contract.settings.max_auto_retries} automatic retries).",
200
+ )
201
+ return _human_required_response(
202
+ adapter,
203
+ summary + "\nThe same evidence failed repeatedly. Stop automatic repair and ask a human "
204
+ "to fix the implementation, revise the contract, or create a local waiver.",
205
+ )
206
+
207
+
208
+ def _result_snapshot(results: list[Any]) -> str:
209
+ return hashlib.sha256(
210
+ json.dumps(
211
+ [(item.constraint_id, item.input_digest, item.verdict.value) for item in results],
212
+ sort_keys=True,
213
+ ).encode()
214
+ ).hexdigest()
215
+
216
+
217
+ _NATIVE_EVENTS = {
218
+ "claude": {
219
+ "session-start": "SessionStart",
220
+ "user-prompt": "UserPromptSubmit",
221
+ "post-tool": "PostToolUse",
222
+ "pre-compact": "PreCompact",
223
+ "stop": "Stop",
224
+ },
225
+ "codex": {
226
+ "session-start": "SessionStart",
227
+ "user-prompt": "UserPromptSubmit",
228
+ "post-tool": "PostToolUse",
229
+ "pre-compact": "PreCompact",
230
+ "stop": "Stop",
231
+ },
232
+ "gemini": {
233
+ "session-start": "SessionStart",
234
+ "user-prompt": "BeforeAgent",
235
+ "post-tool": "AfterTool",
236
+ "pre-compact": "PreCompress",
237
+ "stop": "AfterAgent",
238
+ },
239
+ }
240
+
241
+
242
+ def _context_response(adapter: str, event: str, text: str) -> dict[str, Any]:
243
+ native_event = _NATIVE_EVENTS[adapter].get(event)
244
+ if event == "pre-compact" and adapter in {"claude", "codex"}:
245
+ return {"systemMessage": text}
246
+ if native_event is None:
247
+ return {"systemMessage": text}
248
+ return {
249
+ "hookSpecificOutput": {
250
+ "hookEventName": native_event,
251
+ "additionalContext": text,
252
+ }
253
+ }
254
+
255
+
256
+ def _deny_response(adapter: str, reason: str) -> dict[str, Any]:
257
+ if adapter == "gemini":
258
+ return {"decision": "deny", "reason": reason}
259
+ return {
260
+ "hookSpecificOutput": {
261
+ "hookEventName": "PreToolUse",
262
+ "permissionDecision": "deny",
263
+ "permissionDecisionReason": reason,
264
+ }
265
+ }
266
+
267
+
268
+ def _block_response(adapter: str, reason: str) -> dict[str, Any]:
269
+ if adapter == "gemini":
270
+ return {"decision": "deny", "reason": reason}
271
+ return {"decision": "block", "reason": reason}
272
+
273
+
274
+ def _allow_response(adapter: str, summary: str) -> dict[str, Any]:
275
+ if adapter == "gemini":
276
+ return {"decision": "allow", "systemMessage": summary}
277
+ return {"continue": True, "systemMessage": summary}
278
+
279
+
280
+ def _human_required_response(adapter: str, reason: str) -> dict[str, Any]:
281
+ if adapter == "gemini":
282
+ return {"decision": "deny", "reason": reason}
283
+ return {"continue": False, "stopReason": reason, "systemMessage": reason}
284
+
285
+
286
+ def _is_hook_feedback(prompt: str) -> bool:
287
+ stripped = prompt.strip()
288
+ return stripped.startswith("<hook_prompt ") and stripped.endswith("</hook_prompt>")
289
+
290
+
291
+ def _protected_mutation(payload: dict[str, Any], serialized: str) -> bool:
292
+ protected = (
293
+ "constraintloop." + "yml",
294
+ "constraintloop." + "yaml",
295
+ ".constraintloop/" + "secrets.env",
296
+ )
297
+ tool_name = str(payload.get("tool_name") or payload.get("toolName") or "").lower()
298
+ tool_input = payload.get("tool_input", payload.get("toolInput", payload))
299
+ if not isinstance(tool_input, dict):
300
+ tool_input = {}
301
+ text_values = [value for value in tool_input.values() if isinstance(value, str)]
302
+ if "patch" in tool_name or any(value.startswith("*** Begin Patch") for value in text_values):
303
+ headers = re.findall(
304
+ r"^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$",
305
+ "\n".join(text_values),
306
+ re.MULTILINE,
307
+ )
308
+ return any(any(name in header for name in protected) for header in headers)
309
+ command = tool_input.get("cmd") or tool_input.get("command")
310
+ if isinstance(command, str):
311
+ forbidden_subcommand = "constraintloop " + "waive"
312
+ if forbidden_subcommand in command:
313
+ return True
314
+ mutator = re.search(
315
+ r"(^|[;&|]\s*)(rm|mv|cp|tee|touch|truncate|sed\s+-i|perl\s+-i)\b|[>]",
316
+ command,
317
+ )
318
+ if mutator and any(name in command for name in protected):
319
+ return True
320
+ if any("*** " in value for value in text_values):
321
+ headers = re.findall(
322
+ r"^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$",
323
+ "\n".join(text_values),
324
+ re.MULTILINE,
325
+ )
326
+ return any(any(name in header for name in protected) for header in headers)
327
+ if any(marker in tool_name for marker in ("write", "edit", "delete", "move")):
328
+ path_values = [
329
+ value
330
+ for key, value in tool_input.items()
331
+ if key.lower() in {"path", "file_path", "filepath", "destination", "target"}
332
+ and isinstance(value, str)
333
+ ]
334
+ return any(any(name in value for name in protected) for value in path_values)
335
+ return False
@@ -0,0 +1,334 @@
1
+ """Bounded, journaled convergence-loop transitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import signal
9
+ import time
10
+ import uuid
11
+ from collections.abc import Callable, Iterator
12
+ from contextlib import contextmanager
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from constraintloop.config import contract_digest
17
+ from constraintloop.digest import constraint_input_digest
18
+ from constraintloop.engine import ConstraintEngine, blocking_results
19
+ from constraintloop.models import (
20
+ Contract,
21
+ CycleResult,
22
+ EvidenceRecord,
23
+ LoopConfig,
24
+ LoopJournal,
25
+ LoopState,
26
+ Phase,
27
+ Verdict,
28
+ )
29
+ from constraintloop.state import _read_json, _write_json, _write_lock, cache_root
30
+
31
+ CYCLE_EXIT_CODES = {
32
+ LoopState.PASSED: 0,
33
+ LoopState.REPAIR: 10,
34
+ LoopState.WAITING: 11,
35
+ LoopState.HUMAN_REQUIRED: 12,
36
+ LoopState.BUDGET_EXHAUSTED: 13,
37
+ LoopState.ERROR: 14,
38
+ }
39
+
40
+
41
+ class LoopError(RuntimeError):
42
+ pass
43
+
44
+
45
+ def _safe_name(name: str) -> str:
46
+ safe = "".join(char if char.isalnum() or char in "-_." else "_" for char in name)
47
+ if not safe or safe != name:
48
+ raise LoopError(f"Invalid loop name {name!r}")
49
+ return safe
50
+
51
+
52
+ def loop_root(project_root: Path) -> Path:
53
+ return cache_root(project_root) / "loops"
54
+
55
+
56
+ def journal_path(project_root: Path, loop_name: str) -> Path:
57
+ return loop_root(project_root) / f"{_safe_name(loop_name)}.json"
58
+
59
+
60
+ def lease_path(project_root: Path, loop_name: str) -> Path:
61
+ return loop_root(project_root) / f"{_safe_name(loop_name)}.lease.json"
62
+
63
+
64
+ def evidence_snapshot(record: EvidenceRecord) -> str:
65
+ payload = [
66
+ {
67
+ "constraint_id": item.constraint_id,
68
+ "input_digest": item.input_digest,
69
+ "verdict": item.verdict.value,
70
+ "findings": [
71
+ finding.model_dump(mode="json", exclude_none=True) for finding in item.findings
72
+ ],
73
+ }
74
+ for item in record.results
75
+ ]
76
+ raw = json.dumps(
77
+ {"contract_digest": record.contract_digest, "results": payload},
78
+ sort_keys=True,
79
+ separators=(",", ":"),
80
+ ).encode()
81
+ return "sha256:" + hashlib.sha256(raw).hexdigest()
82
+
83
+
84
+ def _input_snapshot(project_root: Path, contract: Contract, config: LoopConfig) -> str:
85
+ identity = contract_digest(contract)
86
+ inputs = [
87
+ (
88
+ constraint_id,
89
+ constraint_input_digest(
90
+ project_root,
91
+ constraint_id,
92
+ spec,
93
+ contract_digest=identity,
94
+ ),
95
+ )
96
+ for constraint_id, spec in contract.constraints.items()
97
+ if spec.enabled and config.phase in spec.phases
98
+ ]
99
+ return hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()
100
+
101
+
102
+ def run_cycle(
103
+ project_root: Path,
104
+ contract: Contract,
105
+ loop_name: str,
106
+ *,
107
+ record: EvidenceRecord | None = None,
108
+ now: float | None = None,
109
+ goal: str | None = None,
110
+ agent_adapter: str | None = None,
111
+ ) -> CycleResult:
112
+ """Execute exactly one bounded transition and persist it atomically."""
113
+ if loop_name not in contract.loops:
114
+ raise LoopError(f"Unknown loop {loop_name!r}")
115
+ config = contract.loops[loop_name]
116
+ current_time = time.time() if now is None else now
117
+ identity = contract_digest(contract)
118
+ path = journal_path(project_root, loop_name)
119
+ with _write_lock(path):
120
+ raw = _read_json(path, {})
121
+ try:
122
+ journal = LoopJournal.model_validate(raw)
123
+ except Exception as exc:
124
+ if raw:
125
+ raise LoopError(f"Loop journal is corrupt: {path}") from exc
126
+ journal = LoopJournal(
127
+ loop=loop_name,
128
+ contract_digest=identity,
129
+ started_at=current_time,
130
+ updated_at=current_time,
131
+ )
132
+ if journal.contract_digest != identity:
133
+ journal = LoopJournal(
134
+ loop=loop_name,
135
+ contract_digest=identity,
136
+ started_at=current_time,
137
+ updated_at=current_time,
138
+ )
139
+
140
+ input_snapshot = _input_snapshot(project_root, contract, config)
141
+ last_input = journal.input_snapshot
142
+ if (
143
+ record is None
144
+ and journal.prior_state == LoopState.WAITING
145
+ and last_input == input_snapshot
146
+ and current_time - journal.updated_at < config.interval_seconds
147
+ and journal.last_result is not None
148
+ ):
149
+ previous = CycleResult.model_validate(journal.last_result)
150
+ result = previous.model_copy(
151
+ update={
152
+ "observation": journal.observation + 1,
153
+ "wake_after_seconds": max(
154
+ 0.0, config.interval_seconds - (current_time - journal.updated_at)
155
+ ),
156
+ }
157
+ )
158
+ journal.observation = result.observation
159
+ journal.last_result = result.model_dump(mode="json")
160
+ journal.input_snapshot = input_snapshot
161
+ _write_json(path, journal.model_dump(mode="json"))
162
+ return result
163
+
164
+ if record is None:
165
+ record = ConstraintEngine(
166
+ project_root,
167
+ contract,
168
+ use_cache=config.phase != Phase.CI,
169
+ allow_waivers=config.phase != Phase.CI,
170
+ goal=goal,
171
+ agent_adapter=agent_adapter,
172
+ refresh_pending=True,
173
+ ).run(config.phase)
174
+
175
+ snapshot = evidence_snapshot(record)
176
+ observation = journal.observation + 1
177
+ repair_attempt = journal.repair_attempt
178
+ unchanged_repairs = journal.unchanged_repairs
179
+ if journal.prior_state == LoopState.REPAIR:
180
+ repair_attempt += 1
181
+ unchanged_repairs = unchanged_repairs + 1 if journal.prior_snapshot == snapshot else 0
182
+
183
+ required = blocking_results(record)
184
+ pending = [item for item in required if item.verdict == Verdict.PENDING]
185
+ unreliable = [
186
+ item for item in required if item.verdict in {Verdict.ERROR, Verdict.UNCERTAIN}
187
+ ]
188
+ blocking_ids = [item.constraint_id for item in required]
189
+ elapsed = current_time - journal.started_at
190
+
191
+ if not required:
192
+ state = LoopState.PASSED
193
+ action = "Fresh required evidence passes. Stop."
194
+ wake = 0.0
195
+ elif unreliable:
196
+ state = LoopState.ERROR
197
+ action = "Constraint evaluation is unreliable. Inspect evidence and require a human."
198
+ wake = 0.0
199
+ elif elapsed >= config.max_duration_seconds:
200
+ state = LoopState.BUDGET_EXHAUSTED
201
+ action = "The loop duration budget is exhausted. Require a human decision."
202
+ wake = 0.0
203
+ elif pending:
204
+ state = LoopState.WAITING
205
+ action = "Evidence is pending. Make no edits and run one cycle after the wake interval."
206
+ wake = config.interval_seconds
207
+ elif repair_attempt >= config.max_repair_attempts:
208
+ state = LoopState.BUDGET_EXHAUSTED
209
+ action = "The repair-attempt budget is exhausted. Require a human decision."
210
+ wake = 0.0
211
+ elif unchanged_repairs >= config.max_unchanged_repairs:
212
+ state = LoopState.HUMAN_REQUIRED
213
+ action = "Repairs left evidence unchanged. Require a human decision."
214
+ wake = 0.0
215
+ else:
216
+ state = LoopState.REPAIR
217
+ action = "Repair only the listed blocking constraints, then run exactly one new cycle."
218
+ wake = 0.0
219
+
220
+ result = CycleResult(
221
+ loop=loop_name,
222
+ state=state,
223
+ snapshot=snapshot,
224
+ observation=observation,
225
+ repair_attempt=repair_attempt,
226
+ next_action=action,
227
+ wake_after_seconds=wake,
228
+ blocking_constraints=blocking_ids,
229
+ )
230
+ journal.updated_at = current_time
231
+ journal.observation = observation
232
+ journal.repair_attempt = repair_attempt
233
+ journal.unchanged_repairs = unchanged_repairs
234
+ journal.prior_state = state
235
+ journal.prior_snapshot = snapshot
236
+ journal.input_snapshot = input_snapshot
237
+ journal.last_result = result.model_dump(mode="json")
238
+ _write_json(path, journal.model_dump(mode="json"))
239
+ return result
240
+
241
+
242
+ @contextmanager
243
+ def loop_lease(
244
+ project_root: Path,
245
+ loop_name: str,
246
+ *,
247
+ ttl_seconds: float,
248
+ now: float | None = None,
249
+ ) -> Iterator[Callable[[], None]]:
250
+ """Acquire a recoverable single-writer supervisor lease."""
251
+ current_time = time.time() if now is None else now
252
+ path = lease_path(project_root, loop_name)
253
+ token = str(uuid.uuid4())
254
+ with _write_lock(path):
255
+ existing = _read_json(path, {})
256
+ if isinstance(existing, dict) and float(existing.get("expires_at", 0)) > current_time:
257
+ raise LoopError(f"Loop {loop_name!r} already has an active supervisor lease")
258
+ _write_json(
259
+ path,
260
+ {
261
+ "schema_version": 1,
262
+ "loop": loop_name,
263
+ "pid": os.getpid(),
264
+ "token": token,
265
+ "expires_at": current_time + ttl_seconds,
266
+ },
267
+ )
268
+
269
+ def renew() -> None:
270
+ renewed_at = time.time()
271
+ with _write_lock(path):
272
+ existing = _read_json(path, {})
273
+ if not isinstance(existing, dict) or existing.get("token") != token:
274
+ raise LoopError(f"Loop {loop_name!r} supervisor lease was lost")
275
+ existing["expires_at"] = renewed_at + ttl_seconds
276
+ _write_json(path, existing)
277
+
278
+ try:
279
+ yield renew
280
+ finally:
281
+ with _write_lock(path):
282
+ existing = _read_json(path, {})
283
+ if isinstance(existing, dict) and existing.get("token") == token:
284
+ path.unlink(missing_ok=True)
285
+
286
+
287
+ def supervise(
288
+ project_root: Path,
289
+ contract: Contract,
290
+ loop_name: str,
291
+ ) -> Iterator[CycleResult]:
292
+ """Yield state changes while waiting; return on every non-waiting state."""
293
+ if loop_name not in contract.loops:
294
+ raise LoopError(f"Unknown loop {loop_name!r}")
295
+ config = contract.loops[loop_name]
296
+ ttl = max(60.0, config.interval_seconds * 3)
297
+ cancelled = False
298
+
299
+ def cancel(_signum: int, _frame: Any) -> None:
300
+ nonlocal cancelled
301
+ cancelled = True
302
+
303
+ previous_handlers = {
304
+ signum: signal.signal(signum, cancel) for signum in (signal.SIGINT, signal.SIGTERM)
305
+ }
306
+ try:
307
+ with loop_lease(project_root, loop_name, ttl_seconds=ttl) as renew:
308
+ previous_state: LoopState | None = None
309
+ while not cancelled:
310
+ renew()
311
+ result = run_cycle(project_root, contract, loop_name)
312
+ if result.state != previous_state:
313
+ yield result
314
+ previous_state = result.state
315
+ if result.state != LoopState.WAITING:
316
+ return
317
+ wake_at = time.monotonic() + result.wake_after_seconds
318
+ while not cancelled and time.monotonic() < wake_at:
319
+ time.sleep(min(1.0, wake_at - time.monotonic()))
320
+ finally:
321
+ for signum, handler in previous_handlers.items():
322
+ signal.signal(signum, handler)
323
+
324
+
325
+ def loop_prompt(loop_name: str, adapter: str) -> str:
326
+ if adapter not in {"claude", "codex"}:
327
+ raise LoopError(f"Unsupported loop adapter {adapter!r}")
328
+ return (
329
+ f"Run `constraintloop cycle {loop_name} --json` exactly once. Follow only its "
330
+ "`next_action`. Make at most one repair when state is `repair`; make no edits when "
331
+ "state is `waiting`. Stop on `passed`, `human_required`, `budget_exhausted`, or "
332
+ "`error`. Never edit the ConstraintLoop configuration or create a waiver. Repeat "
333
+ "only after the requested repair or wake interval."
334
+ )