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.
- {constraintloop-0.5.0 → constraintloop-0.5.1}/CHANGELOG.md +11 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/PKG-INFO +7 -4
- {constraintloop-0.5.0 → constraintloop-0.5.1}/README.md +6 -3
- {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/convergence-loops.md +5 -1
- {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/faq.md +15 -2
- {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/release-readiness.md +11 -1
- constraintloop-0.5.1/docs/worktrees.md +73 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/pyproject.toml +1 -1
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/__init__.py +1 -1
- constraintloop-0.5.1/src/constraintloop/checkout.py +88 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/cli.py +46 -10
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/digest.py +4 -1
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/engine.py +5 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/loops.py +10 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/state.py +6 -2
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_review_regressions.py +24 -4
- constraintloop-0.5.1/tests/test_worktrees.py +346 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/.gitignore +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/CONTRIBUTING.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/GOVERNANCE.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/LICENSE +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/RELEASE.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/SECURITY.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/SUPPORT.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/completion-policy.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/configuration.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/native-cli-evaluators.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/openai-evaluation.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/pre-release-review-2026-09-06.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/provider-privacy.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/recipes.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/docs/threat-model.md +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/schema/constraintloop.schema.json +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/check_anthropic_sdk.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/check_coverage.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/check_openai_sdk.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/check_sdist_contents.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/generate_schema.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/openai_eval_canary.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/scripts/wheel_failure_smoke.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/__main__.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/_numbers.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/_process.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/challenges.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/config.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/diagnostics.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/environment.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/eval_corpus.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/evaluators.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/hooks.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/hygiene.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/models.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/native_cli_evaluator.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/py.typed +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/redaction.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/runners.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/scaffold.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/src/constraintloop/setup_hooks.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/__init__.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/failure_lab.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/fixtures/openai_eval_corpus_v1.yml +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_challenges.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_cli.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_cli_commands.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_diagnostics.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_digest.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_engine.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_environment.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_eval_corpus.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_evaluators.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_failure_lab.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_hooks.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_loops.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_models.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_native_cli_evaluator.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_process.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_release_metadata.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_runners.py +0 -0
- {constraintloop-0.5.0 → constraintloop-0.5.1}/tests/test_security.py +0 -0
- {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.
|
|
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
|
|
318
|
-
|
|
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
|
|
275
|
-
|
|
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
|
|
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
|
|
83
|
-
|
|
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
|
|
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.
|
|
@@ -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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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 (
|
|
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 (
|
|
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(
|
|
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-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
444
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|