constraintloop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- constraintloop/__init__.py +7 -0
- constraintloop/__main__.py +4 -0
- constraintloop/cli.py +485 -0
- constraintloop/config.py +53 -0
- constraintloop/digest.py +233 -0
- constraintloop/engine.py +466 -0
- constraintloop/environment.py +50 -0
- constraintloop/eval_corpus.py +46 -0
- constraintloop/evaluators.py +334 -0
- constraintloop/hooks.py +335 -0
- constraintloop/loops.py +334 -0
- constraintloop/models.py +397 -0
- constraintloop/native_cli_evaluator.py +464 -0
- constraintloop/py.typed +1 -0
- constraintloop/runners.py +290 -0
- constraintloop/scaffold.py +181 -0
- constraintloop/setup_hooks.py +191 -0
- constraintloop/state.py +225 -0
- constraintloop-0.1.0.dist-info/METADATA +371 -0
- constraintloop-0.1.0.dist-info/RECORD +23 -0
- constraintloop-0.1.0.dist-info/WHEEL +4 -0
- constraintloop-0.1.0.dist-info/entry_points.txt +5 -0
- constraintloop-0.1.0.dist-info/licenses/LICENSE +21 -0
constraintloop/cli.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""ConstraintLoop command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from constraintloop.config import (
|
|
16
|
+
ContractError,
|
|
17
|
+
contract_digest,
|
|
18
|
+
load_contract,
|
|
19
|
+
)
|
|
20
|
+
from constraintloop.digest import constraint_input_digest
|
|
21
|
+
from constraintloop.engine import ConstraintEngine, format_summary
|
|
22
|
+
from constraintloop.environment import load_project_environment, project_environment_path
|
|
23
|
+
from constraintloop.hooks import handle_hook
|
|
24
|
+
from constraintloop.loops import CYCLE_EXIT_CODES, LoopError, loop_prompt, run_cycle, supervise
|
|
25
|
+
from constraintloop.models import (
|
|
26
|
+
CommandEvaluatorConfig,
|
|
27
|
+
Enforcement,
|
|
28
|
+
LoopState,
|
|
29
|
+
Phase,
|
|
30
|
+
RubricConstraint,
|
|
31
|
+
Verdict,
|
|
32
|
+
)
|
|
33
|
+
from constraintloop.scaffold import (
|
|
34
|
+
authoring_proposal,
|
|
35
|
+
enhancement_proposal,
|
|
36
|
+
write_initial_contract,
|
|
37
|
+
write_proposal,
|
|
38
|
+
)
|
|
39
|
+
from constraintloop.setup_hooks import ADAPTERS, install_hooks, uninstall_hooks
|
|
40
|
+
from constraintloop.state import (
|
|
41
|
+
create_advisory_acknowledgment,
|
|
42
|
+
create_waiver,
|
|
43
|
+
load_cached_result,
|
|
44
|
+
load_latest_result,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _root(value: Path) -> Path:
|
|
49
|
+
return value.expanduser().resolve()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.group()
|
|
53
|
+
@click.version_option()
|
|
54
|
+
def main() -> None:
|
|
55
|
+
"""Evidence-based completion gates for AI coding agents."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@main.command("init")
|
|
59
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
60
|
+
@click.option("--force", is_flag=True, help="Replace an existing contract.")
|
|
61
|
+
def init_command(project: Path, force: bool) -> None:
|
|
62
|
+
"""Generate an explicit contract from detected project tooling."""
|
|
63
|
+
root = _root(project)
|
|
64
|
+
path = root / "constraintloop.yml"
|
|
65
|
+
if path.exists() and not force:
|
|
66
|
+
raise click.ClickException(f"{path} already exists; use --force to replace it")
|
|
67
|
+
path = write_initial_contract(root)
|
|
68
|
+
click.echo(f"Created {path}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@main.command("setup")
|
|
72
|
+
@click.option(
|
|
73
|
+
"--adapter",
|
|
74
|
+
type=click.Choice([*ADAPTERS, "all"]),
|
|
75
|
+
default="all",
|
|
76
|
+
show_default=True,
|
|
77
|
+
)
|
|
78
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
79
|
+
def setup_command(adapter: str, project: Path) -> None:
|
|
80
|
+
"""Install idempotent Claude, Codex, and Gemini hook entries."""
|
|
81
|
+
root = _root(project)
|
|
82
|
+
adapters = list(ADAPTERS) if adapter == "all" else [adapter]
|
|
83
|
+
for name in adapters:
|
|
84
|
+
try:
|
|
85
|
+
path = install_hooks(root, name)
|
|
86
|
+
except (OSError, ValueError) as exc:
|
|
87
|
+
raise click.ClickException(f"Could not install {name} hooks: {exc}") from exc
|
|
88
|
+
click.echo(f"Updated {path}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@main.command("uninstall")
|
|
92
|
+
@click.option(
|
|
93
|
+
"--adapter",
|
|
94
|
+
type=click.Choice([*ADAPTERS, "all"]),
|
|
95
|
+
default="all",
|
|
96
|
+
show_default=True,
|
|
97
|
+
)
|
|
98
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
99
|
+
def uninstall_command(adapter: str, project: Path) -> None:
|
|
100
|
+
"""Remove ConstraintLoop hooks while preserving unrelated settings."""
|
|
101
|
+
root = _root(project)
|
|
102
|
+
adapters = list(ADAPTERS) if adapter == "all" else [adapter]
|
|
103
|
+
for name in adapters:
|
|
104
|
+
try:
|
|
105
|
+
path, removed = uninstall_hooks(root, name)
|
|
106
|
+
except (OSError, ValueError) as exc:
|
|
107
|
+
raise click.ClickException(f"Could not remove {name} hooks: {exc}") from exc
|
|
108
|
+
click.echo(f"Removed {removed} ConstraintLoop hook(s) from {path}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _run_phase(project: Path, phase: Phase, json_output: bool, no_cache: bool) -> None:
|
|
112
|
+
root = _root(project)
|
|
113
|
+
try:
|
|
114
|
+
contract, _ = load_contract(root)
|
|
115
|
+
except ContractError as exc:
|
|
116
|
+
raise click.ClickException(str(exc)) from exc
|
|
117
|
+
record = ConstraintEngine(
|
|
118
|
+
root,
|
|
119
|
+
contract,
|
|
120
|
+
use_cache=not no_cache and phase != Phase.CI,
|
|
121
|
+
allow_waivers=phase != Phase.CI,
|
|
122
|
+
).run(phase)
|
|
123
|
+
click.echo(
|
|
124
|
+
record.model_dump_json(indent=2)
|
|
125
|
+
if json_output
|
|
126
|
+
else format_summary(record, include_output=True)
|
|
127
|
+
)
|
|
128
|
+
if not record.passed:
|
|
129
|
+
raise click.exceptions.Exit(1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@main.command("run")
|
|
133
|
+
@click.option("--phase", type=click.Choice(["change", "stop"]), default="stop")
|
|
134
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
135
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
136
|
+
@click.option("--no-cache", is_flag=True)
|
|
137
|
+
def run_command(phase: str, project: Path, json_output: bool, no_cache: bool) -> None:
|
|
138
|
+
"""Run local gates for a lifecycle phase."""
|
|
139
|
+
_run_phase(project, Phase(phase), json_output, no_cache)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@main.command("ci")
|
|
143
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
144
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
145
|
+
def ci_command(project: Path, json_output: bool) -> None:
|
|
146
|
+
"""Run authoritative gates, ignoring local cache and waivers."""
|
|
147
|
+
_run_phase(project, Phase.CI, json_output, True)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@main.command("cycle")
|
|
151
|
+
@click.argument("loop_name")
|
|
152
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
153
|
+
@click.option("--json", "json_output", is_flag=True)
|
|
154
|
+
def cycle_command(loop_name: str, project: Path, json_output: bool) -> None:
|
|
155
|
+
"""Execute exactly one convergence-loop transition."""
|
|
156
|
+
root = _root(project)
|
|
157
|
+
try:
|
|
158
|
+
contract, _ = load_contract(root)
|
|
159
|
+
result = run_cycle(root, contract, loop_name)
|
|
160
|
+
except (ContractError, LoopError) as exc:
|
|
161
|
+
if json_output:
|
|
162
|
+
click.echo(
|
|
163
|
+
json.dumps(
|
|
164
|
+
{
|
|
165
|
+
"schema_version": 1,
|
|
166
|
+
"loop": loop_name,
|
|
167
|
+
"state": "error",
|
|
168
|
+
"message": str(exc),
|
|
169
|
+
"next_action": "Inspect the loop configuration or state.",
|
|
170
|
+
},
|
|
171
|
+
sort_keys=True,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
click.echo(f"error: {exc}", err=True)
|
|
176
|
+
raise click.exceptions.Exit(CYCLE_EXIT_CODES[LoopState.ERROR]) from exc
|
|
177
|
+
click.echo(
|
|
178
|
+
result.model_dump_json() if json_output else f"{result.state.value}: {result.next_action}"
|
|
179
|
+
)
|
|
180
|
+
raise click.exceptions.Exit(CYCLE_EXIT_CODES[result.state])
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@main.command("supervise")
|
|
184
|
+
@click.argument("loop_name")
|
|
185
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
186
|
+
def supervise_command(loop_name: str, project: Path) -> None:
|
|
187
|
+
"""Poll a loop under a single-writer lease and emit JSON Lines."""
|
|
188
|
+
root = _root(project)
|
|
189
|
+
try:
|
|
190
|
+
contract, _ = load_contract(root)
|
|
191
|
+
final = None
|
|
192
|
+
for result in supervise(root, contract, loop_name):
|
|
193
|
+
final = result
|
|
194
|
+
click.echo(result.model_dump_json())
|
|
195
|
+
except (ContractError, LoopError) as exc:
|
|
196
|
+
click.echo(
|
|
197
|
+
json.dumps(
|
|
198
|
+
{
|
|
199
|
+
"schema_version": 1,
|
|
200
|
+
"loop": loop_name,
|
|
201
|
+
"state": "error",
|
|
202
|
+
"message": str(exc),
|
|
203
|
+
"next_action": "Inspect the loop configuration or state.",
|
|
204
|
+
},
|
|
205
|
+
sort_keys=True,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
raise click.exceptions.Exit(CYCLE_EXIT_CODES[LoopState.ERROR]) from exc
|
|
209
|
+
if final is not None:
|
|
210
|
+
raise click.exceptions.Exit(CYCLE_EXIT_CODES[final.state])
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@main.command("loop-prompt")
|
|
214
|
+
@click.argument("loop_name")
|
|
215
|
+
@click.option("--adapter", type=click.Choice(["claude", "codex"]), required=True)
|
|
216
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
217
|
+
def loop_prompt_command(loop_name: str, adapter: str, project: Path) -> None:
|
|
218
|
+
"""Print a provider-neutral bounded-loop prompt for a native agent."""
|
|
219
|
+
root = _root(project)
|
|
220
|
+
try:
|
|
221
|
+
contract, _ = load_contract(root)
|
|
222
|
+
except ContractError as exc:
|
|
223
|
+
raise click.ClickException(str(exc)) from exc
|
|
224
|
+
if loop_name not in contract.loops:
|
|
225
|
+
raise click.ClickException(f"Unknown loop {loop_name!r}")
|
|
226
|
+
click.echo(loop_prompt(loop_name, adapter))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@main.command("status")
|
|
230
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
231
|
+
def status_command(project: Path) -> None:
|
|
232
|
+
"""Show fresh cached evidence without executing commands."""
|
|
233
|
+
root = _root(project)
|
|
234
|
+
try:
|
|
235
|
+
contract, _ = load_contract(root)
|
|
236
|
+
except ContractError as exc:
|
|
237
|
+
raise click.ClickException(str(exc)) from exc
|
|
238
|
+
identity = contract_digest(contract)
|
|
239
|
+
for constraint_id, spec in contract.constraints.items():
|
|
240
|
+
digest = constraint_input_digest(root, constraint_id, spec, contract_digest=identity)
|
|
241
|
+
result = (
|
|
242
|
+
load_latest_result(root, constraint_id)
|
|
243
|
+
if isinstance(spec, RubricConstraint)
|
|
244
|
+
else load_cached_result(root, constraint_id, digest)
|
|
245
|
+
)
|
|
246
|
+
if result is not None and result.input_digest != digest:
|
|
247
|
+
result = None
|
|
248
|
+
if result is None:
|
|
249
|
+
click.echo(f"STALE {constraint_id}: no evidence for current inputs")
|
|
250
|
+
else:
|
|
251
|
+
click.echo(f"{result.verdict.value.upper()} {constraint_id}: {result.message}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@main.command("doctor")
|
|
255
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
256
|
+
def doctor_command(project: Path) -> None:
|
|
257
|
+
"""Validate the contract and report its deterministic identity."""
|
|
258
|
+
root = _root(project)
|
|
259
|
+
try:
|
|
260
|
+
contract, path = load_contract(root)
|
|
261
|
+
except ContractError as exc:
|
|
262
|
+
raise click.ClickException(str(exc)) from exc
|
|
263
|
+
click.echo(f"OK {path}")
|
|
264
|
+
click.echo(f"contract digest: {contract_digest(contract)}")
|
|
265
|
+
click.echo(f"constraints: {len(contract.constraints)}; evaluators: {len(contract.evaluators)}")
|
|
266
|
+
try:
|
|
267
|
+
project_environment = load_project_environment(root)
|
|
268
|
+
except ValueError as exc:
|
|
269
|
+
raise click.ClickException(str(exc)) from exc
|
|
270
|
+
for evaluator_id, config in contract.evaluators.items():
|
|
271
|
+
api_key_env = getattr(config, "api_key_env", None)
|
|
272
|
+
if api_key_env:
|
|
273
|
+
status = (
|
|
274
|
+
"configured"
|
|
275
|
+
if os.environ.get(api_key_env) or project_environment.get(api_key_env)
|
|
276
|
+
else "missing"
|
|
277
|
+
)
|
|
278
|
+
click.echo(f"evaluator {evaluator_id}: {api_key_env} {status}")
|
|
279
|
+
click.echo(f"local secrets file: {project_environment_path(root)}")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _resolve_executable(root: Path, command: str) -> Path | None:
|
|
283
|
+
candidate = Path(command)
|
|
284
|
+
if candidate.parent != Path("."):
|
|
285
|
+
resolved = candidate if candidate.is_absolute() else root / candidate
|
|
286
|
+
return resolved.resolve() if resolved.is_file() else None
|
|
287
|
+
found = shutil.which(command)
|
|
288
|
+
return Path(found).resolve() if found else None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@main.command("debug")
|
|
292
|
+
@click.argument("constraint_id")
|
|
293
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
294
|
+
def debug_command(constraint_id: str, project: Path) -> None:
|
|
295
|
+
"""Explain cached evidence and evaluator availability without running gates."""
|
|
296
|
+
root = _root(project)
|
|
297
|
+
try:
|
|
298
|
+
contract, _ = load_contract(root)
|
|
299
|
+
except ContractError as exc:
|
|
300
|
+
raise click.ClickException(str(exc)) from exc
|
|
301
|
+
if constraint_id not in contract.constraints:
|
|
302
|
+
raise click.ClickException(f"Unknown constraint {constraint_id!r}")
|
|
303
|
+
|
|
304
|
+
spec = contract.constraints[constraint_id]
|
|
305
|
+
identity = contract_digest(contract)
|
|
306
|
+
digest = constraint_input_digest(root, constraint_id, spec, contract_digest=identity)
|
|
307
|
+
click.echo(f"constraint: {constraint_id} ({spec.kind})")
|
|
308
|
+
click.echo(f"current input digest: {digest}")
|
|
309
|
+
|
|
310
|
+
latest = load_latest_result(root, constraint_id)
|
|
311
|
+
if latest is None:
|
|
312
|
+
click.echo("evidence: MISSING")
|
|
313
|
+
else:
|
|
314
|
+
freshness = "FRESH" if latest.input_digest == digest else "STALE"
|
|
315
|
+
click.echo(f"evidence: {freshness} ({latest.verdict.value.upper()})")
|
|
316
|
+
click.echo(f"last input digest: {latest.input_digest}")
|
|
317
|
+
click.echo(f"message: {latest.message}")
|
|
318
|
+
if latest.output_tail:
|
|
319
|
+
click.echo("output tail:")
|
|
320
|
+
click.echo(latest.output_tail)
|
|
321
|
+
if latest.evaluator_calls:
|
|
322
|
+
click.echo("evaluator calls:")
|
|
323
|
+
for call in latest.evaluator_calls:
|
|
324
|
+
click.echo(
|
|
325
|
+
f"- {call.provider}/{call.model}: {call.status}; "
|
|
326
|
+
f"{call.attempts} attempt(s); {call.duration_ms:.0f}ms"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if not isinstance(spec, RubricConstraint):
|
|
330
|
+
click.echo("evaluator: none (deterministic constraint)")
|
|
331
|
+
return
|
|
332
|
+
evaluator = contract.evaluators[spec.evaluator]
|
|
333
|
+
click.echo(f"evaluator: {spec.evaluator} ({evaluator.type})")
|
|
334
|
+
if not isinstance(evaluator, CommandEvaluatorConfig):
|
|
335
|
+
api_key_env = getattr(evaluator, "api_key_env", None)
|
|
336
|
+
if api_key_env:
|
|
337
|
+
availability = "SET" if os.environ.get(api_key_env) else "MISSING"
|
|
338
|
+
click.echo(f"credential environment: {api_key_env} {availability}")
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
if isinstance(evaluator.command, str):
|
|
342
|
+
click.echo(f"command: {evaluator.command}")
|
|
343
|
+
click.echo("executable: shell command; resolution deferred to the shell")
|
|
344
|
+
elif not evaluator.command:
|
|
345
|
+
click.echo("command: EMPTY")
|
|
346
|
+
else:
|
|
347
|
+
click.echo(f"command: {shlex.join(evaluator.command)}")
|
|
348
|
+
executable_path = _resolve_executable(root, evaluator.command[0])
|
|
349
|
+
click.echo(f"executable: {executable_path if executable_path else 'NOT FOUND'}")
|
|
350
|
+
for adapter in ("codex", "claude"):
|
|
351
|
+
native_path = shutil.which(adapter)
|
|
352
|
+
click.echo(f"native CLI {adapter}: {native_path or 'NOT FOUND'}")
|
|
353
|
+
click.echo("debug mode did not execute the evaluator or consume model quota")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@main.command("acknowledge")
|
|
357
|
+
@click.argument("constraint_id")
|
|
358
|
+
@click.option("--reason", required=True)
|
|
359
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
360
|
+
def acknowledge_command(constraint_id: str, reason: str, project: Path) -> None:
|
|
361
|
+
"""Record an agent disposition for exact advisory evidence without waiving it."""
|
|
362
|
+
root = _root(project)
|
|
363
|
+
try:
|
|
364
|
+
contract, _ = load_contract(root)
|
|
365
|
+
except ContractError as exc:
|
|
366
|
+
raise click.ClickException(str(exc)) from exc
|
|
367
|
+
if constraint_id not in contract.constraints:
|
|
368
|
+
raise click.ClickException(f"Unknown constraint {constraint_id!r}")
|
|
369
|
+
spec = contract.constraints[constraint_id]
|
|
370
|
+
if spec.enforcement != Enforcement.ADVISORY:
|
|
371
|
+
raise click.ClickException("Only advisory constraints may be acknowledged")
|
|
372
|
+
digest = constraint_input_digest(
|
|
373
|
+
root,
|
|
374
|
+
constraint_id,
|
|
375
|
+
spec,
|
|
376
|
+
contract_digest=contract_digest(contract),
|
|
377
|
+
)
|
|
378
|
+
result = load_latest_result(root, constraint_id)
|
|
379
|
+
if result is None or result.input_digest != digest:
|
|
380
|
+
raise click.ClickException("No fresh evidence exists for this advisory constraint")
|
|
381
|
+
if result.verdict in {Verdict.PASS, Verdict.SKIPPED, Verdict.WAIVED}:
|
|
382
|
+
raise click.ClickException(f"Advisory result is already {result.verdict.value}")
|
|
383
|
+
explanation = reason.strip()
|
|
384
|
+
if not explanation:
|
|
385
|
+
raise click.ClickException("Acknowledgment reason must not be empty")
|
|
386
|
+
create_advisory_acknowledgment(root, result, explanation)
|
|
387
|
+
click.echo(
|
|
388
|
+
f"Acknowledged {constraint_id} for exact evidence {result.input_digest[:12]}; "
|
|
389
|
+
"the verdict remains advisory and unchanged."
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@main.command("waive")
|
|
394
|
+
@click.argument("constraint_id")
|
|
395
|
+
@click.option("--reason", required=True)
|
|
396
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
397
|
+
def waive_command(constraint_id: str, reason: str, project: Path) -> None:
|
|
398
|
+
"""Waive fresh non-passing local evidence for the exact current inputs."""
|
|
399
|
+
root = _root(project)
|
|
400
|
+
try:
|
|
401
|
+
contract, _ = load_contract(root)
|
|
402
|
+
except ContractError as exc:
|
|
403
|
+
raise click.ClickException(str(exc)) from exc
|
|
404
|
+
if constraint_id not in contract.constraints:
|
|
405
|
+
raise click.ClickException(f"Unknown constraint {constraint_id!r}")
|
|
406
|
+
spec = contract.constraints[constraint_id]
|
|
407
|
+
if isinstance(spec, RubricConstraint):
|
|
408
|
+
raise click.ClickException(
|
|
409
|
+
"Rubric constraints cannot be waived; acknowledge advisory evidence "
|
|
410
|
+
"or obtain fresh quorum"
|
|
411
|
+
)
|
|
412
|
+
explanation = reason.strip()
|
|
413
|
+
if not explanation:
|
|
414
|
+
raise click.ClickException("Waiver reason must not be empty")
|
|
415
|
+
digest = constraint_input_digest(
|
|
416
|
+
root,
|
|
417
|
+
constraint_id,
|
|
418
|
+
spec,
|
|
419
|
+
contract_digest=contract_digest(contract),
|
|
420
|
+
)
|
|
421
|
+
result = load_cached_result(root, constraint_id, digest)
|
|
422
|
+
if result is None:
|
|
423
|
+
raise click.ClickException(
|
|
424
|
+
"No fresh evidence exists; run the constraint before deciding whether to waive it"
|
|
425
|
+
)
|
|
426
|
+
if result.verdict in {Verdict.PASS, Verdict.SKIPPED, Verdict.WAIVED}:
|
|
427
|
+
raise click.ClickException(f"Constraint result is already {result.verdict.value}")
|
|
428
|
+
create_waiver(root, result, contract_digest(contract), explanation)
|
|
429
|
+
click.echo(f"Waived {constraint_id} locally for input {digest[:12]}")
|
|
430
|
+
click.echo("CI ignores local waivers.")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@main.command("hook", hidden=True)
|
|
434
|
+
@click.option("--adapter", type=click.Choice(list(ADAPTERS)), required=True)
|
|
435
|
+
@click.option(
|
|
436
|
+
"--event",
|
|
437
|
+
type=click.Choice(
|
|
438
|
+
["session-start", "user-prompt", "pre-tool", "post-tool", "pre-compact", "stop"]
|
|
439
|
+
),
|
|
440
|
+
required=True,
|
|
441
|
+
)
|
|
442
|
+
@click.option("--project", type=click.Path(path_type=Path), required=True)
|
|
443
|
+
def hook_command(adapter: str, event: str, project: Path) -> None:
|
|
444
|
+
"""Process one native agent hook payload from stdin."""
|
|
445
|
+
try:
|
|
446
|
+
raw = sys.stdin.read()
|
|
447
|
+
decoded: Any = json.loads(raw) if raw.strip() else {}
|
|
448
|
+
except json.JSONDecodeError as exc:
|
|
449
|
+
click.echo(json.dumps({"continue": False, "stopReason": f"Invalid hook JSON: {exc}"}))
|
|
450
|
+
return
|
|
451
|
+
if not isinstance(decoded, dict):
|
|
452
|
+
click.echo(
|
|
453
|
+
json.dumps(
|
|
454
|
+
{
|
|
455
|
+
"continue": False,
|
|
456
|
+
"stopReason": "Invalid hook JSON: top-level value must be an object",
|
|
457
|
+
}
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
return
|
|
461
|
+
payload: dict[str, Any] = decoded
|
|
462
|
+
response = handle_hook(_root(project), adapter, event, payload)
|
|
463
|
+
click.echo(json.dumps(response))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@main.command("enhance")
|
|
467
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
468
|
+
def enhance_command(project: Path) -> None:
|
|
469
|
+
"""Propose stronger tools and gates without installing or enabling them."""
|
|
470
|
+
root = _root(project)
|
|
471
|
+
path = write_proposal(root, "enhance", enhancement_proposal(root))
|
|
472
|
+
click.echo(f"Wrote review-only proposal {path}")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@main.command("author")
|
|
476
|
+
@click.option("--project", type=click.Path(path_type=Path), default=Path("."))
|
|
477
|
+
def author_command(project: Path) -> None:
|
|
478
|
+
"""Create a review-only test-authoring proposal."""
|
|
479
|
+
root = _root(project)
|
|
480
|
+
path = write_proposal(root, "author", authoring_proposal(root))
|
|
481
|
+
click.echo(f"Wrote review-only proposal {path}")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
if __name__ == "__main__":
|
|
485
|
+
main()
|
constraintloop/config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Contract loading and canonical hashing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from constraintloop.models import Contract
|
|
13
|
+
|
|
14
|
+
CONFIG_NAMES = ("constraintloop.yml", "constraintloop.yaml")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContractError(ValueError):
|
|
18
|
+
"""Raised when a contract is missing or invalid."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def find_contract(project_root: Path) -> Path:
|
|
22
|
+
for name in CONFIG_NAMES:
|
|
23
|
+
path = project_root / name
|
|
24
|
+
if path.is_file():
|
|
25
|
+
return path
|
|
26
|
+
raise ContractError(f"No ConstraintLoop contract found in {project_root}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def discover_project_root(start: Path) -> Path:
|
|
30
|
+
"""Walk upward from a hook cwd to the nearest contract."""
|
|
31
|
+
current = start.expanduser().resolve()
|
|
32
|
+
for candidate in (current, *current.parents):
|
|
33
|
+
if any((candidate / name).is_file() for name in CONFIG_NAMES):
|
|
34
|
+
return candidate
|
|
35
|
+
return current
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_contract(project_root: Path) -> tuple[Contract, Path]:
|
|
39
|
+
path = find_contract(project_root)
|
|
40
|
+
try:
|
|
41
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
42
|
+
return Contract.model_validate(raw), path
|
|
43
|
+
except (OSError, yaml.YAMLError, ValidationError) as exc:
|
|
44
|
+
raise ContractError(f"Invalid contract {path}: {exc}") from exc
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def contract_digest(contract: Contract) -> str:
|
|
48
|
+
payload = json.dumps(
|
|
49
|
+
contract.model_dump(mode="json"),
|
|
50
|
+
sort_keys=True,
|
|
51
|
+
separators=(",", ":"),
|
|
52
|
+
).encode()
|
|
53
|
+
return hashlib.sha256(payload).hexdigest()
|