constraintloop 0.3.0__tar.gz → 0.3.1__tar.gz

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 (68) hide show
  1. {constraintloop-0.3.0 → constraintloop-0.3.1}/CHANGELOG.md +5 -0
  2. {constraintloop-0.3.0 → constraintloop-0.3.1}/PKG-INFO +1 -1
  3. {constraintloop-0.3.0 → constraintloop-0.3.1}/docs/faq.md +16 -0
  4. {constraintloop-0.3.0 → constraintloop-0.3.1}/pyproject.toml +1 -1
  5. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/__init__.py +1 -1
  6. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/config.py +11 -1
  7. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/hooks.py +9 -0
  8. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_cli.py +17 -0
  9. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_hooks.py +16 -1
  10. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_models.py +34 -0
  11. {constraintloop-0.3.0 → constraintloop-0.3.1}/.gitignore +0 -0
  12. {constraintloop-0.3.0 → constraintloop-0.3.1}/CONTRIBUTING.md +0 -0
  13. {constraintloop-0.3.0 → constraintloop-0.3.1}/GOVERNANCE.md +0 -0
  14. {constraintloop-0.3.0 → constraintloop-0.3.1}/LICENSE +0 -0
  15. {constraintloop-0.3.0 → constraintloop-0.3.1}/README.md +0 -0
  16. {constraintloop-0.3.0 → constraintloop-0.3.1}/RELEASE.md +0 -0
  17. {constraintloop-0.3.0 → constraintloop-0.3.1}/SECURITY.md +0 -0
  18. {constraintloop-0.3.0 → constraintloop-0.3.1}/SUPPORT.md +0 -0
  19. {constraintloop-0.3.0 → constraintloop-0.3.1}/docs/configuration.md +0 -0
  20. {constraintloop-0.3.0 → constraintloop-0.3.1}/docs/convergence-loops.md +0 -0
  21. {constraintloop-0.3.0 → constraintloop-0.3.1}/docs/native-cli-evaluators.md +0 -0
  22. {constraintloop-0.3.0 → constraintloop-0.3.1}/docs/openai-evaluation.md +0 -0
  23. {constraintloop-0.3.0 → constraintloop-0.3.1}/docs/provider-privacy.md +0 -0
  24. {constraintloop-0.3.0 → constraintloop-0.3.1}/docs/recipes.md +0 -0
  25. {constraintloop-0.3.0 → constraintloop-0.3.1}/docs/release-readiness.md +0 -0
  26. {constraintloop-0.3.0 → constraintloop-0.3.1}/docs/threat-model.md +0 -0
  27. {constraintloop-0.3.0 → constraintloop-0.3.1}/schema/constraintloop.schema.json +0 -0
  28. {constraintloop-0.3.0 → constraintloop-0.3.1}/scripts/check_anthropic_sdk.py +0 -0
  29. {constraintloop-0.3.0 → constraintloop-0.3.1}/scripts/check_coverage.py +0 -0
  30. {constraintloop-0.3.0 → constraintloop-0.3.1}/scripts/check_openai_sdk.py +0 -0
  31. {constraintloop-0.3.0 → constraintloop-0.3.1}/scripts/check_sdist_contents.py +0 -0
  32. {constraintloop-0.3.0 → constraintloop-0.3.1}/scripts/generate_schema.py +0 -0
  33. {constraintloop-0.3.0 → constraintloop-0.3.1}/scripts/openai_eval_canary.py +0 -0
  34. {constraintloop-0.3.0 → constraintloop-0.3.1}/scripts/wheel_failure_smoke.py +0 -0
  35. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/__main__.py +0 -0
  36. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/cli.py +0 -0
  37. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/diagnostics.py +0 -0
  38. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/digest.py +0 -0
  39. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/engine.py +0 -0
  40. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/environment.py +0 -0
  41. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/eval_corpus.py +0 -0
  42. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/evaluators.py +0 -0
  43. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/hygiene.py +0 -0
  44. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/loops.py +0 -0
  45. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/models.py +0 -0
  46. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/native_cli_evaluator.py +0 -0
  47. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/py.typed +0 -0
  48. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/runners.py +0 -0
  49. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/scaffold.py +0 -0
  50. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/setup_hooks.py +0 -0
  51. {constraintloop-0.3.0 → constraintloop-0.3.1}/src/constraintloop/state.py +0 -0
  52. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/__init__.py +0 -0
  53. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/failure_lab.py +0 -0
  54. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/fixtures/openai_eval_corpus_v1.yml +0 -0
  55. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_cli_commands.py +0 -0
  56. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_diagnostics.py +0 -0
  57. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_digest.py +0 -0
  58. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_engine.py +0 -0
  59. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_environment.py +0 -0
  60. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_eval_corpus.py +0 -0
  61. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_evaluators.py +0 -0
  62. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_failure_lab.py +0 -0
  63. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_loops.py +0 -0
  64. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_native_cli_evaluator.py +0 -0
  65. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_release_metadata.py +0 -0
  66. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_runners.py +0 -0
  67. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_security.py +0 -0
  68. {constraintloop-0.3.0 → constraintloop-0.3.1}/tests/test_state.py +0 -0
