From 7b0abb79847e1039feb27b1fb152ef214180e38f Mon Sep 17 00:00:00 2001 From: Justin Johnson Date: Thu, 15 Oct 2026 11:06:31 -0700 Subject: [PATCH 1/3] feat: visible model assignments and guarded RC publication --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 5 +- .small/handoff.small.yml | 2 +- .small/plan.small.yml | 6 + .small/progress.small.yml | 520 +++++++++++++++++++++++++++ .small/workspace.small.yml | 2 +- CHANGELOG.md | 30 +- README.md | 11 +- SPEC.md | 17 + cmd/loopexec/documentation_test.go | 24 +- cmd/loopexec/main.go | 71 ++-- cmd/loopexec/models.go | 157 ++++++++ cmd/loopexec/models_test.go | 250 +++++++++++++ cmd/loopexec/receipt.go | 14 +- cmd/loopexec/report.go | 50 +-- cmd/loopexec/run.go | 14 +- cmd/loopexec/workflow.go | 29 +- docs/cli-reference.generated.md | 79 +++- docs/cli.md | 3 + docs/documentation-continuity.md | 10 + docs/documentation-files.json | 3 + docs/integrations.md | 5 + docs/model-assignments.md | 164 +++++++++ docs/releases/v0.5.0-dev.md | 19 + docs/releases/v0.5.0-rc.1.md | 34 ++ docs/status-and-roadmap.md | 65 ++-- docs/workflows.md | 5 + examples/model-assignment/main.go | 151 ++++++++ internal/modelpolicy/policy.go | 184 ++++++++++ internal/modelpolicy/policy_test.go | 100 ++++++ internal/workflow/contract.go | 50 +-- scripts/build-release.sh | 4 +- scripts/model-assignment-proof.sh | 40 +++ scripts/publish-release.sh | 16 + tools/docs-continuity/main_test.go | 8 + tools/release-policy/main.go | 40 +++ tools/release-policy/main_test.go | 23 ++ tools/release-policy/publish_test.go | 77 ++++ 38 files changed, 2148 insertions(+), 137 deletions(-) create mode 100644 cmd/loopexec/models.go create mode 100644 cmd/loopexec/models_test.go create mode 100644 docs/model-assignments.md create mode 100644 docs/releases/v0.5.0-dev.md create mode 100644 docs/releases/v0.5.0-rc.1.md create mode 100644 examples/model-assignment/main.go create mode 100644 internal/modelpolicy/policy.go create mode 100644 internal/modelpolicy/policy_test.go create mode 100644 scripts/model-assignment-proof.sh create mode 100644 scripts/publish-release.sh create mode 100644 tools/release-policy/main.go create mode 100644 tools/release-policy/main_test.go create mode 100644 tools/release-policy/publish_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df3abc1..f1f17d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,12 +54,15 @@ jobs: run: | go build -o "$RUNNER_TEMP/loopexec" ./cmd/loopexec go build -o "$RUNNER_TEMP/creative-workflow" ./examples/creative-workflow + go build -o "$RUNNER_TEMP/model-assignment" ./examples/model-assignment - name: Legacy proof run: ./scripts/dogfood-proof.sh "$RUNNER_TEMP/loopexec" .artifacts/dogfood - name: Creative proof run: ./scripts/creative-proof.sh "$RUNNER_TEMP/loopexec" "$RUNNER_TEMP/creative-workflow" .artifacts/creative - name: Scoped budget offline proof run: ./scripts/scoped-budget-proof.sh "$RUNNER_TEMP/loopexec" .artifacts/scoped-budget + - name: Model assignment offline proof + run: bash ./scripts/model-assignment-proof.sh "$RUNNER_TEMP/loopexec" "$RUNNER_TEMP/model-assignment" .artifacts/model-assignment - name: Fetch pinned SMALL release shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d77591..188bbc4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,4 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ github.ref_name }} run: | - gh release create "$RELEASE_TAG" dist/loopexec-*.tar.gz dist/loopexec-*.zip dist/checksums.txt \ - --verify-tag --draft --title "LoopExec $RELEASE_TAG" --notes-file "docs/releases/$RELEASE_TAG.md" - go run ./tools/docs-continuity verify-site dist/documentation/manifest.json - gh release edit "$RELEASE_TAG" --draft=false --latest + bash scripts/publish-release.sh "$RELEASE_TAG" dist diff --git a/.small/handoff.small.yml b/.small/handoff.small.yml index 0bce6e9..1c77704 100644 --- a/.small/handoff.small.yml +++ b/.small/handoff.small.yml @@ -1,6 +1,6 @@ small_version: "1.0.0" owner: agent -summary: 'Prepared canonical documentation correction and enforced audit privacy: full Go tests, vet, privacy checks, strict SMALL and seven package checksums pass. Source bundle includes 22 canonical files; new tracking is portable and historical receipts remain unchanged. Hosted CI adoption, paired docs deployment, official-origin readback and v0.4.1 tag publication remain to finish. SMALL1.1.2 lacks a global pre-recording path guard; a separate upstream correction is needed. No new path-bearing captures, private caches or private handoffs are in this delivery.' +summary: 'Model assignments and prerelease-safe publication locally verified for v0.5.0-rc.1. Full Go suite, vet, internal race tests, offline two-role proof and isolated consumer protocol rejection pass. Matching canonical docs prepared. Next: exact-head hosted checks, paired docs deployment and official readback, main merge, rehearsal and RC publication. Stable remains v0.4.1; no live critic or consumer budget reset.' resume: current_task_id: task-36 next_steps: diff --git a/.small/plan.small.yml b/.small/plan.small.yml index 1e2b9e8..2863e6d 100644 --- a/.small/plan.small.yml +++ b/.small/plan.small.yml @@ -130,3 +130,9 @@ tasks: - id: task-42 title: Enforce portable audit privacy and preserve local-only validation history status: completed + - id: task-43 + title: Implement model profile assignments with offline proof and canonical documentation + status: completed + - id: task-44 + title: Make v0.5.0-rc.1 publication prerelease-safe and verify before tag release + status: completed diff --git a/.small/progress.small.yml b/.small/progress.small.yml index 0d9bd1e..7a9c27c 100644 --- a/.small/progress.small.yml +++ b/.small/progress.small.yml @@ -4795,3 +4795,523 @@ entries: status: in_progress task_id: task-42 timestamp: "2026-10-14T07:15:38.124582000Z" + - evidence: Added task task-43 via small plan --add + notes: Implement model profile assignments with offline proof and canonical documentation + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: pending + task_id: task-43 + timestamp: "2026-10-15T17:39:00.983464000Z" + - evidence: Import bounded model-assignment code, tests, workflow proof and canonical prose without unpublished audit captures. + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:39:01.098988000Z" + - command: go test ./cmd/loopexec -run "TestModel|TestLegacyAttestation|TestDocumentationContract" -count=1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T173904.680215000Z.txt + command_sha256: 8af3a0d2035a28aaba2ff8224de5d6fe9461db7e604de3bada0e8ac26ab109ad + command_summary: go test ./cmd/loopexec -run "TestModel|TestLegacyAttestation|TestDocumentationContract" -count=1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:39:04.680215000Z" + - command: go run ./tools/audit-privacy + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T173905.675307000Z.txt + command_sha256: 0c2bb4fddd6d7917f7457560c3f5639ae0ea6c8f8a3bfa6c74845558d08f177d + command_summary: go run ./tools/audit-privacy + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:39:05.675307000Z" + - command: git diff --check + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T173905.802688000Z.txt + command_sha256: 466c2f308b48c7661d646fdd068fbecea974c665fe65dbf8ed508f224180ce0b + command_summary: git diff --check + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:39:05.802688000Z" + - evidence: Local model-assignment regression, generated contract, publication privacy gate and diff checks pass; no provider calls, push or release. + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: completed + task_id: task-43 + timestamp: "2026-10-15T17:39:05.915730000Z" + - evidence: Run final exact-candidate suite, race/vet, clean offline proof and source-bound development bundle checks; no live consumer work. + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:41:08.638942000Z" + - command: go test ./... -count=1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T174214.057835000Z.txt + command_sha256: 3e27f65b08b7a3dedff7e7545d8b74004f1498345862cd3bf91f9ade7c984a64 + command_summary: go test ./... -count=1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:42:14.057835000Z" + - command: go vet ./... + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T174214.456598000Z.txt + command_sha256: 3d4b424f6fcd11f530525ddea9a46aeaffec7c883b6c04b6baf7df36bf47800b + command_summary: go vet ./... + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:42:14.456598000Z" + - command: go test -race ./internal/... -count=1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T174216.848026000Z.txt + command_sha256: e3fb6a27f5734dbf821638e9bc7884f973e8ecdaa20fc40d59e2a91c9a29b751 + command_summary: go test -race ./internal/... -count=1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:42:16.848026000Z" + - command: go test -race ./cmd/loopexec -run TestScopedBudget -count=1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T174220.786980000Z.txt + command_sha256: 565406bf684e1612befc4c5d7f3d2ba6a42db147601428992df6ed53f78c1f07 + command_summary: go test -race ./cmd/loopexec -run TestScopedBudget -count=1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:42:20.786980000Z" + - command: go build -trimpath -o ../loopexec ./cmd/loopexec + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T174221.583946000Z.txt + command_sha256: 928e892584844577282389a3dad11fce663fbf54b7f0079c44edd83314fc99b5 + command_summary: go build -trimpath -o ../loopexec ./cmd/loopexec + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:42:21.583946000Z" + - command: go build -trimpath -o ../fixture-adapter ./examples/model-assignment + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T174221.919848000Z.txt + command_sha256: 3dcd585d8c1f0a3f96112c23c5815607da5245d873a16a576b484ac478dd9d58 + command_summary: go build -trimpath -o ../fixture-adapter ./examples/model-assignment + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:42:21.919848000Z" + - command: bash scripts/model-assignment-proof.sh ../loopexec ../fixture-adapter ../proof-v2 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T174223.804813000Z.txt + command_sha256: 2f08d04d69b1edb65eacf3d975c5e175b90bb9d4fe37d66175a8e6043b91810c + command_summary: bash scripts/model-assignment-proof.sh ../loopexec ../fixture-adapter ../proof-v2 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:42:23.804813000Z" + - command: go run ./tools/docs-continuity bundle ../documentation-v1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T174224.732676000Z.txt + command_sha256: 7b8c3d6e09bed447a751c68a3cee42dda2a1f4f7d7935198dea0301f6cfa348a + command_summary: go run ./tools/docs-continuity bundle ../documentation-v1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:42:24.732676000Z" + - command: go run ./tools/docs-continuity verify-bundle ../documentation-v1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T174225.167410000Z.txt + command_sha256: 0be1ff01b44abe463f49aa855a2898ef04815ef7f160761a40f58fb4493e996a + command_summary: go run ./tools/docs-continuity verify-bundle ../documentation-v1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:42:25.167410000Z" + - command: go run ./tools/audit-privacy + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T174225.768382000Z.txt + command_sha256: 0c2bb4fddd6d7917f7457560c3f5639ae0ea6c8f8a3bfa6c74845558d08f177d + command_summary: go run ./tools/audit-privacy + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:42:25.768382000Z" + - command: git diff --check + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T174225.879848000Z.txt + command_sha256: 466c2f308b48c7661d646fdd068fbecea974c665fe65dbf8ed508f224180ce0b + command_summary: git diff --check + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-43 + timestamp: "2026-10-15T17:42:25.879848000Z" + - evidence: Exact candidate full suite, vet, internal/scoped-budget race checks, two-role offline dispatch proof, source-bound development docs and audit privacy pass. No provider/engine calls, push, release or consumer changes. + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: completed + task_id: task-43 + timestamp: "2026-10-15T17:42:25.992677000Z" + - evidence: Added task task-44 via small plan --add + notes: Make v0.5.0-rc.1 publication prerelease-safe and verify before tag release + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: pending + task_id: task-44 + timestamp: "2026-10-15T17:54:56.604549000Z" + - evidence: Prepare RC version, prerelease/latest policy tests, local consumer protocol smoke, paired documentation deployment and exact-head hosted checks before publication. + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T17:54:56.757894000Z" + - command: git fetch --prune origin + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T175457.409418000Z.txt + command_sha256: ce8432965e13e517a002c972e54c679c2f3d6cd00ebea98f85f22c52bcba321e + command_summary: git fetch --prune origin + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T17:54:57.409418000Z" + - command: gofmt -w tools/release-policy cmd/loopexec/main.go + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T175610.264113000Z.txt + command_sha256: 8c27296f780182ef478a83cf4d8fb246a6a95bb92837c95c986c8928535f9646 + command_summary: gofmt -w tools/release-policy cmd/loopexec/main.go + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T17:56:10.264113000Z" + - command: go test ./tools/release-policy -count=1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T175611.669799000Z.txt + command_sha256: 8495f29e04f28a65c2667266f2c986b5a8a1b1989240120f5d6f789868191c16 + command_summary: go test ./tools/release-policy -count=1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T17:56:11.669799000Z" + - command: gofmt -w tools/release-policy/publish_test.go + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180055.088514000Z.txt + command_sha256: f241179ba83903ba670f3f5968d61b1aad33753cd97fd4897e2369b7c4bcfe87 + command_summary: gofmt -w tools/release-policy/publish_test.go + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:00:55.088514000Z" + - command: go test ./tools/release-policy -count=1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180058.124140000Z.txt + command_sha256: 8495f29e04f28a65c2667266f2c986b5a8a1b1989240120f5d6f789868191c16 + command_summary: go test ./tools/release-policy -count=1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:00:58.124140000Z" + - command: go test ./cmd/loopexec -run '^TestDocumentationContract$' -args -update-docs + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180120.090374000Z.txt + command_sha256: 7b79cd94ae8364c58fce9855b6e5b90640d2258c9b57b66eb7081db70e7af2b2 + command_summary: go test ./cmd/loopexec -run '^TestDocumentationContract$' -args -update-docs + evidence: + exit_code: 1 + kind: cli_execution + outcome: failed + strength: cli_captured + notes: 'apply: command outcome failed; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:01:20.090374000Z" + - command: GOCACHE="$PWD/../go-cache" go test ./cmd/loopexec -run TestDocumentationContract -args -update-docs + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180139.724777000Z.txt + command_sha256: e4147748b94c64d3a2ef52b5ac688ca508b12ba84f615a23c3b18e37b8df4a84 + command_summary: GOCACHE="$PWD/../go-cache" go test ./cmd/loopexec -run TestDocumentationContract -args -update-docs + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:01:39.724777000Z" + - command: GOCACHE="$PWD/../go-cache" go build -trimpath -o ../loopexec-rc1 ./cmd/loopexec + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180224.961371000Z.txt + command_sha256: 66ddc556e7eeddca88d402fed647498f21bbd6db0e6a3152cefb7be6bd7864bf + command_summary: GOCACHE="$PWD/../go-cache" go build -trimpath -o ../loopexec-rc1 ./cmd/loopexec + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:02:24.961371000Z" + - command: GOCACHE="$PWD/../go-cache" go run ./tools/docs-continuity bundle ../documentation-rc1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180308.454457000Z.txt + command_sha256: 8d17c16f077c2944a7f4f66616f19f7be59145b90b2adcfebc13db6bc4d4a421 + command_summary: GOCACHE="$PWD/../go-cache" go run ./tools/docs-continuity bundle ../documentation-rc1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:03:08.454457000Z" + - command: GOCACHE="$PWD/../go-cache" go test ./... -count=1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180329.484979000Z.txt + command_sha256: e97ee78527eea4c77bbd1ff6fe4faf48dced374edd3735d044f409f557d2bd9a + command_summary: GOCACHE="$PWD/../go-cache" go test ./... -count=1 + evidence: + exit_code: 1 + kind: cli_execution + outcome: failed + strength: cli_captured + notes: 'apply: command outcome failed; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:03:29.484979000Z" + - command: GOCACHE="$PWD/../go-cache" go build -trimpath -o ../fixture-adapter-rc1 ./examples/model-assignment + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180333.529340000Z.txt + command_sha256: b9a37ee4bbfae61ece12ee33ab5146f5a05af24c79e39d02b146e0f6e018571d + command_summary: GOCACHE="$PWD/../go-cache" go build -trimpath -o ../fixture-adapter-rc1 ./examples/model-assignment + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:03:33.529340000Z" + - command: GOCACHE="$PWD/../go-cache" go vet ./... + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180333.813198000Z.txt + command_sha256: 6df11ab2037f803f1e91263f80b739e16a3c8cb54504e0f0f10d268b1f04dbf0 + command_summary: GOCACHE="$PWD/../go-cache" go vet ./... + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:03:33.813198000Z" + - command: bash scripts/model-assignment-proof.sh ../loopexec-rc1 ../fixture-adapter-rc1 ../proof-rc1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180406.743485000Z.txt + command_sha256: e5055ceba67d7d0510d3190d641d73d1d3c602e5ecba53045d40920e87b88d8a + command_summary: bash scripts/model-assignment-proof.sh ../loopexec-rc1 ../fixture-adapter-rc1 ../proof-rc1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:04:06.743485000Z" + - command: GOCACHE="$PWD/../go-cache" go test ./tools/docs-continuity ./tools/release-policy -count=1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180407.104390000Z.txt + command_sha256: 04172055c8b1ca21e87f044695f2ca0296048c73be656aeff6c64acaab57b207 + command_summary: GOCACHE="$PWD/../go-cache" go test ./tools/docs-continuity ./tools/release-policy -count=1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:04:07.104390000Z" + - command: GOCACHE="$PWD/../go-cache" go run ./tools/audit-privacy + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180420.993786000Z.txt + command_sha256: 9119a2bd24a974eaaa9ca391ac3b600fb5ccd5378d0e9142308d7919e9cbcf40 + command_summary: GOCACHE="$PWD/../go-cache" go run ./tools/audit-privacy + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:04:20.993786000Z" + - command: GOCACHE="$PWD/../go-cache" go test -race ./internal/... -count=1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180453.869097000Z.txt + command_sha256: 64e53542c13d7ca93533c9c2bbd2e9f1db234dc82c4902ccead8f99fc16ce34b + command_summary: GOCACHE="$PWD/../go-cache" go test -race ./internal/... -count=1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:04:53.869097000Z" + - command: GOCACHE="$PWD/../go-cache" go run ./tools/docs-continuity bundle ../documentation-rc1-final + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180501.271387000Z.txt + command_sha256: 51a151f4f96da4c113a235100b0fbc1fce3c120f6c356debc981f0ec1f61521c + command_summary: GOCACHE="$PWD/../go-cache" go run ./tools/docs-continuity bundle ../documentation-rc1-final + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:05:01.271387000Z" + - command: GOCACHE="$PWD/../go-cache" go test ./... -count=1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180552.040214000Z.txt + command_sha256: e97ee78527eea4c77bbd1ff6fe4faf48dced374edd3735d044f409f557d2bd9a + command_summary: GOCACHE="$PWD/../go-cache" go test ./... -count=1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:05:52.040214000Z" + - evidence: RC channel tests, full Go suite, vet, internal race tests and offline two-role proof pass. Release script readback-failure test retains draft-only state. Isolated actual consumer protocol smoke passes without provider, engine or real project writes. Matching 25-file bundle prepared; hosted rollout remains pending. + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: completed + task_id: task-44 + timestamp: "2026-10-15T18:06:15.714468000Z" + - command: git add .github/workflows/ci.yml .github/workflows/release.yml CHANGELOG.md README.md SPEC.md cmd/loopexec/documentation_test.go cmd/loopexec/main.go cmd/loopexec/models.go cmd/loopexec/models_test... + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.384840000Z.txt + command_sha256: 904c1f97b9c832f0c8a5a3bc29a1a4d8c7396dcb2b6deb4fb898c223e43dd153 + command_summary: git add .github/workflows/ci.yml .github/workflows/release.yml CHANGELOG.md README.md SPEC.md cmd/loopexec/documentation_test.go cmd/loopexec/main.go cmd/loopexec/models.go cmd/loopexec/models_test... + command_summary_version: 2 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:06:16.384840000Z" + - command: git add docs/cli-reference.generated.md docs/cli.md docs/documentation-continuity.md docs/documentation-files.json docs/integrations.md docs/model-assignments.md docs/releases/v0.5.0-dev.md docs/re... + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.528837000Z.txt + command_sha256: 0a21d6593cecd4de970462181f77b993c2b517632ccd691978108be94e4c0303 + command_summary: git add docs/cli-reference.generated.md docs/cli.md docs/documentation-continuity.md docs/documentation-files.json docs/integrations.md docs/model-assignments.md docs/releases/v0.5.0-dev.md docs/re... + command_summary_version: 2 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:06:16.528837000Z" diff --git a/.small/workspace.small.yml b/.small/workspace.small.yml index eaf1087..d3f4211 100644 --- a/.small/workspace.small.yml +++ b/.small/workspace.small.yml @@ -2,6 +2,6 @@ small_version: "1.0.0" owner: agent kind: repo-root created_at: "2026-01-20T23:56:04Z" -updated_at: "2026-10-14T07:15:38.197771Z" +updated_at: "2026-10-15T18:06:16.608395Z" run: replay_id: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 diff --git a/CHANGELOG.md b/CHANGELOG.md index d99b45a..a2562ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,19 +2,35 @@ ## Unreleased -- Added canonical audit privacy policy and a publication gate rejecting new - absolute host paths in SMALL metadata and command proofs. Historical receipts - remain unchanged; unpublished private captures are excluded from delivery. - Named-home references are rejected along with absolute paths. +Future recipes and workflow adapters will remain configurable, opt-in integrations. + +## v0.5.0-rc.1 + +- Added provider-neutral named model profiles and task/role assignments, a + no-spend `models plan` preview, advisory guidance and explicit argv-bound + dispatch. Required planned/reported identity checks preserve incurred usage + on drift. Selection is visible in state, reports, receipts and attestations. +- Added an offline two-role critic fixture/proof and matching adapter contract. + Source identity is `0.5.0-rc.1`; no automatic escalation, live provider test, + acceptance adjustment or budget renewal is included. + +- Added tested stable/RC publication policy. RC tags publish as prereleases, + never Latest, with the same source-bound deployed-documentation gates. + +See [candidate notes](docs/releases/v0.5.0-rc.1.md). Stable promotion requires +separate consumer acceptance; publication status is on GitHub Releases. + +## v0.4.1 - Corrected architecture to describe the shipped external-check runtime; marked the historical SMALL task-list design as planned. - Reconciled source/publication status, canonical Getting Started and conceptual homepage prose; removed obsolete rollout claims and corrected historical releases. -Future recipes and workflow adapters will remain configurable, opt-in integrations. - -## v0.4.1 — current source +- Added canonical audit privacy policy and a publication gate rejecting new + absolute host paths in SMALL metadata and command proofs. Historical receipts + remain unchanged; unpublished private captures are excluded from delivery. + Named-home references are rejected along with absolute paths. See [release notes](docs/releases/v0.4.1.md). Publication status is authoritative on [GitHub Releases](https://github.com/justyn-clark/loopexec/releases). diff --git a/README.md b/README.md index 339728a..528343c 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ The normative contract is `SPEC.md`. The current source implements bounded execu ## Current status -Source identity: **v0.4.1**. Documentation describes current source; the public -binary version is established by GitHub Releases, not this source badge. This patch adds evidence-complete reports and enforced -code/docs release continuity. See [v0.4.1 notes](docs/releases/v0.4.1.md); the +Source identity: **v0.5.0-rc.1**, a release candidate adding +[visible model profiles and task/role assignments](docs/model-assignments.md). +See [candidate notes](docs/releases/v0.5.0-rc.1.md); the [Releases page](https://github.com/justyn-clark/loopexec/releases) establishes publication status. Workflow thresholds remain supplied by each integration. @@ -63,8 +63,9 @@ See `SPEC.md` section 11: go install github.com/justyn-clark/loopexec/cmd/loopexec@latest ``` -Requires Go 1.26 or newer. The command resolves the latest public stable release. Source identity and -publication are distinct: use a pinned tag only when it is listed as public on GitHub Releases. +Requires Go 1.26 or newer. For the published v0.4.1 install, use `@v0.4.1`. +That release does not contain model assignments. After RC publication, pin +`@v0.5.0-rc.1` to test this feature; `@latest` stays on stable. Prebuilt archives and SHA256 checksums are linked from [GitHub Releases](https://github.com/justyn-clark/loopexec/releases). diff --git a/SPEC.md b/SPEC.md index c20bc5d..76cb973 100644 --- a/SPEC.md +++ b/SPEC.md @@ -276,3 +276,20 @@ adjustments MUST preserve original receipts and desired objectives, with separat decision evidence; they MUST NOT silently reset budgets or authorize new work. See [acceptance policy](docs/acceptance-policy.md). Native cross-run decision history is not implemented; these records currently belong to the adapter/operator. + +## 14. Explicit model assignments + +The optional schema-1 workflow `model_policy` defines named profiles, a selected +task and per-role assignments with declared capability requirements and rationale. +`models plan` MUST perform no work or spending. Advisory mode MUST leave adapter +invocation unchanged. Explicit mode MUST bind every assigned model and nonempty +reasoning value through whole argv arguments, reject conflicting exec overrides, +and require matching pre-call declarations and adapter-reported usage identity. +Valid incurred usage MUST remain recorded even when identity drift halts the run. +Model policy is hashed for resume and included in attestation; legacy records +without it remain compatible. Operator declarations and trusted adapter reports +MUST NOT be represented as independent provider identity/capability attestation. +Assignments MUST NOT lower acceptance targets, renew allowances or create a +second retry controller. Automatic routing and escalation are not implemented. +See [model assignments](docs/model-assignments.md) for the normative adapter +contract, validation boundaries and offline proof. diff --git a/cmd/loopexec/documentation_test.go b/cmd/loopexec/documentation_test.go index b360a32..c3f641c 100644 --- a/cmd/loopexec/documentation_test.go +++ b/cmd/loopexec/documentation_test.go @@ -69,7 +69,7 @@ func documentationContract(root *cobra.Command) []byte { case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map: enqueue(typ.Elem()) case reflect.Struct: - if typ.PkgPath() == reflect.TypeOf(response{}).PkgPath() { + if strings.HasPrefix(typ.PkgPath(), "github.com/justyn-clark/loopexec/") { queue = append(queue, typ) } } @@ -82,15 +82,23 @@ func documentationContract(root *cobra.Command) []byte { } seen[typ] = true fmt.Fprintf(&out, "\n### `%s`\n\n", typ.Name()) - for i := 0; i < typ.NumField(); i++ { - field := typ.Field(i) - tag := field.Tag.Get("json") - if field.PkgPath != "" || tag == "-" { - continue + var fields func(reflect.Type) + fields = func(typ reflect.Type) { + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + tag := field.Tag.Get("json") + if field.Anonymous && tag == "" && field.Type.Kind() == reflect.Struct { + fields(field.Type) + continue + } + if field.PkgPath != "" || tag == "-" { + continue + } + fmt.Fprintf(&out, "- `%s`: `%s`\n", tag, field.Type) + enqueue(field.Type) } - fmt.Fprintf(&out, "- `%s`: `%s`\n", tag, field.Type) - enqueue(field.Type) } + fields(typ) } return out.Bytes() } diff --git a/cmd/loopexec/main.go b/cmd/loopexec/main.go index 676de8a..f2c2817 100644 --- a/cmd/loopexec/main.go +++ b/cmd/loopexec/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/justyn-clark/loopexec/internal/modelpolicy" "github.com/justyn-clark/loopexec/internal/subprocess" "github.com/justyn-clark/loopexec/internal/workflow" "os" @@ -16,7 +17,7 @@ import ( const ( toolName = "loopexec" - toolVersion = "0.4.1" + toolVersion = "0.5.0-rc.1" ) // Exit-code classes are the coarse CI-branch buckets defined in SPEC.md section 5. @@ -99,16 +100,17 @@ func (e *cliError) Unwrap() error { } type response struct { - FailureCause string `json:"failure_cause,omitempty"` - BestCandidate string `json:"best_candidate,omitempty"` - Tool string `json:"tool"` - Version string `json:"version"` - Status string `json:"status"` - RunID string `json:"run_id,omitempty"` - Iteration int `json:"iteration,omitempty"` - HaltReason string `json:"halt_reason,omitempty"` - CheckExit *int `json:"check_exit,omitempty"` - Receipt string `json:"receipt,omitempty"` + ModelSelection *modelpolicy.Selection `json:"model_selection,omitempty"` + FailureCause string `json:"failure_cause,omitempty"` + BestCandidate string `json:"best_candidate,omitempty"` + Tool string `json:"tool"` + Version string `json:"version"` + Status string `json:"status"` + RunID string `json:"run_id,omitempty"` + Iteration int `json:"iteration,omitempty"` + HaltReason string `json:"halt_reason,omitempty"` + CheckExit *int `json:"check_exit,omitempty"` + Receipt string `json:"receipt,omitempty"` Probe *probeReport `json:"probe,omitempty"` Doctor *doctorReport `json:"doctor,omitempty"` @@ -234,16 +236,17 @@ func printResponse(cmd *cobra.Command, r response) error { // failure text containing quotes, backslashes, or newlines can never corrupt // the receipt (SPEC.md section 8). type receiptEvent struct { - OutputTruncated bool `json:"output_truncated,omitempty"` - Phase string `json:"phase,omitempty"` - DurationMS int64 `json:"duration_ms"` - Cause string `json:"cause,omitempty"` - TS int64 `json:"ts"` - RunID string `json:"run_id"` - Iteration int `json:"iteration"` - Event string `json:"event"` - Detail string `json:"detail,omitempty"` - ExitCode *int `json:"exit_code,omitempty"` + ModelSelection *modelpolicy.Selection `json:"model_selection,omitempty"` + OutputTruncated bool `json:"output_truncated,omitempty"` + Phase string `json:"phase,omitempty"` + DurationMS int64 `json:"duration_ms"` + Cause string `json:"cause,omitempty"` + TS int64 `json:"ts"` + RunID string `json:"run_id"` + Iteration int `json:"iteration"` + Event string `json:"event"` + Detail string `json:"detail,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` } // Receipt-pin types (SPEC.md section 8): everything that determines the output, @@ -283,17 +286,18 @@ type escalationState struct { // loopState is the durable, resumable machine state (SPEC.md section 8). Slice 0 holds // the subset needed to record a run; later slices add the audit fields. type loopState struct { - Workflow *workflow.Config `json:"workflow,omitempty"` - WorkflowHash string `json:"workflow_hash,omitempty"` - RunPolicyHash string `json:"run_policy_hash,omitempty"` - Budget *budgetBook `json:"budget,omitempty"` - Numeric *numericState `json:"numeric,omitempty"` - Candidate *candidateState `json:"candidate,omitempty"` - BestCandidatePath string `json:"best_candidate_path,omitempty"` - IntegrityBaseline []string `json:"integrity_baseline,omitempty"` - DeadlineNS int64 `json:"deadline_ns,omitempty"` - CommandTimeoutNS int64 `json:"command_timeout_ns,omitempty"` - GraceNS int64 `json:"grace_ns,omitempty"` + ModelSelection *modelpolicy.Selection `json:"model_selection,omitempty"` + Workflow *workflow.Config `json:"workflow,omitempty"` + WorkflowHash string `json:"workflow_hash,omitempty"` + RunPolicyHash string `json:"run_policy_hash,omitempty"` + Budget *budgetBook `json:"budget,omitempty"` + Numeric *numericState `json:"numeric,omitempty"` + Candidate *candidateState `json:"candidate,omitempty"` + BestCandidatePath string `json:"best_candidate_path,omitempty"` + IntegrityBaseline []string `json:"integrity_baseline,omitempty"` + DeadlineNS int64 `json:"deadline_ns,omitempty"` + CommandTimeoutNS int64 `json:"command_timeout_ns,omitempty"` + GraceNS int64 `json:"grace_ns,omitempty"` FailureCause string `json:"failure_cause,omitempty"` SchemaVersion int `json:"schema_version"` @@ -493,7 +497,7 @@ func newRunCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&cfg.workflowFile, "workflow", "", "Pinned workflow JSON (argv hooks, metering, candidates, numeric policy)") + cmd.Flags().StringVar(&cfg.workflowFile, "workflow", "", "Pinned workflow JSON (argv hooks, metering, candidates, numeric policy, optional model assignments)") cmd.Flags().BoolVar(&cfg.resume, "resume", false, "Resume a workflow run with identical policy and cumulative bounds") cmd.Flags().DurationVar(&cfg.totalTimeout, "timeout", 0, "Total run deadline (0 = disabled)") cmd.Flags().DurationVar(&cfg.commandTimeout, "command-timeout", 0, "Deadline for every command and collector (0 = disabled)") @@ -626,6 +630,7 @@ func newRootCmd() *cobra.Command { cmd.AddCommand(newRunCmd()) cmd.AddCommand(newStatusCmd()) cmd.AddCommand(newScopedBudgetCmd()) + cmd.AddCommand(newModelsCmd()) cmd.AddCommand(newCheckCmd()) cmd.AddCommand(newStepCmd()) cmd.AddCommand(newProbeCheckCmd()) diff --git a/cmd/loopexec/models.go b/cmd/loopexec/models.go new file mode 100644 index 0000000..7eb6b1f --- /dev/null +++ b/cmd/loopexec/models.go @@ -0,0 +1,157 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "path/filepath" + "sort" + "strings" + + "github.com/justyn-clark/loopexec/internal/modelpolicy" + "github.com/justyn-clark/loopexec/internal/workflow" + "github.com/spf13/cobra" +) + +func newModelsCmd() *cobra.Command { + root := &cobra.Command{Use: "models", Short: "Inspect configured task-to-model assignments without invoking models"} + var file, workdir string + plan := &cobra.Command{Use: "plan", Short: "Explain the selected workflow task, role profiles and dispatch assurance; spends nothing", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { + var config workflow.Config + if file == "" { + return failResponse(cmd, "", 30, "workspace_invalid", "models plan requires --workflow") + } + path := file + if !filepath.IsAbs(path) { + path = filepath.Join(workdir, path) + } + if workflow.Read(path, &config) != nil || config.SchemaVersion != 1 || config.Models == nil { + return failResponse(cmd, "", 30, "workspace_invalid", "workflow model policy missing or unreadable") + } + selection, err := modelpolicy.Resolve(config.Models) + if err != nil { + return failResponse(cmd, "", 30, "workspace_invalid", err.Error()) + } + if selection.Mode == "explicit" && (len(config.Exec) != 0 || len(config.Meter.Reserve) == 0) { + return failResponse(cmd, "", 30, "workspace_invalid", "explicit model policy requires a reservation hook and no exec override") + } + if jsonOutput { + return printResponse(cmd, response{Tool: toolName, Version: toolVersion, Status: "planned", ModelSelection: selection, Errors: []string{}}) + } + renderModelSelection(cmd.OutOrStdout(), selection) + fmt.Fprintln(cmd.OutOrStdout(), "No commands executed. Capabilities are operator declarations; model identity is not provider-verified.") + return nil + }} + plan.Flags().StringVar(&file, "workflow", "", "Workflow JSON containing model_policy (required)") + plan.Flags().StringVar(&workdir, "workdir", ".", "Directory used to resolve the workflow path") + root.AddCommand(plan) + return root +} + +func renderModelSelection(out io.Writer, s *modelpolicy.Selection) { + if s == nil { + return + } + fmt.Fprintf(out, "model task: %s (%s; %s)\n", s.Task, s.Mode, s.Assurance) + for _, role := range s.Roles { + fmt.Fprintf(out, " %s: profile=%s model=%s/%s", role.Role, role.Profile, role.Provider, role.ModelID) + if role.Version != "" { + fmt.Fprintf(out, " version=%s", role.Version) + } + if role.ReasoningEffort != "" { + fmt.Fprintf(out, " reasoning=%s", role.ReasoningEffort) + } + fmt.Fprintf(out, "\n reason: %s\n", role.Reason) + if len(role.Requires) > 0 { + fmt.Fprintf(out, " requires (declared): %s\n", strings.Join(role.Requires, ", ")) + } + } +} + +func renderReportedModels(out io.Writer, book *budgetBook) { + counts := map[string]int{} + if book != nil { + for _, call := range book.Entries { + if call.Model != nil && call.Status != "skipped" { + m := call.Model + label := fmt.Sprintf("%s: %s/%s", call.Phase, m.Provider, m.ModelID) + if m.ReasoningEffort != "" { + label += " reasoning=" + m.ReasoningEffort + } + if m.Version != "" { + label += " version=" + m.Version + } + counts[label]++ + } + } + } + fmt.Fprintln(out, "adapter-reported identities (not provider-verified):") + if len(counts) == 0 { + fmt.Fprintln(out, " none recorded") + } + keys := make([]string, 0, len(counts)) + for key := range counts { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + unit := "calls" + if counts[key] == 1 { + unit = "call" + } + fmt.Fprintf(out, " %s (%d %s)\n", key, counts[key], unit) + } +} + +func (w *receiptWriter) modelSelection(iteration int, selection *modelpolicy.Selection) { + if w == nil || w.f == nil || selection == nil { + return + } + line, err := json.Marshal(receiptEvent{TS: nowFunc().Unix(), RunID: w.runID, Iteration: iteration, Event: "model_selection", ModelSelection: selection}) + if err == nil { + _, err = w.f.Write(append(line, '\n')) + } + if err != nil { + w.err = err + } +} + +// The trusted adapter must declare model identities before dispatch and report +// them after work. This is not independent provider attestation. +func modelReservations(s *modelpolicy.Selection, calls []workflow.Reservation) bool { + if s == nil || s.Mode != "explicit" { + return true + } + seen := map[string]bool{} + for _, call := range calls { + role := s.Find(call.Phase) + if role == nil { + if call.Model != nil { + return false + } + continue + } + if !role.Matches(call.Model) { + return false + } + seen[call.Phase] = true + } + return len(seen) == len(s.Roles) +} + +func modelUsage(s *modelpolicy.Selection, calls []workflow.Call) bool { + if s == nil || s.Mode != "explicit" { + return true + } + for _, call := range calls { + role := s.Find(call.Phase) + if role == nil { + if call.Model != nil { + return false + } + } else if !(call.Status == "skipped" && call.Model == nil) && !role.Matches(call.Model) { + return false + } + } + return true +} diff --git a/cmd/loopexec/models_test.go b/cmd/loopexec/models_test.go new file mode 100644 index 0000000..cb3dce2 --- /dev/null +++ b/cmd/loopexec/models_test.go @@ -0,0 +1,250 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/justyn-clark/loopexec/internal/modelpolicy" + "github.com/justyn-clark/loopexec/internal/workflow" + "github.com/spf13/cobra" +) + +func TestModelHookHelper(t *testing.T) { + marker := -1 + for i, arg := range os.Args { + if arg == "model-helper" { + marker = i + break + } + } + if marker < 0 { + return + } + phase, mode := os.Args[marker+1], os.Args[marker+2] + var req workflow.Request + if workflow.Read(os.Args[len(os.Args)-1], &req) != nil || req.Models == nil { + os.Exit(2) + } + identity := req.Models.Find("critic").Identity + write := func(v any) { json.NewEncoder(os.Stdout).Encode(v) } + id := workflow.CallID(req.RunID, req.Iteration, req.Phase, "critic") + switch phase { + case "reserve": + if mode == "reserve-drift" { + identity.ModelID = "unapproved" + } + write(workflow.Preflight{SchemaVersion: 1, RunID: req.RunID, Iteration: req.Iteration, Phase: req.Phase, Enforced: true, Calls: []workflow.Reservation{{ID: id, Phase: "critic", Model: &identity, MaxMicroUSD: 100000, MaxTokens: 10}}}) + case "exec": + identity.ModelID, identity.ReasoningEffort = os.Args[marker+3], os.Args[marker+4] + if workflow.Atomic("execution.json", identity) != nil { + os.Exit(2) + } + case "usage": + if workflow.Read("execution.json", &identity) != nil { + os.Exit(2) + } + if mode == "usage-drift" { + identity.ReasoningEffort = "low" + } + write(workflow.Usage{SchemaVersion: 1, RunID: req.RunID, Iteration: req.Iteration, Phase: req.Phase, Calls: []workflow.Call{{ID: id, Phase: "critic", Model: &identity, Status: "actual", MicroUSD: money(100000), Tokens: money(10)}}}) + case "check": + if mode == "reject" { + os.Exit(1) + } + os.Stdout.WriteString("stable\n") + case "score": + v := 5.0 + write(workflow.Score{SchemaVersion: 1, RunID: req.RunID, Iteration: req.Iteration, CandidateID: req.CandidateID, Technical: true, Reviewed: true, Value: &v}) + } + os.Exit(0) +} + +func modelFixture(t *testing.T, mode string) (runConfig, *workflow.Config, func(string) []string) { + t.Helper() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + hook := func(phase string) []string { + return []string{self, "-test.run=^TestModelHookHelper$", "model-helper", phase, mode} + } + command := append(hook("exec"), "{critic.model}", "{critic.reasoning}") + policy := &modelpolicy.Policy{Mode: "explicit", Task: "hardware-review", Profiles: map[string]modelpolicy.Profile{ + "visual": {Identity: modelpolicy.Identity{Provider: "offline", ModelID: "fixture-vision", ReasoningEffort: "high"}, Capabilities: []string{"vision"}}, + }, Tasks: map[string]modelpolicy.Task{"hardware-review": {Roles: map[string]modelpolicy.Assignment{"critic": {Profile: "visual", Reason: "Compare saved equipment captures with reference images", Requires: []string{"vision"}}}, Command: command}}} + w := &workflow.Config{SchemaVersion: 1, Models: policy, Check: hook("check"), Meter: workflow.MeterPolicy{Mode: "strict", Reserve: hook("reserve"), Usage: hook("usage"), MaxCalls: 1, MaxTokens: 10}} + cfg := runConfig{runID: "models", workdir: t.TempDir(), workflowFile: "workflow.json", budgetUSD: .5, maxIterations: 1, commandTimeout: time.Second, grace: 10 * time.Millisecond} + writeModelWorkflow(t, cfg, w) + return cfg, w, hook +} + +func writeModelWorkflow(t *testing.T, cfg runConfig, w *workflow.Config) { + t.Helper() + if err := workflow.Atomic(filepath.Join(cfg.workdir, cfg.workflowFile), w); err != nil { + t.Fatal(err) + } +} + +func TestModelExplicitDispatchAndReceipt(t *testing.T) { + cfg, _, _ := modelFixture(t, "normal") + code, st := runForTest(t, cfg) + if code != 10 || st.ModelSelection == nil || st.Budget.Calls != 1 { + t.Fatalf("%d %+v", code, st) + } + var called modelpolicy.Identity + if err := workflow.Read(filepath.Join(cfg.workdir, "execution.json"), &called); err != nil || called.ModelID != "fixture-vision" || called.ReasoningEffort != "high" { + t.Fatalf("actual argv not selected: %+v %v", called, err) + } + events, _, status := readReceiptEvents(filepath.Join(cfg.workdir, ".loopexec/run-models.jsonl"), cfg.runID) + if status != "readable" { + t.Fatal(status) + } + found := false + for _, event := range events { + found = found || (event.Event == "model_selection" && event.ModelSelection.Find("critic").Profile == "visual") + } + if !found { + t.Fatal("typed selection evidence missing") + } + cmd := newRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"report", "--workdir", cfg.workdir, "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + var result response + if json.Unmarshal(out.Bytes(), &result) != nil || result.Report.ModelSelection.Find("critic").ModelID != called.ModelID || result.Report.Budget.Entries[workflow.CallID(cfg.runID, 1, "exec", "critic")].Model == nil { + t.Fatal(out.String()) + } + key := []byte("fixture-signing-key") + sig, _ := signReceipt(st, key) + st.ModelSelection.Roles[0].ReasoningEffort = "low" + changed, _ := signReceipt(st, key) + if sig == changed { + t.Fatal("selection is not covered by attestation") + } +} + +func TestModelDriftAndBudgetGuards(t *testing.T) { + for _, mode := range []string{"reserve-drift", "usage-drift", "budget", "reject", "numeric"} { + t.Run(mode, func(t *testing.T) { + cfg, w, hook := modelFixture(t, mode) + if mode == "budget" { + cfg.budgetUSD = .05 + } + if mode == "numeric" { + w.Score = &workflow.ScorePolicy{Command: hook("score"), Direction: "maximize", Min: 0, Max: 10, Target: 9, MinImprovement: .1, Tolerance: .01, Patience: 2} + writeModelWorkflow(t, cfg, w) + } + code, st := runForTest(t, cfg) + if code == 10 { + t.Fatal("model policy bypassed existing acceptance guard") + } + _, executed := os.Stat(filepath.Join(cfg.workdir, "execution.json")) + if mode == "reserve-drift" || mode == "budget" { + if !os.IsNotExist(executed) || (mode == "budget" && code != 18) || (mode == "reserve-drift" && code != 19) { + t.Fatalf("work launched before approval: %d %v", code, executed) + } + } else if mode == "usage-drift" && (code != 19 || st.Budget.MicroUSD != 100000 || st.Budget.Calls != 1) { + t.Fatalf("mismatch lost incurred usage: %d %+v", code, st.Budget) + } + if mode == "numeric" && (code != 12 || st.Numeric == nil || st.Numeric.Best == nil || *st.Numeric.Best != 5) { + t.Fatalf("numeric acceptance changed: %d %+v", code, st.Numeric) + } + if mode == "reject" && (code != 12 || st.LastCheckExit == nil || *st.LastCheckExit != 1) { + t.Fatalf("red check bypassed: %d %+v", code, st) + } + }) + } +} + +func TestModelGuidanceDoesNotSelectAndResumePinsPolicy(t *testing.T) { + cfg, w, hook := modelFixture(t, "normal") + w.Models.Mode = "advisory" + w.Exec = append(hook("exec"), "adapter-choice", "low") + writeModelWorkflow(t, cfg, w) + code, st := runForTest(t, cfg) + if code != 10 || st.ModelSelection.Assurance != "guidance_only" { + t.Fatalf("%d %+v", code, st) + } + var actual modelpolicy.Identity + if workflow.Read(filepath.Join(cfg.workdir, "execution.json"), &actual) != nil || actual.ModelID != "adapter-choice" { + t.Fatal("advice changed adapter dispatch") + } + cfg.resume = true + statePath := filepath.Join(cfg.workdir, ".loopexec", perRunStateName(cfg.runID)) + before, _ := os.ReadFile(statePath) + profile := w.Models.Profiles["visual"] + profile.ReasoningEffort = "low" + w.Models.Profiles["visual"] = profile + writeModelWorkflow(t, cfg, w) + cmd := &cobra.Command{} + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + if code := exitCode(executeRun(cmd, cfg)); code != 30 { + t.Fatalf("changed assignment resumed: %d", code) + } + after, _ := os.ReadFile(statePath) + if !bytes.Equal(before, after) { + t.Fatal("rejected resume changed saved state or allowance") + } +} + +func TestModelsPlanIsReadOnlyAndPreflightRejectsAmbiguity(t *testing.T) { + cfg, w, _ := modelFixture(t, "normal") + cmd := newRootCmd() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"models", "plan", "--workflow", cfg.workflowFile, "--workdir", cfg.workdir, "--json"}) + if err := cmd.Execute(); err != nil || !strings.Contains(out.String(), "adapter_report_required") { + t.Fatalf("%v %s", err, out.String()) + } + if _, err := os.Stat(filepath.Join(cfg.workdir, ".loopexec")); !os.IsNotExist(err) { + t.Fatal("planning initialized a run or invoked a hook") + } + w.Exec = []string{"unexpected"} + writeModelWorkflow(t, cfg, w) + if _, _, err := loadWorkflow(cfg); err == nil { + t.Fatal("exec override accepted") + } + w.Exec = nil + w.Meter.Reserve = nil + writeModelWorkflow(t, cfg, w) + if _, _, err := loadWorkflow(cfg); err == nil { + t.Fatal("explicit assignment accepted without pre-call declaration") + } +} + +func TestLegacyAttestationOmitsNewSelection(t *testing.T) { + b, err := canonicalReceipt(loopState{RunID: "legacy"}) + if err != nil || bytes.Contains(b, []byte("model_selection")) { + t.Fatalf("legacy signature payload changed: %s %v", b, err) + } +} + +func TestModelRoleEvidenceIsCompleteAndVersionAware(t *testing.T) { + s := &modelpolicy.Selection{Mode: "explicit", Roles: []modelpolicy.Role{ + {Role: "builder", Identity: modelpolicy.Identity{Provider: "offline", ModelID: "small"}}, + {Role: "critic", Identity: modelpolicy.Identity{Provider: "offline", ModelID: "vision", Version: "r1", ReasoningEffort: "high"}}, + }} + builder, critic := s.Roles[0].Identity, s.Roles[1].Identity + calls := []workflow.Reservation{{Phase: "builder", Model: &builder}, {Phase: "critic", Model: &critic}, {Phase: "local-tool"}} + if !modelReservations(s, calls) || modelReservations(s, calls[:1]) { + t.Fatal("planned roles incomplete or local tool rejected") + } + critic.Version = "r2" + if modelReservations(s, calls) { + t.Fatal("version mismatch accepted") + } + if !modelUsage(s, []workflow.Call{{Phase: "critic", Status: "skipped"}}) || modelUsage(s, []workflow.Call{{Phase: "critic", Status: "actual"}}) || modelUsage(s, []workflow.Call{{Phase: "undeclared", Model: &builder}}) { + t.Fatal("missing/undeclared model identity accepted or skipped phase rejected") + } +} diff --git a/cmd/loopexec/receipt.go b/cmd/loopexec/receipt.go index 65c3b02..8813fa1 100644 --- a/cmd/loopexec/receipt.go +++ b/cmd/loopexec/receipt.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "github.com/justyn-clark/loopexec/internal/modelpolicy" "github.com/justyn-clark/loopexec/internal/subprocess" "github.com/justyn-clark/loopexec/internal/workflow" "os" @@ -55,11 +56,12 @@ func buildManifest(workdir string, files []string) []manifestEntry { // reconstructed for verification: only the pinned, output-determining fields. func canonicalReceipt(st loopState) ([]byte, error) { type rcpt struct { - Workflow *workflow.Config `json:"workflow,omitempty"` - WorkflowHash string `json:"workflow_hash,omitempty"` - Budget *budgetBook `json:"budget,omitempty"` - Numeric *numericState `json:"numeric,omitempty"` - Candidate *candidateState `json:"candidate,omitempty"` + Workflow *workflow.Config `json:"workflow,omitempty"` + WorkflowHash string `json:"workflow_hash,omitempty"` + ModelSelection *modelpolicy.Selection `json:"model_selection,omitempty"` + Budget *budgetBook `json:"budget,omitempty"` + Numeric *numericState `json:"numeric,omitempty"` + Candidate *candidateState `json:"candidate,omitempty"` RunID string `json:"run_id"` HaltReason string `json:"halt_reason"` @@ -71,7 +73,7 @@ func canonicalReceipt(st loopState) ([]byte, error) { CostUSD float64 `json:"cost_usd"` Fingerprint *checkFingerprint `json:"fingerprint,omitempty"` } - return json.Marshal(rcpt{Workflow: st.Workflow, WorkflowHash: st.WorkflowHash, Budget: st.Budget, Numeric: st.Numeric, Candidate: st.Candidate, + return json.Marshal(rcpt{Workflow: st.Workflow, WorkflowHash: st.WorkflowHash, ModelSelection: st.ModelSelection, Budget: st.Budget, Numeric: st.Numeric, Candidate: st.Candidate, RunID: st.RunID, HaltReason: st.HaltReason, Iteration: st.Iteration, diff --git a/cmd/loopexec/report.go b/cmd/loopexec/report.go index 9c6522e..46cb1f4 100644 --- a/cmd/loopexec/report.go +++ b/cmd/loopexec/report.go @@ -9,6 +9,8 @@ import ( "path/filepath" "strings" + "github.com/justyn-clark/loopexec/internal/modelpolicy" + "github.com/spf13/cobra" ) @@ -16,27 +18,28 @@ import ( // nothing (unlike replay / reexecute): it reads the durable state plus the // typed JSONL event log and presents them. It is the read-only audit view. type reportSummary struct { - Budget *budgetBook `json:"budget,omitempty"` - Numeric *numericState `json:"numeric,omitempty"` - Candidate *candidateState `json:"candidate,omitempty"` - BestCandidate string `json:"best_candidate,omitempty"` - Phase string `json:"phase"` - ExitCode int `json:"exit_code"` - Iterations int `json:"iterations"` - Check string `json:"check,omitempty"` - Workdir string `json:"workdir,omitempty"` - CostUSD float64 `json:"cost_usd,omitempty"` - Model *modelPin `json:"model,omitempty"` - Sampling *samplingPin `json:"sampling,omitempty"` - ContextFiles int `json:"context_files,omitempty"` - Fingerprint *checkFingerprint `json:"fingerprint,omitempty"` - Events int `json:"events"` - Receipt string `json:"receipt,omitempty"` - ReceiptStatus string `json:"receipt_status"` - Warnings []string `json:"warnings"` - Timeline []receiptEvent `json:"timeline"` - ScoreTarget *float64 `json:"score_target,omitempty"` - AttestationStatus string `json:"attestation_status"` + ModelSelection *modelpolicy.Selection `json:"model_selection,omitempty"` + Budget *budgetBook `json:"budget,omitempty"` + Numeric *numericState `json:"numeric,omitempty"` + Candidate *candidateState `json:"candidate,omitempty"` + BestCandidate string `json:"best_candidate,omitempty"` + Phase string `json:"phase"` + ExitCode int `json:"exit_code"` + Iterations int `json:"iterations"` + Check string `json:"check,omitempty"` + Workdir string `json:"workdir,omitempty"` + CostUSD float64 `json:"cost_usd,omitempty"` + Model *modelPin `json:"model,omitempty"` + Sampling *samplingPin `json:"sampling,omitempty"` + ContextFiles int `json:"context_files,omitempty"` + Fingerprint *checkFingerprint `json:"fingerprint,omitempty"` + Events int `json:"events"` + Receipt string `json:"receipt,omitempty"` + ReceiptStatus string `json:"receipt_status"` + Warnings []string `json:"warnings"` + Timeline []receiptEvent `json:"timeline"` + ScoreTarget *float64 `json:"score_target,omitempty"` + AttestationStatus string `json:"attestation_status"` // Attested preserves the legacy signature-file-presence flag, not verification. Attested bool `json:"attested"` } @@ -132,6 +135,7 @@ func newReportCmd() *cobra.Command { } sum := &reportSummary{Budget: st.Budget, Numeric: st.Numeric, Candidate: st.Candidate, BestCandidate: st.BestCandidatePath, + ModelSelection: st.ModelSelection, Phase: st.Phase, ExitCode: haltExitCode(st.HaltReason), Iterations: st.Iteration, @@ -194,6 +198,10 @@ func renderReport(cmd *cobra.Command, st loopState, sum *reportSummary, events [ fmt.Fprintf(w, "failure cause: %s\n", st.FailureCause) } fmt.Fprintf(w, "iterations: %d\n", st.Iteration) + renderModelSelection(w, st.ModelSelection) + if st.ModelSelection != nil { + renderReportedModels(w, st.Budget) + } if st.Check != "" { fmt.Fprintf(w, "check: %s\n", st.Check) } diff --git a/cmd/loopexec/run.go b/cmd/loopexec/run.go index d6045e6..0f1acb2 100644 --- a/cmd/loopexec/run.go +++ b/cmd/loopexec/run.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "github.com/justyn-clark/loopexec/internal/modelpolicy" "github.com/justyn-clark/loopexec/internal/workflow" "github.com/spf13/cobra" "os" @@ -118,6 +119,9 @@ func executeRun(cmd *cobra.Command, cfg runConfig) error { st.Sampling = &samplingPin{Temperature: cfg.temperature, Seed: cfg.seed, MaxTokens: cfg.maxTokens} } st.ContextManifest = buildManifest(cfg.workdir, cfg.contextFiles) + if w != nil { + st.ModelSelection, _ = modelpolicy.Resolve(w.Models) + } var ext *workflowRuntime var candidate *candidateManager var tracker *progressTracker @@ -170,7 +174,7 @@ func executeRun(cmd *cobra.Command, cfg runConfig) error { fail("cost_anomaly", "saved_usage_invalid") } else { cap, _ := microUSD(cfg.budgetUSD) - ext = &workflowRuntime{config: w, hash: hash, dir: owned, workdir: cfg.workdir, book: book, cap: cap, runner: runner} + ext = &workflowRuntime{models: st.ModelSelection, config: w, hash: hash, dir: owned, workdir: cfg.workdir, book: book, cap: cap, runner: runner} if book.Pending != nil && halt == "" { if !cfg.resume { fail("cost_anomaly", "unresolved_reservation") @@ -241,6 +245,10 @@ func executeRun(cmd *cobra.Command, cfg runConfig) error { st.Fingerprint = nil } rw.emit(i, "iter_start", "", nil) + rw.modelSelection(i, st.ModelSelection) + if !jsonOutput { + renderModelSelection(cmd.ErrOrStderr(), st.ModelSelection) + } writeHeartbeat(dir, i, "iter_start") if candidate != nil { if e = candidate.intact(cfg.workdir); e != nil { @@ -256,7 +264,7 @@ func executeRun(cmd *cobra.Command, cfg runConfig) error { fail("internal_error", "state_write_failed") break } - if cfg.execCmd != "" || (w != nil && len(w.Exec) > 0) { + if cfg.execCmd != "" || (w != nil && len(w.Exec) > 0) || (st.ModelSelection != nil && st.ModelSelection.Mode == "explicit") { var rc int if ext != nil { v, r, c := ext.paid(i, "exec", cfg.execCmd, w.Exec) @@ -471,7 +479,7 @@ func executeRun(cmd *cobra.Command, cfg runConfig) error { if e = f.Sync(); e != nil { return failResponse(cmd, cfg.runID, 50, "internal_error", "cannot flush receipt") } - r := response{Tool: toolName, Version: toolVersion, Status: "halted", RunID: cfg.runID, Iteration: st.Iteration, HaltReason: halt, CheckExit: st.LastCheckExit, Receipt: receiptPath, Errors: []string{}, FailureCause: st.FailureCause, BestCandidate: st.BestCandidatePath} + r := response{ModelSelection: st.ModelSelection, Tool: toolName, Version: toolVersion, Status: "halted", RunID: cfg.runID, Iteration: st.Iteration, HaltReason: halt, CheckExit: st.LastCheckExit, Receipt: receiptPath, Errors: []string{}, FailureCause: st.FailureCause, BestCandidate: st.BestCandidatePath} if halt == "execution_failure" { r.Status = "error" r.Errors = []string{"execution failure: " + st.FailureCause} diff --git a/cmd/loopexec/workflow.go b/cmd/loopexec/workflow.go index 943c308..c3246c0 100644 --- a/cmd/loopexec/workflow.go +++ b/cmd/loopexec/workflow.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "github.com/justyn-clark/loopexec/internal/modelpolicy" "github.com/justyn-clark/loopexec/internal/subprocess" "github.com/justyn-clark/loopexec/internal/workflow" "os" @@ -10,6 +11,7 @@ import ( ) type workflowRuntime struct { + models *modelpolicy.Selection config *workflow.Config hash, dir, workdir string book *budgetBook @@ -38,6 +40,18 @@ func loadWorkflow(cfg runConfig) (*workflow.Config, string, error) { if w.SchemaVersion != 1 { return nil, "", fmt.Errorf("unsupported workflow schema") } + selection, err := modelpolicy.Resolve(w.Models) + if err != nil { + return nil, "", err + } + if selection != nil { + if cfg.modelID != "" || cfg.modelProvider != "" || cfg.modelVersion != "" { + return nil, "", fmt.Errorf("model_policy cannot be combined with legacy model identity flags") + } + if selection.Mode == "explicit" && (len(w.Exec) != 0 || cfg.execCmd != "" || len(w.Meter.Reserve) == 0) { + return nil, "", fmt.Errorf("explicit model policy requires a reservation hook and task command, with no other exec override") + } + } if len(w.Check) == 0 { return nil, "", fmt.Errorf("workflow requires an argv check") } @@ -87,7 +101,7 @@ func (r commandRunner) argv(iter int, phase string, argv []string) subprocess.Re return v } func (w *workflowRuntime) request(iter int, phase, id string) workflow.Request { - return workflow.Request{SchemaVersion: 1, RunID: w.runner.receipt.runID, Iteration: iter, Phase: phase, ConfigHash: w.hash, CandidateID: id} + return workflow.Request{Models: w.models, SchemaVersion: 1, RunID: w.runner.receipt.runID, Iteration: iter, Phase: phase, ConfigHash: w.hash, CandidateID: id} } func (w *workflowRuntime) hook(req workflow.Request, label string, argv []string) subprocess.Result { if w.runner.ctx.Err() != nil { @@ -119,10 +133,20 @@ func (w *workflowRuntime) reconcile(req workflow.Request) (string, string) { if e := w.saveBudget(); e != nil { return "internal_error", "budget_persist_failed" } + if reason == "" && !modelUsage(w.models, usage.Calls) { + return "model_drift_detected", "model_usage_mismatch" + } return reason, "usage_invalid_or_limit" } func (w *workflowRuntime) paid(iter int, phase, legacy string, argv []string) (subprocess.Result, string, string) { req := w.request(iter, phase, "") + if phase == "exec" && w.models != nil && w.models.Mode == "explicit" { + var err error + argv, err = modelpolicy.Command(w.config.Models.Tasks[w.models.Task].Command, w.models) + if err != nil { + return subprocess.Result{}, "workspace_invalid", "model_command_invalid" + } + } p := w.config.Meter if len(p.Reserve) > 0 { var reservation workflow.Preflight @@ -130,6 +154,9 @@ func (w *workflowRuntime) paid(iter int, phase, legacy string, argv []string) (s if e := structuredResult(v, &reservation); e != nil { return v, "cost_anomaly", "reservation_unavailable" } + if !modelReservations(w.models, reservation.Calls) { + return v, "model_drift_detected", "model_reservation_mismatch" + } if reason := w.book.reserve(reservation, req, p, w.cap); reason != "" { return v, reason, "reservation_denied" } diff --git a/docs/cli-reference.generated.md b/docs/cli-reference.generated.md index 9975523..67a2678 100644 --- a/docs/cli-reference.generated.md +++ b/docs/cli-reference.generated.md @@ -2,7 +2,7 @@ Generated from the Cobra command tree. Do not edit manually. -Source version: `0.4.1`. +Source version: `0.5.0-rc.1`. ## `loopexec` @@ -194,6 +194,26 @@ Usage: `loopexec isolate [flags]` - `--runtime` (`string`, default `docker`): Container runtime used to launch the zones - `--workdir` (`string`, default ``): Directory holding .loopexec (default: current directory) +## `loopexec models` + +Inspect configured task-to-model assignments without invoking models + +Usage: `loopexec models [flags]` + +- `--help, -h` (`bool`, default `false`): help for models +- `--json` (`bool`, default `false`): Emit machine-readable JSON output + +## `loopexec models plan` + +Explain the selected workflow task, role profiles and dispatch assurance; spends nothing + +Usage: `loopexec models plan [flags]` + +- `--help, -h` (`bool`, default `false`): help for plan +- `--json` (`bool`, default `false`): Emit machine-readable JSON output +- `--workdir` (`string`, default `.`): Directory used to resolve the workflow path +- `--workflow` (`string`, default ``): Workflow JSON containing model_policy (required) + ## `loopexec probe-check` Measure check determinism as a confidence bound (no check, no loop) @@ -272,7 +292,7 @@ Usage: `loopexec run [flags]` - `--terminate-grace` (`duration`, default `250ms`): Grace between process-group TERM and KILL - `--timeout` (`duration`, default `0s`): Total run deadline (0 = disabled) - `--workdir` (`string`, default ``): Directory to run commands in (default: current directory) -- `--workflow` (`string`, default ``): Pinned workflow JSON (argv hooks, metering, candidates, numeric policy) +- `--workflow` (`string`, default ``): Pinned workflow JSON (argv hooks, metering, candidates, numeric policy, optional model assignments) ## `loopexec status` @@ -321,6 +341,7 @@ Types and JSON tags are generated from the runtime structs. Value semantics and ### `response` +- `model_selection,omitempty`: `*modelpolicy.Selection` - `failure_cause,omitempty`: `string` - `best_candidate,omitempty`: `string` - `tool`: `string` @@ -347,6 +368,7 @@ Types and JSON tags are generated from the runtime structs. Value semantics and ### `receiptEvent` +- `model_selection,omitempty`: `*modelpolicy.Selection` - `output_truncated,omitempty`: `bool` - `phase,omitempty`: `string` - `duration_ms`: `int64` @@ -358,6 +380,13 @@ Types and JSON tags are generated from the runtime structs. Value semantics and - `detail,omitempty`: `string` - `exit_code,omitempty`: `*int` +### `Selection` + +- `mode`: `string` +- `task`: `string` +- `assurance`: `string` +- `roles`: `[]modelpolicy.Role` + ### `probeReport` - `runs`: `int` @@ -413,6 +442,7 @@ Types and JSON tags are generated from the runtime structs. Value semantics and ### `reportSummary` +- `model_selection,omitempty`: `*modelpolicy.Selection` - `budget,omitempty`: `*main.budgetBook` - `numeric,omitempty`: `*main.numericState` - `candidate,omitempty`: `*main.candidateState` @@ -457,6 +487,18 @@ Types and JSON tags are generated from the runtime structs. Value semantics and - `phase_remaining`: `map[string]int` - `reservation,omitempty`: `string` +### `Role` + +- `provider`: `string` +- `model_id`: `string` +- `version,omitempty`: `string` +- `reasoning_effort,omitempty`: `string` +- `role`: `string` +- `profile`: `string` +- `reason`: `string` +- `capabilities,omitempty`: `[]string` +- `requires,omitempty`: `[]string` + ### `doctorCheck` - `name`: `string` @@ -512,7 +554,40 @@ Types and JSON tags are generated from the runtime structs. Value semantics and - `exit_code`: `int` - `output_sha256`: `string` +### `Call` + +- `model,omitempty`: `*modelpolicy.Identity` +- `id`: `string` +- `phase`: `string` +- `status`: `string` +- `microusd,omitempty`: `*int64` +- `tokens,omitempty`: `*int64` + +### `Preflight` + +- `schema_version`: `int` +- `run_id`: `string` +- `iteration`: `int` +- `phase`: `string` +- `enforced`: `bool` +- `calls`: `[]workflow.Reservation` + ### `manifestEntry` - `path`: `string` - `sha256`: `string` + +### `Identity` + +- `provider`: `string` +- `model_id`: `string` +- `version,omitempty`: `string` +- `reasoning_effort,omitempty`: `string` + +### `Reservation` + +- `model,omitempty`: `*modelpolicy.Identity` +- `id`: `string` +- `phase`: `string` +- `max_microusd`: `int64` +- `max_tokens`: `int64` diff --git a/docs/cli.md b/docs/cli.md index 6d2af29..8871f28 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -8,6 +8,8 @@ and examples; both must be updated together when the behavior changes. ## Commands +- `loopexec models plan` + - Preview the selected workflow task's configured model profiles, role assignments, reasoning settings, capability declarations and rationale. `--workflow ` is required; `--workdir` defaults to `.`. Read-only: no hooks, calls, reservations or runtime initialization. Supports human output and one `--json` object with `model_selection`. See [model assignments](model-assignments.md). - `loopexec init` - Initialize local loopexec workspace metadata. - `loopexec demo` @@ -24,6 +26,7 @@ and examples; both must be updated together when the behavior changes. - Optional comprehension gate (section 9): `--comprehension-every N` halts `comprehension_debt_exceeded` after N merged-but-unread iterations (cleared by `loopexec ack`). Each iteration also updates a heartbeat for `loopexec watch`. - Halt reasons are **computed** from observed state (`success_condition_met` / `max_iterations_reached` / `execution_failure` / `workspace_invalid`, plus `no_progress_detected` / `oscillation_detected` / `same_test_regressed` when `--failures-cmd` is set), never forced by a flag. - Writes a typed JSONL receipt to `.loopexec/run-.jsonl` and atomic state to `.loopexec/state.json`. + - Optional workflow `model_policy` selects advisory guidance or explicit argv-bound task/role assignments. Explicit mode requires reservation/usage identity, rejects other exec overrides and legacy model identity flags, and reports drift as exit 19 while retaining incurred usage. Selection appears in run JSON, state, per-attempt receipt events and reports; provider identity is adapter-reported, not independently verified. No automatic model escalation, budget renewal or quality-target change occurs. - `loopexec probe-check` - Measure check determinism as a confidence bound (SPEC O2): no check, no loop. - Flags: `--check ""` (required), `--runs N` (default derived), `--max-flake-rate R` (derives run count via the rule of three, `runs >= 3/R`), `--workdir`. diff --git a/docs/documentation-continuity.md b/docs/documentation-continuity.md index 32cbced..a79af52 100644 --- a/docs/documentation-continuity.md +++ b/docs/documentation-continuity.md @@ -81,6 +81,16 @@ The Release workflow can also be dispatched on main for a non-publishing CI and deployed-docs rehearsal. Both dispatch and tag runs require source reachable from main. Only a tag run may enter the publication job, after CI and docs readback pass. +## Release channels + +The release policy accepts canonical `vX.Y.Z` and `vX.Y.Z-rc.N` tags (N >= 1). +Development, beta and malformed tags fail closed. Stable releases are marked +Latest; RCs are explicitly prereleases with Latest disabled. Both draft creation +and publication use this policy, covered by no-network shell orchestration tests. +An RC never bypasses bundle validation or either official deployed-docs readback. +Keep the stable install as default and require an explicit candidate pin until +consumer acceptance justifies a separate stable release. + ## Enforcement and rollout boundary Require the CI checks on protected main, restrict release-tag and GitHub release diff --git a/docs/documentation-files.json b/docs/documentation-files.json index e05a948..0fc1aa5 100644 --- a/docs/documentation-files.json +++ b/docs/documentation-files.json @@ -10,6 +10,7 @@ "docs/cli-reference.generated.md", "docs/architecture.md", "docs/workflows.md", + "docs/model-assignments.md", "docs/integrations.md", "docs/loop-contract.md", "docs/small-integration.md", @@ -21,6 +22,8 @@ "docs/releases/v0.3.0.md", "docs/releases/v0.4.0.md", "docs/releases/v0.4.1.md", + "docs/releases/v0.5.0-dev.md", + "docs/releases/v0.5.0-rc.1.md", "examples/scoped-budget/README.md" ] } diff --git a/docs/integrations.md b/docs/integrations.md index c9834c1..5501c1b 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -42,6 +42,11 @@ Local/subscription usage may report unknown dollars while time, iteration, call, and token bounds remain available. Model/sampling flags record metadata; the adapter's actual argv/configuration chooses and limits the invoked model. +The unreleased [model assignment contract](model-assignments.md) adds explicit +argv-bound profiles and role mappings, plus a read-only `models plan` preview. +Adapters must consume the selected settings and report observed invocation +identity; updating LoopExec alone does not migrate an existing consumer. + Legacy `--failures-cmd` and `--integrity-cmd` collect newline identities or `{"ids":[]}`. Collector failure or stderr cannot become valid evidence. The integrity baseline must retain all members. Numeric mode accepts a separate diff --git a/docs/model-assignments.md b/docs/model-assignments.md new file mode 100644 index 0000000..bf35946 --- /dev/null +++ b/docs/model-assignments.md @@ -0,0 +1,164 @@ +# Model Assignments + +Status: `0.5.0-rc.1` release-candidate source; GitHub Releases establishes +publication status. LoopExec remains a +provider-neutral governor. No model ranking, price catalog, SDK, automatic +routing or automatic escalation is included. + +## Preview Without Spending + +```bash +loopexec models plan --workflow workflow.json --workdir build/review +loopexec models plan --workflow workflow.json --workdir build/review --json +``` + +Planning explains the selected task's role/profile assignments, requested model, +effort, rationale, capabilities and assurance. It runs no hooks, initializes no +runtime state, reserves no calls and invokes no models. It validates model +policy, not project readiness, shared budget, executables or provider access. + +Use configurable task-specific profiles, not a universal intelligence score. +Routine edits may suit an economical profile; difficult diagnosis may suit +deeper reasoning; image comparison requires declared vision capability. Evaluate +quality, latency and cost per accepted result, not just price per call. + +## Configuration + +Add `model_policy` to schema-1 workflow JSON. This is a fragment: existing check +and metering hooks are still required. + +```json +{ + "model_policy": { + "mode": "explicit", + "task": "studio-review", + "profiles": { + "routine": { + "provider": "configured-provider", + "model_id": "configured-small-model", + "reasoning_effort": "low" + }, + "visual-review": { + "provider": "configured-provider", + "model_id": "configured-vision-model", + "reasoning_effort": "high", + "capabilities": ["vision"] + } + }, + "tasks": { + "studio-review": { + "roles": { + "builder": { + "profile": "routine", + "reason": "Bounded routine preparation" + }, + "critic": { + "profile": "visual-review", + "requires": ["vision"], + "reason": "Compare saved equipment captures with references" + } + }, + "command": [ + "node", "adapter/attempt.mjs", + "--builder-model", "{builder.model}", + "--builder-effort", "{builder.reasoning}", + "--critic-model", "{critic.model}", + "--critic-effort", "{critic.reasoning}" + ] + } + } + } +} +``` + +The command is an operator-provided adapter, not a bundled provider launcher. It +must accept these arguments plus the final request-file argument and apply the +values to its actual child calls. Unsupported settings must fail, never fall +back silently. Effort values are provider-specific portable identifiers, not a +built-in provider capability database. Optional profile `version` pins the +adapter-reported version when supplied. + +`advisory` mode records guidance and leaves the existing workflow `exec` +unchanged. It makes no identity-compliance claim. + +`explicit` mode uses the selected task's `command`. Omit workflow `exec` and +legacy `--exec`; conflicting overrides fail before work. Each assigned model and +nonempty effort must have whole argv placeholders `{role.model}` and +`{role.reasoning}`. Optional placeholders are `{role.provider}` and +`{role.version}` when nonempty. No shell-text interpolation or partial-argument +substitution occurs. The existing absolute request-file path is appended last. +Credentials never belong in profiles, commands, reasons or receipts. Prefer +portable relative paths for configured executables and resources. + +All tasks are validated. Missing profiles, modes, declared capabilities, bound +explicit fields and safe display text fail closed. Select a different task in +configuration before a new run. Resume requires identical policy, including +assignments, rationale and command. There are no automatic fallbacks. + +## Adapter Evidence + +Every hook receives `model_selection`: `mode`, `task`, `assurance`, and sorted +`roles`. Each role includes profile, provider, model ID, optional version/effort, +required/declared capabilities and reason. State, reports, final run output and +typed per-attempt receipt events expose the selection. Human runs print it before +each attempt; JSON mode retains one stdout object and exposes the live selection +through request/state/receipt files. Use `models plan` for a pre-launch JSON view. + +Explicit mode requires a reserve hook even for unmetered workflows. Every role +needs a planned call with that role as `phase` and a matching `model` object: + +```json +{ + "id": "review/000001/exec/critic", + "phase": "critic", + "max_tokens": 1000, + "model": { + "provider": "configured-provider", + "model_id": "configured-vision-model", + "reasoning_effort": "high" + } +} +``` + +Usage calls include the same identity fields alongside existing status, money +and tokens. Report observed invocation values, not an unchecked copy of the +request. Executed assigned calls require matching identity; skipped calls may +omit it. Unassigned model roles are rejected; non-model tool phases remain +allowed. A configured version must match; an unspecified version may be reported. +Missing/wrong planned identities stop before work. Reported drift halts with +`model_drift_detected` (19), retaining valid incurred usage. Unknown dollar costs +remain unknown. Budget/call/token accounting still uses the existing contract. + +Assurance is `guidance_only` or `adapter_report_required`, not independent +provider attestation. Capabilities are operator declarations. A malicious adapter +can lie or make undeclared calls; existing provider/containment enforcement is +still necessary. Legacy model flags remain recording-only and cannot be combined +with `model_policy`. Selection and reported usage are covered by existing receipt +attestation; old workflows and signatures omit new fields and remain compatible. + +## Quality and Consumer Testing + +Assignments never add retries, grant calls, renew allowances, reset ledgers, +launch extra critics or lower quality targets. LoopExec alone owns the loop; an +adapter performs one bounded sequence. Run technical checks before critics and +keep fresh independent read-only review boundaries. Check/score hooks replay +saved evidence without model work. A stronger model cannot approve a red check +or waive an unmet acceptance target. + +The 3D Home World studio-hardware review is a planned consumer test, not a global +quality standard. Its adapter must deliberately consume assignments and report +actual invocation settings while preserving readiness gates and per-asset spend +history. A LoopExec update alone does not update that adapter or authorize another +critic. Do not change a pinned in-progress run. New runs require appropriate +approval and retain existing shared allowances and original receipts. + +```bash +go build -trimpath -o build/loopexec ./cmd/loopexec +go build -trimpath -o build/model-assignment ./examples/model-assignment +bash scripts/model-assignment-proof.sh build/loopexec build/model-assignment build/model-proof +``` + +This proof uses synthetic builder/critic models, no engines, images, credentials +or paid provider. It covers two-role argv dispatch, no-spend planning, drift, +budget/technical denial, resume, replay and attestation. It is not a visual score +or a live provider test. diff --git a/docs/releases/v0.5.0-dev.md b/docs/releases/v0.5.0-dev.md new file mode 100644 index 0000000..491f905 --- /dev/null +++ b/docs/releases/v0.5.0-dev.md @@ -0,0 +1,19 @@ +# v0.5.0-dev + +Unreleased local development build. Published stable versions are authoritative +on [GitHub Releases](https://github.com/justyn-clark/loopexec/releases). + +Adds configurable provider-neutral model profiles and named task/role +assignments. `models plan` previews rationale without work or spending. Advisory +mode leaves invocation unchanged. Explicit mode binds model and reasoning values +to argv, checks planned and adapter-reported identities, and exposes selection in +reports, state, per-attempt receipts and attestations. + +No automatic routing, escalation, SDK, price catalog, target adjustment or budget +renewal is included. Provider identity/capabilities are not independently attested. +Old workflows and signatures remain compatible. + +See [model assignments](../model-assignments.md) for the contract and offline +proof. Home World integration and live testing remain separate. Publication +requires matching canonical documentation deployment and official-origin +readback; neither is claimed for this development build. diff --git a/docs/releases/v0.5.0-rc.1.md b/docs/releases/v0.5.0-rc.1.md new file mode 100644 index 0000000..00ff27c --- /dev/null +++ b/docs/releases/v0.5.0-rc.1.md @@ -0,0 +1,34 @@ +# v0.5.0-rc.1 + +Release candidate for visible, provider-neutral model assignments. This is not +stable v0.5.0 and does not replace v0.4.1 as latest stable. Publication status is +authoritative on [GitHub Releases](https://github.com/justyn-clark/loopexec/releases/tag/v0.5.0-rc.1). + +## Model assignments + +- Named profiles and per-task builder/critic roles carry provider, model identity, + reasoning effort, capabilities and human-visible rationale. +- `models plan` previews the selection without running hooks, spending or creating + run state. Advisory mode leaves the work command unchanged. +- Explicit mode binds whole argv placeholders, requires a reservation hook and + checks planned/reported model identities. Drift halts retain incurred usage. +- State, reports, typed receipts and attestations include selection. Existing + records without a selection remain compatible. + +See [model assignments](../model-assignments.md) for the adapter contract and +offline proof. Adapter reports are not independent provider attestation. There +is no automatic routing, escalation, model catalog, budget renewal or acceptance +adjustment. Quality targets remain integration-specific. + +## Installation and promotion + +After publication, pin `go install github.com/justyn-clark/loopexec/cmd/loopexec@v0.5.0-rc.1` +or use the matching checksummed release archive. `@latest` stays on stable. +Consumer adapters must implement explicit argv dispatch, planned reservations and +reported identity; installing the binary alone does not migrate an adapter. +Live consumer/critic acceptance is a separate gate before stable promotion. + +Release tooling accepts stable tags and canonical `-rc.N` tags only. RCs are +published as prereleases with Latest disabled. Both channels require the same +matching source-bound documentation bundle and official-origin readback before +draft creation and immediately before publication. diff --git a/docs/status-and-roadmap.md b/docs/status-and-roadmap.md index da93054..0d0bb69 100644 --- a/docs/status-and-roadmap.md +++ b/docs/status-and-roadmap.md @@ -1,27 +1,49 @@ # LoopExec status and roadmap -Historical audit verified October 13, 2026, before v0.4.1 publication. Release and deployment status can change; -[GitHub Releases](https://github.com/justyn-clark/loopexec/releases) remains authoritative. -LoopExec is a general-purpose bounded loop governor. The documentation site -presents its contract; it does not host the runtime. +## Development Update: October 15, 2026 + +Current source is the `0.5.0-rc.1` model-assignment release candidate, not stable +v0.5.0. See [model assignments](model-assignments.md) and +[candidate notes](releases/v0.5.0-rc.1.md). The Releases page establishes publication. +It adds no-spend planning, advisory profiles, explicit role bindings and +adapter-reported identity checks. Offline verification does not establish live +provider behavior or Home World adapter readiness. Automatic routing is absent. + +v0.4.1 was published and verified October 14 at core `80b56add` and documentation +`c98b6c7f`: matching source-bound docs, Linux/macOS CI, archives/checksums and a +fresh pinned install passed. Its publication and continuity rollout supersede +the October 7 blockers below. See the [release](https://github.com/justyn-clark/loopexec/releases/tag/v0.4.1). +New model-assignment source/docs have not been deployed or published. + +Next: finish model-assignment regression/offline proofs, review adapter wiring +with existing readiness and shared budgets intact, then conduct an explicitly +authorized consumer test. A model change does not authorize another critic call. + +## Historical Audit: October 7, 2026 + +The sections below retain the dated October 7 audit, not current release status. +Snapshot verified October 7, 2026. This is not a live release feed. +This audit covers the general-purpose LoopExec governor and published `loopexec` +binary. The home project's quality verifier is one consumer, not its product scope. ## Published and deployed | Surface | Verified status | Evidence | | --- | --- | --- | -| Source identity | v0.4.1, merged on main | [Source](https://github.com/justyn-clark/loopexec/tree/main), [notes](releases/v0.4.1.md) | -| Public release at audit | v0.4.0, September 26, 2026 | [Release](https://github.com/justyn-clark/loopexec/releases/tag/v0.4.0) | -| Release assets | Six macOS/Linux/Windows amd64/arm64 archives and SHA256 checksums | v0.4.0 release assets | -| Canonical documentation flow | Source-bound bundle imported by the site; official-origin readback required for each release source | [Continuity policy](documentation-continuity.md) | -| Hosted core gates | CI and non-publishing Release rehearsal passed on merged source `6b027d0` | [CI](https://github.com/justyn-clark/loopexec/actions/runs/37960688842), [rehearsal](https://github.com/justyn-clark/loopexec/actions/runs/37961116072) | -| Hosted site gate | Canonical Docs passed on docs source `595e4b0` | [CI](https://github.com/justyn-clark/loopexec.dev/actions/runs/38295817979) | -| Account enforcement | Required Linux/macOS CI checks enabled on core main, including administrators; force pushes and deletion disabled | GitHub branch-protection API, October 13, 2026 | - -The live documentation manifest already describes source v0.4.1. This is a -documentation source identity, not evidence of a public binary release. -Every subsequent documentation change needs a new bundle and deployment -readback. Linux/macOS CI covers runtime behavior; Windows is cross-built -and native Windows runtime remains unverified. +| Latest published release | v0.4.0, September 26, 2026; public, not a draft or prerelease | [Release](https://github.com/justyn-clark/loopexec/releases/tag/v0.4.0) | +| Release artifacts | Six macOS/Linux/Windows amd64/arm64 archives plus SHA256 checksums | Release assets and [successful release workflow](https://github.com/justyn-clark/loopexec/actions/runs/36276149282) | +| Release commit | `c0e4cc2653b428faa2fceb6485f94a98aa3167d5` | Annotated v0.4.0 tag resolves to this commit | +| Published main | `4c96ad49d9d57199824fc44c7eeee8108d292dbd`; local main matched at audit start | [Main](https://github.com/justyn-clark/loopexec/tree/main) | +| Main CI | Passed on the exact published main commit | [CI run](https://github.com/justyn-clark/loopexec/actions/runs/36288545259) | +| Open GitHub backlog | No open issues or PRs at audit time; not evidence that the product has no remaining work | Repository issues/PR API reads | +| Public documentation | https://loopexec.dev is accessible; its homepage/changelog describe v0.2.0 | Public page read and matching source in the separate `loopexec.dev` repository | + +LoopExec ships as a locally installed CLI; the deployed website is documentation, +not a hosted LoopExec runtime. The docs repository is configured for Vercel, but +this audit did not verify the provider deployment ID or control-plane settings. +Linux/macOS CI exercises runtime behavior. Windows is cross-built; native +Windows runtime remains unverified. Asset metadata was checked live; the binary +archives were not downloaded again during this audit. ## Available capabilities @@ -33,9 +55,10 @@ replay/attestation, halt explanations and subprocess deadlines remain available. See [SPEC section 11](../SPEC.md#11-capability-status-normative-source-for-the-docs-matrix) for the capability-level contract. -The v0.4.1 source improves reports, enforces code/docs continuity gates, +The current local patch improves reports, enforces code/docs continuity gates, packages source-bound docs and adds general-purpose [acceptance guidance](acceptance-policy.md). -Those source changes do not alter existing v0.4.0 packages or consumer installations. The source and site integration are merged; v0.4.1 has not been published at this audit. +Those changes are unreleased and do not alter v0.4.0 packages or existing consumer +installations. No new version, push, tag, deployment or publication was performed. An optional Jev semantic judge exists in a separate local `codex/jev-judge` worktree based on the older v0.3.0 development lineage. It is uncommitted and @@ -48,8 +71,8 @@ before considering it for a release. | Priority | Work | Acceptance evidence | | --- | --- | --- | -| P0 | Keep each new canonical bundle and rendered site synchronized | Source-bound manifest/content readback, rendered-page checks and correct published/unreleased boundaries; publication remains blocked until matching | -| P0 | Require committed CI/account release protections | Exact-commit CI plus live branch/tag/release permission verification; local tests do not establish hosted enforcement | +| P0 | Adopt canonical docs/build handshake in the site, then sync home/install/CLI/capability/changelog | Source-bound manifest/content readback, rendered-page checks and correct published/unreleased boundaries; publication remains blocked until matching | +| P0 | Commit continuity gates and require CI/account release protections | Exact-commit CI plus live branch/tag/release permission verification; local tests do not establish hosted enforcement | | P0 | Release these report fixes after review and CI | Exact-commit CI, offline proof receipts, packages/checksums and fresh install verification | | P1 | Migrate existing adapters from v0.3.0/custom budget guards deliberately | Historic and failed reservations preserved; no restored allowance; parity and concurrency checks before model launch | | P1 | Add general-purpose read-only objective history across run IDs | Objective/candidate/policy/evidence/decision/budget/promotion history; explicit owner revisions and missing-evidence warnings; no work or model calls | diff --git a/docs/workflows.md b/docs/workflows.md index 5f8090b..bcb2a0e 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -4,6 +4,11 @@ The Go governor remains the only retry controller. The opt-in `run --workflow` contract composes an argv adapter with exact accounting, candidate snapshots, and optional numeric progress. It needs no model SDK or engine dependency. +Optional [model assignments](model-assignments.md) add a no-spend `models plan` +preview, advisory task/role profiles, and explicit argv-bound invocation with +pre-call declarations and adapter-reported identity checks. They do not change +quality targets, allowances or the single retry-controller boundary. + ## Run controls `--timeout 30s` bounds the total run, including collectors and hooks. diff --git a/examples/model-assignment/main.go b/examples/model-assignment/main.go new file mode 100644 index 0000000..2674dcd --- /dev/null +++ b/examples/model-assignment/main.go @@ -0,0 +1,151 @@ +// This adapter simulates two model roles. It never invokes a model or engine. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/justyn-clark/loopexec/internal/modelpolicy" + "github.com/justyn-clark/loopexec/internal/workflow" +) + +type executed struct { + Role string `json:"role"` + Model *modelpolicy.Identity `json:"model,omitempty"` + Status string `json:"status"` +} + +func initialize(args []string) error { + flags := flag.NewFlagSet("init", flag.ContinueOnError) + dir := flags.String("dir", "", "New fixture workspace") + scenario := flags.String("scenario", "normal", "normal, budget, reserve-drift, usage-drift, technical") + if err := flags.Parse(args); err != nil { + return err + } + if *dir == "" { + return fmt.Errorf("--dir required") + } + if _, err := os.Stat(*dir); !os.IsNotExist(err) { + return fmt.Errorf("fixture directory must not exist") + } + root, err := filepath.Abs(*dir) + if err != nil { + return err + } + self, err := os.Executable() + if err != nil { + return err + } + adapter, err := filepath.Rel(root, self) + if err != nil { + return err + } + if filepath.Dir(adapter) == "." { + adapter = "." + string(filepath.Separator) + adapter + } + policy := &modelpolicy.Policy{Mode: "explicit", Task: "studio-review", Profiles: map[string]modelpolicy.Profile{ + "routine": {Identity: modelpolicy.Identity{Provider: "offline", ModelID: "fixture-small", ReasoningEffort: "low"}, Capabilities: []string{"text"}}, + "visual-review": {Identity: modelpolicy.Identity{Provider: "offline", ModelID: "fixture-vision", ReasoningEffort: "high"}, Capabilities: []string{"text", "vision"}}, + }, Tasks: map[string]modelpolicy.Task{"studio-review": {Roles: map[string]modelpolicy.Assignment{ + "builder": {Profile: "routine", Reason: "Simulate a bounded routine preparation task"}, + "critic": {Profile: "visual-review", Reason: "Simulate independent comparison of equipment captures with references", Requires: []string{"vision"}}, + }, Command: []string{adapter, "attempt", "{builder.model}", "{builder.reasoning}", "{critic.model}", "{critic.reasoning}"}}}} + w := workflow.Config{SchemaVersion: 1, Models: policy, Check: []string{adapter, "check"}, Meter: workflow.MeterPolicy{Mode: "unmetered", Reserve: []string{adapter, "reserve"}, Usage: []string{adapter, "usage"}, MaxCalls: 2, MaxTokens: 20}} + if *scenario == "budget" { + w.Meter.MaxCalls = 1 + } + if err := os.MkdirAll(root, 0700); err != nil { + return err + } + if err := workflow.Atomic(filepath.Join(root, "workflow.json"), w); err != nil { + return err + } + return workflow.Atomic(filepath.Join(root, "scenario.json"), *scenario) +} + +func execute(args []string) (int, error) { + if len(args) == 0 { + return 2, fmt.Errorf("command required") + } + if args[0] == "init" { + return 0, initialize(args[1:]) + } + if len(args) < 2 { + return 2, fmt.Errorf("request required") + } + var req workflow.Request + if err := workflow.Read(args[len(args)-1], &req); err != nil || req.Models == nil || len(req.Models.Roles) != 2 { + return 2, fmt.Errorf("invalid fixture request") + } + var scenario string + if err := workflow.Read("scenario.json", &scenario); err != nil { + return 2, err + } + output := json.NewEncoder(os.Stdout) + switch args[0] { + case "reserve": + calls := []workflow.Reservation{} + for _, role := range req.Models.Roles { + model := role.Identity + if scenario == "reserve-drift" && role.Role == "critic" { + model.ModelID = "wrong-fixture-model" + } + calls = append(calls, workflow.Reservation{ID: workflow.CallID(req.RunID, req.Iteration, req.Phase, role.Role), Phase: role.Role, Model: &model, MaxTokens: 10}) + } + return 0, output.Encode(workflow.Preflight{SchemaVersion: 1, RunID: req.RunID, Iteration: req.Iteration, Phase: req.Phase, Calls: calls}) + case "attempt": + if len(args) != 6 { + return 2, fmt.Errorf("expected two model/effort pairs and request") + } + calls := []executed{} + for i, role := range req.Models.Roles { + model := role.Identity + model.ModelID, model.ReasoningEffort = args[1+2*i], args[2+2*i] + call := executed{Role: role.Role, Model: &model, Status: "unknown"} + if scenario == "technical" && role.Role == "critic" { + call.Model, call.Status = nil, "skipped" + } + calls = append(calls, call) + } + return 0, workflow.Atomic("simulated-execution.json", calls) + case "usage": + var executedCalls []executed + if err := workflow.Read("simulated-execution.json", &executedCalls); err != nil { + return 2, err + } + calls := []workflow.Call{} + for _, actual := range executedCalls { + if scenario == "usage-drift" && actual.Role == "critic" { + actual.Model.ModelID = "wrong-fixture-model" + } + tokens := int64(5) + if actual.Status == "skipped" { + tokens = 0 + } + calls = append(calls, workflow.Call{ID: workflow.CallID(req.RunID, req.Iteration, req.Phase, actual.Role), Phase: actual.Role, Model: actual.Model, Status: actual.Status, Tokens: &tokens}) + } + return 0, output.Encode(workflow.Usage{SchemaVersion: 1, RunID: req.RunID, Iteration: req.Iteration, Phase: req.Phase, Calls: calls}) + case "check": + if _, err := os.Stat("simulated-execution.json"); err != nil { + return 1, nil + } + if scenario == "technical" { + return 1, nil + } + fmt.Println("synthetic fixture accepted; not a visual quality judgment") + return 0, nil + } + return 2, fmt.Errorf("unknown fixture phase") +} + +func main() { + code, err := execute(os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + code = 2 + } + os.Exit(code) +} diff --git a/internal/modelpolicy/policy.go b/internal/modelpolicy/policy.go new file mode 100644 index 0000000..6829ba5 --- /dev/null +++ b/internal/modelpolicy/policy.go @@ -0,0 +1,184 @@ +// Package modelpolicy resolves explicit, provider-neutral model assignments. +package modelpolicy + +import ( + "fmt" + "regexp" + "sort" + "strings" + "unicode" +) + +type Identity struct { + Provider string `json:"provider"` + ModelID string `json:"model_id"` + Version string `json:"version,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` +} + +type Profile struct { + Identity + Capabilities []string `json:"capabilities,omitempty"` +} + +type Assignment struct { + Profile string `json:"profile"` + Reason string `json:"reason"` + Requires []string `json:"requires,omitempty"` +} + +type Task struct { + Roles map[string]Assignment `json:"roles"` + Command []string `json:"command,omitempty"` +} + +type Policy struct { + Mode string `json:"mode"` + Task string `json:"task"` + Profiles map[string]Profile `json:"profiles"` + Tasks map[string]Task `json:"tasks"` +} + +type Role struct { + Identity + Role string `json:"role"` + Profile string `json:"profile"` + Reason string `json:"reason"` + Capabilities []string `json:"capabilities,omitempty"` + Requires []string `json:"requires,omitempty"` +} + +type Selection struct { + Mode string `json:"mode"` + Task string `json:"task"` + Assurance string `json:"assurance"` + Roles []Role `json:"roles"` +} + +var name = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`) +var identity = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$`) + +func safeText(s string) bool { + return strings.TrimSpace(s) != "" && len(s) <= 500 && !strings.ContainsFunc(s, unicode.IsControl) +} + +func names(values []string) bool { + seen := map[string]bool{} + for _, s := range values { + if !name.MatchString(s) || seen[s] { + return false + } + seen[s] = true + } + return true +} + +// Resolve checks every configured task, not just the selected one. No I/O occurs. +func Resolve(p *Policy) (*Selection, error) { + if p == nil { + return nil, nil + } + if (p.Mode != "advisory" && p.Mode != "explicit") || !name.MatchString(p.Task) || len(p.Profiles) == 0 || len(p.Profiles) > 64 || len(p.Tasks) == 0 || len(p.Tasks) > 64 { + return nil, fmt.Errorf("invalid model policy mode, task or profile/task count") + } + for id, profile := range p.Profiles { + if !name.MatchString(id) || !identity.MatchString(profile.Provider) || !identity.MatchString(profile.ModelID) || (profile.Version != "" && !identity.MatchString(profile.Version)) || (profile.ReasoningEffort != "" && !name.MatchString(profile.ReasoningEffort)) || !names(profile.Capabilities) { + return nil, fmt.Errorf("invalid model profile fields") + } + } + var selected *Selection + for taskID, task := range p.Tasks { + if !name.MatchString(taskID) || len(task.Roles) == 0 || len(task.Roles) > 16 { + return nil, fmt.Errorf("invalid model task roles") + } + s := &Selection{Mode: p.Mode, Task: taskID, Assurance: "guidance_only", Roles: []Role{}} + if p.Mode == "explicit" { + s.Assurance = "adapter_report_required" + } + for role, assignment := range task.Roles { + profile, ok := p.Profiles[assignment.Profile] + if !ok || !name.MatchString(role) || !safeText(assignment.Reason) || !names(assignment.Requires) { + return nil, fmt.Errorf("invalid model assignment or missing profile") + } + for _, required := range assignment.Requires { + found := false + for _, capability := range profile.Capabilities { + found = found || capability == required + } + if !found { + return nil, fmt.Errorf("model profile lacks a required declared capability") + } + } + s.Roles = append(s.Roles, Role{Identity: profile.Identity, Role: role, Profile: assignment.Profile, Reason: assignment.Reason, Capabilities: profile.Capabilities, Requires: assignment.Requires}) + } + sort.Slice(s.Roles, func(i, j int) bool { return s.Roles[i].Role < s.Roles[j].Role }) + if p.Mode == "explicit" { + if _, err := Command(task.Command, s); err != nil { + return nil, err + } + } + if taskID == p.Task { + selected = s + } + } + if selected == nil { + return nil, fmt.Errorf("selected model task does not exist") + } + return selected, nil +} + +// Command substitutes whole argv elements, never shell text. Each assigned model +// and configured effort must be bound, so explicit mode cannot be metadata-only. +func Command(argv []string, s *Selection) ([]string, error) { + if len(argv) == 0 || len(argv) > 256 { + return nil, fmt.Errorf("explicit model task requires a bounded argv command") + } + values := map[string]string{} + required := map[string]bool{} + for _, role := range s.Roles { + prefix := "{" + role.Role + "." + values[prefix+"provider}"] = role.Provider + values[prefix+"model}"] = role.ModelID + values[prefix+"version}"] = role.Version + values[prefix+"reasoning}"] = role.ReasoningEffort + required[prefix+"model}"] = false + if role.ReasoningEffort != "" { + required[prefix+"reasoning}"] = false + } + } + result := append([]string(nil), argv...) + for i, arg := range result { + if arg == "" || len(arg) > 8192 || strings.ContainsRune(arg, 0) { + return nil, fmt.Errorf("invalid model command argument") + } + if strings.ContainsAny(arg, "{}") { + value, ok := values[arg] + if !ok || i == 0 || value == "" { + return nil, fmt.Errorf("model placeholders must be known, nonempty whole arguments after the executable") + } + result[i] = value + if _, ok := required[arg]; ok { + required[arg] = true + } + } + } + for _, bound := range required { + if !bound { + return nil, fmt.Errorf("explicit model command must bind every role's model and configured reasoning") + } + } + return result, nil +} + +func (s *Selection) Find(role string) *Role { + for i := range s.Roles { + if s.Roles[i].Role == role { + return &s.Roles[i] + } + } + return nil +} + +func (r *Role) Matches(actual *Identity) bool { + return actual != nil && r.Provider == actual.Provider && r.ModelID == actual.ModelID && r.ReasoningEffort == actual.ReasoningEffort && (r.Version == "" || r.Version == actual.Version) +} diff --git a/internal/modelpolicy/policy_test.go b/internal/modelpolicy/policy_test.go new file mode 100644 index 0000000..1f5a2c5 --- /dev/null +++ b/internal/modelpolicy/policy_test.go @@ -0,0 +1,100 @@ +package modelpolicy + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func policyFixture() *Policy { + return &Policy{Mode: "explicit", Task: "studio-review", Profiles: map[string]Profile{ + "visual": {Identity: Identity{Provider: "offline", ModelID: "fixture-vision", ReasoningEffort: "high"}, Capabilities: []string{"vision"}}, + "routine": {Identity: Identity{Provider: "offline", ModelID: "fixture-small", ReasoningEffort: "low"}}, + }, Tasks: map[string]Task{"studio-review": {Roles: map[string]Assignment{ + "critic": {Profile: "visual", Reason: "Reference-image comparison requires vision", Requires: []string{"vision"}}, + "builder": {Profile: "routine", Reason: "Bounded routine edits"}, + }, Command: []string{"adapter", "--critic-model", "{critic.model}", "--critic-effort", "{critic.reasoning}", "--builder-model", "{builder.model}", "--builder-effort", "{builder.reasoning}"}}}} +} + +func TestResolveAndCommand(t *testing.T) { + p := policyFixture() + s, err := Resolve(p) + if err != nil || len(s.Roles) != 2 || s.Roles[0].Role != "builder" || s.Find("critic").ModelID != "fixture-vision" { + t.Fatalf("%+v %v", s, err) + } + argv, err := Command(p.Tasks[p.Task].Command, s) + if err != nil || !reflect.DeepEqual(argv, []string{"adapter", "--critic-model", "fixture-vision", "--critic-effort", "high", "--builder-model", "fixture-small", "--builder-effort", "low"}) { + t.Fatalf("%v %v", argv, err) + } + if p.Tasks[p.Task].Command[2] != "{critic.model}" { + t.Fatal("resolution mutated pinned input") + } + p.Mode = "advisory" + task := p.Tasks[p.Task] + task.Command = nil + p.Tasks[p.Task] = task + s, err = Resolve(p) + if err != nil || s.Assurance != "guidance_only" { + t.Fatalf("%+v %v", s, err) + } +} + +func TestModelPolicyRejectsInvalidAssignments(t *testing.T) { + for name, mutate := range map[string]func(*Policy){ + "unknown mode": func(p *Policy) { p.Mode = "automatic" }, + "missing task": func(p *Policy) { p.Task = "missing" }, + "unknown profile": func(p *Policy) { + p.Tasks[p.Task].Roles["critic"] = Assignment{Profile: "missing", Reason: "No fallback"} + }, + "capability": func(p *Policy) { v := p.Profiles["visual"]; v.Capabilities = nil; p.Profiles["visual"] = v }, + "control text": func(p *Policy) { + v := p.Tasks[p.Task].Roles["critic"] + v.Reason = "bad\x1b[2J" + p.Tasks[p.Task].Roles["critic"] = v + }, + "shell model": func(p *Policy) { v := p.Profiles["visual"]; v.ModelID = "bad; exit 0"; p.Profiles["visual"] = v }, + "partial placeholder": func(p *Policy) { p.Tasks[p.Task].Command[2] = "--model={critic.model}" }, + "unknown placeholder": func(p *Policy) { p.Tasks[p.Task].Command[2] = "{critic.secret}" }, + "unbound model": func(p *Policy) { p.Tasks[p.Task].Command[2] = "hardcoded-other-model" }, + "unbound effort": func(p *Policy) { p.Tasks[p.Task].Command[4] = "low" }, + "placeholder executable": func(p *Policy) { p.Tasks[p.Task].Command[0] = "{critic.model}" }, + "invalid unselected task": func(p *Policy) { p.Tasks["later"] = Task{} }, + } { + t.Run(name, func(t *testing.T) { + p := policyFixture() + mutate(p) + if _, err := Resolve(p); err == nil { + t.Fatal("unsafe or ambiguous assignment accepted") + } + }) + } +} + +func TestLegacyAndStableSelection(t *testing.T) { + if s, err := Resolve(nil); s != nil || err != nil { + t.Fatalf("legacy changed: %+v %v", s, err) + } + var previous string + for i := 0; i < 30; i++ { + s, _ := Resolve(policyFixture()) + b, _ := json.Marshal(s) + if i > 0 && string(b) != previous { + t.Fatal("selection order is nondeterministic") + } + previous = string(b) + } + if strings.Contains(previous, "command") { + t.Fatal("selection exposes invocation text") + } + s, _ := Resolve(policyFixture()) + critic := s.Find("critic") + actual := critic.Identity + if !critic.Matches(&actual) { + t.Fatal("matching identity rejected") + } + actual.ReasoningEffort = "low" + if critic.Matches(&actual) || critic.Matches(nil) { + t.Fatal("mismatched or absent identity accepted") + } +} diff --git a/internal/workflow/contract.go b/internal/workflow/contract.go index f6a4533..4d8ce06 100644 --- a/internal/workflow/contract.go +++ b/internal/workflow/contract.go @@ -10,17 +10,20 @@ import ( "io" "os" "path/filepath" + + "github.com/justyn-clark/loopexec/internal/modelpolicy" ) const MaxEvidence = 1 << 20 type Config struct { - SchemaVersion int `json:"schema_version"` - Exec []string `json:"exec"` - Check []string `json:"check"` - Meter MeterPolicy `json:"metering"` - Candidate *CandidatePolicy `json:"candidate,omitempty"` - Score *ScorePolicy `json:"score,omitempty"` + Models *modelpolicy.Policy `json:"model_policy,omitempty"` + SchemaVersion int `json:"schema_version"` + Exec []string `json:"exec"` + Check []string `json:"check"` + Meter MeterPolicy `json:"metering"` + Candidate *CandidatePolicy `json:"candidate,omitempty"` + Score *ScorePolicy `json:"score,omitempty"` } type MeterPolicy struct { Mode string `json:"mode"` @@ -48,19 +51,21 @@ type ScorePolicy struct { Patience int `json:"patience"` } type Request struct { - SchemaVersion int `json:"schema_version"` - RunID string `json:"run_id"` - Iteration int `json:"iteration"` - Phase string `json:"phase"` - ConfigHash string `json:"config_hash"` - CandidateID string `json:"candidate_id,omitempty"` - Reservations []Reservation `json:"reservations,omitempty"` + Models *modelpolicy.Selection `json:"model_selection,omitempty"` + SchemaVersion int `json:"schema_version"` + RunID string `json:"run_id"` + Iteration int `json:"iteration"` + Phase string `json:"phase"` + ConfigHash string `json:"config_hash"` + CandidateID string `json:"candidate_id,omitempty"` + Reservations []Reservation `json:"reservations,omitempty"` } type Reservation struct { - ID string `json:"id"` - Phase string `json:"phase"` - MaxMicroUSD int64 `json:"max_microusd"` - MaxTokens int64 `json:"max_tokens"` + Model *modelpolicy.Identity `json:"model,omitempty"` + ID string `json:"id"` + Phase string `json:"phase"` + MaxMicroUSD int64 `json:"max_microusd"` + MaxTokens int64 `json:"max_tokens"` } type Preflight struct { SchemaVersion int `json:"schema_version"` @@ -71,11 +76,12 @@ type Preflight struct { Calls []Reservation `json:"calls"` } type Call struct { - ID string `json:"id"` - Phase string `json:"phase"` - Status string `json:"status"` // actual | unknown | skipped - MicroUSD *int64 `json:"microusd,omitempty"` - Tokens *int64 `json:"tokens,omitempty"` + Model *modelpolicy.Identity `json:"model,omitempty"` + ID string `json:"id"` + Phase string `json:"phase"` + Status string `json:"status"` // actual | unknown | skipped + MicroUSD *int64 `json:"microusd,omitempty"` + Tokens *int64 `json:"tokens,omitempty"` } type Usage struct { SchemaVersion int `json:"schema_version"` diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 883d64c..23e4e8c 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -[[ "$#" -eq 2 ]] || { echo "usage: build-release.sh " >&2; exit 2; } +[[ "$#" -eq 2 ]] || { echo "usage: build-release.sh " >&2; exit 2; } TAG="$1" OUT="$2" -[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "a stable vX.Y.Z tag is required" >&2; exit 2; } command -v jq >/dev/null +go run ./tools/release-policy "$TAG" >/dev/null version="$(go run ./cmd/loopexec version --json | jq -er '.version')" [[ "v$version" == "$TAG" ]] || { echo "source version differs from release tag" >&2; exit 1; } go test ./cmd/loopexec -run '^TestDocumentationContract$' -count=1 diff --git a/scripts/model-assignment-proof.sh b/scripts/model-assignment-proof.sh new file mode 100644 index 0000000..9b5f73d --- /dev/null +++ b/scripts/model-assignment-proof.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail +fail() { printf 'MODEL_ASSIGNMENT_PROOF_FAIL: %s\n' "$1" >&2; exit 1; } +[[ "$#" -eq 3 ]] || fail "usage: model-assignment-proof.sh " +BIN="$(cd "$(dirname "$1")" && pwd -P)/$(basename "$1")" +ADAPTER="$(cd "$(dirname "$2")" && pwd -P)/$(basename "$2")" +OUT="$3" +[[ ! -e "$OUT" ]] || fail "preserve existing proof directory" +mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd -P)" +expect() { + local want="$1" result="$2" code + shift 2 + set +e + "$@" > "$result.json" 2> "$result.stderr" + code=$? + set -e + [[ "$code" -eq "$want" ]] || fail "unexpected exit $code, expected $want; inspect the relative proof case" +} +for scenario in normal budget reserve-drift usage-drift technical; do + ROOT="$OUT/$scenario" + "$ADAPTER" init --dir "$ROOT" --scenario "$scenario" + expect 0 "$OUT/$scenario-plan" "$BIN" models plan --json --workflow workflow.json --workdir "$ROOT" + [[ ! -e "$ROOT/.loopexec" && ! -e "$ROOT/simulated-execution.json" ]] || fail "planning performed work" + want=10 + case "$scenario" in + budget) want=18 ;; + reserve-drift|usage-drift) want=19 ;; + technical) want=12 ;; + esac + expect "$want" "$OUT/$scenario-run" "$BIN" run --json --workdir "$ROOT" --workflow workflow.json --run-id roles --max-iterations 1 --timeout 15s --command-timeout 2s + expect 0 "$OUT/$scenario-report" "$BIN" report --json --workdir "$ROOT" --run-id roles +done +[[ ! -e "$OUT/budget/simulated-execution.json" ]] || fail "budget denial launched work" +[[ ! -e "$OUT/reserve-drift/simulated-execution.json" ]] || fail "identity mismatch launched work" +expect 10 "$OUT/normal-resume" "$BIN" run --json --workdir "$OUT/normal" --workflow workflow.json --run-id roles --max-iterations 1 --timeout 15s --command-timeout 2s --resume +expect 0 "$OUT/normal-replay" "$BIN" replay --json --workdir "$OUT/normal" --run-id roles +expect 0 "$OUT/normal-attest" "$BIN" attest --json --workdir "$OUT/normal" --run-id roles +expect 0 "$OUT/normal-verify" "$BIN" attest --verify --json --workdir "$OUT/normal" --run-id roles +printf 'MODEL_ASSIGNMENT_PROOF_OK: two configured roles, no-spend planning, pre/post-call drift guards, unchanged budget/technical gates, resume/replay/attestation; all model work synthetic\n' diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh new file mode 100644 index 0000000..63286cb --- /dev/null +++ b/scripts/publish-release.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +[[ "$#" -eq 2 ]] || { echo "usage: publish-release.sh " >&2; exit 2; } +TAG="$1" +OUT="$2" +policy="$(go run ./tools/release-policy "$TAG")" +prerelease="$(printf '%s' "$policy" | jq -er '.prerelease | tostring')" +latest="$(printf '%s' "$policy" | jq -er '.latest | tostring')" +[[ -f "$OUT/checksums.txt" && -f "docs/releases/$TAG.md" ]] || { echo "release packages or notes missing" >&2; exit 2; } +go run ./tools/docs-continuity verify-bundle "$OUT/documentation" +go run ./tools/docs-continuity verify-site "$OUT/documentation/manifest.json" +gh release create "$TAG" "$OUT"/loopexec-*.tar.gz "$OUT"/loopexec-*.zip "$OUT/checksums.txt" \ + --verify-tag --draft "--prerelease=$prerelease" "--latest=$latest" \ + --title "LoopExec $TAG" --notes-file "docs/releases/$TAG.md" +go run ./tools/docs-continuity verify-site "$OUT/documentation/manifest.json" +gh release edit "$TAG" --draft=false "--prerelease=$prerelease" "--latest=$latest" diff --git a/tools/docs-continuity/main_test.go b/tools/docs-continuity/main_test.go index fb967fb..f512a9e 100644 --- a/tools/docs-continuity/main_test.go +++ b/tools/docs-continuity/main_test.go @@ -220,6 +220,14 @@ func TestReleasePublicationGatesRemainWired(t *testing.T) { t.Fatal(err) } text := string(data) + if !strings.Contains(text, `bash scripts/publish-release.sh "$RELEASE_TAG" dist`) || !strings.Contains(text, "verify-site dist/documentation/manifest.json") { + t.Fatal("release workflow must delegate publication after deployed-docs verification") + } + data, err = os.ReadFile("../../scripts/publish-release.sh") + if err != nil { + t.Fatal(err) + } + text = string(data) check := strings.Index(text, "verify-site") create := strings.Index(text, "gh release create") publish := strings.Index(text, "--draft=false") diff --git a/tools/release-policy/main.go b/tools/release-policy/main.go new file mode 100644 index 0000000..6e195f8 --- /dev/null +++ b/tools/release-policy/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "strings" +) + +type releasePolicy struct { + Version string `json:"version"` + Prerelease bool `json:"prerelease"` + Latest bool `json:"latest"` +} + +var releaseTag = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-rc\.[1-9][0-9]*)?$`) + +func policyFor(tag string) (releasePolicy, error) { + if len(tag) > 100 || !releaseTag.MatchString(tag) { + return releasePolicy{}, fmt.Errorf("release tag must be vX.Y.Z or vX.Y.Z-rc.N with canonical numeric components") + } + pre := strings.Contains(tag, "-rc.") + return releasePolicy{Version: strings.TrimPrefix(tag, "v"), Prerelease: pre, Latest: !pre}, nil +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: release-policy ") + os.Exit(2) + } + policy, err := policyFor(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + if err := json.NewEncoder(os.Stdout).Encode(policy); err != nil { + os.Exit(1) + } +} diff --git a/tools/release-policy/main_test.go b/tools/release-policy/main_test.go new file mode 100644 index 0000000..7d8afb3 --- /dev/null +++ b/tools/release-policy/main_test.go @@ -0,0 +1,23 @@ +package main + +import "testing" + +func TestReleaseChannels(t *testing.T) { + for _, tag := range []string{"v0.4.1", "v0.5.0", "v1.0.0"} { + got, err := policyFor(tag) + if err != nil || got.Prerelease || !got.Latest || "v"+got.Version != tag { + t.Fatalf("stable policy: %+v %v", got, err) + } + } + for _, tag := range []string{"v0.5.0-rc.1", "v1.0.0-rc.12"} { + got, err := policyFor(tag) + if err != nil || !got.Prerelease || got.Latest || "v"+got.Version != tag { + t.Fatalf("RC must not replace stable latest: %+v %v", got, err) + } + } + for _, tag := range []string{"", "main", "v0.5.0-dev", "v0.5.0-beta.1", "v0.5.0-rc.0", "v0.5.0-rc.01", "v01.2.3", "v1.02.3", "v1.2.03", "v1.2.3;echo", "v1.2.3\n"} { + if _, err := policyFor(tag); err == nil { + t.Fatalf("invalid publish tag accepted: %q", tag) + } + } +} diff --git a/tools/release-policy/publish_test.go b/tools/release-policy/publish_test.go new file mode 100644 index 0000000..9cec5bc --- /dev/null +++ b/tools/release-policy/publish_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPublicationChannelAndReadback(t *testing.T) { + for _, tc := range []struct { + tag string + fail bool + }{{"v0.5.0", false}, {"v0.5.0-rc.1", false}, {"v0.5.0-rc.1", true}} { + t.Run(tc.tag+map[bool]string{true: "-readback-failure"}[tc.fail], func(t *testing.T) { + dir := t.TempDir() + write := func(name, data string, mode os.FileMode) { + t.Helper() + p := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(data), mode); err != nil { + t.Fatal(err) + } + } + policy, _ := policyFor(tc.tag) + encoded, _ := json.Marshal(policy) + write("bin/go", "#!/bin/sh\nprintf 'go %s\\n' \"$*\" >> \"$COMMAND_LOG\"\nshift\ncase \"$1\" in\n./tools/release-policy) printf '%s\\n' '"+string(encoded)+"';;\n./tools/docs-continuity) if [ \"$2\" = verify-site ]; then\n if [ -f \"$SITE_SEEN\" ] && [ \"$FAIL_SECOND\" = 1 ]; then exit 1; fi\n touch \"$SITE_SEEN\"\n fi;;\nesac\n", 0755) + write("bin/gh", "#!/bin/sh\nprintf 'gh %s\\n' \"$*\" >> \"$COMMAND_LOG\"\n", 0755) + for _, name := range []string{"dist/checksums.txt", "dist/loopexec-test.tar.gz", "dist/loopexec-test.zip", "docs/releases/" + tc.tag + ".md"} { + write(name, "fixture", 0644) + } + script, err := filepath.Abs("../../scripts/publish-release.sh") + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("bash", script, tc.tag, "dist") + cmd.Dir = dir + fail := "0" + if tc.fail { + fail = "1" + } + cmd.Env = append(os.Environ(), "PATH="+filepath.Join(dir, "bin")+string(os.PathListSeparator)+os.Getenv("PATH"), "COMMAND_LOG="+filepath.Join(dir, "commands"), "SITE_SEEN="+filepath.Join(dir, "site-seen"), "FAIL_SECOND="+fail) + out, err := cmd.CombinedOutput() + if (err != nil) != tc.fail { + t.Fatalf("error=%v, output=%s", err, out) + } + data, err := os.ReadFile(filepath.Join(dir, "commands")) + if err != nil { + t.Fatal(err) + } + log := string(data) + create := strings.Index(log, "gh release create") + first := strings.Index(log, "go run ./tools/docs-continuity verify-site") + last := strings.LastIndex(log, "go run ./tools/docs-continuity verify-site") + edit := strings.Index(log, "gh release edit") + if first < 0 || create <= first || last <= create { + t.Fatalf("missing readback gates: %s", log) + } + if tc.fail && edit >= 0 || !tc.fail && edit <= last { + t.Fatalf("publication not gated: %s", log) + } + flags := "--prerelease=false --latest=true" + if policy.Prerelease { + flags = "--prerelease=true --latest=false" + } + for _, line := range strings.Split(log, "\n") { + if strings.HasPrefix(line, "gh release ") && !strings.Contains(line, flags) { + t.Fatalf("wrong channel: %s", line) + } + } + }) + } +} From d8e219dbf80c7d3c8f90526e0c7882cc31e502dc Mon Sep 17 00:00:00 2001 From: Justin Johnson Date: Thu, 15 Oct 2026 11:08:38 -0700 Subject: [PATCH 2/3] fix: preserve audit ancestry in hosted co-change checks --- .github/workflows/ci.yml | 2 +- .small/progress.small.yml | 105 +++++++++++++++++++++++++++++ .small/workspace.small.yml | 2 +- CHANGELOG.md | 2 + docs/documentation-continuity.md | 4 ++ tools/docs-continuity/main_test.go | 11 +++ 6 files changed, 124 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1f17d2..2f5bfd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} run: | - git fetch --no-tags --depth=1 origin "$BASE_SHA" + git fetch --no-tags origin "$BASE_SHA" git diff --name-only "$BASE_SHA" HEAD | ./scripts/check-small-sync.sh git diff --name-only "$BASE_SHA" HEAD | bash ./scripts/check-docs-sync.sh - name: Verify documentation contract and source-bound bundle diff --git a/.small/progress.small.yml b/.small/progress.small.yml index 7a9c27c..01a9945 100644 --- a/.small/progress.small.yml +++ b/.small/progress.small.yml @@ -5315,3 +5315,108 @@ entries: status: in_progress task_id: task-44 timestamp: "2026-10-15T18:06:16.528837000Z" + - command: git add examples/model-assignment internal/modelpolicy internal/workflow/contract.go scripts/build-release.sh scripts/model-assignment-proof.sh scripts/publish-release.sh tools/release-policy tools... + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.707108000Z.txt + command_sha256: f117be87c698b4d26dc3cbfe0199edda07b73c535aacf1dd7573bcaa2a6781a2 + command_summary: git add examples/model-assignment internal/modelpolicy internal/workflow/contract.go scripts/build-release.sh scripts/model-assignment-proof.sh scripts/publish-release.sh tools/release-policy tools... + command_summary_version: 2 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:06:16.707108000Z" + - command: GOCACHE="$PWD/../go-cache" go run ./tools/audit-privacy --base origin/main + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180631.708034000Z.txt + command_sha256: 238396b1559892e3f67c9416e114dd4952e3d631b0cae046468922a3a5ab3656 + command_summary: GOCACHE="$PWD/../go-cache" go run ./tools/audit-privacy --base origin/main + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:06:31.708034000Z" + - command: 'git commit -m "feat: visible model assignments and guarded RC publication"' + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180631.996409000Z.txt + command_sha256: 8fc6667e1ea83c0c1fd9072bb9f2f09a9c5027f4305b7f600e5ac35c9a6ea5c5 + command_summary: 'git commit -m "feat: visible model assignments and guarded RC publication"' + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:06:31.996409000Z" + - command: git push -u origin feature/model-assignments + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180642.330452000Z.txt + command_sha256: b2b0981ccdc04c6ac93bff3d5151d0029f9bfb6004679730ed83878c2eb65b7b + command_summary: git push -u origin feature/model-assignments + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:06:42.330452000Z" + - command: gh pr create --base main --head feature/model-assignments --title "Model assignments and guarded v0.5.0 RC publication" --body "Adds visible provider-neutral task/role profiles, no-spend planning a... + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180700.764203000Z.txt + command_sha256: d1d64a47f8c603ded238b7f999709fe590bba98803877decdc1e749de80f4149 + command_summary: gh pr create --base main --head feature/model-assignments --title "Model assignments and guarded v0.5.0 RC publication" --body "Adds visible provider-neutral task/role profiles, no-spend planning a... + command_summary_version: 2 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:07:00.764203000Z" + - command: GOCACHE="$PWD/../go-cache" go test ./tools/docs-continuity ./tools/release-policy -count=1 + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180826.636328000Z.txt + command_sha256: 04172055c8b1ca21e87f044695f2ca0296048c73be656aeff6c64acaab57b207 + command_summary: GOCACHE="$PWD/../go-cache" go test ./tools/docs-continuity ./tools/release-policy -count=1 + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:08:26.636328000Z" + - command: GOCACHE="$PWD/../go-cache" go run ./tools/docs-continuity bundle ../documentation-rc1-ci-fix + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180837.933109000Z.txt + command_sha256: 359559b2cd80e3d2ed4822dff7e255dfded92fedd297b5127fd01364e949f8c6 + command_summary: GOCACHE="$PWD/../go-cache" go run ./tools/docs-continuity bundle ../documentation-rc1-ci-fix + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:08:37.933109000Z" + - evidence: Hosted PR failed because the later depth-one co-change fetch severed immutable privacy-baseline ancestry. Removed only that depth limit, retained full checkout and privacy enforcement, and added wiring regression test plus matching continuity prose. + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:08:38.086849000Z" diff --git a/.small/workspace.small.yml b/.small/workspace.small.yml index d3f4211..3e74cea 100644 --- a/.small/workspace.small.yml +++ b/.small/workspace.small.yml @@ -2,6 +2,6 @@ small_version: "1.0.0" owner: agent kind: repo-root created_at: "2026-01-20T23:56:04Z" -updated_at: "2026-10-15T18:06:16.608395Z" +updated_at: "2026-10-15T18:08:38.164644Z" run: replay_id: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 diff --git a/CHANGELOG.md b/CHANGELOG.md index a2562ba..c729dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Future recipes and workflow adapters will remain configurable, opt-in integratio - Added tested stable/RC publication policy. RC tags publish as prereleases, never Latest, with the same source-bound deployed-documentation gates. +- Preserve full Git history during CI co-change fetches so the immutable audit + privacy baseline remains an ancestor on PR and main builds. See [candidate notes](docs/releases/v0.5.0-rc.1.md). Stable promotion requires separate consumer acceptance; publication status is on GitHub Releases. diff --git a/docs/documentation-continuity.md b/docs/documentation-continuity.md index a79af52..c5a1e77 100644 --- a/docs/documentation-continuity.md +++ b/docs/documentation-continuity.md @@ -99,6 +99,10 @@ An administrator or direct manual publication can bypass repository automation; account rules and human semantic review are still required. Do not claim they are configured without live verification. +Full Git history must remain available throughout CI: a later shallow fetch of +the co-change base can sever the older immutable audit-baseline ancestry even +after an initial full checkout. The co-change fetch must not use a depth limit. + The gates are committed on main and run in CI. The separate website consumes the canonical bundle and exposes the manifest, raw documents and homepage marker. A successful previous deployment does not prove that a new bundle is deployed: diff --git a/tools/docs-continuity/main_test.go b/tools/docs-continuity/main_test.go index f512a9e..dcc59ae 100644 --- a/tools/docs-continuity/main_test.go +++ b/tools/docs-continuity/main_test.go @@ -244,3 +244,14 @@ func TestReleasePublicationGatesRemainWired(t *testing.T) { } } } + +func TestCICochangeFetchPreservesAuditAncestry(t *testing.T) { + data, err := os.ReadFile("../../.github/workflows/ci.yml") + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "fetch-depth: 0") || !strings.Contains(text, `git fetch --no-tags origin "$BASE_SHA"`) || strings.Contains(text, "--depth=") { + t.Fatal("co-change fetch must preserve full audit-baseline ancestry") + } +} From bc20c61127deb8e783d6cf6724c57c3686f1b712 Mon Sep 17 00:00:00 2001 From: Justin Johnson Date: Thu, 15 Oct 2026 11:11:37 -0700 Subject: [PATCH 3/3] audit: retain reviewed portable command proofs for RC delivery --- .../commands/20261015T180616.384840000Z.txt | 1 + .../commands/20261015T180616.528837000Z.txt | 1 + .../commands/20261015T180616.707108000Z.txt | 1 + .../commands/20261015T180700.764203000Z.txt | 1 + .small/progress.small.yml | 56 +++++++++++++++++++ .small/workspace.small.yml | 2 +- 6 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 .small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.384840000Z.txt create mode 100644 .small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.528837000Z.txt create mode 100644 .small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.707108000Z.txt create mode 100644 .small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180700.764203000Z.txt diff --git a/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.384840000Z.txt b/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.384840000Z.txt new file mode 100644 index 0000000..fe46d53 --- /dev/null +++ b/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.384840000Z.txt @@ -0,0 +1 @@ +git add .github/workflows/ci.yml .github/workflows/release.yml CHANGELOG.md README.md SPEC.md cmd/loopexec/documentation_test.go cmd/loopexec/main.go cmd/loopexec/models.go cmd/loopexec/models_test.go cmd/loopexec/receipt.go cmd/loopexec/report.go cmd/loopexec/run.go cmd/loopexec/workflow.go \ No newline at end of file diff --git a/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.528837000Z.txt b/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.528837000Z.txt new file mode 100644 index 0000000..bb5946f --- /dev/null +++ b/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.528837000Z.txt @@ -0,0 +1 @@ +git add docs/cli-reference.generated.md docs/cli.md docs/documentation-continuity.md docs/documentation-files.json docs/integrations.md docs/model-assignments.md docs/releases/v0.5.0-dev.md docs/releases/v0.5.0-rc.1.md docs/status-and-roadmap.md docs/workflows.md \ No newline at end of file diff --git a/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.707108000Z.txt b/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.707108000Z.txt new file mode 100644 index 0000000..7e03655 --- /dev/null +++ b/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180616.707108000Z.txt @@ -0,0 +1 @@ +git add examples/model-assignment internal/modelpolicy internal/workflow/contract.go scripts/build-release.sh scripts/model-assignment-proof.sh scripts/publish-release.sh tools/release-policy tools/docs-continuity/main_test.go .small/plan.small.yml .small/progress.small.yml .small/handoff.small.yml .small/workspace.small.yml \ No newline at end of file diff --git a/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180700.764203000Z.txt b/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180700.764203000Z.txt new file mode 100644 index 0000000..ade5d58 --- /dev/null +++ b/.small-command-proofs/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180700.764203000Z.txt @@ -0,0 +1 @@ +gh pr create --base main --head feature/model-assignments --title "Model assignments and guarded v0.5.0 RC publication" --body "Adds visible provider-neutral task/role profiles, no-spend planning and explicit model-bound adapter dispatch. Drift preserves incurred usage; existing budgets and acceptance remain unchanged. Includes canonical docs, generated CLI contract, offline proof and stable/RC release policy tests. Full Go suite, vet, internal race and isolated no-model consumer protocol smoke pass. Paired docs deployment and official readback remain required before RC publication; no live critic or stable promotion." \ No newline at end of file diff --git a/.small/progress.small.yml b/.small/progress.small.yml index 01a9945..65748fd 100644 --- a/.small/progress.small.yml +++ b/.small/progress.small.yml @@ -5420,3 +5420,59 @@ entries: status: in_progress task_id: task-44 timestamp: "2026-10-15T18:08:38.086849000Z" + - command: git add .github/workflows/ci.yml tools/docs-continuity/main_test.go docs/documentation-continuity.md CHANGELOG.md .small/progress.small.yml .small/workspace.small.yml + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180838.325721000Z.txt + command_sha256: 9512485a20c90ce468ec3fe04b959bd425de7acc1d6cd324a9706957a44afc8b + command_summary: git add .github/workflows/ci.yml tools/docs-continuity/main_test.go docs/documentation-continuity.md CHANGELOG.md .small/progress.small.yml .small/workspace.small.yml + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:08:38.325721000Z" + - command: 'git commit -m "fix: preserve audit ancestry in hosted co-change checks"' + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180838.664441000Z.txt + command_sha256: 5c2fefb30f1202e92d25906681fe33a4cd972fb5547445582b17796a7070ee4e + command_summary: 'git commit -m "fix: preserve audit ancestry in hosted co-change checks"' + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:08:38.664441000Z" + - command: git push origin feature/model-assignments + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T180840.611697000Z.txt + command_sha256: 5f8ac29a92bfee0f2dca439a495190bbebdd77888ce47de1b06060aff80e23dd + command_summary: git push origin feature/model-assignments + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:08:40.611697000Z" + - command: GOCACHE="$PWD/../go-cache" go run ./tools/audit-privacy --base origin/main + command_ref: .small-cache/logs/f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96/commands/20261015T181136.402148000Z.txt + command_sha256: 238396b1559892e3f67c9416e114dd4952e3d631b0cae046468922a3a5ab3656 + command_summary: GOCACHE="$PWD/../go-cache" go run ./tools/audit-privacy --base origin/main + evidence: + exit_code: 0 + kind: cli_execution + outcome: succeeded + strength: cli_captured + notes: 'apply: command outcome succeeded; task acceptance unchanged' + replayId: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96 + status: in_progress + task_id: task-44 + timestamp: "2026-10-15T18:11:36.402148000Z" diff --git a/.small/workspace.small.yml b/.small/workspace.small.yml index 3e74cea..8598fa1 100644 --- a/.small/workspace.small.yml +++ b/.small/workspace.small.yml @@ -2,6 +2,6 @@ small_version: "1.0.0" owner: agent kind: repo-root created_at: "2026-01-20T23:56:04Z" -updated_at: "2026-10-15T18:08:38.164644Z" +updated_at: "2026-10-15T18:11:36.482552Z" run: replay_id: f6bbda15d790df6d023dea340edd1b85ccac89c82db362652a1b656981e82d96