devrites 3.0.7 → 3.2.0
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.
- package/CHANGELOG.md +18 -0
- package/README.md +82 -50
- package/SECURITY.md +31 -29
- package/docs/adr/0001-go-engine-as-control-plane.md +1 -1
- package/docs/adr/0002-dual-host-harness.md +1 -1
- package/docs/adr/0004-state-schema-phases-sections.md +1 -2
- package/docs/adr/0006-clock-seam-and-engine-ci-gates.md +3 -4
- package/docs/adr/0009-prebuild-decision-coverage-and-readiness.md +67 -0
- package/docs/adr/0010-agent-first-fresh-context-orchestration.md +71 -0
- package/docs/adr/0011-define-plan-transition-rights.md +28 -0
- package/docs/adr/0012-semantic-workspace-upgrades.md +77 -0
- package/docs/adr/README.md +4 -0
- package/docs/agents/triage-labels.md +7 -7
- package/docs/architecture.md +179 -141
- package/docs/cli.md +47 -12
- package/docs/command-map.md +61 -38
- package/docs/engine/agent-contract.md +92 -15
- package/docs/engine/commands.md +248 -72
- package/docs/engine/state-schema.md +52 -23
- package/docs/engine/workspace-schema.md +103 -15
- package/docs/extensions.md +1 -1
- package/docs/flow.md +86 -51
- package/docs/harness-compliance.md +29 -5
- package/docs/orchestration.md +107 -79
- package/docs/quick-reference.md +11 -3
- package/docs/release.md +4 -3
- package/docs/skills.md +67 -38
- package/docs/usage.md +84 -39
- package/engine/cmd/releasepack/main.go +219 -0
- package/engine/cmd/releasepack/main_test.go +170 -0
- package/engine/commands.go +170 -23
- package/engine/git_guard.go +187 -0
- package/engine/git_guard_test.go +283 -0
- package/engine/hookpolicy.go +53 -55
- package/engine/hookpolicy_test.go +91 -1
- package/engine/hooks.go +296 -75
- package/engine/hooks_events_test.go +276 -6
- package/engine/hooks_workspace.go +640 -159
- package/engine/internal/devritespaths/paths.go +65 -10
- package/engine/internal/devritespaths/paths_test.go +110 -0
- package/engine/internal/doctor/doctor.go +153 -23
- package/engine/internal/doctor/doctor_test.go +74 -0
- package/engine/internal/forge/forge.go +940 -0
- package/engine/internal/forge/forge_test.go +576 -0
- package/engine/internal/forge/git.go +245 -0
- package/engine/internal/forge/liveness_unix.go +48 -0
- package/engine/internal/forge/liveness_windows.go +67 -0
- package/engine/internal/forge/manifest.go +402 -0
- package/engine/internal/gate/gate.go +90 -71
- package/engine/internal/gate/gate_test.go +71 -2
- package/engine/internal/harness/compliance.go +18 -24
- package/engine/internal/harness/harness.go +37 -44
- package/engine/internal/harness/harness_test.go +21 -5
- package/engine/internal/install/install.go +486 -38
- package/engine/internal/install/install_test.go +383 -10
- package/engine/internal/iohooks/iohooks.go +350 -59
- package/engine/internal/iohooks/iohooks_test.go +421 -1
- package/engine/internal/lib/buildreadiness.go +43 -18
- package/engine/internal/lib/clarifyreturn.go +91 -0
- package/engine/internal/lib/clarifyreturn_test.go +123 -0
- package/engine/internal/lib/context.go +54 -15
- package/engine/internal/lib/cursor_compat_test.go +8 -0
- package/engine/internal/lib/extensions.go +2 -3
- package/engine/internal/lib/gitauthority.go +601 -0
- package/engine/internal/lib/gitauthority_test.go +346 -0
- package/engine/internal/lib/jsonout.go +25 -11
- package/engine/internal/lib/jsonout_test.go +16 -0
- package/engine/internal/lib/lanes.go +14 -8
- package/engine/internal/lib/observability_test.go +358 -0
- package/engine/internal/lib/packageexistence.go +151 -35
- package/engine/internal/lib/packageexistence_test.go +163 -0
- package/engine/internal/lib/progress.go +11 -9
- package/engine/internal/lib/provenance.go +462 -0
- package/engine/internal/lib/provenance_test.go +154 -0
- package/engine/internal/lib/readiness_contract.json +118 -0
- package/engine/internal/lib/readinessartifact.go +533 -0
- package/engine/internal/lib/readinessartifact_test.go +422 -0
- package/engine/internal/lib/reconcile.go +712 -88
- package/engine/internal/lib/reconcile_test.go +335 -16
- package/engine/internal/lib/recoveryattempts.go +298 -0
- package/engine/internal/lib/recoveryattempts_test.go +215 -0
- package/engine/internal/lib/resolve.go +23 -19
- package/engine/internal/lib/runbook_context_test.go +20 -0
- package/engine/internal/lib/session.go +701 -9
- package/engine/internal/lib/session_test.go +80 -13
- package/engine/internal/lib/testintegrity.go +33 -37
- package/engine/internal/lib/testintegrity_test.go +63 -1
- package/engine/internal/migrate/migrate.go +81 -1
- package/engine/internal/migrate/migrate_test.go +22 -1
- package/engine/internal/reason/reason.go +180 -0
- package/engine/internal/reason/reason_test.go +35 -0
- package/engine/internal/rootfacts/facts.go +466 -0
- package/engine/internal/rootfacts/facts_test.go +306 -0
- package/engine/internal/safepath/safepath.go +55 -0
- package/engine/internal/safepath/safepath_test.go +69 -0
- package/engine/internal/safepath/safepath_windows_test.go +26 -0
- package/engine/internal/state/clarify_transition.go +165 -0
- package/engine/internal/state/clarify_transition_test.go +130 -0
- package/engine/internal/state/cmd/workflowmanifest/main.go +12 -8
- package/engine/internal/state/cursor.go +58 -0
- package/engine/internal/state/cursor_test.go +42 -1
- package/engine/internal/state/feature.go +35 -48
- package/engine/internal/state/schema.go +90 -37
- package/engine/internal/state/snapshot.go +18 -2
- package/engine/internal/state/state_test.go +187 -7
- package/engine/internal/state/status.go +48 -11
- package/engine/internal/state/workflow_manifest.json +231 -50
- package/engine/internal/toolpolicy/classifier.go +533 -0
- package/engine/internal/toolpolicy/classifier_test.go +424 -0
- package/engine/internal/toolpolicy/git.go +616 -0
- package/engine/internal/toolpolicy/scanner.go +382 -0
- package/engine/main.go +160 -77
- package/engine/observability_cli_test.go +52 -0
- package/engine/root_routing_test.go +277 -0
- package/engine/testdata/golden/TestParityBuildReadiness/arg=approved.golden +1 -1
- package/engine/testdata/golden/TestParityBuildReadiness/arg=emptystatus.golden +1 -1
- package/engine/testdata/golden/TestParityBuildReadiness/arg=legacycontract.golden +1 -0
- package/engine/testdata/golden/TestParityBuildReadiness/arg=noclarify.golden +1 -0
- package/engine/testdata/golden/TestParityBuildReadiness/arg=novet.golden +1 -0
- package/engine/testdata/golden/TestParityBuildReadiness/arg=stalevet.golden +1 -0
- package/engine/testdata/golden/TestParityBuildReadiness/arg=trailhash.golden +1 -1
- package/engine/testdata/golden/TestParityBuildReadiness/arg=trailpipe.golden +1 -1
- package/engine/testdata/golden/TestParityBuildReadiness/arg=vetnotready.golden +1 -0
- package/engine/testdata/golden/TestParityProgress/arg=allbuilt.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=done.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=mid.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=nophase.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=noslice.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=plan.golden +1 -1
- package/engine/testdata/golden/TestParityProgress/arg=seal.golden +1 -1
- package/engine/testdata/golden/TestParityReconcile/check-clean.golden +1 -1
- package/engine/testdata/golden/TestParityReconcile/inline-fallback.golden +1 -0
- package/engine/testdata/golden/TestParityReconcile/snapshot-no-allowlist.golden +1 -0
- package/engine/testdata/golden/TestParityWrightScope/devrites-edit-denied.golden +2 -0
- package/engine/testdata/golden/TestParityWrightScope/out-of-scope-enforce-denies.golden +1 -1
- package/engine/tests/adr_0011_define_plan_test.go +30 -0
- package/engine/tests/budget_test.go +19 -8
- package/engine/tests/concurrency_cli_test.go +7 -1
- package/engine/tests/doctor_cli_test.go +148 -2
- package/engine/tests/forge_cli_test.go +463 -0
- package/engine/tests/gate_test.go +63 -8
- package/engine/tests/hook_test.go +260 -8
- package/engine/tests/hooks_io_test.go +94 -3
- package/engine/tests/json_contract_test.go +127 -2
- package/engine/tests/migrate_cli_test.go +2 -2
- package/engine/tests/parity_buildreadiness_test.go +167 -10
- package/engine/tests/parity_learnings_test.go +16 -17
- package/engine/tests/parity_reconcile_test.go +20 -17
- package/engine/tests/parity_resolve_test.go +12 -11
- package/engine/tests/parity_test.go +21 -17
- package/pack/.claude/agents/devrites-code-reviewer.md +62 -48
- package/pack/.claude/agents/devrites-devex-reviewer.md +69 -53
- package/pack/.claude/agents/devrites-doubt-reviewer.md +29 -22
- package/pack/.claude/agents/devrites-evidence-scout.md +69 -0
- package/pack/.claude/agents/devrites-forge-judge.md +74 -61
- package/pack/.claude/agents/devrites-frontend-reviewer.md +48 -39
- package/pack/.claude/agents/devrites-performance-reviewer.md +49 -40
- package/pack/.claude/agents/devrites-plan-drafter.md +73 -0
- package/pack/.claude/agents/devrites-plan-reviewer.md +80 -47
- package/pack/.claude/agents/devrites-proof-runner.md +74 -0
- package/pack/.claude/agents/devrites-retrospector.md +48 -45
- package/pack/.claude/agents/devrites-security-auditor.md +46 -36
- package/pack/.claude/agents/devrites-simplifier-reviewer.md +50 -46
- package/pack/.claude/agents/devrites-slice-wright.md +153 -165
- package/pack/.claude/agents/devrites-spec-reviewer.md +34 -32
- package/pack/.claude/agents/devrites-strategy-reviewer.md +63 -34
- package/pack/.claude/agents/devrites-test-analyst.md +40 -30
- package/pack/.claude/agents/devrites-upgrade-planner.md +100 -0
- package/pack/.claude/settings.json +2 -1
- package/pack/.claude/skills/devrites-audit/SKILL.md +40 -61
- package/pack/.claude/skills/devrites-debug-recovery/SKILL.md +24 -13
- package/pack/.claude/skills/devrites-debug-recovery/reference/build-the-loop.md +24 -21
- package/pack/.claude/skills/devrites-debug-recovery/reference/cleanup-and-classify.md +34 -6
- package/pack/.claude/skills/devrites-debug-recovery/reference/instrumentation.md +2 -2
- package/pack/.claude/skills/devrites-doubt/SKILL.md +25 -17
- package/pack/.claude/skills/devrites-interview/SKILL.md +53 -52
- package/pack/.claude/skills/devrites-lib/SKILL.md +12 -9
- package/pack/.claude/skills/devrites-lib/reference/intent-map.md +4 -2
- package/pack/.claude/skills/devrites-lib/reference/parallel-dispatch.md +82 -137
- package/pack/.claude/skills/devrites-lib/reference/reply-contract.md +8 -1
- package/pack/.claude/skills/devrites-lib/reference/standards/README.md +38 -55
- package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +53 -27
- package/pack/.claude/skills/devrites-lib/reference/standards/agents.md +200 -190
- package/pack/.claude/skills/devrites-lib/reference/standards/anti-patterns.md +5 -15
- package/pack/.claude/skills/devrites-lib/reference/standards/ci-cd.md +27 -58
- package/pack/.claude/skills/devrites-lib/reference/standards/code-review.md +16 -52
- package/pack/.claude/skills/devrites-lib/reference/standards/coding-style.md +2 -10
- package/pack/.claude/skills/devrites-lib/reference/standards/context-hygiene.md +18 -61
- package/pack/.claude/skills/devrites-lib/reference/standards/core.md +23 -18
- package/pack/.claude/skills/devrites-lib/reference/standards/deprecation.md +17 -57
- package/pack/.claude/skills/devrites-lib/reference/standards/development-workflow.md +4 -33
- package/pack/.claude/skills/devrites-lib/reference/standards/git-workflow.md +4 -20
- package/pack/.claude/skills/devrites-lib/reference/standards/hooks.md +3 -14
- package/pack/.claude/skills/devrites-lib/reference/standards/patterns.md +9 -25
- package/pack/.claude/skills/devrites-lib/reference/standards/performance.md +0 -9
- package/pack/.claude/skills/devrites-lib/reference/standards/principles.md +1 -3
- package/pack/.claude/skills/devrites-lib/reference/standards/security.md +17 -7
- package/pack/.claude/skills/devrites-lib/reference/standards/testing.md +1 -27
- package/pack/.claude/skills/devrites-lib/reference/standards/tooling.md +46 -50
- package/pack/.claude/skills/devrites-lib/reference/workspace-artifact-schema.md +40 -10
- package/pack/.claude/skills/devrites-source-driven/SKILL.md +23 -22
- package/pack/.claude/skills/rite/SKILL.md +10 -6
- package/pack/.claude/skills/rite/reference/menu.md +13 -7
- package/pack/.claude/skills/rite-adopt/SKILL.md +33 -37
- package/pack/.claude/skills/rite-autocomplete/SKILL.md +33 -28
- package/pack/.claude/skills/rite-autocomplete/reference/loop.md +21 -21
- package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +15 -6
- package/pack/.claude/skills/rite-build/SKILL.md +35 -30
- package/pack/.claude/skills/rite-build/reference/afk-discipline.md +41 -39
- package/pack/.claude/skills/rite-build/reference/evidence-standard.md +10 -0
- package/pack/.claude/skills/rite-build/reference/forge.md +186 -156
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +4 -3
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +96 -175
- package/pack/.claude/skills/rite-build/reference/spec-drift-guard.md +10 -4
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +129 -134
- package/pack/.claude/skills/rite-clarify/SKILL.md +90 -0
- package/pack/.claude/skills/rite-clarify/reference/decision-coverage.md +61 -0
- package/pack/.claude/skills/rite-converge/SKILL.md +35 -25
- package/pack/.claude/skills/rite-define/SKILL.md +58 -32
- package/pack/.claude/skills/rite-define/reference/gates.md +16 -15
- package/pack/.claude/skills/rite-define/reference/plan-template.md +18 -8
- package/pack/.claude/skills/rite-frame/reference/failure-modes.md +22 -24
- package/pack/.claude/skills/rite-plan/SKILL.md +47 -16
- package/pack/.claude/skills/rite-plan/reference/task-breakdown.md +4 -0
- package/pack/.claude/skills/rite-polish/SKILL.md +27 -23
- package/pack/.claude/skills/rite-prototype/SKILL.md +25 -26
- package/pack/.claude/skills/rite-prove/SKILL.md +27 -17
- package/pack/.claude/skills/rite-resolve/SKILL.md +12 -11
- package/pack/.claude/skills/rite-resolve/reference/answer-protocol.md +3 -0
- package/pack/.claude/skills/rite-review/SKILL.md +37 -28
- package/pack/.claude/skills/rite-seal/reference/phase-contract.md +27 -92
- package/pack/.claude/skills/rite-seal/reference/risk-and-rollback.md +13 -0
- package/pack/.claude/skills/rite-ship/reference/design-memory.md +31 -36
- package/pack/.claude/skills/rite-spec/SKILL.md +84 -135
- package/pack/.claude/skills/rite-spec/reference/investigation.md +37 -33
- package/pack/.claude/skills/rite-spec/reference/question-protocol.md +6 -2
- package/pack/.claude/skills/rite-spec/reference/spec-checklists.md +25 -24
- package/pack/.claude/skills/rite-spec/reference/spec-template.md +9 -2
- package/pack/.claude/skills/rite-spec/reference/state-workspace.md +13 -3
- package/pack/.claude/skills/rite-temper/SKILL.md +62 -47
- package/pack/.claude/skills/rite-temper/reference/review-dimensions.md +24 -22
- package/pack/.claude/skills/rite-upgrade/SKILL.md +125 -0
- package/pack/.claude/skills/rite-vet/SKILL.md +114 -113
- package/pack/.claude/skills/rite-vet/reference/artifacts.md +50 -9
- package/pack/.claude/skills/rite-vet/reference/review-axes.md +39 -37
- package/pack/generated/claude/agents/devrites-code-reviewer.md +62 -48
- package/pack/generated/claude/agents/devrites-devex-reviewer.md +69 -53
- package/pack/generated/claude/agents/devrites-doubt-reviewer.md +29 -22
- package/pack/generated/claude/agents/devrites-evidence-scout.md +69 -0
- package/pack/generated/claude/agents/devrites-forge-judge.md +74 -61
- package/pack/generated/claude/agents/devrites-frontend-reviewer.md +48 -39
- package/pack/generated/claude/agents/devrites-performance-reviewer.md +49 -40
- package/pack/generated/claude/agents/devrites-plan-drafter.md +73 -0
- package/pack/generated/claude/agents/devrites-plan-reviewer.md +80 -47
- package/pack/generated/claude/agents/devrites-proof-runner.md +74 -0
- package/pack/generated/claude/agents/devrites-retrospector.md +48 -45
- package/pack/generated/claude/agents/devrites-security-auditor.md +46 -36
- package/pack/generated/claude/agents/devrites-simplifier-reviewer.md +50 -46
- package/pack/generated/claude/agents/devrites-slice-wright.md +153 -165
- package/pack/generated/claude/agents/devrites-spec-reviewer.md +34 -32
- package/pack/generated/claude/agents/devrites-strategy-reviewer.md +63 -34
- package/pack/generated/claude/agents/devrites-test-analyst.md +40 -30
- package/pack/generated/claude/agents/devrites-upgrade-planner.md +100 -0
- package/pack/generated/claude/settings.json +2 -1
- package/pack/generated/claude/skills/devrites-audit/SKILL.md +40 -61
- package/pack/generated/claude/skills/devrites-debug-recovery/SKILL.md +24 -13
- package/pack/generated/claude/skills/devrites-debug-recovery/reference/build-the-loop.md +24 -21
- package/pack/generated/claude/skills/devrites-debug-recovery/reference/cleanup-and-classify.md +34 -6
- package/pack/generated/claude/skills/devrites-debug-recovery/reference/instrumentation.md +2 -2
- package/pack/generated/claude/skills/devrites-doubt/SKILL.md +25 -17
- package/pack/generated/claude/skills/devrites-interview/SKILL.md +53 -52
- package/pack/generated/claude/skills/devrites-lib/SKILL.md +12 -9
- package/pack/generated/claude/skills/devrites-lib/reference/intent-map.md +4 -2
- package/pack/generated/claude/skills/devrites-lib/reference/parallel-dispatch.md +82 -137
- package/pack/generated/claude/skills/devrites-lib/reference/reply-contract.md +8 -1
- package/pack/generated/claude/skills/devrites-lib/reference/standards/README.md +38 -55
- package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +53 -27
- package/pack/generated/claude/skills/devrites-lib/reference/standards/agents.md +200 -190
- package/pack/generated/claude/skills/devrites-lib/reference/standards/anti-patterns.md +5 -15
- package/pack/generated/claude/skills/devrites-lib/reference/standards/ci-cd.md +27 -58
- package/pack/generated/claude/skills/devrites-lib/reference/standards/code-review.md +16 -52
- package/pack/generated/claude/skills/devrites-lib/reference/standards/coding-style.md +2 -10
- package/pack/generated/claude/skills/devrites-lib/reference/standards/context-hygiene.md +18 -61
- package/pack/generated/claude/skills/devrites-lib/reference/standards/core.md +23 -18
- package/pack/generated/claude/skills/devrites-lib/reference/standards/deprecation.md +17 -57
- package/pack/generated/claude/skills/devrites-lib/reference/standards/development-workflow.md +4 -33
- package/pack/generated/claude/skills/devrites-lib/reference/standards/git-workflow.md +4 -20
- package/pack/generated/claude/skills/devrites-lib/reference/standards/hooks.md +3 -14
- package/pack/generated/claude/skills/devrites-lib/reference/standards/patterns.md +9 -25
- package/pack/generated/claude/skills/devrites-lib/reference/standards/performance.md +0 -9
- package/pack/generated/claude/skills/devrites-lib/reference/standards/principles.md +1 -3
- package/pack/generated/claude/skills/devrites-lib/reference/standards/security.md +17 -7
- package/pack/generated/claude/skills/devrites-lib/reference/standards/testing.md +1 -27
- package/pack/generated/claude/skills/devrites-lib/reference/standards/tooling.md +46 -50
- package/pack/generated/claude/skills/devrites-lib/reference/workspace-artifact-schema.md +40 -10
- package/pack/generated/claude/skills/devrites-source-driven/SKILL.md +23 -22
- package/pack/generated/claude/skills/rite/SKILL.md +10 -6
- package/pack/generated/claude/skills/rite/reference/menu.md +13 -7
- package/pack/generated/claude/skills/rite-adopt/SKILL.md +33 -37
- package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +33 -28
- package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +21 -21
- package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +15 -6
- package/pack/generated/claude/skills/rite-build/SKILL.md +35 -30
- package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +41 -39
- package/pack/generated/claude/skills/rite-build/reference/evidence-standard.md +10 -0
- package/pack/generated/claude/skills/rite-build/reference/forge.md +186 -156
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +4 -3
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +96 -175
- package/pack/generated/claude/skills/rite-build/reference/spec-drift-guard.md +10 -4
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +129 -134
- package/pack/generated/claude/skills/rite-clarify/SKILL.md +90 -0
- package/pack/generated/claude/skills/rite-clarify/reference/decision-coverage.md +61 -0
- package/pack/generated/claude/skills/rite-converge/SKILL.md +35 -25
- package/pack/generated/claude/skills/rite-define/SKILL.md +58 -32
- package/pack/generated/claude/skills/rite-define/reference/gates.md +16 -15
- package/pack/generated/claude/skills/rite-define/reference/plan-template.md +18 -8
- package/pack/generated/claude/skills/rite-frame/reference/failure-modes.md +22 -24
- package/pack/generated/claude/skills/rite-plan/SKILL.md +47 -16
- package/pack/generated/claude/skills/rite-plan/reference/task-breakdown.md +4 -0
- package/pack/generated/claude/skills/rite-polish/SKILL.md +27 -23
- package/pack/generated/claude/skills/rite-prototype/SKILL.md +25 -26
- package/pack/generated/claude/skills/rite-prove/SKILL.md +27 -17
- package/pack/generated/claude/skills/rite-resolve/SKILL.md +12 -11
- package/pack/generated/claude/skills/rite-resolve/reference/answer-protocol.md +3 -0
- package/pack/generated/claude/skills/rite-review/SKILL.md +37 -28
- package/pack/generated/claude/skills/rite-seal/reference/phase-contract.md +27 -92
- package/pack/generated/claude/skills/rite-seal/reference/risk-and-rollback.md +13 -0
- package/pack/generated/claude/skills/rite-ship/reference/design-memory.md +31 -36
- package/pack/generated/claude/skills/rite-spec/SKILL.md +84 -135
- package/pack/generated/claude/skills/rite-spec/reference/investigation.md +37 -33
- package/pack/generated/claude/skills/rite-spec/reference/question-protocol.md +6 -2
- package/pack/generated/claude/skills/rite-spec/reference/spec-checklists.md +25 -24
- package/pack/generated/claude/skills/rite-spec/reference/spec-template.md +9 -2
- package/pack/generated/claude/skills/rite-spec/reference/state-workspace.md +13 -3
- package/pack/generated/claude/skills/rite-temper/SKILL.md +62 -47
- package/pack/generated/claude/skills/rite-temper/reference/review-dimensions.md +24 -22
- package/pack/generated/claude/skills/rite-upgrade/SKILL.md +125 -0
- package/pack/generated/claude/skills/rite-vet/SKILL.md +114 -113
- package/pack/generated/claude/skills/rite-vet/reference/artifacts.md +50 -9
- package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +39 -37
- package/pack/generated/codex/AGENTS.md +5 -2
- package/pack/generated/codex/agents/devrites-code-reviewer.toml +69 -47
- package/pack/generated/codex/agents/devrites-devex-reviewer.toml +75 -51
- package/pack/generated/codex/agents/devrites-doubt-reviewer.toml +35 -20
- package/pack/generated/codex/agents/devrites-evidence-scout.toml +75 -0
- package/pack/generated/codex/agents/devrites-forge-judge.toml +80 -59
- package/pack/generated/codex/agents/devrites-frontend-reviewer.toml +54 -37
- package/pack/generated/codex/agents/devrites-performance-reviewer.toml +55 -38
- package/pack/generated/codex/agents/devrites-plan-drafter.toml +79 -0
- package/pack/generated/codex/agents/devrites-plan-reviewer.toml +82 -47
- package/pack/generated/codex/agents/devrites-proof-runner.toml +80 -0
- package/pack/generated/codex/agents/devrites-retrospector.toml +54 -43
- package/pack/generated/codex/agents/devrites-security-auditor.toml +53 -35
- package/pack/generated/codex/agents/devrites-simplifier-reviewer.toml +56 -44
- package/pack/generated/codex/agents/devrites-slice-wright.toml +159 -163
- package/pack/generated/codex/agents/devrites-spec-reviewer.toml +40 -30
- package/pack/generated/codex/agents/devrites-strategy-reviewer.toml +65 -34
- package/pack/generated/codex/agents/devrites-test-analyst.toml +46 -28
- package/pack/generated/codex/agents/devrites-upgrade-planner.toml +106 -0
- package/pack/generated/codex/hooks.json +4 -14
- package/pack/generated/codex/skills/devrites-api-interface/SKILL.md +7 -3
- package/pack/generated/codex/skills/devrites-audit/SKILL.md +47 -64
- package/pack/generated/codex/skills/devrites-browser-proof/SKILL.md +7 -3
- package/pack/generated/codex/skills/devrites-debug-recovery/SKILL.md +31 -16
- package/pack/generated/codex/skills/devrites-debug-recovery/reference/build-the-loop.md +24 -21
- package/pack/generated/codex/skills/devrites-debug-recovery/reference/cleanup-and-classify.md +34 -6
- package/pack/generated/codex/skills/devrites-debug-recovery/reference/instrumentation.md +2 -2
- package/pack/generated/codex/skills/devrites-doubt/SKILL.md +32 -20
- package/pack/generated/codex/skills/devrites-frontend-craft/SKILL.md +7 -3
- package/pack/generated/codex/skills/devrites-interview/SKILL.md +60 -55
- package/pack/generated/codex/skills/devrites-lib/SKILL.md +19 -12
- package/pack/generated/codex/skills/devrites-lib/reference/intent-map.md +4 -2
- package/pack/generated/codex/skills/devrites-lib/reference/parallel-dispatch.md +82 -137
- package/pack/generated/codex/skills/devrites-lib/reference/reply-contract.md +8 -1
- package/pack/generated/codex/skills/devrites-lib/reference/standards/README.md +38 -55
- package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +53 -27
- package/pack/generated/codex/skills/devrites-lib/reference/standards/agents.md +200 -190
- package/pack/generated/codex/skills/devrites-lib/reference/standards/anti-patterns.md +5 -15
- package/pack/generated/codex/skills/devrites-lib/reference/standards/ci-cd.md +27 -58
- package/pack/generated/codex/skills/devrites-lib/reference/standards/code-review.md +16 -52
- package/pack/generated/codex/skills/devrites-lib/reference/standards/coding-style.md +2 -10
- package/pack/generated/codex/skills/devrites-lib/reference/standards/context-hygiene.md +18 -61
- package/pack/generated/codex/skills/devrites-lib/reference/standards/core.md +23 -18
- package/pack/generated/codex/skills/devrites-lib/reference/standards/deprecation.md +17 -57
- package/pack/generated/codex/skills/devrites-lib/reference/standards/development-workflow.md +4 -33
- package/pack/generated/codex/skills/devrites-lib/reference/standards/git-workflow.md +4 -20
- package/pack/generated/codex/skills/devrites-lib/reference/standards/hooks.md +3 -14
- package/pack/generated/codex/skills/devrites-lib/reference/standards/patterns.md +9 -25
- package/pack/generated/codex/skills/devrites-lib/reference/standards/performance.md +0 -9
- package/pack/generated/codex/skills/devrites-lib/reference/standards/principles.md +1 -3
- package/pack/generated/codex/skills/devrites-lib/reference/standards/security.md +17 -7
- package/pack/generated/codex/skills/devrites-lib/reference/standards/testing.md +1 -27
- package/pack/generated/codex/skills/devrites-lib/reference/standards/tooling.md +46 -50
- package/pack/generated/codex/skills/devrites-lib/reference/workspace-artifact-schema.md +40 -10
- package/pack/generated/codex/skills/devrites-prose-craft/SKILL.md +7 -3
- package/pack/generated/codex/skills/devrites-refresh-indexes/SKILL.md +7 -3
- package/pack/generated/codex/skills/devrites-source-driven/SKILL.md +29 -24
- package/pack/generated/codex/skills/devrites-ux-shape/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite/SKILL.md +21 -13
- package/pack/generated/codex/skills/rite/reference/menu.md +13 -7
- package/pack/generated/codex/skills/rite-adopt/SKILL.md +40 -40
- package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +40 -31
- package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +21 -21
- package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +15 -6
- package/pack/generated/codex/skills/rite-build/SKILL.md +42 -33
- package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +41 -39
- package/pack/generated/codex/skills/rite-build/reference/evidence-standard.md +10 -0
- package/pack/generated/codex/skills/rite-build/reference/forge.md +186 -156
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +4 -3
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +96 -175
- package/pack/generated/codex/skills/rite-build/reference/spec-drift-guard.md +10 -4
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +129 -134
- package/pack/generated/codex/skills/rite-clarify/SKILL.md +106 -0
- package/pack/generated/codex/skills/rite-clarify/reference/decision-coverage.md +61 -0
- package/pack/generated/codex/skills/rite-converge/SKILL.md +42 -28
- package/pack/generated/codex/skills/rite-customize/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-define/SKILL.md +65 -35
- package/pack/generated/codex/skills/rite-define/reference/gates.md +16 -15
- package/pack/generated/codex/skills/rite-define/reference/plan-template.md +18 -8
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-dogfood/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-explain/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-frame/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-frame/reference/failure-modes.md +22 -24
- package/pack/generated/codex/skills/rite-handoff/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-learn/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-plan/SKILL.md +54 -19
- package/pack/generated/codex/skills/rite-plan/reference/task-breakdown.md +5 -1
- package/pack/generated/codex/skills/rite-polish/SKILL.md +34 -26
- package/pack/generated/codex/skills/rite-pov/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-pr-feedback/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-pressure-test/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-prototype/SKILL.md +32 -29
- package/pack/generated/codex/skills/rite-prove/SKILL.md +34 -20
- package/pack/generated/codex/skills/rite-quick/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-resolve/SKILL.md +19 -14
- package/pack/generated/codex/skills/rite-resolve/reference/answer-protocol.md +3 -0
- package/pack/generated/codex/skills/rite-review/SKILL.md +44 -31
- package/pack/generated/codex/skills/rite-seal/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-seal/reference/phase-contract.md +27 -92
- package/pack/generated/codex/skills/rite-seal/reference/risk-and-rollback.md +13 -0
- package/pack/generated/codex/skills/rite-ship/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-ship/reference/design-memory.md +31 -36
- package/pack/generated/codex/skills/rite-spec/SKILL.md +91 -138
- package/pack/generated/codex/skills/rite-spec/reference/investigation.md +37 -33
- package/pack/generated/codex/skills/rite-spec/reference/question-protocol.md +6 -2
- package/pack/generated/codex/skills/rite-spec/reference/spec-checklists.md +25 -24
- package/pack/generated/codex/skills/rite-spec/reference/spec-template.md +9 -2
- package/pack/generated/codex/skills/rite-spec/reference/state-workspace.md +13 -3
- package/pack/generated/codex/skills/rite-status/SKILL.md +7 -3
- package/pack/generated/codex/skills/rite-temper/SKILL.md +69 -50
- package/pack/generated/codex/skills/rite-temper/reference/review-dimensions.md +24 -22
- package/pack/generated/codex/skills/rite-upgrade/SKILL.md +141 -0
- package/pack/generated/codex/skills/rite-upgrade/agents/openai.yaml +2 -0
- package/pack/generated/codex/skills/rite-vet/SKILL.md +121 -116
- package/pack/generated/codex/skills/rite-vet/reference/artifacts.md +50 -9
- package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +40 -38
- package/pack/generated/codex/skills/rite-zoom-out/SKILL.md +7 -3
- package/package.json +1 -1
- package/scripts/build-release-tarball.sh +32 -15
- package/scripts/check-authority-drift.py +125 -0
- package/scripts/check-instruction-size-baseline.mjs +19 -11
- package/scripts/check-invocation-integrity.py +2 -0
- package/scripts/codex-generate.sh +69 -33
- package/scripts/grade-feature.sh +121 -40
- package/scripts/live-hosts/agent-result.schema.json +230 -0
- package/scripts/live-hosts/claude.sh +87 -0
- package/scripts/live-hosts/codex.sh +81 -0
- package/scripts/live-hosts/common.sh +113 -0
- package/scripts/live-hosts/fake-host.py +264 -0
- package/scripts/live-hosts/host-transport.py +287 -0
- package/scripts/release-check.sh +5 -1
- package/scripts/run-agent-contract-evals.py +1380 -0
- package/scripts/run-behavioral-evals.sh +24 -30
- package/scripts/run-evals.sh +1 -5
- package/scripts/run-live-behavioral-evals.py +1274 -144
- package/scripts/run-outcome-evals.sh +445 -88
- package/scripts/run-tests.mjs +30 -2
- package/scripts/skills-inventory.mjs +1 -1
- package/scripts/validate-workflow-security.py +39 -20
- package/scripts/validate-workspace-schema.py +362 -10
- package/scripts/validate.sh +21 -15
- package/engine/testdata/golden/TestParityWrightScope/devrites-edit-allowed.golden +0 -1
- /package/engine/testdata/golden/{TestParityReconcile/check-no-claimed.golden → TestParityBuildReadiness/arg=clarifyopen.golden} +0 -0
|
@@ -0,0 +1,1380 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run the hermetic Claude/Codex agent-contract matrix.
|
|
3
|
+
|
|
4
|
+
Fake mode is deterministic and network-free. Live mode is manual, paid, and
|
|
5
|
+
fails closed unless host pins, distinct scoped credentials, artifact input,
|
|
6
|
+
and both cost ceilings pass preflight.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import signal
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from decimal import Decimal, InvalidOperation
|
|
26
|
+
from pathlib import Path, PurePosixPath
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
31
|
+
DEFAULT_MATRIX = ROOT / "evals" / "agent-contract" / "contract-matrix.json"
|
|
32
|
+
FAKE_HOST = ROOT / "scripts" / "live-hosts" / "fake-host.py"
|
|
33
|
+
LIVE_CONFIRMATION = "RUN-PAID-HOST-CONTRACTS"
|
|
34
|
+
SUMMARY_SCHEMA = "devrites-agent-contract-summary/v1"
|
|
35
|
+
PREFLIGHT_SCHEMA = "devrites-agent-contract-preflight/v1"
|
|
36
|
+
BUDGET_SCHEMA = "devrites-agent-contract-budget/v1"
|
|
37
|
+
LIVE_RUNTIME_COST_CAPS = {"claude": "cli-max-budget-usd"}
|
|
38
|
+
RUN_ID_RE = re.compile(r"^drv-run-v1:[0-9a-f]{32}$")
|
|
39
|
+
REASON_RE = re.compile(r'\bID\s*=\s*"([A-Z0-9-]+)"')
|
|
40
|
+
SAFE_TOKEN_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
|
|
41
|
+
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
42
|
+
SENSITIVE_ASSIGNMENT_RE = re.compile(
|
|
43
|
+
r"(?i)(?:api[_-]?key|authorization|bearer|credential|password|secret|token)\s*[:=]\s*\S+"
|
|
44
|
+
)
|
|
45
|
+
SECRET_TOKEN_RE = re.compile(
|
|
46
|
+
r"(?i)\b(?:sk|rk|ghp|github_pat|xox[baprs])[-_][a-z0-9_-]{8,}\b"
|
|
47
|
+
)
|
|
48
|
+
TEXT_PAYLOAD_RE = re.compile(r"(?i)(?:^|[\s,{])(prompt|raw|source|trace)\s*[:=]")
|
|
49
|
+
MANDATORY_SIGNALS = {
|
|
50
|
+
"auth_preflight",
|
|
51
|
+
"budget_bound",
|
|
52
|
+
"cleanup_ready",
|
|
53
|
+
"environment_allowlisted",
|
|
54
|
+
"mcp_absent",
|
|
55
|
+
"plugins_absent",
|
|
56
|
+
"roots_isolated",
|
|
57
|
+
"shell_startup_ignored",
|
|
58
|
+
"version_pinned",
|
|
59
|
+
}
|
|
60
|
+
RESULT_KEYS = {
|
|
61
|
+
"result_version",
|
|
62
|
+
"run_id",
|
|
63
|
+
"cell_id",
|
|
64
|
+
"host",
|
|
65
|
+
"role",
|
|
66
|
+
"status",
|
|
67
|
+
"baseline_id",
|
|
68
|
+
"scope_id",
|
|
69
|
+
"budget",
|
|
70
|
+
"side_effects",
|
|
71
|
+
"payload_type",
|
|
72
|
+
"execution",
|
|
73
|
+
"signals",
|
|
74
|
+
"usage",
|
|
75
|
+
"dispatch_reason_id",
|
|
76
|
+
"result_reason_id",
|
|
77
|
+
"outcome",
|
|
78
|
+
"terminal_sentinel",
|
|
79
|
+
"host_version",
|
|
80
|
+
"model",
|
|
81
|
+
}
|
|
82
|
+
FORBIDDEN_RESULT_KEYS = {
|
|
83
|
+
"absolute_path",
|
|
84
|
+
"auth",
|
|
85
|
+
"config",
|
|
86
|
+
"home",
|
|
87
|
+
"prompt",
|
|
88
|
+
"raw",
|
|
89
|
+
"scratch_root",
|
|
90
|
+
"source",
|
|
91
|
+
"trace",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ContractError(Exception):
|
|
96
|
+
def __init__(self, message: str, reason_id: str = "DRV-AGENT-RESULT-MALFORMED"):
|
|
97
|
+
super().__init__(message)
|
|
98
|
+
self.reason_id = reason_id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def canonical_json(value: Any) -> bytes:
|
|
102
|
+
return json.dumps(
|
|
103
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
|
104
|
+
).encode()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def sha256_bytes(value: bytes) -> str:
|
|
108
|
+
return hashlib.sha256(value).hexdigest()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_json(path: Path) -> Any:
|
|
112
|
+
try:
|
|
113
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
114
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
115
|
+
raise ContractError("JSON-compatible YAML is required") from exc
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def exact_keys(value: dict[str, Any], required: set[str], where: str) -> None:
|
|
119
|
+
keys = set(value)
|
|
120
|
+
if keys != required:
|
|
121
|
+
raise ContractError(f"{where} keys do not match the frozen schema")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def safe_relative(value: Any, where: str) -> str:
|
|
125
|
+
if not isinstance(value, str) or not value:
|
|
126
|
+
raise ContractError(f"{where} must be a non-empty relative path")
|
|
127
|
+
path = PurePosixPath(value)
|
|
128
|
+
if path.is_absolute() or ".." in path.parts or str(path) != value or "\\" in value:
|
|
129
|
+
raise ContractError(f"{where} is not a confined portable path")
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def safe_id(value: Any, where: str) -> str:
|
|
134
|
+
if (
|
|
135
|
+
not isinstance(value, str)
|
|
136
|
+
or not 1 <= len(value) <= 128
|
|
137
|
+
or value.startswith("/")
|
|
138
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
|
139
|
+
):
|
|
140
|
+
raise ContractError(
|
|
141
|
+
f"{where} is not a safe identifier", "DRV-AGENT-UNAVAILABLE"
|
|
142
|
+
)
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def positive_int(value: Any, where: str) -> int:
|
|
147
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
148
|
+
raise ContractError(f"{where} must be a positive integer")
|
|
149
|
+
return value
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def money(value: Any, where: str, *, allow_zero: bool = False) -> Decimal:
|
|
153
|
+
try:
|
|
154
|
+
parsed = Decimal(str(value))
|
|
155
|
+
except (InvalidOperation, ValueError) as exc:
|
|
156
|
+
raise ContractError(f"{where} must be a decimal amount") from exc
|
|
157
|
+
if not parsed.is_finite() or parsed < 0 or (not allow_zero and parsed == 0):
|
|
158
|
+
raise ContractError(
|
|
159
|
+
f"{where} must be {'non-negative' if allow_zero else 'positive'}"
|
|
160
|
+
)
|
|
161
|
+
return parsed
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def private_string_fault(value: str) -> str | None:
|
|
165
|
+
if (
|
|
166
|
+
value.startswith(("/", "~", "file://"))
|
|
167
|
+
or re.match(r"^[A-Za-z]:[\\/]", value)
|
|
168
|
+
or "\x00" in value
|
|
169
|
+
):
|
|
170
|
+
return "path"
|
|
171
|
+
if len(value) > 256 or any(character in value for character in ("\n", "\r", "```")):
|
|
172
|
+
return "text payload"
|
|
173
|
+
if (
|
|
174
|
+
SENSITIVE_ASSIGNMENT_RE.search(value)
|
|
175
|
+
or SECRET_TOKEN_RE.search(value)
|
|
176
|
+
or TEXT_PAYLOAD_RE.search(value)
|
|
177
|
+
):
|
|
178
|
+
return "sensitive payload"
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def frozen_reasons() -> set[str]:
|
|
183
|
+
path = ROOT / "engine" / "internal" / "reason" / "reason.go"
|
|
184
|
+
try:
|
|
185
|
+
reasons = set(REASON_RE.findall(path.read_text(encoding="utf-8")))
|
|
186
|
+
except OSError as exc:
|
|
187
|
+
raise ContractError("frozen reason catalog is unavailable") from exc
|
|
188
|
+
if not reasons:
|
|
189
|
+
raise ContractError("frozen reason catalog is empty")
|
|
190
|
+
return reasons
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def validate_matrix(data: Any) -> dict[str, Any]:
|
|
194
|
+
if not isinstance(data, dict):
|
|
195
|
+
raise ContractError("matrix must be an object")
|
|
196
|
+
exact_keys(
|
|
197
|
+
data,
|
|
198
|
+
{"schema", "packet_schema", "result_schema", "hosts", "budget", "scenarios"},
|
|
199
|
+
"matrix",
|
|
200
|
+
)
|
|
201
|
+
if data["schema"] != "devrites-agent-contract-matrix/v1":
|
|
202
|
+
raise ContractError("matrix schema is unsupported")
|
|
203
|
+
if (
|
|
204
|
+
data["packet_schema"] != "agent-packet/v1"
|
|
205
|
+
or data["result_schema"] != "agent-result/v1"
|
|
206
|
+
):
|
|
207
|
+
raise ContractError("transport schema is unsupported")
|
|
208
|
+
|
|
209
|
+
reasons = frozen_reasons()
|
|
210
|
+
hosts = data["hosts"]
|
|
211
|
+
if not isinstance(hosts, list) or len(hosts) != 2:
|
|
212
|
+
raise ContractError("matrix must define exactly two hosts")
|
|
213
|
+
host_ids: set[str] = set()
|
|
214
|
+
for host in hosts:
|
|
215
|
+
if not isinstance(host, dict):
|
|
216
|
+
raise ContractError("host row must be an object")
|
|
217
|
+
exact_keys(host, {"id", "launcher", "fake_version"}, "host")
|
|
218
|
+
host_id = host["id"]
|
|
219
|
+
if host_id not in {"claude", "codex"} or host_id in host_ids:
|
|
220
|
+
raise ContractError("host IDs must be unique claude and codex")
|
|
221
|
+
host_ids.add(host_id)
|
|
222
|
+
safe_relative(host["launcher"], "host.launcher")
|
|
223
|
+
if not isinstance(host["fake_version"], str) or not host["fake_version"]:
|
|
224
|
+
raise ContractError("host.fake_version must be non-empty")
|
|
225
|
+
launcher = (ROOT / host["launcher"]).resolve()
|
|
226
|
+
if ROOT.resolve() not in launcher.parents:
|
|
227
|
+
raise ContractError("host launcher escapes the repository")
|
|
228
|
+
|
|
229
|
+
budget = data["budget"]
|
|
230
|
+
if not isinstance(budget, dict):
|
|
231
|
+
raise ContractError("budget must be an object")
|
|
232
|
+
exact_keys(
|
|
233
|
+
budget,
|
|
234
|
+
{
|
|
235
|
+
"timeout_seconds",
|
|
236
|
+
"estimated_max_cost_usd_per_cell",
|
|
237
|
+
"max_files",
|
|
238
|
+
"max_loaded_lines",
|
|
239
|
+
"max_result_lines",
|
|
240
|
+
},
|
|
241
|
+
"budget",
|
|
242
|
+
)
|
|
243
|
+
for key in ("timeout_seconds", "max_files", "max_loaded_lines", "max_result_lines"):
|
|
244
|
+
positive_int(budget[key], f"budget.{key}")
|
|
245
|
+
money(
|
|
246
|
+
budget["estimated_max_cost_usd_per_cell"],
|
|
247
|
+
"budget.estimated_max_cost_usd_per_cell",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
scenarios = data["scenarios"]
|
|
251
|
+
if not isinstance(scenarios, list) or not scenarios:
|
|
252
|
+
raise ContractError("matrix scenarios must be non-empty")
|
|
253
|
+
scenario_ids: set[str] = set()
|
|
254
|
+
for scenario in scenarios:
|
|
255
|
+
if not isinstance(scenario, dict):
|
|
256
|
+
raise ContractError("scenario row must be an object")
|
|
257
|
+
exact_keys(
|
|
258
|
+
scenario,
|
|
259
|
+
{"id", "role", "role_class", "simulate", "allowed_repo_writes", "expect"},
|
|
260
|
+
"scenario",
|
|
261
|
+
)
|
|
262
|
+
scenario_id = scenario["id"]
|
|
263
|
+
if (
|
|
264
|
+
not isinstance(scenario_id, str)
|
|
265
|
+
or not SAFE_TOKEN_RE.fullmatch(scenario_id)
|
|
266
|
+
or scenario_id in scenario_ids
|
|
267
|
+
):
|
|
268
|
+
raise ContractError("scenario IDs must be unique safe tokens")
|
|
269
|
+
scenario_ids.add(scenario_id)
|
|
270
|
+
if not isinstance(scenario["role"], str) or not scenario["role"]:
|
|
271
|
+
raise ContractError("scenario.role must be non-empty")
|
|
272
|
+
if scenario["role_class"] not in {"reviewer", "wright"}:
|
|
273
|
+
raise ContractError("scenario.role_class is unsupported")
|
|
274
|
+
if scenario["simulate"] not in {
|
|
275
|
+
"accepted",
|
|
276
|
+
"generic-fallback",
|
|
277
|
+
"inline-fallback",
|
|
278
|
+
"interrupted",
|
|
279
|
+
"malformed",
|
|
280
|
+
"missing",
|
|
281
|
+
"post-compaction",
|
|
282
|
+
"reviewer-denied",
|
|
283
|
+
"session-start",
|
|
284
|
+
"stale",
|
|
285
|
+
"wright-denied",
|
|
286
|
+
"write-allowed",
|
|
287
|
+
}:
|
|
288
|
+
raise ContractError("scenario.simulate is unsupported")
|
|
289
|
+
writes = scenario["allowed_repo_writes"]
|
|
290
|
+
if not isinstance(writes, list) or len(writes) != len(set(writes)):
|
|
291
|
+
raise ContractError("allowed writes must be a unique list")
|
|
292
|
+
for index, path in enumerate(writes):
|
|
293
|
+
safe_relative(path, f"allowed_repo_writes[{index}]")
|
|
294
|
+
if scenario["role_class"] == "reviewer" and writes:
|
|
295
|
+
raise ContractError("reviewers cannot receive repo writes")
|
|
296
|
+
|
|
297
|
+
expect = scenario["expect"]
|
|
298
|
+
if not isinstance(expect, dict):
|
|
299
|
+
raise ContractError("scenario.expect must be an object")
|
|
300
|
+
exact_keys(
|
|
301
|
+
expect,
|
|
302
|
+
{
|
|
303
|
+
"execution_mode",
|
|
304
|
+
"guard_strength",
|
|
305
|
+
"independence",
|
|
306
|
+
"dispatch_reason_id",
|
|
307
|
+
"result_reason_id",
|
|
308
|
+
"status",
|
|
309
|
+
"outcome",
|
|
310
|
+
"terminal_sentinel",
|
|
311
|
+
"host_exit",
|
|
312
|
+
"required_signals",
|
|
313
|
+
},
|
|
314
|
+
"scenario.expect",
|
|
315
|
+
)
|
|
316
|
+
if expect["execution_mode"] not in {"named", "generic", "inline"}:
|
|
317
|
+
raise ContractError("execution mode is unsupported")
|
|
318
|
+
if expect["guard_strength"] not in {
|
|
319
|
+
"enforced",
|
|
320
|
+
"observed",
|
|
321
|
+
"unavailable",
|
|
322
|
+
"bypassed",
|
|
323
|
+
}:
|
|
324
|
+
raise ContractError("guard strength is unsupported")
|
|
325
|
+
if not isinstance(expect["independence"], bool):
|
|
326
|
+
raise ContractError("independence must be boolean")
|
|
327
|
+
for key in ("dispatch_reason_id", "result_reason_id"):
|
|
328
|
+
if expect[key] not in reasons:
|
|
329
|
+
raise ContractError(f"{key} is not in the frozen reason catalog")
|
|
330
|
+
if expect["status"] not in {"complete", "partial", "blocked", "failed"}:
|
|
331
|
+
raise ContractError("expected status is unsupported")
|
|
332
|
+
if expect["outcome"] not in {"passed", "denied", "unavailable"}:
|
|
333
|
+
raise ContractError("expected outcome is unsupported")
|
|
334
|
+
if expect["terminal_sentinel"] not in {
|
|
335
|
+
"complete",
|
|
336
|
+
"interrupted",
|
|
337
|
+
"malformed",
|
|
338
|
+
"missing",
|
|
339
|
+
"stale",
|
|
340
|
+
}:
|
|
341
|
+
raise ContractError("terminal sentinel is unsupported")
|
|
342
|
+
if isinstance(expect["host_exit"], bool) or expect["host_exit"] not in {0, 130}:
|
|
343
|
+
raise ContractError("expected host exit is unsupported")
|
|
344
|
+
required_signals = expect["required_signals"]
|
|
345
|
+
if (
|
|
346
|
+
not isinstance(required_signals, list)
|
|
347
|
+
or len(required_signals) != len(set(required_signals))
|
|
348
|
+
or not all(
|
|
349
|
+
isinstance(item, str)
|
|
350
|
+
and SAFE_TOKEN_RE.fullmatch(item.replace("_", "-"))
|
|
351
|
+
for item in required_signals
|
|
352
|
+
)
|
|
353
|
+
):
|
|
354
|
+
raise ContractError("required signals must be unique safe tokens")
|
|
355
|
+
return data
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def tree_snapshot(root: Path) -> dict[str, dict[str, Any]]:
|
|
359
|
+
out: dict[str, dict[str, Any]] = {}
|
|
360
|
+
if not root.exists():
|
|
361
|
+
return out
|
|
362
|
+
for path in sorted(root.rglob("*")):
|
|
363
|
+
rel = path.relative_to(root).as_posix()
|
|
364
|
+
mode = stat.S_IMODE(path.lstat().st_mode)
|
|
365
|
+
if path.is_symlink():
|
|
366
|
+
out[rel] = {
|
|
367
|
+
"kind": "symlink",
|
|
368
|
+
"mode": mode,
|
|
369
|
+
"sha256": sha256_bytes(os.readlink(path).encode()),
|
|
370
|
+
}
|
|
371
|
+
elif path.is_file():
|
|
372
|
+
out[rel] = {
|
|
373
|
+
"kind": "file",
|
|
374
|
+
"mode": mode,
|
|
375
|
+
"sha256": sha256_bytes(path.read_bytes()),
|
|
376
|
+
}
|
|
377
|
+
return out
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def tree_delta(
|
|
381
|
+
before: dict[str, dict[str, Any]], after: dict[str, dict[str, Any]]
|
|
382
|
+
) -> list[dict[str, Any]]:
|
|
383
|
+
delta: list[dict[str, Any]] = []
|
|
384
|
+
for path in sorted(set(before) | set(after)):
|
|
385
|
+
if before.get(path) == after.get(path):
|
|
386
|
+
continue
|
|
387
|
+
if path not in after:
|
|
388
|
+
delta.append(
|
|
389
|
+
{"path": path, "kind": "deleted", "sha256": sha256_bytes(b"deleted")}
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
delta.append(
|
|
393
|
+
{
|
|
394
|
+
"path": path,
|
|
395
|
+
"kind": after[path]["kind"],
|
|
396
|
+
"sha256": after[path]["sha256"],
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
return delta
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def parse_assignments(values: list[str], hosts: set[str], label: str) -> dict[str, str]:
|
|
403
|
+
out: dict[str, str] = {}
|
|
404
|
+
for value in values:
|
|
405
|
+
if "=" not in value:
|
|
406
|
+
raise ContractError(f"{label} must use HOST=VALUE", "DRV-AGENT-UNAVAILABLE")
|
|
407
|
+
host, item = value.split("=", 1)
|
|
408
|
+
if host not in hosts or not item or host in out:
|
|
409
|
+
raise ContractError(
|
|
410
|
+
f"{label} has an invalid or duplicate host", "DRV-AGENT-UNAVAILABLE"
|
|
411
|
+
)
|
|
412
|
+
out[host] = item
|
|
413
|
+
return out
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def secure_auth_file(path_value: str) -> Path:
|
|
417
|
+
path = Path(path_value)
|
|
418
|
+
try:
|
|
419
|
+
info = path.lstat()
|
|
420
|
+
except OSError as exc:
|
|
421
|
+
raise ContractError(
|
|
422
|
+
"scoped auth file is unavailable", "DRV-AGENT-UNAVAILABLE"
|
|
423
|
+
) from exc
|
|
424
|
+
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
|
425
|
+
raise ContractError(
|
|
426
|
+
"scoped auth file must be a regular non-symlink", "DRV-AGENT-UNAVAILABLE"
|
|
427
|
+
)
|
|
428
|
+
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600:
|
|
429
|
+
raise ContractError(
|
|
430
|
+
"scoped auth file must be owner-only mode 0600", "DRV-AGENT-UNAVAILABLE"
|
|
431
|
+
)
|
|
432
|
+
if info.st_size <= 0 or info.st_size > 64 * 1024:
|
|
433
|
+
raise ContractError("scoped auth file size is invalid", "DRV-AGENT-UNAVAILABLE")
|
|
434
|
+
return path.resolve()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def launcher_version(
|
|
438
|
+
host: dict[str, Any], fake: bool, fake_fault: str | None = None
|
|
439
|
+
) -> str:
|
|
440
|
+
launcher = ROOT / host["launcher"]
|
|
441
|
+
env = {
|
|
442
|
+
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
443
|
+
"LANG": "C",
|
|
444
|
+
"LC_ALL": "C",
|
|
445
|
+
}
|
|
446
|
+
if fake:
|
|
447
|
+
env["DEVRITES_CONTRACT_FAKE_HOST"] = str(FAKE_HOST)
|
|
448
|
+
env["DEVRITES_CONTRACT_FAKE_VERSION"] = host["fake_version"]
|
|
449
|
+
env["DEVRITES_CONTRACT_HOST"] = host["id"]
|
|
450
|
+
if fake_fault == "version-mismatch":
|
|
451
|
+
env["DEVRITES_CONTRACT_FAKE_VERSION"] += "-mismatch"
|
|
452
|
+
try:
|
|
453
|
+
proc = subprocess.run(
|
|
454
|
+
[str(launcher), "version"],
|
|
455
|
+
env=env,
|
|
456
|
+
text=True,
|
|
457
|
+
stdout=subprocess.PIPE,
|
|
458
|
+
stderr=subprocess.DEVNULL,
|
|
459
|
+
timeout=15,
|
|
460
|
+
check=False,
|
|
461
|
+
)
|
|
462
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
463
|
+
raise ContractError(
|
|
464
|
+
"host version preflight failed", "DRV-AGENT-UNAVAILABLE"
|
|
465
|
+
) from exc
|
|
466
|
+
version = proc.stdout.strip()
|
|
467
|
+
if proc.returncode != 0 or not version or "\n" in version:
|
|
468
|
+
raise ContractError("host version preflight failed", "DRV-AGENT-UNAVAILABLE")
|
|
469
|
+
return version
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def load_budget_ledger(path: Path) -> dict[str, Any]:
|
|
473
|
+
data = load_json(path)
|
|
474
|
+
if not isinstance(data, dict):
|
|
475
|
+
raise ContractError("budget ledger must be an object", "DRV-AGENT-UNAVAILABLE")
|
|
476
|
+
exact_keys(data, {"schema", "month", "spent_usd"}, "budget ledger")
|
|
477
|
+
if data["schema"] != BUDGET_SCHEMA:
|
|
478
|
+
raise ContractError(
|
|
479
|
+
"budget ledger schema is unsupported", "DRV-AGENT-UNAVAILABLE"
|
|
480
|
+
)
|
|
481
|
+
if data["month"] != datetime.now(timezone.utc).strftime("%Y-%m"):
|
|
482
|
+
raise ContractError(
|
|
483
|
+
"budget ledger month is not current", "DRV-AGENT-UNAVAILABLE"
|
|
484
|
+
)
|
|
485
|
+
money(data["spent_usd"], "budget ledger spent_usd", allow_zero=True)
|
|
486
|
+
return data
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def live_preflight(
|
|
490
|
+
args: argparse.Namespace, matrix: dict[str, Any]
|
|
491
|
+
) -> tuple[dict[str, dict[str, str]], Decimal, Decimal, Path]:
|
|
492
|
+
hosts = {row["id"]: row for row in matrix["hosts"]}
|
|
493
|
+
host_ids = set(hosts)
|
|
494
|
+
if args.confirm_live != LIVE_CONFIRMATION:
|
|
495
|
+
raise ContractError("live confirmation is missing", "DRV-AGENT-UNAVAILABLE")
|
|
496
|
+
versions = parse_assignments(args.host_version, host_ids, "--host-version")
|
|
497
|
+
models = parse_assignments(args.host_model, host_ids, "--host-model")
|
|
498
|
+
versions = {
|
|
499
|
+
host: safe_id(value, f"--host-version {host}")
|
|
500
|
+
for host, value in versions.items()
|
|
501
|
+
}
|
|
502
|
+
models = {
|
|
503
|
+
host: safe_id(value, f"--host-model {host}")
|
|
504
|
+
for host, value in models.items()
|
|
505
|
+
}
|
|
506
|
+
auth_values = parse_assignments(args.auth_file, host_ids, "--auth-file")
|
|
507
|
+
if (
|
|
508
|
+
set(versions) != host_ids
|
|
509
|
+
or set(models) != host_ids
|
|
510
|
+
or set(auth_values) != host_ids
|
|
511
|
+
):
|
|
512
|
+
raise ContractError(
|
|
513
|
+
"every host needs a version, model, and auth file", "DRV-AGENT-UNAVAILABLE"
|
|
514
|
+
)
|
|
515
|
+
auth = {host: secure_auth_file(auth_values[host]) for host in host_ids}
|
|
516
|
+
auth_identities = {
|
|
517
|
+
(path.stat().st_dev, path.stat().st_ino) for path in auth.values()
|
|
518
|
+
}
|
|
519
|
+
if len(set(auth.values())) != len(auth) or len(auth_identities) != len(auth):
|
|
520
|
+
raise ContractError(
|
|
521
|
+
"host credentials must use separate scoped files", "DRV-AGENT-UNAVAILABLE"
|
|
522
|
+
)
|
|
523
|
+
actual_versions: dict[str, dict[str, str]] = {}
|
|
524
|
+
for host_id, host in hosts.items():
|
|
525
|
+
actual = launcher_version(host, False)
|
|
526
|
+
if actual != versions[host_id]:
|
|
527
|
+
raise ContractError(
|
|
528
|
+
"host version does not match its exact pin", "DRV-AGENT-UNAVAILABLE"
|
|
529
|
+
)
|
|
530
|
+
actual_versions[host_id] = {
|
|
531
|
+
"version": actual,
|
|
532
|
+
"model": models[host_id],
|
|
533
|
+
"auth_file": str(auth[host_id]),
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if args.host_artifacts is None or not args.host_artifacts.is_dir():
|
|
537
|
+
raise ContractError(
|
|
538
|
+
"live mode requires a prepared host artifact directory",
|
|
539
|
+
"DRV-AGENT-UNAVAILABLE",
|
|
540
|
+
)
|
|
541
|
+
per_run = money(args.max_cost_per_run_usd, "--max-cost-per-run-usd")
|
|
542
|
+
monthly = money(args.max_monthly_cost_usd, "--max-monthly-cost-usd")
|
|
543
|
+
if args.budget_ledger is None:
|
|
544
|
+
raise ContractError(
|
|
545
|
+
"live mode requires a monthly budget ledger", "DRV-AGENT-UNAVAILABLE"
|
|
546
|
+
)
|
|
547
|
+
ledger = load_budget_ledger(args.budget_ledger)
|
|
548
|
+
spent = money(ledger["spent_usd"], "budget ledger spent_usd", allow_zero=True)
|
|
549
|
+
projected = per_run * Decimal(len(matrix["hosts"]) * len(matrix["scenarios"]))
|
|
550
|
+
if spent + projected > monthly:
|
|
551
|
+
raise ContractError(
|
|
552
|
+
"the full matrix exceeds the monthly ceiling", "DRV-AGENT-UNAVAILABLE"
|
|
553
|
+
)
|
|
554
|
+
uncapped = sorted(host_ids - LIVE_RUNTIME_COST_CAPS.keys())
|
|
555
|
+
if uncapped:
|
|
556
|
+
raise ContractError(
|
|
557
|
+
"paid live host lacks an enforced runtime/provider cost cap: "
|
|
558
|
+
+ ", ".join(uncapped),
|
|
559
|
+
"DRV-AGENT-UNAVAILABLE",
|
|
560
|
+
)
|
|
561
|
+
return actual_versions, per_run, monthly, args.budget_ledger
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def seed_project(
|
|
565
|
+
project: Path, live: bool, host_artifacts: Path | None, env: dict[str, str]
|
|
566
|
+
) -> None:
|
|
567
|
+
project.mkdir(parents=True)
|
|
568
|
+
(project / "README.md").write_text(
|
|
569
|
+
"# Synthetic agent contract fixture\n", encoding="utf-8"
|
|
570
|
+
)
|
|
571
|
+
(project / "src").mkdir()
|
|
572
|
+
if not live:
|
|
573
|
+
return
|
|
574
|
+
install_env = {
|
|
575
|
+
"PATH": env["PATH"],
|
|
576
|
+
"HOME": env["HOME"],
|
|
577
|
+
"TMPDIR": env["TMPDIR"],
|
|
578
|
+
"LANG": "C",
|
|
579
|
+
"LC_ALL": "C",
|
|
580
|
+
"DEVRITES_HOST_ARTIFACT_DIR": str(host_artifacts),
|
|
581
|
+
}
|
|
582
|
+
proc = subprocess.run(
|
|
583
|
+
["bash", str(ROOT / "install.sh"), "--target", str(project)],
|
|
584
|
+
env=install_env,
|
|
585
|
+
stdout=subprocess.DEVNULL,
|
|
586
|
+
stderr=subprocess.DEVNULL,
|
|
587
|
+
timeout=180,
|
|
588
|
+
check=False,
|
|
589
|
+
)
|
|
590
|
+
if proc.returncode != 0:
|
|
591
|
+
raise ContractError(
|
|
592
|
+
"isolated host-pack install failed", "DRV-AGENT-UNAVAILABLE"
|
|
593
|
+
)
|
|
594
|
+
fixture = ROOT / "evals" / "golden" / "shippable-feature"
|
|
595
|
+
workspace = project / ".devrites" / "work" / "shippable-feature"
|
|
596
|
+
workspace.parent.mkdir(parents=True, exist_ok=True)
|
|
597
|
+
shutil.copytree(fixture, workspace)
|
|
598
|
+
(project / ".devrites" / "ACTIVE").write_text(
|
|
599
|
+
"shippable-feature\n", encoding="utf-8"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def make_packet(
|
|
604
|
+
matrix: dict[str, Any],
|
|
605
|
+
host: dict[str, Any],
|
|
606
|
+
scenario: dict[str, Any],
|
|
607
|
+
project: Path,
|
|
608
|
+
scratch: Path,
|
|
609
|
+
) -> dict[str, Any]:
|
|
610
|
+
baseline_tree = tree_snapshot(project)
|
|
611
|
+
diff_hash = sha256_bytes(canonical_json(baseline_tree))
|
|
612
|
+
touched_hash = sha256_bytes(canonical_json(sorted(baseline_tree)))
|
|
613
|
+
run_id = "drv-run-v1:" + os.urandom(16).hex()
|
|
614
|
+
baseline_id = f"no-git:{diff_hash}:{touched_hash}"
|
|
615
|
+
budget = matrix["budget"]
|
|
616
|
+
return {
|
|
617
|
+
"packet_version": matrix["packet_schema"],
|
|
618
|
+
"run_id": run_id,
|
|
619
|
+
"cell_id": f"{host['id']}:{scenario['id']}",
|
|
620
|
+
"host": host["id"],
|
|
621
|
+
"role": scenario["role"],
|
|
622
|
+
"role_class": scenario["role_class"],
|
|
623
|
+
"phase": "eval",
|
|
624
|
+
"attempt": 1,
|
|
625
|
+
"baseline": {
|
|
626
|
+
"id": baseline_id,
|
|
627
|
+
"head": "no-git",
|
|
628
|
+
"diff_sha256": diff_hash,
|
|
629
|
+
"touched_sha256": touched_hash,
|
|
630
|
+
},
|
|
631
|
+
"objective_id": scenario["id"],
|
|
632
|
+
"inputs": [{"path": "README.md", "purpose": "synthetic-contract-fixture"}],
|
|
633
|
+
"scope": {
|
|
634
|
+
"id": sha256_bytes(canonical_json(scenario["allowed_repo_writes"])),
|
|
635
|
+
"in": ["README.md"],
|
|
636
|
+
"out": ["repository-history", "external-systems"],
|
|
637
|
+
"allowed_repo_writes": scenario["allowed_repo_writes"],
|
|
638
|
+
},
|
|
639
|
+
"budgets": {
|
|
640
|
+
"max_files": budget["max_files"],
|
|
641
|
+
"max_loaded_lines": budget["max_loaded_lines"],
|
|
642
|
+
"max_result_lines": budget["max_result_lines"],
|
|
643
|
+
},
|
|
644
|
+
"scratch_root": str(scratch),
|
|
645
|
+
"simulate": scenario["simulate"],
|
|
646
|
+
"expected_execution": scenario["expect"]["execution_mode"],
|
|
647
|
+
"expected_guard": scenario["expect"]["guard_strength"],
|
|
648
|
+
"allowed_signals": sorted(
|
|
649
|
+
MANDATORY_SIGNALS | set(scenario["expect"]["required_signals"])
|
|
650
|
+
),
|
|
651
|
+
"result_schema": matrix["result_schema"],
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def write_transport(path: Path, value: Any) -> None:
|
|
656
|
+
path.write_bytes(canonical_json(value) + b"\n")
|
|
657
|
+
path.chmod(0o600)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def synthetic_reconcile(
|
|
661
|
+
host: dict[str, Any],
|
|
662
|
+
scenario: dict[str, Any],
|
|
663
|
+
exit_code: int,
|
|
664
|
+
reason_id: str,
|
|
665
|
+
sentinel: str,
|
|
666
|
+
host_version: str,
|
|
667
|
+
model: str,
|
|
668
|
+
*,
|
|
669
|
+
live: bool,
|
|
670
|
+
identity_matched: bool = True,
|
|
671
|
+
baseline_matched: bool = True,
|
|
672
|
+
) -> dict[str, Any]:
|
|
673
|
+
expect = scenario["expect"]
|
|
674
|
+
return {
|
|
675
|
+
"cell_id": f"{host['id']}:{scenario['id']}",
|
|
676
|
+
"host": host["id"],
|
|
677
|
+
"scenario": scenario["id"],
|
|
678
|
+
"role": scenario["role"],
|
|
679
|
+
"passed": False,
|
|
680
|
+
"status": "partial" if sentinel == "interrupted" else "failed",
|
|
681
|
+
"outcome": "unavailable",
|
|
682
|
+
"execution_mode": expect["execution_mode"],
|
|
683
|
+
"guard_strength": expect["guard_strength"],
|
|
684
|
+
"independence": expect["independence"],
|
|
685
|
+
"dispatch_reason_id": expect["dispatch_reason_id"],
|
|
686
|
+
"result_reason_id": reason_id,
|
|
687
|
+
"terminal_sentinel": sentinel,
|
|
688
|
+
"host_exit": exit_code,
|
|
689
|
+
"host_version": host_version,
|
|
690
|
+
"model": model,
|
|
691
|
+
"identity_matched": identity_matched,
|
|
692
|
+
"baseline_matched": baseline_matched,
|
|
693
|
+
"repo_writes": [],
|
|
694
|
+
"signals": sorted(MANDATORY_SIGNALS if sentinel == "interrupted" else []),
|
|
695
|
+
"usage": {
|
|
696
|
+
"input_tokens": 0,
|
|
697
|
+
"output_tokens": 0,
|
|
698
|
+
"cost_usd": None if live else "0",
|
|
699
|
+
"cost_provenance": "unavailable" if live else "synthetic",
|
|
700
|
+
},
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def declared_writes(value: Any) -> list[dict[str, Any]]:
|
|
705
|
+
if not isinstance(value, list):
|
|
706
|
+
raise ContractError("repo_writes must be a list")
|
|
707
|
+
out: list[dict[str, Any]] = []
|
|
708
|
+
for item in value:
|
|
709
|
+
if not isinstance(item, dict):
|
|
710
|
+
raise ContractError("repo write must be an object")
|
|
711
|
+
exact_keys(item, {"path", "kind", "sha256"}, "repo write")
|
|
712
|
+
safe_relative(item["path"], "repo write path")
|
|
713
|
+
if item["kind"] not in {
|
|
714
|
+
"file",
|
|
715
|
+
"symlink",
|
|
716
|
+
"deleted",
|
|
717
|
+
} or not SHA256_RE.fullmatch(str(item["sha256"])):
|
|
718
|
+
raise ContractError("repo write metadata is invalid")
|
|
719
|
+
out.append(item)
|
|
720
|
+
if len({item["path"] for item in out}) != len(out):
|
|
721
|
+
raise ContractError("repo writes contain duplicates")
|
|
722
|
+
return sorted(out, key=lambda item: item["path"])
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def validate_result(
|
|
726
|
+
raw: Any,
|
|
727
|
+
packet: dict[str, Any],
|
|
728
|
+
host: dict[str, Any],
|
|
729
|
+
scenario: dict[str, Any],
|
|
730
|
+
exit_code: int,
|
|
731
|
+
actual_delta: list[dict[str, Any]],
|
|
732
|
+
host_version: str,
|
|
733
|
+
model: str,
|
|
734
|
+
reasons: set[str],
|
|
735
|
+
live: bool,
|
|
736
|
+
) -> dict[str, Any]:
|
|
737
|
+
if not isinstance(raw, dict) or set(raw) != RESULT_KEYS:
|
|
738
|
+
raise ContractError("result object does not match the frozen schema")
|
|
739
|
+
if any(key in FORBIDDEN_RESULT_KEYS for key in raw):
|
|
740
|
+
raise ContractError("result contains a forbidden retention key")
|
|
741
|
+
if raw["result_version"] != "agent-result/v1":
|
|
742
|
+
raise ContractError("result version is unsupported")
|
|
743
|
+
identity_fields = {
|
|
744
|
+
"run_id": packet["run_id"],
|
|
745
|
+
"cell_id": packet["cell_id"],
|
|
746
|
+
"host": packet["host"],
|
|
747
|
+
"role": packet["role"],
|
|
748
|
+
}
|
|
749
|
+
if any(raw.get(key) != value for key, value in identity_fields.items()):
|
|
750
|
+
raise ContractError(
|
|
751
|
+
"result identity mismatch", "DRV-AGENT-RESULT-IDENTITY-MISMATCH"
|
|
752
|
+
)
|
|
753
|
+
if not RUN_ID_RE.fullmatch(raw["run_id"]):
|
|
754
|
+
raise ContractError("result run ID is malformed")
|
|
755
|
+
if raw["baseline_id"] != packet["baseline"]["id"]:
|
|
756
|
+
raise ContractError("result baseline is stale", "DRV-AGENT-RESULT-STALE")
|
|
757
|
+
if raw["scope_id"] != packet["scope"]["id"]:
|
|
758
|
+
raise ContractError(
|
|
759
|
+
"result scope identity mismatch", "DRV-AGENT-RESULT-IDENTITY-MISMATCH"
|
|
760
|
+
)
|
|
761
|
+
if raw["host_version"] != host_version or raw["model"] != model:
|
|
762
|
+
raise ContractError(
|
|
763
|
+
"result host pin identity mismatch", "DRV-AGENT-RESULT-IDENTITY-MISMATCH"
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
if raw["status"] not in {"complete", "partial", "blocked", "failed"}:
|
|
767
|
+
raise ContractError("result status is unsupported")
|
|
768
|
+
if raw["outcome"] not in {"passed", "denied", "unavailable"}:
|
|
769
|
+
raise ContractError("result outcome is unsupported")
|
|
770
|
+
if raw["terminal_sentinel"] not in {"complete", "interrupted"}:
|
|
771
|
+
raise ContractError("result terminal sentinel is unsupported")
|
|
772
|
+
if raw["payload_type"] not in {"review-findings", "wright-report"}:
|
|
773
|
+
raise ContractError("result payload type is unsupported")
|
|
774
|
+
|
|
775
|
+
execution = raw["execution"]
|
|
776
|
+
if not isinstance(execution, dict):
|
|
777
|
+
raise ContractError("result execution must be an object")
|
|
778
|
+
exact_keys(
|
|
779
|
+
execution, {"mode", "guard_strength", "independence"}, "result execution"
|
|
780
|
+
)
|
|
781
|
+
if execution["mode"] not in {"named", "generic", "inline"}:
|
|
782
|
+
raise ContractError("result execution mode is unsupported")
|
|
783
|
+
if execution["guard_strength"] not in {
|
|
784
|
+
"enforced",
|
|
785
|
+
"observed",
|
|
786
|
+
"unavailable",
|
|
787
|
+
"bypassed",
|
|
788
|
+
}:
|
|
789
|
+
raise ContractError("result guard strength is unsupported")
|
|
790
|
+
if not isinstance(execution["independence"], bool):
|
|
791
|
+
raise ContractError("result independence must be boolean")
|
|
792
|
+
|
|
793
|
+
for key in ("dispatch_reason_id", "result_reason_id"):
|
|
794
|
+
if raw[key] not in reasons:
|
|
795
|
+
raise ContractError("result reason is not frozen")
|
|
796
|
+
budget = raw["budget"]
|
|
797
|
+
if not isinstance(budget, dict):
|
|
798
|
+
raise ContractError("result budget must be an object")
|
|
799
|
+
exact_keys(
|
|
800
|
+
budget,
|
|
801
|
+
{"files_used", "loaded_lines_used", "result_lines_used"},
|
|
802
|
+
"result budget",
|
|
803
|
+
)
|
|
804
|
+
packet_limits = packet["budgets"]
|
|
805
|
+
for used_key, limit_key in (
|
|
806
|
+
("files_used", "max_files"),
|
|
807
|
+
("loaded_lines_used", "max_loaded_lines"),
|
|
808
|
+
("result_lines_used", "max_result_lines"),
|
|
809
|
+
):
|
|
810
|
+
used = budget[used_key]
|
|
811
|
+
if (
|
|
812
|
+
isinstance(used, bool)
|
|
813
|
+
or not isinstance(used, int)
|
|
814
|
+
or used < 0
|
|
815
|
+
or used > packet_limits[limit_key]
|
|
816
|
+
):
|
|
817
|
+
raise ContractError("result exceeded its packet budget")
|
|
818
|
+
|
|
819
|
+
side_effects = raw["side_effects"]
|
|
820
|
+
if not isinstance(side_effects, dict):
|
|
821
|
+
raise ContractError("result side_effects must be an object")
|
|
822
|
+
exact_keys(
|
|
823
|
+
side_effects, {"repo_writes", "scratch_write_count"}, "result side_effects"
|
|
824
|
+
)
|
|
825
|
+
writes = declared_writes(side_effects["repo_writes"])
|
|
826
|
+
if (
|
|
827
|
+
isinstance(side_effects["scratch_write_count"], bool)
|
|
828
|
+
or not isinstance(side_effects["scratch_write_count"], int)
|
|
829
|
+
or side_effects["scratch_write_count"] < 0
|
|
830
|
+
):
|
|
831
|
+
raise ContractError("scratch write count is invalid")
|
|
832
|
+
if writes != actual_delta:
|
|
833
|
+
raise ContractError(
|
|
834
|
+
"declared and observed repo effects differ", "DRV-AGENT-RESULT-OUT-OF-SCOPE"
|
|
835
|
+
)
|
|
836
|
+
allowed = set(packet["scope"]["allowed_repo_writes"])
|
|
837
|
+
if any(item["path"] not in allowed for item in actual_delta):
|
|
838
|
+
raise ContractError(
|
|
839
|
+
"repo effect escaped the exact allowlist", "DRV-AGENT-RESULT-OUT-OF-SCOPE"
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
signals = raw["signals"]
|
|
843
|
+
if (
|
|
844
|
+
not isinstance(signals, list)
|
|
845
|
+
or len(signals) != len(set(signals))
|
|
846
|
+
or not all(isinstance(item, str) and item for item in signals)
|
|
847
|
+
):
|
|
848
|
+
raise ContractError("result signals are invalid")
|
|
849
|
+
if not MANDATORY_SIGNALS.issubset(signals):
|
|
850
|
+
raise ContractError("result is missing hermetic preflight signals")
|
|
851
|
+
allowed_signals = MANDATORY_SIGNALS | set(scenario["expect"]["required_signals"])
|
|
852
|
+
if not set(signals).issubset(allowed_signals):
|
|
853
|
+
raise ContractError("result contains a signal outside the scenario vocabulary")
|
|
854
|
+
usage = raw["usage"]
|
|
855
|
+
if not isinstance(usage, dict):
|
|
856
|
+
raise ContractError("result usage must be an object")
|
|
857
|
+
usage = dict(usage)
|
|
858
|
+
if not live and set(usage) == {"input_tokens", "output_tokens", "cost_usd"}:
|
|
859
|
+
usage["cost_provenance"] = "synthetic"
|
|
860
|
+
exact_keys(
|
|
861
|
+
usage,
|
|
862
|
+
{"input_tokens", "output_tokens", "cost_usd", "cost_provenance"},
|
|
863
|
+
"result usage",
|
|
864
|
+
)
|
|
865
|
+
for key in ("input_tokens", "output_tokens"):
|
|
866
|
+
if (
|
|
867
|
+
isinstance(usage[key], bool)
|
|
868
|
+
or not isinstance(usage[key], int)
|
|
869
|
+
or usage[key] < 0
|
|
870
|
+
):
|
|
871
|
+
raise ContractError("result token count is invalid")
|
|
872
|
+
provenance = usage["cost_provenance"]
|
|
873
|
+
if provenance == "unavailable":
|
|
874
|
+
if not live or usage["cost_usd"] is not None:
|
|
875
|
+
raise ContractError("unavailable cost provenance is inconsistent")
|
|
876
|
+
elif provenance == "provider-reported":
|
|
877
|
+
if not live or usage["cost_usd"] is None:
|
|
878
|
+
raise ContractError("provider cost provenance is inconsistent")
|
|
879
|
+
usage["cost_usd"] = str(
|
|
880
|
+
money(usage["cost_usd"], "result cost", allow_zero=True)
|
|
881
|
+
)
|
|
882
|
+
elif provenance == "synthetic":
|
|
883
|
+
if live or usage["cost_usd"] is None:
|
|
884
|
+
raise ContractError("synthetic cost provenance is inconsistent")
|
|
885
|
+
usage["cost_usd"] = str(
|
|
886
|
+
money(usage["cost_usd"], "result cost", allow_zero=True)
|
|
887
|
+
)
|
|
888
|
+
else:
|
|
889
|
+
raise ContractError("result cost provenance is unsupported")
|
|
890
|
+
|
|
891
|
+
return {
|
|
892
|
+
"cell_id": packet["cell_id"],
|
|
893
|
+
"host": host["id"],
|
|
894
|
+
"scenario": scenario["id"],
|
|
895
|
+
"role": scenario["role"],
|
|
896
|
+
"passed": False,
|
|
897
|
+
"status": raw["status"],
|
|
898
|
+
"outcome": raw["outcome"],
|
|
899
|
+
"execution_mode": execution["mode"],
|
|
900
|
+
"guard_strength": execution["guard_strength"],
|
|
901
|
+
"independence": execution["independence"],
|
|
902
|
+
"dispatch_reason_id": raw["dispatch_reason_id"],
|
|
903
|
+
"result_reason_id": raw["result_reason_id"],
|
|
904
|
+
"terminal_sentinel": raw["terminal_sentinel"],
|
|
905
|
+
"host_exit": exit_code,
|
|
906
|
+
"host_version": raw["host_version"],
|
|
907
|
+
"model": raw["model"],
|
|
908
|
+
"identity_matched": True,
|
|
909
|
+
"baseline_matched": True,
|
|
910
|
+
"repo_writes": writes,
|
|
911
|
+
"signals": sorted(signals),
|
|
912
|
+
"usage": usage,
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def usage_cost(usage: dict[str, Any]) -> Decimal | None:
|
|
917
|
+
if usage["cost_provenance"] == "unavailable":
|
|
918
|
+
return None
|
|
919
|
+
return money(usage["cost_usd"], "result cost", allow_zero=True)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def result_matches_expected(result: dict[str, Any], scenario: dict[str, Any]) -> bool:
|
|
923
|
+
expect = scenario["expect"]
|
|
924
|
+
pairs = {
|
|
925
|
+
"status": expect["status"],
|
|
926
|
+
"outcome": expect["outcome"],
|
|
927
|
+
"execution_mode": expect["execution_mode"],
|
|
928
|
+
"guard_strength": expect["guard_strength"],
|
|
929
|
+
"independence": expect["independence"],
|
|
930
|
+
"dispatch_reason_id": expect["dispatch_reason_id"],
|
|
931
|
+
"result_reason_id": expect["result_reason_id"],
|
|
932
|
+
"terminal_sentinel": expect["terminal_sentinel"],
|
|
933
|
+
"host_exit": expect["host_exit"],
|
|
934
|
+
}
|
|
935
|
+
if any(result.get(key) != value for key, value in pairs.items()):
|
|
936
|
+
return False
|
|
937
|
+
if not set(expect["required_signals"]).issubset(result.get("signals", [])):
|
|
938
|
+
return False
|
|
939
|
+
if scenario["simulate"] == "write-allowed":
|
|
940
|
+
if [item["path"] for item in result["repo_writes"]] != scenario[
|
|
941
|
+
"allowed_repo_writes"
|
|
942
|
+
]:
|
|
943
|
+
return False
|
|
944
|
+
elif result["repo_writes"]:
|
|
945
|
+
return False
|
|
946
|
+
return True
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def run_cell(
|
|
950
|
+
matrix: dict[str, Any],
|
|
951
|
+
host: dict[str, Any],
|
|
952
|
+
scenario: dict[str, Any],
|
|
953
|
+
*,
|
|
954
|
+
live: bool,
|
|
955
|
+
host_version: str,
|
|
956
|
+
model: str,
|
|
957
|
+
auth_file: Path | None,
|
|
958
|
+
host_artifacts: Path | None,
|
|
959
|
+
per_run_cost: Decimal,
|
|
960
|
+
fake_fault: str | None,
|
|
961
|
+
) -> dict[str, Any]:
|
|
962
|
+
reasons = frozen_reasons()
|
|
963
|
+
run_root = Path(tempfile.mkdtemp(prefix="devrites-agent-contract-")).resolve()
|
|
964
|
+
project = run_root / "project"
|
|
965
|
+
config = run_root / "config"
|
|
966
|
+
state = run_root / "state"
|
|
967
|
+
home = run_root / "home"
|
|
968
|
+
scratch = run_root / "scratch"
|
|
969
|
+
tmp = run_root / "tmp"
|
|
970
|
+
guard = run_root / "guard"
|
|
971
|
+
for path in (config, state, home, scratch, tmp, guard):
|
|
972
|
+
path.mkdir(mode=0o700)
|
|
973
|
+
(guard / "sentinel").write_text("unchanged\n", encoding="utf-8")
|
|
974
|
+
|
|
975
|
+
if auth_file is None:
|
|
976
|
+
auth_file = state / f"{host['id']}.auth"
|
|
977
|
+
auth_file.write_text("fake-scoped-credential\n", encoding="utf-8")
|
|
978
|
+
auth_file.chmod(0o600)
|
|
979
|
+
base_env = {
|
|
980
|
+
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
981
|
+
"LANG": "C",
|
|
982
|
+
"LC_ALL": "C",
|
|
983
|
+
"HOME": str(home),
|
|
984
|
+
"TMPDIR": str(tmp),
|
|
985
|
+
"XDG_CONFIG_HOME": str(config),
|
|
986
|
+
"XDG_STATE_HOME": str(state),
|
|
987
|
+
}
|
|
988
|
+
try:
|
|
989
|
+
seed_project(project, live, host_artifacts, base_env)
|
|
990
|
+
packet = make_packet(matrix, host, scenario, project, scratch)
|
|
991
|
+
packet_path = scratch / "agent-packet.yaml"
|
|
992
|
+
result_path = scratch / "agent-result.yaml"
|
|
993
|
+
write_transport(packet_path, packet)
|
|
994
|
+
|
|
995
|
+
before_project = tree_snapshot(project)
|
|
996
|
+
before_guard = tree_snapshot(guard)
|
|
997
|
+
env = {
|
|
998
|
+
**base_env,
|
|
999
|
+
"DEVRITES_ROOT": str(project),
|
|
1000
|
+
"DEVRITES_AGENT_SCRATCH": str(scratch),
|
|
1001
|
+
"DEVRITES_RUN_ID": packet["run_id"],
|
|
1002
|
+
"DEVRITES_CONTRACT_RUN_ROOT": str(run_root),
|
|
1003
|
+
"DEVRITES_CONTRACT_PACKET": str(packet_path),
|
|
1004
|
+
"DEVRITES_CONTRACT_RESULT": str(result_path),
|
|
1005
|
+
"DEVRITES_CONTRACT_HOST": host["id"],
|
|
1006
|
+
"DEVRITES_CONTRACT_HOST_VERSION_PIN": host_version,
|
|
1007
|
+
"DEVRITES_CONTRACT_MODEL": model,
|
|
1008
|
+
"DEVRITES_CONTRACT_AUTH_FILE": str(auth_file),
|
|
1009
|
+
"DEVRITES_CONTRACT_MAX_COST_USD": str(per_run_cost),
|
|
1010
|
+
}
|
|
1011
|
+
if not live:
|
|
1012
|
+
env["DEVRITES_CONTRACT_FAKE_HOST"] = str(FAKE_HOST)
|
|
1013
|
+
env["DEVRITES_CONTRACT_FAKE_VERSION"] = host["fake_version"]
|
|
1014
|
+
if fake_fault and fake_fault.startswith(packet["cell_id"] + ":"):
|
|
1015
|
+
env["DEVRITES_CONTRACT_FAKE_FAULT"] = fake_fault.rsplit(":", 1)[1]
|
|
1016
|
+
|
|
1017
|
+
launcher = ROOT / host["launcher"]
|
|
1018
|
+
if live and scenario["simulate"] == "interrupted":
|
|
1019
|
+
proc_live = subprocess.Popen(
|
|
1020
|
+
[str(launcher), "run"],
|
|
1021
|
+
env=env,
|
|
1022
|
+
stdout=subprocess.DEVNULL,
|
|
1023
|
+
stderr=subprocess.DEVNULL,
|
|
1024
|
+
start_new_session=True,
|
|
1025
|
+
)
|
|
1026
|
+
time.sleep(2)
|
|
1027
|
+
if proc_live.poll() is None:
|
|
1028
|
+
os.killpg(proc_live.pid, signal.SIGINT)
|
|
1029
|
+
try:
|
|
1030
|
+
exit_code = proc_live.wait(timeout=15)
|
|
1031
|
+
except subprocess.TimeoutExpired:
|
|
1032
|
+
os.killpg(proc_live.pid, signal.SIGKILL)
|
|
1033
|
+
proc_live.wait()
|
|
1034
|
+
exit_code = 130
|
|
1035
|
+
else:
|
|
1036
|
+
try:
|
|
1037
|
+
proc = subprocess.run(
|
|
1038
|
+
[str(launcher), "run"],
|
|
1039
|
+
env=env,
|
|
1040
|
+
stdout=subprocess.DEVNULL,
|
|
1041
|
+
stderr=subprocess.DEVNULL,
|
|
1042
|
+
timeout=matrix["budget"]["timeout_seconds"],
|
|
1043
|
+
check=False,
|
|
1044
|
+
start_new_session=True,
|
|
1045
|
+
)
|
|
1046
|
+
exit_code = proc.returncode
|
|
1047
|
+
except subprocess.TimeoutExpired:
|
|
1048
|
+
exit_code = 130
|
|
1049
|
+
|
|
1050
|
+
after_project = tree_snapshot(project)
|
|
1051
|
+
actual_delta = tree_delta(before_project, after_project)
|
|
1052
|
+
if before_guard != tree_snapshot(guard):
|
|
1053
|
+
result = synthetic_reconcile(
|
|
1054
|
+
host,
|
|
1055
|
+
scenario,
|
|
1056
|
+
exit_code,
|
|
1057
|
+
"DRV-AGENT-RESULT-OUT-OF-SCOPE",
|
|
1058
|
+
"complete",
|
|
1059
|
+
host_version,
|
|
1060
|
+
model,
|
|
1061
|
+
live=live,
|
|
1062
|
+
)
|
|
1063
|
+
elif not result_path.exists():
|
|
1064
|
+
result = synthetic_reconcile(
|
|
1065
|
+
host,
|
|
1066
|
+
scenario,
|
|
1067
|
+
exit_code,
|
|
1068
|
+
"DRV-AGENT-UNAVAILABLE",
|
|
1069
|
+
"missing",
|
|
1070
|
+
host_version,
|
|
1071
|
+
model,
|
|
1072
|
+
live=live,
|
|
1073
|
+
)
|
|
1074
|
+
else:
|
|
1075
|
+
try:
|
|
1076
|
+
raw = load_json(result_path)
|
|
1077
|
+
result = validate_result(
|
|
1078
|
+
raw,
|
|
1079
|
+
packet,
|
|
1080
|
+
host,
|
|
1081
|
+
scenario,
|
|
1082
|
+
exit_code,
|
|
1083
|
+
actual_delta,
|
|
1084
|
+
host_version,
|
|
1085
|
+
model,
|
|
1086
|
+
reasons,
|
|
1087
|
+
live,
|
|
1088
|
+
)
|
|
1089
|
+
except ContractError as exc:
|
|
1090
|
+
sentinel = {
|
|
1091
|
+
"DRV-AGENT-RESULT-STALE": "stale",
|
|
1092
|
+
"DRV-AGENT-RESULT-MALFORMED": "malformed",
|
|
1093
|
+
"DRV-AGENT-RESULT-IDENTITY-MISMATCH": "malformed",
|
|
1094
|
+
"DRV-AGENT-RESULT-OUT-OF-SCOPE": "malformed",
|
|
1095
|
+
}.get(exc.reason_id, "malformed")
|
|
1096
|
+
result = synthetic_reconcile(
|
|
1097
|
+
host,
|
|
1098
|
+
scenario,
|
|
1099
|
+
exit_code,
|
|
1100
|
+
exc.reason_id,
|
|
1101
|
+
sentinel,
|
|
1102
|
+
host_version,
|
|
1103
|
+
model,
|
|
1104
|
+
live=live,
|
|
1105
|
+
identity_matched=exc.reason_id
|
|
1106
|
+
!= "DRV-AGENT-RESULT-IDENTITY-MISMATCH",
|
|
1107
|
+
baseline_matched=exc.reason_id != "DRV-AGENT-RESULT-STALE",
|
|
1108
|
+
)
|
|
1109
|
+
if (
|
|
1110
|
+
scenario["simulate"] == "interrupted"
|
|
1111
|
+
and result["result_reason_id"] == "DRV-AGENT-UNAVAILABLE"
|
|
1112
|
+
):
|
|
1113
|
+
result["status"] = "partial"
|
|
1114
|
+
result["terminal_sentinel"] = "interrupted"
|
|
1115
|
+
result["signals"] = sorted(
|
|
1116
|
+
MANDATORY_SIGNALS | {"progress_recorded", "partial_metadata"}
|
|
1117
|
+
)
|
|
1118
|
+
cost = usage_cost(result["usage"])
|
|
1119
|
+
if cost is not None and cost > per_run_cost:
|
|
1120
|
+
result["passed"] = False
|
|
1121
|
+
result["result_reason_id"] = "DRV-AGENT-UNAVAILABLE"
|
|
1122
|
+
return result
|
|
1123
|
+
result["passed"] = result_matches_expected(result, scenario)
|
|
1124
|
+
return result
|
|
1125
|
+
finally:
|
|
1126
|
+
shutil.rmtree(run_root, ignore_errors=True)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def assert_private_metadata(value: Any) -> None:
|
|
1130
|
+
def walk(item: Any) -> None:
|
|
1131
|
+
if isinstance(item, dict):
|
|
1132
|
+
if FORBIDDEN_RESULT_KEYS.intersection(item):
|
|
1133
|
+
raise ContractError("summary contains forbidden retention fields")
|
|
1134
|
+
for key, child in item.items():
|
|
1135
|
+
if isinstance(key, str):
|
|
1136
|
+
if key.lower() in FORBIDDEN_RESULT_KEYS:
|
|
1137
|
+
raise ContractError(
|
|
1138
|
+
"summary contains a forbidden retention key"
|
|
1139
|
+
)
|
|
1140
|
+
if private_string_fault(key):
|
|
1141
|
+
raise ContractError("summary contains a private metadata key")
|
|
1142
|
+
walk(child)
|
|
1143
|
+
elif isinstance(item, list):
|
|
1144
|
+
for child in item:
|
|
1145
|
+
walk(child)
|
|
1146
|
+
elif isinstance(item, str):
|
|
1147
|
+
fault = private_string_fault(item)
|
|
1148
|
+
if fault:
|
|
1149
|
+
raise ContractError(f"summary contains a private {fault}")
|
|
1150
|
+
|
|
1151
|
+
walk(value)
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def write_summary(results_dir: Path | None, summary: dict[str, Any]) -> None:
|
|
1155
|
+
assert_private_metadata(summary)
|
|
1156
|
+
encoded = json.dumps(summary, sort_keys=True, indent=2) + "\n"
|
|
1157
|
+
if results_dir is None:
|
|
1158
|
+
sys.stdout.write(encoded)
|
|
1159
|
+
return
|
|
1160
|
+
results_dir.mkdir(parents=True, exist_ok=True)
|
|
1161
|
+
target = results_dir / "summary.json"
|
|
1162
|
+
temp = results_dir / ".summary.json.tmp"
|
|
1163
|
+
temp.write_text(encoded, encoding="utf-8")
|
|
1164
|
+
temp.chmod(0o600)
|
|
1165
|
+
os.replace(temp, target)
|
|
1166
|
+
sys.stdout.write(encoded)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def write_ledger(path: Path, month: str, spent: Decimal) -> None:
|
|
1170
|
+
updated = {"schema": BUDGET_SCHEMA, "month": month, "spent_usd": str(spent)}
|
|
1171
|
+
temp = path.with_name(path.name + ".tmp")
|
|
1172
|
+
temp.write_bytes(canonical_json(updated) + b"\n")
|
|
1173
|
+
temp.chmod(0o600)
|
|
1174
|
+
os.replace(temp, path)
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def locked_ledger(path: Path) -> tuple[int, Path, dict[str, Any]]:
|
|
1178
|
+
lock = path.with_name(path.name + ".lock")
|
|
1179
|
+
try:
|
|
1180
|
+
descriptor = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
1181
|
+
except FileExistsError as exc:
|
|
1182
|
+
raise ContractError(
|
|
1183
|
+
"budget ledger is locked by another run", "DRV-AGENT-UNAVAILABLE"
|
|
1184
|
+
) from exc
|
|
1185
|
+
try:
|
|
1186
|
+
return descriptor, lock, load_budget_ledger(path)
|
|
1187
|
+
except Exception:
|
|
1188
|
+
os.close(descriptor)
|
|
1189
|
+
lock.unlink(missing_ok=True)
|
|
1190
|
+
raise
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def reserve_budget(path: Path, monthly: Decimal, amount: Decimal) -> None:
|
|
1194
|
+
descriptor, lock, ledger = locked_ledger(path)
|
|
1195
|
+
try:
|
|
1196
|
+
spent = money(ledger["spent_usd"], "ledger spent", allow_zero=True)
|
|
1197
|
+
if spent + amount > monthly:
|
|
1198
|
+
raise ContractError(
|
|
1199
|
+
"the full matrix exceeds the monthly ceiling", "DRV-AGENT-UNAVAILABLE"
|
|
1200
|
+
)
|
|
1201
|
+
write_ledger(path, ledger["month"], spent + amount)
|
|
1202
|
+
finally:
|
|
1203
|
+
os.close(descriptor)
|
|
1204
|
+
lock.unlink(missing_ok=True)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def settle_budget(path: Path, reserved: Decimal, actual: Decimal | None) -> None:
|
|
1208
|
+
descriptor, lock, ledger = locked_ledger(path)
|
|
1209
|
+
try:
|
|
1210
|
+
spent = money(ledger["spent_usd"], "ledger spent", allow_zero=True)
|
|
1211
|
+
settled = reserved if actual is None else actual
|
|
1212
|
+
write_ledger(
|
|
1213
|
+
path, ledger["month"], max(Decimal("0"), spent - reserved + settled)
|
|
1214
|
+
)
|
|
1215
|
+
finally:
|
|
1216
|
+
os.close(descriptor)
|
|
1217
|
+
lock.unlink(missing_ok=True)
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def parse_args() -> argparse.Namespace:
|
|
1221
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1222
|
+
mode = parser.add_mutually_exclusive_group()
|
|
1223
|
+
mode.add_argument(
|
|
1224
|
+
"--fake",
|
|
1225
|
+
action="store_true",
|
|
1226
|
+
help="run the deterministic fake-host matrix (default)",
|
|
1227
|
+
)
|
|
1228
|
+
mode.add_argument(
|
|
1229
|
+
"--live",
|
|
1230
|
+
action="store_true",
|
|
1231
|
+
help="run paid host CLIs after strict manual preflight",
|
|
1232
|
+
)
|
|
1233
|
+
parser.add_argument("--matrix", type=Path, default=DEFAULT_MATRIX)
|
|
1234
|
+
parser.add_argument("--results-dir", type=Path)
|
|
1235
|
+
parser.add_argument("--preflight-only", action="store_true")
|
|
1236
|
+
parser.add_argument("--confirm-live")
|
|
1237
|
+
parser.add_argument(
|
|
1238
|
+
"--host-version", action="append", default=[], metavar="HOST=VERSION"
|
|
1239
|
+
)
|
|
1240
|
+
parser.add_argument(
|
|
1241
|
+
"--host-model", action="append", default=[], metavar="HOST=MODEL"
|
|
1242
|
+
)
|
|
1243
|
+
parser.add_argument("--auth-file", action="append", default=[], metavar="HOST=PATH")
|
|
1244
|
+
parser.add_argument("--host-artifacts", type=Path)
|
|
1245
|
+
parser.add_argument("--max-cost-per-run-usd")
|
|
1246
|
+
parser.add_argument("--max-monthly-cost-usd")
|
|
1247
|
+
parser.add_argument("--budget-ledger", type=Path)
|
|
1248
|
+
parser.add_argument(
|
|
1249
|
+
"--fake-fault",
|
|
1250
|
+
help="fake-only fault injection as HOST:SCENARIO:identity-mismatch|escape-write|privacy-leak|cost-overrun",
|
|
1251
|
+
)
|
|
1252
|
+
return parser.parse_args()
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def main() -> int:
|
|
1256
|
+
args = parse_args()
|
|
1257
|
+
try:
|
|
1258
|
+
matrix = validate_matrix(load_json(args.matrix))
|
|
1259
|
+
matrix_digest = sha256_bytes(canonical_json(matrix))
|
|
1260
|
+
host_rows = {row["id"]: row for row in matrix["hosts"]}
|
|
1261
|
+
cells = len(matrix["hosts"]) * len(matrix["scenarios"])
|
|
1262
|
+
live = bool(args.live)
|
|
1263
|
+
live_info: dict[str, dict[str, str]] = {}
|
|
1264
|
+
ledger_path: Path | None = None
|
|
1265
|
+
monthly = Decimal("0")
|
|
1266
|
+
if live:
|
|
1267
|
+
if args.results_dir is None:
|
|
1268
|
+
raise ContractError(
|
|
1269
|
+
"live mode requires --results-dir", "DRV-AGENT-UNAVAILABLE"
|
|
1270
|
+
)
|
|
1271
|
+
live_info, per_run, monthly, ledger_path = live_preflight(args, matrix)
|
|
1272
|
+
else:
|
|
1273
|
+
if any(
|
|
1274
|
+
[
|
|
1275
|
+
args.confirm_live,
|
|
1276
|
+
args.host_version,
|
|
1277
|
+
args.host_model,
|
|
1278
|
+
args.auth_file,
|
|
1279
|
+
args.host_artifacts,
|
|
1280
|
+
args.max_cost_per_run_usd,
|
|
1281
|
+
args.max_monthly_cost_usd,
|
|
1282
|
+
args.budget_ledger,
|
|
1283
|
+
]
|
|
1284
|
+
):
|
|
1285
|
+
raise ContractError("live-only options cannot be used in fake mode")
|
|
1286
|
+
per_run = money(
|
|
1287
|
+
matrix["budget"]["estimated_max_cost_usd_per_cell"],
|
|
1288
|
+
"matrix estimated cost",
|
|
1289
|
+
)
|
|
1290
|
+
for host_id, row in host_rows.items():
|
|
1291
|
+
actual = launcher_version(
|
|
1292
|
+
row,
|
|
1293
|
+
True,
|
|
1294
|
+
"version-mismatch"
|
|
1295
|
+
if args.fake_fault == f"{host_id}:version:version-mismatch"
|
|
1296
|
+
else None,
|
|
1297
|
+
)
|
|
1298
|
+
if actual != row["fake_version"]:
|
|
1299
|
+
raise ContractError(
|
|
1300
|
+
"fake host version pin mismatch", "DRV-AGENT-UNAVAILABLE"
|
|
1301
|
+
)
|
|
1302
|
+
live_info[host_id] = {
|
|
1303
|
+
"version": actual,
|
|
1304
|
+
"model": f"{host_id}-contract-fake",
|
|
1305
|
+
"auth_file": "",
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
if args.preflight_only:
|
|
1309
|
+
summary = {
|
|
1310
|
+
"schema": PREFLIGHT_SCHEMA,
|
|
1311
|
+
"mode": "live" if live else "fake",
|
|
1312
|
+
"matrix_digest": matrix_digest,
|
|
1313
|
+
"cells": cells,
|
|
1314
|
+
"hosts": [
|
|
1315
|
+
{
|
|
1316
|
+
"host": host_id,
|
|
1317
|
+
"version": live_info[host_id]["version"],
|
|
1318
|
+
"model": live_info[host_id]["model"],
|
|
1319
|
+
}
|
|
1320
|
+
for host_id in sorted(host_rows)
|
|
1321
|
+
],
|
|
1322
|
+
"max_live_cost_usd": str(per_run * Decimal(cells)),
|
|
1323
|
+
"monthly_ceiling_usd": str(monthly) if live else "0",
|
|
1324
|
+
"ready": True,
|
|
1325
|
+
}
|
|
1326
|
+
write_summary(args.results_dir, summary)
|
|
1327
|
+
return 0
|
|
1328
|
+
|
|
1329
|
+
results: list[dict[str, Any]] = []
|
|
1330
|
+
reservation = per_run * Decimal(cells)
|
|
1331
|
+
if live and ledger_path is not None:
|
|
1332
|
+
reserve_budget(ledger_path, monthly, reservation)
|
|
1333
|
+
for host in matrix["hosts"]:
|
|
1334
|
+
info = live_info[host["id"]]
|
|
1335
|
+
auth = Path(info["auth_file"]) if live else None
|
|
1336
|
+
for scenario in matrix["scenarios"]:
|
|
1337
|
+
result = run_cell(
|
|
1338
|
+
matrix,
|
|
1339
|
+
host,
|
|
1340
|
+
scenario,
|
|
1341
|
+
live=live,
|
|
1342
|
+
host_version=info["version"],
|
|
1343
|
+
model=info["model"],
|
|
1344
|
+
auth_file=auth,
|
|
1345
|
+
host_artifacts=args.host_artifacts,
|
|
1346
|
+
per_run_cost=per_run,
|
|
1347
|
+
fake_fault=args.fake_fault,
|
|
1348
|
+
)
|
|
1349
|
+
results.append(result)
|
|
1350
|
+
|
|
1351
|
+
observed_costs = [usage_cost(row["usage"]) for row in results]
|
|
1352
|
+
total_cost = (
|
|
1353
|
+
sum((cost for cost in observed_costs if cost is not None), Decimal("0"))
|
|
1354
|
+
if all(cost is not None for cost in observed_costs)
|
|
1355
|
+
else None
|
|
1356
|
+
)
|
|
1357
|
+
summary = {
|
|
1358
|
+
"schema": SUMMARY_SCHEMA,
|
|
1359
|
+
"mode": "live" if live else "fake",
|
|
1360
|
+
"matrix_digest": matrix_digest,
|
|
1361
|
+
"cells_total": len(results),
|
|
1362
|
+
"cells_passed": sum(1 for row in results if row["passed"]),
|
|
1363
|
+
"cells_failed": sum(1 for row in results if not row["passed"]),
|
|
1364
|
+
"cost_usd": str(total_cost) if total_cost is not None else None,
|
|
1365
|
+
"reason_counts": dict(
|
|
1366
|
+
sorted(Counter(row["result_reason_id"] for row in results).items())
|
|
1367
|
+
),
|
|
1368
|
+
"results": results,
|
|
1369
|
+
}
|
|
1370
|
+
write_summary(args.results_dir, summary)
|
|
1371
|
+
if live and ledger_path is not None:
|
|
1372
|
+
settle_budget(ledger_path, reservation, total_cost)
|
|
1373
|
+
return 0 if summary["cells_failed"] == 0 else 1
|
|
1374
|
+
except ContractError as exc:
|
|
1375
|
+
sys.stderr.write(f"agent-contract preflight failed: {exc.reason_id}: {exc}\n")
|
|
1376
|
+
return 2
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
if __name__ == "__main__":
|
|
1380
|
+
sys.exit(main())
|