@@ -5,6 +5,11 @@ Versioning, with the usual initial-development flexibility for `0.y.z`.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.3.1] - 2026-08-31
9
+
10
+ - Prevent recursive Claude Stop-hook invocations from repeatedly blocking completion.
11
+ - Add actionable version-skew guidance when a hook runtime rejects unknown contract keys.
12
+
8
13
  ## [0.3.0] - 2026-08-18
9
14
 
10
15
  - Add native monotonic ratchet constraints with committed JSON baselines,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: constraintloop
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Evidence-based completion gates for AI coding agents
5
5
  Project-URL: Homepage, https://github.com/mauhpr/constraintloop
6
6
  Project-URL: Documentation, https://github.com/mauhpr/constraintloop/tree/main/docs
@@ -149,6 +149,22 @@ Generated commands include the resolved project argument. If the error remains,
149
149
  inspect the relevant `.claude`, `.codex`, or `.gemini` settings file for a stale
150
150
  manually copied command.
151
151
 
152
+ ### Why does a hook reject documented contract keys as extra inputs?
153
+
154
+ The hook is running an older ConstraintLoop executable than the contract expects.
155
+ Upgrade that installation, then regenerate the hook commands so they resolve to the
156
+ same release:
157
+
158
+ ```bash
159
+ constraintloop --version
160
+ constraintloop setup --adapter all --project .
161
+ ```
162
+
163
+ Current releases include their version and this recovery action when strict schema
164
+ validation encounters unknown keys. Recursive Claude Stop-hook calls are allowed to
165
+ finish after delivering the error once, so an invalid or version-skewed contract does
166
+ not create a repeated Stop-hook loop.
167
+
152
168
  ## Loops
153
169
 
154
170
  ### Can I configure an unlimited repair loop?
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "constraintloop"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Evidence-based completion gates for AI coding agents"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -4,4 +4,4 @@ The v0.2 supported interfaces are the CLI and documented versioned protocols.
4
4
  Python submodules are internal and may change during initial development.
