constraintloop 0.5.0__tar.gz → 0.5.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 (80) hide show
  1. {constraintloop-0.5.0 → constraintloop-0.5.1}/CHANGELOG.md +11 -0
  2. {constraintloop-0.5.0 → constraintloop-0.5.1}/PKG-INFO +7 -4
  3. {constraintloop-0.5.0 → constraintloop-0.5.1}/README.md +6 -3
  4. {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/convergence-loops.md +5 -1
  5. {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/faq.md +15 -2
  6. {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/release-readiness.md +11 -1
  7. constraintloop-0.5.1/docs/worktrees.md +73 -0
  8. {constraintloop-0.5.0 → constraintloop-0.5.1}/pyproject.toml +1 -1
  9. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/__init__.py +1 -1
  10. constraintloop-0.5.1/src/constraintloop/checkout.py +88 -0
  11. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/cli.py +46 -10
  12. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/digest.py +4 -1
  13. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/engine.py +5 -0
  14. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/loops.py +10 -0
  15. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/state.py +6 -2
  16. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_review_regressions.py +24 -4
  17. constraintloop-0.5.1/tests/test_worktrees.py +346 -0
  18. {constraintloop-0.5.0 → constraintloop-0.5.1}/.gitignore +0 -0
  19. {constraintloop-0.5.0 → constraintloop-0.5.1}/CONTRIBUTING.md +0 -0
  20. {constraintloop-0.5.0 → constraintloop-0.5.1}/GOVERNANCE.md +0 -0
  21. {constraintloop-0.5.0 → constraintloop-0.5.1}/LICENSE +0 -0
  22. {constraintloop-0.5.0 → constraintloop-0.5.1}/RELEASE.md +0 -0
  23. {constraintloop-0.5.0 → constraintloop-0.5.1}/SECURITY.md +0 -0
  24. {constraintloop-0.5.0 → constraintloop-0.5.1}/SUPPORT.md +0 -0
  25. {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/completion-policy.md +0 -0
  26. {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/configuration.md +0 -0
  27. {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/native-cli-evaluators.md +0 -0
  28. {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/openai-evaluation.md +0 -0
  29. {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/pre-release-review-2026-09-06.md +0 -0
  30. {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/provider-privacy.md +0 -0
  31. {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/recipes.md +0 -0
  32. {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/threat-model.md +0 -0
  33. {constraintloop-0.5.0 → constraintloop-0.5.1}/schema/constraintloop.schema.json +0 -0
  34. {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/check_anthropic_sdk.py +0 -0
  35. {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/check_coverage.py +0 -0
  36. {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/check_openai_sdk.py +0 -0
  37. {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/check_sdist_contents.py +0 -0
  38. {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/generate_schema.py +0 -0
  39. {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/openai_eval_canary.py +0 -0
  40. {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/wheel_failure_smoke.py +0 -0
  41. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/__main__.py +0 -0
  42. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/_numbers.py +0 -0
  43. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/_process.py +0 -0
  44. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/challenges.py +0 -0
  45. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/config.py +0 -0
  46. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/diagnostics.py +0 -0
  47. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/environment.py +0 -0
  48. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/eval_corpus.py +0 -0
  49. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/evaluators.py +0 -0
  50. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/hooks.py +0 -0
  51. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/hygiene.py +0 -0
  52. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/models.py +0 -0
  53. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/native_cli_evaluator.py +0 -0
  54. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/py.typed +0 -0
  55. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/redaction.py +0 -0
  56. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/runners.py +0 -0
  57. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/scaffold.py +0 -0
  58. {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/setup_hooks.py +0 -0
  59. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/__init__.py +0 -0
  60. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/failure_lab.py +0 -0
  61. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/fixtures/openai_eval_corpus_v1.yml +0 -0
  62. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_challenges.py +0 -0
  63. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_cli.py +0 -0
  64. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_cli_commands.py +0 -0
  65. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_diagnostics.py +0 -0
  66. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_digest.py +0 -0
  67. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_engine.py +0 -0
  68. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_environment.py +0 -0
  69. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_eval_corpus.py +0 -0
  70. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_evaluators.py +0 -0
  71. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_failure_lab.py +0 -0
  72. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_hooks.py +0 -0
  73. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_loops.py +0 -0
  74. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_models.py +0 -0
  75. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_native_cli_evaluator.py +0 -0
  76. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_process.py +0 -0
  77. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_release_metadata.py +0 -0
  78. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_runners.py +0 -0
  79. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_security.py +0 -0
  80. {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_state.py +0 -0
@@ -5,6 +5,17 @@ Versioning, with the usual initial-development flexibility for `0.y.z`.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.5.1] - 2026-09-08
9
+
10
+ - Isolate local evidence, sessions, waivers, acknowledgments, journals, and leases
11
+ by resolved project/worktree and branch (commit for detached HEAD). Bind input
12
+ digests to checkout identity and HEAD, keeping branch retry budgets across commits.
13
+ - Reject checkout changes during gate evaluation and supervision. Report project,
14
+ worktree, branch, HEAD, and state directory in `doctor` and `explain`.
15
+ - Migration: Git projects start fresh state under `.constraintloop/state/checkouts/`;
16
+ prior unscoped state is retained but never imported into a branch. All prior input
17
+ digests become stale. Run checks again and start a fresh challenge cycle if used.
18
+
8
19
  ## [0.5.0] - 2026-09-06
9
20
 
10
21
  - Add optional session challenge gates for Claude Code, Codex, and Gemini CLI:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: constraintloop
3
- Version: 0.5.0
3
+ Version: 0.5.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
@@ -314,10 +314,13 @@ Commands run from their configured project-contained `cwd`, with the selected
314
314
  project root prepended to `PYTHONPATH`.
315
315
 
316
316
  Evidence is keyed by the constraint definition and the bytes of every file
317
- matched by `watch`. A source change therefore makes old evidence and waivers
318
- stale without a mutable invalidation list. Local state lives under the
317
+ matched by `watch`, plus the resolved project/worktree, branch, and HEAD. A source
318
+ change or new HEAD therefore makes old evidence and waivers stale without a
319
+ mutable invalidation list. Local state lives under the
319
320
  gitignored `.constraintloop/state` directory; set `CONSTRAINTLOOP_CACHE_DIR` to
320
- override it.
321
+ override it. Git projects use a separate state subdirectory per branch in each
322
+ worktree. Use one worktree per concurrent line of work; `doctor` and `explain`
323
+ show the active scope. See [worktrees and concurrent sessions](docs/worktrees.md).
321
324
 
322
325
  For stronger machine-local gates, create a gitignored
323
326
  `constraintloop.local.yml`. ConstraintLoop recursively merges mappings over the
@@ -271,10 +271,13 @@ Commands run from their configured project-contained `cwd`, with the selected
271
271
  project root prepended to `PYTHONPATH`.
272
272
 
273
273
  Evidence is keyed by the constraint definition and the bytes of every file
274
- matched by `watch`. A source change therefore makes old evidence and waivers
275
- stale without a mutable invalidation list. Local state lives under the
274
+ matched by `watch`, plus the resolved project/worktree, branch, and HEAD. A source
275
+ change or new HEAD therefore makes old evidence and waivers stale without a
276
+ mutable invalidation list. Local state lives under the
276
277
  gitignored `.constraintloop/state` directory; set `CONSTRAINTLOOP_CACHE_DIR` to
277
- override it.
278
+ override it. Git projects use a separate state subdirectory per branch in each
279
+ worktree. Use one worktree per concurrent line of work; `doctor` and `explain`
280
+ show the active scope. See [worktrees and concurrent sessions](docs/worktrees.md).
278
281
 
279
282
  For stronger machine-local gates, create a gitignored
280
283
  `constraintloop.local.yml`. ConstraintLoop recursively merges mappings over the
@@ -144,7 +144,8 @@ single-shot, tool-disabled reviews and cannot perform a repair transition.
144
144
 
145
145
  The supervisor:
146
146
 
147
- - use a single-writer lease under `.constraintloop/state/loops/`;
147
+ - use a single-writer lease in the active state directory's `loops/` subdirectory
148
+ (`doctor` reports the directory; Git checkouts separate state by worktree and branch);
148
149
  - recover leases after a bounded TTL;
149
150
  - handle cancellation signals;
150
151
  - never count polling observations as repair attempts;
@@ -153,6 +154,9 @@ The supervisor:
153
154
  - redact secrets and cap retained output;
154
155
  - stop at every attempt and duration budget.
155
156
 
157
+ Supervision stops if the Git checkout changes while it is running. Start a new
158
+ supervisor in the intended worktree so it reloads that checkout's contract.
159
+
156
160
  ## Native integration
157
161
 
158
162
  `constraintloop loop-prompt NAME --adapter claude|codex|gemini` prints a durable
@@ -79,10 +79,23 @@ call.
79
79
 
80
80
  ### Why does a result say `cached`?
81
81
 
82
- The contract definition and every matched `watch` input have the same digest as
83
- the prior evidence. Use `--no-cache` for a deliberate local rerun. CI always
82
+ The contract definition, checkout identity (project, worktree, branch, HEAD), and
83
+ every matched `watch` input have the same digest as the prior evidence.
84
+ Use `--no-cache` for a deliberate local rerun. CI always
84
85
  reruns without local evidence.
85
86
 
87
+ ### Can concurrent sessions use separate worktrees?
88
+
89
+ Yes, including detached worktrees and nested monorepo projects. Each worktree
90
+ and branch has its own evidence, goals, waivers, acknowledgments, loop journals,
91
+ and budgets. A shared `CONSTRAINTLOOP_CACHE_DIR` still separates resolved project
92
+ paths. `constraintloop doctor` and `constraintloop explain --json` report the
93
+ active worktree, branch, HEAD, and state directory.
94
+
95
+ Use a dedicated worktree for each concurrent line of work. Multiple sessions
96
+ in one directory still share its checked-out files and external services.
97
+ See [worktree setup and container isolation](worktrees.md).
98
+
86
99
  ### Why did an unrelated edit invalidate a result?
87
100
 
88
101
  The constraint probably has a broad `watch`, such as `"**/*"`. Narrow it to the
@@ -1,6 +1,6 @@
1
1
  # Release readiness
2
2
 
3
- This is the current release checklist, updated September 6, 2026 for v0.5.0.
3
+ This is the current release checklist, updated September 8, 2026 for v0.5.1.
4
4
  The authoritative publication procedure is [RELEASE.md](../RELEASE.md).
5
5
  Historical v0.1 milestones are complete; they are not current release gates.
6
6
 
@@ -22,6 +22,16 @@ Historical v0.1 milestones are complete; they are not current release gates.
22
22
  - Verify publication and a fresh public-index install afterward. Never replace
23
23
  an existing version, publish locally, or add long-lived registry tokens.
24
24
 
25
+ ## v0.5.1 worktree isolation
26
+
27
+ Worktree regressions cover linked and detached checkouts, shared cache directories,
28
+ branch switches at the same commit, HEAD changes with unchanged watched files,
29
+ session goals and retry budgets, local exceptions, loop journals and leases,
30
+ checkout changes during evaluation and supervision, nested-project hooks,
31
+ legacy-state isolation, and challenge-only loops. See [worktrees](worktrees.md)
32
+ for setup and migration instructions. Container resource isolation remains the
33
+ consuming project's responsibility.
34
+
25
35
  ## v0.5 review closure
26
36
 
27
37
  The [pre-release review](pre-release-review-2026-09-06.md) records ten reproduced
@@ -0,0 +1,73 @@
1
+ # Worktrees and concurrent sessions
2
+
3
+ Use one Git worktree per concurrent line of work. Each directory has its own
4
+ checkout of the contract, source files, and local ConstraintLoop state. Linked
5
+ and detached worktrees are supported, including contracts below the Git root.
6
+
7
+ ## What is isolated
8
+
9
+ For Git projects, state lives under
10
+ `.constraintloop/state/checkouts/<scope>/` in the selected project. The scope
11
+ includes the resolved project path, worktree Git directory, and full branch
12
+ reference. Detached checkouts use the HEAD commit instead of a branch.
13
+
14
+ Evidence, session goals and retries, waivers, advisory acknowledgments, loop
15
+ journals, challenge work, and supervisor leases all use this directory. Setting
16
+ `CONSTRAINTLOOP_CACHE_DIR` relocates storage while preserving project and checkout
17
+ separation. Symlink aliases of the same project resolve to the same scope.
18
+ Projects outside Git continue using `.constraintloop/state/` directly.
19
+
20
+ Evidence digests also include HEAD, the contract, and watched file bytes. An
21
+ empty commit therefore makes earlier evidence stale even when watched files
22
+ are identical. Retry budgets persist across commits on the same branch. Switching
23
+ branches selects that branch's state; switching back resumes its budgets and
24
+ reuses evidence only if its inputs still match. A checkout change detected during
25
+ evaluation or supervision prevents completion and requires another run.
26
+
27
+ This does not make concurrent branch switching in one directory safe. Sessions
28
+ there still share files and commands; use separate worktrees for independent tasks.
29
+
30
+ ## Set up a line of work
31
+
32
+ Create a worktree from the desired starting branch or commit:
33
+
34
+ ```bash
35
+ git worktree add -b task/worktree-fix ../project-worktree-fix HEAD
36
+ ```
37
+
38
+ Bootstrap dependencies and local environment files in the new directory using
39
+ the project's normal setup process. Git does not copy ignored virtual environments,
40
+ secrets, or local hook settings. Then install hooks with the ConstraintLoop
41
+ executable intended for that worktree:
42
+
43
+ ```bash
44
+ constraintloop setup --adapter all --project ../project-worktree-fix
45
+ constraintloop doctor --deep --project ../project-worktree-fix
46
+ constraintloop explain --json --project ../project-worktree-fix
47
+ ```
48
+
49
+ If the contract is in a subproject, pass that directory, for example
50
+ `--project ../project-worktree-fix/packages/atool`, to each command. Generated hooks
51
+ retain this subproject path relative to the active worktree root. Start the coding
52
+ session in that worktree. Inspect `state_directory` in `explain --json` to verify
53
+ that sessions intended to be independent have different scopes.
54
+
55
+ ## Containers and integration tests
56
+
57
+ Git worktrees isolate files; they do not isolate the Docker daemon, host ports,
58
+ databases, or test cleanup. ConstraintLoop does not allocate these resources.
59
+
60
+ Configure the consuming project's container setup with distinct Compose project
61
+ names, host ports, and named resources where required. TestContainers suites need
62
+ their own resource and cleanup isolation; a Compose project name alone does not
63
+ provide it. Until the project's integration runner supports concurrent execution,
64
+ serialize those test runs across worktrees. `doctor --deep` checks declared local
65
+ prerequisites and Docker availability, but cannot certify arbitrary test code's
66
+ container isolation.
67
+
68
+ ## Existing state after upgrading
69
+
70
+ Earlier unscoped Git state remains on disk but is not imported into any checkout
71
+ scope, because its originating branch is unknown. Run the gates again. Start a
72
+ fresh challenge cycle if the contract uses challenges. `doctor` shows the active
73
+ state directory; no manual cache deletion or contract weakening is needed.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "constraintloop"
3
- version = "0.5.0"
3
+ version = "0.5.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.5 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.5.0"
7
+ __version__ = "0.5.1"
@@ -0,0 +1,88 @@
1
+ """Git checkout identity without following a shared repository's main worktree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import subprocess
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Checkout:
15
+ project_root: str
16
+ worktree_root: str | None = None
17
+ git_dir: str | None = None
18
+ branch: str | None = None
19
+ head: str | None = None
20
+
21
+ def snapshot(self) -> str:
22
+ return json.dumps(asdict(self), sort_keys=True, separators=(",", ":"))
23
+
24
+ def state_key(self) -> str | None:
25
+ """Keep budgets across commits on a branch, separate detached checkouts."""
26
+ if self.git_dir is None:
27
+ return None
28
+ identity = (self.project_root, self.git_dir, self.branch or self.head)
29
+ return hashlib.sha256(json.dumps(identity).encode()).hexdigest()[:20]
30
+
31
+
32
+ def ensure_checkout_unchanged(project_root: Path, expected: Checkout) -> None:
33
+ if checkout_context(project_root) != expected:
34
+ raise ValueError(
35
+ "Git checkout changed during evaluation; evidence cannot authorize completion. "
36
+ "Run gates again in a dedicated worktree for this task."
37
+ )
38
+
39
+
40
+ def checkout_context(project_root: Path) -> Checkout:
41
+ root = project_root.expanduser().resolve()
42
+ # A .git file marks linked worktrees and submodules just as a directory marks
43
+ # a regular checkout. Projects outside Git keep their existing local scope.
44
+ if not any((candidate / ".git").exists() for candidate in (root, *root.parents)):
45
+ return Checkout(project_root=str(root))
46
+
47
+ # Git hooks can export the originating checkout's repository paths. The
48
+ # explicit project directory must remain authoritative for our inspection.
49
+ environment = {
50
+ key: value
51
+ for key, value in os.environ.items()
52
+ if key
53
+ not in {
54
+ "GIT_DIR",
55
+ "GIT_WORK_TREE",
56
+ "GIT_COMMON_DIR",
57
+ "GIT_INDEX_FILE",
58
+ "GIT_OBJECT_DIRECTORY",
59
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
60
+ "GIT_NAMESPACE",
61
+ }
62
+ }
63
+
64
+ def git(*args: str, optional: bool = False) -> str | None:
65
+ try:
66
+ result = subprocess.run(
67
+ ["git", *args],
68
+ cwd=root,
69
+ env=environment,
70
+ capture_output=True,
71
+ encoding="utf-8",
72
+ errors="replace",
73
+ timeout=5,
74
+ check=False,
75
+ )
76
+ except (OSError, subprocess.TimeoutExpired) as exc:
77
+ raise ValueError(f"Could not inspect Git checkout in {root}") from exc
78
+ if result.returncode != 0:
79
+ if optional and result.returncode == 1:
80
+ return None
81
+ raise ValueError(f"Could not inspect Git checkout in {root}")
82
+ return result.stdout.strip()
83
+
84
+ worktree = git("rev-parse", "--show-toplevel")
85
+ git_dir = git("rev-parse", "--absolute-git-dir")
86
+ branch = git("symbolic-ref", "--quiet", "HEAD", optional=True)
87
+ head = git("rev-parse", "--verify", "--quiet", "HEAD", optional=True)
88
+ return Checkout(str(root), worktree, git_dir, branch, head)
@@ -13,6 +13,7 @@ from typing import Any, cast
13
13
  import click
14
14
 
15
15
  from constraintloop import __version__
16
+ from constraintloop.checkout import checkout_context
16
17
  from constraintloop.config import (
17
18
  ContractError,
18
19
  contract_digest,
@@ -58,6 +59,7 @@ from constraintloop.setup_hooks import (
58
59
  uninstall_pre_push_hook,
59
60
  )
60
61
  from constraintloop.state import (
62
+ cache_root,
61
63
  create_advisory_acknowledgment,
62
64
  create_waiver,
63
65
  load_cached_result,
@@ -164,13 +166,16 @@ def _run_phase(project: Path, phase: Phase, json_output: bool, no_cache: bool) -
164
166
  contract, _ = load_contract(root, include_local=phase != Phase.CI)
165
167
  except ContractError as exc:
166
168
  raise click.ClickException(str(exc)) from exc
167
- record = ConstraintEngine(
168
- root,
169
- contract,
170
- use_cache=not no_cache and phase != Phase.CI,
171
- allow_waivers=phase.allows_local_waivers,
172
- progress=None if json_output else lambda message: click.echo(message, err=True),
173
- ).run(phase)
169
+ try:
170
+ record = ConstraintEngine(
171
+ root,
172
+ contract,
173
+ use_cache=not no_cache and phase != Phase.CI,
174
+ allow_waivers=phase.allows_local_waivers,
175
+ progress=None if json_output else lambda message: click.echo(message, err=True),
176
+ ).run(phase)
177
+ except ValueError as exc:
178
+ raise click.ClickException(str(exc)) from exc
174
179
  click.echo(
175
180
  record.model_dump_json(indent=2)
176
181
  if json_output
@@ -208,7 +213,7 @@ def cycle_command(loop_name: str, project: Path, json_output: bool) -> None:
208
213
  try:
209
214
  contract, _ = load_loop_contract(root, loop_name)
210
215
  result = run_cycle(root, contract, loop_name)
211
- except (ContractError, LoopError) as exc:
216
+ except (ValueError, LoopError) as exc:
212
217
  if json_output:
213
218
  click.echo(
214
219
  json.dumps(
@@ -243,7 +248,7 @@ def supervise_command(loop_name: str, project: Path) -> None:
243
248
  for result in supervise(root, contract, loop_name):
244
249
  final = result
245
250
  click.echo(result.model_dump_json())
246
- except (ContractError, LoopError) as exc:
251
+ except (ValueError, LoopError) as exc:
247
252
  click.echo(
248
253
  json.dumps(
249
254
  {
@@ -408,10 +413,18 @@ def explain_command(phase: str, project: Path, json_output: bool) -> None:
408
413
  )
409
414
  if json_output:
410
415
  click.echo(
411
- json.dumps({"phase": selected_phase.value, "constraints": explanations}, indent=2)
416
+ json.dumps(
417
+ {
418
+ "phase": selected_phase.value,
419
+ "scope": _checkout_scope(root),
420
+ "constraints": explanations,
421
+ },
422
+ indent=2,
423
+ )
412
424
  )
413
425
  return
414
426
  click.echo(f"ConstraintLoop explain: phase={selected_phase.value}")
427
+ _print_checkout_scope(root)
415
428
  for item in explanations:
416
429
  click.echo(
417
430
  f"- {str(item['decision']).upper()} {item['constraint_id']} ({item['kind']}): "
@@ -439,6 +452,28 @@ def _dependency_chains(contract: Any, constraint_id: str) -> list[str]:
439
452
  return [" -> ".join(chain) for chain in paths(constraint_id) if len(chain) > 1]
440
453
 
441
454
 
455
+ def _checkout_scope(root: Path) -> dict[str, str | None]:
456
+ checkout = checkout_context(root)
457
+ return {
458
+ "project_root": checkout.project_root,
459
+ "worktree_root": checkout.worktree_root,
460
+ "git_dir": checkout.git_dir,
461
+ "branch": checkout.branch,
462
+ "head": checkout.head,
463
+ "state_directory": str(cache_root(root)),
464
+ }
465
+
466
+
467
+ def _print_checkout_scope(root: Path) -> None:
468
+ scope = _checkout_scope(root)
469
+ click.echo(f"project: {scope['project_root']}")
470
+ click.echo(f"worktree: {scope['worktree_root'] or 'not a Git checkout'}")
471
+ if scope["git_dir"] is not None:
472
+ click.echo(f"branch: {scope['branch'] or '(detached HEAD)'}")
473
+ click.echo(f"HEAD: {scope['head'] or '(unborn)'}")
474
+ click.echo(f"state directory: {scope['state_directory']}")
475
+
476
+
442
477
  @main.group("baseline")
443
478
  def baseline_group() -> None:
444
479
  """Manage committed ratchet baselines."""
@@ -527,6 +562,7 @@ def doctor_command(project: Path, deep: bool) -> None:
527
562
  raise click.ClickException(str(exc)) from exc
528
563
  click.echo(f"OK {path}")
529
564
  click.echo(f"contract digest: {contract_digest(contract)}")
565
+ _print_checkout_scope(root)
530
566
  click.echo(f"constraints: {len(contract.constraints)}; evaluators: {len(contract.evaluators)}")
531
567
  try:
532
568
  project_environment = load_project_environment(root)
@@ -8,6 +8,7 @@ import subprocess
8
8
  from collections.abc import Iterable
9
9
  from pathlib import Path, PurePosixPath
10
10
 
11
+ from constraintloop.checkout import Checkout, checkout_context
11
12
  from constraintloop.models import ConstraintSpec, RatchetConstraint
12
13
  from constraintloop.redaction import redact_text as redact_text
13
14
 
@@ -58,8 +59,9 @@ def constraint_input_digest(
58
59
  spec: ConstraintSpec,
59
60
  *,
60
61
  contract_digest: str | None = None,
62
+ checkout: Checkout | None = None,
61
63
  ) -> str:
62
- digest = hashlib.sha256(b"constraintloop-input-v2\0")
64
+ digest = hashlib.sha256(b"constraintloop-input-v3\0")
63
65
 
64
66
  def add(value: bytes) -> None:
65
67
  digest.update(len(value).to_bytes(8, "big"))
@@ -67,6 +69,7 @@ def constraint_input_digest(
67
69
 
68
70
  add(constraint_id.encode())
69
71
  add((contract_digest or "").encode())
72
+ add((checkout or checkout_context(project_root)).snapshot().encode())
70
73
  add(json.dumps(spec.model_dump(mode="json"), sort_keys=True, separators=(",", ":")).encode())
71
74
  if isinstance(spec, RatchetConstraint):
72
75
  baseline_path = (project_root / spec.baseline_file).resolve()
@@ -14,6 +14,7 @@ from contextlib import contextmanager
14
14
  from pathlib import Path
15
15
  from typing import Any, cast
16
16
 
17
+ from constraintloop.checkout import checkout_context, ensure_checkout_unchanged
17
18
  from constraintloop.config import contract_digest
18
19
  from constraintloop.digest import (
19
20
  changed_files,
@@ -79,6 +80,7 @@ class ConstraintEngine:
79
80
  def run(self, phase: Phase) -> EvidenceRecord:
80
81
  """Run applicable constraints in dependency order."""
81
82
  started_at = time.time()
83
+ self.checkout = checkout_context(self.project_root)
82
84
  results: dict[str, ConstraintResult] = {}
83
85
  pending = {
84
86
  constraint_id
@@ -130,6 +132,7 @@ class ConstraintEngine:
130
132
  constraint_id,
131
133
  spec,
132
134
  contract_digest=self.contract_digest,
135
+ checkout=self.checkout,
133
136
  )
134
137
  if pending_dependencies:
135
138
  result = ConstraintResult(
@@ -203,6 +206,7 @@ class ConstraintEngine:
203
206
  )
204
207
  pending.difference_update(ready)
205
208
 
209
+ ensure_checkout_unchanged(self.project_root, self.checkout)
206
210
  record = EvidenceRecord(
207
211
  run_id=str(uuid.uuid4()),
208
212
  project_root=str(self.project_root),
@@ -296,6 +300,7 @@ class ConstraintEngine:
296
300
  )
297
301
 
298
302
  result = ConstraintResult.model_validate(result.model_dump())
303
+ ensure_checkout_unchanged(self.project_root, self.checkout)
299
304
  if self.use_cache and phase != Phase.CI:
300
305
  save_cached_result(self.project_root, result, cache_digest=cache_digest)
301
306
  return result
@@ -21,6 +21,7 @@ from constraintloop.challenges import (
21
21
  challenge_input_digest,
22
22
  challenge_request,
23
23
  )
24
+ from constraintloop.checkout import checkout_context, ensure_checkout_unchanged
24
25
  from constraintloop.config import contract_digest
25
26
  from constraintloop.digest import constraint_input_digest
26
27
  from constraintloop.engine import ConstraintEngine, blocking_causes
@@ -97,6 +98,7 @@ def _input_snapshot(
97
98
  project_root: Path, contract: Contract, config: LoopConfig, goal: str | None = None
98
99
  ) -> str:
99
100
  identity = contract_digest(contract)
101
+ checkout = checkout_context(project_root)
100
102
  inputs = [
101
103
  (
102
104
  constraint_id,
@@ -105,12 +107,14 @@ def _input_snapshot(
105
107
  constraint_id,
106
108
  spec,
107
109
  contract_digest=identity,
110
+ checkout=checkout,
108
111
  ),
109
112
  )
110
113
  for constraint_id, spec in contract.constraints.items()
111
114
  if spec.enabled and config.phase in spec.phases
112
115
  ]
113
116
  inputs.append(("goal", goal or ""))
117
+ inputs.append(("checkout", checkout.snapshot()))
114
118
  if config.challenge is not None:
115
119
  inputs.extend(
116
120
  [
@@ -139,6 +143,7 @@ def run_cycle(
139
143
  config = contract.loops[loop_name]
140
144
  current_time = time.time() if now is None else now
141
145
  identity = contract_digest(contract)
146
+ checkout = checkout_context(project_root)
142
147
  path = journal_path(project_root, loop_name)
143
148
  with _write_lock(path):
144
149
  try:
@@ -193,6 +198,7 @@ def run_cycle(
193
198
  )
194
199
  journal.observation = result.observation
195
200
  journal.last_result = result.model_dump(mode="json")
201
+ ensure_checkout_unchanged(project_root, checkout)
196
202
  journal.input_snapshot = input_snapshot
197
203
  _write_json(path, journal.model_dump(mode="json"))
198
204
  if on_record is not None and journal.last_evidence is not None:
@@ -210,6 +216,7 @@ def run_cycle(
210
216
  refresh_pending=True,
211
217
  ).run(config.phase)
212
218
 
219
+ ensure_checkout_unchanged(project_root, checkout)
213
220
  if on_record is not None:
214
221
  on_record(record)
215
222
 
@@ -329,6 +336,7 @@ def run_cycle(
329
336
  journal.input_snapshot = input_snapshot
330
337
  journal.last_result = result.model_dump(mode="json")
331
338
  journal.last_evidence = record
339
+ ensure_checkout_unchanged(project_root, checkout)
332
340
  _write_json(path, journal.model_dump(mode="json"))
333
341
  return result
334
342
 
@@ -475,6 +483,7 @@ def supervise(
475
483
  raise LoopError(f"Unknown loop {loop_name!r}")
476
484
  config = contract.loops[loop_name]
477
485
  ttl = max(60.0, config.interval_seconds * 3)
486
+ checkout = checkout_context(project_root)
478
487
  cancelled = False
479
488
 
480
489
  def cancel(_signum: int, _frame: Any) -> None:
@@ -488,6 +497,7 @@ def supervise(
488
497
  with loop_lease(project_root, loop_name, ttl_seconds=ttl) as renew:
489
498
  previous_state: LoopState | None = None
490
499
  while not cancelled:
500
+ ensure_checkout_unchanged(project_root, checkout)
491
501
  renew()
492
502
  result = run_cycle(project_root, contract, loop_name)
493
503
  renew()
@@ -14,6 +14,7 @@ from pathlib import Path
14
14
  from typing import Any
15
15
 
16
16
  from constraintloop._numbers import finite_number
17
+ from constraintloop.checkout import checkout_context
17
18
  from constraintloop.digest import project_key
18
19
  from constraintloop.models import ConstraintResult
19
20
 
@@ -21,8 +22,11 @@ from constraintloop.models import ConstraintResult
21
22
  def cache_root(project_root: Path) -> Path:
22
23
  override = os.environ.get("CONSTRAINTLOOP_CACHE_DIR")
23
24
  if override:
24
- return Path(override).expanduser() / project_key(project_root)
25
- return project_root.resolve() / ".constraintloop" / "state"
25
+ base = Path(override).expanduser().resolve() / project_key(project_root)
26
+ else:
27
+ base = project_root.resolve() / ".constraintloop" / "state"
28
+ key = checkout_context(project_root).state_key()
29
+ return base / "checkouts" / key if key is not None else base
26
30
 
27
31
 
28
32
  def _project_dir(project_root: Path) -> Path:
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import sys
7
+ import threading
7
8
  import time
8
9
 
9
10
  import pytest
@@ -18,7 +19,13 @@ from constraintloop.hooks import handle_hook
18
19
  from constraintloop.loops import LoopError, journal_path, lease_path, loop_lease, run_cycle
19
20
  from constraintloop.models import Contract, LoopState, MetricThreshold, Phase, Verdict
20
21
  from constraintloop.redaction import redact_value
21
- from constraintloop.state import create_waiver, load_ratchet_baseline, save_ratchet_baseline
22
+ from constraintloop.state import (
23
+ _write_json,
24
+ _write_lock,
25
+ create_waiver,
26
+ load_ratchet_baseline,
27
+ save_ratchet_baseline,
28
+ )
22
29
 
23
30
 
24
31
  @pytest.fixture(autouse=True)
@@ -430,7 +437,19 @@ def test_supervisor_lease_renews_without_another_cycle(tmp_path):
430
437
 
431
438
 
432
439
  @pytest.mark.parametrize("renew_after_loss", [False, True])
433
- def test_lost_supervisor_heartbeat_fails_closed_and_preserves_new_owner(tmp_path, renew_after_loss):
440
+ def test_lost_supervisor_heartbeat_fails_closed_and_preserves_new_owner(
441
+ tmp_path, renew_after_loss, monkeypatch
442
+ ):
443
+ heartbeat_exited = threading.Event()
444
+
445
+ class ObservedHeartbeat(threading.Thread):
446
+ def run(self):
447
+ try:
448
+ super().run()
449
+ finally:
450
+ heartbeat_exited.set()
451
+
452
+ monkeypatch.setattr("constraintloop.loops.threading.Thread", ObservedHeartbeat)
434
453
  with (
435
454
  pytest.raises(LoopError, match="renewal failed"),
436
455
  loop_lease(
@@ -440,8 +459,9 @@ def test_lost_supervisor_heartbeat_fails_closed_and_preserves_new_owner(tmp_path
440
459
  ) as renew,
441
460
  ):
442
461
  path = lease_path(tmp_path, "review")
443
- path.write_text(json.dumps({"token": "new-owner", "expires_at": time.time() + 10}))
444
- time.sleep(0.15)
462
+ with _write_lock(path):
463
+ _write_json(path, {"token": "new-owner", "expires_at": time.time() + 10})
464
+ assert heartbeat_exited.wait(timeout=5), "heartbeat did not observe the lost lease"
445
465
  if renew_after_loss:
446
466
  renew()
447
467
  assert json.loads(path.read_text())["token"] == "new-owner"
@@ -0,0 +1,346 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shlex
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+ import yaml
12
+ from click.testing import CliRunner
13
+
14
+ from constraintloop.checkout import checkout_context
15
+ from constraintloop.cli import main
16
+ from constraintloop.config import contract_digest
17
+ from constraintloop.digest import constraint_input_digest
18
+ from constraintloop.engine import ConstraintEngine
19
+ from constraintloop.hooks import handle_hook
20
+ from constraintloop.loops import journal_path, loop_lease, run_cycle, show_challenge, supervise
21
+ from constraintloop.models import ChallengeConfig, Contract, LoopState, Phase, Verdict
22
+ from constraintloop.setup_hooks import install_hooks
23
+ from constraintloop.state import (
24
+ advisory_acknowledgment_reason,
25
+ cache_root,
26
+ create_advisory_acknowledgment,
27
+ create_waiver,
28
+ load_latest_result,
29
+ load_session,
30
+ save_session,
31
+ )
32
+
33
+
34
+ def git(root: Path, *args: str) -> str:
35
+ return subprocess.run(
36
+ ["git", *args], cwd=root, check=True, capture_output=True, text=True
37
+ ).stdout.strip()
38
+
39
+
40
+ @pytest.fixture
41
+ def repository(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
42
+ monkeypatch.delenv("CONSTRAINTLOOP_CACHE_DIR", raising=False)
43
+ root = tmp_path / "repo"
44
+ root.mkdir()
45
+ git(root, "init", "-q", "-b", "main")
46
+ git(root, "config", "user.name", "Test")
47
+ git(root, "config", "user.email", "test@example.invalid")
48
+ git(root, "config", "commit.gpgsign", "false")
49
+ git(root, "config", "core.hooksPath", str(tmp_path / "no-hooks"))
50
+ (root / "source").write_text("same\n")
51
+ git(root, "add", "source")
52
+ git(root, "commit", "-qm", "initial")
53
+ return root
54
+
55
+
56
+ def contract(*, failure: bool = False) -> Contract:
57
+ return Contract.model_validate(
58
+ {
59
+ "settings": {"max_auto_retries": 1},
60
+ "constraints": {
61
+ "check": {
62
+ "kind": "command",
63
+ "command": [sys.executable, "-c", f"raise SystemExit({int(failure)})"],
64
+ "watch": ["source"],
65
+ "phases": ["stop", "ci"],
66
+ }
67
+ },
68
+ "loops": {
69
+ "completion": {
70
+ "phase": "stop",
71
+ "interval_seconds": 10,
72
+ "max_repair_attempts": 3,
73
+ "max_unchanged_repairs": 2,
74
+ "max_duration_seconds": 100,
75
+ }
76
+ },
77
+ }
78
+ )
79
+
80
+
81
+ @pytest.mark.parametrize("shared_cache", [False, True])
82
+ @pytest.mark.parametrize("detached", [False, True])
83
+ def test_worktrees_isolate_all_state(
84
+ repository: Path, monkeypatch: pytest.MonkeyPatch, shared_cache: bool, detached: bool
85
+ ) -> None:
86
+ if shared_cache:
87
+ monkeypatch.setenv("CONSTRAINTLOOP_CACHE_DIR", str(repository.parent / "cache"))
88
+ linked = repository.parent / "linked"
89
+ options = ["--detach"] if detached else ["-b", "feature"]
90
+ git(repository, "worktree", "add", *options, str(linked), "HEAD")
91
+ policy = contract(failure=True)
92
+ first = ConstraintEngine(repository, policy).run(Phase.STOP).results[0]
93
+ save_session(repository, "same-session", {"goal": "main task", "attempts": 4})
94
+ create_waiver(repository, first, contract_digest(policy), "local exception")
95
+ create_advisory_acknowledgment(repository, first, "main disposition")
96
+ run_cycle(repository, policy, "completion")
97
+
98
+ assert cache_root(repository) != cache_root(linked)
99
+ assert load_latest_result(linked, "check") is None
100
+ assert load_session(linked, "same-session") == {}
101
+ assert advisory_acknowledgment_reason(linked, first) is None
102
+ assert not journal_path(linked, "completion").exists()
103
+ with (
104
+ loop_lease(repository, "completion", ttl_seconds=60),
105
+ loop_lease(linked, "completion", ttl_seconds=60),
106
+ ):
107
+ other = ConstraintEngine(linked, policy).run(Phase.STOP).results[0]
108
+ assert other.verdict == Verdict.FAIL
109
+ assert not other.cached
110
+ assert other.input_digest != first.input_digest
111
+ assert ConstraintEngine(repository, policy).run(Phase.STOP).results[0].verdict == Verdict.WAIVED
112
+ assert ConstraintEngine(linked, policy).run(Phase.STOP).results[0].cached
113
+
114
+
115
+ def test_branch_switch_isolates_evidence_goals_and_budgets(repository: Path) -> None:
116
+ policy = contract(failure=True)
117
+ # Exercise ordinary Stop retries separately from the loop's repair budget.
118
+ hook_policy = policy.model_dump(mode="json", exclude={"loops"})
119
+ (repository / "constraintloop.yml").write_text(yaml.safe_dump(hook_policy))
120
+ payload = {"session_id": "session"}
121
+ handle_hook(repository, "claude", "user-prompt", {**payload, "prompt": "main task"})
122
+ assert handle_hook(repository, "claude", "stop", payload)["decision"] == "block"
123
+ assert handle_hook(repository, "claude", "stop", payload)["continue"] is False
124
+ first = run_cycle(repository, policy, "completion", now=100)
125
+ second = run_cycle(repository, policy, "completion", now=101)
126
+ assert second.repair_attempt > first.repair_attempt
127
+ original_state = cache_root(repository)
128
+ original_head = checkout_context(repository).head
129
+
130
+ git(repository, "checkout", "-qb", "other")
131
+ assert checkout_context(repository).head == original_head
132
+ assert cache_root(repository) != original_state
133
+ assert load_latest_result(repository, "check") is None
134
+ assert load_session(repository, "session") == {}
135
+ assert handle_hook(repository, "claude", "stop", payload)["decision"] == "block"
136
+ other = run_cycle(repository, policy, "completion", now=102)
137
+ assert other.observation == 1
138
+ assert other.repair_attempt == 0
139
+
140
+ git(repository, "checkout", "-q", "main")
141
+ assert cache_root(repository) == original_state
142
+ assert load_session(repository, "session")["goal"] == "main task"
143
+ assert handle_hook(repository, "claude", "stop", payload)["continue"] is False
144
+ assert run_cycle(repository, policy, "completion", now=103).repair_attempt > 0
145
+
146
+
147
+ @pytest.mark.parametrize("failure", [False, True])
148
+ def test_commit_invalidates_evidence_without_resetting_budgets(
149
+ repository: Path, failure: bool
150
+ ) -> None:
151
+ policy = contract(failure=failure)
152
+ first = ConstraintEngine(repository, policy).run(Phase.STOP).results[0]
153
+ if failure:
154
+ create_waiver(repository, first, contract_digest(policy), "old HEAD only")
155
+ save_session(repository, "session", {"attempts": 2})
156
+ original_state = cache_root(repository)
157
+ git(repository, "commit", "--allow-empty", "-qm", "new HEAD, same watched bytes")
158
+
159
+ assert cache_root(repository) == original_state
160
+ assert load_session(repository, "session")["attempts"] == 2
161
+ # Write the same policy for the CLI's freshness diagnostics.
162
+ (repository / "constraintloop.yml").write_text(yaml.safe_dump(policy.model_dump(mode="json")))
163
+ status = CliRunner().invoke(main, ["status", "--project", str(repository)])
164
+ assert status.exit_code == 0, status.output
165
+ assert "STALE check" in status.output
166
+ second = ConstraintEngine(repository, policy).run(Phase.STOP).results[0]
167
+ assert not second.cached
168
+ assert second.input_digest != first.input_digest
169
+ assert second.verdict == first.verdict
170
+ assert ConstraintEngine(repository, policy).run(Phase.STOP).results[0].cached
171
+
172
+
173
+ def test_detached_and_unborn_checkout_identity(repository: Path) -> None:
174
+ policy = contract()
175
+ branch = checkout_context(repository)
176
+ initial = constraint_input_digest(repository, "check", policy.constraints["check"])
177
+ git(repository, "checkout", "--detach", "-q")
178
+ detached = checkout_context(repository)
179
+ assert detached.branch is None
180
+ assert detached.head == branch.head
181
+ assert detached.state_key() != branch.state_key()
182
+ assert constraint_input_digest(repository, "check", policy.constraints["check"]) != initial
183
+ git(repository, "commit", "--allow-empty", "-qm", "detached commit")
184
+ assert checkout_context(repository).state_key() != detached.state_key()
185
+
186
+ unborn = repository.parent / "unborn"
187
+ unborn.mkdir()
188
+ git(unborn, "init", "-q", "-b", "main")
189
+ context = checkout_context(unborn)
190
+ assert context.head is None
191
+ assert context.branch == "refs/heads/main"
192
+ assert context.state_key() is not None
193
+
194
+
195
+ def test_git_environment_cannot_redirect_worktree_scope(
196
+ repository: Path, monkeypatch: pytest.MonkeyPatch
197
+ ) -> None:
198
+ linked = repository.parent / "linked"
199
+ git(repository, "worktree", "add", "--detach", str(linked), "HEAD")
200
+ expected = checkout_context(linked)
201
+ monkeypatch.setenv("GIT_DIR", str(repository / ".git"))
202
+ monkeypatch.setenv("GIT_WORK_TREE", str(repository))
203
+ assert checkout_context(linked) == expected
204
+ alias = repository.parent / "alias"
205
+ alias.symlink_to(linked, target_is_directory=True)
206
+ assert cache_root(alias) == cache_root(linked)
207
+
208
+
209
+ def test_checkout_change_during_execution_fails_closed(repository: Path) -> None:
210
+ policy = Contract.model_validate(
211
+ {
212
+ "constraints": {
213
+ "switch": {
214
+ "kind": "command",
215
+ "command": ["git", "checkout", "-qb", "switched"],
216
+ "watch": ["source"],
217
+ }
218
+ }
219
+ }
220
+ )
221
+ (repository / "constraintloop.yml").write_text(yaml.safe_dump(policy.model_dump(mode="json")))
222
+ response = handle_hook(repository, "claude", "stop", {"session_id": "switch"})
223
+ assert response["continue"] is False
224
+ assert "checkout changed" in response["stopReason"]
225
+ assert load_latest_result(repository, "switch") is None
226
+ git(repository, "checkout", "-q", "main")
227
+ assert load_latest_result(repository, "switch") is None
228
+ git(repository, "branch", "-D", "switched")
229
+ result = CliRunner().invoke(main, ["run", "--project", str(repository)])
230
+ assert result.exit_code == 1
231
+ assert "checkout changed" in result.output
232
+
233
+
234
+ def test_doctor_explain_and_copied_hooks_target_nested_linked_project(repository: Path) -> None:
235
+ project = repository / "packages" / "atool"
236
+ project.mkdir(parents=True)
237
+ policy = contract()
238
+ (project / "constraintloop.yml").write_text(yaml.safe_dump(policy.model_dump(mode="json")))
239
+ git(repository, "add", "packages")
240
+ git(repository, "commit", "-qm", "nested contract")
241
+ hook_path = install_hooks(
242
+ project, "claude", hook_executable=shlex.join([sys.executable, "-m", "constraintloop"])
243
+ )
244
+ command = json.loads(hook_path.read_text())["hooks"]["Stop"][0]["hooks"][0]["command"]
245
+ linked = repository.parent / "linked"
246
+ git(repository, "worktree", "add", "--detach", str(linked), "HEAD")
247
+ linked_project = linked / "packages" / "atool"
248
+ environment = {**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")}
249
+ hook = subprocess.run(
250
+ command,
251
+ shell=True,
252
+ cwd=linked_project,
253
+ env=environment,
254
+ input=json.dumps({"session_id": "linked"}),
255
+ text=True,
256
+ capture_output=True,
257
+ check=True,
258
+ )
259
+ assert json.loads(hook.stdout).get("continue") is not False
260
+ assert load_latest_result(linked_project, "check") is not None
261
+ assert load_latest_result(project, "check") is None
262
+
263
+ runner = CliRunner()
264
+ doctor = runner.invoke(main, ["doctor", "--project", str(linked_project)])
265
+ assert doctor.exit_code == 0, doctor.output
266
+ assert f"worktree: {linked}" in doctor.output
267
+ assert "branch: (detached HEAD)" in doctor.output
268
+ assert str(cache_root(linked_project)) in doctor.output
269
+ explain = runner.invoke(main, ["explain", "--json", "--project", str(linked_project)])
270
+ assert explain.exit_code == 0, explain.output
271
+ scope = json.loads(explain.output)["scope"]
272
+ assert scope["project_root"] == str(linked_project)
273
+ assert scope["worktree_root"] == str(linked)
274
+ assert scope["branch"] is None
275
+ assert scope["state_directory"] == str(cache_root(linked_project))
276
+
277
+
278
+ @pytest.mark.parametrize("error", [OSError("missing git"), subprocess.TimeoutExpired("git", 5)])
279
+ def test_unreadable_checkout_is_not_treated_as_non_git(
280
+ repository: Path, monkeypatch: pytest.MonkeyPatch, error: Exception
281
+ ) -> None:
282
+ def fail(*args, **kwargs):
283
+ raise error
284
+
285
+ monkeypatch.setattr("constraintloop.checkout.subprocess.run", fail)
286
+ with pytest.raises(ValueError, match="Could not inspect Git checkout"):
287
+ checkout_context(repository)
288
+
289
+
290
+ def test_broken_git_marker_fails_closed(tmp_path: Path) -> None:
291
+ (tmp_path / ".git").write_text("gitdir: missing\n")
292
+ with pytest.raises(ValueError, match="Could not inspect Git checkout"):
293
+ checkout_context(tmp_path)
294
+
295
+
296
+ def test_non_git_projects_keep_local_state(tmp_path: Path) -> None:
297
+ assert checkout_context(tmp_path).git_dir is None
298
+ assert cache_root(tmp_path) == tmp_path / ".constraintloop" / "state"
299
+
300
+
301
+ def test_unscoped_legacy_evidence_is_not_imported(repository: Path) -> None:
302
+ policy = contract()
303
+ result = ConstraintEngine(repository, policy, use_cache=False).run(Phase.STOP).results[0]
304
+ legacy = repository / ".constraintloop" / "state"
305
+ legacy.mkdir(parents=True)
306
+ evidence = legacy / "evidence.json"
307
+ evidence.write_text(json.dumps({"check": result.model_dump(mode="json")}))
308
+
309
+ assert load_latest_result(repository, "check") is None
310
+ assert not ConstraintEngine(repository, policy).run(Phase.STOP).results[0].cached
311
+ assert evidence.is_file()
312
+
313
+
314
+ def test_supervisor_does_not_follow_branch_switch_and_releases_original_lease(
315
+ repository: Path,
316
+ ) -> None:
317
+ policy = contract()
318
+ policy.constraints["check"] = policy.constraints["check"].model_copy(
319
+ update={"command": [sys.executable, "-c", "raise SystemExit(75)"]}
320
+ )
321
+ policy.loops["completion"].interval_seconds = 0.001
322
+ runner = supervise(repository, policy, "completion")
323
+ try:
324
+ assert next(runner).state == LoopState.WAITING
325
+ git(repository, "checkout", "-qb", "other")
326
+ with pytest.raises(ValueError, match="checkout changed"):
327
+ next(runner)
328
+ assert not journal_path(repository, "completion").exists()
329
+ git(repository, "checkout", "-q", "main")
330
+ with loop_lease(repository, "completion", ttl_seconds=60):
331
+ pass
332
+ finally:
333
+ runner.close()
334
+
335
+
336
+ def test_challenge_only_loop_refreshes_request_on_new_head(repository: Path) -> None:
337
+ policy = contract()
338
+ policy.constraints = {}
339
+ policy.loops["completion"].challenge = ChallengeConfig(count=1, watch=["source"])
340
+ assert run_cycle(repository, policy, "completion").state == LoopState.CHALLENGE
341
+ first = show_challenge(repository, policy, "completion")["request"]
342
+ git(repository, "commit", "--allow-empty", "-qm", "new HEAD")
343
+ assert run_cycle(repository, policy, "completion").state == LoopState.CHALLENGE
344
+ second = show_challenge(repository, policy, "completion")["request"]
345
+ assert second["request_id"] != first["request_id"]
346
+ assert second["input_snapshot"] != first["input_snapshot"]
File without changes