constraintloop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """ConstraintLoop package metadata.
2
+
3
+ The v0.1 supported interfaces are the CLI and documented versioned protocols.
4
+ Python submodules are internal and may change during initial development.
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from constraintloop.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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()
@@ -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()