5
5
  """
6
6
 
7
- __version__ = "0.3.0"
7
+ __version__ = "0.3.1"
@@ -10,6 +10,7 @@ from typing import Any
10
10
  import yaml
11
11
  from pydantic import ValidationError
12
12
 
13
+ from constraintloop import __version__
13
14
  from constraintloop.models import Contract
14
15
 
15
16
  CONFIG_NAMES = ("constraintloop.yml", "constraintloop.yaml")
@@ -69,7 +70,16 @@ def load_contract(project_root: Path, *, include_local: bool = True) -> tuple[Co
69
70
  except ContractError:
70
71
  raise
71
72
  except (OSError, yaml.YAMLError, ValidationError) as exc:
72
- raise ContractError(f"Invalid contract {path}: {exc}") from exc
73
+ hint = ""
74
+ if isinstance(exc, ValidationError) and any(
75
+ error["type"] == "extra_forbidden" for error in exc.errors()
76
+ ):
77
+ hint = (
78
+ f"\nConstraintLoop {__version__} does not recognize one or more keys. "
79
+ "Check for typos; if the contract uses features from a newer release, upgrade "
80
+ "the hook executable and rerun `constraintloop setup --adapter all --project .`."
81
+ )
82
+ raise ContractError(f"Invalid contract {path}: {exc}{hint}") from exc
73
83
 
74
84
 
75
85
  def _merge_mappings(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
@@ -21,6 +21,15 @@ def handle_hook(
21
21
  event: str,
22
22
  payload: dict[str, Any],
23
23
  ) -> dict[str, Any]:
24
+ # Claude re-runs Stop hooks after a hook has already blocked completion and
25
+ # marks that recursive invocation explicitly. Blocking again traps the
26
+ # agent in a hook loop, so the recursive call must succeed without running
27
+ # the contract a second time.
28
+ if event == "stop" and (
29
+ payload.get("stop_hook_active") is True or payload.get("stopHookActive") is True
30
+ ):
31
+ return {}
32
+
24
33
  session_id = str(
25
34
  payload.get("session_id")
26
35
  or payload.get("sessionId")
@@ -6,6 +6,7 @@ import pytest
6
6
  import yaml
7
7
  from click.testing import CliRunner
8
8
 
9
+ from constraintloop import __version__
9
10
  from constraintloop.cli import main
10
11
  from constraintloop.config import ContractError, load_contract
11
12
 
@@ -127,6 +128,22 @@ def test_contract_rejects_ambiguous_and_non_mapping_overlays(tmp_path: Path) ->
127
128
  load_contract(tmp_path)
128
129
 
129
130
 
131
+ def test_unknown_contract_keys_report_runtime_version_and_upgrade_action(tmp_path: Path) -> None:
132
+ payload = {
133
+ "settings": {"future_setting": True},
134
+ "constraints": {"check": {"kind": "command", "command": ["true"]}},
135
+ }
136
+ (tmp_path / "constraintloop.yml").write_text(yaml.safe_dump(payload), encoding="utf-8")
137
+
138
+ with pytest.raises(ContractError) as raised:
139
+ load_contract(tmp_path)
140
+
141
+ message = str(raised.value)
142
+ assert "future_setting" in message
143
+ assert f"ConstraintLoop {__version__} does not recognize one or more keys" in message
144
+ assert "constraintloop setup --adapter all --project ." in message
145
+
146
+
130
147
  @pytest.mark.parametrize(
131
148
  ("overlay", "field"),
132
149
  [
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  import pytest
8
8
  import yaml
9
9
 
10
+ from constraintloop import __version__
10
11
  from constraintloop.hooks import handle_hook
11
12
  from constraintloop.setup_hooks import install_hooks, uninstall_hooks
12
13
  from constraintloop.state import create_advisory_acknowledgment, load_latest_result, load_session
@@ -41,6 +42,20 @@ def test_stop_blocks_then_requires_human(tmp_path: Path, monkeypatch) -> None:
41
42
  assert "ask a human" in second["stopReason"]
42
43
 
43
44
 
45
+ @pytest.mark.parametrize("active_field", ["stop_hook_active", "stopHookActive"])
46
+ def test_recursive_stop_hook_succeeds_without_loading_contract(
47
+ tmp_path: Path, active_field: str
48
+ ) -> None:
49
+ response = handle_hook(
50
+ tmp_path,
51
+ "claude",
52
+ "stop",
53
+ {active_field: True},
54
+ )
55
+
56
+ assert response == {}
57
+
58
+
44
59
  def test_pre_tool_protects_contract(tmp_path: Path) -> None:
45
60
  response = handle_hook(
46
61
  tmp_path,
@@ -125,7 +140,7 @@ def test_setup_pins_uvx_and_supports_explicit_hook_executable(tmp_path: Path, mo
125
140
  command = json.loads(path.read_text(encoding="utf-8"))["hooks"]["Stop"][0]["hooks"][0][
126
141
  "command"
127
142
  ]
128
- assert command.startswith("uvx --from constraintloop==0.3.0 constraintloop hook")
143
+ assert command.startswith(f"uvx --from constraintloop=={__version__} constraintloop hook")
129
144
 
130
145
  path = install_hooks(tmp_path, "codex", hook_executable="pipx run constraintloop")
131
146
  command = json.loads(path.read_text(encoding="utf-8"))["hooks"]["Stop"][0]["hooks"][0][
@@ -6,6 +6,40 @@ from pydantic import ValidationError
6
6
  from constraintloop.models import Contract
7
7
 
8
8
 
9
+ def test_contract_accepts_progress_interval_and_command_retry_policies() -> None:
10
+ contract = Contract.model_validate(
11
+ {
12
+ "settings": {"progress_interval_seconds": 10},
13
+ "constraints": {
14
+ "alembic_heads": {
15
+ "kind": "command",
16
+ "command": ["check-alembic-heads"],
17
+ "timeout_seconds": 300,
18
+ "retry": {
19
+ "max_attempts": 2,
20
+ "exit_codes": [1],
21
+ "total_timeout_seconds": 600,
22
+ },
23
+ },
24
+ "async_postgres_tests": {
25
+ "kind": "command",
26
+ "command": ["pytest", "-m", "async_postgres"],
27
+ "timeout_seconds": 900,
28
+ "retry": {
29
+ "max_attempts": 2,
30
+ "retry_timeouts": True,
31
+ "total_timeout_seconds": 1800,
32
+ },
33
+ },
34
+ },
35
+ }
36
+ )
37
+
38
+ assert contract.settings.progress_interval_seconds == 10
39
+ assert contract.constraints["alembic_heads"].retry is not None
40
+ assert contract.constraints["async_postgres_tests"].retry is not None
41
+
42
+
9
43
  def test_required_rubric_demands_majority_quorum() -> None:
10
44
  with pytest.raises(ValidationError, match="at least two runs"):
11
45
  Contract.model_validate(
File without changes
File without changes