diff --git a/.github/workflows/auto-estimate.yml b/.github/workflows/auto-estimate.yml new file mode 100644 index 0000000..6912fe0 --- /dev/null +++ b/.github/workflows/auto-estimate.yml @@ -0,0 +1,24 @@ +name: Auto-estimate + +# Label an issue `estimate` and agent-estimate posts an estimate comment +# on it. Recipe from kiloloop/agent-estimate README ("Auto-estimate on +# label") — consumes the published Action via the v0 tag. + +on: + issues: + types: [labeled] + +permissions: + contents: read + issues: write + +jobs: + estimate: + if: github.event.label.name == 'estimate' + runs-on: ubuntu-latest + steps: + - uses: kiloloop/agent-estimate@7b30943bac5648cf183f3d050baa6fea5485b8a9 # v0.7.4 + with: + issues: ${{ github.event.issue.number }} + output-mode: issue-comment + title: 'Agent Estimate — issue #${{ github.event.issue.number }}' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50935f9..80516b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,10 +14,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" cache: pip @@ -30,7 +30,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[crypto]" build pytest ruff + python -m pip install --group dev -e ".[crypto]" - name: Run quality gate run: make preflight ARGS="--full" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c970478..4b97063 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" cache: pip @@ -32,7 +32,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - python -m pip install -e ".[crypto]" build pytest ruff + python -m pip install --group dev -e ".[crypto]" - name: Run quality gate run: make preflight ARGS="--full" @@ -42,7 +42,7 @@ jobs: # upload-artifact is still on v6; download-artifact moved to v7 for Node 24. - name: Upload release artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: python-package-distributions path: dist/ @@ -59,7 +59,7 @@ jobs: steps: - name: Download release artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: python-package-distributions path: dist/ @@ -116,11 +116,11 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # download-artifact v7 is the current Node 24-compatible major. - name: Download release artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: python-package-distributions path: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c31224b..d39047e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,51 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.4.4] - 2026-08-28 + +The central debrief store is the headline change: every agent's full end-of-session debrief now lands in the kernel-owned, append-only `org-memory/debriefs////` layout — one immutable file per session — instead of per-project trees, with `oacp org-memory init` creating the store and `oacp doctor` checking its setup ([org memory](docs/protocol/org_memory.md#debrief-store)). + +### Added + +- Central session-debrief store under org-memory `debriefs/`, created by `oacp org-memory init` and setup-checked by `oacp doctor` ([org memory](docs/protocol/org_memory.md#debrief-store)). +- `oacp add-agent` and `oacp init --agents` populate the instance agent registry; `oacp agent sync` backfills existing workspaces ([agent profiles](docs/protocol/agent_profiles.md)). +- `oacp autonomy-finalize` writes and validates checkpoint and terminal autonomy audit updates under lock; `oacp doctor` sweeps audit directories with the same checks ([autonomy](docs/protocol/autonomy.md#terminal-finalization-and-audit-integrity)). +- Every persisted autonomy evaluation carries an `evaluation_id`; re-evaluations supersede the prior record through a locked transaction ([autonomy](docs/protocol/autonomy.md#terminal-finalization-and-audit-integrity)). +- The wheel ships the four kernel protocol docs, the wire message template, and five previously unpackaged scripts under `oacp/_protocol/`, `oacp/_templates/`, and `oacp/_scripts/`. +- Preflight and CI fail when `scripts/` and the wheel's force-include table drift apart. +- `waiting_on_peer` checkpoint sub-basis separates peer-reply latency from working time in a realized time breach ([threshold checkpoint](docs/protocol/autonomy.md#threshold-exceeded-checkpoint)). +- Conformance fixture pinning the default scope envelope stamped on profileless admits. + +### Removed + +- Zero-consumer scripts `normalize_findings.py`, `create_handoff_packet.py`, and `init_project_workspace.sh` with their Makefile targets; `oacp init` replaces `make init`. + +### Changed + +- `actual_minutes` is active wall-clock from receiver-stamped `work_started_at_utc`, excluding admission idle and re-authorization pauses ([threshold checkpoint](docs/protocol/autonomy.md#threshold-exceeded-checkpoint)). +- `expected_files_touched` counts distinct deliverable files only, not receiver bookkeeping, memory, cache, or scratch writes ([autonomy policy](docs/protocol/autonomy.md#message-fields)). +- Parent-less `oacp send` messages get an automatic thread identifier ([conversation threading](docs/protocol/inbox_outbox.md#conversation-threading)). +- The README `## Commands` table is generated from `oacp --help` via `make docs`, with a drift test. +- Moved the runtime capability matrix to the public [Kiloloop research repository](https://github.com/kiloloop/research/blob/main/runtime-comparison/runtime_capability_matrix.md). +- Moved the prompt-caching guide to the public [Kiloloop research repository](https://github.com/kiloloop/research/blob/main/runtime-comparison/prompt-caching-patterns.md), leaving a redirect at its former path. +- Raised the optional `cryptography` floor to 50.0.0 and the Python floor to 3.9.2 to exclude known-vulnerable crypto releases. +- Pinned the development and release toolchain through a shared dependency group and refreshed workflow actions to immutable commits. +- Folded [`SPEC.md`](SPEC.md) into a thin index over `docs/protocol/`; the package `Documentation` URL is unchanged. +- Pricing/commercial content sensitivity is an advisory instead of a hard stop for reply-only task profiles ([autonomy](docs/protocol/autonomy.md#four-gate-evaluator)). +- Enveloped sessions can no longer write the receiver's `audit/autonomy_decisions/` directly; only the canonical `oacp` audit writers can ([autonomy](docs/protocol/autonomy.md#envelope-compilation-phase-2)). + +### Fixed + +- Autonomy lexical classification records every match with its source span and demotion basis ([autonomy](docs/protocol/autonomy.md#lexical-provenance)). +- Negated or descriptive public-repository, dependency-install, and merge-method language no longer trips lexical hard stops; stacked negations and later unaccounted occurrences still cannot demote an affirmative match ([autonomy](docs/protocol/autonomy.md#lexical-provenance)). +- `SPEC.md` describes the archive-never-delete inbox lifecycle instead of saying recipients delete processed messages ([inbox/outbox](docs/protocol/inbox_outbox.md)). +- The envelope hook no longer counts phantom `files_touched` from heredoc bodies or unexpanded shell variables; variable-spelled write targets escalate to `ask` ([autonomy](docs/protocol/autonomy.md#envelope-compilation-phase-2)). +- Autonomy audit records carry a complete admission ledger (`admission_axes`), so a hard stop no longer masks the threshold, side-effect, or declaration axes ([autonomy](docs/protocol/autonomy.md#admission-ledger)). +- `declaration_error` pauses record which profile field failed and why. +- A granted checkpoint re-authorization widens the live file budget, so extra files no longer stay blocked after a valid grant ([autonomy](docs/protocol/autonomy.md#envelope-drift)). + ## [0.4.3] - 2026-08-12 ### Added @@ -762,6 +807,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Checkout step in github-release workflow job (#19) - Pre-release audit fixes: SHA-pinned actions, dangling doc refs (#15, #16) +[0.4.4]: https://github.com/kiloloop/oacp/compare/v0.4.3...v0.4.4 [0.4.3]: https://github.com/kiloloop/oacp/compare/v0.4.2...v0.4.3 [0.4.2]: https://github.com/kiloloop/oacp/compare/v0.4.1...v0.4.2 [0.4.1]: https://github.com/kiloloop/oacp/compare/v0.4.0...v0.4.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5749df0..f25d6b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,13 +34,16 @@ Please read and follow our [Code of Conduct](https://github.com/kiloloop/.github ## Development Setup +Development and test tooling requires Python 3.10 or newer. The installed CLI +continues to support Python 3.9.2 and newer. + ```bash # Clone git clone https://github.com/kiloloop/oacp.git cd oacp -# Install dependencies -pip install pyyaml pytest +# Install the package, crypto extra, and pinned development tools +python -m pip install --group dev -e ".[crypto]" # Verify setup make preflight @@ -81,6 +84,16 @@ make preflight ARGS="--full" - Keep the first line under 72 characters - Reference issue numbers where applicable: "Fix message validation (#42)" +## Changelog Entries + +Entries in `CHANGELOG.md` follow [Common Changelog](https://common-changelog.org) discipline: a changelog answers "does this affect me, and how", not "how does it work". + +- One line, one change — split a multi-facet feature into two or three scoped bullets rather than one long bullet. +- State the user-visible delta, not the implementation path. +- Do not inline flag enums or sub-mechanism walkthroughs — link the protocol spec or doc section that carries the detail. +- One link per bullet, pointing at the best entry point. +- Each bullet must read as self-describing without its `### Added`/`### Changed` heading. + ## License By contributing, you agree that your contributions will be licensed under the [Apache 2.0 License](LICENSE). diff --git a/Makefile b/Makefile index 7fbf0ac..09b29ca 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ endif # ── Targets ────────────────────────────────────────────────────────────── -.PHONY: help init update test validate validate-msg validate-card send quality packet handoff normalize preflight doctor +.PHONY: help update test docs validate validate-msg validate-card send quality packet preflight doctor help: ## Show available targets (default) @echo "OACP — task runner" @@ -26,15 +26,15 @@ help: ## Show available targets (default) @grep -E '^[a-z][-a-z]+:.*## ' $(MAKEFILE_LIST) | \ awk -F ':.*## ' '{ printf " %-14s %s\n", $$1, $$2 }' -init: _require-project ## Create a new project workspace - bash $(SCRIPTS_DIR)/init_project_workspace.sh $(PROJECT) $(ARGS) - update: _require-project ## Sync existing workspace with latest structure bash $(SCRIPTS_DIR)/update_workspace.sh $(PROJECT) $(ARGS) test: ## Run all tests python3 -m pytest $(SCRIPTS_DIR)/../tests -v $(ARGS) +docs: ## Regenerate the README command table from oacp --help + python3 $(SCRIPTS_DIR)/gen_readme_commands.py --write + validate-msg: ## Validate inbox messages python3 $(SCRIPTS_DIR)/validate_message.py $(ARGS) @@ -57,9 +57,3 @@ quality: ## Run quality gate check packet: _require-project ## Create a review/findings/merge packet bash $(SCRIPTS_DIR)/init_packet.sh $(PROJECT) $(ARGS) - -handoff: _require-project ## Create a structured handoff packet - python3 $(SCRIPTS_DIR)/create_handoff_packet.py $(PROJECT) $(ARGS) - -normalize: ## Normalize raw findings into canonical YAML - python3 $(SCRIPTS_DIR)/normalize_findings.py $(ARGS) diff --git a/QUICKSTART.md b/QUICKSTART.md index bc3db52..97b4780 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -4,7 +4,7 @@ Get from zero to your first agent-to-agent message in 5 minutes. ## Prerequisites -- Python 3.9+ and Bash 3.2+ +- Python 3.9.2+ and Bash 3.2+ ## 1. Set Up OACP Home diff --git a/README.md b/README.md index dac01fa..96e4982 100644 --- a/README.md +++ b/README.md @@ -225,22 +225,32 @@ uv tool install . ## Commands + | Command | Description | |---------|-------------| -| `oacp init` | Create a project workspace under `$OACP_HOME/projects/` | +| `oacp init` | Create a project workspace under $OACP_HOME/projects/ | | `oacp add-agent` | Add an agent to an existing project workspace | -| `oacp setup` | Generate runtime-specific config files (Claude, Codex, Cursor, Gemini) | -| `oacp send` | Send a protocol-compliant inbox message (`--from` auto-inferred) | -| `oacp inbox` | List pending messages across agents (table or `--json`) | -| `oacp watch` | Emit inbox delta events for one agent across selected projects | -| `oacp memory` | Archive, restore, or git-sync project/org memory files | -| `oacp session-init` | Verify Codex startup inputs, optionally pull memory, and update status | -| `oacp agent` | Manage global agent profiles (`init`, `show`, `list`) | -| `oacp org-memory` | Initialize org-level memory at `$OACP_HOME/org-memory/` | -| `oacp write-event` | Write an event to `org-memory/events/` | +| `oacp agent` | Manage global agent profiles (init, sync, show, list) | +| `oacp inbox` | List pending inbox messages | +| `oacp watch` | Emit inbox delta events for Monitor-friendly polling | +| `oacp retention` | Prune project message history by age and count | +| `oacp memory` | Archive, restore, or sync memory files | +| `oacp session-init` | Verify Codex startup inputs and emit SessionStart context | +| `oacp setup` | Generate runtime-specific config files in a repo | +| `oacp send` | Send a protocol-compliant inbox message | +| `oacp key` | Generate and inspect message-signing keys | +| `oacp trust` | Import, inspect, and revoke trust-root entries (catalog + pins) | +| `oacp org-memory` | Initialize org-level memory at $OACP_HOME/org-memory/ | +| `oacp write-event` | Write an event to org-memory/events/ | +| `oacp autonomy-outcome` | Record a human approval/decline in an autonomy audit | +| `oacp autonomy-finalize` | Record checkpoints and terminal states in an autonomy audit | +| `oacp envelope` | Compile, show, or clear the runtime envelope for a task | | `oacp doctor` | Check environment and workspace health | | `oacp validate` | Validate an inbox/outbox YAML message | -| `oacp --version` | Print the installed version | +| `oacp verify` | Verify a message's auth trailer against receiver-local pins | + + +`oacp --version` prints the installed version.
Key flags @@ -291,7 +301,7 @@ it is not part of the OACP product surface. ## Prerequisites -- Python 3.9+ +- Python 3.9.2+ - Bash 3.2+ (macOS default is fine) - `gh` CLI (optional, for GitHub operations) diff --git a/SPEC.md b/SPEC.md index c8557ce..5fb8871 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,748 +1,60 @@ # OACP — Protocol Specification -**Version**: 0.2.0 **License**: Apache-2.0 -This document is the protocol specification for OACP (Open Agent Coordination Protocol) — a file-based coordination layer for multi-agent engineering workflows. It defines the message formats, state machines, review processes, and safety rules that enable agents on different runtimes (Claude, Codex, Cursor, Gemini, or any future runtime) to collaborate asynchronously through a shared filesystem. - -OACP is not a framework or SDK. It is a set of conventions, YAML schemas, and shell scripts that any agent runtime can implement. - ---- - -## Table of Contents - -1. [Protocol Overview](#1-protocol-overview) -2. [Dispatch State Machine](#2-dispatch-state-machine) -3. [Review Loop Protocol](#3-review-loop-protocol) -4. [Cross-Runtime Sync](#4-cross-runtime-sync) -5. [Agent Safety Defaults](#5-agent-safety-defaults) -6. [Org-Level Memory and Sync](#6-org-level-memory-and-sync) -7. [Kernel Script Inventory](#7-kernel-script-inventory) - ---- - -## 1. Protocol Overview - -Full specification: [`docs/protocol/inbox_outbox.md`](docs/protocol/inbox_outbox.md) - -### Core Concept - -Agents communicate through a shared filesystem using YAML messages. Each agent has an inbox and outbox directory within a project workspace: - -``` -$OACP_HOME/ -├── agents/ # Global agent profiles -│ ├── claude/ -│ │ └── profile.yaml # Global identity defaults -│ └── codex/ -│ └── profile.yaml -└── projects// - ├── agents/ - │ ├── claude/ - │ │ ├── inbox/ # Other agents write here - │ │ ├── outbox/ # Claude's sent messages (copies) - │ │ ├── status.yaml # Dynamic agent state - │ │ └── agent_card.yaml # Static agent identity (overrides global) - │ ├── codex/ - │ │ ├── inbox/ - │ │ ├── outbox/ - │ │ └── ... - │ └── cursor/ - │ └── ... - ├── memory/ # Shared durable memory - │ ├── project_facts.md - │ ├── decision_log.md - │ ├── open_threads.md - │ └── known_debt.md - │ └── archive/ - ├── packets/ # Review/findings artifacts - │ ├── review/ - │ └── findings/ - ├── merges/ # Merge decision records - └── workspace.json # Project metadata -``` - -### Message Format - -Messages are YAML files. Filename convention: `__.yaml` - -**Required fields:** - -| Field | Type | Description | -|-------|------|-------------| -| `id` | string | Unique message ID: `msg---` | -| `from` | string | Sender agent name | -| `to` | string or list | Recipient(s) — string for point-to-point, list for broadcast (max 10) | -| `type` | string | Message type (see below) | -| `priority` | string | `P0` \| `P1` \| `P2` \| `P3` | -| `created_at_utc` | string | ISO 8601 UTC timestamp | -| `subject` | string | Subject line | -| `body` | string | Message content (multi-line markdown) | - -**Optional fields:** `expires_at`, `channel`, `autonomy_hint`, `related_packet`, -`related_pr`, `conversation_id`, `parent_message_id`, `context_keys`, plus the -review-loop telemetry fields `model`, `turns`, `input_tokens`, `output_tokens`, -`wall_time_s`, and `est_cost_usd` - -`autonomy_hint` is an advisory sender hint, such as `auto_proceed`. Receivers -remain authoritative: local autonomy config, message content, safety defaults, -and runtime/tool permissions determine whether a message can be accepted without -interactive human confirmation. - -Receiver autonomy is defined by `agents//config.yaml` and the -scope-envelope contract in [`docs/protocol/autonomy.md`](docs/protocol/autonomy.md). -Sender-marked `oacp-guardrails` body fences and the 45-minute standard -`auto_review` cap are defined there as well. - -### Message Types - -| Type | Purpose | Response Expected | -|------|---------|-------------------| -| `task_request` | Assign work to an agent | Reply or artifact | -| `question` | Request information or a decision | Reply message | -| `notification` | FYI update | None (unless reporting findings) | -| `follow_up` | Track non-blocking deferred work | None | -| `handoff` | Transfer ownership of in-flight work | `handoff_complete` | -| `handoff_complete` | Confirm handoff target is done | None | -| `review_request` | Request code review of a PR | `review_feedback` or `review_lgtm` | -| `review_feedback` | Return review findings | `review_addressed` | -| `review_addressed` | Report that feedback was addressed | New `review_request` | -| `review_lgtm` | Approve — quality gate passed | None (author may merge) | -| `brainstorm_request` | Open-ended research/analysis | Report in outbox | -| `brainstorm_followup` | Amend in-progress brainstorm scope | Incorporated into next round | - -### Message Lifecycle - -1. Sender creates a YAML message file. -2. Sender writes to `agents//inbox/.yaml`. -3. Sender copies to `agents//outbox/.yaml`. -4. Recipient reads from inbox when polling or at session start. -5. Recipient deletes from inbox after processing. -6. Replies are new messages written to the original sender's inbox. - -### Polling Convention - -Agents check their inbox at session start and after completing each major task. There is no real-time notification — this is a poll-based protocol. Wait states use shell/script polling loops, not LLM turns. - -### Processing Order - -Process by priority: P0 first, then P1, then P2/P3. Within the same priority, process oldest first (by filename timestamp). `task_request` and `review_request` take precedence over `notification` and `follow_up` at the same priority level. - -### Conversation Threading - -Optional fields enable multi-message threading: - -- **`conversation_id`** (`conv---`) — groups related messages -- **`parent_message_id`** — links a reply to the message it responds to -- **`context_keys`** — concise summary of prior context (under 500 words) - -### Broadcast - -The `to` field accepts a list for multi-recipient delivery. One copy per recipient inbox, one copy in sender's outbox. Max 10 recipients. `handoff` and `handoff_complete` are point-to-point only. - -### Task Negotiation - -Agents can negotiate work splits before starting implementation using a structured propose/ack/counter handshake. Full specification: [`docs/protocol/task_negotiation.md`](docs/protocol/task_negotiation.md). - ---- - -## 2. Dispatch State Machine - -Full specification: [`docs/protocol/dispatch_states.yaml`](docs/protocol/dispatch_states.yaml) - -The dispatch state machine tracks the lifecycle of a `task_request` from delivery to completion. It has two tracks: agent-side (the agent handling the task) and dispatcher-side (the orchestrator tracking progress). - -### Agent-Side States - -``` -received → accepted → working → pr_opened → in_review → done - ↘ ↘ ↘ ↑ - rejected blocked blocked changes_requested - ↘ - failed -``` - -| State | Description | Terminal | -|-------|-------------|----------| -| `received` | `task_request` delivered to inbox | No | -| `accepted` | Agent has read and accepted the task | No | -| `working` | Agent is actively implementing | No | -| `pr_opened` | Agent has opened a PR (work-in-progress) | No | -| `in_review` | PR is under cross-agent review | No | -| `changes_requested` | Reviewer requested changes | No | -| `done` | PR merged or deliverable provided | **Yes** | -| `rejected` | Agent cannot or will not complete the task | **Yes** | -| `blocked` | Agent is blocked and cannot continue | No | -| `failed` | Agent crashed or hit an unrecoverable error | No | - -### Key Transitions - -- **`received` → `accepted`**: Agent reads and accepts the task. Ack recommended for P0/P1. When accepted by autonomy policy, this transition is preserved and records `accepted_by`, `human_confirmed`, `autonomy_mode`, `policy_ref`, `policy_hash`, and `reason_codes`. -- **`working` → `pr_opened`**: Agent opens a PR. **Notification required** with `WIP:` subject prefix. -- **`pr_opened` → `in_review`**: Agent requests cross-agent review via `review_request`. -- **`in_review` → `done`**: PR merged after review approval. Guard: `pr_merged AND review_approved`. -- **`working` → `done`**: Non-PR task completed (research, advisory). -- **`failed` → `working`**: Retry requires explicit dispatcher authorization (prevents retry storms). - -### Dispatcher-Side States - -The dispatcher (e.g., an orchestrator agent) tracks dispatches from its perspective: - -| State | Trigger | -|-------|---------| -| `sent` | Dispatch message written to agent inbox | -| `ackd` | Agent acknowledged receipt | -| `in_progress` | Within SLA window, no PR yet | -| `in_review` | WIP notification received with `related_pr` | -| `done` | Final notification with merge SHA or results | -| `responded` | Agent replied to non-PR task (terminal for non-PR tasks) | -| `blocked` | Agent reported a blocker | -| `rejected` | Agent rejected the task | -| `overdue` | No response beyond SLA threshold (computed) | - -### SLA Thresholds - -| Priority | First Response SLA | -|----------|--------------------| -| P0 | 2 hours | -| P1 | 24 hours | -| P2 | 48 hours | -| P3 | No SLA | - -### Done Criteria - -- **PR-based tasks**: done = PR merged, not opened -- **Non-PR tasks**: done = deliverable provided or question answered -- Final notification must include `merge_sha` (PR tasks) or `results_summary` (non-PR tasks) - -### Acceptance Metadata - -Receivers must not collapse `received → accepted`, even when a task is -auto-accepted. Transition metadata records who or what accepted the task: - -```yaml -transition: received_to_accepted -accepted_by: autonomy_policy # human | autonomy_policy | --autonomous-flag -human_confirmed: false -autonomy_mode: auto_review -policy_ref: agents/codex/config.yaml -policy_hash: sha256:... -reason_codes: - - task_profile_present - - risk_threshold_passed -``` - -Autonomy audit results use a stable terminal-state taxonomy -(`done`, `paused`, `blocked`, `superseded`, `error`) plus -`completion_kind` for detailed outcomes. Post-acceptance scope drift is recorded -through the threshold checkpoint described in -[`docs/protocol/autonomy.md`](docs/protocol/autonomy.md). - -### Brainstorm Lifecycle - -Brainstorm messages follow a simpler lifecycle: `received` → `researching` → `report_delivered`. No PR, no review loop. The dispatcher sees: `Sent` → `In progress` → `Responded`. - ---- - -## 3. Review Loop Protocol - -Full specification: [`docs/protocol/review_loop.md`](docs/protocol/review_loop.md) -Packet state machine: [`docs/protocol/packet_states.yaml`](docs/protocol/packet_states.yaml) -Shared workspace protocol: [`docs/protocol/multi_agent_shared_workspace.md`](docs/protocol/multi_agent_shared_workspace.md) - -### Two Review Mechanisms - -OACP provides two complementary review mechanisms: - -1. **Packet-based review** — formal review/findings/merge artifact lifecycle for heavyweight changes. Defined in `multi_agent_shared_workspace.md`. -2. **Inbox-based review loop** — lightweight, message-driven review for PR-level code review. Defined in `review_loop.md`. - -Both share the same quality gate criteria. - -### Packet-Based Review Flow - -``` -Implementer creates Review Packet - ↓ -Reviewer returns Findings Packet (batched) - ↓ -Implementer addresses findings + publishes Merge Decision - ↓ -If blockers remain → Round 2 (max 2 async rounds) - ↓ -If still unresolved → Escalate to synchronous decision -``` - -**Packet state machine**: `submitted` → `in_review` → `findings_returned` → `fixing` → `merge_decision` → `merged` (or `escalated`). - -**Artifacts:** -- Review Packet (`packets/review/.md`) — scope, risk map, validation results, rollback notes -- Findings Packet (`packets/findings/.yaml`) — severity (`P0`-`P3`), `blocking` flag, `status` per finding -- Merge Decision (`merges/.md`) — resolution table, post-fix validation, remaining blockers, QA signoff - -**Naming convention**: `___r` - -### Inbox-Based Review Loop - -The review loop uses four message types over the inbox protocol: - -``` -Author Reviewer (invocation A) - | | - |─── review_request ───────────────────→ | (PR#, branch, summary, budgets) - | | - |←── review_feedback OR review_lgtm ──── | (one terminal response, then exit) - | - |─── review_addressed ────────────────→ | (commit SHA, addressed summary) - |─── review_request (round N+1) ──────→ | (explicit re-invocation) - | | - |←── review_feedback OR review_lgtm ──── | (invocation B terminal response) -``` - -**Stateless reviewer model**: A reviewer invocation handles exactly one round, produces one terminal response (`review_feedback` or `review_lgtm`), and exits. The author coordinates multi-round progression by re-invoking the reviewer. - -### Quality Gate - -A change is merge-ready when: - -- No unresolved `P0` findings -- No unresolved blocking findings (`P1` and blocking `P2`) -- Deferred non-blocking findings (`P2`/`P3`) captured in `review_lgtm.nits` with owner and tracking reference -- All validation commands recorded with passing outcomes -- Deploy-affecting changes include rollback notes (packet-based review) -- QA signoff recorded with verdict `approved` (packet-based review) - -### Risk-Tiered Finding Outcomes - -| Severity | Merge Impact | Exit Path | -|----------|-------------|-----------| -| `P0` | Always blocking | Must fix before LGTM | -| `P1` | Blocking | Must fix before LGTM | -| `P2` | Reviewer judgment | Block if material risk; otherwise defer to `review_lgtm.nits` | -| `P3` | Non-blocking | Capture in `review_lgtm.nits`, proceed to LGTM | - -### Round Limits and Budget Controls - -- **Default max rounds**: 2 (configurable up to 3 per project) -- **Reviewer turn budget**: `max_turns_reviewer` (default 8) -- **Reviewer time budget**: `max_runtime_s_reviewer` (default 600s) -- Budget exhaustion triggers `review_feedback` with `escalation: reviewer_budget_exceeded` - -### Post-LGTM Nit Lifecycle - -Non-blocking items deferred at LGTM follow this lifecycle: - -1. **Capture** (reviewer) — include in `review_lgtm.body.nits` with `nit_id`, `owner`, `next_action` -2. **Adopt** (author, at merge) — confirm ownership, preserve nits list -3. **Batch** (author, within 24h) — open tracking issue per PR -4. **Resolve or expire** — 14-day status update, 30-day close/escalate (or `expires_at_utc`) - ---- - -## 4. Cross-Runtime Sync - -Full specification: [`docs/protocol/cross_runtime_sync.md`](docs/protocol/cross_runtime_sync.md) -Session init protocol: [`docs/protocol/session_init.md`](docs/protocol/session_init.md) -Runtime capabilities: [`docs/protocol/runtime_capabilities.md`](docs/protocol/runtime_capabilities.md) - -### Problem - -Multi-agent workflows span different runtimes — each with its own context window, memory mechanism, and conversation state. Without explicit sync, agents lose context at handoff boundaries, duplicate decisions, or contradict prior work. - -### Three Sync Mechanisms - -#### 1. Durable Memory Files (most durable) - -Location: `$OACP_HOME/projects//memory/` - -| File | Purpose | -|------|---------| -| `project_facts.md` | Agent roles, repo structure, architecture, conventions | -| `decision_log.md` | Timestamped decisions with rationale | -| `open_threads.md` | Unresolved issues, blocked epics, cross-agent coordination | -| `known_debt.md` | Verified unresolved debt and recurring cleanup items | - -The top-level `memory/` files are the active working set. Historical memory can be moved into `memory/archive/`, which is not loaded at session start by default. - -All runtimes read the active memory files at session start. Only stable, verified outcomes are written here. Promotion flows through merge decisions via a project-defined durable-memory promotion mechanism. - -#### 2. Handoff Messages with Context Keys (ephemeral) - -When handing off work between agents (especially across runtimes), the sender includes `context_keys` in the handoff message — decisions made, artifacts produced, open questions, and what was tried and failed. - -#### 3. Packet-Based Review Artifacts (structured) - -Review packets, findings packets, and merge decisions form a structured audit trail. Any runtime can parse the fixed schema to understand what was reviewed, found, and resolved. - -### Sync Points - -| Sync Point | Direction | Action | -|------------|-----------|--------| -| Session start | Memory → Agent | Read all 4 active memory files (not `memory/archive/`) | -| Task completion | Agent → Memory | Write stable outcomes via merge decision | -| Handoff | Agent → Agent | Include `conversation_id` + `context_keys` | -| Review cycle start | Packets → Agent | Read relevant packet history | -| PR merge | Agent → Memory | Update memory if conventions/architecture changed | - -### Session Init Protocol - -Agents follow a 6-step init sequence at session start: - -1. **Load global rules** (required) — safety defaults, tool preferences -2. **Load project rules** (required) — repo structure, conventions -3. **Load durable memory** (required) — project facts, decisions, open threads, known debt -4. **Check inbox** (optional) — summarize pending messages -5. **Load skills/tools** (optional) — runtime-specific capabilities -6. **Report status** (required) — update `status.yaml` - -All init failures are degraded mode, not hard blocks. Safety defaults from `agent_safety_defaults.md` cannot be relaxed by project rules. - -### Runtime-Specific Notes - -| Concern | Claude | Codex | Gemini | -|---------|--------|-------|--------| -| Config file | `CLAUDE.md` | `AGENTS.md` | System prompt / `.agent/rules/` | -| Memory loading | Auto-loaded or explicit read | Explicit read at session start | Must be explicit (session-scoped memory) | -| Context keys at handoff | Important for all | Especially important (ephemeral sessions) | Critical (no cross-session persistence) | - -### Agent Status - -Each agent publishes `status.yaml` reflecting current state: - -```yaml -runtime: claude -model: claude-opus-4-6 -status: available # available | busy | offline -current_task: "" -capabilities: - - headless - - shell_access - - git_ops -updated_at: "2026-01-15T10:00:00Z" -``` - -Status is updated at session init, task start, task complete, and session close. Stale threshold: 1 hour. - -### Agent Cards - -Static identity files (`agent_card.yaml`) declare skills, capabilities, permissions, and protocol bindings. They complement `status.yaml` (dynamic state) with stable metadata for discovery and routing. Schema inspired by Google's A2A Agent Card spec, adapted for file-based transport. - ---- - -## 5. Agent Safety Defaults - -Full specification: [`docs/protocol/agent_safety_defaults.md`](docs/protocol/agent_safety_defaults.md) -Credential scoping: [`docs/protocol/credential_scoping.md`](docs/protocol/credential_scoping.md) - -### Baseline Rules - -These defaults apply to all agents (Claude, Codex, Cursor, Gemini) unless a project-level config explicitly overrides a specific rule. Safety defaults can only be made **stricter** by project rules, never relaxed. - -Autonomy introduces a separate receiver acceptance policy layer; it does not -relax the safety floor. OACP distinguishes three layers: - -1. **Safety defaults** — non-negotiable baseline constraints, such as no - unapproved push/deploy/merge/publish, no destructive commands, no secrets, - and disciplined staging. Projects may make these stricter, never looser. -2. **Receiver acceptance policy** — configurable rules for whether a receiver - may move a message from `received` to `accepted` without interactive human - confirmation. This is governed by `agents//config.yaml` and - [`docs/protocol/autonomy.md`](docs/protocol/autonomy.md). -3. **Runtime/tool safety** — runtime-specific permission gates that still apply - after acceptance, such as editor edit approval, shell sandboxing, or Codex - approval modes. OACP acceptance never grants runtime tool permissions. - -Regardless of autonomy mode, receivers must pause on destructive command tokens -(`rm -rf`, `--force`, `--no-verify`, `--dangerously-skip-permissions`) and on -actual requests for external side effects or sensitive scope: push, deploy, -merge, publish, credential rotation, dependency install, auth/config/secrets, -public repos, pricing/commercial content, or memory SSOT. Profileless message -types that are explicitly allowed for auto-review may log incidental -side-effect verb mentions as notes instead of hard stops, but destructive tokens -and real side-effect requests still pause. - -#### Git Safety - -- No push/deploy without explicit approval from the user or dispatcher -- No destructive commands (`push --force`, `reset --hard`, `branch -D`, `clean -f`, `checkout .`) unless explicitly requested -- No direct-to-main pushes — all changes require a PR -- No hook bypasses (`--no-verify`, `--no-gpg-sign`) unless explicitly requested -- New commits over amend — after a pre-commit hook failure, fix the issue and create a new commit - -#### Staging Hygiene - -- Stage only files relevant to the current task — no `git add .` or `git add -A` -- No secrets or credentials in commits -- Verify before commit — `git status` and `git diff --staged` before every commit - -#### Inbox Safety - -- Delete inbox messages only after fully processing them -- Never silently consume a message that expects a response -- Always reply with `--parent-message-id` to maintain threading -- Idempotent sends — check for duplicates before sending to prevent retry storms - -#### Scope Discipline - -- Do not edit files outside the requested scope -- Do not modify auth, config, or secrets without explicit approval -- Do not install packages or change dependencies unless required and approved -- Do not create files unnecessarily — prefer editing existing files - -### Credential Scoping - -Full specification: [`docs/protocol/credential_scoping.md`](docs/protocol/credential_scoping.md) - -Agents operate under least-privilege credentials: - -- **Per-agent credentials** — each agent gets its own tokens. No sharing. Provides audit trail, blast-radius containment, and independent rotation. -- **Per-project boundaries** — credentials are scoped to the project they serve. -- **Environment variables only** — credentials loaded from env vars at runtime, never stored in version-controlled files. -- **Rotation support** — agents pick up new credentials on next invocation. Rotate at least every 90 days. - -**Permission model by role:** - -| Role | GitHub Permissions | -|------|-------------------| -| Implementer (Claude, Codex) | `contents: write`, `pull_requests: write`, `issues: write` | -| QA/Reviewer (Gemini) | `contents: read`, `pull_requests: read`, `issues: write` | -| Poll daemon | `pull_requests: read`, `issues: read` | - ---- - -## 6. Org-Level Memory and Sync - -Full specification: [`docs/protocol/org_memory.md`](docs/protocol/org_memory.md) - -### Purpose - -Shared, cross-project memory for multi-agent organizations. Agents across projects read org-wide decisions, conventions, and events from a single location. Complements per-project memory — does not replace it. - -OACP can optionally sync curated memory across machines using one plain git -repository rooted at `$OACP_HOME`. This feature is opt-in and tracks only: - -- `$OACP_HOME/org-memory/**` -- `$OACP_HOME/projects//memory/**` - -Cross-machine inbox delivery, `agents/` runtime state, `status.yaml`, concurrent -writer protocols, encrypted transport, and runtime-specific memory outside -`$OACP_HOME` are out of scope. - -### Directory Structure - -``` -$OACP_HOME/org-memory/ -├── recent.md # Always-loaded rolling summary (~150 lines) -├── decisions.md # Org-wide decisions (illustrative default) -├── rules.md # Standing conventions (illustrative default) -└── events/ # Timestamped event entries - └── YYYYMMDD-HHMMSS-short-slug.md -``` - -When memory sync is enabled, `$OACP_HOME` also contains: - -```text -$OACP_HOME/ -├── .gitignore # positive allowlist -├── .oacp-memory-repo # tracked marker; activates hooks -├── org-memory/ # tracked -└── projects/*/memory/ # tracked -``` - -Canonical root `.gitignore`: - -```gitignore -* -!*/ -!.gitignore -!.oacp-memory-repo -!org-memory/** -!projects/*/memory/** -projects/*/memory/.cache/ -``` - -### Key Concepts - -- **`recent.md`** is the always-loaded cross-project context. Agents read it at session start via session-init hooks. Kept concise (~150 lines) as a rolling summary of current state, not full history. -- **Topical files** (`decisions.md`, `rules.md`) are the structured knowledge layer. Adopters choose which topical files to create — these are illustrative defaults, not protocol requirements. -- **Events** (`events/`) are timestamped entries that agents append during debriefs. Low-friction write path — only frontmatter required. - -### Event Schema - -```yaml ---- -created_at_utc: "2026-03-17T17:01:20Z" # required — full timestamp -date: "2026-03-17" # required — human-readable -agent: claude # required — creator -project: my-project # required — originating project -type: decision # required — decision | event | rule -source_ref: debrief-20260317-s76 # optional — provenance -related: ["PR #43"] # optional — cross-references -supersedes: event/20260310-old-decision # optional — overrides prior entry ---- - -Short description of what happened and why it matters. -``` - -### Permission Model - -| Role | recent.md | Topical files | events/ | -|------|:---------:|:-------------:|:-------:| -| Agent | read | read | read + write | -| Coordinator | read + write | read + write | read + write | - -Agents write events only (append-only). The coordinator curates topical files and `recent.md` from events. - -### CLI - -- **`oacp org-memory init`** — scaffold `$OACP_HOME/org-memory/` with default files -- **`oacp write-event`** — create an event file with proper frontmatter -- **`oacp memory init [--remote URL]`** — initialize the `$OACP_HOME` memory git repo, marker, canonical allowlist, optional remote, and initial commit -- **`oacp memory clone [--force]`** — clone a memory repo into `$OACP_HOME`; refuses non-empty targets unless `--force` moves the existing directory aside -- **`oacp memory pull`** — advisory fetch plus fast-forward-only pull; warns loudly on dirty, ahead, behind, diverged, or fetch-failed states and never auto-merges -- **`oacp memory push`** — stages only the memory allowlist, commits as `memory: @ (N files)`, then pushes when a remote is configured -- **`oacp memory disable`** — removes `.oacp-memory-repo` locally while leaving `.git/` intact -- **`oacp doctor --memory`** — runs advisory memory sync checks for marker state, allowlist drift, tracked/untracked leakage, clean/ahead/behind/diverged state, remote reachability, commit staleness, `agents/` leakage, and per-project memory overlay safety - -### Integration with Session Init - -Agents can read `org-memory/recent.md` at session start for cross-project context. This integration is runtime-specific — for example, Claude Code loads it via a session-init hook, while other runtimes may read it explicitly. Topical files and `events/` are available for on-demand reading when agents need deeper org context. - -Memory sync has three activation states: - -| State | Trigger | Behavior | -|-------|---------|----------| -| Disabled | No `.oacp-memory-repo` marker | Hooks no-op silently; `oacp doctor --memory` reports not configured | -| Local-only | `oacp memory init` | Wrap-up commits memory locally for audit history; no push | -| Synced | `oacp memory init --remote URL` or `oacp memory clone ` | Session start pulls fast-forward updates; wrap-up commits and pushes | - -Lifecycle hooks must check for `$OACP_HOME/.oacp-memory-repo` first and exit -without output when it is absent. Claude runtime setup installs hook scripts for -`SessionStart` pull and wrap-up/session-end push: - -1. Session start runs `oacp memory pull`. It exits advisory-successfully, pulls - only clean behind states with `--ff-only`, and warns loudly when memory is - dirty, ahead, diverged, behind, or remote fetch fails. -2. Wrap-up runs `oacp memory push`. It stages only `.gitignore`, - `.oacp-memory-repo`, `org-memory/**`, and `projects/*/memory/**`; commits - with the fixed terse message format; pushes when a remote exists; and warns - loudly when memory is dirty, behind, diverged, or push fails. - ---- - -## 7. Kernel Script Inventory - -The kernel boundary was established through a classification audit of all project files. - -OACP ships a **kernel** — the minimal set of scripts, templates, and docs needed to adopt the protocol. Everything else is internal tooling for advanced orchestration workflows. - -### Kernel Scripts - -These scripts ship with the OSS release. Most are stdlib-only Python or POSIX shell with no external dependencies beyond `python3`, `git`, and `gh`. Exception: `preflight.py` also requires `ruff`, `shellcheck`, and optionally `pyyaml` for YAML validation. - -Scripts marked **CLI** are exposed as `oacp` subcommands. Scripts marked **script-only** are invoked directly and have no CLI wrapper. - -| Script | Purpose | Exposure | -|--------|---------|----------| -| `init_project_workspace.py` | Creates a new project workspace | CLI: `oacp init` | -| `add_agent.py` | Add an agent to an existing project workspace | CLI: `oacp add-agent` | -| `agent_profile.py` | Two-tier agent profile management | CLI: `oacp agent` | -| `send_inbox_message.py` | Compose and send inbox messages | CLI: `oacp send` | -| `oacp_inbox.py` | List pending inbox messages | CLI: `oacp inbox` | -| `memory_cli.py` | Archive, restore, or git-sync project/org memory files | CLI: `oacp memory` | -| `memory_sync.py` | Shared git allowlist and memory sync helpers | imported by CLI/doctor | -| `setup_runtime.py` | Generate runtime-specific config files | CLI: `oacp setup` | -| `init_org_memory.py` | Scaffold org-level memory directory | CLI: `oacp org-memory` | -| `write_event.py` | Write timestamped events to org-memory | CLI: `oacp write-event` | -| `oacp_doctor.py` | Environment and workspace health check (flutter-doctor-style) | CLI: `oacp doctor` | -| `autonomy_gate.py` | Evaluates receiver autonomy scope-envelope decisions and checkpoint drift | script-only | -| `validate_message.py` | Validates inbox/outbox message YAML | CLI: `oacp validate` | -| `session_lifecycle_hooks.py` | Session init and close hooks (`init_session`, `close_session`) | CLI-wrapped | -| `codex_session_init.py` | Codex startup protocol loader | CLI-wrapped | -| `check_quality_gate.py` | Validates findings packets against merge-readiness criteria | script-only | -| `normalize_findings.py` | Converts raw reviewer output to canonical findings YAML | script-only | -| `validate_agent_card.py` | Validates agent card YAML against the schema | script-only | -| `create_handoff_packet.py` | CLI for creating structured handoff packets | script-only | -| `handoff_schema.py` | Shared validation library for handoff and message schemas | script-only | -| `init_packet.sh` | Bootstraps review/findings/merge packet directories | script-only | -| `preflight.py` | Unified quality checks — CI runs this on every PR | script-only | -| `promote_to_archive.py` | Move a non-standard memory file into `memory/archive/` | script-only | -| `restore_from_archive.py` | Restore an archived memory file into the active `memory/` working set | script-only | -| `update_workspace.sh` | Idempotent workspace sync across protocol versions | script-only | - -### Kernel Templates (19) - -Core packet, role, and guardrail templates. Includes: - -- Packet templates: `review_packet`, `findings_packet`, `merge_decision`, `test_packet`, `checkpoint`, `handoff_packet`, `manual_validation` -- Messaging: `inbox_message` -- Agent identity: `agent_card`, `agent_status`, `skills_manifest` (spec: `docs/protocol/skills_manifest.yaml`, template: `templates/`) -- CI: `github_actions_quality_gate.yaml` -- Roles: `role_baseline`, `role_definition` -- Guardrails: `coding_standards`, `safe_commands`, `secrets_rules` -- Claude adapters: `role_agent`, `guardrail` - -### Kernel Docs (19) - -All protocol specs (13) and guides (6). See the audit for the complete file list. - -### Internal-Only (63 files) - -Not shipped in the OSS release. Includes orchestration scripts (brainstorm, task board, polling, lock primitives), runtime-specific adapters, analytics, brainstorm templates, prompt templates, dispatch/executor workflows, and planning docs. - -### Kernel Boundary Criteria - -A file is **kernel** if an external adopter needs it to use the base protocol. A file is **internal** if it's only needed for advanced orchestration, our specific ops workflows, or runtime-specific automation that adopters should implement themselves. - ---- - -## Appendix: Quick Start - -```bash -# 1. Initialize a project workspace -oacp init - -# 2. Verify environment health -oacp doctor --project - -# 3. Add an agent to the workspace -oacp add-agent alice --runtime claude - -# 4. Send a task request -oacp send \ - --from claude --to codex --type task_request \ - --subject "Implement feature X" --body "Details..." - -# 5. Check inbox for messages -# (list YAML files in agents//inbox/) - -# 6. Run quality checks -make preflight - -# 7. Validate a message file -oacp validate path/to/message.yaml - -# 8. Initialize review packets -scripts/init_packet.sh # (no CLI wrapper yet) -``` - -## Cross-References - -| Topic | Document | -|-------|----------| -| Inbox/outbox messaging | [`docs/protocol/inbox_outbox.md`](docs/protocol/inbox_outbox.md) | -| Dispatch states | [`docs/protocol/dispatch_states.yaml`](docs/protocol/dispatch_states.yaml) | -| Review loop | [`docs/protocol/review_loop.md`](docs/protocol/review_loop.md) | -| Packet states | [`docs/protocol/packet_states.yaml`](docs/protocol/packet_states.yaml) | -| Shared workspace | [`docs/protocol/multi_agent_shared_workspace.md`](docs/protocol/multi_agent_shared_workspace.md) | -| Cross-runtime sync | [`docs/protocol/cross_runtime_sync.md`](docs/protocol/cross_runtime_sync.md) | -| Session init | [`docs/protocol/session_init.md`](docs/protocol/session_init.md) | -| Receiver autonomy | [`docs/protocol/autonomy.md`](docs/protocol/autonomy.md) | -| Safety defaults | [`docs/protocol/agent_safety_defaults.md`](docs/protocol/agent_safety_defaults.md) | -| Credential scoping | [`docs/protocol/credential_scoping.md`](docs/protocol/credential_scoping.md) | -| Runtime capabilities | [`docs/protocol/runtime_capabilities.md`](docs/protocol/runtime_capabilities.md) | -| Task negotiation | [`docs/protocol/task_negotiation.md`](docs/protocol/task_negotiation.md) | -| MCP integration | [`docs/protocol/mcp_integration.md`](docs/protocol/mcp_integration.md) | -| Skills manifest | [`docs/protocol/skills_manifest.yaml`](docs/protocol/skills_manifest.yaml) | -| Setup guide | [`docs/guides/setup.md`](docs/guides/setup.md) | -| Adoption guide | [`docs/guides/adoption.md`](docs/guides/adoption.md) | +OACP (Open Agent Coordination Protocol) is a file-based coordination layer for +multi-agent engineering workflows: agents on different runtimes (Claude, Codex, +Cursor, Gemini, or any future runtime) collaborate asynchronously through YAML +messages in a shared filesystem — no server, no daemon. The protocol is a small +kernel — the message envelope and its lifecycle, message signing and +verification, receiver autonomy with audit receipts, and the org-memory layout — +plus userland conventions (review loops, task negotiation, session init, safety +defaults) that build on it. This file is the index: the normative text lives in +the documents below and is versioned with the `oacp-cli` package (see +[`CHANGELOG.md`](CHANGELOG.md)). + +## Kernel documents + +What every receiver must understand or verify to exchange a message. These four +documents ship inside the `oacp-cli` wheel. + +| Document | Governs | +|---|---| +| [`docs/protocol/inbox_outbox.md`](docs/protocol/inbox_outbox.md) | Wire format including the signed `auth` trailer; directory layout; message types; lifecycle — a processed inbound message is archived to `inbox/archive/` by digest-checked, no-clobber move (never deleted), and an intake rejection is quarantined to `dead_letter/`; threading, broadcast, expiry, retention. | +| [`docs/protocol/message_signing.md`](docs/protocol/message_signing.md) | Trust root and receiver pins, verify modes (`off` / `warn` / `enforce`), receiver audit stamping, policy-file signing, key management, rotation and revocation, signing conformance. | +| [`docs/protocol/autonomy.md`](docs/protocol/autonomy.md) | Receiver autonomy: config and task profiles, the four-gate admission evaluator and hard stops, audit records (admission ledger, human outcomes, terminal finalization), threshold checkpoints and re-authorization, scope-envelope enforcement, continuation grants. | +| [`docs/protocol/org_memory.md`](docs/protocol/org_memory.md) | Org-level memory: directory structure, event file schema, the debrief store, permission model and lifecycle. | + +## Userland documents + +Conventions layered on the kernel. Adopt what fits; none of them change what a +receiver must verify. + +| Document | Covers | +|---|---| +| [`docs/protocol/review_loop.md`](docs/protocol/review_loop.md) | Inbox-driven code review: stateless reviewer rounds, findings packets, quality gate, round budgets, post-LGTM nits. | +| [`docs/protocol/task_negotiation.md`](docs/protocol/task_negotiation.md) | Propose / accept / counter-propose handshake for splitting work between agents. | +| [`docs/protocol/multi_agent_shared_workspace.md`](docs/protocol/multi_agent_shared_workspace.md) | Shared-folder implementation → QA → deployment handoff with batched findings and signoff. | +| [`docs/protocol/session_init.md`](docs/protocol/session_init.md) | Runtime-agnostic session-start sequence and failure handling. | +| [`docs/protocol/cross_runtime_sync.md`](docs/protocol/cross_runtime_sync.md) | Keeping context consistent across runtimes: durable memory, handoff messages, review artifacts. | +| [`docs/protocol/runtime_capabilities.md`](docs/protocol/runtime_capabilities.md) | Static capability declarations, dynamic `status.yaml`, health-check contract, agent cards. | +| [`docs/protocol/agent_profiles.md`](docs/protocol/agent_profiles.md) | Two-tier identity: global agent profiles and project-level agent cards. | +| [`docs/protocol/agent_safety_defaults.md`](docs/protocol/agent_safety_defaults.md) | Baseline git, staging, inbox, credential, and scope rules every agent follows. | +| [`docs/protocol/credential_scoping.md`](docs/protocol/credential_scoping.md) | Per-agent, per-project least-privilege credentials and rotation. | +| [`docs/protocol/mcp_integration.md`](docs/protocol/mcp_integration.md) | Attaching MCP tool outputs to findings as structured evidence. | +| [`docs/protocol/dispatch_states.yaml`](docs/protocol/dispatch_states.yaml) · [`packet_states.yaml`](docs/protocol/packet_states.yaml) · [`skills_manifest.yaml`](docs/protocol/skills_manifest.yaml) | Machine-readable dispatch and review-packet state machines, and the skills manifest. | + +Guides — [`docs/guides/setup.md`](docs/guides/setup.md), +[`adoption.md`](docs/guides/adoption.md), [`doctor.md`](docs/guides/doctor.md), +[`versioning.md`](docs/guides/versioning.md), +[`unified_skill_spec.md`](docs/guides/unified_skill_spec.md) — and the +executable conformance fixtures under [`tests/conformance/`](tests/conformance/) +(autonomy, signing, intake, envelope) round out the set. Runtime-specific skills +that operate the protocol live in the companion +[oacp-skills](https://github.com/kiloloop/oacp-skills) repository. + +## Getting started + +[`QUICKSTART.md`](QUICKSTART.md) sends a first message in five minutes; +[`README.md`](README.md) carries the command reference (`oacp --help` is +authoritative). diff --git a/docs/guides/doctor.md b/docs/guides/doctor.md index 6028bc1..e3caa4f 100644 --- a/docs/guides/doctor.md +++ b/docs/guides/doctor.md @@ -22,7 +22,7 @@ Verifies that required and optional CLI tools are installed and reachable on `PA | Check | Required? | What it looks for | |-------|-----------|-------------------| | `git` | Yes | Git CLI | -| `python3` | Yes | Python 3.9+ interpreter | +| `python3` | Yes | Python 3.9.2+ interpreter | | `gh` | Yes | GitHub CLI (for PR and issue workflows) | | `ruff` | No | Python linter (optional, used in preflight) | | `shellcheck` | No | Shell script linter (optional) | @@ -176,7 +176,7 @@ The exit code reflects the overall result: | Issue | Fix | |-------|-----| | `git — not found` | Install Git: https://git-scm.com/downloads | -| `python3 — not found` | Install Python 3.9+: https://www.python.org/downloads/ | +| `python3 — not found` | Install Python 3.9.2+: https://www.python.org/downloads/ | | `gh — not found` | Install GitHub CLI: `brew install gh` or https://cli.github.com/ | | `pyyaml — not importable` | `pip install pyyaml` | | `ruff — not installed` | `pip install ruff` (optional, for linting) | diff --git a/docs/guides/prompt_caching.md b/docs/guides/prompt_caching.md index 8fad998..063b976 100644 --- a/docs/guides/prompt_caching.md +++ b/docs/guides/prompt_caching.md @@ -1,149 +1,3 @@ # Prompt Caching Patterns -How to maximize prompt cache hits across Claude, Codex, and Gemini to reduce cost and latency. - -## Why It Matters - -Prompt caching avoids reprocessing static context (system prompts, CLAUDE.md, project facts) on every turn. In practice: -- **Cache read**: 10x cheaper than uncached input ($0.50/MTok vs $5.00/MTok for Opus 4.8) -- **Cache write**: 1.25x input price for the default 5-minute TTL, 2x for the 1-hour TTL (one-time cost, amortized over subsequent reads) -- **Observed savings**: 83-95% cost reduction on cached input in multi-turn agent sessions - -## Claude - -### Automatic — No Setup Required - -Prompt caching is **enabled by default** for all Claude API usage. Every Claude Code session, headless run, and agent team benefits automatically — there is nothing to opt in to or configure. - -How it works: -- The API caches the longest common prefix of your prompt across requests -- In a Claude Code session, the system prompt + CLAUDE.md + tool definitions form a stable prefix -- After turn 1, all subsequent turns read this prefix from cache instead of reprocessing it -- Cache TTL is ~5 minutes of inactivity, auto-extended on every hit -- A typical multi-turn session sees **90%+ cache hit rate** out of the box - -### Maximizing Cache Hits - -The default behavior already handles the common case. These tips help squeeze out the remaining savings: - -1. **Keep static context at the top of the prompt** - - System prompt, CLAUDE.md, project_facts.md, and tool definitions are loaded first - - These rarely change within a session → high cache hit rate - -2. **Front-load stable context in CLAUDE.md** - - Put repo structure, conventions, and protocol rules early - - Put volatile content (open threads, recent decisions) in separate files loaded later - -3. **Batch agent work into focused sessions** - - A 20-turn session on one task reuses cache across all turns - - Switching tasks mid-session may invalidate cache if the prefix changes - -### Explicit Cache Control (Custom API Integrations Only) - -When building custom integrations with the Messages API (not Claude Code CLI), you can explicitly mark content blocks for caching. This is **not needed** for Claude Code — it handles caching automatically. - -```json -{ - "role": "user", - "content": [ - { - "type": "text", - "text": "", - "cache_control": {"type": "ephemeral"} - } - ] -} -``` - -Use this when you have a large reference document mid-conversation that isn't part of the natural prefix (e.g., a full API spec injected as a user message). - -### Headless Claude automation - -When running headless Claude sessions (for example, via a polling daemon or CI-triggered agent invocations), maximize caching by: -- Loading skill files and project context as system prompt (cached after first call) -- Processing multiple PRs in sequence within one invocation to share the same cache -- Keeping poll intervals short enough to stay within cache TTL (~5 min) - -### Multi-Agent Teams - -When spawning parallel agents via Claude Code teams: -- Each agent gets its own session → **separate cache** per agent -- Shared CLAUDE.md and tool definitions still cache within each agent's session -- Observed: 94% cache hit rate across 3 parallel agents (workflow-templates run) - -## Codex - -### How It Works - -Codex CLI sends the full prompt to the Codex API on each invocation. There is no built-in cross-turn prompt cache like Claude's. - -### Context Reuse Strategies - -1. **Keep prompts short and focused** - - Codex charges per input token with no cache discount - - Include only the files and context directly relevant to the task - -2. **Use `--file` flags to scope context** - ```bash - codex --file src/auth.py --file tests/test_auth.py "Fix the token expiration bug" - ``` - -3. **Batch related fixes in one invocation** - - `codex exec` processes all instructions in a single session - - Avoids re-sending the same context for each fix - -4. **Lean on diff-only context** - - For PR fix loops, pass the diff + findings rather than full file contents - -## Gemini - -### How It Works - -Gemini supports implicit context caching for large prompts. The API automatically caches prompts above a size threshold. - -### Context Reuse Strategies - -1. **Use cached content API for large stable context** - ``` - POST /cachedContents - { - "model": "models/gemini-2.5-pro", - "contents": [{"role": "user", "parts": [{"text": ""}]}], - "ttl": "600s" - } - ``` - -2. **Reference cached content in subsequent requests** - ``` - POST /generateContent - { - "cachedContent": "cachedContents/abc123", - "contents": [{"role": "user", "parts": [{"text": "New instruction"}]}] - } - ``` - -3. **Gemini CLI sessions** — context is maintained within a session automatically; no explicit caching needed for interactive use. - -## Cost Comparison - -Approximate pricing as of June 2026 (per million tokens). Cache read is 0.1x input; cache write is shown at the default 5-minute TTL (1.25x input) — the 1-hour TTL costs 2x input. Check each provider's current pricing page for up-to-date rates: - -| Runtime | Input | Cached Read | Cache Write | Output | -|---------|------:|------------:|------------:|-------:| -| Claude Fable 5 | $10.00 | $1.00 | $12.50 | $50.00 | -| Claude Opus (4.6/4.7/4.8) | $5.00 | $0.50 | $6.25 | $25.00 | -| Claude Sonnet 4.6 | $3.00 | $0.30 | $3.75 | $15.00 | -| Claude Haiku 4.5 | $1.00 | $0.10 | $1.25 | $5.00 | -| Codex | varies | N/A | N/A | varies | -| Gemini Pro | $1.25 | $0.31 | — | $10.00 | - -## Practical Guidelines - -1. **Measure your cache hit rate** — check `cache_read_input_tokens` vs `input_tokens` in API responses or team stats output -2. **Target >80% cache hit rate** for multi-turn sessions — if lower, your prefix is changing too often -3. **Don't over-optimize** — the biggest savings come from the default behavior (CLAUDE.md + tools cached automatically) -4. **Watch for cache-busting patterns**: - - Injecting timestamps or random IDs into system prompts - - Reordering tool definitions between turns - - Changing the user message prefix frequently -5. **Mind the minimum cacheable prefix** — prefixes below the model minimum silently don't cache (no error; `cache_creation_input_tokens` stays 0). The minimum is 2,048 tokens on Fable 5 and Sonnet 4.6, and 4,096 tokens on Opus 4.8/4.7/4.6 and Haiku 4.5. +This guide has moved to the public [Kiloloop research repository](https://github.com/kiloloop/research/blob/main/runtime-comparison/prompt-caching-patterns.md). diff --git a/docs/guides/runtime_capability_matrix.md b/docs/guides/runtime_capability_matrix.md deleted file mode 100644 index 6f722a3..0000000 --- a/docs/guides/runtime_capability_matrix.md +++ /dev/null @@ -1,162 +0,0 @@ -# Cross-Runtime Parity Matrix - -**Date**: 2026-08-07 - -This is a capability comparison across the currently profiled agent runtimes (Claude Code, Codex, Gemini), compiled from each runtime's self-report and current runtime changelogs. Cursor support is scaffold-only until Cursor-owned onboarding lands, so Cursor is intentionally excluded from this comparison table; see `docs/protocol/runtime_capabilities.md` for its conservative scaffold defaults. - -Claude was last checked against Claude Code `v2.1.225` (verified in-session 2026-08-07 via `claude --version`) with Claude Fable 5 (`claude-fable-5`, serving model verified in-session on the 1M-context variant). Fable 5 (released 2026-06-09, first Mythos-class model) is now included in Max and Team Premium plan usage as part of the shared weekly limit pool (the launch-window free-inclusion/credit period has ended); the API rate is $10/$50 per MTok. Claude Opus 5 (`claude-opus-5`, released 2026-07-24) is the current Opus tier at $5/$25 per MTok with the same 1M context; Opus 4.8 remains available at the same price and remains the safeguard-fallback target for Fable 5's classifier fallbacks (unchanged by the Opus 5 release). - -Codex was last checked against app update `26.727`, stable CLI `0.146.0`, GPT-5.6, and the OpenAI Codex/API changelog entries through 2026-07-30. Runtime availability remains configuration-dependent: standard multi-agent support and memories are stable, while multi-agent V2, token budgets, current-time reminders, and remote Code Mode may still require explicit enablement or experimental configuration. - ---- - -## 1. Core Capability Matrix - -| Capability | Claude (Claude Code CLI) | Codex (Desktop App) | Gemini | -| ---------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| Spawn background tasks | Yes — Task tool + Bash `run_in_background` | Yes — shell background processes | Yes — `run_command` async mode | -| Spawn subagents | Yes — typed agents (Explore, Plan, general-purpose, code-reviewer, etc.) | Yes — stable native multi-agent lifecycle with runtime metadata and follow-up controls | Partial — `browser_subagent` only | -| Parallel agent teams | Yes — TeamCreate, task lists, SendMessage, broadcast | Partial — parallel delegation is supported; opt-in multi-agent V2 adds configurable models, reasoning, concurrency, and roles, but there is no shared team/task-list primitive | No — parallel tool calls but no independent agent instances | -| MCP tools | Yes — extensible via MCP servers | Yes — MCP/plugin support with `/mcp verbose`, per-server environment targeting, read-only MCP parallelism, scriptable plugin inventory, and default tool-search exposure where supported | Yes — MCP server support | -| Web search | Yes — native WebSearch tool | Yes — web search/fetch tools; hosted web tools are expanding in code-mode flows | Yes — native `search_web` tool | -| Browser interaction | Partial — WebFetch (read-only, HTML→markdown) | Configuration-dependent — Browser and Chrome plugins can navigate, click, type, inspect, and capture approved contexts; history search remains separately permissioned | Yes — full browser control (click, type, navigate, screenshot, video) | -| File system access | Sandboxed — configurable read/write allowlists | Policy-dependent per session; named permission profiles can include deny-read rules and managed requirements | Full — unrestricted | -| Multi-folder projects | Manual workspace composition | Yes — the primary folder controls new chats, Git, and automatic instruction/skill/config discovery; secondary folders provide file access | Manual workspace composition | -| Git operations | Yes — via Bash (may need sandbox configuration) | Yes — native; in a multi-folder project, operations are rooted in the primary folder | Yes — via shell | -| GitHub CLI (gh) | Yes — via Bash (may need sandbox configuration) | Yes — authenticated | Yes — native | -| Session memory | Strong — auto-loaded MEMORY.md + optional MCP memory | Partial — stable generated app memories plus OACP file memory; app memories are not protocol SSOT | Partial — Knowledge Items (not directly writable), conversation logs | -| Interactive mode | Yes — CLI chat with permissions, plan mode | Yes — desktop app and CLI/TUI, including Plan and Goal modes, named and pinned sessions, side-chat switching, forks, and archive/unarchive/delete flows | Yes — chat with task UI, artifacts | -| Context window | ~1M across the current lineup (Fable 5, Opus 5, Opus 4.8); auto-compaction extends indefinitely | Model-dependent; GPT-5.6 Sol, Terra, and Luna are documented at 272K | ~1M tokens | -| Cost model | Token-based, visible in statusline; Fable 5 API rate is $10/$50 per MTok (2× the $5/$25 shared by Opus 5 and Opus 4.8) | ChatGPT sessions do not surface per-session cost; API Fast mode for Sol trades 2× price for up to 2.5× speed, while Terra and Luna target lower-cost work | Token-based | -| Sandbox restrictions | Yes — configurable allowlists | Session-dependent; supports deny-read policies, isolated `codex exec`, named permission profiles, managed requirements, and explicit approval policies | None — full system access | - ---- - -## 2. Distinctive Capabilities - -| Capability | Runtime | Details | -| -------------------------------- | ------------- | ---------------------------------------------------------------------------- | -| Typed subagent orchestration | Claude | Multiple agent types with scoped tools and model selection | -| Team coordination primitive | Claude | TeamCreate + task lists + assignment + broadcast + shutdown | -| Dynamic multi-agent workflows | Claude | Workflow tool orchestrates tens–hundreds of agents; `/workflows` to view | -| Plan mode | Claude, Codex | Claude has structured explore → plan → approve → implement; Codex CLI can move from planning into fresh-context implementation | -| Auto-compaction | Claude | Context auto-compresses, enabling unlimited session length | -| Cross-session semantic search | Claude | MCP-based searchable memory (optional) | -| Cross-session messaging | Claude | SendMessage reaches the user's other Claude Code sessions, including across machines (macOS/Linux), with ListAgents discovery; inbound delivery is configurable (`crossSessionInbound`, `dialogExpiry`) and messages can initiate conversations with Remote Control sessions by name | -| Browser automation | Codex, Gemini | Codex Browser/Chrome plugins can click, type, navigate, inspect, and capture approved contexts; Gemini also supports WebP video recording | -| GPT-5.6 model family | Codex | Sol, Terra, and Luna provide task-tier choices with a documented 272K context window | -| Image generation | Codex, Gemini | Codex CLI image generation is enabled by default; Gemini has native `generate_image` | -| URL content reading (no browser) | Gemini | `read_url_content` fetches HTML→markdown or PDF directly | -| Code outline navigation | Gemini | `view_file_outline`, `view_code_item` for structured exploration | -| PTY / terminal stdin | Codex, Gemini | Codex: native PTY; Gemini: `send_command_input` (Claude lacks stdin support) | -| `apply_patch` editing | Codex | Grammar-based file edits | -| App-level computer use | Codex | macOS app, simulator, and GUI-only workflows; unavailable in EEA, UK, and Switzerland at launch | -| Windows computer use | Codex | Codex app can operate Windows desktop apps in the foreground when available | -| Remote host control | Codex | Mobile or desktop remote control can run work on connected Mac or Windows hosts with host-local files, credentials, plugins, skills, and config; Claude Code's cross-session messaging now also reaches its own Remote Control sessions on other machines, narrowing this distinction | -| App-level artifact review | Codex | Sidebar preview for generated PDFs, spreadsheets, documents, and presentations | -| App-level PR review | Codex | PR sidebar can inspect changed files, review comments, and follow-up fixes | -| Multi-folder projects | Codex | One project can span repositories; the primary folder owns Git and automatic instruction, skill, and config discovery, while secondary folders are file-access-only | -| Session organization | Codex | CLI/TUI supports named and pinned sessions, side-chat switching, archive/unarchive, deletion, and temporary or persisted forks | -| App-server automation | Codex | JSON-RPC app-server, SDK, schema generation, thread APIs, and authenticated WebSocket/Unix-socket/stdio transports; remote Code Mode remains experimental | -| Hosted site deployment | Codex | Sites preview can create, deploy, inspect, and manage hosted websites or internal tools through the Codex app | -| Plugin marketplace inventory | Codex | Agent Plugin manifests, workspace publishing, additional marketplaces, and `codex plugin list --json`; availability and trust remain configuration-dependent | -| Goal mode | Codex | Stable long-running objective mode with dedicated state; candidate for OACP wait/review-loop experiments | -| Record & Replay | Codex | Mac desktop workflows can be recorded and converted into reusable skills; candidate for private skill capture after privacy review | -| Current-time reminders | Codex | Announced CLI surface for relative-date work; feature availability must be checked before workflow dependence | - ---- - -## 3. Public OACP Skills Coverage - -Scope: skills shipped in [`kiloloop/oacp-skills`](https://github.com/kiloloop/oacp-skills). Private/local skills (debrief, sync, blitz, team-stats, worktree-workflow, send-message, etc.) are intentionally not tracked here — this table is meant as a cross-runtime parity signal for distributable skills only. - -| Skill | Claude | Codex | Gemini | -| ---------------------- | ------- | -------- | ------------ | -| `check-inbox` | Working | Working | Not packaged | -| `doctor` | Working | Working | Not packaged | -| `review-loop-reviewer` | Working | Working | Not packaged | -| `review-loop-author` | Working | Working | Not packaged | -| `self-improve` | Working | Working | Not packaged | - ---- - -## 4. Strengths Summary - -| Dimension | Claude | Codex | Gemini | -| --------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | -| Best at | Orchestration, multi-agent teams, persistent memory, plan-then-execute | Terminal-native execution, GPT-5.6 agentic coding, fast iterative patching, native delegation, plan-to-implementation handoff, app-assisted review, app-server automation, protocol discipline | Web research, browser automation, visual verification, large context | -| Ideal task type | Team coordination, complex multi-file refactors, long-running sessions | Shell-heavy workflows, long-horizon coding, targeted file edits, deterministic scripts, PR follow-up, artifact review, CLI planning passes, plugin/app-server automation prototypes | External research, UI testing, document review, MCP integrations | -| Cost profile | Flexible (haiku subagents for cheap tasks, opus for complex, Fable 5 at 2× Opus API rates for the hardest work) | Per-session ChatGPT cost not visible; Sol, Terra, and Luna provide quality/speed/cost tiers, with API Fast mode available for Sol | Token-based, web search has additional costs | - ---- - -## 5. Known Limitations Summary - -| Limitation | Claude | Codex | Gemini | -| ----------------------------- | ----------------------------- | -------------------------------------------------------------- | ---------------------- | -| No subagents | — | — | Partial (browser only) | -| No browser automation | Yes (read-only) | Configuration-dependent — Browser/Chrome plugins and an approved context are required | — | -| No image generation | Yes | — | — | -| No persistent writable memory | — | Partial (app memories are not a replacement for OACP durable memory) | Yes | -| Sandbox friction | Yes (configurable) | Session-dependent | — | -| No team primitive | — | Partial — native parallel delegation exists, but not a shared task-list/broadcast primitive | Yes | -| Context limits | Auto-compaction mitigates | GPT-5.6 Sol, Terra, and Luna are 272K; compaction behavior is runtime-dependent | Large but finite | -| No terminal stdin | Yes | — | — | -| Cost not surfaced | — | Yes | — | -| Permanent session delete | — | `codex delete` is available; use archive/unarchive for routine cleanup and reserve delete for explicit destructive cleanup | — | -| Serving model can change mid-session | Yes (Fable 5 only — cyber/bio-chem/distillation classifiers fall back to Opus 4.8, a target unchanged by the Opus 5 release; enabled by default and user-configurable in Claude interfaces — off-toggle in Settings > Capabilities, or Config > MODEL & OUTPUT in Claude Code, after which a flagged request pauses instead of switching; a session event is emitted on switch; <5% of sessions) | — | — | - ---- - -## 6. Parity Gaps — Actionable Items - -These are the highest-impact gaps where one runtime's limitation blocks effective collaboration: - -| Gap | Affected Runtime(s) | Impact | Proposed Fix | -| --------------------------- | ---------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------ | -| No shared team/task-list primitive | Codex, Gemini | Codex can delegate in parallel but lacks Claude-style shared task lists and broadcast; Gemini lacks independent general agents | Agent cards — let runtimes discover and delegate to capable peers | -| Memory asymmetry | Codex (partial), Gemini (KIs only) | Cross-session context degrades without MEMORY.md equivalent | Standardize memory protocol; each runtime implements its own persistence layer | -| Sandbox blocks git/gh | Claude | Every git/gh call needs sandbox configuration | Configure sandbox allowlists or disable sandbox for specific commands | -| Full browser automation gap | Claude; Codex without Browser/Chrome plugins | Claude is read-only; Codex browser control depends on installed plugins and an approved browser context | Delegate to a browser-capable runtime or enable the scoped Codex browser surface after privacy review | -| Reviewer cost | All (especially Claude) | High cost for single PR review with polling pattern | Stateless reviewer rounds — one round per invocation | -| Public skill coverage | Gemini | `kiloloop/oacp-skills` ships `claude/` and `codex/` variants for all 5 public skills; no `gemini/` variants — Gemini users must rely on convention-based adoption | Add `gemini/` variants to each public skill, or document the convention-based pattern as a first-class install path | - ---- - -## 7. Additional Dimensions - -| Dimension | Claude | Codex | Gemini | -| ---------------------------- | -------------------------------- | ------------------------------------------ | --------------------------------------------- | -| Max parallel tool calls | ~10+ | Yes (parallel independent calls) | ~10 (practical) | -| Side conversations | No | Yes — named/pinned sessions and side-chat switching in CLI/TUI | No | -| Hooks system | Yes (pre/post tool call hooks) | Yes (stable hooks and extension lifecycle hooks; plugin-bundled hooks are configuration-dependent) | No | -| Automation scheduling | Yes (`CronCreate`, `ScheduleWakeup`, `/loop`, `/schedule` skills) | Yes (desktop app thread automations, Goal mode, and app-server/SDK automation surfaces) | No | -| Notebook editing | Yes (NotebookEdit tool) | No | No | -| PDF reading | Yes (max 20 pages/request) | No native tool | Via `read_url_content` | -| Image reading (multimodal) | Yes | Yes (desktop app local image/view support) | Yes | -| Artifact system | No | Yes (sidebar preview for generated files) | Yes (task.md, implementation plans) | -| Video recording | No | No | Yes (WebP via browser) | -| Image generation | No | Yes (enabled by default in CLI) | Yes | -| MCP diagnostics | Partial | Yes (`/mcp verbose`, per-server environment targeting, read-only MCP parallelism, plugin JSON inventory) | Partial | -| Multi-file editing primitive | Edit tool (one file at a time) | `apply_patch` (one file) | `multi_replace_file_content` (non-contiguous) | -| Workflow file format | SKILL.md with YAML frontmatter | SKILL.md with YAML frontmatter | Markdown with YAML frontmatter | -| Policy visibility at runtime | Partial (sandbox config visible) | Yes (session policy, approval policy, sandbox, and named permission profiles) | Yes (`SafeToAutoRun` flags) | -| Long-running shell sessions | Bash tool (no stdin) | Yes (PTY + stdin; multiple terminals in app) | Yes (`send_command_input`) | -| App-server / SDK | No | Yes (JSON-RPC app-server, Python SDK, thread/fork APIs, authenticated WebSocket/Unix/stdio transports, and experimental remote Code Mode) | No | -| Multi-folder projects | No native primary-folder model | Yes (primary folder owns Git and automatic instruction/config discovery; secondary folders are file-access-only) | No native primary-folder model | -| Hosted site deployment | No | Yes (Sites preview, app-only/cloud-hosted with separate secret management) | No | -| Desktop workflow capture | No | Yes (Record & Replay on Mac; privacy-sensitive and best treated as private-skill capture until reviewed) | No | - ---- - -## 8. Source Notes - -- GPT-5.6 model availability, the corrected 272K context window, stable memories and multi-agent support, session organization, Agent Plugins, multi-folder behavior, browser/Chrome changes, and app-server transports come from OpenAI's Codex changelog: . -- Codex app/CLI capability changes are reviewed through app `26.727` and stable CLI `0.146.0`; workstation-specific alpha versions are intentionally kept out of this public matrix. -- GPT-5.6 API model and pricing changes through 2026-07-30 come from the OpenAI API changelog: . -- Sites, Amazon Bedrock, Remote, Record & Replay, app-server, plugin, and permissions details come from the official Codex docs under . -- Claude Fable 5 release date and pricing come from Anthropic's 2026-06-09 announcement: ; the current plan-inclusion posture (Max/Team Premium shared weekly pool, launch promo ended) reflects Anthropic's plan policy as of 2026-07-20. Claude Opus 5 model ID, pricing ($5/$25 per MTok), 1M context, and its release as the current Opus tier were re-verified 2026-08-03 against Anthropic's model documentation (models overview + migration guide at ): Opus 5 is a drop-in at Opus 4.8's pricing, and Opus 4.8 stays available. Present-day interface fallback behavior comes from Anthropic's Help Center article "Why Claude switched models in your conversation with Fable 5" (2026-07-01, verified 2026-08-04): automatic switching to Opus 4.8 is enabled by default and user-configurable — Settings > Capabilities in the apps, Config > MODEL & OUTPUT in Claude Code ("Switch models when a message is flagged" toggle); with it off, a flagged request pauses the conversation. The Fable 5 / Mythos 5 system card §1.5 ("Novel safeguards") remains the source for the safeguard design, the observed fallback-rate/session-event facts, and the API posture: the Messages API blocks by default with a structured refusal category and offers opt-in server-side fallback — whose category-routed default likewise targets Opus 4.8 for cyber-class refusals, so the fallback target is unchanged post-Opus-5. The serving model (claude-fable-5) and Claude Code version (v2.1.225) in the header were verified in-session by the Claude runtime on 2026-08-07. Cross-session messaging capabilities (SendMessage across sessions and machines, ListAgents discovery, crossSessionInbound/dialogExpiry settings) come from the Claude Code v2.1.222–v2.1.225 release notes. - ---- - -*Each runtime should update only its own column. Discrepancies should be resolved by the runtime owner.* diff --git a/docs/guides/setup.md b/docs/guides/setup.md index a303174..d4fd899 100644 --- a/docs/guides/setup.md +++ b/docs/guides/setup.md @@ -3,7 +3,7 @@ ## Prerequisites - `bash` 3.2+ (macOS default) or 4+ (recommended) -- `python3` 3.9+ (for JSON state management, quality gate scripts, and inbox messaging) +- `python3` 3.9.2+ (for JSON state management, quality gate scripts, and inbox messaging) - `gh` CLI (optional, for GitHub operations — `gh auth login`) - Agent runtime CLI: `claude`, `codex`, `cursor`, or `gemini` (depending on your agents) diff --git a/docs/protocol/agent_profiles.md b/docs/protocol/agent_profiles.md index bd100cf..2380713 100644 --- a/docs/protocol/agent_profiles.md +++ b/docs/protocol/agent_profiles.md @@ -24,7 +24,9 @@ $OACP_HOME/ └── outbox/ ``` -- **Global profiles** (`agents//profile.yaml`) are created once per agent and rarely change. +- **Global profiles** (`agents//profile.yaml`) form the instance registry. + `oacp add-agent` and `oacp init --agents` create them and append project + memberships without replacing existing identity fields. - **Project cards** (`projects//agents//agent_card.yaml`) carry project-specific overrides — permissions, skills, routing rules — and are the authoritative resolved identity for that project. - **Status files** (`status.yaml`) remain separate; they track dynamic session state, not static identity. @@ -80,9 +82,10 @@ Global profiles use `agent_profile.template.yaml`. Fields: |-------|------|----------|-------------| | `version` | string | yes | Schema version (semver, currently `0.2.0`) | | `name` | string | yes | Agent identifier (matches directory name) | -| `runtime` | string | yes | One of: `claude`, `codex`, `gemini`, `human` | -| `model` | string | no | Model version identifier | +| `runtime` | string | yes | One of: `claude`, `codex`, `cursor`, `gemini`, `human`, `unknown` | +| `model` | string | no | Configured model identifier; a runtime alias may select that runtime's environment default | | `description` | string | no | One-line role summary | +| `projects` | list | yes | Project workspaces where this agent is initialized | | `routing_rules` | dict | no | `primary` (preferred targets) and `avoid` (agents to skip) | | `trust_level` | string | no | `untrusted`, `standard`, `elevated`, or `admin` | | `quota` | dict | no | `max_cost_usd_per_month`, `reset_day`, `warn_threshold` | @@ -123,17 +126,30 @@ oacp agent show claude --project my-app ### `oacp agent list` -List known agents with their tier tags. +List the instance registry with each agent's runtime and project memberships. +`--project` preserves the merged global/project view for one workspace. ```bash oacp agent list -# claude (global) -# codex (global) +# claude runtime=claude memberships=my-app,other-app (global) +# codex runtime=codex memberships=my-app (global) oacp agent list --project my-app -# claude (global, project) -# codex (global) -# gemini (project) +# claude runtime=claude memberships=my-app,other-app (global, project) +# codex runtime=codex memberships=my-app (global, project) +``` + +### `oacp agent sync` + +Backfill the instance registry once from existing project agent directories. +The command infers defaults from agent cards and status files, preserves every +existing identity value, and only appends missing memberships. It is safe to +repeat. `oacp doctor` reports missing profiles or memberships and points to +this command. + +```bash +oacp agent sync +# Agent registry synchronized: 3 agent(s), 8 project membership(s); ... ``` ## Relationship to Existing Agent Cards @@ -144,7 +160,7 @@ Agent cards predate global profiles and remain the authoritative per-project ide |---------|---------------|--------------------| | **Location** | `$OACP_HOME/agents//profile.yaml` | `$OACP_HOME/projects//agents//agent_card.yaml` | | **Scope** | All projects | Single project | -| **Updates** | Rarely (identity changes) | Per project as needed | +| **Updates** | Identity changes plus append-only project membership | Per project as needed | | **Authority** | Defaults only | Authoritative for the project | | **Extra sections** | None | `permissions`, `availability`, `protocol` | diff --git a/docs/protocol/autonomy.md b/docs/protocol/autonomy.md index 792e01e..cebeaae 100644 --- a/docs/protocol/autonomy.md +++ b/docs/protocol/autonomy.md @@ -106,6 +106,15 @@ schema-invalid profile pauses with `task_profile_unparsable`; it is not a fatal message-schema error. Message types listed in `allow_without_task_profile`, such as `brainstorm_request`, may auto-accept without the block. +`expected_files_touched` counts distinct deliverable files only: files created +or modified as part of the requested outcome or a review-required correction. +Exclude receiver-local inbox, outbox, audit, memory, cache, and scratch writes +used for protocol bookkeeping or temporary work. For example, a change to one +protocol document, one template, and one test declares `3` even when the +receiver also writes an audit record, reply body, and cache entries; a +reply-only analysis that uses scratch files but produces no file deliverable +declares `0`. + ### Default scope envelope (profileless admissions) `allow_without_task_profile` is an **admission-only exemption**: the sender @@ -160,11 +169,14 @@ Do not merge, deploy, publish, or touch credentials. Gate 3 excludes well-formed `oacp-guardrails` fence contents from ordinary side-effect, auth/config/secrets, and ambiguous-scope pause classification, but -records every matching term as a `lexical_advisory`; fenced text is never -invisible to the audit. Destructive commands, direct main pushes, credential +records every matching span in `matched_patterns` and logs the term classes as +`lexical_advisory`; fenced text is never invisible to the audit. Destructive +commands, direct main pushes, credential rotation, dependency installation, public-repository text, memory SSOT text, and pricing/commercial content are scanned across the raw body and remain hard -even inside the fence. An unclosed or differently labeled fence is not skipped. +even inside the fence (the reply-only carve-out for pricing/commercial content, +under Gate 3, keys on the declared profile shape, not on fencing). An unclosed +or differently labeled fence is not skipped. ## Four-Gate Evaluator @@ -190,6 +202,9 @@ If any required gate is missing or uncertain, the receiver pauses. and every other external side-effect class pause, and a declared `merges_pr` pauses at admission regardless. - Pauses contradictory profile fields with `declaration_error`. + - Evaluates every envelope-derived axis before any early return and + records the result as the admission ledger (see "Admission ledger" + under Audit Events); the verdict is still the first failing axis. 3. **Receiver classification** - Pause unconditionally on destructive command tokens: `rm -rf`, `--force`, `--no-verify`, `--dangerously-skip-permissions`. @@ -215,8 +230,11 @@ If any required gate is missing or uncertain, the receiver pauses. `skip` and `without` remain same-clause only because their ordinary prose uses are ambiguous. Heading scope ends at the first blank line or next heading, so the first governed line must follow the heading directly. - Non-demotable hard stops remain hard even when they appear in such a - clause or block. + Direct-main pushes, credential rotation, memory-SSOT scope, and + pricing/commercial content remain hard even when they appear in such a + clause or block. Dependency-install and public-repository matches receive + this context handling only for the proven negated/out-of-scope forms; + affirmative forms remain hard. - With a complete profile, demote side-effect or sensitive-scope lexical matches to a logged `lexical_advisory` when the corresponding declaration is `false`. Missing/unparsable profiles and contradictory declarations do @@ -224,12 +242,35 @@ If any required gate is missing or uncertain, the receiver pauses. - When policy explicitly uses `external_side_effects: allow`, declared ordinary external side-effect verbs are also advisory; non-demotable hard stops remain hard. + - Treat the repository-setting phrase `merge method` and descriptive + dependency-introspection wording such as `what pip install pulls` as + reference-only advisories. This is a narrow contextual classification, + not removal or weakening of the merge or dependency-install classes; + affirmative action wording follows the existing hard-stop or granular + declaration path. - Pause when the body touches declared auth/config/secrets/credentials or public-repository scope, or any memory SSOT scope. - - Keep `commercial`, `pricing`, public-repository text, and memory SSOT text - hard with no fence or negation demotion. Pricing/commercial matches are - reported separately as `hard_stop_content_sensitivity` rather than action - risk. + - Keep `commercial`, `pricing`, affirmative public-repository text, and + memory SSOT text hard. Pricing/commercial matches receive no fence or + negation demotion and are reported separately as + `hard_stop_content_sensitivity` rather than action risk. + - Reply-only carve-out for pricing/commercial content. The category was + ruled always-hard — "`commercial`/`pricing` pauses stay HARD, relabeled a + *content-sensitivity* category (out of the action-risk FP stats)" — and + that ruling is amended for exactly one profile shape: a complete task + profile that declares `sends_oacp_reply_only: true` **and** every other + side-effect flag false (`external_side_effects`, `creates_or_updates_pr`, + `comments_on_github`, `commits_changes`, `merges_pr`, `files_issues`). + For that shape a pricing/commercial match does not pause: every matching + term is recorded as a `lexical_advisory_reply_only` note carrying the + term and as structured span provenance whose `demotion_basis` is + `reply_only_advisory`, recorded ahead of the other Gate-3 classes so the + advisory survives whichever hard stop or axis governs the verdict, and + the verdict comes from the remaining axes. Any + other shape keeps the hard stop: a side-effect flag `true`, a profile that + omits `sends_oacp_reply_only`, a missing, unparsable, or contradictory + profile, and the profileless default envelope (reply-only by bound, but it + declares nothing). Fencing and negation still never demote the category. - Pause when file scope is ambiguous or broader than the declared profile. 4. **Runtime/workspace** - Worktree is clean or the task can be isolated to a fresh branch. @@ -244,7 +285,10 @@ cannot override hard stops. Regardless of autonomy mode, receivers must pause on destructive command tokens (`rm -rf`, `--force`, `--no-verify`, `--dangerously-skip-permissions`), direct main pushes, credential rotation, -dependency installation, memory SSOT scope, and pricing/commercial content. +affirmative dependency installation, memory SSOT scope, and +pricing/commercial content — +the last with the single declared reply-only profile-shape carve-out described +under Gate 3, where it records as an advisory instead. Declared auth/config/secrets/dependencies/public scope still pauses through Gate 2. The only standard external-side-effect exception is the configured `allow_pr_artifacts` private-repository class described above. @@ -303,6 +347,15 @@ task_profile: continuation_grants: {} breached: [] co_occurring_reason_codes: [] +admission_axes: + evaluated: true + thresholds: [] + declared_risk: [] + declaration: [] + side_effects: [] + continuation_grant: [] + declaration_errors: [] +matched_patterns: [] runtime: agent: codex model: gpt-5 # serving model, normalized at the writer; null only with a reason @@ -317,6 +370,7 @@ result: completion_kind: auto_accepted actual_minutes: null actual_files_touched: null + work_started_at_utc: null predicted_risk_materialized: false completed_at_utc: null envelope_enforcement: none @@ -329,6 +383,7 @@ result: breached_fields: [] declaration_errors: [] breach_basis: null + breach_sub_basis: null paused_at_utc: null action: not_evaluated predicted_risk_materialized: false @@ -429,6 +484,93 @@ deduplicated against `reason_codes`, and empty on auto-accepted decisions. Threshold-calibration analytics should read `reason_codes` and `co_occurring_reason_codes` together. +### Lexical provenance + +`matched_patterns` is a source-ordered list of every autonomy lexical match, +including the profileless-risk vocabulary and every Gate-3 class, not only the +match that drove the verdict. Each entry has this additive shape: + +```yaml +matched_patterns: + - pattern: merge + category: side_effect + span: {start: 48, end: 53} + demotion_basis: reference_only +``` + +`span` is a zero-based, end-exclusive Unicode-code-point range in the original +message body. `demotion_basis` records why the hit was demoted or why it stayed +operative: `negated`, `reference_only`, `guardrails_fence`, `profile_false`, +`profile_true`, `policy_allowed`, `reply_only_advisory`, `affirmative`, +`profileless_type`, `profileless_risk`, or `non_demotable`. +Contextual demotion is occurrence-scoped: negated or descriptive wording that +demotes one match does not demote a later affirmative match in the same clause, +and a match that spans a clause boundary remains non-demotable. A later +dependency-install or public-repository occurrence requires its own negation +after the preceding class cue; the evaluator does not infer scope from a list +of contrast words. Dependency-install matching also stops at another install +verb, and an otherwise demotable match containing its own negation stays hard +as ambiguous. The first occurrence is demoted only when a narrow recognized +negation form demonstrably governs that occurrence (including the proven +direct dependency-install, direct public-repository action, and out-of-scope +public-repository forms); an unrelated negation earlier in the clause is not a +basis, and unrecognized governance fails closed. A recognized governance form +also fails closed when another negation term precedes its proving term in the +same governance prefix; this deliberately keeps both inverting and reinforcing +stacked-negation constructions hard because their polarity is ambiguous. +Pricing/commercial matches therefore +distinguish the declared reply-only advisory shape from the otherwise +non-demotable content-sensitivity class without changing the class policy. + +The legacy singular `matched_pattern` remains on a paused decision and still +names the first blocking match. `logged_notes` remains the compatibility +surface for advisory reason codes. New readers use `matched_patterns` for +complete forensic evidence and must not infer that a missing singular field +means no lexical term matched. + +### Admission ledger + +`admission_axes` is the structured record of every envelope-derived +admission axis, evaluated in full before any early return — the Gate-3 +lexical returns and every Gate-2 return alike — so a pause taken for one +reason never leaves another axis unrecorded. Each axis lists the pinned +reason codes that held (empty means passed), in evaluation order: + +| Axis | Reason codes | +|---|---| +| `thresholds` | `estimated_minutes_exceeds_threshold`, `expected_files_touched_exceeds_threshold` | +| `declared_risk` | `destructive_ops_pause`, `auth_config_or_secrets_pause`, `dependency_changes_pause`, `public_visibility_pause` | +| `declaration` | `declaration_error` | +| `side_effects` | `merges_pr_pause`, `external_side_effects_not_pr_artifact`, `external_side_effects_pause`, and the granular `_pause` codes | +| `continuation_grant` | the `continuation_grant_*` codes, including `continuation_grant_scope_exceeded` | + +The ledger is evidence, never verdict: `reason_codes` keep the pinned +first-failure shape, and every other axis the ledger holds surfaces +through `co_occurring_reason_codes`, which is therefore complete for the +envelope-derived axes — silence there means passed. `evaluated: false` +with every axis `null` marks a pause taken before a scope envelope +existed (malformed config, `always_pause` mode, message-integrity +failures, a missing or unparsable profile, review-lifecycle admissions): +nothing was evaluated, and the record says so instead of reading as "all +passed". Lexical classification is body-derived: `matched_patterns` records +every hit, while the compatibility field `matched_pattern` names the first +blocking hit. Neither is a ledger axis. + +`declaration_errors` carries the cause of an admission-time +`declaration_error`: the contradicted field, its declared value, and the +declared fields it conflicts with — for example +`task_profile.external_side_effects` declared `false` against +`task_profile.commits_changes`. The field paths also land in `breached`, +as before. + +Admission and checkpoint evidence never overwrite each other. The ledger +and `co_occurring_reason_codes` describe admission only and are left +untouched by every later write; checkpoint outcomes live in +`result.threshold_checkpoint`. A receiver-side checkpoint update that +replaces `reason_codes` with a checkpoint reason discards the admission +history — the finalizer (`oacp autonomy-finalize --checkpoint`) is the +write path precisely because it leaves the admission fields alone. + ### Pinned completion_kind taxonomy `result.completion_kind` names the terminal shape of the **evaluation** only — @@ -520,6 +662,143 @@ grant request never prevents recording the task-level outcome; the recorder sets `grant.request_error` and requires an explicit replacement scope before that malformed request can be approved or modified. +### Terminal finalization and audit integrity + +Every receiver-side write to an audit record after admission goes through +one lock-aware code path: + +```bash +# mid-task §E checkpoint (same points on every receiver) +oacp autonomy-finalize --checkpoint --actuals + +# terminal update +oacp autonomy-finalize --final-state done \ + --actual-minutes 30 --actual-files-touched 3 \ + --realized creates_or_updates_pr --realized commits_changes \ + --reply-message-id +``` + +Hand-edited terminal blocks are what produced off-enum vocabulary, +duplicate live evaluations, and terminal records still carrying a paused +checkpoint action; the finalizer enforces the pinned enums and +cross-field invariants at write time, under the shared audit lock. + +**Run-state vocabulary.** `result.final_state` splits by lifecycle phase: +`pending`, `paused`, and `blocked` are live states; `done`, `superseded`, +and `error` are terminal. Only terminal states can be finalized. +`pending` is receiver-written (an admission record picked up for +execution) — the evaluator itself never writes it. + +**Invariants (terminal ⇒ not paused).** Finalizing `done` requires: a +recorded human outcome when the admission decision was `paused`; a +resolved checkpoint (a `resumed` re-authorization disposition, or a +post-pause human outcome) when the record's checkpoint breached; and no +live sibling evaluation of the same logical message. A still-paused +checkpoint `action` reconciles to `resumed_after_reauthorization` at +finalization once its re-authorization resolved — but an answered pause +covers only what was paused and granted: every final axis is still +compared against the resolved scope (the envelope, an accepted grant, +the recorded re-authorization scope, and the answered pause's own +realized effects), and any new expansion — an uncovered realized effect, +a numeric beyond a scoped re-authorization budget, or a numeric beyond +the pause-time extent a scope-less approval cleared — refuses terminal +reconciliation and routes through a fresh `--checkpoint` with +re-authorization input. Realized effects are monotonic evidence: a +`true` checkpoint value carries forward when terminal actuals omit it, +and an explicit terminal `false` against a recorded `true` is refused as +contradictory under-reporting. A mid-task `--checkpoint` refuses a closed +record outright (completion evidence present, or superseded): a +checkpoint never reopens terminal state. `completion_kind` is never +receiver-composed: a record whose kind or state is off-enum is refused +for `done`/`error` — it is closed via supersession, never edited in +place. + +**Terminal checkpoint parity.** Finalizing `done` on an envelope-bearing +record always evaluates the §E threshold checkpoint against the final +actuals, whichever receiver writes it. Within the envelope, the +checkpoint records `within_declared_envelope` (or +`continued_with_grant`) and the terminal write proceeds; a breach leaves +the record checkpoint-paused (exit code 4) and the §E re-authorization +flow applies before any terminal state can be written. Realized effects +use the canonical axis names only: `actual_minutes`, +`actual_files_touched`, `side_effects_actual.`, and +`task_profile.` for declared-intent corrections. + +**Evaluation identity and supersession.** Every persisted evaluation +carries an `evaluation_id` derived from receiver, message id, message +bytes, and evaluation time. Re-evaluating a message the evaluator +already recorded adopts the existing record when nothing changed (same +bytes, same policy, same verdict — no duplicate is written) and +supersedes it otherwise: the new record references its predecessor via +`supersedes_evaluation_id`, and the predecessor closes with +`final_state: superseded` plus `superseded_by_evaluation_id` — a state +update, never a rewrite of what it said. The prior scan, the +adopt-or-write choice, and predecessor supersession run as one +transaction under a stable lock keyed by (receiver, message id), so +concurrent evaluations of the same logical message serialize instead of +both staying live; adoption also self-heals a crashed predecessor +transaction by superseding any other live prior it finds, stamping the +adopted survivor's `evaluation_id` and `superseded_evaluation_ids` so +every healed predecessor stays resolvable. When one successor closes +several live priors, it lists all of them in +`superseded_evaluation_ids` alongside the single-valued +`supersedes_evaluation_id` (the newest). The transaction fails closed +and is rollback-capable for every failure class, validation and +filesystem alike: every live predecessor is strict-load preflighted +before anything is written, so ambiguous predecessor evidence +(duplicate-key YAML) fails the whole evaluation with nothing persisted +and the malformed bytes untouched; any later failure restores every +already-mutated predecessor byte-for-byte (read-back verified) and +removes a just-written successor before raising — never a partial +success that leaves two live evaluations or a predecessor linked to a +successor that no longer exists. Every superseded record must name its +successor, and resolution is strict: `--superseded-by` is mandatory on +the supersession transition and must resolve to exactly one other +evaluation in the same audit directory carrying the same receiver and +message id and referencing the predecessor back through +`supersedes_evaluation_id` or `superseded_evaluation_ids` — a record +merely carrying the id (unrelated identity, ambiguous holders, no +back-reference) is refused, only strict duplicate-key-refusing bytes +serve as successor evidence, and the sweep flags dangling, +self-referential, ambiguous, or unrelated-identity successor chains. +The transaction pre-acquires every affected record's per-file audit +lock in deterministic order and holds them across capture, mutation, +and restore — the newly created successor included, its lock entered +before publication and held until the transaction commits or rolls +back — so conforming per-record writers (human-outcome recording, +`message_auth` attachment) serialize with it rather than losing a +committed update to its rollback. +The supersession transition is also the documented +repair for legacy off-enum records — it bypasses the enum guards, +preserves the off-enum `completion_kind`, and stashes an off-enum run +state as `result.legacy_final_state` before overwriting it. Superseded +records are transparent to envelope-clear validation, excluded from live +duplicate detection, and skipped by the off-enum sweep checks (their +history is closed); exactly one evaluation per logical message stays +live. + +**Legacy vocabulary.** Records written before this rail carry off-enum +values; the canonical mapping is documentation for collectors, not a +rewrite instruction — `executed` and `review_round_delivered` map to +`auto_accepted`, `human_approved_completed` and +`completed_after_human_approval` to `admission_paused`, and +`final_state: completed` to `done`. `superseded` itself is pinned, not +legacy. + +**Validation.** `oacp autonomy-finalize --validate [--sweep]` +reports pinned finding codes (`off_enum_completion_kind`, +`off_enum_final_state`, `duplicate_logical_id`, `duplicate_yaml_key`, +`paused_terminal_checkpoint_action`, `paused_terminal_completed`, +`terminal_paused_without_outcome`, `superseded_missing_successor`, +`breached_empty_fields`, `invalid_human_outcome`, +`decision_kind_incoherent`, `off_enum_breach_basis`, +`breach_basis_incoherent`, `record_unparsable`; advisory: +`noncanonical_checkpoint_axis`, `terminal_missing_actuals`), and +`oacp doctor` sweeps every receiver's audit directory with the same +checks. Duplicate YAML keys are refused +outright — plain YAML loading silently keeps the later value. The +fixture set lives in `tests/conformance/autonomy/records/`. + ### Pinned reason-code taxonomy Evaluator implementations must reject unpinned reason codes. The canonical @@ -610,13 +889,26 @@ Decision trace: - Gate 3 fails: body matches `rm -rf`. - Decision: `paused`. - Reason codes: `hard_stop_destructive_command`. -- Audit event includes `matched_pattern: "rm -rf"`. +- Audit event includes legacy `matched_pattern: "rm -rf"` plus a + `matched_patterns` entry with its exact source span and + `demotion_basis: non_demotable`. The receiver must pause before any action runs. No autonomy mode can override the hard stop. ## Threshold-Exceeded Checkpoint +`result.work_started_at_utc` is the receiver-stamped time of its first action +on that task. `actual_minutes` is active wall-clock time from that stamp to +completion or the current checkpoint, rounded up to a whole minute, excluding +each represented re-authorization pause from `paused_at_utc` through +`cleared_paused_at_utc`. If a terminal outcome leaves that pause uncleared, +active work ends at `paused_at_utc`; the subsequent paused wait does not count. +The current record shape represents at most its current pause interval. +Admission-to-start idle time never counts; time waiting on a peer reviewer does +count because it is part of that task's review loop. Serialized tasks admitted +together therefore stamp and measure their own starts independently. + Receivers must evaluate a threshold checkpoint if work expands beyond the declared scope envelope after acceptance: @@ -646,6 +938,7 @@ result: breached_fields: - actual_files_touched breach_basis: realized + breach_sub_basis: null paused_at_utc: "2026-05-12T13:48:25Z" action: paused_for_reauthorization ``` @@ -668,6 +961,26 @@ A breached checkpoint stamps two fields beyond the breach itself: `declared_intent` record legitimately combines `breached: true` with all-false realized effects and low actual counts — the sender's under-declaration was caught, not an executed drift. +- `breach_sub_basis` — optional refinement of a `realized` breach on the + time axis (`actual_minutes` in `breached_fields`). The only pinned + value is `waiting_on_peer`: the clock overran while the receiver was + waiting on a peer reply (a review round, a re-authorization answer) + rather than working, so the ledger can separate review latency from + working time when calibrating the time axis. Receivers pass it in the + checkpoint actuals (`actuals.breach_sub_basis`); it is `null` on every + other checkpoint. An off-vocabulary value, or the value on an + unbreached checkpoint, on a `declared_intent` breach, or on a breach + that does not include the time axis, is rejected rather than recorded — + and the same grammar is enforced on read-back: the audit validator + (`oacp autonomy-finalize --validate`, `oacp doctor`, and the finalizer's + own residual check) reports `off_enum_breach_basis` / + `breach_basis_incoherent` on a persisted `breach_basis` or + `breach_sub_basis` that the evaluator would have refused — an + off-vocabulary value, a basis on an unbreached checkpoint, a sub-basis + off the realized time axis, or an enum-valid basis on the opposite + breach source (`declared_intent` over realized axes, realized effects, + or materialized risk; `realized` over `task_profile.*` corrections) — + so stored ledger evidence cannot drift off the grammar by hand-editing. A prospective correction is expressed through its own checkpoint input, never by marking a realized effect true (that would assert an outward @@ -790,7 +1103,7 @@ threshold_checkpoint: scope: # the effective grant — policy-capped; max_actual_minutes: 30 # the durable value later checkpoints disposition: resumed # and envelope recompiles consume - cleared_paused_at_utc: "2026-05-12T12:30:00Z" + cleared_paused_at_utc: "2026-05-12T12:50:00Z" advisory: - channel: sender_reply decision: approved @@ -840,11 +1153,11 @@ through a standing granted scope is spent. Presenting it against a new breach is a reuse attempt and fails with `checkpoint_reauthorization_stale`. Consumption state lives on the audit record: the -`threshold_checkpoint.reauthorization` block binds the governing answer to -the pause it cleared (`cleared_paused_at_utc`) and preserves the granted -scope for later checkpoints. A new checkpoint re-stamps `paused_at_utc`; -whether prior answers still cover it is decided by the arbitration above, -never by implicit trust. +`threshold_checkpoint.reauthorization` block records when the governing +answer cleared the current `paused_at_utc` interval in +`cleared_paused_at_utc` and preserves the granted scope for later checkpoints. +A new checkpoint re-stamps `paused_at_utc`; whether prior answers still cover +it is decided by the arbitration above, never by implicit trust. ### Boundary-action grants @@ -1075,7 +1388,9 @@ Runtime decisions: - Protocol bookkeeping never consumes the file budget. The receiver's own `audit/`, `inbox/`, and `outbox/` directories and the runtime scratchpad (reply/body-file composition) are the enforcement layer's instrumentation - surfaces, not task scope: writes there skip the counter entirely. The + surfaces, not task scope: writes there skip the counter entirely. + `audit/autonomy_decisions/` is the exception carved out of `audit/` — it + is authority, not instrumentation (see below). The exemption is receiver-scoped (a peer agent's directories are task scope), containment is judged on resolved filesystem targets (a symlink planted under an exempt root that points into ordinary task scope stays counted), @@ -1084,19 +1399,29 @@ Runtime decisions: `config.yaml` sits outside the exempt directories. Without this class, a tightly declared task is guaranteed to trip the ceiling at close-out on its own mandatory audit write. -- Two receiver surfaces are the opposite of exempt. The receiver's `state/` - directory holds the active envelope itself: writing, removing, - relocating, or copying anything under it from inside the session is - denied as envelope self-modification, categorically — filesystem-mutator - operands (`rm`, `unlink`, `mv`, `cp`, …) are gated by resolved path - regardless of source/destination role, not just write targets. Operands - are judged as the utility parses them (GNU target-directory spellings and - `--` included), and mutator operands or Bash write targets bearing shell - expansion syntax escalate to **ask** — the shell expands patterns after - classification, so a literal spelling proves nothing about the effective - target. Trust - roots (the receiver's `trust/` pins and the project trust catalog) are - authority-bearing auth configuration: writes and mutations are denied +- Two receiver surfaces are **authority**: the opposite of exempt, and + governed by one boundary rather than two. The receiver's `state/` + directory holds the active envelope itself; its + `audit/autonomy_decisions/` directory holds the records whose recorded + re-authorization can widen that envelope's live file bound. Writing, + removing, relocating, or copying anything under either from inside the + session is denied as authority self-modification, categorically — + filesystem-mutator operands (`rm`, `unlink`, `mv`, `cp`, …) are gated by + resolved path regardless of source/destination role, not just write + targets, because deleting or relocating a record changes which record + governs just as surely as rewriting one. Operands are judged as the + utility parses them (GNU target-directory spellings and `--` included), + and mutator operands or Bash write targets bearing shell expansion syntax + escalate to **ask** — the shell expands patterns after classification, so + a literal spelling proves nothing about the effective target. The + canonical audit writers (`oacp autonomy-finalize`, + `oacp autonomy-outcome`, `oacp verify --attach-audit`) reach the record + as classified commands that escalate for review, never as file writes. + The boundary is deliberately scoped to the session the envelope binds: it + is the *self*-authorization path that must close, and a concurrent + session is already outside that envelope's reach. +- Trust roots (the receiver's `trust/` pins and the project trust catalog) + are authority-bearing auth configuration: writes and mutations are denied unless the envelope declares `touches_auth_config_or_secrets`, and even then they count as ordinary task scope. CLI-mediated updates (`oacp trust import`) are classified as commands and escalate for review @@ -1128,9 +1453,11 @@ to a safe-id grammar at compile time) never reaches a filesystem glob: record cannot be validated in-session and escalates to **ask**. The ordering this creates is deliberate: finish the task, update the audit -record's `result` block (a bookkeeping write, exempt from the counter), -then clear. `oacp envelope compile` from inside the session stays denied -unconditionally — completion sanctions the exit, never recompilation. +record's `result` block through the canonical writer (`oacp +autonomy-finalize`; a direct write to the record is denied as authority +self-modification), then clear. `oacp envelope compile` from inside the +session stays denied unconditionally — completion sanctions the exit, never +recompilation. ### Envelope drift @@ -1142,6 +1469,15 @@ protocol above: the deny fires once, the session stops, notifies the sender, and awaits re-authorization. A revised profile is recompiled with `oacp envelope compile --extend`, which preserves accumulated counters. +A granted re-authorization takes effect without that recompile, which is +denied from inside the bound session: the adapter reads the governing audit +record's `threshold_checkpoint.reauthorization.scope` at enforcement time and +overlays it on the compiled envelope, so the effective ceiling is +`expected_files_touched` widened to the granted `max_actual_files_touched` — +never past it, never below it, and never from a scope-less approval, which +grants nothing durable. The envelope itself stays immutable; only `scope` +(the policy-capped value) is consulted, never `requested_scope`. + ### Enforcement recording The audit `result` block records `envelope_enforcement: hooks | none`. @@ -1351,11 +1687,12 @@ the accepted `review_continuation.scope`. Audit `result.final_state` is limited to: -- `done` -- `paused` -- `blocked` -- `superseded` -- `error` +- `pending` (live; receiver-written at pickup — the evaluator never writes it) +- `paused` (live) +- `blocked` (live) +- `done` (terminal) +- `superseded` (terminal; see "Terminal finalization and audit integrity") +- `error` (terminal) `result.completion_kind` is separately pinned to the evaluation-shape enum above (see "Pinned completion_kind taxonomy"). Missing-profile messages that diff --git a/docs/protocol/inbox_outbox.md b/docs/protocol/inbox_outbox.md index 56a8601..87df8ba 100644 --- a/docs/protocol/inbox_outbox.md +++ b/docs/protocol/inbox_outbox.md @@ -377,7 +377,7 @@ Agents can maintain continuity across handoffs and multi-step exchanges using op ### How It Works -1. **Starting a conversation**: The initiating agent generates a `conversation_id` (e.g., `conv-20260211-codex-001`) and includes it in the first message. +1. **Starting a conversation**: When a parent-less `oacp send` omits `conversation_id`, the CLI generates and stamps one (e.g., `conv-20260211-codex-000137`); an explicit value is preserved. 2. **Continuing a conversation**: Subsequent messages in the same thread reuse the same `conversation_id` and set `parent_message_id` to the `id` of the message being replied to. 3. **Handoff messages**: When handing off work (`type: handoff`), the sender SHOULD include `conversation_id` and `context_keys` so the receiving agent can pick up without re-reading the full history. 4. **Context keys**: A concise summary of decisions made, artifacts produced, and open questions from the prior conversation. This avoids the anti-pattern of forwarding raw conversation transcripts. diff --git a/docs/protocol/org_memory.md b/docs/protocol/org_memory.md index 0a5d887..67075eb 100644 --- a/docs/protocol/org_memory.md +++ b/docs/protocol/org_memory.md @@ -13,6 +13,10 @@ $OACP_HOME/org-memory/ rules.md # topical: standing conventions (illustrative default) events/ # chronological: timestamped entries YYYYMMDD-HHMMSS-short-slug.md + debriefs/ # central session-debrief store (append-only, full records) + / + // + YYYYMMDD--.md ``` `decisions.md` and `rules.md` are illustrative defaults, not protocol requirements. Adopters choose which topical files to create (e.g., `agents.md`, `architecture.md`). @@ -66,15 +70,175 @@ Examples: - `20260317-170120-api-convention.md` - `20260318-091500-deploy-freeze.md` +## Debrief Store + +`org-memory/debriefs/` is the central, append-only store for full session +debriefs. Every agent's end-of-session debrief lands here — one immutable file +per session — instead of in per-project trees. Debriefs are the full-fidelity +session record; events remain the filtered-outcome channel. The two are +distinct artifact classes and neither substitutes for the other. + +`oacp org-memory init` creates `debriefs/` (with a `.gitkeep` placeholder so +the empty directory survives git-based sync). `oacp doctor` checks the store +setup — directory presence, path layout, lingering staging artifacts, +irregular entries — and never opens debrief files: content and format +verification belong to the writer contract (read-back at publication) and to +git history, not to the doctor. The kernel owns only this layout and schema; +the writer that produces debrief files is adopter tooling (for example, a +debrief skill script) — there is no kernel subcommand for writing debriefs. + +### Path and File Naming + +``` +org-memory/debriefs////--.md +``` + +- `` — the originating project workspace name, exactly as it appears + under `$OACP_HOME/projects/`, byte-for-byte and case-sensitive. The segment + follows the workspace project-name rule (any name that does not start with + `.` and contains no `/` or `\`), so every valid workspace name has a valid + debrief path. +- `/` — the year and zero-padded month of the session start, in UTC. + Both MUST agree with the filename's `` prefix. +- Filename grammar (anchored regex): + + ``` + ^(?P\d{8})-(?P[A-Za-z0-9][A-Za-z0-9._-]{0,63})-(?P[a-z0-9]{1,32})\.md$ + ``` + + - `` — the session start date in UTC. MUST equal the date of + `started_utc` in the frontmatter and the `/` parent directories. + - `` — the writing agent's name, exactly as registered (the agent + grammar is the protocol's canonical agent-name rule, so hyphens, dots, + underscores, and mixed case are all representable). Case-sensitive and + byte-for-byte identical to the frontmatter `agent` field. + - `` — a short stable session identifier: lowercase letters and + digits only, 1-32 characters, **never hyphens**. Recommended: the first 8 + characters of the harness session UUID. Distinguishes multiple sessions + by the same agent on the same day. + - **Parse rule**: the session identifier is the substring after the *final* + hyphen; hyphens may therefore appear inside the agent segment but never + inside the session identifier, which keeps the three-part filename + uniquely parseable for any valid agent name. + +Examples: `debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md`, +`debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md` (agent +`bob-ops`). + +### Debrief File Schema + +```markdown +--- +schema_version: 1 # required — integer, this schema +project: demo-project # required — matches the path segment +agent: alice # required — matches the filename segment +runtime: claude # required — runtime family that ran the session +session: 1f3a9c2b # required — matches the filename segment +started_utc: 2026-08-25T20:04:11Z # required — session start, ISO 8601 UTC (Z) +ended_utc: 2026-08-25T22:01:47Z # required — session end, ISO 8601 UTC (Z); >= started_utc +content_sha256: <64 lowercase hex chars> # required — SHA-256 of the body (definition below) +immutable: true # required — literal true; the append-only assertion +--- + + +``` + +**Body and content hash.** The body is every byte after the line that closes +the frontmatter block (the second `---` line, including its trailing newline), +exactly as stored — no trailing-whitespace or newline normalization. +`content_sha256` is the lowercase-hex SHA-256 of those UTF-8 bytes. Writers +compute the hash over the final body bytes at write time; validators recompute +it the same way. + +### Append-Only Rule + +Debrief files are written once, at session end, and never rewritten: + +- One session = one file. A later session the same day writes a new file with + a different `` identifier — never an append to an existing file. +- Corrections and follow-ups are new artifacts (a new debrief, or an event + referencing the original) — never edits to a landed debrief. +- A file whose body no longer matches its `content_sha256`, or that was + rewritten after publication, is a protocol violation. Detection is an + adopter-tooling concern (boundaries below); `oacp doctor` checks setup + only and does not open debrief files. + +**Rewrite detection (normative).** On a git-backed store, git state and +history are the authoritative rewrite evidence: a tracked debrief modified or +deleted in the working tree, or touched by more than one commit, is flagged; +when the evidence queries themselves fail, the validator reports the evidence +as unavailable rather than clean. On a store that is not a git repository, +the fallback signal is the file's modification time measured against its own +`ended_utc`: publication legitimately happens shortly after session end, so a +modification time up to exactly 3,600 seconds after `ended_utc` is accepted +as the publication window, and a file whose modification time exceeds +`ended_utc` by MORE than 3,600 seconds (strictly greater) is flagged. +Validators implement this exact boundary. + +**Writer commit contract.** Because writers live outside the kernel, this +section is the shared authority every independent writer implements against. +Publication is failure-atomic: the canonical path only ever holds a complete, +verified record — never partial bytes. + +- **Stage privately first.** Write the full record to a private staging file + in the same `///` directory, named + `.stage..` (the leading dot keeps it outside the + canonical namespace; `` is a writer-unique token). Flush it and + verify the staged bytes (size and `content_sha256`) before publication. +- **Publish with an atomic no-replace primitive** — `link()` to the canonical + name followed by unlinking the staging name, `renameat2(..., + RENAME_NOREPLACE)`, or an equivalent that fails if the target exists. A + rename that can replace an existing target is forbidden, and so is writing + through the canonical path directly: `O_CREAT|O_EXCL` on the canonical + path is NOT sufficient — it reserves the name atomically but publishes + content non-atomically, so a failure between open and write would expose a + partial record. +- **Collision rule, evaluated at publish.** If the canonical path already + exists: byte-identical to the staged record = idempotent success (remove + the staging file and report success); any difference = hard failure — the + writer reports it and recovers by publishing under a *new* `` + identifier, never by replacing the existing record. +- **Failure leaves the canonical namespace clean.** Validation errors, short + writes, crashes, and read-back mismatches before publication MUST leave + the canonical path absent; at worst a private staging file remains. + Writers deterministically remove or adopt *their own* stale staging + artifacts on retry; `oacp doctor` reports lingering staging files. +- **Verify after publish.** Read the canonical file back and confirm + `content_sha256`; a mismatch is a reported failure, never a silent + success. +- The target MUST be a regular file. Writers MUST NOT follow symlinks, and + MUST fail when the existing path is a symlink or any non-regular file. +- Parent directories (`///`) may be created with ordinary + make-parents semantics; only the file itself carries the publication rule. +- Writer conformance tests accompany the writer implementation and MUST pin: + exception/short-write before publish (canonical path stays absent), + interrupted publication recovery, read-back mismatch, retry after success + (idempotent), differing-content collision, symlink-at-target, and + concurrent identical and differing writers. + +### Curation Guard + +Raw debriefs never enter `recent.md` or `events/` — only curated folds do. +The coordinator reads debriefs and promotes durable outcomes into events and +topical files; the raw session narrative stays in `debriefs/`. This keeps the +always-loaded surfaces bounded and the event channel filtered. Debrief files +are likewise never auto-loaded at session start. + +### Sync + +The cross-machine memory-sync allowlist covers `org-memory/debriefs/**` (it is +inside `org-memory/**`, which syncs in full). No per-adopter sync +configuration is needed. + ## Permission Model -| Role | recent.md | Topical files | events/ | -|------|:---------:|:-------------:|:-------:| -| Agent read | yes | yes | yes | -| Agent write | no | no | yes | -| Coordinator write | yes | yes | yes | +| Role | recent.md | Topical files | events/ | debriefs/ | +|------|:---------:|:-------------:|:-------:|:---------:| +| Agent read | yes | yes | yes | yes | +| Agent write | no | no | yes | yes (own sessions, append-only) | +| Coordinator write | yes | yes | yes | yes (append-only) | -- **Agents** write events only (append-only, no coordination needed) +- **Agents** write events and their own session debriefs (both append-only, no coordination needed) - **Agents** may propose topical promotions or corrections via events (type: `rule` or `decision`) — the coordinator decides whether to incorporate - **Agents** may proactively read `events/` for urgent context (e.g., "API X is down") without waiting for coordinator curation - **Coordinator** curates topical files and `recent.md` from events @@ -85,6 +249,8 @@ Examples: - Events are archived after an adopter-defined retention period (reference default: 30 days) - Patterns that repeat 3+ times in events should be promoted to topical files - `recent.md` reflects current state, not full history — it is a rolling summary +- Debriefs are permanent history: individual files are never rewritten (see + Append-Only Rule); retention beyond that is adopter-defined ## Integration Pattern (Cortex Reference Implementation) @@ -92,23 +258,24 @@ Cortex demonstrates the dual-pipeline pattern — same source data, two audience **Debrief step (write):** - Debrief → cortex inbox (existing, for human) -- Debrief → `org-memory/events/` (new, for agents) -- Both writes treated as a logical unit — retry/warn on partial failure -- `source_ref` in event frontmatter matches the debrief ID for reconciliation +- Debrief → `org-memory/debriefs/` (the canonical immutable session record — see Debrief Store) +- Curated outcome events → `org-memory/events/` (filtered derivatives, for agents — never the raw debrief; see Curation Guard) +- All three writes treated as a logical unit — retry/warn on partial failure +- `source_ref` in event frontmatter matches the debrief-store filename stem (`--`) for reconciliation **Sync step (curate):** -- Debriefs → SSOT + vault daily notes (existing, for human) +- Debriefs (read from `org-memory/debriefs/`) → SSOT + vault daily notes (existing, for human) - Events → topical files + `recent.md` (new, for agents) - Sync cross-references SSOT when curating topical files to prevent drift - Sync is idempotent — handles duplicates/replays via `source_ref` + `created_at_utc` -**Consistency model:** Eventual, not strong. The two pipelines may temporarily diverge. `source_ref` enables reconciliation. Adopter defines failure semantics: retryable partial failure, blocked debrief, or acceptable degraded mode. +**Consistency model:** Eventual, not strong. The pipelines may temporarily diverge. `source_ref` enables reconciliation against the debrief-store record. Adopter failure semantics (retryable partial failure, blocked debrief, or acceptable degraded mode) apply to the inbox and event writes; the debrief-store write is required by the Debrief Store section, and a store write that ultimately fails is a reported failure to retry, never an accepted degraded mode. ## v0.2 Scope 1. Format spec (directory structure, frontmatter schema, naming convention) -2. CLI: `oacp org-memory init` (scaffold directory) and `oacp write-event` (create event files) -3. Agents write events during debrief +2. CLI: `oacp org-memory init` (scaffold directory, including `debriefs/`) and `oacp write-event` (create event files) +3. Agents write their full session debrief to `org-memory/debriefs/` (via adopter tooling — see Debrief Store) and curated outcome events during debrief 4. Agents read topical files + `recent.md` for org context 5. Coordinator maintains topical files during sync diff --git a/docs/protocol/runtime_capabilities.md b/docs/protocol/runtime_capabilities.md index b6fa32b..bd0f45a 100644 --- a/docs/protocol/runtime_capabilities.md +++ b/docs/protocol/runtime_capabilities.md @@ -49,7 +49,7 @@ These are reference defaults. Actual capabilities may vary by configuration. | `async_tasks` | yes | yes | no | yes | | `image_generation` | no | no | no | yes | -See `docs/guides/runtime_capability_matrix.md` for the full parity matrix with details. +See the public [cross-runtime parity matrix](https://github.com/kiloloop/research/blob/main/runtime-comparison/runtime_capability_matrix.md) for full details. ## Dynamic Status Schema @@ -277,10 +277,10 @@ The card schema is inspired by Google's A2A Agent Card spec (v0.3.0). Key differ ## Cross-References - **Agent Profiles**: `docs/protocol/agent_profiles.md` — two-tier global profile + project card system -- **Parity Matrix**: `docs/guides/runtime_capability_matrix.md` — detailed per-runtime capability comparison +- **Parity Matrix**: [Cross-runtime parity matrix](https://github.com/kiloloop/research/blob/main/runtime-comparison/runtime_capability_matrix.md) — detailed per-runtime capability comparison - **Inbox Protocol**: `docs/protocol/inbox_outbox.md` — agent messaging format - **Session telemetry**: `scripts/session_lifecycle_hooks.py` — coordinator compatibility state, separate from runtime startup and `status.yaml` -- **Workspace Setup**: `scripts/init_project_workspace.sh` — project initialization +- **Workspace Setup**: `scripts/init_project_workspace.py` (`oacp init`) — project initialization - **Agent Card Template**: `templates/agent_card.template.yaml` — card template - **Card Validator**: `scripts/validate_agent_card.py` — schema validation diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md index 9808739..b344b64 100644 --- a/examples/quickstart/README.md +++ b/examples/quickstart/README.md @@ -13,7 +13,7 @@ you (alice) AI agent (bob) ## Prerequisites -- Python 3.9+ +- Python 3.9.2+ - `pip install oacp-cli` - An AI agent runtime — [Claude Code](https://claude.ai/code), [Codex](https://openai.com/index/codex/), or any agent that can read/write files diff --git a/oacp/cli.py b/oacp/cli.py index 9515e9c..f9f0014 100644 --- a/oacp/cli.py +++ b/oacp/cli.py @@ -21,7 +21,7 @@ Commands: init Create a project workspace under $OACP_HOME/projects/ add-agent Add an agent to an existing project workspace - agent Manage global agent profiles (init, show, list) + agent Manage global agent profiles (init, sync, show, list) inbox List pending inbox messages watch Emit inbox delta events for Monitor-friendly polling retention Prune project message history by age and count @@ -34,6 +34,7 @@ org-memory Initialize org-level memory at $OACP_HOME/org-memory/ write-event Write an event to org-memory/events/ autonomy-outcome Record a human approval/decline in an autonomy audit + autonomy-finalize Record checkpoints and terminal states in an autonomy audit envelope Compile, show, or clear the runtime envelope for a task doctor Check environment and workspace health validate Validate an inbox/outbox YAML message @@ -57,6 +58,7 @@ oacp org-memory init oacp write-event --agent claude --project my-project --type decision --slug api-convention --body "Use REST for public APIs" oacp autonomy-outcome /path/to/audit.yaml --decision approved + oacp autonomy-finalize /path/to/audit.yaml --final-state done --actual-minutes 30 --actual-files-touched 3 oacp envelope compile /path/to/message.yaml --receiver claude oacp envelope show --project my-project oacp doctor @@ -80,6 +82,7 @@ "org-memory": "init_org_memory.py", "write-event": "write_event.py", "autonomy-outcome": "record_autonomy_outcome.py", + "autonomy-finalize": "finalize_autonomy_record.py", "envelope": "envelope_compiler.py", "doctor": "oacp_doctor.py", "validate": "validate_message.py", diff --git a/pyproject.toml b/pyproject.toml index ba14364..524d1cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "hatchling.build" [project] name = "oacp-cli" -version = "0.4.3" +version = "0.4.4" description = "Open Agent Coordination Protocol CLI for file-based multi-agent workflows" readme = "README.md" license = "Apache-2.0" license-files = ["LICENSE"] -requires-python = ">=3.9" +requires-python = ">=3.9.2" authors = [ { name = "Kiloloop" }, ] @@ -32,7 +32,7 @@ classifiers = [ [project.optional-dependencies] crypto = [ - "cryptography>=3.4", + "cryptography>=50.0.0", ] [project.urls] @@ -47,8 +47,10 @@ oacp-envelope-hook = "oacp.envelope_hook:main" [dependency-groups] dev = [ - "pytest>=8.0", - "ruff>=0.11.0", + "build==1.5.0", + "hatchling==1.27.0", + "pytest==9.1.1", + "ruff==0.16.2", ] [tool.hatch.build.targets.sdist] @@ -68,6 +70,7 @@ packages = ["oacp"] "scripts/oacp_doctor.py" = "oacp/_scripts/oacp_doctor.py" "scripts/autonomy_gate.py" = "oacp/_scripts/autonomy_gate.py" "scripts/record_autonomy_outcome.py" = "oacp/_scripts/record_autonomy_outcome.py" +"scripts/finalize_autonomy_record.py" = "oacp/_scripts/finalize_autonomy_record.py" "scripts/envelope_compiler.py" = "oacp/_scripts/envelope_compiler.py" "scripts/claude_envelope_hook.py" = "oacp/_scripts/claude_envelope_hook.py" "scripts/init_project_workspace.py" = "oacp/_scripts/init_project_workspace.py" @@ -93,9 +96,20 @@ packages = ["oacp"] "scripts/setup_runtime.py" = "oacp/_scripts/setup_runtime.py" "scripts/init_org_memory.py" = "oacp/_scripts/init_org_memory.py" "scripts/write_event.py" = "oacp/_scripts/write_event.py" +"scripts/check_quality_gate.py" = "oacp/_scripts/check_quality_gate.py" +"scripts/init_packet.sh" = "oacp/_scripts/init_packet.sh" +"scripts/preflight.py" = "oacp/_scripts/preflight.py" +"scripts/update_workspace.sh" = "oacp/_scripts/update_workspace.sh" +"scripts/validate_agent_card.py" = "oacp/_scripts/validate_agent_card.py" +"scripts/gen_readme_commands.py" = "oacp/_scripts/gen_readme_commands.py" "docs/protocol/agent_safety_defaults.md" = "oacp/_protocol/agent_safety_defaults.md" "docs/protocol/dispatch_states.yaml" = "oacp/_protocol/dispatch_states.yaml" "docs/protocol/session_init.md" = "oacp/_protocol/session_init.md" +"docs/protocol/inbox_outbox.md" = "oacp/_protocol/inbox_outbox.md" +"docs/protocol/message_signing.md" = "oacp/_protocol/message_signing.md" +"docs/protocol/autonomy.md" = "oacp/_protocol/autonomy.md" +"docs/protocol/org_memory.md" = "oacp/_protocol/org_memory.md" +"templates/inbox_message.template.yaml" = "oacp/_templates/inbox_message.template.yaml" "templates/runtime_capabilities.yaml" = "oacp/_templates/runtime_capabilities.yaml" "templates/org-memory/recent.md" = "oacp/_templates/org-memory/recent.md" "templates/org-memory/decisions.md" = "oacp/_templates/org-memory/decisions.md" diff --git a/scripts/_oacp_constants.py b/scripts/_oacp_constants.py index f6f7532..80e1ae8 100644 --- a/scripts/_oacp_constants.py +++ b/scripts/_oacp_constants.py @@ -77,6 +77,78 @@ def locked_audit(audit_path: Path): fcntl.flock(handle.fileno(), fcntl.LOCK_UN) +def atomic_replace_yaml(path: Path, data: dict) -> None: + """Atomically replace a YAML file in place, preserving its mode. + + The shared write half of every audit read-modify-write: dump, fsync a + sibling temp file, then rename over the original so readers never see + a partial record. Callers must already hold ``locked_audit`` on the + target. YAML import stays local so this module keeps loading in + environments without pyyaml (doctor degrades gracefully there). + """ + import os + import tempfile + + import yaml + + path = Path(path) + content = yaml.safe_dump(data, sort_keys=False, allow_unicode=True) + mode = path.stat().st_mode + temp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + temp_path = Path(handle.name) + os.chmod(temp_path, mode) + os.replace(temp_path, path) + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink() + + +def atomic_replace_bytes(path: Path, data: bytes) -> None: + """Atomically replace a file with exact bytes, preserving its mode. + + The restore half of a rolled-back multi-file audit transaction: the + original bytes go back verbatim (not a re-serialization, which would + normalize formatting and collapse duplicate-key evidence). Same + fsync + rename discipline as ``atomic_replace_yaml``; callers must + already hold ``locked_audit`` on the target. + """ + import os + import tempfile + + path = Path(path) + mode = path.stat().st_mode + temp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + temp_path = Path(handle.name) + os.chmod(temp_path, mode) + os.replace(temp_path, path) + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink() + + def _write_if_missing(path: Path, content: str) -> bool: """Write content to *path* only if it does not already exist.""" if path.exists(): diff --git a/scripts/add_agent.py b/scripts/add_agent.py index 6830472..8c7b942 100644 --- a/scripts/add_agent.py +++ b/scripts/add_agent.py @@ -22,6 +22,7 @@ _write_if_missing, utc_now_iso, ) +from agent_profile import load_global_profile, upsert_global_profile AGENT_SUBDIRS = ( "inbox", @@ -219,20 +220,16 @@ def add_agent( # Check for global profile defaults global_profile = None - global_profile_path = oacp_root / "agents" / agent_name / "profile.yaml" - if global_profile_path.is_file(): - try: - if yaml is not None: - global_profile = yaml.safe_load( - global_profile_path.read_text(encoding="utf-8") - ) - if not isinstance(global_profile, dict): - global_profile = None - except Exception as exc: - print(f"Warning: could not load global profile for '{agent_name}': {exc}", file=sys.stderr) - global_profile = None + try: + global_profile = load_global_profile(oacp_root, agent_name) + except (OSError, ValueError) as exc: + raise ValueError( + f"could not load global profile for '{agent_name}': {exc}" + ) from exc # Optional runtime-specific files + registry_model: Optional[str] = None + registry_description: Optional[str] = None if runtime is not None: caps = _load_runtime_capabilities().get(runtime, {}) # If global profile exists, use its identity fields as defaults @@ -254,10 +251,26 @@ def add_agent( else: skipped_files.append(str(card_path.relative_to(project_dir))) + registry_model = str(caps.get("model", runtime)) + registry_description = f"{agent_name} agent ({runtime} runtime)" + + registry_runtime = runtime or ( + agent_name if agent_name in CREATABLE_RUNTIMES else "unknown" + ) + registry_update = upsert_global_profile( + oacp_root, + agent_name, + registry_runtime, + projects=[project_name], + model=registry_model, + description=registry_description, + ) + return { "agent_dir": agent_dir, "created_files": created_files, "skipped_files": skipped_files, + "registry_update": registry_update, } @@ -287,6 +300,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if result["skipped_files"]: for f in result["skipped_files"]: print(f" ~ {f} (already exists, skipped)") + registry = result["registry_update"] + marker = "+" if registry["action"] == "created" else "~" + print( + f" {marker} {registry['path']} " + f"(registry {registry['action']})" + ) return 0 diff --git a/scripts/agent_profile.py b/scripts/agent_profile.py index 0173da1..dd40ae5 100644 --- a/scripts/agent_profile.py +++ b/scripts/agent_profile.py @@ -22,9 +22,14 @@ import argparse import copy +from contextlib import contextmanager +import fcntl +import os +import stat import sys +import tempfile from pathlib import Path -from typing import Any, Dict, Optional, Sequence +from typing import Any, Dict, Iterator, List, Optional, Sequence try: import yaml @@ -40,7 +45,6 @@ AGENT_RE, ALL_RUNTIMES, _template_path, - _write_if_missing, is_agent_dir, ) @@ -53,12 +57,17 @@ def _validate_name(name: str) -> Optional[str]: if not NAME_RE.fullmatch(name): return f"invalid agent name '{name}': must be 1-64 alphanumeric chars, dots, hyphens, or underscores" return None + + def _load_yaml(path: Path) -> Dict[str, Any]: """Load a YAML file using PyYAML.""" if yaml is None: raise RuntimeError("PyYAML is required: pip install pyyaml") raw = path.read_text(encoding="utf-8") - data = yaml.safe_load(raw) + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as exc: + raise ValueError(f"invalid YAML in {path}: {exc}") from exc if data is None: data = {} if not isinstance(data, dict): @@ -71,6 +80,250 @@ def _dump_yaml(data: Dict[str, Any]) -> str: if yaml is None: raise RuntimeError("PyYAML is required: pip install pyyaml") return yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True) + + +@contextmanager +def _locked_profile(profile_path: Path) -> Iterator[None]: + """Serialize updates to one global profile across concurrent writers.""" + profile_path.parent.mkdir(parents=True, exist_ok=True) + directory_fd = os.open(profile_path.parent, os.O_RDONLY) + try: + fcntl.flock(directory_fd, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(directory_fd, fcntl.LOCK_UN) + finally: + os.close(directory_fd) + + +def _atomic_write_profile(path: Path, data: Dict[str, Any]) -> None: + """Atomically replace one profile while preserving its existing mode.""" + content = _dump_yaml(data) + mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644 + temp_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + temp_path = Path(handle.name) + os.chmod(temp_path, mode) + os.replace(temp_path, path) + temp_path = None + finally: + if temp_path is not None and temp_path.exists(): + temp_path.unlink() + + +def _new_profile( + name: str, + runtime: str, + *, + model: str, + description: str, + projects: Sequence[str], +) -> Dict[str, Any]: + """Build a new profile from the shipped template and identity defaults.""" + with _template_path("agent_profile.template.yaml") as tpl_path: + data = _load_yaml(tpl_path) + data["name"] = name + data["runtime"] = runtime + data["model"] = model + data["description"] = description + data["projects"] = list(projects) + return data + + +def upsert_global_profile( + oacp_root: Path, + name: str, + runtime: str, + *, + projects: Sequence[str] = (), + model: Optional[str] = None, + description: Optional[str] = None, +) -> Dict[str, Any]: + """Create or extend an instance-level agent registration. + + Existing identity values are never replaced. Missing identity keys are + filled, and new project memberships are appended in caller order. The + directory lock plus atomic replace prevents concurrent additions from losing + memberships without adding registry artifacts. + """ + err = _validate_name(name) + if err: + raise ValueError(err) + if runtime not in ALL_RUNTIMES: + raise ValueError( + f"invalid runtime '{runtime}': must be one of {tuple(ALL_RUNTIMES)}" + ) + for project in projects: + if project.startswith(".") or "/" in project or "\\" in project: + raise ValueError(f"invalid project name '{project}'") + + model_default = model if model is not None else ( + runtime if runtime != "unknown" else "" + ) + description_default = description if description is not None else ( + f"{name} agent ({runtime} runtime)" if runtime != "unknown" else f"{name} agent" + ) + profile_path = oacp_root / "agents" / name / "profile.yaml" + + with _locked_profile(profile_path): + if profile_path.is_file(): + data = _load_yaml(profile_path) + action = "unchanged" + identity_defaults = { + "name": name, + "runtime": runtime, + "model": model_default, + "description": description_default, + } + for key, value in identity_defaults.items(): + if key not in data: + data[key] = value + action = "updated" + + memberships = data.get("projects") + if memberships is None: + memberships = [] + data["projects"] = memberships + action = "updated" + if not isinstance(memberships, list) or not all( + isinstance(item, str) for item in memberships + ): + raise ValueError( + f"projects must be a list of strings in {profile_path}" + ) + for project in projects: + if project not in memberships: + memberships.append(project) + action = "updated" + + if action == "updated": + _atomic_write_profile(profile_path, data) + else: + data = _new_profile( + name, + runtime, + model=model_default, + description=description_default, + projects=projects, + ) + _atomic_write_profile(profile_path, data) + action = "created" + + return {"path": profile_path, "action": action, "profile": data} + + +def discover_project_memberships(oacp_root: Path) -> Dict[str, List[str]]: + """Return visible project memberships keyed by agent name.""" + memberships: Dict[str, List[str]] = {} + projects_dir = oacp_root / "projects" + if not projects_dir.is_dir(): + return memberships + for project_dir in sorted(projects_dir.iterdir()): + if not is_agent_dir(project_dir): + continue + agents_dir = project_dir / "agents" + if not agents_dir.is_dir(): + continue + for agent_dir in sorted(agents_dir.iterdir()): + if is_agent_dir(agent_dir): + memberships.setdefault(agent_dir.name, []).append(project_dir.name) + return memberships + + +def _identity_from_projects( + oacp_root: Path, + name: str, + projects: Sequence[str], +) -> Dict[str, str]: + """Infer registry defaults from project cards/status without overriding them.""" + cards: List[Dict[str, Any]] = [] + statuses: List[Dict[str, Any]] = [] + for project in projects: + agent_dir = oacp_root / "projects" / project / "agents" / name + for filename, target in (("agent_card.yaml", cards), ("status.yaml", statuses)): + path = agent_dir / filename + if not path.is_file(): + continue + try: + target.append(_load_yaml(path)) + except (OSError, ValueError): + continue + + runtime = next( + ( + str(card["runtime"]) + for card in cards + if card.get("runtime") in ALL_RUNTIMES + ), + "", + ) + if not runtime and name in ALL_RUNTIMES and name != "unknown": + runtime = name + if not runtime: + runtime = next( + ( + str(status["runtime"]) + for status in statuses + if status.get("runtime") in ALL_RUNTIMES + ), + "unknown", + ) + + model = next( + ( + str(source["model"]) + for source in [*cards, *statuses] + if isinstance(source.get("model"), str) and source["model"].strip() + ), + runtime if runtime != "unknown" else "", + ) + description = next( + ( + str(card["description"]) + for card in cards + if isinstance(card.get("description"), str) + and card["description"].strip() + ), + f"{name} agent ({runtime} runtime)" if runtime != "unknown" else f"{name} agent", + ) + return {"runtime": runtime, "model": model, "description": description} + + +def sync_agent_registry(oacp_root: Path) -> Dict[str, Any]: + """Backfill all project agent directories into the instance registry.""" + memberships = discover_project_memberships(oacp_root) + actions: Dict[str, str] = {} + for name, projects in memberships.items(): + identity = _identity_from_projects(oacp_root, name, projects) + result = upsert_global_profile( + oacp_root, + name, + identity["runtime"], + projects=projects, + model=identity["model"], + description=identity["description"], + ) + actions[name] = str(result["action"]) + return { + "agents": len(memberships), + "memberships": sum(len(projects) for projects in memberships.values()), + "created": sum(action == "created" for action in actions.values()), + "updated": sum(action == "updated" for action in actions.values()), + "unchanged": sum(action == "unchanged" for action in actions.values()), + "actions": actions, + } # --------------------------------------------------------------------------- # Merge logic # --------------------------------------------------------------------------- @@ -208,20 +461,19 @@ def cmd_init(args: argparse.Namespace, oacp_root: Path) -> int: print(f"Error: invalid runtime '{runtime}': must be one of {VALID_RUNTIMES}", file=sys.stderr) return 1 - with _template_path("agent_profile.template.yaml") as tpl_path: - template = tpl_path.read_text(encoding="utf-8") - - # Fill in identity fields - template = template.replace('name: ""', f'name: "{name}"', 1) - template = template.replace('runtime: ""', f'runtime: "{runtime}"', 1) - - profile_dir = oacp_root / "agents" / name - profile_path = profile_dir / "profile.yaml" + try: + result = upsert_global_profile(oacp_root, name, runtime) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 - if _write_if_missing(profile_path, template): + profile_path = result["path"] + if result["action"] == "created": print(f"Created global profile: {profile_path}") + elif result["action"] == "updated": + print(f"Updated global profile (identity preserved): {profile_path}") else: - print(f"Profile already exists (skipped): {profile_path}") + print(f"Profile already exists (unchanged): {profile_path}") return 0 @@ -285,8 +537,51 @@ def cmd_list(args: argparse.Namespace, oacp_root: Path) -> int: if name in project_names: tags.append("project") tag_str = ", ".join(tags) - print(f" {name} ({tag_str})") + try: + profile = load_global_profile(oacp_root, name) or {} + except (OSError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + identity = _identity_from_projects( + oacp_root, + name, + [project] if project and name in project_names else [], + ) + runtime = profile.get("runtime") or identity["runtime"] + projects = profile.get("projects", []) + if projects is None: + projects = [] + if not isinstance(projects, list) or not all( + isinstance(membership, str) for membership in projects + ): + print( + f"Error: projects must be a list of strings in " + f"{oacp_root / 'agents' / name / 'profile.yaml'}", + file=sys.stderr, + ) + return 1 + project_text = ",".join(projects) if projects else "-" + print( + f" {name} runtime={runtime} memberships={project_text} ({tag_str})" + ) + + return 0 + +def cmd_sync(args: argparse.Namespace, oacp_root: Path) -> int: + """Backfill the instance registry from project agent directories.""" + del args + try: + result = sync_agent_registry(oacp_root) + except (OSError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + print( + "Agent registry synchronized: " + f"{result['agents']} agent(s), {result['memberships']} project membership(s); " + f"created {result['created']}, updated {result['updated']}, " + f"unchanged {result['unchanged']}." + ) return 0 @@ -312,6 +607,9 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace: p_init.add_argument("name", help="Agent name") p_init.add_argument("--runtime", required=True, help="Agent runtime (claude, codex, cursor, gemini, human)") + # sync + sub.add_parser("sync", help="Backfill the instance registry from project agents") + # show p_show = sub.add_parser("show", help="Show merged agent profile") p_show.add_argument("name", help="Agent name") @@ -341,6 +639,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: dispatch = { "init": cmd_init, + "sync": cmd_sync, "show": cmd_show, "list": cmd_list, } diff --git a/scripts/autonomy_gate.py b/scripts/autonomy_gate.py index b586936..9f8234a 100644 --- a/scripts/autonomy_gate.py +++ b/scripts/autonomy_gate.py @@ -11,6 +11,7 @@ from __future__ import annotations import argparse +import contextlib import datetime as dt import hashlib import json @@ -23,7 +24,14 @@ import yaml -from _oacp_constants import REPO_SLUG_RE, SPEC_VERSION, locked_audit, utc_now_iso +from _oacp_constants import ( + REPO_SLUG_RE, + SPEC_VERSION, + atomic_replace_bytes, + atomic_replace_yaml, + locked_audit, + utc_now_iso, +) from validate_message import validate_message_dict @@ -82,6 +90,28 @@ "expected_files_touched", *LEGACY_PROFILE_BOOL_FIELDS, ) +# The structured admission ledger: every envelope-derived admission axis, +# evaluated in full before any early return, in evaluation order. A pause +# taken for one reason never leaves another axis unrecorded — record +# silence means "passed", never "not evaluated". Lexical (Gate-3) +# classification is body-derived and records every match in +# `matched_patterns`; the legacy singular `matched_pattern` still names the +# first blocking match. Lexical evidence is not a ledger axis. +ADMISSION_AXES = ( + "thresholds", + "declared_risk", + "declaration", + "side_effects", + "continuation_grant", +) +# Declared-risk profile flags and the per-knob admission pause each one +# names, in the evaluation order the reason codes are recorded in. +DECLARED_RISK_REASONS = ( + ("destructive_ops", "destructive_ops_pause"), + ("touches_auth_config_or_secrets", "auth_config_or_secrets_pause"), + ("touches_dependencies", "dependency_changes_pause"), + ("public_visibility", "public_visibility_pause"), +) FINAL_STATES = {"done", "paused", "blocked", "superseded", "error"} # `result.completion_kind` names the terminal shape of the EVALUATION only — @@ -101,6 +131,12 @@ # prospectively — the undeclared action was caught before it materialized # (§E: mandatory before performing ANY newly discovered outward action). BREACH_BASES = ("declared_intent", "realized") +# Checkpoint breach sub-basis: refines a `realized` breach on the time axis +# only. `waiting_on_peer` marks a checkpoint whose clock overran while the +# receiver was waiting on a peer reply (a review round, a re-authorization +# answer) rather than working, so the ledger can separate review latency +# from working time when calibrating the time axis. +BREACH_SUB_BASES = ("waiting_on_peer",) # Checkpoint re-authorization channels, highest precedence first. Precedence # is by channel rank, never arrival order. GH comments are consultable but # never authoritative: they sit outside the protocol's identity and @@ -217,13 +253,17 @@ r"(?ms)^[ \t]*```oacp-guardrails[ \t]*\n" r"(?P.*?)^[ \t]*```[ \t]*(?:\n|$)" ) -NEGATION_PREFIX_RE = re.compile( - r"\b(?:" +NEGATION_TERM_PATTERN = ( r"no|not|never|do\s+not|does\s+not|don't|doesn't|" r"out\s+of\s+scope|exclude(?:s|d)?|avoid|refrain\s+from|" r"prohibited|forbidden|skip|without" - r")\b" - r"[^.!?;\n]{0,160}$", +) +NEGATION_TERM_RE = re.compile( + rf"\b(?:{NEGATION_TERM_PATTERN})\b", + re.IGNORECASE, +) +NEGATION_PREFIX_RE = re.compile( + rf"\b(?:{NEGATION_TERM_PATTERN})\b[^.!?;\n]{{0,160}}$", re.IGNORECASE, ) BLOCK_NEGATION_PREFIX_RE = re.compile( @@ -255,6 +295,23 @@ ("merge", re.compile(r"(? Dict[str, Any]: return data +class DuplicateKeyError(ValueError): + """A YAML mapping key appeared twice in one document.""" + + +class _StrictYamlLoader(yaml.SafeLoader): + """SafeLoader that refuses duplicate mapping keys. + + Plain PyYAML silently keeps the later value, so a duplicated + ``logged_notes:`` key can shadow the populated one with an empty list + and the loss is invisible to every reader. Any writer that re-serializes + a record MUST load it through this loader first — rewriting a + plain-loaded mapping collapses the duplicate-key evidence for good. + """ + + +def _strict_construct_mapping( + loader: _StrictYamlLoader, node: Any, deep: bool = False +) -> Dict[Any, Any]: + mapping: Dict[Any, Any] = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node, deep=deep) + if key in mapping: + raise DuplicateKeyError( + f"duplicate mapping key {key!r} at line {key_node.start_mark.line + 1}" + ) + mapping[key] = loader.construct_object(value_node, deep=deep) + return mapping + + +_StrictYamlLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _strict_construct_mapping +) + + +def load_yaml_strict(path: Path) -> Dict[str, Any]: + """Load a YAML mapping, refusing duplicate keys and non-mappings.""" + data = yaml.load(path.read_text(encoding="utf-8"), Loader=_StrictYamlLoader) + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a YAML mapping") + return data + + def _parse_yaml_mapping(raw: bytes, path: Path) -> Dict[str, Any]: """Parse a mapping from an already-read snapshot (never re-reads *path*).""" data = yaml.safe_load(raw.decode("utf-8")) @@ -423,6 +562,7 @@ def write_audit_record( policy_path: Path, receiver: str, now_utc: Optional[dt.datetime] = None, + hold_lock: Optional[contextlib.ExitStack] = None, ) -> Path: """Persist a documented audit event without mutating evaluator stdout. @@ -484,14 +624,35 @@ def write_audit_record( ) message_id = str(decision.get("message_id") or "missing-message-id") - safe_message_id = re.sub(r"[^A-Za-z0-9._-]", "_", message_id).strip("._") - safe_message_id = safe_message_id[:200] or "missing-message-id" + # Every persisted evaluation carries an identity; re-evaluations of the + # same message reference their predecessor through it instead of + # accumulating indistinguishable duplicates. + audit_record.setdefault( + "evaluation_id", + evaluation_identity( + receiver, + message_id, + str(decision.get("message_sha256") or ""), + created_at, + ), + ) + audit_record.setdefault("supersedes_evaluation_id", None) + safe_message_id = _safe_message_id(message_id) stamp = created_at.replace(":", "").replace("-", "") audit_path = audit_dir / f"{stamp}_{safe_message_id}.yaml" + if audit_path.exists(): + # A distinct evaluation of the same message in the same second + # (e.g. an amended body superseding its predecessor) is legitimate + # now that logical duplicates adopt or supersede before reaching + # this writer — disambiguate by evaluation identity instead of + # refusing. A true duplicate shares the identity and still trips + # the in-lock existence check below. + eval_suffix = str(audit_record["evaluation_id"]).replace("eval-", "")[:8] + audit_path = audit_dir / f"{stamp}_{safe_message_id}_{eval_suffix}.yaml" content = yaml.safe_dump(audit_record, sort_keys=False, allow_unicode=True) - temp_path: Optional[Path] = None - with locked_audit(audit_path): + def _publish_locked() -> None: + temp_path: Optional[Path] = None if audit_path.exists(): raise FileExistsError(f"audit record already exists: {audit_path}") try: @@ -513,6 +674,18 @@ def write_audit_record( if temp_path is not None and temp_path.exists(): temp_path.unlink() + if hold_lock is not None: + # Transactional caller: the record's lock is entered on the + # caller's stack BEFORE publication and stays held until the + # caller's whole transaction commits or rolls back, so a + # per-record writer can never commit an update to this record + # that a rollback would then delete. + hold_lock.enter_context(locked_audit(audit_path)) + _publish_locked() + else: + with locked_audit(audit_path): + _publish_locked() + return audit_path @@ -739,6 +912,36 @@ def _profile_is_complete(profile: Optional[Dict[str, Any]]) -> bool: return isinstance(profile, dict) and all(key in profile for key in COMPLETE_PROFILE_FIELDS) +# Every side-effect flag other than `sends_oacp_reply_only`: the legacy +# aggregate plus each granular capability. All must be false for a profile +# to have the reply-only shape. +REPLY_ONLY_OTHER_SIDE_EFFECT_FIELDS = ( + "external_side_effects", + *COVERABLE_CONTINUATION_FIELDS, +) + + +def _reply_only_profile_shape( + profile: Optional[Dict[str, Any]], + envelope: Optional[Dict[str, Any]], +) -> bool: + """The reply-only profile shape: a complete declared profile whose + ``sends_oacp_reply_only`` is true and every other side-effect flag — + ``external_side_effects`` and each granular capability — is false. + + Declaration-only by construction: the profileless default envelope is + reply-only too, but it declares nothing, and the content-sensitivity + carve-out keys on what the sender declared. A missing, unparsable, or + contradictory profile, or one that omits ``sends_oacp_reply_only``, + does not have the shape. + """ + if envelope is None or not _profile_is_complete(profile): + return False + if not envelope.get("sends_oacp_reply_only"): + return False + return not any(envelope.get(field) for field in REPLY_ONLY_OTHER_SIDE_EFFECT_FIELDS) + + def _record_lexical_note( notes: List[Dict[str, str]], code: str, @@ -768,6 +971,280 @@ def _match_is_negated(body: str, match: re.Match[str]) -> bool: return False +def _contextual_match_is_negated( + label: str, + body: str, + match: re.Match[str], +) -> bool: + """Require an independent negation basis for each hard-stop occurrence.""" + if not _match_is_negated(body, match): + return False + if NEGATION_TERM_RE.search(match.group(0)) is not None: + return False + + prefix = body[:match.start()] + boundary = max(prefix.rfind(mark) for mark in ("\n", ".", "!", "?", ";", "—", "–")) + clause_prefix = prefix[boundary + 1:] + if label == "install dependency": + cue_pattern = INSTALL_VERB_RE + governance_pattern = INSTALL_NEGATION_GOVERNANCE_RE + elif label == "public repo": + cue_pattern = PUBLIC_REPO_RE + governance_pattern = PUBLIC_REPO_NEGATION_GOVERNANCE_RE + else: + return False + prior_occurrences = list(cue_pattern.finditer(clause_prefix)) + governance_prefix = ( + clause_prefix[prior_occurrences[-1].end():] + if prior_occurrences + else clause_prefix + ) + governance_match = governance_pattern.search(governance_prefix) + if governance_match is None: + return False + return NEGATION_TERM_RE.search( + governance_prefix[:governance_match.start()] + ) is None + + +def _match_is_reference_only( + label: str, + body: str, + match: re.Match[str], +) -> bool: + """Recognize only the descriptive lexical contexts proven in field data. + + These patterns are intentionally narrow. They do not make a whole class + demotable: they identify the repository's merge-method setting and + dependency-introspection wording that describe a token instead of asking + the receiver to perform its action. + """ + if not _match_is_clause_bounded(match): + return False + left = max(body.rfind(mark, 0, match.start()) for mark in ("\n", ".", "!", "?", ";")) + right_candidates = [ + position + for mark in ("\n", ".", "!", "?", ";") + if (position := body.find(mark, match.end())) >= 0 + ] + right = min(right_candidates) if right_candidates else len(body) + clause_start = left + 1 + clause = body[clause_start:right] + + if label == "merge": + reference_patterns = ( + re.compile( + r"\bmerge(?:[- ]commit)?\s+" + r"(?:method|setting|settings|strategy|mode)\b", + re.IGNORECASE, + ), + ) + elif label == "install dependency": + reference_patterns = ( + re.compile( + r"\bwhat\s+(?:a\s+)?(?:pip\s+)?install\b" + r"[^.!?;\n—–]{0,160}?\b" + r"(?:pulls?|installs?|includes?|requires?)\b" + r"[^.!?;\n—–]{0,160}?\bdependenc(?:y|ies)\b", + re.IGNORECASE, + ), + re.compile( + r"\b(?:install/build|package\s+installs?)\b" + r"[^.!?;\n—–]{0,80}?\bread\s+as\b" + r"[^.!?;\n—–]{0,80}?\bdependenc(?:y|ies)(?:-class)?\b", + re.IGNORECASE, + ), + ) + else: + return False + + for reference_pattern in reference_patterns: + for reference in reference_pattern.finditer(clause): + reference_start = clause_start + reference.start() + reference_end = clause_start + reference.end() + if reference_start <= match.start() and match.end() <= reference_end: + return True + return False + + +def _match_is_clause_bounded(match: re.Match[str]) -> bool: + return re.search(r"[.!?;\n—–]", match.group(0)) is None + + +def _contextual_non_demotable_basis( + label: str, + body: str, + match: re.Match[str], +) -> str: + """Return the shared verdict/provenance disposition for one hard match.""" + if not _match_is_clause_bounded(match): + return "non_demotable" + if label in { + "install dependency", + "public repo", + } and _contextual_match_is_negated(label, body, match): + return "negated" + if label == "install dependency" and _match_is_reference_only( + label, body, match + ): + return "reference_only" + return "non_demotable" + + +def _guardrails_content_spans(body: str) -> List[Tuple[int, int]]: + return [match.span("content") for match in GUARDRAILS_FENCE_RE.finditer(body)] + + +def _match_is_in_spans( + match: re.Match[str], + spans: Sequence[Tuple[int, int]], +) -> bool: + return any(start <= match.start() and match.end() <= end for start, end in spans) + + +def _collect_lexical_provenance( + body: str, + profile: Optional[Dict[str, Any]], + envelope: Optional[Dict[str, Any]], + policy: Optional[Dict[str, Any]], + msg_type: str, +) -> List[Dict[str, Any]]: + """Return every lexical match with its source span and disposition basis. + + Spans are zero-based, end-exclusive Unicode-code-point offsets into the + original message body. The list is source ordered; overlapping patterns + remain separate evidence because they belong to distinct policy classes. + """ + fence_spans = _guardrails_content_spans(body) + profile_complete = _profile_is_complete(profile) + external_policy = None + if isinstance(policy, dict) and isinstance(policy.get("thresholds"), dict): + external_policy = policy["thresholds"].get("external_side_effects") + content_reply_only = _reply_only_profile_shape(profile, envelope) + profileless_type = ( + profile is None + and isinstance(policy, dict) + and msg_type in policy.get("allow_without_task_profile", ()) + ) + profileless_risk = ( + profile is None + and isinstance(policy, dict) + and msg_type not in policy.get("allow_without_task_profile", ()) + and msg_type not in REVIEW_LIFECYCLE_TYPES + ) + + def basis( + category: str, + label: str, + match: re.Match[str], + profile_field: Optional[str] = None, + ) -> str: + fenced = _match_is_in_spans(match, fence_spans) + if category == "destructive_command": + return "non_demotable" + if category == "side_effect": + if profileless_risk: + return "profileless_risk" + if fenced: + return "guardrails_fence" + if _match_is_negated(body, match): + return "negated" + if _match_is_reference_only(label, body, match): + return "reference_only" + if profileless_type: + return "profileless_type" + if profile_complete and envelope is not None and not envelope["external_side_effects"]: + return "profile_false" + if external_policy == "allow" and envelope is not None and envelope["external_side_effects"]: + return "policy_allowed" + if label == "merge" and envelope is not None and envelope.get("merges_pr"): + return "profile_true" + return "affirmative" + if category == "non_demotable_side_effect": + if profileless_risk: + return "profileless_risk" + return _contextual_non_demotable_basis(label, body, match) + if category == "sensitive_scope": + if fenced: + return "guardrails_fence" + if _match_is_negated(body, match): + return "negated" + if ( + profile_field is not None + and profile_complete + and envelope is not None + and not envelope[profile_field] + ): + return "profile_false" + return "affirmative" + if category == "content_sensitivity": + return "reply_only_advisory" if content_reply_only else "non_demotable" + if category == "non_demotable_sensitive_scope": + return _contextual_non_demotable_basis(label, body, match) + if category == "ambiguous_scope": + if fenced: + return "guardrails_fence" + if _match_is_negated(body, match): + return "negated" + return "affirmative" + return "profileless_risk" + + hits: List[Dict[str, Any]] = [] + + def append_matches( + category: str, + patterns: Sequence[Tuple[str, re.Pattern[str]]], + ) -> None: + for label, pattern in patterns: + for match in pattern.finditer(body): + hits.append({ + "pattern": label, + "category": category, + "span": {"start": match.start(), "end": match.end()}, + "demotion_basis": basis(category, label, match), + }) + + append_matches("destructive_command", DESTRUCTIVE_PATTERNS) + append_matches("side_effect", SIDE_EFFECT_VERB_PATTERNS) + append_matches("non_demotable_side_effect", NON_DEMOTABLE_SIDE_EFFECT_PATTERNS) + for label, pattern, profile_field in DECLARATION_AWARE_SENSITIVE_PATTERNS: + for match in pattern.finditer(body): + hits.append({ + "pattern": label, + "category": "sensitive_scope", + "span": {"start": match.start(), "end": match.end()}, + "demotion_basis": basis( + "sensitive_scope", label, match, profile_field + ), + }) + append_matches("content_sensitivity", CONTENT_SENSITIVITY_PATTERNS) + append_matches( + "non_demotable_sensitive_scope", NON_DEMOTABLE_SENSITIVE_PATTERNS + ) + append_matches("ambiguous_scope", AMBIGUOUS_SCOPE_PATTERNS) + if profileless_risk: + append_matches("profileless_risk", PROFILELESS_ONLY_RISK_PATTERNS) + + unknown_bases = { + hit["demotion_basis"] for hit in hits + } - LEXICAL_DEMOTION_BASES + if unknown_bases: + raise ValueError( + "unregistered lexical demotion basis: " + + ", ".join(sorted(unknown_bases)) + ) + + return sorted( + hits, + key=lambda hit: ( + hit["span"]["start"], + hit["span"]["end"], + hit["category"], + hit["pattern"], + ), + ) + + def _gate3_body(body: str, notes: List[Dict[str, str]]) -> str: fence_matches = list(GUARDRAILS_FENCE_RE.finditer(body)) advisory_patterns = ( @@ -800,6 +1277,11 @@ def _first_effective_match( if _match_is_negated(body, match): _record_lexical_note(notes, "lexical_advisory_negated", label) continue + if _match_is_reference_only(label, body, match): + _record_lexical_note( + notes, "lexical_advisory_reference_only", label + ) + continue if demote_declared or label in demote_labels: _record_lexical_note(notes, "lexical_advisory_declared", label) continue @@ -807,6 +1289,30 @@ def _first_effective_match( return None +def _first_contextual_non_demotable_match( + patterns: Sequence[Tuple[str, re.Pattern[str]]], + body: str, + notes: List[Dict[str, str]], + *, + contextual_labels: FrozenSet[str], +) -> Optional[str]: + """Keep a class hard while demoting only documented contextual matches.""" + for label, pattern in patterns: + for match in pattern.finditer(body): + if label in contextual_labels: + match_basis = _contextual_non_demotable_basis(label, body, match) + if match_basis == "negated": + _record_lexical_note(notes, "lexical_advisory_negated", label) + continue + if match_basis == "reference_only": + _record_lexical_note( + notes, "lexical_advisory_reference_only", label + ) + continue + return label + return None + + def _first_sensitive_match( body: str, notes: List[Dict[str, str]], @@ -920,6 +1426,381 @@ def message_expired( return now >= expires +def _safe_message_id(message_id: str) -> str: + """Filesystem-safe form of a message id (never trusted as identity).""" + safe = re.sub(r"[^A-Za-z0-9._-]", "_", message_id).strip("._") + return safe[:200] or "missing-message-id" + + +def evaluation_identity( + receiver: str, + message_id: str, + message_sha256: str, + created_at_utc: str, +) -> str: + """Deterministic identity for one evaluation event. + + Derived from what makes an evaluation distinct — who evaluated, which + message, which exact bytes, and when — so re-running the writer on the + same event reproduces the same id instead of minting a fresh one. + Distinct evaluations of the same message (an amended body, a later + re-evaluation) differ in ``message_sha256`` or ``created_at_utc`` and + get distinct ids, which is what supersession chains reference. + """ + digest = hashlib.sha256( + "\n".join((receiver, message_id, message_sha256, created_at_utc)).encode("utf-8") + ).hexdigest() + return f"eval-{digest[:16]}" + + +def find_prior_evaluations( + audit_dir: Optional[Path], + receiver: str, + message_id: str, +) -> List[Tuple[Path, Dict[str, Any]]]: + """Return every readable audit record matching this logical identity.""" + if not message_id or audit_dir is None or not audit_dir.is_dir(): + return [] + priors: List[Tuple[Path, Dict[str, Any]]] = [] + for audit_path in sorted(audit_dir.glob("*.yaml")): + try: + audit = yaml.safe_load(audit_path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + continue + if not isinstance(audit, dict): + continue + if audit.get("message_id") == message_id and audit.get("receiver") == receiver: + priors.append((audit_path, audit)) + return priors + + +def _prior_evaluation_id(prior: Mapping[str, Any], receiver: str) -> str: + """A prior's on-disk evaluation_id, or the deterministic identity it + will be stamped with (``supersede_audit_record`` and adoption both use + the same derivation, so a computed reference always resolves).""" + return str( + prior.get("evaluation_id") + or evaluation_identity( + receiver, + str(prior.get("message_id") or ""), + str(prior.get("message_sha256") or ""), + str(prior.get("created_at_utc") or ""), + ) + ) + + +def supersede_audit_record( + audit_path: Path, + *, + superseded_by: str, + now_utc: Optional[dt.datetime] = None, +) -> None: + """Close a stale evaluation in favor of a newer one, non-destructively. + + A state update under the audit lock: ``final_state`` moves to + ``superseded``, the superseding evaluation is referenced, and a + completion stamp closes the record. Admission history — decision, + reason codes, checkpoint blocks, human outcomes — is preserved + verbatim; supersession is how off-vocabulary or stale records leave + the live corpus without rewriting what they said. + """ + with locked_audit(audit_path): + _supersede_audit_record_locked( + audit_path, superseded_by=superseded_by, now_utc=now_utc + ) + + +def _supersede_audit_record_locked( + audit_path: Path, + *, + superseded_by: str, + now_utc: Optional[dt.datetime] = None, +) -> None: + """The lock-free half of ``supersede_audit_record``. + + The caller MUST already hold ``locked_audit(audit_path)`` — + ``persist_evaluation`` pre-acquires every affected record's lock for + its whole capture/mutate/restore span (flock is not reentrant, so it + cannot call the locking wrapper). + """ + # Strict load: automatic supersession re-serializes the record, and + # rewriting a plain-loaded mapping would silently collapse + # duplicate-key evidence (PyYAML keeps only the later value). Fail + # closed and leave malformed evidence untouched instead. + record = load_yaml_strict(audit_path) + result = record.setdefault("result", {}) + if not isinstance(result, dict): + raise ValueError(f"{audit_path}: result must be a mapping") + if result.get("final_state") == "superseded": + return + if result.get("final_state") not in FINAL_STATES | {"pending"}: + # Preserve legacy off-enum run state as history before the + # overwrite — the original value is part of what supersession + # documents. + result["legacy_final_state"] = result.get("final_state") + result["final_state"] = "superseded" + if not result.get("completed_at_utc"): + result["completed_at_utc"] = utc_now_iso(now_utc) + if not record.get("evaluation_id"): + record["evaluation_id"] = evaluation_identity( + str(record.get("receiver") or ""), + str(record.get("message_id") or ""), + str(record.get("message_sha256") or ""), + str(record.get("created_at_utc") or ""), + ) + record["superseded_by_evaluation_id"] = superseded_by + atomic_replace_yaml(audit_path, record) + + +def persist_evaluation( + audit_dir: Path, + decision: Dict[str, Any], + *, + config: Dict[str, Any], + message: Dict[str, Any], + message_path: Path, + policy_path: Path, + receiver: str, +) -> Dict[str, Any]: + """Persist a decision as one logical-identity transaction. + + The prior scan, the adopt-or-write choice, and predecessor supersession + are serialized under a stable lock keyed by (receiver, message id) — + per-file locks alone cannot prevent two concurrent evaluations of the + same logical message from both scanning an empty directory and both + staying live. Adoption also self-heals a crashed predecessor + transaction: any other live prior of the adopted identity is superseded + on the next evaluation, so exactly one evaluation per logical message + stays live even across a writer that died between its write and its + supersession pass. + + Returns ``{"action": "adopted"|"written", "audit_path", "evaluation_id", + "superseded": [paths]}``. The transaction pre-acquires every affected + record's ``locked_audit`` in deterministic path order and holds them + across byte capture, mutation, and any restore — including the newly + created successor, whose lock is entered before publication and held + until the transaction commits or rolls back — so per-record audit + writers serialize with it instead of losing a committed update to its + rollback; the authoritative record view is re-read under those locks. + It fails closed and is rollback-capable for every failure class, + validation and filesystem alike: every live predecessor is + strict-load preflighted BEFORE anything is written, so a predecessor + that cannot be superseded automatically (e.g. duplicate-key YAML — + evidence is preserved, never normalized) raises with nothing + persisted; any later failure restores every already-mutated + predecessor byte-for-byte (read-back verified) and removes a + just-written successor under the same locks before raising. + Reporting success while a second evaluation stays live — or leaving a + predecessor linked to a successor that was rolled back — would defeat + the transaction's one-live-record invariant. + """ + message_id = str(message.get("id") or "") + audit_dir.mkdir(parents=True, exist_ok=True) + identity_anchor = audit_dir / f".identity_{receiver}_{_safe_message_id(message_id)}" + + # Original bytes of every file this transaction has mutated, in + # mutation order. Any failure — validation OR filesystem — restores + # them all (and removes a just-written successor) before raising, so + # the caller never observes duplicate live evaluations or a + # predecessor linked to a successor that no longer exists. + mutated: List[Tuple[Path, bytes]] = [] + + def _restore_pre_call_state(successor_path: Optional[Path]) -> None: + problems: List[str] = [] + for path, original in mutated: + try: + atomic_replace_bytes(path, original) + if path.read_bytes() != original: + problems.append(f"{path.name}: read-back mismatch after restore") + except OSError as exc: + problems.append(f"{path.name}: restore failed ({exc})") + if successor_path is not None: + try: + successor_path.unlink() + except FileNotFoundError: + pass + except OSError as exc: + problems.append( + f"{successor_path.name}: rollback unlink failed ({exc})" + ) + if successor_path.exists(): + problems.append( + f"{successor_path.name}: still present after rollback" + ) + if problems: + raise OSError( + "logical-identity transaction rollback incomplete — manual " + "repair required: " + "; ".join(problems) + ) + + def _supersede_all_or_restore( + targets: List[Path], + successor_id: str, + successor_path: Optional[Path], + ) -> List[Path]: + superseded: List[Path] = [] + for prior_path in targets: + try: + original = prior_path.read_bytes() + mutated.append((prior_path, original)) + _supersede_audit_record_locked( + prior_path, superseded_by=successor_id + ) + except Exception as exc: + _restore_pre_call_state(successor_path) + raise ValueError( + "logical-identity transaction failed: live predecessor " + f"{prior_path.name} could not be superseded ({exc}) — " + "pre-call state restored; repair it manually before " + "re-evaluating this message" + ) from exc + superseded.append(prior_path) + return superseded + + def _stamp_adopted_links( + prior_path: Path, adopted_id: str, superseded_ids: List[str] + ) -> None: + """Make an adopted survivor a strictly resolvable successor. + + The strict resolver requires the successor file to carry its + evaluation_id and to reference every predecessor it absorbed — + a pre-identity adopted record satisfies neither, which would + leave its healed predecessors dangling. The caller already holds + this record's ``locked_audit`` for the whole transaction span. + """ + record = load_yaml_strict(prior_path) + changed = False + if not record.get("evaluation_id"): + record["evaluation_id"] = adopted_id + changed = True + existing = record.get("superseded_evaluation_ids") + merged = list(existing) if isinstance(existing, list) else [] + for superseded_id in superseded_ids: + if superseded_id not in merged: + merged.append(superseded_id) + changed = True + if changed: + record["superseded_evaluation_ids"] = merged + atomic_replace_yaml(prior_path, record) + + with locked_audit(identity_anchor), contextlib.ExitStack() as record_locks: + scan = find_prior_evaluations(audit_dir, receiver, message_id) + candidate_paths = sorted({ + path + for path, prior in scan + if (prior.get("result") or {}).get("final_state") != "superseded" + }) + # Deterministic-order lock acquisition over every record this + # transaction may mutate, held across byte capture, mutation, and + # any restore — per-record audit writers (human-outcome recording, + # message_auth attachment) serialize with the transaction instead + # of having a committed update erased by its rollback. + for path in candidate_paths: + record_locks.enter_context(locked_audit(path)) + # Authoritative view: re-read under the held locks (the unlocked + # scan can be stale against a concurrent per-record writer). This + # is also the strict preflight — fail the whole transaction while + # nothing has changed. + live: List[Tuple[Path, Dict[str, Any]]] = [] + preflight_failures: List[Tuple[Path, str]] = [] + for path in candidate_paths: + try: + strict = load_yaml_strict(path) + strict_result = strict.get("result") + if strict_result is not None and not isinstance(strict_result, dict): + raise ValueError("result must be a mapping") + except FileNotFoundError: + continue # removed since the scan — nothing left to close + except ValueError as exc: + preflight_failures.append((path, str(exc))) + continue + if (strict.get("result") or {}).get("final_state") == "superseded": + continue # closed since the scan — already resolved + live.append((path, strict)) + if preflight_failures: + details = "; ".join( + f"{path.name}: {reason}" for path, reason in preflight_failures + ) + raise ValueError( + "logical-identity transaction failed: live predecessor(s) " + f"could not be superseded ({details}) — evidence preserved, " + "nothing written; repair them manually before re-evaluating " + "this message" + ) + adoptable = [ + (path, prior) + for path, prior in live + if prior.get("message_sha256") == decision.get("message_sha256") + and prior.get("policy_sha256") == decision.get("policy_sha256") + and prior.get("decision") == decision.get("decision") + ] + if adoptable: + prior_path, prior = max(adoptable, key=lambda item: item[0].name) + adopted_id = _prior_evaluation_id(prior, receiver) + others = [ + (path, other) for path, other in live if path != prior_path + ] + if others: + try: + original = prior_path.read_bytes() + mutated.append((prior_path, original)) + _stamp_adopted_links( + prior_path, + adopted_id, + [_prior_evaluation_id(o, receiver) for _p, o in others], + ) + except Exception as exc: + _restore_pre_call_state(None) + raise ValueError( + "logical-identity transaction failed: adopted " + f"evaluation {prior_path.name} could not be stamped " + f"as successor ({exc}) — pre-call state restored" + ) from exc + superseded = _supersede_all_or_restore( + [path for path, _o in others], adopted_id, None + ) + return { + "action": "adopted", + "audit_path": prior_path, + "evaluation_id": adopted_id, + "superseded": superseded, + } + + if live: + # The newest predecessor keeps the single-valued back-pointer + # (each writer stamped it against the then-newest live record); + # the full list is what lets the strict resolver follow every + # predecessor of a multi-prior self-heal back to this successor. + ordered = sorted(live, key=lambda item: item[0].name) + decision["supersedes_evaluation_id"] = _prior_evaluation_id( + ordered[-1][1], receiver + ) + decision["superseded_evaluation_ids"] = [ + _prior_evaluation_id(prior, receiver) for _path, prior in ordered + ] + audit_path = write_audit_record( + audit_dir, + decision, + config=config, + message=message, + message_path=message_path, + policy_path=policy_path, + receiver=receiver, + hold_lock=record_locks, + ) + written = yaml.safe_load(audit_path.read_text(encoding="utf-8")) + new_id = str(written.get("evaluation_id")) + superseded = _supersede_all_or_restore( + [path for path, _prior in live], new_id, audit_path + ) + return { + "action": "written", + "audit_path": audit_path, + "evaluation_id": new_id, + "superseded": superseded, + } + + def prior_auto_accept_exists( message_id: str, receiver: str, @@ -958,11 +1839,7 @@ def obvious_no_profile_risk(body: str) -> bool: patterns = ( SIDE_EFFECT_VERB_PATTERNS + NON_DEMOTABLE_SIDE_EFFECT_PATTERNS - + ( - ("pull request", re.compile(r"\bpull\s+request\b|\bPR\b")), - ("github", re.compile(r"\bgithub\b", re.IGNORECASE)), - ("commit", re.compile(r"\bcommit(?:s|ted|ting)?\b", re.IGNORECASE)), - ) + + PROFILELESS_ONLY_RISK_PATTERNS ) return first_match(patterns, body) is not None @@ -1723,14 +2600,34 @@ def _side_effect_reasons( return reasons -def _profile_declaration_errors(envelope: Dict[str, Any]) -> List[str]: - breaches: List[str] = [] - artifact_fields = [key for key in COVERABLE_CONTINUATION_FIELDS if envelope[key]] +def _profile_declaration_details(envelope: Dict[str, Any]) -> List[Dict[str, Any]]: + """Admission-time declaration errors with their cause. + + Each entry names the contradicted field, what it declared, and the + declared fields it conflicts with — so a ``declaration_error`` pause + records which axis failed and why, not just that one did. + """ + details: List[Dict[str, Any]] = [] + artifact_fields = [ + f"task_profile.{key}" for key in COVERABLE_CONTINUATION_FIELDS if envelope[key] + ] if artifact_fields and not envelope["external_side_effects"]: - breaches.append("task_profile.external_side_effects") + details.append({ + "field": "task_profile.external_side_effects", + "declared": False, + "conflicts_with": artifact_fields, + }) if envelope["sends_oacp_reply_only"] and artifact_fields: - breaches.append("task_profile.sends_oacp_reply_only") - return breaches + details.append({ + "field": "task_profile.sends_oacp_reply_only", + "declared": True, + "conflicts_with": artifact_fields, + }) + return details + + +def _profile_declaration_errors(envelope: Dict[str, Any]) -> List[str]: + return [entry["field"] for entry in _profile_declaration_details(envelope)] def _threshold_reasons( @@ -1745,6 +2642,70 @@ def _threshold_reasons( return reasons +def _declared_risk_reasons(envelope: Dict[str, Any]) -> List[str]: + return [code for key, code in DECLARED_RISK_REASONS if envelope[key]] + + +def not_evaluated_admission_ledger() -> Dict[str, Any]: + """The ledger shape for a pause taken before a scope envelope exists. + + Nothing envelope-derived can be evaluated without an envelope, and the + record says so explicitly: ``evaluated: false`` with every axis null + is distinguishable from an evaluated ledger whose axes all passed. + """ + ledger: Dict[str, Any] = {"evaluated": False} + for axis in ADMISSION_AXES: + ledger[axis] = None + ledger["declaration_errors"] = None + return ledger + + +def admission_ledger( + envelope: Dict[str, Any], + policy: Dict[str, Any], + grant_result: Dict[str, Any], +) -> Dict[str, Any]: + """Evaluate every envelope-derived admission axis, independent of order. + + Each axis lists the pinned reason codes that held (empty = passed). + The ledger is pure evidence: the verdict still comes from the first + failing axis in evaluation order, and ``reason_codes`` keep that + pinned first-failure shape; everything else the ledger holds surfaces + through ``co_occurring_reason_codes``. + """ + thresholds = policy["thresholds"] + grant_codes: List[str] = [] + if grant_result.get("decision") not in {"accepted", "not_present"}: + grant_codes.extend(grant_result.get("reason_codes") or []) + if continuation_scope_breaches(envelope, grant_result): + grant_codes.append("continuation_grant_scope_exceeded") + declaration_errors = _profile_declaration_details(envelope) + return { + "evaluated": True, + "thresholds": _threshold_reasons(envelope, thresholds), + "declared_risk": _declared_risk_reasons(envelope), + "declaration": ["declaration_error"] if declaration_errors else [], + "side_effects": _side_effect_reasons( + envelope, + grant_result, + str(thresholds["external_side_effects"]), + policy["private_repo_allowlist"], + ), + "continuation_grant": grant_codes, + "declaration_errors": declaration_errors, + } + + +def admission_ledger_codes(ledger: Dict[str, Any]) -> List[str]: + """Every reason code an evaluated ledger holds, deduplicated, axis order.""" + codes: List[str] = [] + for axis in ADMISSION_AXES: + for code in ledger.get(axis) or []: + if code not in codes: + codes.append(code) + return codes + + def _actual_side_effects(actuals: Dict[str, Any]) -> Dict[str, bool]: side_effects = actuals.get("side_effects_actual") or {} if not isinstance(side_effects, dict): @@ -1786,6 +2747,16 @@ def _breach_basis(actuals: Dict[str, Any]) -> Optional[str]: return str(value) +def _breach_sub_basis(actuals: Dict[str, Any]) -> Optional[str]: + value = actuals.get("breach_sub_basis") + if value is None: + return None + if value not in BREACH_SUB_BASES: + choices = " or ".join(BREACH_SUB_BASES) + raise ValueError(f"actuals.breach_sub_basis must be {choices}") + return str(value) + + def _declared_intent_fields(actuals: Dict[str, Any]) -> List[str]: """Validated prospective-breach input. @@ -2201,7 +3172,7 @@ def boundary_covered(key: str) -> bool: block["disposition"] = "insufficient" if fresh else "stale" return block["disposition"] = "resumed" - block["cleared_paused_at_utc"] = paused_at + block["cleared_paused_at_utc"] = answer["decided_at_utc"] def evaluate_threshold_checkpoint( @@ -2219,6 +3190,7 @@ def evaluate_threshold_checkpoint( "breached_fields": [], "declaration_errors": [], "breach_basis": None, + "breach_sub_basis": None, "paused_at_utc": None, "action": "not_evaluated", "predicted_risk_materialized": False, @@ -2328,6 +3300,28 @@ def evaluate_threshold_checkpoint( paused_at = _actual_utc_text(actuals, "paused_at_utc") or utc_now_iso() basis = "declared_intent" if intent_fields else "realized" + # A sub-basis refines a realized time-axis breach only: `waiting_on_peer` + # says the clock overran while the receiver waited on a peer reply, not + # while working. It is rejected anywhere it cannot mean that — on an + # unbreached checkpoint, a prospective (declared_intent) breach, or a + # breach that does not include the time axis — so the ledger can trust + # the split instead of recording a stray label. + sub_basis = _breach_sub_basis(actuals) + if sub_basis is not None: + if not breached: + raise ValueError( + "actuals.breach_sub_basis requires a breached checkpoint" + ) + if basis != "realized": + raise ValueError( + "actuals.breach_sub_basis requires breach_basis realized" + ) + if "actual_minutes" not in breached_fields: + raise ValueError( + f"actuals.breach_sub_basis {sub_basis} requires a time-axis " + "breach (actual_minutes in breached_fields)" + ) + # Re-authorization arbitration: channel answers presented by the # receiver are arbitrated against THIS pause. A resumed disposition is # the only one that clears the breach; everything else leaves the @@ -2381,6 +3375,7 @@ def evaluate_threshold_checkpoint( "breached_fields": breached_fields, "declaration_errors": declaration_errors, "breach_basis": basis, + "breach_sub_basis": sub_basis, "paused_at_utc": paused_at, "action": action, "predicted_risk_materialized": predicted_value, @@ -2404,6 +3399,7 @@ def _base_result( "completion_kind": completion_kind, "actual_minutes": checkpoint.get("actual_minutes"), "actual_files_touched": checkpoint.get("actual_files_touched"), + "work_started_at_utc": None, "predicted_risk_materialized": bool( checkpoint.get("predicted_risk_materialized", False) ), @@ -2455,12 +3451,19 @@ def evaluate_autonomy( """ msg_hash = message_sha256(message, message_path, message_raw) policy_hash = canonical_policy_sha256(config) + body = str(message.get("body") or "") + msg_type = str(message.get("type") or "") logged_notes: List[Dict[str, str]] = [] profile_snapshot: Optional[Dict[str, Any]] = None envelope_source: Optional[str] = None # Resolved below; pre-bound so early pauses (malformed config) can # evaluate checkpoints with sender authority failing closed. policy: Optional[Dict[str, Any]] = None + # The structured admission ledger. Stays `evaluated: false` on every + # pause taken before a scope envelope exists — there is nothing to + # evaluate against, and the record says so instead of reading as + # "all passed". + admission_axes: Dict[str, Any] = not_evaluated_admission_ledger() def finish(decision: Dict[str, Any]) -> Dict[str, Any]: reason_codes = list(decision.get("reason_codes") or []) @@ -2501,6 +3504,14 @@ def finish(decision: Dict[str, Any]) -> Dict[str, Any]: reason_codes if decision.get("decision") == "paused" else [], ) decision.setdefault("co_occurring_reason_codes", []) + decision["admission_axes"] = admission_axes + decision["matched_patterns"] = _collect_lexical_provenance( + body, + profile_snapshot, + decision.get("scope_envelope"), + policy, + msg_type, + ) return decision def paused( @@ -2577,9 +3588,6 @@ def paused( if prior_auto_accept_exists(str(message.get("id") or ""), receiver, audit_dir): return paused(mode, ["message_replayed"]) - body = str(message.get("body") or "") - msg_type = str(message.get("type") or "") - if msg_type in REVIEW_LIFECYCLE_TYPES: # Review-loop lifecycle admission: the four task gates do not run — # these messages carry no task profile, and a granted reviewer @@ -2657,12 +3665,47 @@ def paused( gate3_body = _gate3_body(body, logged_notes) - # Gate-2 numeric thresholds are evaluated before any Gate-3 early-out so - # a lexical hard stop cannot leave a co-occurring breach unevaluated: - # record silence must mean "passed", never "not evaluated". - masked_threshold_reasons: List[str] = [] + grant_result: Dict[str, Any] = {"present": False, "enabled": False} + if envelope is not None and envelope_source == SCOPE_ENVELOPE_SOURCE_PROFILE: + # Continuation grants are a sender-declared surface; a default + # envelope declares nothing, so grant interplay is reachable on + # exempt types only through a voluntary profile (the supported + # override path). Resolved ahead of Gate 3 because the ledger's + # side-effect axis is grant-aware. + grant_result = evaluate_continuation_grant( + message, + envelope, + bool(policy["continuation_grants_enabled"]), + audit_dir=audit_dir, + receiver=receiver, + ) + + # Every envelope-derived admission axis is evaluated here, before any + # Gate-3 early-out, and recorded as the admission ledger: a pause taken + # for one reason (most often a lexical hard stop) must not leave another + # axis unrecorded — record silence means "passed", never "not + # evaluated". The ledger never changes the verdict: `reason_codes` keep + # their pinned first-failure shape, and every other axis that held + # lands in `co_occurring_reason_codes`. + masked_admission_reasons: List[str] = [] if envelope is not None: - masked_threshold_reasons = _threshold_reasons(envelope, policy["thresholds"]) + admission_axes = admission_ledger(envelope, policy, grant_result) + masked_admission_reasons = admission_ledger_codes(admission_axes) + + # The one carve-out from the always-hard content-sensitivity class: a + # complete profile declaring reply-only work with every other + # side-effect flag false. For that shape the category records — every + # matching term, as an advisory carrying the term — and never pauses. + # Recorded here, ahead of every Gate-3 early return, so the advisory + # survives whichever hard stop or axis governs the verdict; the + # hard-stop branch below stays in place for every other shape. + content_reply_only = _reply_only_profile_shape(profile, envelope) + if content_reply_only: + for label, pattern in CONTENT_SENSITIVITY_PATTERNS: + if pattern.search(body): + _record_lexical_note( + logged_notes, "lexical_advisory_reply_only", label + ) matched = first_match(DESTRUCTIVE_PATTERNS, body) if matched: @@ -2670,8 +3713,9 @@ def paused( mode, ["hard_stop_destructive_command"], envelope=envelope, + grant_result=grant_result, matched_pattern=matched, - co_occurring=masked_threshold_reasons, + co_occurring=masked_admission_reasons, ) external_policy = str(policy["thresholds"]["external_side_effects"]) @@ -2710,21 +3754,28 @@ def paused( mode, ["hard_stop_external_side_effect"], envelope=envelope, + grant_result=grant_result, matched_pattern=matched, - co_occurring=masked_threshold_reasons, + co_occurring=masked_admission_reasons, ) git_push_or_deploy_policy = str(policy["thresholds"]["git_push_or_deploy"]) matched = None if git_push_or_deploy_policy == "pause": - matched = first_match(NON_DEMOTABLE_SIDE_EFFECT_PATTERNS, body) + matched = _first_contextual_non_demotable_match( + NON_DEMOTABLE_SIDE_EFFECT_PATTERNS, + body, + logged_notes, + contextual_labels=frozenset({"install dependency"}), + ) if matched: return paused( mode, ["hard_stop_external_side_effect"], envelope=envelope, + grant_result=grant_result, matched_pattern=matched, - co_occurring=masked_threshold_reasons, + co_occurring=masked_admission_reasons, ) matched = _first_sensitive_match(gate3_body, logged_notes, profile, envelope) @@ -2733,28 +3784,38 @@ def paused( mode, ["hard_stop_sensitive_scope"], envelope=envelope, + grant_result=grant_result, matched_pattern=matched, - co_occurring=masked_threshold_reasons, + co_occurring=masked_admission_reasons, ) matched = first_match(CONTENT_SENSITIVITY_PATTERNS, body) - if matched: + if matched and not content_reply_only: + # Fence and negation never demote this class; only the declared + # reply-only shape (recorded above) does. return paused( mode, ["hard_stop_content_sensitivity"], envelope=envelope, + grant_result=grant_result, matched_pattern=matched, - co_occurring=masked_threshold_reasons, + co_occurring=masked_admission_reasons, ) - matched = first_match(NON_DEMOTABLE_SENSITIVE_PATTERNS, body) + matched = _first_contextual_non_demotable_match( + NON_DEMOTABLE_SENSITIVE_PATTERNS, + body, + logged_notes, + contextual_labels=frozenset({"public repo"}), + ) if matched: return paused( mode, ["hard_stop_sensitive_scope"], envelope=envelope, + grant_result=grant_result, matched_pattern=matched, - co_occurring=masked_threshold_reasons, + co_occurring=masked_admission_reasons, ) matched = _first_effective_match( @@ -2767,84 +3828,62 @@ def paused( mode, ["file_scope_ambiguous"], envelope=envelope, + grant_result=grant_result, matched_pattern=matched, - co_occurring=masked_threshold_reasons, + co_occurring=masked_admission_reasons, ) - grant_result: Dict[str, Any] = {"present": False, "enabled": False} if envelope is not None: - if envelope_source == SCOPE_ENVELOPE_SOURCE_PROFILE: - # Continuation grants are a sender-declared surface; a default - # envelope declares nothing, so grant interplay is reachable on - # exempt types only through a voluntary profile (the supported - # override path). - grant_result = evaluate_continuation_grant( - message, - envelope, - bool(policy["continuation_grants_enabled"]), - audit_dir=audit_dir, - receiver=receiver, - ) - declaration_breaches = _profile_declaration_errors(envelope) - if declaration_breaches: + # Gate-2 admission verdict, read off the ledger in the pinned + # evaluation order — the first failing axis names `reason_codes`; + # every other axis that held is already in the ledger and lands in + # `co_occurring_reason_codes`. + if admission_axes["declaration"]: return paused( mode, ["declaration_error"], envelope=envelope, grant_result=grant_result, - breached=declaration_breaches, - co_occurring=masked_threshold_reasons, + breached=[ + entry["field"] for entry in admission_axes["declaration_errors"] + ], + co_occurring=masked_admission_reasons, ) - grant_breaches = continuation_scope_breaches(envelope, grant_result) - if grant_breaches: + if "continuation_grant_scope_exceeded" in admission_axes["continuation_grant"]: return paused( mode, ["continuation_grant_scope_exceeded"], envelope=envelope, grant_result=grant_result, - breached=grant_breaches, - co_occurring=masked_threshold_reasons, + breached=continuation_scope_breaches(envelope, grant_result), + co_occurring=masked_admission_reasons, ) - hard_profile_reasons = [] - if envelope["destructive_ops"]: - hard_profile_reasons.append("destructive_ops_pause") - if envelope["touches_auth_config_or_secrets"]: - hard_profile_reasons.append("auth_config_or_secrets_pause") - if envelope["touches_dependencies"]: - hard_profile_reasons.append("dependency_changes_pause") - if envelope["public_visibility"]: - hard_profile_reasons.append("public_visibility_pause") - if hard_profile_reasons: + if admission_axes["declared_risk"]: # Declared-risk-flag pauses are admission pauses, not lexical # hard stops: the cause is already named by the per-knob reason # codes. return paused( mode, - hard_profile_reasons, + list(admission_axes["declared_risk"]), envelope=envelope, grant_result=grant_result, - co_occurring=masked_threshold_reasons, + co_occurring=masked_admission_reasons, ) - reasons = [] - if grant_result.get("decision") not in {"accepted", "not_present"}: - reasons.extend(grant_result.get("reason_codes") or []) - reasons.extend(_threshold_reasons(envelope, policy["thresholds"])) - side_effect_reasons = _side_effect_reasons( - envelope, - grant_result, - external_policy, - policy["private_repo_allowlist"], - ) - reasons.extend(side_effect_reasons) + reasons = [ + *admission_axes["continuation_grant"], + *admission_axes["thresholds"], + *admission_axes["side_effects"], + ] if reasons: return paused( mode, reasons, envelope=envelope, grant_result=grant_result, + co_occurring=masked_admission_reasons, ) checkpoint = evaluate_threshold_checkpoint( @@ -3006,7 +4045,13 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if args.audit_dir is not None and decision.get("reason_codes") != [ "message_replayed" ]: - write_audit_record( + # Re-evaluations supersede rather than duplicate: an identical + # evaluation (same bytes, same policy, same verdict) adopts the + # existing record, and a changed one closes every live + # predecessor as superseded so exactly one evaluation per + # logical message stays live. The whole sequence is one + # logical-identity transaction inside persist_evaluation. + outcome = persist_evaluation( args.audit_dir, decision, config=config, @@ -3015,6 +4060,20 @@ def main(argv: Optional[Sequence[str]] = None) -> int: policy_path=args.config, receiver=args.receiver, ) + decision["evaluation_id"] = outcome["evaluation_id"] + if outcome["action"] == "adopted": + decision["adopted_audit_record"] = str(outcome["audit_path"]) + print( + f"NOTE: adopted existing evaluation " + f"{outcome['evaluation_id']}; no duplicate record written", + file=sys.stderr, + ) + if outcome["superseded"]: + print( + f"NOTE: superseded {len(outcome['superseded'])} prior " + "evaluation(s) for this message", + file=sys.stderr, + ) elif args.audit_dir is not None: print("NOTE: replay detected; audit record not written", file=sys.stderr) print(json.dumps(decision, indent=2)) diff --git a/scripts/claude_envelope_hook.py b/scripts/claude_envelope_hook.py index 0177ddd..211378f 100644 --- a/scripts/claude_envelope_hook.py +++ b/scripts/claude_envelope_hook.py @@ -16,6 +16,9 @@ outside the receiver allowlist, or drifts past ``expected_files_touched`` (denied with the canonical ``[oacp-envelope] Blocked: autonomy threshold exceeded`` opener so the session pivots to the §E checkpoint protocol). + The file ceiling is the compiled bound widened by any granted §E + re-authorization: the envelope stays immutable, and the grant recorded in + the task's audit record is read as an overlay at enforcement time. - ``ask`` — the hook cannot confidently classify the call (exotic compound command, unresolvable repo). The exact command is escalated for just-in-time human review instead of blanket-denied or silently allowed. @@ -74,8 +77,28 @@ # protected path while its literal spelling does not. Such operands escalate. EXPANSION_SYNTAX_RE = re.compile(r"[*?\[\]{}]") SUBSTITUTION_RE = re.compile(r"\$\(([^()]*)\)|`([^`]*)`") -REDIRECT_TARGET_RE = re.compile(r">>?\s*([^\s;|&]+)") +# Every Bash output-redirect spelling that names a file: `>`, `>>`, the +# noclobber override `>|` (the scanner keeps `>|` inside its segment, so +# the `|` here is never a pipe), `<>`, `&>` / `&>>`, and the combined +# stdout+stderr form `>&word`. The same `>&` prefix also spells file +# descriptor duplication and closure (`>&2`, `2>&1`, `>&-`): the word +# after a `dup` match is a file only when it is not digits or `-`. +REDIRECT_TARGET_RE = re.compile( + r">>?(?:(?P&)|\|)?\s*(?P[^\s;|&]+)" +) +# `<(cmd)` / `>(cmd)`: the inner command runs in its own process and is +# classified as a segment, like `$(cmd)`. +PROCESS_SUBSTITUTION_RE = re.compile(r"[<>]\(([^()]*)\)") +# A short-option cluster ending in GNU `-t` (`-t`, `-rt`, `-vtDIR`): the +# target-directory flag of cp/mv/install. Case-sensitive — `-T` is the +# opposite flag. +SHORT_TARGET_DIRECTORY_RE = re.compile(r"^-[A-Za-z]*t(.*)$") ENV_ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") +# `<[A-Za-z0-9_.-]+)/(?P[A-Za-z0-9_.-]+?)(?:\.git)?/?$" ) @@ -258,19 +281,25 @@ def _with_message_id(decision: Decision, message_id: str) -> Decision: class WorkspaceContext: """Workspace facts resolved by ``process()`` that classification needs beyond the envelope's own constraints: where the receiver's bookkeeping - surfaces live, and which audit records can sanction a completion clear. - ``None`` (no resolved workspace, e.g. direct ``classify`` calls) keeps - every context-dependent decision fail-closed.""" + surfaces live, and which audit records can sanction a completion clear or + widen the file budget. ``None`` (no resolved workspace, e.g. direct + ``classify`` calls) keeps every context-dependent decision fail-closed.""" - __slots__ = ("oacp_root", "project", "receiver", "message_id") + __slots__ = ("oacp_root", "project", "receiver", "message_id", "message_sha256") def __init__( - self, oacp_root: Path, project: str, receiver: str, message_id: str + self, + oacp_root: Path, + project: str, + receiver: str, + message_id: str, + message_sha256: str = "", ) -> None: self.oacp_root = oacp_root self.project = project self.receiver = receiver self.message_id = message_id + self.message_sha256 = message_sha256 def agent_dir(self) -> Path: return ( @@ -283,6 +312,23 @@ def audit_dir(self) -> Path: def state_dir(self) -> Path: return self.agent_dir() / "state" + def authority_roots(self) -> List[Tuple[str, Path]]: + """Receiver surfaces the enveloped session may never modify itself. + + Both enforcement sites — the write-target gate and the role-agnostic + filesystem-mutator operand gate — consume this one list, so the two + boundaries cannot drift apart. ``state/`` holds the active envelope; + ``audit/autonomy_decisions/`` holds the records whose recorded + re-authorization can widen that envelope's live bound. Modifying + either from inside the session is self-authorization, and that is + true regardless of operand role: deleting or relocating a record + changes which record governs just as surely as rewriting one. + """ + return [ + ("envelope state", self.state_dir()), + ("autonomy audit record", self.audit_dir()), + ] + def trust_roots(self) -> List[Path]: return [ self.agent_dir() / "trust", @@ -1027,6 +1073,16 @@ def _classify_envelope_clear( str(record.get("message_id") or "") == context.message_id and str(record.get("receiver") or "") == context.receiver ): + record_result = record.get("result") + state_value = ( + record_result.get("final_state") + if isinstance(record_result, dict) + else None + ) + if state_value == "superseded": + # A superseded evaluation's authority transferred to its + # successor; it must neither sanction nor block the clear. + continue matches.append((candidate.name, record)) if not matches: return _deny( @@ -1160,6 +1216,107 @@ def _mutator_operands(tokens: List[str]) -> List[str]: return operands +def _target_directory_option(tokens: List[str]) -> Optional[str]: + """Value of a GNU target-directory option (`-t DIR`, `-tDIR`, a short + cluster ending in `t`, `--target-directory DIR`, `--target-directory=DIR`, + and — because GNU getopt_long accepts unambiguous abbreviations — any + `--t…` prefix of the long name, attached or separate) — the destination + of every operand when present. `--` ends option processing. None when + the invocation carries no such option.""" + index = 1 + while index < len(tokens): + token = tokens[index] + if token == "--": + return None + if token.startswith("--"): + name, attached, value = token[2:].partition("=") + if name and "target-directory".startswith(name): + if attached: + return value + return tokens[index + 1] if index + 1 < len(tokens) else None + index += 1 + continue + if token.startswith("-"): + match = SHORT_TARGET_DIRECTORY_RE.match(token) + if match is not None: + if match.group(1): + return match.group(1) + return tokens[index + 1] if index + 1 < len(tokens) else None + index += 1 + return None + + +def _operands(tokens: List[str]) -> List[str]: + """Non-option operands of an argv, honouring `--` (everything after it + is an operand even when it starts with a dash).""" + operands: List[str] = [] + options_ended = False + for token in tokens[1:]: + if options_ended or token == "-" or not token.startswith("-"): + operands.append(token) + elif token == "--": + options_ended = True + return operands + + +def _sed_in_place_targets(tokens: List[str]) -> Optional[List[str]]: + """File operands of an in-place sed invocation; None when sed is not + editing in place (its output then goes to stdout and any redirect is + gated separately). + + In-place spellings: `-i[SUFFIX]`, a short cluster containing `i` + (`-ni`, `-Ei.bak`), `--in-place[=SUFFIX]` and its unambiguous prefixes + (`--i…`). Script options (`-e`/`-f`, attached or separate, + `--expression`/`--file` with prefixes) make every operand a file; + without one the first operand is the script. `-l`/`--line-length` + values are skipped so they never read as operands. + """ + in_place = False + script_option = False + operands: List[str] = [] + options_ended = False + index = 1 + while index < len(tokens): + token = tokens[index] + index += 1 + if options_ended or token == "-" or not token.startswith("-"): + operands.append(token) + continue + if token == "--": + options_ended = True + continue + if token.startswith("--"): + name, attached, _value = token[2:].partition("=") + if not name: + continue + if "in-place".startswith(name): + in_place = True + elif "expression".startswith(name) or ( + len(name) >= 2 and "file".startswith(name) + ): + script_option = True + if not attached: + index += 1 + elif "line-length".startswith(name) and not attached: + index += 1 + continue + for position, letter in enumerate(token[1:]): + if letter == "i": + # The rest of the cluster is the optional backup suffix. + in_place = True + break + if letter in ("e", "f", "l"): + # Value is the rest of the cluster, or the next token. + if letter != "l": + script_option = True + if position == len(token) - 2: + index += 1 + break + if not in_place: + return None + return operands if script_option else operands[1:] + + def _segment_write_targets(tokens: List[str], segment: str) -> List[str]: """Determinable file-write targets of one shell segment (F-004). @@ -1168,22 +1325,34 @@ def _segment_write_targets(tokens: List[str], segment: str) -> List[str]: targets — the secret/dependency/counter gate then simply does not fire. """ targets = [ - match.group(1).strip("'\"") + match.group("word").strip("'\"") for match in REDIRECT_TARGET_RE.finditer(segment) + # `> >(cmd)`: the destination is the inner command, classified as + # its own segment; the operator spelling is not a path. + if not match.group("word").startswith((">(", "<(")) + # `>&N` / `>&-` duplicate or close a descriptor; only a non-numeric + # word after `>&` is Bash's combined-output redirect to a file. + and not ( + match.group("dup") + and (match.group("word").isdigit() or match.group("word") == "-") + ) ] if not tokens: return targets prog = tokens[0].rsplit("/", 1)[-1] - positional = [token for token in tokens[1:] if not token.startswith("-")] + positional = _operands(tokens) if prog in ("touch", "tee"): targets.extend(positional) - elif prog in ("cp", "mv", "install") and len(positional) >= 2: - targets.append(positional[-1]) + elif prog in ("cp", "mv", "install"): + target_directory = _target_directory_option(tokens) + if target_directory is not None: + targets.append(target_directory) + elif len(positional) >= 2: + targets.append(positional[-1]) elif prog == "truncate": targets.extend(positional) - elif prog == "sed" and any(token.startswith("-i") for token in tokens[1:]): - # First positional is the script; the rest are edited in place. - targets.extend(positional[1:]) + elif prog == "sed": + targets.extend(_sed_in_place_targets(tokens) or []) elif prog == "dd": targets.extend( token[len("of="):] for token in tokens[1:] if token.startswith("of=") @@ -1191,6 +1360,110 @@ def _segment_write_targets(tokens: List[str], segment: str) -> List[str]: return targets +def _reauthorized_scope( + context: Optional[WorkspaceContext], +) -> Optional[Dict[str, Any]]: + """The durable scope a resumed §E re-authorization granted this task. + + The envelope is immutable once compiled, so a granted re-authorization + reaches enforcement as an overlay read here rather than as a recompile + (``oacp envelope compile --extend`` is in-session-denied by design). + Only the audit record's *effective* ``scope`` is consulted — the + policy-capped value the gate wrote, never the raw ``requested_scope`` a + sender asked for. A fresh scope-less approval records no scope: it + cleared exactly one pause and creates nothing durable, so it widens + nothing here either. + + Record selection mirrors the completion clear: content identity only + (``message_id`` + ``receiver``, plus the envelope's ``message_sha256`` so + a record for different message bytes can never widen this envelope), + filenames are never trusted, ``superseded`` evaluations are skipped, and + the newest match governs. Every failure path returns ``None`` — an + absent, unreadable, or malformed record leaves the compiled bound + standing, which is the fail-closed direction for a widening overlay. + """ + if context is None or not context.message_id or not context.message_sha256: + return None + audit_dir = context.audit_dir() + if not audit_dir.is_dir(): + return None + from _oacp_constants import locked_audit + + matches: List[Tuple[str, Dict[str, Any]]] = [] + for candidate in sorted(audit_dir.glob("*.yaml")): + try: + # Read under the record's own audit lock: the canonical writers + # hold it across their read-modify-replace, so a concurrent + # checkpoint write cannot be observed half-applied. + with locked_audit(candidate): + record = load_yaml_file(candidate) + except Exception: + # An unreadable record cannot widen anything; the compiled bound + # stands and the write blocks with the canonical opener. + continue + if not isinstance(record, dict): + continue + if ( + str(record.get("message_id") or "") != context.message_id + or str(record.get("receiver") or "") != context.receiver + or str(record.get("message_sha256") or "") != context.message_sha256 + ): + continue + result = record.get("result") + if not isinstance(result, dict): + continue + if result.get("final_state") == "superseded": + continue + matches.append((candidate.name, record)) + if not matches: + return None + governing = max(matches, key=lambda item: item[0])[1]["result"] + checkpoint = governing.get("threshold_checkpoint") + if not isinstance(checkpoint, dict): + return None + reauth = checkpoint.get("reauthorization") + if not isinstance(reauth, dict): + return None + # Validate the whole re-authorization state, not just `disposition`: a + # partial or malformed block must not widen anything. The vocabulary is + # imported from the evaluator that writes it, never re-spelled here, so + # the two cannot drift. `human_outcome` is deliberately NOT required — + # the governing channel may be `sender_reply`, whose authority is the + # sender's signed follow-up, not a receiver-side human ruling. + from autonomy_gate import REAUTH_DECISIONS, REAUTH_GOVERNING_CHANNELS + + if ( + reauth.get("disposition") != "resumed" + or reauth.get("presented") is not True + or reauth.get("channel") not in REAUTH_GOVERNING_CHANNELS + or reauth.get("decision") not in REAUTH_DECISIONS + or reauth.get("decision") == "declined" + ): + return None + scope = reauth.get("scope") + return scope if isinstance(scope, dict) else None + + +def _reauthorized_file_budget( + scope: Optional[Dict[str, Any]], declared: int +) -> int: + """``declared``, widened to a granted bound but never narrowed by one. + + A grant can only ever open room the envelope did not already have: the + overlay widens exactly to the recorded scope and no further, and a scope + at or below the compiled bound is a no-op, so existing envelopes keep + their outcomes unchanged. Only the files axis is overlaid — the grant's + ``max_actual_minutes`` has no hook enforcement point (``estimated_minutes`` + is recorded for the §E self-check and carries no runtime semantics). + """ + if not scope: + return declared + granted = scope.get("max_actual_files_touched") + if isinstance(granted, bool) or not isinstance(granted, int): + return declared + return granted if granted > declared else declared + + def _gate_write_paths( paths: List[str], cwd: str, @@ -1205,9 +1478,13 @@ def _gate_write_paths( the `expected_files_touched` ceiling (F-004: `touch a b c`). Bookkeeping surfaces pass the secret/dependency gates but never reach the counter: completion instrumentation must not compete with the task's declared - file budget.""" + file budget. The ceiling is the compiled ``expected_files_touched``, + widened at enforcement time to a granted §E re-authorization when one + governs the task — resolved lazily on first contact with the ceiling so + the ordinary path never reads the audit directory.""" touched = list(counters.get("files_touched") or []) - expected = int(constraints.get("expected_files_touched") or 0) + declared = int(constraints.get("expected_files_touched") or 0) + expected: Optional[int] = None new_files: List[str] = [] for path in paths: normalized = _normalize_file_path(path, cwd) @@ -1222,12 +1499,15 @@ def _gate_write_paths( ) if context is not None: canonical = os.path.realpath(normalized) - if _within(canonical, os.path.realpath(str(context.state_dir()))): - return _deny( - f"{verb} of envelope state {normalized!r} from inside the " - "enveloped session is envelope self-modification; the " - "active envelope is never written directly" - ) + for label, root in context.authority_roots(): + if _within(canonical, os.path.realpath(str(root))): + return _deny( + f"{verb} of {label} {normalized!r} from inside the " + "enveloped session is authority self-modification; " + "these surfaces govern the envelope's own bound and " + "are never written directly (the canonical oacp " + "writers are classified as commands)" + ) if not constraints.get("touches_auth_config_or_secrets"): for root in context.trust_roots(): if _within(canonical, os.path.realpath(str(root))): @@ -1252,11 +1532,16 @@ def _gate_write_paths( continue if normalized in touched or normalized in new_files: continue - if len(touched) + len(new_files) >= expected: - return _deny( - f"{BLOCKED_OPENER} — files_touched expected {expected}, " - f"now {len(touched) + len(new_files) + 1}" - ) + if len(touched) + len(new_files) >= declared: + if expected is None: + expected = _reauthorized_file_budget( + _reauthorized_scope(context), declared + ) + if len(touched) + len(new_files) >= expected: + return _deny( + f"{BLOCKED_OPENER} — files_touched expected {expected}, " + f"now {len(touched) + len(new_files) + 1}" + ) new_files.append(normalized) if new_files: return Decision("allow", new_files=new_files) @@ -1338,6 +1623,12 @@ def flush() -> None: index += 1 continue + if char == "|" and current and current[-1] == ">": + # `>|` (noclobber override) is a redirection, not a pipe; its + # target stays visible to the redirect gate. + current.append(char) + index += 1 + continue if char in (";", "\n", "|"): flush() index += 1 @@ -1366,6 +1657,96 @@ def flush() -> None: return segments +def _heredoc_delimiters(line: str) -> Optional[List[Tuple[str, bool, bool, int, int]]]: + """Heredoc operators a line opens, outside quotes, in operator order. + + Each entry is ``(word, strip_leading_tabs, body_expands, start, end)``: + the delimiter word, whether ``<<-`` tab-stripping applies, whether the + body undergoes shell expansion (a bare, unquoted, unescaped word — a + quoted or backslashed delimiter makes the body literal data), and the + operator's span on the line. Returns None when the line carries a + ``<<`` outside quotes with no parseable delimiter — the body's extent + is then unknowable and the caller escalates. + """ + delimiters: List[Tuple[str, bool, bool, int, int]] = [] + quote: Optional[str] = None + index = 0 + while index < len(line): + char = line[index] + if char == "\\" and quote != "'": + index += 2 + continue + if quote is not None: + if char == quote: + quote = None + index += 1 + continue + if char in ("'", '"'): + quote = char + index += 1 + continue + if line.startswith("<<<", index): + index += 3 + continue + if line.startswith("<<", index): + match = HEREDOC_OPERATOR_RE.match(line, index) + if match is None: + return None + word = match.group(2) or match.group(3) or match.group(5) + expands = match.group(5) is not None and match.group(4) is None + delimiters.append( + (word, bool(match.group(1)), expands, match.start(), match.end()) + ) + index = match.end() + continue + index += 1 + return delimiters + + +def _strip_heredoc_bodies(command: str) -> str: + """Drop heredoc bodies, their terminator lines, and the `<` inside a string literal there as a + write. The operator line itself stays, minus the operator, so a redirect + or writer program on it (`cat > path < List[str]: """Return top-level and command-substitution segments (F-002).""" segments = _split_shell_segments(command) @@ -1382,6 +1763,11 @@ def _segments_of(command: str) -> List[str]: for match in substitutions: inner = match.group(1) or match.group(2) or "" segments.extend(_split_shell_segments(inner)) + process_substitutions = list(PROCESS_SUBSTITUTION_RE.finditer(command)) + if command.count("<(") + command.count(">(") != len(process_substitutions): + raise ValueError("nested or parenthesized process substitution") + for match in process_substitutions: + segments.extend(_split_shell_segments(match.group(1))) return segments @@ -1513,14 +1899,20 @@ def _classify_segment( ) if prog in FS_MUTATORS and context is not None: # Operand gate, role-agnostic: deleting, relocating, copying out, or - # aliasing envelope state is self-modification just as much as + # aliasing an authority surface is self-modification just as much as # writing it, and the same for trust-root material under the auth # gate — the write-target gate alone sees only destinations. The - # operands judged must be the ones the utility will actually use: - # GNU target-directory spellings are parsed, `--` ends option - # processing, and expansion syntax escalates (the shell expands it - # after classification, so its literal spelling proves nothing). - state_root = os.path.realpath(str(context.state_dir())) + # authority roots come from the same list the write-target gate + # consumes, so a surface can never be protected against writes but + # left open to `rm`/`mv`. The operands judged must be the ones the + # utility will actually use: GNU target-directory spellings are + # parsed, `--` ends option processing, and expansion syntax escalates + # (the shell expands it after classification, so its literal + # spelling proves nothing). + authority_roots = [ + (label, os.path.realpath(str(root))) + for label, root in context.authority_roots() + ] trust_roots = [os.path.realpath(str(r)) for r in context.trust_roots()] for operand in _mutator_operands(tokens): if EXPANSION_SYNTAX_RE.search(operand) or "$" in operand: @@ -1530,11 +1922,13 @@ def _classify_segment( "explicit literal paths" ) canonical = os.path.realpath(_normalize_file_path(operand, cwd)) - if _within(canonical, state_root): - return _deny( - f"{prog} touching envelope state {operand!r} from inside " - "the enveloped session is envelope self-modification" - ) + for label, root in authority_roots: + if _within(canonical, root): + return _deny( + f"{prog} touching {label} {operand!r} from inside " + "the enveloped session is authority " + "self-modification" + ) if not constraints.get("touches_auth_config_or_secrets"): for root in trust_roots: if _within(canonical, root): @@ -1599,9 +1993,9 @@ def classify_bash( write_targets: List[str] = [] try: - segments = _segments_of(command) - except ValueError: - return _ask(f"cannot segment shell command: {command!r}") + segments = _segments_of(_strip_heredoc_bodies(command)) + except ValueError as error: + return _ask(f"cannot segment shell command ({error}): {command!r}") # A command with exactly one segment has no earlier shell state (cd, # export) that could retarget a target-sensitive subcommand after # validation — the completion clear is sanctioned only in that form. @@ -1626,6 +2020,17 @@ def classify_bash( return decision for target in write_targets: + # A target spelled through a shell variable or substitution takes + # its value from shell state the classifier cannot see; escalate + # instead of normalizing the unexpanded spelling to a cwd-relative + # phantom path (which both miscounts the budget and can hide a + # real write elsewhere). + if "$" in target or "`" in target: + return _ask( + f"write target {target!r} references a shell variable or " + "substitution that cannot be statically resolved; use a " + "literal path" + ) # Bash-derived targets are shell-expanded after classification — # a pattern can reach a path its literal spelling does not. File-tool # paths (Edit/Write) never pass here and may contain literal brackets. @@ -2051,6 +2456,7 @@ def process(payload: Dict[str, Any], receiver: str = "claude") -> Decision: project=str(envelope.get("project") or project), receiver=str(envelope.get("receiver") or receiver), message_id=str(envelope.get("message_id") or ""), + message_sha256=str(envelope.get("message_sha256") or ""), ) decision = classify( str(payload.get("tool_name") or ""), diff --git a/scripts/create_handoff_packet.py b/scripts/create_handoff_packet.py deleted file mode 100644 index 6f0c683..0000000 --- a/scripts/create_handoff_packet.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""Create a structured handoff packet. - -Usage: - create_handoff_packet.py --from --to --intent [options] - -Options: - --artifact Artifact path/PR to review (repeatable) - --done Definition-of-done item (repeatable) - --next-step Suggested next step (repeatable) - --output Output file path (default: projects//packets/handoff/__to_.yaml) - --oacp-dir Override OACP home directory (default: $OACP_HOME or ~/oacp) - --dry-run Print packet instead of writing - --json Output machine-readable report -""" - -from __future__ import annotations - -import argparse -import datetime as dt -import json -import re -import sys -from pathlib import Path -from typing import Dict, List - -from handoff_schema import validate_handoff_packet_text - -_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+") - - -def _sanitize(text: str) -> str: - cleaned = _SAFE_NAME_RE.sub("-", text.strip()) - cleaned = cleaned.strip("-") - return cleaned or "unknown" - - -def _quote(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - - -def render_packet(data: Dict[str, object]) -> str: - artifacts = data["artifacts_to_review"] - done_items = data["definition_of_done"] - next_steps = data["suggested_next_steps"] - - lines: List[str] = [ - f"source_agent: {_quote(str(data['source_agent']))}", - f"target_agent: {_quote(str(data['target_agent']))}", - f"intent: {_quote(str(data['intent']))}", - "", - "artifacts_to_review:", - ] - - for item in artifacts: - lines.append(f" - {_quote(str(item))}") - - lines.append("") - lines.append("definition_of_done:") - for item in done_items: - lines.append(f" - {_quote(str(item))}") - - lines.extend( - [ - "", - "context_bundle:", - " files_touched:", - " - path: \"TBD\"", - " rationale: \"Fill before sending\"", - " decisions_made:", - " - decision: \"TBD\"", - " alternatives_considered:", - " - \"TBD\"", - " blockers_hit:", - " - blocker: \"none\"", - " workarounds_attempted:", - " - \"n/a\"", - " suggested_next_steps:", - ] - ) - - for item in next_steps: - lines.append(f" - {_quote(str(item))}") - - return "\n".join(lines) + "\n" - - -def _default_output_path(project_dir: Path, source: str, target: str) -> Path: - stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") - name = f"{stamp}_{_sanitize(source)}_to_{_sanitize(target)}.yaml" - return project_dir / "packets" / "handoff" / name - - -def parse_args(argv: List[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Create a structured handoff packet") - parser.add_argument("project", help="Project under OACP_HOME/projects") - parser.add_argument("--from", dest="source_agent", required=True, help="Source agent") - parser.add_argument("--to", dest="target_agent", required=True, help="Target agent") - parser.add_argument("--intent", required=True, help="Short handoff intent") - parser.add_argument("--artifact", action="append", default=[], help="Artifact path or PR ref") - parser.add_argument("--done", action="append", default=[], help="Definition-of-done item") - parser.add_argument("--next-step", action="append", default=[], help="Suggested next step") - parser.add_argument("--output", default=None, help="Output path") - parser.add_argument( - "--oacp-dir", - default=None, - help="Override OACP home directory (default: $OACP_HOME or ~/oacp)", - ) - parser.add_argument("--dry-run", action="store_true", help="Print packet instead of writing") - parser.add_argument("--json", action="store_true", dest="json_output", help="JSON output") - return parser.parse_args(argv) - - -def main(argv: List[str] | None = None) -> int: - args = parse_args(argv or sys.argv[1:]) - from _oacp_env import resolve_oacp_home - oacp_dir = resolve_oacp_home(args.oacp_dir) - project_dir = oacp_dir / "projects" / args.project - - if not project_dir.is_dir(): - print(f"Error: project directory not found: {project_dir}", file=sys.stderr) - return 2 - - artifacts = args.artifact or ["PR #"] - done_items = args.done or ["Implement agreed deliverables and update tests"] - next_steps = args.next_step or ["Review packet and begin implementation"] - - packet_data: Dict[str, object] = { - "source_agent": args.source_agent, - "target_agent": args.target_agent, - "intent": args.intent, - "artifacts_to_review": artifacts, - "definition_of_done": done_items, - "suggested_next_steps": next_steps, - } - - packet_text = render_packet(packet_data) - validation_errors = validate_handoff_packet_text(packet_text) - if validation_errors: - for err in validation_errors: - print(f"ERROR: {err}", file=sys.stderr) - return 1 - - output_path = Path(args.output).expanduser() if args.output else _default_output_path( - project_dir, - args.source_agent, - args.target_agent, - ) - - report = { - "project": args.project, - "source_agent": args.source_agent, - "target_agent": args.target_agent, - "intent": args.intent, - "dry_run": bool(args.dry_run), - "output_path": str(output_path), - } - - if args.dry_run: - if args.json_output: - report["packet"] = packet_text - print(json.dumps(report, indent=2)) - else: - print(packet_text, end="") - return 0 - - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(packet_text, encoding="utf-8") - - if args.json_output: - print(json.dumps(report, indent=2)) - else: - print(f"OK: {output_path}") - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/finalize_autonomy_record.py b/scripts/finalize_autonomy_record.py new file mode 100644 index 0000000..3ff870f --- /dev/null +++ b/scripts/finalize_autonomy_record.py @@ -0,0 +1,1279 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Finalize and validate autonomy audit records. + +One lock-aware code path owns every receiver-side write to an audit +record after admission: mid-task threshold checkpoints (``--checkpoint``) +and terminal updates (``--final-state``). Hand-edited terminal blocks are +what produced off-enum vocabulary, duplicate live evaluations, and +terminal records still carrying a paused checkpoint action — this module +enforces the pinned enums and cross-field invariants at write time, and +exposes the same checks as a validator (`validate_audit_record`, +`sweep_audit_dir`) for `oacp doctor` and the conformance fixtures. + +Exit codes: 0 written (or validated) · 2 usage/validation error · +4 checkpoint breached — the record is now checkpoint-paused and the §E +re-authorization flow applies before any terminal state can be written. +""" + +from __future__ import annotations + +import argparse +import copy +import datetime as dt +import json +import math +import re +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import yaml + +from _oacp_constants import atomic_replace_yaml, locked_audit, utc_now_iso +from autonomy_gate import ( + AUTONOMY_AUDIT_SCHEMA_VERSION, + BREACH_BASES, + BREACH_SUB_BASES, + COVERABLE_CONTINUATION_FIELDS, + DuplicateKeyError, + FINAL_STATES, + LEGACY_PROFILE_BOOL_FIELDS, + PINNED_COMPLETION_KINDS, + _actual_side_effects, + evaluate_threshold_checkpoint, + evaluation_identity, + load_yaml_strict, +) +from record_autonomy_outcome import GRANT_DECISIONS, HUMAN_DECISIONS + +__all__ = [ + "DuplicateKeyError", + "apply_checkpoint", + "finalize_audit_record", + "load_audit_strict", + "sweep_audit_dir", + "validate_audit_record", +] + + +# The run-state vocabulary, split by lifecycle phase. ``pending`` is the +# receiver-written in-flight value (an admission record picked up for +# execution); the evaluator itself never writes it, so it extends the +# evaluator's FINAL_STATES rather than appearing there. +TERMINAL_FINAL_STATES = frozenset({"done", "superseded", "error"}) +LIVE_FINAL_STATES = frozenset({"pending", "paused", "blocked"}) +VALID_FINAL_STATES = TERMINAL_FINAL_STATES | LIVE_FINAL_STATES +assert TERMINAL_FINAL_STATES | (LIVE_FINAL_STATES - {"pending"}) == FINAL_STATES + +# Canonical mapping for off-enum vocabulary observed in pre-rail records. +# The mapping is documentation for auditors and collectors — history is +# never rewritten. A record carrying one of these is closed by superseding +# it (a state update through this module), not by editing the old value. +LEGACY_COMPLETION_KIND_MAP = { + "executed": "auto_accepted", + "review_round_delivered": "auto_accepted", + "human_approved_completed": "admission_paused", + "completed_after_human_approval": "admission_paused", +} +LEGACY_FINAL_STATE_MAP = {"completed": "done"} + +# Canonical realized-axis names a checkpoint may breach on. Everything the +# evaluator emits is drawn from these; receiver-composed variants (three +# observed spellings for the file axis alone) fragment calibration. +CANONICAL_NUMERIC_AXES = ("actual_minutes", "actual_files_touched") +CANONICAL_CHECKPOINT_AXES = frozenset(CANONICAL_NUMERIC_AXES) | { + f"side_effects_actual.{field}" for field in COVERABLE_CONTINUATION_FIELDS +} | { + f"task_profile.{field}" + for field in (*LEGACY_PROFILE_BOOL_FIELDS, *COVERABLE_CONTINUATION_FIELDS) +} + +# Validator finding codes, pinned like reason codes. ``error`` findings are +# integrity violations the fleet drives to zero; ``advisory`` findings are +# ambiguous-by-construction (an auto-accepted record is born +# ``final_state: done`` with null actuals, so missing terminal data cannot +# be distinguished from work still in flight) or purely historical. +FINDING_SEVERITIES = { + "record_unparsable": "error", + "duplicate_yaml_key": "error", + "off_enum_completion_kind": "error", + "missing_completion_kind": "error", + "off_enum_final_state": "error", + "decision_kind_incoherent": "error", + "paused_terminal_checkpoint_action": "error", + "paused_terminal_completed": "error", + "terminal_paused_without_outcome": "error", + "superseded_missing_successor": "error", + "breached_empty_fields": "error", + "off_enum_breach_basis": "error", + "breach_basis_incoherent": "error", + "invalid_human_outcome": "error", + "duplicate_logical_id": "error", + "noncanonical_checkpoint_axis": "advisory", + "terminal_missing_actuals": "advisory", + "actual_minutes_inconsistent": "error", + "work_started_before_admission": "error", +} + + +# The strict duplicate-key-refusing loader lives in autonomy_gate (shared +# with automatic supersession, which must also fail closed on ambiguous +# YAML); this module re-exports it under its historical name. +load_audit_strict = load_yaml_strict + + +def _finding(code: str, detail: str) -> Dict[str, str]: + if code not in FINDING_SEVERITIES: # pragma: no cover - programming guard + raise ValueError(f"unpinned finding code: {code}") + return {"code": code, "severity": FINDING_SEVERITIES[code], "detail": detail} + + +def _parse_utc(value: Any) -> Optional[dt.datetime]: + try: + return dt.datetime.strptime(str(value), "%Y-%m-%dT%H:%M:%SZ").replace( + tzinfo=dt.timezone.utc + ) + except (TypeError, ValueError): + return None + + +def _result_block(record: Dict[str, Any]) -> Dict[str, Any]: + result = record.get("result") + return result if isinstance(result, dict) else {} + + +def _checkpoint_block(record: Dict[str, Any]) -> Dict[str, Any]: + checkpoint = _result_block(record).get("threshold_checkpoint") + return checkpoint if isinstance(checkpoint, dict) else {} + + +def _active_minutes( + record: Dict[str, Any], + started_at_utc: Any, + completed_at_utc: Any, +) -> int: + """Return active wall-clock minutes, rounded up to a whole minute. + + A checkpoint may carry one re-authorization pause interval. Peer-review + waits remain inside the wall clock. A resolved pause excludes the + explicit ``paused_at_utc`` -> ``cleared_paused_at_utc`` interval; an + uncleared pause ends the active clock at ``paused_at_utc``. + """ + started = _parse_utc(started_at_utc) + completed = _parse_utc(completed_at_utc) + if started is None or completed is None: + raise ValueError( + "work_started_at_utc and completed_at_utc must use " + "YYYY-MM-DDTHH:MM:SSZ" + ) + if completed < started: + raise ValueError("completed_at_utc precedes work_started_at_utc") + + paused_seconds = 0.0 + checkpoint = _checkpoint_block(record) + paused_text = checkpoint.get("paused_at_utc") + reauthorization = checkpoint.get("reauthorization") + cleared_text = ( + reauthorization.get("cleared_paused_at_utc") + if isinstance(reauthorization, dict) + else None + ) + if paused_text is not None: + paused = _parse_utc(paused_text) + cleared = completed if cleared_text is None else _parse_utc(cleared_text) + if paused is None or cleared is None: + raise ValueError( + "a re-authorization pause needs parseable paused_at_utc and, " + "when present, cleared_paused_at_utc timestamps" + ) + if not (started <= paused <= cleared <= completed): + raise ValueError( + "the re-authorization pause interval must fall within the " + "work-start to completion interval" + ) + paused_seconds = (cleared - paused).total_seconds() + elif cleared_text is not None: + raise ValueError( + "cleared_paused_at_utc requires a parseable paused_at_utc timestamp" + ) + + active_seconds = (completed - started).total_seconds() - paused_seconds + if active_seconds < 0: # pragma: no cover - guarded by interval ordering + raise ValueError("re-authorization pauses exceed the task wall clock") + return int(math.ceil(active_seconds / 60.0)) + + +def _is_closed(record: Dict[str, Any]) -> bool: + """A record is closed once it carries completion evidence. + + ``final_state`` alone cannot answer this: auto-accepted records are + born ``done`` (conformance-pinned admission shape) with null actuals + and no completion stamp. ``superseded`` closes unconditionally — its + authority transferred to the superseding evaluation. + """ + if _result_block(record).get("final_state") == "superseded": + return True + return bool(_result_block(record).get("completed_at_utc")) + + +def _human_outcome_recorded(record: Dict[str, Any]) -> bool: + outcome = _result_block(record).get("human_outcome") + return isinstance(outcome, dict) and outcome.get("recorded") is True + + +def _checkpoint_resolved(record: Dict[str, Any]) -> bool: + """True when a breached checkpoint has a governing resumed answer.""" + checkpoint = _checkpoint_block(record) + if checkpoint.get("breached") is not True: + return True + reauth = checkpoint.get("reauthorization") + if isinstance(reauth, dict) and reauth.get("disposition") == "resumed": + return True + # A receiver-side human ruling recorded AFTER the checkpoint pause + # (via `oacp autonomy-outcome` on the checkpoint-paused record) also + # clears it: the recorder refuses checkpoint records without a + # paused_at_utc stamp, so a recorded outcome whose decision time is + # not before the pause is a checkpoint answer, not the admission one. + outcome = _result_block(record).get("human_outcome") + if not isinstance(outcome, dict) or outcome.get("recorded") is not True: + return False + if outcome.get("decision") not in {"approved", "modified"}: + return False + decided = _parse_utc(outcome.get("decided_at_utc")) + paused = _parse_utc(checkpoint.get("paused_at_utc")) + if decided is None or paused is None: + return False + return decided >= paused + + +_EVALUATION_ID_RE = re.compile(r"^eval-[0-9a-f]{16}$") + + +def validate_audit_record( + record: Dict[str, Any], + *, + source: str = "", +) -> List[Dict[str, str]]: + """Return pinned integrity findings for one parsed audit record.""" + findings: List[Dict[str, str]] = [] + result = _result_block(record) + checkpoint = _checkpoint_block(record) + schema = record.get("schema_version") + decision = record.get("decision") + kind = result.get("completion_kind") + state = result.get("final_state") + + if state == "superseded": + # Closed history: authority transferred to the successor, and the + # record left the live corpus — collectors and duplicate detection + # exclude it, and legacy off-enum vocabulary preserved inside it is + # exactly what the supersession repair is for. The one invariant + # that must hold is the successor chain itself. + successor = record.get("superseded_by_evaluation_id") + if not isinstance(successor, str) or not _EVALUATION_ID_RE.fullmatch(successor): + findings.append(_finding( + "superseded_missing_successor", + f"{source}: superseded record names no well-formed " + f"superseded_by_evaluation_id (found {successor!r})", + )) + return findings + + if kind is None: + if isinstance(schema, int) and schema >= AUTONOMY_AUDIT_SCHEMA_VERSION: + findings.append(_finding( + "missing_completion_kind", + f"{source}: schema-v{schema} record has no result.completion_kind", + )) + elif kind not in PINNED_COMPLETION_KINDS: + mapped = LEGACY_COMPLETION_KIND_MAP.get(str(kind)) + hint = ( + f"; canonical mapping: {mapped}" if mapped else "; no canonical mapping" + ) + findings.append(_finding( + "off_enum_completion_kind", + f"{source}: completion_kind {kind!r} is off-enum{hint} — close via " + "supersession, never by rewriting history", + )) + + if state not in VALID_FINAL_STATES: + mapped_state = LEGACY_FINAL_STATE_MAP.get(str(state)) + hint = ( + f"; canonical mapping: {mapped_state}" + if mapped_state + else "; no canonical mapping" + ) + findings.append(_finding( + "off_enum_final_state", + f"{source}: final_state {state!r} is off-enum{hint}", + )) + + if kind in PINNED_COMPLETION_KINDS and decision in {"auto_accepted", "paused"}: + coherent = { + "auto_accepted": {"auto_accepted", "checkpoint_paused"}, + "paused": {"admission_paused", "checkpoint_paused", "config_malformed"}, + }[str(decision)] + if kind not in coherent: + findings.append(_finding( + "decision_kind_incoherent", + f"{source}: decision {decision!r} cannot carry " + f"completion_kind {kind!r}", + )) + + completed_at = result.get("completed_at_utc") + if state in TERMINAL_FINAL_STATES: + if checkpoint.get("action") == "paused_for_reauthorization": + findings.append(_finding( + "paused_terminal_checkpoint_action", + f"{source}: final_state {state!r} with threshold_checkpoint." + "action still paused_for_reauthorization — reconcile at " + "finalization", + )) + if state == "done" and completed_at: + if decision == "paused" and not _human_outcome_recorded(record): + findings.append(_finding( + "terminal_paused_without_outcome", + f"{source}: paused admission finalized done without a " + "recorded human outcome", + )) + if kind == "checkpoint_paused" and not _checkpoint_resolved(record): + findings.append(_finding( + "terminal_paused_without_outcome", + f"{source}: checkpoint-paused record finalized done " + "without a resumed re-authorization or post-pause " + "human outcome", + )) + if state == "done" and completed_at: + for key in CANONICAL_NUMERIC_AXES: + value = result.get(key) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + findings.append(_finding( + "terminal_missing_actuals", + f"{source}: completed record has no usable result.{key}", + )) + elif state in LIVE_FINAL_STATES and completed_at: + findings.append(_finding( + "paused_terminal_completed", + f"{source}: final_state {state!r} but result.completed_at_utc is " + "set — the run ended without a terminal state", + )) + + started_at = result.get("work_started_at_utc") + if started_at is not None: + started = _parse_utc(started_at) + admission = _parse_utc(record.get("created_at_utc")) + if started is None: + findings.append(_finding( + "actual_minutes_inconsistent", + f"{source}: result.work_started_at_utc {started_at!r} is " + "not a YYYY-MM-DDTHH:MM:SSZ timestamp", + )) + else: + if admission is not None and started < admission: + findings.append(_finding( + "work_started_before_admission", + f"{source}: result.work_started_at_utc {started_at!r} " + f"precedes admission at {record.get('created_at_utc')!r}", + )) + actual_minutes = result.get("actual_minutes") + if ( + completed_at is not None + and isinstance(actual_minutes, int) + and not isinstance(actual_minutes, bool) + ): + try: + derived_minutes = _active_minutes( + record, started_at, completed_at + ) + except ValueError as exc: + findings.append(_finding( + "actual_minutes_inconsistent", + f"{source}: cannot validate result.actual_minutes " + f"against its work clock — {exc}", + )) + else: + if abs(actual_minutes - derived_minutes) > 1: + findings.append(_finding( + "actual_minutes_inconsistent", + f"{source}: result.actual_minutes {actual_minutes} " + f"differs from the work clock ({derived_minutes}) " + "by more than one minute", + )) + + if checkpoint.get("breached") is True and not checkpoint.get("breached_fields"): + findings.append(_finding( + "breached_empty_fields", + f"{source}: threshold_checkpoint.breached is true with an empty " + "breached_fields list", + )) + for list_key in ("breached_fields", "declaration_errors"): + entries = checkpoint.get(list_key) + if not isinstance(entries, list): + continue + for entry in entries: + if str(entry) not in CANONICAL_CHECKPOINT_AXES: + findings.append(_finding( + "noncanonical_checkpoint_axis", + f"{source}: threshold_checkpoint.{list_key} entry " + f"{entry!r} is not a canonical realized-axis name", + )) + + # The breach-basis grammar is enforced on read-back, not only when the + # evaluator writes it: a persisted record is durable ledger evidence, + # and validate/finalize/doctor must not certify an off-vocabulary or + # misplaced basis. A null basis on a breached checkpoint is tolerated — + # records written before the basis existed carry it. + breached = checkpoint.get("breached") is True + breached_fields = checkpoint.get("breached_fields") + declaration_errors = checkpoint.get("declaration_errors") + breached_axes = { + str(entry) + for entries in (breached_fields, declaration_errors) + if isinstance(entries, list) + for entry in entries + } + basis = checkpoint.get("breach_basis") + if basis is not None and basis not in BREACH_BASES: + findings.append(_finding( + "off_enum_breach_basis", + f"{source}: threshold_checkpoint.breach_basis {basis!r} is " + f"off-enum (pinned: {', '.join(BREACH_BASES)})", + )) + elif basis is not None and not breached: + findings.append(_finding( + "breach_basis_incoherent", + f"{source}: threshold_checkpoint.breach_basis {basis!r} on an " + "unbreached checkpoint", + )) + elif basis is not None: + # Mirror the evaluator's breach-source grammar: `declared_intent` + # labels only a prospective task_profile.* correction with no + # realized effect and no materialized risk; `realized` labels only + # realized numeric / side-effect axes. An enum-valid label on the + # opposite source shape is exactly what the evaluator refuses to + # write, so read-back refuses to certify it too. + prospective = sorted( + axis for axis in breached_axes if axis.startswith("task_profile.") + ) + realized_axes = sorted(breached_axes.difference(prospective)) + effects = checkpoint.get("side_effects_actual") + realized_effects = sorted( + str(key) + for key, value in (effects.items() if isinstance(effects, dict) else ()) + if value is True + ) + mismatch: List[str] = [] + if basis == "declared_intent": + if realized_axes: + mismatch.append(f"breached fields carry realized axes {realized_axes}") + if realized_effects: + mismatch.append(f"side_effects_actual realized {realized_effects}") + if checkpoint.get("predicted_risk_materialized") is True: + mismatch.append("predicted_risk_materialized is true") + elif prospective: + mismatch.append( + f"breached fields carry prospective task_profile axes {prospective}" + ) + if mismatch: + findings.append(_finding( + "breach_basis_incoherent", + f"{source}: threshold_checkpoint.breach_basis {basis!r} does not " + "match its breach source — " + "; ".join(mismatch), + )) + sub_basis = checkpoint.get("breach_sub_basis") + if sub_basis is not None and sub_basis not in BREACH_SUB_BASES: + findings.append(_finding( + "off_enum_breach_basis", + f"{source}: threshold_checkpoint.breach_sub_basis {sub_basis!r} " + f"is off-enum (pinned: {', '.join(BREACH_SUB_BASES)})", + )) + elif sub_basis is not None: + misplaced: List[str] = [] + if not breached: + misplaced.append("checkpoint is not breached") + if basis != "realized": + misplaced.append(f"breach_basis is {basis!r}, not realized") + if "actual_minutes" not in breached_axes: + misplaced.append("actual_minutes is not among breached_fields") + if misplaced: + findings.append(_finding( + "breach_basis_incoherent", + f"{source}: threshold_checkpoint.breach_sub_basis " + f"{sub_basis!r} cannot apply — " + "; ".join(misplaced), + )) + + outcome = result.get("human_outcome") + if isinstance(outcome, dict) and outcome.get("recorded") is True: + problems: List[str] = [] + actor = str(outcome.get("actor") or "") + if not actor or any(ch.isspace() for ch in actor): + problems.append("actor must be a non-empty whitespace-free handle") + if outcome.get("decision") not in HUMAN_DECISIONS: + problems.append(f"decision {outcome.get('decision')!r} off-enum") + if _parse_utc(outcome.get("decided_at_utc")) is None: + problems.append("decided_at_utc unparsable") + latency = outcome.get("decision_latency_seconds") + if not isinstance(latency, int) or isinstance(latency, bool) or latency < 0: + problems.append("decision_latency_seconds must be a non-negative int") + grant = outcome.get("grant") + grant_decision = grant.get("decision") if isinstance(grant, dict) else None + if grant_decision not in GRANT_DECISIONS | {"not_recorded"}: + problems.append(f"grant.decision {grant_decision!r} off-enum") + if problems: + findings.append(_finding( + "invalid_human_outcome", + f"{source}: recorded human_outcome invalid — " + + "; ".join(problems), + )) + + return findings + + +def sweep_audit_dir(audit_dir: Path) -> Dict[str, Any]: + """Validate every record in an audit directory plus cross-record checks. + + Returns ``{"records": {filename: [findings]}, "duplicate_groups": + [...]}``. Duplicate logical IDs count only records not yet closed as + ``superseded`` — a superseded stale sibling is the resolved shape. + """ + per_file: Dict[str, List[Dict[str, str]]] = {} + live_by_identity: Dict[Tuple[str, str], List[str]] = {} + parsed: Dict[str, Dict[str, Any]] = {} + for path in sorted(audit_dir.glob("*.yaml")): + try: + record = load_audit_strict(path) + except DuplicateKeyError as exc: + per_file[path.name] = [_finding( + "duplicate_yaml_key", f"{path.name}: {exc}" + )] + continue + except Exception as exc: + per_file[path.name] = [_finding( + "record_unparsable", f"{path.name}: {exc}" + )] + continue + parsed[path.name] = record + per_file[path.name] = validate_audit_record(record, source=path.name) + identity = ( + str(record.get("receiver") or ""), + str(record.get("message_id") or ""), + ) + if all(identity): + state = _result_block(record).get("final_state") + if state != "superseded": + live_by_identity.setdefault(identity, []).append(path.name) + + # Successor chains must resolve strictly: authority transferred to the + # unique same-identity successor that references this predecessor back. + # A well-formed but dangling, self-referential, ambiguous, or + # unrelated-identity successor id is the same orphan shape as a + # missing one. + ids_by_value: Dict[str, List[str]] = {} + for name, record in parsed.items(): + evaluation_id = record.get("evaluation_id") + if isinstance(evaluation_id, str) and evaluation_id: + ids_by_value.setdefault(evaluation_id, []).append(name) + for name, record in parsed.items(): + if _result_block(record).get("final_state") != "superseded": + continue + successor = record.get("superseded_by_evaluation_id") + if not isinstance(successor, str) or not _EVALUATION_ID_RE.fullmatch(successor): + continue # flagged by validate_audit_record already + holders = [ + parsed[holder] + for holder in ids_by_value.get(successor, []) + if holder != name + ] + link_error = _successor_link_error(record, holders, successor) + if link_error: + per_file.setdefault(name, []).append(_finding( + "superseded_missing_successor", f"{name}: {link_error}" + )) + + duplicate_groups: List[Dict[str, Any]] = [] + for (receiver, message_id), names in sorted(live_by_identity.items()): + if len(names) < 2: + continue + duplicate_groups.append({ + "receiver": receiver, + "message_id": message_id, + "files": names, + }) + for name in names: + per_file.setdefault(name, []).append(_finding( + "duplicate_logical_id", + f"{name}: {len(names)} live evaluations for " + f"({receiver}, {message_id}) — supersede the stale ones", + )) + return {"records": per_file, "duplicate_groups": duplicate_groups} + + +def _build_actuals( + args: argparse.Namespace, + record: Dict[str, Any], + *, + measured_at_utc: str, +) -> Dict[str, Any]: + if args.actuals is not None: + data = yaml.safe_load(args.actuals.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"{args.actuals} must contain a YAML mapping") + actuals = dict(data) + else: + actuals = {} + if args.actual_minutes is not None: + actuals["actual_minutes"] = args.actual_minutes + if args.actual_files_touched is not None: + actuals["actual_files_touched"] = args.actual_files_touched + if args.realized: + actuals["side_effects_actual"] = {key: True for key in args.realized} + if args.completed_at: + actuals["completed_at_utc"] = args.completed_at + if args.predicted_risk_materialized is not None: + actuals["predicted_risk_materialized"] = ( + args.predicted_risk_materialized == "true" + ) + + if args.started_at is not None: + existing = actuals.get("work_started_at_utc") + if existing is not None and existing != args.started_at: + raise ValueError( + "--started-at conflicts with actuals.work_started_at_utc" + ) + actuals["work_started_at_utc"] = args.started_at + + started_at = actuals.get("work_started_at_utc") + if started_at is not None: + started = _parse_utc(started_at) + admission = _parse_utc(record.get("created_at_utc")) + if started is None: + raise ValueError("--started-at must use YYYY-MM-DDTHH:MM:SSZ") + if admission is not None and started < admission: + raise ValueError( + "work_started_at_utc precedes the admission decision " + f"({record.get('created_at_utc')})" + ) + if "actual_minutes" not in actuals: + completed_at = actuals.get("completed_at_utc") or measured_at_utc + actuals["actual_minutes"] = _active_minutes( + record, started_at, completed_at + ) + return actuals + + +def _live_siblings( + audit_path: Path, record: Dict[str, Any] +) -> List[str]: + """Names of other non-superseded records sharing this logical identity.""" + receiver = str(record.get("receiver") or "") + message_id = str(record.get("message_id") or "") + if not receiver or not message_id: + return [] + names: List[str] = [] + for path in sorted(audit_path.parent.glob("*.yaml")): + if path.name == audit_path.name: + continue + try: + sibling = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + continue + if not isinstance(sibling, dict): + continue + if ( + str(sibling.get("receiver") or "") == receiver + and str(sibling.get("message_id") or "") == message_id + and _result_block(sibling).get("final_state") != "superseded" + ): + names.append(path.name) + return names + + +def _predecessor_evaluation_id(record: Dict[str, Any]) -> str: + """The id a successor must reference — on-disk value or the same + deterministic identity ``_ensure_evaluation_id`` would stamp.""" + return str( + record.get("evaluation_id") + or evaluation_identity( + str(record.get("receiver") or ""), + str(record.get("message_id") or ""), + str(record.get("message_sha256") or ""), + str(record.get("created_at_utc") or ""), + ) + ) + + +def _successor_link_error( + predecessor: Dict[str, Any], + holders: List[Dict[str, Any]], + successor_id: str, +) -> Optional[str]: + """One strict cross-record resolver for finalization and the sweep. + + Authority must transfer to the unique successor of the same logical + identity, and that successor must reference this predecessor back + through ``supersedes_evaluation_id`` or ``superseded_evaluation_ids``. + A record that merely carries the id — unrelated identity, ambiguous + holders, no back-reference — is the same orphan shape as a missing + successor. + """ + if not holders: + return ( + f"superseded_by_evaluation_id {successor_id} does not resolve " + "to any other evaluation in this directory" + ) + if len(holders) > 1: + return ( + f"superseded_by_evaluation_id {successor_id} is ambiguous — " + f"{len(holders)} records carry it" + ) + successor = holders[0] + pred_identity = ( + str(predecessor.get("receiver") or ""), + str(predecessor.get("message_id") or ""), + ) + succ_identity = ( + str(successor.get("receiver") or ""), + str(successor.get("message_id") or ""), + ) + if pred_identity != succ_identity: + return ( + f"successor {successor_id} belongs to a different logical " + "identity — authority transfers only within the same " + "(receiver, message_id)" + ) + pred_id = _predecessor_evaluation_id(predecessor) + listed = successor.get("superseded_evaluation_ids") + if successor.get("supersedes_evaluation_id") != pred_id and not ( + isinstance(listed, list) and pred_id in listed + ): + return ( + f"successor {successor_id} does not reference this evaluation " + f"({pred_id}) through supersedes_evaluation_id or " + "superseded_evaluation_ids" + ) + return None + + +def _resolve_successor( + audit_path: Path, record: Dict[str, Any], successor_id: str +) -> Optional[str]: + """Error detail when *successor_id* fails strict resolution, else None. + + Discovery is permissive (locating which files claim the id), but only + strict bytes serve as successor evidence: a candidate that fails the + duplicate-key-refusing loader is refused outright — a permissively + loaded duplicate-key record would let whichever duplicate value the + parser kept decide the back-reference. + """ + holders: List[Dict[str, Any]] = [] + for path in sorted(audit_path.parent.glob("*.yaml")): + if path.name == audit_path.name: + continue + try: + candidate = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + continue + if not ( + isinstance(candidate, dict) + and candidate.get("evaluation_id") == successor_id + ): + continue + try: + holders.append(load_audit_strict(path)) + except Exception as exc: + return ( + f"candidate successor {path.name} carrying {successor_id} " + f"has ambiguous or unreadable bytes ({exc}) — repair it " + "before superseding onto it" + ) + return _successor_link_error(record, holders, successor_id) + + +def _ensure_evaluation_id(record: Dict[str, Any]) -> None: + if record.get("evaluation_id"): + return + record["evaluation_id"] = evaluation_identity( + str(record.get("receiver") or ""), + str(record.get("message_id") or ""), + str(record.get("message_sha256") or ""), + str(record.get("created_at_utc") or ""), + ) + + +def _grant_result(record: Dict[str, Any]) -> Dict[str, Any]: + grant = record.get("continuation_grant") + return grant if isinstance(grant, dict) else {} + + +def apply_checkpoint( + record: Dict[str, Any], + actuals: Dict[str, Any], + *, + now_utc: Optional[str] = None, + allow_closed: bool = False, +) -> Tuple[Dict[str, Any], bool]: + """Evaluate and record a §E checkpoint in place; return (record, paused). + + On an unresolved breach the record becomes the in-place + checkpoint-paused shape the outcome recorder expects: + ``completion_kind: checkpoint_paused`` (the checkpoint evaluation is + what updated the result block — this is the evaluator's kind for it, + not a receiver-composed value), ``final_state: paused``, and a + ``paused_at_utc`` stamp. A breach arbitrated to ``resumed`` keeps the + admission kind and run state, mirroring the evaluator's own resumed + decision shape. Admission-time fields (decision, reason_codes, + co_occurring_reason_codes, admission_axes, breached) stay untouched as + history — checkpoint reasons live in ``result.threshold_checkpoint`` + and never replace admission reasons. + + A closed record (completion evidence present, or superseded) is + refused: a mid-task checkpoint must never reopen terminal state. + ``allow_closed`` exists solely for the finalizer's explicit + ``--replace`` correction path. + """ + if _is_closed(record) and not allow_closed: + raise ValueError( + "record is already closed (completed_at_utc set or superseded); " + "a checkpoint cannot reopen terminal state" + ) + updated = copy.deepcopy(record) + envelope = updated.get("scope_envelope") + if not isinstance(envelope, dict): + raise ValueError( + "record carries no scope_envelope; nothing to checkpoint against" + ) + checkpoint = evaluate_threshold_checkpoint( + envelope, _grant_result(updated), actuals + ) + result = updated.setdefault("result", {}) + breached = checkpoint.get("breached") is True + reauth = checkpoint.get("reauthorization") + resumed = isinstance(reauth, dict) and reauth.get("disposition") == "resumed" + if breached and not resumed: + if not checkpoint.get("paused_at_utc"): + checkpoint["paused_at_utc"] = now_utc or utc_now_iso() + result["completion_kind"] = "checkpoint_paused" + result["final_state"] = "paused" + result["threshold_checkpoint"] = checkpoint + result["actual_minutes"] = checkpoint.get("actual_minutes") + result["actual_files_touched"] = checkpoint.get("actual_files_touched") + result["predicted_risk_materialized"] = bool( + checkpoint.get("predicted_risk_materialized", False) + ) + if "work_started_at_utc" in actuals: + result["work_started_at_utc"] = actuals["work_started_at_utc"] + return updated, breached and not resumed + + +def _reconcile_resolved_checkpoint( + updated: Dict[str, Any], actuals: Dict[str, Any] +) -> None: + """Carry a resolved checkpoint to terminal shape without re-arbitrating. + + A recorded re-authorization is an answered pause; re-evaluating it from + terminal actuals that lack the original ``reauthorization`` input would + clobber the recorded answer and re-pause an already-resolved breach. + But an answered pause covers only what was paused and granted — every + final axis is still compared against the resolved scope, and any new + expansion refuses terminal reconciliation: a realized effect not + covered by the envelope, an accepted grant, the recorded + re-authorization scope, or the answered pause itself, or a numeric + beyond a scoped re-authorization budget, must go through a fresh + ``--checkpoint`` (with re-authorization input) before the record can + finalize. Within scope, numerics update to the final measurements, the + complete terminal ``side_effects_actual`` map is persisted, and a + still-paused action reconciles to ``resumed_after_reauthorization`` + (the six paused-terminal records in the audit corpora are exactly this + missed reconciliation). + """ + result = updated.setdefault("result", {}) + checkpoint = result.setdefault("threshold_checkpoint", {}) + envelope = updated.get("scope_envelope") + envelope = envelope if isinstance(envelope, dict) else {} + grant = _grant_result(updated) + grant_scope = grant.get("scope") if grant.get("decision") == "accepted" else None + grant_scope = grant_scope if isinstance(grant_scope, dict) else {} + reauth = checkpoint.get("reauthorization") + reauth = reauth if isinstance(reauth, dict) else {} + reauth_scope = reauth.get("scope") + reauth_scope = reauth_scope if isinstance(reauth_scope, dict) else {} + paused_effects = checkpoint.get("side_effects_actual") + paused_effects = paused_effects if isinstance(paused_effects, dict) else {} + + final_effects = _actual_side_effects(actuals) + raw_effects = actuals.get("side_effects_actual") or {} + uncovered = sorted( + key + for key, realized in final_effects.items() + if realized + and envelope.get(key) is not True + and grant_scope.get(key) is not True + and reauth_scope.get(key) is not True + and paused_effects.get(key) is not True + ) + if uncovered: + fields = ", ".join(f"side_effects_actual.{key}" for key in uncovered) + raise ValueError( + f"terminal actuals realize {fields} beyond the resolved " + "re-authorization — record a fresh checkpoint (--checkpoint " + "with the new actuals and re-authorization input) before " + "finalizing" + ) + # Realized effects are monotonic evidence: a true checkpoint value can + # never become false at finalization, and terminal actuals that + # explicitly claim so are contradictory under-reporting. + contradicted = sorted( + key + for key, prior_true in paused_effects.items() + if prior_true is True and raw_effects.get(key) is False + ) + if contradicted: + fields = ", ".join(f"side_effects_actual.{key}" for key in contradicted) + raise ValueError( + f"terminal actuals declare {fields} false but the checkpoint " + "recorded it realized — realized effects are monotonic evidence" + ) + # Numeric bounds follow the answer's consumption shape: a scoped + # re-authorization budget stands for the rest of the task up to the + # budget; a scope-less approval authorizes exactly the extent recorded + # at the pause it answered and cannot waive later growth. + numeric_budgets = { + "actual_minutes": reauth_scope.get("max_actual_minutes"), + "actual_files_touched": reauth_scope.get("max_actual_files_touched"), + } + for key, budget in numeric_budgets.items(): + bound = budget if isinstance(budget, int) else checkpoint.get(key) + basis = ( + "re-authorized budget" + if isinstance(budget, int) + else "extent the scope-less approval cleared" + ) + final_value = actuals.get(key) + if ( + isinstance(bound, int) + and isinstance(final_value, int) + and not isinstance(final_value, bool) + and final_value > bound + ): + raise ValueError( + f"terminal {key} {final_value} exceeds the {basis} " + f"({bound}) — record a fresh checkpoint before finalizing" + ) + + for key in CANONICAL_NUMERIC_AXES: + if key in actuals: + checkpoint[key] = actuals[key] + result[key] = actuals[key] + merged_effects = { + key: bool(paused_effects.get(key)) or bool(final_effects.get(key)) + for key in {*paused_effects, *final_effects} + } + checkpoint["side_effects_actual"] = merged_effects + if checkpoint.get("action") == "paused_for_reauthorization": + checkpoint["action"] = "resumed_after_reauthorization" + + +def finalize_audit_record( + audit_path: Path, + record: Dict[str, Any], + *, + final_state: str, + actuals: Dict[str, Any], + reply_message_id: Optional[str] = None, + artifacts: Optional[Sequence[str]] = None, + superseded_by: Optional[str] = None, + replace: bool = False, + now_utc: Optional[str] = None, +) -> Tuple[Dict[str, Any], bool]: + """Return the finalized record; raises on any integrity violation. + + The second return value is True when a terminal checkpoint breached + and the record was left checkpoint-paused instead of terminal. + """ + if final_state not in TERMINAL_FINAL_STATES: + choices = ", ".join(sorted(TERMINAL_FINAL_STATES)) + raise ValueError(f"final_state must be one of: {choices}") + schema = record.get("schema_version") + if schema not in {1, AUTONOMY_AUDIT_SCHEMA_VERSION}: + raise ValueError("audit.schema_version must be 1 or 2") + result = _result_block(record) + kind = result.get("completion_kind") + current_state = result.get("final_state") + if superseded_by is not None and final_state != "superseded": + raise ValueError("--superseded-by is valid only with final_state superseded") + if final_state == "superseded": + # The supersession repair is the documented exit for records this + # module otherwise refuses to touch — off-enum legacy vocabulary, + # closed history — so it bypasses the enum and closed guards. The + # two invariants it keeps: never re-supersede (the chain would + # fork), and always name the successor that inherited authority + # (an orphan supersession is invisible to doctor and + # envelope-clear for no benefit). + if current_state == "superseded": + raise ValueError("record is already superseded") + if not isinstance(superseded_by, str) or not _EVALUATION_ID_RE.fullmatch( + superseded_by + ): + raise ValueError( + "final_state superseded requires --superseded-by naming the " + "successor evaluation_id (eval-<16 hex>)" + ) + link_error = _resolve_successor(audit_path, record, superseded_by) + if link_error: + # Authority must transfer TO the real replacement: a record + # that merely carries the id — or none at all — is an orphan + # chain, invisible to doctor and envelope-clear for no benefit. + raise ValueError( + f"--superseded-by refused: {link_error} — record the " + "successor (same receiver and message_id, referencing this " + "evaluation through supersedes_evaluation_id or " + "superseded_evaluation_ids) first" + ) + else: + if kind not in PINNED_COMPLETION_KINDS: + raise ValueError( + f"refusing to finalize: completion_kind {kind!r} is off-enum " + "— supersede this record instead of finalizing onto it" + ) + if current_state not in VALID_FINAL_STATES: + raise ValueError( + f"refusing to finalize: final_state {current_state!r} is " + "off-enum — supersede this record instead" + ) + if _is_closed(record) and not replace: + raise ValueError( + "record is already closed (completed_at_utc set or " + "superseded); use --replace only for a deliberate correction" + ) + + updated = copy.deepcopy(record) + if final_state == "superseded" and current_state not in VALID_FINAL_STATES: + # Preserve the legacy off-enum run state as history — supersession + # overwrites final_state, and the original value is part of what + # the repair is documenting. + updated.setdefault("result", {})["legacy_final_state"] = current_state + if final_state == "done": + if record.get("decision") == "paused" and not _human_outcome_recorded(record): + raise ValueError( + "terminal ⇒ not paused: a paused admission needs a recorded " + "human outcome (oacp autonomy-outcome) before it can be done" + ) + siblings = _live_siblings(audit_path, record) + if siblings: + raise ValueError( + "duplicate logical id: live sibling evaluation(s) " + f"{', '.join(siblings)} — finalize the stale ones as " + "superseded first" + ) + prior_breach = _checkpoint_block(record).get("breached") is True + if prior_breach: + if not _checkpoint_resolved(record): + raise ValueError( + "terminal ⇒ not paused: the breached checkpoint has no " + "resumed re-authorization or post-pause human outcome" + ) + _reconcile_resolved_checkpoint(updated, actuals) + elif isinstance(updated.get("scope_envelope"), dict): + # Terminal checkpoint parity: every envelope-bearing record gets + # a checkpoint evaluation at finalization, so coverage no longer + # depends on which receiver wrote the record. + updated, checkpoint_paused = apply_checkpoint( + updated, actuals, now_utc=now_utc, allow_closed=replace + ) + if checkpoint_paused: + return updated, True + else: + result_block = updated.setdefault("result", {}) + for key in CANONICAL_NUMERIC_AXES: + if key in actuals: + result_block[key] = actuals[key] + predicted = actuals.get("predicted_risk_materialized") + if isinstance(predicted, bool): + result_block["predicted_risk_materialized"] = predicted + for key in CANONICAL_NUMERIC_AXES: + value = updated["result"].get(key) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError( + f"finalizing done requires a non-negative integer {key}" + ) + + result_block = updated.setdefault("result", {}) + if "work_started_at_utc" in actuals: + result_block["work_started_at_utc"] = actuals["work_started_at_utc"] + completed = ( + actuals.get("completed_at_utc") + or result_block.get("completed_at_utc") + or now_utc + or utc_now_iso() + ) + if _parse_utc(completed) is None: + raise ValueError("completed_at_utc must use YYYY-MM-DDTHH:MM:SSZ") + result_block["final_state"] = final_state + result_block["completed_at_utc"] = completed + checkpoint = result_block.get("threshold_checkpoint") + if isinstance(checkpoint, dict) and checkpoint.get("completed_at_utc") is None: + checkpoint["completed_at_utc"] = completed + if final_state != "done": + for key in CANONICAL_NUMERIC_AXES: + if key in actuals: + result_block[key] = actuals[key] + if reply_message_id is not None: + result_block["reply_message_id"] = reply_message_id + else: + result_block.setdefault("reply_message_id", None) + if artifacts: + result_block["artifacts"] = [str(item) for item in artifacts] + else: + result_block.setdefault("artifacts", []) + _ensure_evaluation_id(updated) + if superseded_by is not None: + updated["superseded_by_evaluation_id"] = superseded_by + + residual = [ + finding + for finding in validate_audit_record(updated, source=audit_path.name) + if finding["severity"] == "error" + ] + if residual: + details = "; ".join(finding["detail"] for finding in residual) + raise ValueError(f"finalized record fails validation: {details}") + return updated, False + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("audit_file", type=Path) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument( + "--final-state", choices=sorted(TERMINAL_FINAL_STATES) + ) + mode.add_argument( + "--checkpoint", + action="store_true", + help="record a mid-task §E checkpoint evaluation instead of a terminal state", + ) + mode.add_argument( + "--validate", + action="store_true", + help="report integrity findings for the record (or its directory) and write nothing", + ) + parser.add_argument("--actuals", type=Path, help="§E actuals mapping (YAML)") + parser.add_argument("--actual-minutes", type=int) + parser.add_argument("--actual-files-touched", type=int) + parser.add_argument( + "--started-at", + help=( + "receiver work start (YYYY-MM-DDTHH:MM:SSZ); derives " + "actual_minutes when --actual-minutes is omitted" + ), + ) + parser.add_argument( + "--realized", + action="append", + choices=sorted(COVERABLE_CONTINUATION_FIELDS), + help="side effect actually performed (repeatable)", + ) + parser.add_argument("--reply-message-id") + parser.add_argument("--artifact", action="append", dest="artifacts") + parser.add_argument("--completed-at") + parser.add_argument( + "--predicted-risk-materialized", choices=("true", "false"), default=None + ) + parser.add_argument( + "--superseded-by", + help="evaluation_id of the superseding evaluation (final_state superseded)", + ) + parser.add_argument( + "--sweep", + action="store_true", + help="with --validate: sweep the record's whole directory", + ) + parser.add_argument("--replace", action="store_true") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + try: + if args.validate: + if args.sweep: + report = sweep_audit_dir(args.audit_file.parent if args.audit_file.is_file() else args.audit_file) + else: + findings = validate_audit_record( + load_audit_strict(args.audit_file), + source=args.audit_file.name, + ) + report = {"records": {args.audit_file.name: findings}} + errors = sum( + 1 + for findings in report["records"].values() + for finding in findings + if finding["severity"] == "error" + ) + if args.json: + print(json.dumps(report, indent=2)) + else: + for name, findings in sorted(report["records"].items()): + for finding in findings: + print(f"{finding['severity'].upper()}: {finding['detail']}") + print(f"{errors} error finding(s)") + return 2 if errors else 0 + + with locked_audit(args.audit_file): + record = load_audit_strict(args.audit_file) + measured_at_utc = args.completed_at or utc_now_iso() + actuals = _build_actuals( + args, record, measured_at_utc=measured_at_utc + ) + if args.checkpoint: + updated, paused = apply_checkpoint( + record, actuals, now_utc=measured_at_utc + ) + else: + updated, paused = finalize_audit_record( + args.audit_file, + record, + final_state=args.final_state, + actuals=actuals, + reply_message_id=args.reply_message_id, + artifacts=args.artifacts, + superseded_by=args.superseded_by, + replace=args.replace, + now_utc=measured_at_utc, + ) + if not args.dry_run: + atomic_replace_yaml(args.audit_file, updated) + + result = updated.get("result", {}) + checkpoint = result.get("threshold_checkpoint", {}) + summary = { + "audit_file": str(args.audit_file), + "dry_run": args.dry_run, + "final_state": result.get("final_state"), + "completion_kind": result.get("completion_kind"), + "evaluation_id": updated.get("evaluation_id"), + "checkpoint_action": ( + checkpoint.get("action") if isinstance(checkpoint, dict) else None + ), + } + if args.json: + print(json.dumps(summary, indent=2)) + elif paused: + print( + f"CHECKPOINT PAUSED: {args.audit_file} — " + "Blocked: autonomy threshold exceeded; notify the sender and " + "re-authorize per the §E flow before finalizing" + ) + else: + print( + f"OK: {args.audit_file} — {summary['final_state']} " + f"({summary['completion_kind']})" + ) + return 4 if paused else 0 + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gen_readme_commands.py b/scripts/gen_readme_commands.py new file mode 100644 index 0000000..169fef3 --- /dev/null +++ b/scripts/gen_readme_commands.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Generate the README command table from the CLI help text. + +The ``## Commands`` table in ``README.md`` is generated from the ``Commands:`` +block of ``oacp.cli.HELP_TEXT`` (what ``oacp --help`` prints) and lives +between two HTML-comment markers. ``tests/test_readme_commands.py`` fails +when the two drift. + + make docs # regenerate in place + python3 scripts/gen_readme_commands.py # print the generated block + python3 scripts/gen_readme_commands.py --check # exit 1 on drift +""" + +from __future__ import annotations + +import argparse +import difflib +import re +import sys +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + +REPO_ROOT = Path(__file__).resolve().parent.parent +README_PATH = REPO_ROOT / "README.md" + +BEGIN_MARKER = ( + "" +) +END_MARKER = "" + +Command = Tuple[str, str] + + +def load_help_text() -> str: + """Return ``HELP_TEXT`` from this checkout, never from an installed wheel.""" + root = str(REPO_ROOT) + if root not in sys.path: + sys.path.insert(0, root) + from oacp.cli import HELP_TEXT + + return HELP_TEXT + + +def parse_commands(help_text: str) -> List[Command]: + """Return ``(name, description)`` pairs from the ``Commands:`` block. + + The block starts at the ``Commands:`` line and ends at the first blank + line or non-indented line after it (the next section header). Each + entry is an indented name, two or more spaces, then its description. + """ + commands: List[Command] = [] + in_block = False + for line in help_text.splitlines(): + if not in_block: + in_block = line.strip() == "Commands:" + continue + if not line.strip() or not line.startswith(" "): + break + parts = re.split(r"\s{2,}", line.strip(), maxsplit=1) + if len(parts) != 2: + raise ValueError(f"cannot parse command line: {line!r}") + commands.append((parts[0], parts[1])) + if not commands: + raise ValueError("no Commands: block found in help text") + return commands + + +def render_table(commands: Sequence[Command]) -> str: + rows = ["| Command | Description |", "|---------|-------------|"] + for name, description in commands: + cell = description.replace("|", "\\|") + rows.append(f"| `oacp {name}` | {cell} |") + return "\n".join(rows) + + +def render_block(commands: Sequence[Command]) -> str: + return "\n".join([BEGIN_MARKER, render_table(commands), END_MARKER]) + + +def extract_block(readme_text: str) -> str: + """Return the marker-delimited block (markers included) from README text.""" + start = readme_text.find(BEGIN_MARKER) + end = readme_text.find(END_MARKER, start if start >= 0 else 0) + if start < 0 or end < 0: + raise ValueError( + "README is missing the generated-commands markers " + f"({BEGIN_MARKER!r} ... {END_MARKER!r})" + ) + return readme_text[start : end + len(END_MARKER)] + + +def replace_block(readme_text: str, block: str) -> str: + return readme_text.replace(extract_block(readme_text), block, 1) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Generate the README command table from oacp --help." + ) + parser.add_argument( + "--readme", + type=Path, + default=README_PATH, + help="README to read/update (default: the repo README.md)", + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--write", action="store_true", help="rewrite the README block in place" + ) + mode.add_argument( + "--check", + action="store_true", + help="exit 1 with a diff when the README block is behind the help text", + ) + args = parser.parse_args(argv) + + commands = parse_commands(load_help_text()) + expected = render_block(commands) + + if not (args.write or args.check): + print(expected) + return 0 + + readme_text = args.readme.read_text(encoding="utf-8") + current = extract_block(readme_text) + if current == expected: + print( + f"OK: README command table matches oacp --help ({len(commands)} commands)" + ) + return 0 + + if args.write: + args.readme.write_text(replace_block(readme_text, expected), encoding="utf-8") + print(f"Regenerated README command table ({len(commands)} commands)") + return 0 + + sys.stdout.writelines( + difflib.unified_diff( + current.splitlines(keepends=True), + expected.splitlines(keepends=True), + fromfile=f"{args.readme.name} (current)", + tofile="oacp --help (expected)", + ) + ) + print( + "DRIFT: README command table is behind oacp --help; run `make docs`", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/init_org_memory.py b/scripts/init_org_memory.py index 7f74375..bdc1d3e 100644 --- a/scripts/init_org_memory.py +++ b/scripts/init_org_memory.py @@ -9,6 +9,7 @@ decisions.md rules.md events/ + debriefs/ Usage: init_org_memory.py [--oacp-dir ] @@ -59,16 +60,26 @@ def initialize_org_memory(oacp_root: Path) -> dict: """ org_memory_dir = oacp_root / "org-memory" events_dir = org_memory_dir / "events" + debriefs_dir = org_memory_dir / "debriefs" # Create directories org_memory_dir.mkdir(parents=True, exist_ok=True) events_dir.mkdir(parents=True, exist_ok=True) + debriefs_dir.mkdir(parents=True, exist_ok=True) template_dir = _find_template_dir() created = [] skipped = [] + # .gitkeep so the empty debriefs/ tree survives git-based memory sync + gitkeep = debriefs_dir / ".gitkeep" + if gitkeep.exists(): + skipped.append("debriefs/.gitkeep") + else: + gitkeep.write_text("", encoding="utf-8") + created.append("debriefs/.gitkeep") + for filename in _TEMPLATE_FILES: target = org_memory_dir / filename if target.exists(): diff --git a/scripts/init_packet.sh b/scripts/init_packet.sh index a5d48bf..3e79bad 100755 --- a/scripts/init_packet.sh +++ b/scripts/init_packet.sh @@ -28,7 +28,7 @@ TEMPLATE_DIR="$SCRIPT_DIR/../templates" if [[ ! -d "$PROJECT_ROOT" ]]; then echo "Error: project workspace not found: $PROJECT_ROOT" - echo "Run: $SCRIPT_DIR/init_project_workspace.sh $PROJECT_NAME" + echo "Run: oacp init $PROJECT_NAME" exit 2 fi diff --git a/scripts/init_project_workspace.py b/scripts/init_project_workspace.py index e00a103..aa60553 100644 --- a/scripts/init_project_workspace.py +++ b/scripts/init_project_workspace.py @@ -12,7 +12,14 @@ import sys from typing import Dict, List, Optional, Sequence, Tuple -from _oacp_constants import AGENT_RE, SPEC_VERSION, _template_path, _write_if_missing +from _oacp_constants import ( + AGENT_RE, + CREATABLE_RUNTIMES, + SPEC_VERSION, + _template_path, + _write_if_missing, +) +from agent_profile import upsert_global_profile DEFAULT_AGENTS = ("claude", "codex", "cursor") @@ -201,6 +208,13 @@ def initialize_workspace( receiver_config = _receiver_config_template() for agent in agents: _write_if_missing(project_root / "agents" / agent / "config.yaml", receiver_config) + runtime = agent if agent in CREATABLE_RUNTIMES else "unknown" + upsert_global_profile( + oacp_root, + agent, + runtime, + projects=[project_name], + ) workspace_path = project_root / "workspace.json" now = dt.datetime.now(dt.timezone.utc).isoformat() diff --git a/scripts/init_project_workspace.sh b/scripts/init_project_workspace.sh deleted file mode 100755 index 5bdfe0a..0000000 --- a/scripts/init_project_workspace.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -exec python3 "$SCRIPT_DIR/init_project_workspace.py" "$@" diff --git a/scripts/normalize_findings.py b/scripts/normalize_findings.py deleted file mode 100755 index c563179..0000000 --- a/scripts/normalize_findings.py +++ /dev/null @@ -1,635 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 -"""normalize_findings.py — Convert raw agent QA output into canonical findings YAML. - -Accepts structured JSON or unstructured plain-text input and produces a findings -packet that conforms to the canonical format defined in -templates/findings_packet.template.yaml. - -Usage: - normalize_findings.py --input --format json|text --packet-id - [--reviewer ] [--round ] [--output ] - normalize_findings.py --help - -Input formats: - json — A JSON object or array. If an object, looks for a "findings" key - containing a list of finding objects. If an array, treats each - element as a finding. Each finding object should have at minimum - a description/summary/message field plus optional severity, - blocking, status, file, line, area fields. - - text — Unstructured review comments (one finding per paragraph, separated - by blank lines). Lines starting with "P0"–"P3" or containing - severity markers are parsed for severity. Everything else defaults - to P2/non-blocking/open. - -Output: - Canonical findings YAML written to stdout (or --output file). - -Exit codes: - 0 — Success - 1 — Validation failure (output would be malformed) - 2 — Usage error / bad input - -Stdlib-only (no external dependencies). -""" - -import json -import os -import re -import sys -from datetime import datetime, timezone - - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -VALID_SEVERITIES = {"P0", "P1", "P2", "P3"} -VALID_STATUSES = {"open", "fixed", "wont_fix"} -VALID_AREAS = { - "code", - "test", - "docs", - "config", - "infra", - "security", - "performance", - "other", -} - -DEFAULT_SEVERITY = "P2" -DEFAULT_STATUS = "open" -DEFAULT_BLOCKING = False -DEFAULT_AREA = "code" - - -# --------------------------------------------------------------------------- -# JSON input parser -# --------------------------------------------------------------------------- - - -def parse_json_input(raw_text): - """Parse structured JSON input into a list of finding dicts.""" - try: - data = json.loads(raw_text) - except json.JSONDecodeError as exc: - print(f"Error: invalid JSON input: {exc}", file=sys.stderr) - sys.exit(2) - - findings_raw = [] - - if isinstance(data, list): - findings_raw = data - elif isinstance(data, dict): - # Try common keys agents might use - for key in ("findings", "issues", "comments", "results", "items"): - if key in data and isinstance(data[key], list): - findings_raw = data[key] - break - if not findings_raw: - # Single finding wrapped in an object - findings_raw = [data] - else: - print("Error: JSON input must be an object or array", file=sys.stderr) - sys.exit(2) - - findings = [] - for i, item in enumerate(findings_raw): - if not isinstance(item, dict): - print(f"Warning: skipping non-object finding at index {i}", file=sys.stderr) - continue - findings.append(_normalize_finding_dict(item, i + 1)) - - return findings - - -def _normalize_finding_dict(raw, index): - """Map a raw JSON finding dict to canonical fields.""" - # Extract description from various possible field names - description = "" - for key in ( - "description", - "summary", - "message", - "text", - "body", - "detail", - "finding", - "comment", - "recommendation", - ): - if key in raw and raw[key]: - description = str(raw[key]).strip() - break - - # Severity - severity = _extract_severity(raw.get("severity", "")) - if not severity: - # Try to infer from description - severity = _infer_severity_from_text(description) or DEFAULT_SEVERITY - - # Blocking - blocking = raw.get("blocking", DEFAULT_BLOCKING) - if isinstance(blocking, str): - blocking = blocking.lower() in ("true", "yes", "1") - - # Status - status = str(raw.get("status", DEFAULT_STATUS)).lower().strip() - if status not in VALID_STATUSES: - status = DEFAULT_STATUS - - # Area - area = str(raw.get("area", raw.get("category", DEFAULT_AREA))).lower().strip() - if area not in VALID_AREAS: - area = DEFAULT_AREA - - # File and line - file_path = raw.get("file", raw.get("path", raw.get("filename", ""))) - line = raw.get("line", raw.get("line_number", raw.get("lineno"))) - if line is not None: - try: - line = int(line) - except (ValueError, TypeError): - line = None - - # Evidence / repro / expected / recommendation - evidence = str(raw.get("evidence", raw.get("snippet", ""))).strip() - repro = str(raw.get("repro", raw.get("reproduction", raw.get("steps", "")))).strip() - expected = str(raw.get("expected", raw.get("expected_behavior", ""))).strip() - recommendation = str( - raw.get("recommendation", raw.get("suggestion", raw.get("fix", ""))) - ).strip() - - # If recommendation is empty but description was from a different field, check for recommendation - if not recommendation and description != str(raw.get("recommendation", "")).strip(): - recommendation = "" - - return { - "id": f"F-{index:03d}", - "severity": severity, - "blocking": blocking, - "status": status, - "area": area, - "file": str(file_path) if file_path else "", - "line": line, - "description": description, - "repro": repro, - "expected": expected, - "evidence": evidence, - "recommendation": recommendation, - } - - -# --------------------------------------------------------------------------- -# Plain-text input parser -# --------------------------------------------------------------------------- - - -def parse_text_input(raw_text): - """Parse unstructured plain-text review comments into findings.""" - # Split into paragraphs (separated by blank lines) - paragraphs = re.split(r"\n\s*\n", raw_text.strip()) - - findings = [] - for i, para in enumerate(paragraphs): - para = para.strip() - if not para: - continue - - finding = _parse_text_paragraph(para, i + 1) - if finding: - findings.append(finding) - - return findings - - -def _parse_text_paragraph(text, index): - """Parse a single paragraph into a finding dict.""" - lines = text.strip().splitlines() - if not lines: - return None - - first_line = lines[0].strip() - - # Try to extract severity from the beginning of the paragraph - severity = _infer_severity_from_text(first_line) - if not severity: - severity = DEFAULT_SEVERITY - - # Try to extract file:line references - file_path = "" - line_num = None - file_match = re.search(r"(?:^|\s)([a-zA-Z0-9_./-]+\.[a-zA-Z0-9]+)(?::(\d+))?", text) - if file_match: - candidate = file_match.group(1) - # Filter out things that don't look like file paths - if "/" in candidate or candidate.count(".") == 1: - file_path = candidate - if file_match.group(2): - line_num = int(file_match.group(2)) - - # Detect blocking signals - blocking = _infer_blocking(text, severity) - - # Clean up description: strip leading severity markers - description = re.sub( - r"^\s*\[?P[0-3]\]?\s*[-:.]?\s*", "", first_line, flags=re.IGNORECASE - ).strip() - if len(lines) > 1: - rest = "\n".join(item_line.strip() for item_line in lines[1:]).strip() - description = f"{description}\n{rest}" if description else rest - - return { - "id": f"F-{index:03d}", - "severity": severity, - "blocking": blocking, - "status": DEFAULT_STATUS, - "area": DEFAULT_AREA, - "file": file_path, - "line": line_num, - "description": description, - "repro": "", - "expected": "", - "evidence": "", - "recommendation": "", - } - - -# --------------------------------------------------------------------------- -# Severity / blocking inference helpers -# --------------------------------------------------------------------------- - - -def _extract_severity(value): - """Normalize a severity string to P0-P3 or None.""" - if not value: - return None - s = str(value).upper().strip() - if s in VALID_SEVERITIES: - return s - # Handle "critical", "high", "medium", "low" - mapping = { - "CRITICAL": "P0", - "BLOCKER": "P0", - "HIGH": "P1", - "MAJOR": "P1", - "MEDIUM": "P2", - "MODERATE": "P2", - "NORMAL": "P2", - "LOW": "P3", - "MINOR": "P3", - "TRIVIAL": "P3", - "INFO": "P3", - } - return mapping.get(s) - - -def _infer_severity_from_text(text): - """Try to find a severity marker in free text.""" - match = re.search(r"\b(P[0-3])\b", text, re.IGNORECASE) - if match: - return match.group(1).upper() - - text_upper = text.upper() - for keyword, sev in [ - ("CRITICAL", "P0"), - ("BLOCKER", "P0"), - ("HIGH", "P1"), - ("MAJOR", "P1"), - ("MEDIUM", "P2"), - ("LOW", "P3"), - ("MINOR", "P3"), - ]: - if keyword in text_upper: - return sev - - return None - - -def _infer_blocking(text, severity): - """Infer whether a finding is blocking from text and severity.""" - text_lower = text.lower() - if re.search(r"\bblocking\b", text_lower): - return True - if re.search(r"\bnon[- ]?blocking\b", text_lower): - return False - if re.search(r"\bmust[ -]fix\b", text_lower): - return True - # P0 findings are blocking by default - if severity == "P0": - return True - return DEFAULT_BLOCKING - - -# --------------------------------------------------------------------------- -# Validation -# --------------------------------------------------------------------------- - - -def validate_findings(findings): - """Validate that all findings have required fields with valid values. - - Returns a list of error strings (empty if valid). - """ - errors = [] - if not findings: - errors.append("No findings produced from input") - return errors - - for i, f in enumerate(findings): - fid = f.get("id", f"index {i}") - - if f.get("severity") not in VALID_SEVERITIES: - errors.append( - f"{fid}: invalid severity '{f.get('severity')}' (must be P0-P3)" - ) - - if not isinstance(f.get("blocking"), bool): - errors.append(f"{fid}: 'blocking' must be a boolean") - - if f.get("status") not in VALID_STATUSES: - errors.append( - f"{fid}: invalid status '{f.get('status')}' (must be open/fixed/wont_fix)" - ) - - return errors - - -# --------------------------------------------------------------------------- -# YAML output (stdlib-only) -# --------------------------------------------------------------------------- - - -def _yaml_scalar(value): - """Format a Python value as a YAML scalar string.""" - if value is None: - return "null" - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, int): - return str(value) - if isinstance(value, str): - if not value: - return '""' - # Quote if the string contains characters that could be misinterpreted - needs_quote = any( - c in value - for c in ( - ":", - "#", - "{", - "}", - "[", - "]", - ",", - "&", - "*", - "?", - "|", - "-", - "<", - ">", - "=", - "!", - "%", - "@", - "`", - "\n", - ) - ) - if needs_quote or value.lower() in ("true", "false", "null", "yes", "no"): - escaped = value.replace("\\", "\\\\").replace('"', '\\"') - if "\n" in escaped: - # Use literal block for multiline - return None # signal to use block style - return f'"{escaped}"' - return value - return str(value) - - -def _yaml_block_scalar(value, indent): - """Format a multiline string as a YAML literal block scalar.""" - prefix = " " * indent - lines = value.splitlines() - result = "|\n" - for line in lines: - result += f"{prefix}{line}\n" - return result - - -def emit_yaml(packet_id, reviewer, round_num, findings): - """Produce canonical findings YAML as a string.""" - now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - blocking_count = sum(1 for f in findings if f.get("blocking")) - non_blocking_count = len(findings) - blocking_count - - lines = [] - lines.append(f'packet_id: "{packet_id}"') - lines.append('source_review_packet: ""') - lines.append(f'reviewer: "{reviewer}"') - lines.append(f"round: {round_num}") - lines.append(f'created_at_utc: "{now}"') - lines.append("summary:") - lines.append(' verdict: ""') - lines.append(f" blocking_count: {blocking_count}") - lines.append(f" non_blocking_count: {non_blocking_count}") - lines.append("findings:") - - for f in findings: - lines.append(f' - id: "{f["id"]}"') - lines.append(f' severity: "{f["severity"]}"') - lines.append(f" blocking: {_yaml_scalar(f['blocking'])}") - lines.append(f' status: "{f["status"]}"') - lines.append(f' area: "{f.get("area", DEFAULT_AREA)}"') - lines.append(f" file: {_yaml_scalar(f.get('file', ''))}") - - line_val = f.get("line") - lines.append(f" line: {_yaml_scalar(line_val)}") - - # Text fields — use block scalar if multiline - for field in ("description", "repro", "expected", "evidence", "recommendation"): - val = f.get(field, "") - scalar = _yaml_scalar(val) - if scalar is None: - # multiline block - lines.append(f" {field}: {_yaml_block_scalar(val, 6)}") - else: - lines.append(f" {field}: {scalar}") - - lines.append("qa_validation:") - lines.append(" commands_run: []") - lines.append(" deployment_check:") - lines.append(" ready: false") - lines.append(" rollback_verified: false") - lines.append(' notes: ""') - - return "\n".join(lines) + "\n" - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - - -def parse_args(argv): - """Minimal argument parser (stdlib-only).""" - args = { - "input": None, - "format": None, - "packet_id": None, - "reviewer": "unknown", - "round": 1, - "output": None, - } - - i = 0 - while i < len(argv): - a = argv[i] - if a in ("-h", "--help"): - print(__doc__.strip()) - sys.exit(0) - elif a == "--input" and i + 1 < len(argv): - args["input"] = argv[i + 1] - i += 2 - elif a == "--format" and i + 1 < len(argv): - args["format"] = argv[i + 1] - i += 2 - elif a == "--packet-id" and i + 1 < len(argv): - args["packet_id"] = argv[i + 1] - i += 2 - elif a == "--reviewer" and i + 1 < len(argv): - args["reviewer"] = argv[i + 1] - i += 2 - elif a == "--round" and i + 1 < len(argv): - args["round"] = int(argv[i + 1]) - i += 2 - elif a == "--output" and i + 1 < len(argv): - args["output"] = argv[i + 1] - i += 2 - elif a == "--interactive-plan": - print_interactive_plan() - sys.exit(0) - else: - print(f"Error: unknown option '{a}'", file=sys.stderr) - print( - "Usage: normalize_findings.py --input --format json|text --packet-id ", - file=sys.stderr, - ) - sys.exit(2) - - # Validate required args - missing = [] - if not args["input"]: - missing.append("--input") - if not args["format"]: - missing.append("--format") - if not args["packet_id"]: - missing.append("--packet-id") - - if missing: - print( - f"Error: missing required arguments: {', '.join(missing)}", file=sys.stderr - ) - print( - "Usage: normalize_findings.py --input --format json|text --packet-id ", - file=sys.stderr, - ) - sys.exit(2) - - if args["format"] not in ("json", "text"): - print( - f"Error: --format must be 'json' or 'text', got '{args['format']}'", - file=sys.stderr, - ) - sys.exit(2) - - return args - - -def print_interactive_plan(): - """Output step-by-step instructions for manual findings normalization.""" - plan = """# Normalize Findings — Manual Plan - -## Steps - -1. **Collect raw review output** — copy the LLM's review response into a text file -2. **For each finding block**, extract these fields: - - | Field | Required | How to find it | - |-------|----------|----------------| - | `id` | yes | Assign sequentially: F-001, F-002, ... | - | `severity` | yes | Look for P0/P1/P2/P3 or keywords: critical→P0, major→P1, minor→P2, nit→P3 | - | `blocking` | yes | P0 = always true. Look for "must fix", "blocking" = true. Otherwise false | - | `status` | yes | Set to "open" for all new findings | - | `area` | yes | code / docs / tests / protocol / config | - | `file` | yes | File path mentioned in the finding | - | `line` | no | Line number if mentioned | - | `description` | yes | The main issue description | - | `recommendation` | yes | Suggested fix | - -3. **Format as YAML** using the template at `templates/findings_packet.template.yaml` -4. **Validate**: every finding must have id, severity (P0-P3), blocking (bool), status (open/fixed/wont_fix) -5. **Save** to `packets/findings/___r.yaml` - -## Automated Alternative - -```bash -normalize_findings.py --input raw_output.txt --format text --packet-id --reviewer --round 1 -``` -""" - print(plan) - - -def main(): - args = parse_args(sys.argv[1:]) - - # Read input - input_path = args["input"] - if input_path == "-": - raw_text = sys.stdin.read() - elif not os.path.isfile(input_path): - print(f"Error: input file not found: {input_path}", file=sys.stderr) - sys.exit(2) - else: - with open(input_path, "r") as fh: - raw_text = fh.read() - - if not raw_text.strip(): - print("Error: input file is empty", file=sys.stderr) - sys.exit(2) - - # Parse - if args["format"] == "json": - findings = parse_json_input(raw_text) - else: - findings = parse_text_input(raw_text) - - # Validate - errors = validate_findings(findings) - if errors: - print("Validation errors:", file=sys.stderr) - for err in errors: - print(f" - {err}", file=sys.stderr) - sys.exit(1) - - # Emit - yaml_output = emit_yaml( - packet_id=args["packet_id"], - reviewer=args["reviewer"], - round_num=args["round"], - findings=findings, - ) - - if args["output"]: - with open(args["output"], "w") as fh: - fh.write(yaml_output) - print(f"Wrote {len(findings)} findings to {args['output']}", file=sys.stderr) - else: - sys.stdout.write(yaml_output) - - -if __name__ == "__main__": - main() diff --git a/scripts/oacp_doctor.py b/scripts/oacp_doctor.py index 7498a67..06a5844 100644 --- a/scripts/oacp_doctor.py +++ b/scripts/oacp_doctor.py @@ -24,6 +24,7 @@ import argparse import datetime as dt import json +import os import re import shutil import subprocess @@ -34,12 +35,14 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple from _oacp_constants import ( + AGENT_RE, ALL_RUNTIMES, CANONICAL_CAPABILITIES, REPO_SLUG_RE, is_agent_dir, utc_now_iso, ) +from agent_profile import discover_project_memberships from memory_sync import ( CANONICAL_MEMORY_GITIGNORE, MARKER_FILE, @@ -250,7 +253,7 @@ def check_workspace(project_dir: Path) -> DoctorCategory: name="workspace.json", severity=Severity.error, message="workspace.json — not found", - fix_hint=f"Run: make init PROJECT={project_dir.name}", + fix_hint=f"Run: oacp init {project_dir.name}", )) else: try: @@ -276,7 +279,7 @@ def check_workspace(project_dir: Path) -> DoctorCategory: name="agents/", severity=Severity.error, message="agents/ directory — not found", - fix_hint=f"Run: make init PROJECT={project_dir.name}", + fix_hint=f"Run: oacp init {project_dir.name}", )) else: agent_count = sum(1 for d in agents_dir.iterdir() if is_agent_dir(d)) @@ -781,10 +784,200 @@ def check_autonomy( message=f"{agent_name}/autonomy audit — no orphaned policy refs", )) + # Audit-record integrity sweep: off-enum vocabulary, duplicate live + # evaluations, paused-terminal shapes, and malformed human-outcome + # blocks. Doctor diagnoses; the finalizer is the enforcement. + try: + import finalize_autonomy_record + except ImportError: # pragma: no cover - packaging guard + finalize_autonomy_record = None # type: ignore[assignment] + if finalize_autonomy_record is not None and audit_dir.is_dir(): + report = finalize_autonomy_record.sweep_audit_dir(audit_dir) + record_count = len(report["records"]) + error_files: List[str] = [] + advisory_files: List[str] = [] + for name, findings in sorted(report["records"].items()): + severities = {finding["severity"] for finding in findings} + if "error" in severities: + error_files.append(name) + elif "advisory" in severities: + advisory_files.append(name) + if error_files: + cat.results.append(DoctorResult( + name=f"{agent_name}/autonomy-audit-integrity", + severity=Severity.error, + message=( + f"{agent_name}/autonomy audit — " + f"{len(error_files)}/{record_count} record(s) with " + f"integrity errors: {_summarize_paths(error_files)}" + ), + fix_hint=( + "oacp autonomy-finalize --validate " + "[--sweep] to list findings; close stale or " + "off-vocabulary records via supersession" + ), + )) + else: + cat.results.append(DoctorResult( + name=f"{agent_name}/autonomy-audit-integrity", + severity=Severity.ok, + message=( + f"{agent_name}/autonomy audit — {record_count} " + "record(s) validated, no integrity errors" + ), + )) + duplicates = report["duplicate_groups"] + if duplicates: + grouped = [ + f"{group['message_id']} ({len(group['files'])} live)" + for group in duplicates + ] + cat.results.append(DoctorResult( + name=f"{agent_name}/autonomy-audit-duplicates", + severity=Severity.error, + message=( + f"{agent_name}/autonomy audit — duplicate live " + f"evaluation(s): {_summarize_paths(grouped)}" + ), + fix_hint=( + "oacp autonomy-finalize " + "--final-state superseded --superseded-by " + ), + )) + elif record_count: + cat.results.append(DoctorResult( + name=f"{agent_name}/autonomy-audit-duplicates", + severity=Severity.ok, + message=( + f"{agent_name}/autonomy audit — one live evaluation " + "per message" + ), + )) + if advisory_files: + cat.results.append(DoctorResult( + name=f"{agent_name}/autonomy-audit-advisories", + severity=Severity.warn, + message=( + f"{agent_name}/autonomy audit — " + f"{len(advisory_files)} record(s) with advisory " + f"findings: {_summarize_paths(advisory_files)}" + ), + )) + + return cat + + +# ── Category 5: Agent Registry ─────────────────────────────────────────── + + +def check_agent_registry( + oacp_dir: Path, + yaml_loader: Optional[Any] = None, +) -> DoctorCategory: + """Check that every project agent is present in the instance registry.""" + cat = DoctorCategory(name="Agent Registry") + memberships = discover_project_memberships(oacp_dir) + if not memberships: + cat.results.append(DoctorResult( + name="registry", + severity=Severity.ok, + message="No project agents found — registry is empty", + )) + return cat + + loader = yaml_loader + if loader is None: + yaml_mod = _try_yaml_import() + if yaml_mod is not None: + loader = yaml_mod.safe_load + if loader is None: + cat.results.append(DoctorResult( + name="registry", + severity=Severity.skip, + message="Agent registry validation skipped — PyYAML unavailable", + fix_hint="Install PyYAML to validate global agent profiles", + )) + return cat + + missing_count = 0 + for agent_name, expected_projects in memberships.items(): + profile_path = oacp_dir / "agents" / agent_name / "profile.yaml" + if not profile_path.is_file(): + missing_count += 1 + cat.results.append(DoctorResult( + name=f"{agent_name}/profile.yaml", + severity=Severity.warn, + message=( + f"{agent_name}/profile.yaml — missing from instance registry " + f"({len(expected_projects)} project membership(s))" + ), + fix_hint="Run: oacp agent sync", + )) + continue + + try: + data = loader(profile_path.read_text(encoding="utf-8")) + except Exception as exc: + cat.results.append(DoctorResult( + name=f"{agent_name}/profile.yaml", + severity=Severity.error, + message=f"{agent_name}/profile.yaml — invalid YAML: {exc}", + fix_hint="Repair the profile; doctor will not overwrite identity fields", + )) + continue + if not isinstance(data, dict): + cat.results.append(DoctorResult( + name=f"{agent_name}/profile.yaml", + severity=Severity.error, + message=f"{agent_name}/profile.yaml — top level must be a mapping", + fix_hint="Repair the profile; doctor will not overwrite identity fields", + )) + continue + + registered = data.get("projects") + if not isinstance(registered, list) or not all( + isinstance(project, str) for project in registered + ): + cat.results.append(DoctorResult( + name=f"{agent_name}/projects", + severity=Severity.error, + message=f"{agent_name}/profile.yaml — projects must be a list of strings", + fix_hint="Repair projects, then run: oacp agent sync", + )) + continue + + missing_projects = [ + project for project in expected_projects if project not in registered + ] + if missing_projects: + missing_count += 1 + cat.results.append(DoctorResult( + name=f"{agent_name}/projects", + severity=Severity.warn, + message=( + f"{agent_name}/profile.yaml — missing memberships: " + f"{', '.join(missing_projects)}" + ), + fix_hint="Run: oacp agent sync", + )) + + if missing_count == 0 and not any( + result.severity == Severity.error for result in cat.results + ): + cat.results.append(DoctorResult( + name="registry", + severity=Severity.ok, + message=( + f"{len(memberships)} agent(s), " + f"{sum(len(projects) for projects in memberships.values())} " + "project membership(s) — registered" + ), + )) return cat -# ── Category 5: Agent Status ───────────────────────────────────────────── +# ── Category 6: Agent Status ───────────────────────────────────────────── def check_agent_status( @@ -1505,6 +1698,210 @@ def custom_git_runner( # ── Orchestrator ────────────────────────────────────────────────────────── +# ── Category 8: Org-Memory Debrief Store ───────────────────────────────── + + +# The agent segment is the protocol's canonical agent grammar (AGENT_RE); +# the session segment is hyphen-free, so the split-on-last-hyphen parse is +# deterministic for every valid agent name. +_AGENT_FRAGMENT = AGENT_RE.pattern.lstrip("^").rstrip("$") +DEBRIEF_FILENAME_RE = re.compile( + rf"^(?P\d{{8}})-(?P{_AGENT_FRAGMENT})-(?P[a-z0-9]{{1,32}})\.md$" +) + + +def _valid_debrief_project_segment(name: str) -> bool: + # Mirrors the workspace project-name rule: any name that does not start + # with '.' and contains no path separators has a valid debrief path. + return not name.startswith(".") and "/" not in name and "\\" not in name + + +# ── Debrief store validation scope ─────────────────────────────────────── +# Setup-level by design: the doctor confirms the store exists, the path +# layout is canonical, and nothing irregular sits in the namespace. It +# never opens debrief files — content and format verification belong to +# the writer contract (read-back at publication) and to git history, and +# the store is written by trusted local agents, so the doctor is a +# diagnostic for accidental drift, not a security boundary. One working +# rule: a failed traversal or classification produces an explicit non-ok +# row, never a clean result. + + +def check_org_memory(oacp_dir: Path) -> DoctorCategory: + """Check the org-memory debrief store setup: layout, staging, symlinks.""" + cat = DoctorCategory(name="Org Memory") + org_memory = oacp_dir / "org-memory" + if not org_memory.is_dir(): + cat.results.append(DoctorResult( + name="org-memory-dir", + severity=Severity.skip, + message="org-memory/ — not initialized", + fix_hint="Run: oacp org-memory init", + )) + return cat + + debriefs = org_memory / "debriefs" + if not debriefs.is_dir(): + cat.results.append(DoctorResult( + name="debriefs-dir", + severity=Severity.warn, + message="org-memory/debriefs/ — missing (pre-debrief-store layout)", + fix_hint="Run: oacp org-memory init", + )) + return cat + cat.results.append(DoctorResult( + name="debriefs-dir", + severity=Severity.ok, + message="org-memory/debriefs/ — present", + )) + + layout_bad: List[str] = [] + staging: List[str] = [] + irregular: List[str] = [] + walk_errors: List[str] = [] + total = 0 + + def _walk_error(exc: OSError) -> None: + # A directory the walk cannot enter hides an unknown number of + # records; the failure must surface as its own row. + location = getattr(exc, "filename", None) or str(debriefs) + try: + rel_loc = Path(location).relative_to(debriefs).as_posix() or "." + except ValueError: + rel_loc = str(location) + walk_errors.append(f"{rel_loc}: {exc.__class__.__name__}") + + entries: List[Path] = [] + # followlinks=False so a symlinked directory cannot pull foreign trees + # into the store; the link itself is still flagged below. + for dirpath, dirnames, filenames in os.walk( + debriefs, onerror=_walk_error, followlinks=False + ): + dpath = Path(dirpath) + kept: List[str] = [] + for dname in sorted(dirnames): + entry = dpath / dname + try: + is_link = entry.is_symlink() + except OSError as exc: + walk_errors.append( + f"{entry.relative_to(debriefs).as_posix()}: " + f"{exc.__class__.__name__}" + ) + continue + if is_link: + irregular.append( + entry.relative_to(debriefs).as_posix() + "/ (symlinked directory)" + ) + else: + kept.append(dname) + dirnames[:] = kept + entries.extend(dpath / f for f in filenames) + + for file_path in sorted(entries): + rel = file_path.relative_to(debriefs).as_posix() + if rel == ".gitkeep": + continue + # Writer staging artifacts (.stage..) are outside the + # canonical namespace; lingering ones mean interrupted publication. + if file_path.name.startswith(".stage."): + staging.append(rel) + continue + # The namespace holds regular files reached without following + # links; classification failures surface, never raise. + try: + if file_path.is_symlink(): + irregular.append(f"{rel} (symlink)") + continue + regular = file_path.is_file() + except OSError as exc: + walk_errors.append(f"{rel}: {exc.__class__.__name__}") + continue + if not regular: + irregular.append(f"{rel} (not a regular file)") + continue + total += 1 + parts = rel.split("/") + match = DEBRIEF_FILENAME_RE.match(parts[-1]) if len(parts) == 4 else None + date_valid = False + if match is not None: + try: + dt.datetime.strptime(match.group("date"), "%Y%m%d") + date_valid = True + except ValueError: + pass + if ( + match is None + or not date_valid + or not _valid_debrief_project_segment(parts[0]) + or parts[1] != match.group("date")[0:4] + or parts[2] != match.group("date")[4:6] + ): + layout_bad.append(rel) + + if staging: + cat.results.append(DoctorResult( + name="debriefs-staging", + severity=Severity.warn, + message=( + f"{len(staging)} lingering writer staging artifact(s) " + f"(interrupted publication): {_summarize_paths(staging)}" + ), + fix_hint="The owning writer removes or adopts its stale staging files on retry", + )) + + if irregular: + cat.results.append(DoctorResult( + name="debriefs-irregular", + severity=Severity.error, + message=( + f"{len(irregular)} non-regular entr(ies) under debriefs/ " + f"(the store holds regular files, never symlinks): " + f"{_summarize_paths(irregular)}" + ), + )) + + if walk_errors: + cat.results.append(DoctorResult( + name="debriefs-unreadable", + severity=Severity.error, + message=( + f"{len(walk_errors)} entr(ies) under debriefs/ could not be " + f"inspected (setup check incomplete): " + f"{_summarize_paths(walk_errors)}" + ), + )) + + if total == 0: + if not walk_errors: + cat.results.append(DoctorResult( + name="debriefs-layout", + severity=Severity.ok, + message="debriefs/ — empty store, nothing to validate", + )) + return cat + + if layout_bad: + cat.results.append(DoctorResult( + name="debriefs-layout", + severity=Severity.error, + message=( + f"{len(layout_bad)} of {total} debrief file(s) outside the " + f"canonical ///--.md " + f"layout: {_summarize_paths(layout_bad)}" + ), + fix_hint="Move or rename to the canonical path; never rewrite contents", + )) + else: + cat.results.append(DoctorResult( + name="debriefs-layout", + severity=Severity.ok, + message=f"{total} debrief file(s) — canonical layout", + )) + + return cat + + def run_doctor( *, project: Optional[str] = None, @@ -1521,6 +1918,13 @@ def run_doctor( # Always run environment checks categories.append(check_environment(runner=runner, which_fn=which_fn)) + # Org-memory is opt-in: debrief-store checks run only when it exists + if (oacp_dir / "org-memory").is_dir(): + categories.append(check_org_memory(oacp_dir)) + + if (oacp_dir / "projects").is_dir(): + categories.append(check_agent_registry(oacp_dir, yaml_loader=yaml_loader)) + # Workspace checks require a project if project: project_dir = oacp_dir / "projects" / project @@ -1530,7 +1934,7 @@ def run_doctor( name="project-dir", severity=Severity.error, message=f"Project directory not found: {project_dir}", - fix_hint=f"Run: make init PROJECT={project}", + fix_hint=f"Run: oacp init {project}", )) categories.append(ws_cat) else: diff --git a/scripts/preflight.py b/scripts/preflight.py index 50082fe..9c66d2d 100644 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -6,6 +6,7 @@ Fast mode (default): - Merge conflict marker scan - Makefile `.PHONY`/target consistency checks +- Packaging boundary: `scripts/` contents == wheel force-include entries - YAML syntax validation for `templates/` and `docs/protocol/` - `ruff` on all tracked Python files - `shellcheck` on all tracked `scripts/**/*.sh` @@ -158,6 +159,125 @@ def check_makefile(repo_root: Path) -> CheckResult: ) +FORCE_INCLUDE_HEADER = "[tool.hatch.build.targets.wheel.force-include]" +_FORCE_INCLUDE_ENTRY = re.compile(r'^"([^"]+)"\s*=\s*"([^"]+)"$') + + +def parse_force_include(pyproject_path: Path) -> Tuple[List[Tuple[str, str]], List[str]]: + """Parse the wheel force-include table line-wise. + + Returns (entries, errors) with entries in file order. Line-wise parsing + keeps the check dependency-free on Python < 3.11 (no tomllib); the table + format is enforced as one `"source" = "destination"` pair per line. + """ + raw = pyproject_path.read_text(encoding="utf-8") + entries: List[Tuple[str, str]] = [] + errors: List[str] = [] + seen_sources = set() + in_table = False + table_found = False + + for lineno, line in enumerate(raw.splitlines(), start=1): + stripped = line.strip() + if stripped == FORCE_INCLUDE_HEADER: + in_table = True + table_found = True + continue + if not in_table: + continue + if stripped.startswith("["): + in_table = False + continue + if not stripped or stripped.startswith("#"): + continue + match = _FORCE_INCLUDE_ENTRY.match(stripped) + if not match: + errors.append( + f"pyproject.toml:{lineno}: unparseable force-include line: {stripped[:60]}" + ) + continue + source, destination = match.group(1), match.group(2) + if source in seen_sources: + errors.append( + f"pyproject.toml:{lineno}: duplicate force-include source: {source}" + ) + continue + seen_sources.add(source) + entries.append((source, destination)) + + if not table_found: + errors.append(f"pyproject.toml: missing {FORCE_INCLUDE_HEADER} table") + return entries, errors + + +def _iter_script_files(repo_root: Path) -> List[str]: + """Repo-relative POSIX paths of all regular files under scripts/.""" + scripts_root = repo_root / "scripts" + if not scripts_root.is_dir(): + return [] + files: List[str] = [] + for path in sorted(scripts_root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(repo_root) + if any(part in SKIP_DIRS or part.startswith(".") for part in rel.parts): + continue + files.append(rel.as_posix()) + return files + + +def check_packaging_boundary(repo_root: Path) -> CheckResult: + """Enforce `scripts/` == force-include: every script is packaged and every + force-include source exists on disk. Fails on drift in either direction.""" + start = time.monotonic() + pyproject_path = repo_root / "pyproject.toml" + if not pyproject_path.is_file(): + return CheckResult( + name="packaging-boundary", + passed=False, + details="pyproject.toml not found", + duration_s=time.monotonic() - start, + ) + + entries, problems = parse_force_include(pyproject_path) + sources = [source for source, _ in entries] + + scripts_on_disk = set(_iter_script_files(repo_root)) + script_sources = {source for source in sources if source.startswith("scripts/")} + + unpackaged = sorted(scripts_on_disk - script_sources) + if unpackaged: + problems.append( + "scripts/ files missing from force-include: " + ", ".join(unpackaged) + ) + + missing_files = sorted( + source for source in sources if not (repo_root / source).is_file() + ) + if missing_files: + problems.append( + "force-include sources with no file on disk: " + ", ".join(missing_files) + ) + + if problems: + return CheckResult( + name="packaging-boundary", + passed=False, + details="\n".join(problems), + duration_s=time.monotonic() - start, + ) + + return CheckResult( + name="packaging-boundary", + passed=True, + details=( + f"scripts/ ({len(scripts_on_disk)} files) matches force-include; " + f"all {len(sources)} sources exist" + ), + duration_s=time.monotonic() - start, + ) + + def _discover_repo_files(repo_root: Path, runner: Runner) -> List[Path]: rc, output = runner(["git", "ls-files"], repo_root) if rc == 0: @@ -396,6 +516,7 @@ def run_preflight( results = [ check_conflict_markers(repo_root, runner=runner), check_makefile(repo_root), + check_packaging_boundary(repo_root), check_yaml_syntax(repo_root, loader=yaml_loader), check_ruff(repo_root, runner=runner), check_shellcheck(repo_root, runner=runner), diff --git a/scripts/record_autonomy_outcome.py b/scripts/record_autonomy_outcome.py index d932fe8..c99e24d 100644 --- a/scripts/record_autonomy_outcome.py +++ b/scripts/record_autonomy_outcome.py @@ -9,15 +9,13 @@ import copy import datetime as dt import json -import os import sys -import tempfile from pathlib import Path from typing import Any, Dict, Optional, Sequence, Tuple import yaml -from _oacp_constants import locked_audit, utc_now_iso +from _oacp_constants import atomic_replace_yaml, locked_audit, utc_now_iso from autonomy_gate import ( AUTONOMY_AUDIT_SCHEMA_VERSION, PINNED_COMPLETION_KINDS, @@ -265,30 +263,6 @@ def record_human_outcome( return updated -def _atomic_write_yaml(path: Path, data: Dict[str, Any]) -> None: - content = yaml.safe_dump(data, sort_keys=False, allow_unicode=True) - mode = path.stat().st_mode - temp_path: Optional[Path] = None - try: - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as handle: - handle.write(content) - handle.flush() - os.fsync(handle.fileno()) - temp_path = Path(handle.name) - os.chmod(temp_path, mode) - os.replace(temp_path, path) - finally: - if temp_path is not None and temp_path.exists(): - temp_path.unlink() - - def _grant_scope_from_file(path: Optional[Path]) -> Optional[Dict[str, Any]]: if path is None: return None @@ -353,7 +327,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: actor=args.actor, ) if not args.dry_run: - _atomic_write_yaml(args.audit_file, updated) + atomic_replace_yaml(args.audit_file, updated) outcome = updated["result"]["human_outcome"] if args.json: diff --git a/scripts/send_inbox_message.py b/scripts/send_inbox_message.py index 631722e..d21402c 100644 --- a/scripts/send_inbox_message.py +++ b/scripts/send_inbox_message.py @@ -101,6 +101,13 @@ def generate_message_id(sender: str) -> str: return f"msg-{compact_ts}-{sender}-{rand4}" +def generate_conversation_id(sender: str) -> str: + """Generate a thread ID with a random six-digit sequence.""" + now = dt.datetime.now(dt.timezone.utc) + sequence = secrets.randbelow(1_000_000) + return f"conv-{now.strftime('%Y%m%d')}-{sender}-{sequence:06d}" + + def generate_timestamp() -> str: """Generate a UTC RFC3339 timestamp (seconds precision).""" return utc_now_iso() @@ -700,8 +707,10 @@ def send_message( f"— thread may be broken! A new conversation_id will be generated." ) if not conversation_id: - now = dt.datetime.now(dt.timezone.utc) - conversation_id = f"conv-{now.strftime('%Y%m%d')}-{sender}-1" + conversation_id = generate_conversation_id(sender) + + if not parent_message_id and not conversation_id: + conversation_id = generate_conversation_id(sender) # Determine the 'to' value for the message dict to_value: Any = recipients_list if is_broadcast else recipients_list[0] diff --git a/scripts/update_workspace.sh b/scripts/update_workspace.sh index 1d120aa..64453e4 100755 --- a/scripts/update_workspace.sh +++ b/scripts/update_workspace.sh @@ -5,14 +5,14 @@ set -euo pipefail # update_workspace.sh — idempotent sync of an existing project workspace # with the latest directory structure expected by OACP. -# Directory structure — keep in sync with init_project_workspace.sh +# Directory structure — keep in sync with init_project_workspace.py (oacp init) usage() { cat < [--repo /path/to/repo] [--link SRC:DST ...] [--dry-run] [--quiet] Sync an existing project workspace with the latest directory structure. -The workspace must already exist (use init_project_workspace.sh to create one). +The workspace must already exist (use \`oacp init \` to create one). Options: --repo PATH Set repo root for artifact symlinks. @@ -66,7 +66,7 @@ done # ── Guard: workspace must already exist ────────────────────────────────── if [[ ! -d "$PROJECT_ROOT" ]]; then echo "Error: workspace '$PROJECT_ROOT' does not exist." >&2 - echo "Use init_project_workspace.sh to create a new workspace." >&2 + echo "Use 'oacp init' to create a new workspace." >&2 exit 2 fi diff --git a/templates/agent_profile.template.yaml b/templates/agent_profile.template.yaml index 0ea3a3e..7a63e42 100644 --- a/templates/agent_profile.template.yaml +++ b/templates/agent_profile.template.yaml @@ -12,6 +12,7 @@ name: "" # agent name (matches directory name) runtime: "" # claude | codex | cursor | gemini | human model: "" # model id (e.g., claude-opus-4-6) description: "" # one-line role summary +projects: [] # project workspace memberships # ── Routing ────────────────────────────────────────────────────────────── diff --git a/templates/handoff_packet.template.yaml b/templates/handoff_packet.template.yaml index f030035..e1fd8bd 100644 --- a/templates/handoff_packet.template.yaml +++ b/templates/handoff_packet.template.yaml @@ -1,5 +1,5 @@ # Structured handoff packet template -# Copy into a project packet folder (or use `make handoff`) before sending. +# Copy into a project packet folder before sending. source_agent: "" target_agent: "" diff --git a/templates/inbox_message.template.yaml b/templates/inbox_message.template.yaml index 8d489ac..72d50ff 100644 --- a/templates/inbox_message.template.yaml +++ b/templates/inbox_message.template.yaml @@ -19,6 +19,8 @@ body: | task_profile: estimated_minutes: 20 risk_tier: P3 + # Distinct deliverable files only; exclude receiver-local inbox, outbox, + # audit, memory, cache, and scratch writes. expected_files_touched: 3 destructive_ops: false external_side_effects: false diff --git a/tests/conformance/autonomy/README.md b/tests/conformance/autonomy/README.md index 2d3d27d..e6e6ffa 100644 --- a/tests/conformance/autonomy/README.md +++ b/tests/conformance/autonomy/README.md @@ -21,7 +21,10 @@ expected: ``` Consumers may add implementation-specific trace fields, but `decision`, `mode`, -`reason_codes`, and `matched_pattern` when present must match. +`reason_codes`, and legacy `matched_pattern` when present must match. Evaluator +outputs also carry `matched_patterns`: every lexical hit has a pattern name, +source span, category, and non-empty `demotion_basis`; the executable runner +validates that additive provenance across every fixture. These fixtures may also include: @@ -45,7 +48,28 @@ These fixtures may also include: - `expected.result.threshold_checkpoint` for envelope drift decisions - `expected.breached` for the pinned top-level breach list - `expected.task_profile` for full declared-profile capture +- `expected.admission_axes` for the pinned admission ledger (exact match) +- generated `matched_patterns` for complete lexical provenance; this is + validated structurally by the runner rather than repeated in every expected + YAML file The executable runner is `tests/test_autonomy_gate.py`; every expected fixture is evaluated against `scripts/autonomy_gate.py`. Evaluator reason codes are a pinned enum, and any unregistered code fails the runner. + +The `ledger_replay/` subdirectory holds an anonymized replay corpus: audit +records from a fleet corpus whose evaluator early-outs left envelope-derived +admission axes unrecorded. Each case pins the unchanged verdict together +with the complete `admission_axes` ledger and `co_occurring_reason_codes` +the evaluator must now produce; the runner is +`tests/test_autonomy_ledger_replay.py`. The same file's +`content_sensitivity` section replays the reply-only carve-out over every +content-sensitivity hard stop of one window plus a control, pinning which +records demote to a `lexical_advisory_reply_only` note and which keep the +hard stop. + +The `records/` subdirectory pins a second contract class: audit-record +integrity findings (off-enum vocabulary, duplicate live evaluations, +paused-terminal shapes) validated by `scripts/finalize_autonomy_record.py` +and run by `tests/test_audit_record_conformance.py` — see +`records/README.md`. diff --git a/tests/conformance/autonomy/expected/brainstorm_without_profile_auto_accepts.yaml b/tests/conformance/autonomy/expected/brainstorm_without_profile_auto_accepts.yaml index 2ca700f..40afb73 100644 --- a/tests/conformance/autonomy/expected/brainstorm_without_profile_auto_accepts.yaml +++ b/tests/conformance/autonomy/expected/brainstorm_without_profile_auto_accepts.yaml @@ -13,3 +13,13 @@ expected: - risk_threshold_passed - hard_stops_clear - workspace_check_required + # Admission-only exemption: the profileless admit runs under the + # documented default envelope, and the record names that source. + scope_envelope_source: default_profileless + scope_envelope: + estimated_minutes: 25 + expected_files_touched: 2 + risk_tier: P3 + sends_oacp_reply_only: true + creates_or_updates_pr: false + external_side_effects: false diff --git a/tests/conformance/autonomy/expected/checkpoint_reauth_boundary_action_resumes.yaml b/tests/conformance/autonomy/expected/checkpoint_reauth_boundary_action_resumes.yaml index 25803d3..4c7c696 100644 --- a/tests/conformance/autonomy/expected/checkpoint_reauth_boundary_action_resumes.yaml +++ b/tests/conformance/autonomy/expected/checkpoint_reauth_boundary_action_resumes.yaml @@ -37,5 +37,5 @@ expected: decided_at_utc: "2026-05-12T12:50:00Z" actor: alice disposition: resumed - cleared_paused_at_utc: "2026-05-12T12:30:00Z" + cleared_paused_at_utc: "2026-05-12T12:50:00Z" advisory: [] diff --git a/tests/conformance/autonomy/expected/checkpoint_reauth_cross_channel_resumes.yaml b/tests/conformance/autonomy/expected/checkpoint_reauth_cross_channel_resumes.yaml index ec6e626..ac4aa98 100644 --- a/tests/conformance/autonomy/expected/checkpoint_reauth_cross_channel_resumes.yaml +++ b/tests/conformance/autonomy/expected/checkpoint_reauth_cross_channel_resumes.yaml @@ -33,5 +33,5 @@ expected: decided_at_utc: "2026-05-12T12:50:00Z" actor: alice disposition: resumed - cleared_paused_at_utc: "2026-05-12T12:30:00Z" + cleared_paused_at_utc: "2026-05-12T12:50:00Z" advisory: [] diff --git a/tests/conformance/autonomy/expected/checkpoint_reauth_same_channel_resumes.yaml b/tests/conformance/autonomy/expected/checkpoint_reauth_same_channel_resumes.yaml index b8efbaf..9f1ab04 100644 --- a/tests/conformance/autonomy/expected/checkpoint_reauth_same_channel_resumes.yaml +++ b/tests/conformance/autonomy/expected/checkpoint_reauth_same_channel_resumes.yaml @@ -33,5 +33,5 @@ expected: decided_at_utc: "2026-05-12T12:45:00Z" source_message_id: msg-20260512124500-sender-reauth1 disposition: resumed - cleared_paused_at_utc: "2026-05-12T12:30:00Z" + cleared_paused_at_utc: "2026-05-12T12:45:00Z" advisory: [] diff --git a/tests/conformance/autonomy/expected/checkpoint_reauth_scopeless_boundary_resumes.yaml b/tests/conformance/autonomy/expected/checkpoint_reauth_scopeless_boundary_resumes.yaml index 0369de1..fca5733 100644 --- a/tests/conformance/autonomy/expected/checkpoint_reauth_scopeless_boundary_resumes.yaml +++ b/tests/conformance/autonomy/expected/checkpoint_reauth_scopeless_boundary_resumes.yaml @@ -38,5 +38,5 @@ expected: requested_scope: null scope: null disposition: resumed - cleared_paused_at_utc: "2026-05-12T12:30:00Z" + cleared_paused_at_utc: "2026-05-12T12:50:00Z" advisory: [] diff --git a/tests/conformance/autonomy/expected/declaration_error_admission_pauses.yaml b/tests/conformance/autonomy/expected/declaration_error_admission_pauses.yaml new file mode 100644 index 0000000..a48692a --- /dev/null +++ b/tests/conformance/autonomy/expected/declaration_error_admission_pauses.yaml @@ -0,0 +1,29 @@ +case: declaration_error_admission_pauses +config: configs/auto_review_standard.yaml +message: messages/declaration_error_admission.yaml +expected: + decision: paused + mode: auto_review + reason_codes: + - declaration_error + breached: + - task_profile.external_side_effects + co_occurring_reason_codes: + - external_side_effects_not_pr_artifact + admission_axes: + evaluated: true + thresholds: [] + declared_risk: [] + declaration: + - declaration_error + side_effects: + - external_side_effects_not_pr_artifact + continuation_grant: [] + declaration_errors: + - field: task_profile.external_side_effects + declared: false + conflicts_with: + - task_profile.commits_changes + result: + final_state: paused + completion_kind: admission_paused diff --git a/tests/conformance/autonomy/expected/hard_stop_masking_threshold_pauses.yaml b/tests/conformance/autonomy/expected/hard_stop_masking_threshold_pauses.yaml index 4975dd1..9eed8bd 100644 --- a/tests/conformance/autonomy/expected/hard_stop_masking_threshold_pauses.yaml +++ b/tests/conformance/autonomy/expected/hard_stop_masking_threshold_pauses.yaml @@ -9,6 +9,17 @@ expected: matched_pattern: "deploy" co_occurring_reason_codes: - expected_files_touched_exceeds_threshold + - external_side_effects_not_pr_artifact + admission_axes: + evaluated: true + thresholds: + - expected_files_touched_exceeds_threshold + declared_risk: [] + declaration: [] + side_effects: + - external_side_effects_not_pr_artifact + continuation_grant: [] + declaration_errors: [] result: final_state: paused completion_kind: admission_paused diff --git a/tests/conformance/autonomy/expected/sensitive_content_reply_only_advisory.yaml b/tests/conformance/autonomy/expected/sensitive_content_reply_only_advisory.yaml new file mode 100644 index 0000000..3fc9ccd --- /dev/null +++ b/tests/conformance/autonomy/expected/sensitive_content_reply_only_advisory.yaml @@ -0,0 +1,19 @@ +case: sensitive_content_reply_only_advisory +config: configs/auto_review_standard.yaml +message: messages/sensitive_content_reply_only.yaml +expected: + decision: auto_accepted + mode: auto_review + reason_codes: + - message_valid + - message_not_expired + - message_hash_recorded + - task_profile_present + - task_type_allowed + - risk_threshold_passed + - hard_stops_clear + - lexical_advisory + - workspace_check_required + logged_notes: + - matched_pattern: pricing + - matched_pattern: commercial diff --git a/tests/conformance/autonomy/ledger_replay/corpus.yaml b/tests/conformance/autonomy/ledger_replay/corpus.yaml new file mode 100644 index 0000000..d847061 --- /dev/null +++ b/tests/conformance/autonomy/ledger_replay/corpus.yaml @@ -0,0 +1,913 @@ +# Admission-ledger replay corpus. +# +# Twenty-five anonymized reproductions of audit records from a fleet corpus in +# which a structured-predicate replay found envelope-derived admission axes +# that held but went unrecorded — 44 qualitative axes across these records +# plus one masked numeric threshold. Forty-three of the 45 reproduce from the +# evaluator alone (its early returns skipped every axis evaluated after the +# one that drove the verdict); the two on ledger-19 were lost receiver-side +# when a checkpoint update overwrote the admission reasons. Each case carries the +# receiver policy shape, the declared profile, a body line that reproduces the +# recorded primary reason, the pinned verdict, and `expected_axes` — every +# envelope-derived axis that holds for that profile under the policy — plus +# `field_record_missing`, the subset the original record failed to carry. +# +# Runner: tests/test_autonomy_ledger_replay.py. A conforming evaluator records +# every expected axis in `admission_axes` and surfaces every non-primary one +# through `co_occurring_reason_codes`, with the verdict unchanged. +policy: + max_estimated_minutes: 45 + max_expected_files_touched: 5 + external_side_effects: allow_pr_artifacts + private_repo_allowlist: + - example-org/private-repo +cases: +- case: ledger-01 + body_line: Work on the public repo. + task_profile: + estimated_minutes: 40 + risk_tier: P2 + expected_files_touched: 12 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + target_repo: example-org/private-repo + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + merges_pr: true + files_issues: false + sends_oacp_reply_only: false + expected_matched_pattern: public repo + expected_reason_codes: + - hard_stop_sensitive_scope + expected_axes: + - expected_files_touched_exceeds_threshold + - merges_pr_pause + field_record_missing: + - merges_pr_pause +- case: ledger-02 + body_line: Do the task. + task_profile: + estimated_minutes: 40 + risk_tier: P2 + expected_files_touched: 15 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: true + public_visibility: false + target_repo: example-org/private-repo + creates_or_updates_pr: true + comments_on_github: false + commits_changes: true + merges_pr: true + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - dependency_changes_pause + expected_axes: + - expected_files_touched_exceeds_threshold + - dependency_changes_pause + - merges_pr_pause + field_record_missing: + - merges_pr_pause +- case: ledger-03 + body_line: Do the task. + task_profile: + estimated_minutes: 35 + risk_tier: P2 + expected_files_touched: 15 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: true + target_repo: example-org/other-repo + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - public_visibility_pause + expected_axes: + - expected_files_touched_exceeds_threshold + - public_visibility_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact +- case: ledger-04 + body_line: Do the task. + task_profile: + estimated_minutes: 25 + risk_tier: P2 + expected_files_touched: 55 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: true + target_repo: example-org/other-repo + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - public_visibility_pause + expected_axes: + - expected_files_touched_exceeds_threshold + - public_visibility_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact +- case: ledger-05 + body_line: Do the task. + task_profile: + estimated_minutes: 20 + risk_tier: P2 + expected_files_touched: 55 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: true + target_repo: example-org/other-repo + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - public_visibility_pause + expected_axes: + - expected_files_touched_exceeds_threshold + - public_visibility_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact +- case: ledger-06 + body_line: Do the task. + task_profile: + estimated_minutes: 40 + risk_tier: P1 + expected_files_touched: 9 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: true + public_visibility: false + target_repo: example-org/private-repo + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + merges_pr: true + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - dependency_changes_pause + expected_axes: + - expected_files_touched_exceeds_threshold + - dependency_changes_pause + - merges_pr_pause + field_record_missing: + - merges_pr_pause +- case: ledger-07 + body_line: Edit config.yaml. + task_profile: + estimated_minutes: 35 + risk_tier: P2 + expected_files_touched: 8 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: true + touches_dependencies: false + public_visibility: false + target_repo: example-org/private-repo + creates_or_updates_pr: true + comments_on_github: false + commits_changes: true + merges_pr: true + files_issues: false + sends_oacp_reply_only: false + expected_matched_pattern: config + expected_reason_codes: + - hard_stop_sensitive_scope + expected_axes: + - expected_files_touched_exceeds_threshold + - auth_config_or_secrets_pause + - merges_pr_pause + field_record_missing: + - auth_config_or_secrets_pause + - merges_pr_pause +- case: ledger-08 + body_line: Publish the release. + task_profile: + estimated_minutes: 40 + risk_tier: P1 + expected_files_touched: 50 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: true + touches_dependencies: false + public_visibility: true + target_repo: example-org/private-repo + creates_or_updates_pr: true + comments_on_github: false + commits_changes: true + merges_pr: true + files_issues: false + sends_oacp_reply_only: false + expected_matched_pattern: publish + expected_reason_codes: + - hard_stop_external_side_effect + expected_axes: + - expected_files_touched_exceeds_threshold + - auth_config_or_secrets_pause + - public_visibility_pause + - merges_pr_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - auth_config_or_secrets_pause + - external_side_effects_not_pr_artifact + - merges_pr_pause + - public_visibility_pause +- case: ledger-09 + body_line: Do the task. + task_profile: + estimated_minutes: 40 + risk_tier: P1 + expected_files_touched: 45 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: true + touches_dependencies: false + public_visibility: false + target_repo: '' + creates_or_updates_pr: false + comments_on_github: false + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - auth_config_or_secrets_pause + expected_axes: + - expected_files_touched_exceeds_threshold + - auth_config_or_secrets_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact +- case: ledger-10 + body_line: Publish the release. + task_profile: + estimated_minutes: 45 + risk_tier: P2 + expected_files_touched: 8 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: true + target_repo: example-org/private-repo + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + merges_pr: true + files_issues: false + sends_oacp_reply_only: false + expected_matched_pattern: publish + expected_reason_codes: + - hard_stop_external_side_effect + expected_axes: + - expected_files_touched_exceeds_threshold + - public_visibility_pause + - merges_pr_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact + - merges_pr_pause + - public_visibility_pause +- case: ledger-11 + body_line: Merge the branch. + task_profile: + estimated_minutes: 20 + risk_tier: P2 + expected_files_touched: 5 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: true + target_repo: '' + creates_or_updates_pr: false + comments_on_github: false + commits_changes: false + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_matched_pattern: merge + expected_reason_codes: + - hard_stop_external_side_effect + expected_axes: + - public_visibility_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact + - public_visibility_pause +- case: ledger-12 + body_line: Do the task. + task_profile: + estimated_minutes: 15 + risk_tier: P1 + expected_files_touched: 3 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: true + target_repo: '' + creates_or_updates_pr: false + comments_on_github: true + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - public_visibility_pause + expected_axes: + - public_visibility_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact +- case: ledger-13 + body_line: Do the task. + task_profile: + estimated_minutes: 20 + risk_tier: P2 + expected_files_touched: 4 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + target_repo: '' + creates_or_updates_pr: false + comments_on_github: false + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - declaration_error + expected_axes: + - declaration_error + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact +- case: ledger-14 + body_line: Publish the release. + task_profile: + estimated_minutes: 25 + risk_tier: P2 + expected_files_touched: 15 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: true + target_repo: '' + creates_or_updates_pr: true + comments_on_github: false + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_matched_pattern: publish + expected_reason_codes: + - hard_stop_external_side_effect + expected_axes: + - expected_files_touched_exceeds_threshold + - public_visibility_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact + - public_visibility_pause +- case: ledger-15 + body_line: Publish the release. + task_profile: + estimated_minutes: 60 + risk_tier: P1 + expected_files_touched: 60 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: true + public_visibility: true + target_repo: '' + creates_or_updates_pr: true + comments_on_github: false + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_matched_pattern: publish + expected_reason_codes: + - hard_stop_external_side_effect + expected_axes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + - dependency_changes_pause + - public_visibility_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - dependency_changes_pause + - external_side_effects_not_pr_artifact + - public_visibility_pause +- case: ledger-16 + body_line: Publish the release. + task_profile: + estimated_minutes: 60 + risk_tier: P1 + expected_files_touched: 60 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: true + public_visibility: true + target_repo: '' + creates_or_updates_pr: true + comments_on_github: false + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_matched_pattern: publish + expected_reason_codes: + - hard_stop_external_side_effect + expected_axes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + - dependency_changes_pause + - public_visibility_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - dependency_changes_pause + - external_side_effects_not_pr_artifact + - public_visibility_pause +- case: ledger-17 + body_line: Work on the public repo. + task_profile: + estimated_minutes: 50 + risk_tier: P2 + expected_files_touched: 60 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + target_repo: example-org/other-repo + creates_or_updates_pr: true + comments_on_github: false + commits_changes: false + merges_pr: true + files_issues: false + sends_oacp_reply_only: false + expected_matched_pattern: public repo + expected_reason_codes: + - hard_stop_sensitive_scope + expected_axes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + - merges_pr_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact + - merges_pr_pause +- case: ledger-18 + body_line: Publish the release. + task_profile: + estimated_minutes: 40 + risk_tier: P2 + expected_files_touched: 120 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: true + target_repo: example-org/other-repo + creates_or_updates_pr: true + comments_on_github: false + commits_changes: false + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_matched_pattern: publish + expected_reason_codes: + - hard_stop_external_side_effect + expected_axes: + - expected_files_touched_exceeds_threshold + - public_visibility_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact + - public_visibility_pause +- case: ledger-19 + body_line: Do the task. + task_profile: + estimated_minutes: 15 + risk_tier: P3 + expected_files_touched: 6 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + target_repo: '' + creates_or_updates_pr: false + comments_on_github: false + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + note: field record's reason_codes were receiver-overwritten with the checkpoint reason; the admission + verdict is replayed from the envelope + expected_reason_codes: + - expected_files_touched_exceeds_threshold + - external_side_effects_not_pr_artifact + expected_axes: + - expected_files_touched_exceeds_threshold + - external_side_effects_not_pr_artifact + field_record_missing: + - expected_files_touched_exceeds_threshold + - external_side_effects_not_pr_artifact +- case: ledger-20 + body_line: Do the task. + task_profile: + estimated_minutes: 25 + risk_tier: P2 + expected_files_touched: 4 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + target_repo: '' + creates_or_updates_pr: false + comments_on_github: false + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - declaration_error + expected_axes: + - declaration_error + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact +- case: ledger-21 + body_line: Do the task. + task_profile: + estimated_minutes: 50 + risk_tier: P2 + expected_files_touched: 45 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: true + public_visibility: false + target_repo: example-org/other-repo + creates_or_updates_pr: true + comments_on_github: false + commits_changes: false + merges_pr: true + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - dependency_changes_pause + expected_axes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + - dependency_changes_pause + - merges_pr_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact + - merges_pr_pause +- case: ledger-22 + body_line: Install the dependency. + task_profile: + estimated_minutes: 60 + risk_tier: P2 + expected_files_touched: 20 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: true + public_visibility: false + target_repo: '' + creates_or_updates_pr: true + comments_on_github: false + commits_changes: false + merges_pr: true + files_issues: true + sends_oacp_reply_only: false + expected_matched_pattern: install dependency + expected_reason_codes: + - hard_stop_external_side_effect + expected_axes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + - dependency_changes_pause + - merges_pr_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - dependency_changes_pause + - external_side_effects_not_pr_artifact + - merges_pr_pause +- case: ledger-23 + body_line: Do the task. + task_profile: + estimated_minutes: 75 + risk_tier: P1 + expected_files_touched: 60 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: true + public_visibility: false + target_repo: example-org/other-repo + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + merges_pr: true + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - dependency_changes_pause + expected_axes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + - dependency_changes_pause + - merges_pr_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact + - merges_pr_pause +- case: ledger-24 + body_line: Do the task. + task_profile: + estimated_minutes: 90 + risk_tier: P1 + expected_files_touched: 30 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: true + public_visibility: false + target_repo: example-org/private-repo + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + merges_pr: true + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - dependency_changes_pause + expected_axes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + - dependency_changes_pause + - merges_pr_pause + field_record_missing: + - merges_pr_pause +- case: ledger-25 + body_line: Do the task. + task_profile: + estimated_minutes: 95 + risk_tier: P1 + expected_files_touched: 40 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: true + public_visibility: true + target_repo: example-org/private-repo + creates_or_updates_pr: true + comments_on_github: true + commits_changes: true + merges_pr: true + files_issues: false + sends_oacp_reply_only: false + expected_reason_codes: + - dependency_changes_pause + - public_visibility_pause + expected_axes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + - dependency_changes_pause + - public_visibility_pause + - merges_pr_pause + - external_side_effects_not_pr_artifact + field_record_missing: + - external_side_effects_not_pr_artifact + - merges_pr_pause + +# Content-sensitivity replay: the reply-only carve-out. +# +# Six anonymized reproductions of every content-sensitivity hard stop the +# same fleet corpus recorded over one two-week window (two mirror-pair +# research briefs sent to two receivers each, one competitive-analysis +# brief, one re-evaluation after a brief amendment) plus one control. +# Every original record carried the single reason code +# hard_stop_content_sensitivity and was cleared by a human unchanged. +# +# Under the reply-only carve-out the category is an advisory for exactly +# one profile shape: a complete profile declaring sends_oacp_reply_only +# true with every other side-effect flag false. Three of the six records +# (cat5-01..03) have that shape and replay with the matched term recorded +# as a lexical_advisory_reply_only note and the verdict taken by the axes +# the hard stop used to mask. The other three (cat5-04..06) declared the +# legacy five-flag profile and omitted sends_oacp_reply_only, so the +# shape is not declared and the hard stop is unchanged. The control +# (cat5-control) declares a commit and stays hard. +# +# Runner: the content-sensitivity tests in tests/test_autonomy_ledger_replay.py. +# recorded_reason_codes is what the original fleet record carried. +content_sensitivity: + cases: + - case: cat5-01 + type: brainstorm_request + body_line: >- + Literature sweep: the claim that the approach beat eight commercial + products is secondary and unverified - a lead, not evidence. + task_profile: + estimated_minutes: 50 + risk_tier: P2 + expected_files_touched: 8 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + sends_oacp_reply_only: true + recorded_reason_codes: + - hard_stop_content_sensitivity + expected_reason_codes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + expected_notes: + - code: lexical_advisory_reply_only + matched_pattern: commercial + expected_axes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + - case: cat5-02 + type: brainstorm_request + body_line: >- + Literature sweep: the claim that the approach beat eight commercial + products is secondary and unverified - a lead, not evidence. + task_profile: + estimated_minutes: 50 + risk_tier: P2 + expected_files_touched: 8 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + sends_oacp_reply_only: true + recorded_reason_codes: + - hard_stop_content_sensitivity + expected_reason_codes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + expected_notes: + - code: lexical_advisory_reply_only + matched_pattern: commercial + expected_axes: + - estimated_minutes_exceeds_threshold + - expected_files_touched_exceeds_threshold + - case: cat5-03 + type: task_request + body_line: >- + Code-first competitive analysis of the project: people, backing, + business-model signals (hosted tier? pricing page?), exact license. + task_profile: + estimated_minutes: 35 + risk_tier: P2 + expected_files_touched: 15 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + sends_oacp_reply_only: true + recorded_reason_codes: + - hard_stop_content_sensitivity + expected_reason_codes: + - expected_files_touched_exceeds_threshold + expected_notes: + - code: lexical_advisory_reply_only + matched_pattern: pricing + expected_axes: + - expected_files_touched_exceeds_threshold + - case: cat5-04 + type: brainstorm_request + body_line: >- + Deep-dive on three harness-onboarding candidates; frame the interop + proof as coverage across frontier, commercial, and open-weight runtimes. + task_profile: + estimated_minutes: 25 + risk_tier: P2 + expected_files_touched: 12 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + recorded_reason_codes: + - hard_stop_content_sensitivity + expected_matched_pattern: commercial + expected_reason_codes: + - hard_stop_content_sensitivity + expected_axes: + - expected_files_touched_exceeds_threshold + - case: cat5-05 + type: brainstorm_request + body_line: >- + Deep-dive on three harness-onboarding candidates; frame the interop + proof as coverage across frontier, commercial, and open-weight runtimes. + task_profile: + estimated_minutes: 25 + risk_tier: P2 + expected_files_touched: 12 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + recorded_reason_codes: + - hard_stop_content_sensitivity + expected_matched_pattern: commercial + expected_reason_codes: + - hard_stop_content_sensitivity + expected_axes: + - expected_files_touched_exceeds_threshold + - case: cat5-06 + type: brainstorm_request + body_line: >- + Deep-dive on three harness-onboarding candidates (amended brief); frame + the interop proof as coverage across frontier, commercial, and + open-weight runtimes. + task_profile: + estimated_minutes: 25 + risk_tier: P2 + expected_files_touched: 12 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + recorded_reason_codes: + - hard_stop_content_sensitivity + expected_matched_pattern: commercial + expected_reason_codes: + - hard_stop_content_sensitivity + expected_axes: + - expected_files_touched_exceeds_threshold + - case: cat5-control + type: task_request + body_line: >- + Literature sweep: the claim that the approach beat eight commercial + products is secondary and unverified; commit the sweep notes to the repo. + task_profile: + estimated_minutes: 10 + risk_tier: P2 + expected_files_touched: 2 + destructive_ops: false + external_side_effects: true + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + target_repo: example-org/private-repo + creates_or_updates_pr: false + comments_on_github: false + commits_changes: true + merges_pr: false + files_issues: false + sends_oacp_reply_only: false + recorded_reason_codes: + - hard_stop_content_sensitivity + expected_matched_pattern: commercial + expected_reason_codes: + - hard_stop_content_sensitivity + expected_axes: + - external_side_effects_not_pr_artifact diff --git a/tests/conformance/autonomy/messages/declaration_error_admission.yaml b/tests/conformance/autonomy/messages/declaration_error_admission.yaml new file mode 100644 index 0000000..28b34c0 --- /dev/null +++ b/tests/conformance/autonomy/messages/declaration_error_admission.yaml @@ -0,0 +1,25 @@ +id: msg-20260814023818-alice-decl1 +from: alice +to: codex +type: task_request +priority: P2 +created_at_utc: "2026-08-14T02:38:18Z" +subject: "Update the runbook and commit" +body: | + ## Task + Update the runbook and commit the change on a branch. + + task_profile: + estimated_minutes: 20 + risk_tier: P2 + expected_files_touched: 4 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + creates_or_updates_pr: false + comments_on_github: false + commits_changes: true + files_issues: false + sends_oacp_reply_only: false diff --git a/tests/conformance/autonomy/messages/sensitive_content_reply_only.yaml b/tests/conformance/autonomy/messages/sensitive_content_reply_only.yaml new file mode 100644 index 0000000..fb91531 --- /dev/null +++ b/tests/conformance/autonomy/messages/sensitive_content_reply_only.yaml @@ -0,0 +1,28 @@ +id: msg-20260818090000-iris-reply-only-research +from: iris +to: codex +type: task_request +priority: P2 +created_at_utc: "2026-08-18T09:00:00Z" +subject: "Research brief: hosted-tier landscape" +body: | + ## Task + Survey the hosted-tier landscape for this class of tool: note each + project's pricing page if one exists and whether a commercial offering + sits behind it. Reply with the survey; write nothing to the repo. + + task_profile: + estimated_minutes: 20 + risk_tier: P2 + expected_files_touched: 1 + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + creates_or_updates_pr: false + comments_on_github: false + commits_changes: false + merges_pr: false + files_issues: false + sends_oacp_reply_only: true diff --git a/tests/conformance/autonomy/records/README.md b/tests/conformance/autonomy/records/README.md new file mode 100644 index 0000000..e8bbc34 --- /dev/null +++ b/tests/conformance/autonomy/records/README.md @@ -0,0 +1,18 @@ +# Audit-record integrity fixtures + +Sanitized reproductions of the terminal-record failure shapes observed in +receiver audit corpora: off-enum `completion_kind` / `final_state` +vocabulary, duplicate live evaluations of one logical message, terminal +records still carrying a paused checkpoint action, duplicated YAML keys, +`breached: true` with an empty field list, fragmented realized-axis +spellings, a `breach_basis` label contradicting its breach source +(`declared_intent` over a realized axis and a realized-true effect), +and a supersession chain hijacked by an unrelated-identity +record (`superseded_unrelated_successor`) — plus valid shapes +(`clean_terminal_done`, `final_state_superseded_valid`) that must stay +finding-free. + +`expected_findings.yaml` pins the exact finding-code multiset per record. +The executable runner is `tests/test_audit_record_conformance.py`, which +copies this directory into a scratch audit dir and compares +`finalize_autonomy_record.sweep_audit_dir` output against the pins. diff --git a/tests/conformance/autonomy/records/breach_basis_source_mismatch.yaml b/tests/conformance/autonomy/records/breach_basis_source_mismatch.yaml new file mode 100644 index 0000000..35649d0 --- /dev/null +++ b/tests/conformance/autonomy/records/breach_basis_source_mismatch.yaml @@ -0,0 +1,42 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0031 +message_sha256: 1f0031aa +decision: auto_accepted +mode: auto_review +reason_codes: +- message_valid +result: + final_state: done + completion_kind: checkpoint_paused + actual_minutes: 40 + actual_files_touched: 7 + predicted_risk_materialized: false + completed_at_utc: "2026-08-01T02:10:00Z" + envelope_enforcement: none + threshold_checkpoint: + evaluated: true + actual_minutes: 40 + actual_files_touched: 7 + side_effects_actual: + creates_or_updates_pr: false + comments_on_github: false + commits_changes: true + breached: true + breached_fields: + - actual_files_touched + declaration_errors: [] + breach_basis: declared_intent + paused_at_utc: "2026-08-01T01:40:00Z" + action: resumed_after_reauthorization + reauthorization: + presented: true + channel: receiver_human + decision: approved + decided_at_utc: "2026-08-01T01:50:00Z" + actor: alice + disposition: resumed + advisory: [] diff --git a/tests/conformance/autonomy/records/breached_empty_fields.yaml b/tests/conformance/autonomy/records/breached_empty_fields.yaml new file mode 100644 index 0000000..79782d4 --- /dev/null +++ b/tests/conformance/autonomy/records/breached_empty_fields.yaml @@ -0,0 +1,30 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0010 +message_sha256: 1f0010aa +decision: auto_accepted +mode: auto_review +reason_codes: +- message_valid +result: + final_state: paused + completion_kind: checkpoint_paused + actual_minutes: 30 + actual_files_touched: 2 + predicted_risk_materialized: true + completed_at_utc: null + envelope_enforcement: none + threshold_checkpoint: + evaluated: true + actual_minutes: 30 + actual_files_touched: 2 + side_effects_actual: {} + breached: true + breached_fields: [] + declaration_errors: [] + breach_basis: realized + paused_at_utc: "2026-08-01T01:40:00Z" + action: paused_for_reauthorization diff --git a/tests/conformance/autonomy/records/clean_terminal_done.yaml b/tests/conformance/autonomy/records/clean_terminal_done.yaml new file mode 100644 index 0000000..f6c00da --- /dev/null +++ b/tests/conformance/autonomy/records/clean_terminal_done.yaml @@ -0,0 +1,37 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0012 +message_sha256: 1f0012aa +decision: paused +mode: auto_review +reason_codes: +- expected_files_touched_exceeds_threshold +evaluation_id: eval-00000000000012aa +supersedes_evaluation_id: null +result: + final_state: done + completion_kind: admission_paused + actual_minutes: 18 + actual_files_touched: 3 + predicted_risk_materialized: false + completed_at_utc: "2026-08-01T02:00:00Z" + envelope_enforcement: none + reply_message_id: msg-20260801020000-claude-0012 + artifacts: [] + human_outcome: + recorded: true + actor: alice + decision: approved + decided_at_utc: "2026-08-01T01:05:00Z" + decision_latency_seconds: 300 + pause_reason_codes: + - expected_files_touched_exceeds_threshold + grant: + decision: not_requested + request_present: false + request_error: null + requested_scope: null + granted_scope: null diff --git a/tests/conformance/autonomy/records/duplicate_live_a.yaml b/tests/conformance/autonomy/records/duplicate_live_a.yaml new file mode 100644 index 0000000..9ecaac6 --- /dev/null +++ b/tests/conformance/autonomy/records/duplicate_live_a.yaml @@ -0,0 +1,19 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0013 +message_sha256: 1f0013aa +decision: paused +mode: auto_review +reason_codes: +- estimated_minutes_exceeds_threshold +result: + final_state: paused + completion_kind: admission_paused + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: null + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/duplicate_live_b.yaml b/tests/conformance/autonomy/records/duplicate_live_b.yaml new file mode 100644 index 0000000..59b287b --- /dev/null +++ b/tests/conformance/autonomy/records/duplicate_live_b.yaml @@ -0,0 +1,19 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T02:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0013 +message_sha256: 1f0013bb +decision: paused +mode: auto_review +reason_codes: +- estimated_minutes_exceeds_threshold +result: + final_state: paused + completion_kind: admission_paused + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: null + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/duplicate_yaml_key_logged_notes.yaml b/tests/conformance/autonomy/records/duplicate_yaml_key_logged_notes.yaml new file mode 100644 index 0000000..4ebfc84 --- /dev/null +++ b/tests/conformance/autonomy/records/duplicate_yaml_key_logged_notes.yaml @@ -0,0 +1,23 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0009 +message_sha256: 1f0009aa +decision: paused +mode: auto_review +reason_codes: +- hard_stop_sensitive_scope +logged_notes: +- code: lexical_advisory_declared + matched_pattern: merge +logged_notes: [] +result: + final_state: paused + completion_kind: admission_paused + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: null + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/expected_findings.yaml b/tests/conformance/autonomy/records/expected_findings.yaml new file mode 100644 index 0000000..375b0f6 --- /dev/null +++ b/tests/conformance/autonomy/records/expected_findings.yaml @@ -0,0 +1,46 @@ +# Expected validator/sweep finding codes per record, pinned like the +# decision-contract fixtures. Codes come from +# scripts/finalize_autonomy_record.py FINDING_SEVERITIES; the runner is +# tests/test_audit_record_conformance.py, which sweeps this directory and +# compares exact code multisets. +breached_empty_fields.yaml: +- breached_empty_fields +breach_basis_source_mismatch.yaml: +- breach_basis_incoherent +clean_terminal_done.yaml: [] +duplicate_live_a.yaml: +- duplicate_logical_id +duplicate_live_b.yaml: +- duplicate_logical_id +duplicate_yaml_key_logged_notes.yaml: +- duplicate_yaml_key +final_state_superseded_valid.yaml: [] +inconsistent_work_clock.yaml: +- work_started_before_admission +- actual_minutes_inconsistent +noncanonical_axis_names.yaml: +- noncanonical_checkpoint_axis +- noncanonical_checkpoint_axis +off_enum_final_state_completed.yaml: +- off_enum_final_state +off_enum_kind_completed_after_human_approval.yaml: +- off_enum_completion_kind +off_enum_kind_executed.yaml: +- off_enum_completion_kind +off_enum_kind_human_approved_completed.yaml: +- off_enum_completion_kind +off_enum_kind_review_round_delivered.yaml: +- off_enum_completion_kind +superseded_legacy_kind_repaired.yaml: [] +superseded_legacy_successor_live.yaml: [] +superseded_successor_live.yaml: [] +superseded_missing_successor.yaml: +- superseded_missing_successor +superseded_unrelated_successor.yaml: +- superseded_missing_successor +unrelated_identity_holder_live.yaml: [] +paused_terminal_action_unreconciled.yaml: +- paused_terminal_checkpoint_action +paused_terminal_completed_stamp.yaml: +- paused_terminal_completed +serialized_wave_item.yaml: [] diff --git a/tests/conformance/autonomy/records/final_state_superseded_valid.yaml b/tests/conformance/autonomy/records/final_state_superseded_valid.yaml new file mode 100644 index 0000000..801cd90 --- /dev/null +++ b/tests/conformance/autonomy/records/final_state_superseded_valid.yaml @@ -0,0 +1,21 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0006 +message_sha256: 1f0006aa +decision: paused +mode: auto_review +reason_codes: +- expected_files_touched_exceeds_threshold +evaluation_id: eval-00000000000006aa +superseded_by_evaluation_id: eval-00000000000006bb +result: + final_state: superseded + completion_kind: admission_paused + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: "2026-08-01T03:00:00Z" + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/inconsistent_work_clock.yaml b/tests/conformance/autonomy/records/inconsistent_work_clock.yaml new file mode 100644 index 0000000..da1f5fd --- /dev/null +++ b/tests/conformance/autonomy/records/inconsistent_work_clock.yaml @@ -0,0 +1,21 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: codex +sender: iris +message_id: msg-20260801010000-iris-badclock +message_sha256: badc10cc +decision: auto_accepted +mode: auto_review +reason_codes: +- auto_review_all_gates_passed +evaluation_id: eval-0000000000badc10 +result: + final_state: done + completion_kind: auto_accepted + work_started_at_utc: "2026-08-01T00:55:00Z" + actual_minutes: 25 + actual_files_touched: 1 + predicted_risk_materialized: false + completed_at_utc: "2026-08-01T01:45:00Z" + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/noncanonical_axis_names.yaml b/tests/conformance/autonomy/records/noncanonical_axis_names.yaml new file mode 100644 index 0000000..a6fc1be --- /dev/null +++ b/tests/conformance/autonomy/records/noncanonical_axis_names.yaml @@ -0,0 +1,32 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0011 +message_sha256: 1f0011aa +decision: auto_accepted +mode: auto_review +reason_codes: +- message_valid +result: + final_state: paused + completion_kind: checkpoint_paused + actual_minutes: 30 + actual_files_touched: 9 + predicted_risk_materialized: true + completed_at_utc: null + envelope_enforcement: none + threshold_checkpoint: + evaluated: true + actual_minutes: 30 + actual_files_touched: 9 + side_effects_actual: {} + breached: true + breached_fields: + - files_touched_actual + declaration_errors: + - github_comments + breach_basis: realized + paused_at_utc: "2026-08-01T01:40:00Z" + action: paused_for_reauthorization diff --git a/tests/conformance/autonomy/records/off_enum_final_state_completed.yaml b/tests/conformance/autonomy/records/off_enum_final_state_completed.yaml new file mode 100644 index 0000000..03279ff --- /dev/null +++ b/tests/conformance/autonomy/records/off_enum_final_state_completed.yaml @@ -0,0 +1,19 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0005 +message_sha256: 1f0005aa +decision: auto_accepted +mode: auto_review +reason_codes: +- message_valid +result: + final_state: completed + completion_kind: auto_accepted + actual_minutes: 12 + actual_files_touched: 2 + predicted_risk_materialized: false + completed_at_utc: "2026-08-01T02:00:00Z" + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/off_enum_kind_completed_after_human_approval.yaml b/tests/conformance/autonomy/records/off_enum_kind_completed_after_human_approval.yaml new file mode 100644 index 0000000..d6ec5f0 --- /dev/null +++ b/tests/conformance/autonomy/records/off_enum_kind_completed_after_human_approval.yaml @@ -0,0 +1,19 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0004 +message_sha256: 1f0004aa +decision: auto_accepted +mode: auto_review +reason_codes: +- message_valid +result: + final_state: done + completion_kind: completed_after_human_approval + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: null + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/off_enum_kind_executed.yaml b/tests/conformance/autonomy/records/off_enum_kind_executed.yaml new file mode 100644 index 0000000..c2211c3 --- /dev/null +++ b/tests/conformance/autonomy/records/off_enum_kind_executed.yaml @@ -0,0 +1,19 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0001 +message_sha256: 1f0001aa +decision: auto_accepted +mode: auto_review +reason_codes: +- message_valid +result: + final_state: done + completion_kind: executed + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: null + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/off_enum_kind_human_approved_completed.yaml b/tests/conformance/autonomy/records/off_enum_kind_human_approved_completed.yaml new file mode 100644 index 0000000..39c4dbc --- /dev/null +++ b/tests/conformance/autonomy/records/off_enum_kind_human_approved_completed.yaml @@ -0,0 +1,19 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0003 +message_sha256: 1f0003aa +decision: auto_accepted +mode: auto_review +reason_codes: +- message_valid +result: + final_state: done + completion_kind: human_approved_completed + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: null + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/off_enum_kind_review_round_delivered.yaml b/tests/conformance/autonomy/records/off_enum_kind_review_round_delivered.yaml new file mode 100644 index 0000000..b4aef21 --- /dev/null +++ b/tests/conformance/autonomy/records/off_enum_kind_review_round_delivered.yaml @@ -0,0 +1,19 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0002 +message_sha256: 1f0002aa +decision: auto_accepted +mode: auto_review +reason_codes: +- message_valid +result: + final_state: done + completion_kind: review_round_delivered + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: null + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/paused_terminal_action_unreconciled.yaml b/tests/conformance/autonomy/records/paused_terminal_action_unreconciled.yaml new file mode 100644 index 0000000..3a6d9d3 --- /dev/null +++ b/tests/conformance/autonomy/records/paused_terminal_action_unreconciled.yaml @@ -0,0 +1,39 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0007 +message_sha256: 1f0007aa +decision: auto_accepted +mode: auto_review +reason_codes: +- message_valid +result: + final_state: done + completion_kind: checkpoint_paused + actual_minutes: 55 + actual_files_touched: 6 + predicted_risk_materialized: true + completed_at_utc: "2026-08-01T02:10:00Z" + envelope_enforcement: none + threshold_checkpoint: + evaluated: true + actual_minutes: 55 + actual_files_touched: 6 + side_effects_actual: {} + breached: true + breached_fields: + - actual_files_touched + declaration_errors: [] + breach_basis: realized + paused_at_utc: "2026-08-01T01:40:00Z" + action: paused_for_reauthorization + reauthorization: + presented: true + channel: receiver_human + decision: approved + decided_at_utc: "2026-08-01T01:50:00Z" + actor: alice + disposition: resumed + advisory: [] diff --git a/tests/conformance/autonomy/records/paused_terminal_completed_stamp.yaml b/tests/conformance/autonomy/records/paused_terminal_completed_stamp.yaml new file mode 100644 index 0000000..1edfd29 --- /dev/null +++ b/tests/conformance/autonomy/records/paused_terminal_completed_stamp.yaml @@ -0,0 +1,19 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0008 +message_sha256: 1f0008aa +decision: paused +mode: auto_review +reason_codes: +- merges_pr_pause +result: + final_state: paused + completion_kind: admission_paused + actual_minutes: 20 + actual_files_touched: 3 + predicted_risk_materialized: false + completed_at_utc: "2026-08-01T02:00:00Z" + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/serialized_wave_item.yaml b/tests/conformance/autonomy/records/serialized_wave_item.yaml new file mode 100644 index 0000000..7359c45 --- /dev/null +++ b/tests/conformance/autonomy/records/serialized_wave_item.yaml @@ -0,0 +1,52 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: codex +sender: iris +message_id: msg-20260801010000-iris-0284 +message_sha256: 1f0284aa +decision: auto_accepted +mode: auto_review +reason_codes: +- auto_review_all_gates_passed +evaluation_id: eval-0000000000000284 +scope_envelope: + estimated_minutes: 30 + expected_files_touched: 5 + risk_tier: P2 + target_repo: example-org/private-repo + destructive_ops: false + external_side_effects: false + touches_auth_config_or_secrets: false + touches_dependencies: false + public_visibility: false + creates_or_updates_pr: false + comments_on_github: false + commits_changes: false + merges_pr: false + files_issues: false + sends_oacp_reply_only: true + continuation_grants: {} +result: + final_state: done + completion_kind: auto_accepted + work_started_at_utc: "2026-08-01T01:20:00Z" + actual_minutes: 25 + actual_files_touched: 1 + predicted_risk_materialized: false + completed_at_utc: "2026-08-01T01:45:00Z" + envelope_enforcement: none + threshold_checkpoint: + evaluated: true + actual_minutes: 25 + actual_files_touched: 1 + side_effects_actual: {} + breached: false + breached_fields: [] + declaration_errors: [] + breach_basis: null + breach_sub_basis: null + paused_at_utc: null + action: continue + predicted_risk_materialized: false + completed_at_utc: "2026-08-01T01:45:00Z" diff --git a/tests/conformance/autonomy/records/superseded_legacy_kind_repaired.yaml b/tests/conformance/autonomy/records/superseded_legacy_kind_repaired.yaml new file mode 100644 index 0000000..7fbc25e --- /dev/null +++ b/tests/conformance/autonomy/records/superseded_legacy_kind_repaired.yaml @@ -0,0 +1,22 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0015 +message_sha256: 1f0015aa +decision: auto_accepted +mode: auto_review +reason_codes: +- message_valid +evaluation_id: eval-00000000000015aa +superseded_by_evaluation_id: eval-00000000000015bb +result: + final_state: superseded + completion_kind: executed + legacy_final_state: completed + actual_minutes: 12 + actual_files_touched: 2 + predicted_risk_materialized: false + completed_at_utc: "2026-08-01T03:00:00Z" + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/superseded_legacy_successor_live.yaml b/tests/conformance/autonomy/records/superseded_legacy_successor_live.yaml new file mode 100644 index 0000000..bed1beb --- /dev/null +++ b/tests/conformance/autonomy/records/superseded_legacy_successor_live.yaml @@ -0,0 +1,21 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T02:30:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0015 +message_sha256: 1f0015bb +decision: paused +mode: auto_review +reason_codes: +- estimated_minutes_exceeds_threshold +evaluation_id: eval-00000000000015bb +supersedes_evaluation_id: eval-00000000000015aa +result: + final_state: paused + completion_kind: admission_paused + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: null + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/superseded_missing_successor.yaml b/tests/conformance/autonomy/records/superseded_missing_successor.yaml new file mode 100644 index 0000000..0d34818 --- /dev/null +++ b/tests/conformance/autonomy/records/superseded_missing_successor.yaml @@ -0,0 +1,19 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0014 +message_sha256: 1f0014aa +decision: paused +mode: auto_review +reason_codes: +- estimated_minutes_exceeds_threshold +result: + final_state: superseded + completion_kind: admission_paused + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: "2026-08-01T03:00:00Z" + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/superseded_successor_live.yaml b/tests/conformance/autonomy/records/superseded_successor_live.yaml new file mode 100644 index 0000000..4b9a179 --- /dev/null +++ b/tests/conformance/autonomy/records/superseded_successor_live.yaml @@ -0,0 +1,21 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T02:30:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0006 +message_sha256: 1f0006bb +decision: paused +mode: auto_review +reason_codes: +- expected_files_touched_exceeds_threshold +evaluation_id: eval-00000000000006bb +supersedes_evaluation_id: eval-00000000000006aa +result: + final_state: paused + completion_kind: admission_paused + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: null + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/superseded_unrelated_successor.yaml b/tests/conformance/autonomy/records/superseded_unrelated_successor.yaml new file mode 100644 index 0000000..d59319b --- /dev/null +++ b/tests/conformance/autonomy/records/superseded_unrelated_successor.yaml @@ -0,0 +1,21 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T01:00:00Z" +receiver: claude +sender: alice +message_id: msg-20260801010000-alice-0016 +message_sha256: 1f0016aa +decision: paused +mode: auto_review +reason_codes: +- estimated_minutes_exceeds_threshold +evaluation_id: eval-00000000000016aa +superseded_by_evaluation_id: eval-00000000000099bb +result: + final_state: superseded + completion_kind: admission_paused + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: "2026-08-01T03:00:00Z" + envelope_enforcement: none diff --git a/tests/conformance/autonomy/records/unrelated_identity_holder_live.yaml b/tests/conformance/autonomy/records/unrelated_identity_holder_live.yaml new file mode 100644 index 0000000..6729077 --- /dev/null +++ b/tests/conformance/autonomy/records/unrelated_identity_holder_live.yaml @@ -0,0 +1,20 @@ +schema_version: 2 +spec_version: "0.4.3" +created_at_utc: "2026-08-01T02:30:00Z" +receiver: bob +sender: alice +message_id: msg-20260801010000-alice-0099 +message_sha256: 1f0099bb +decision: paused +mode: auto_review +reason_codes: +- estimated_minutes_exceeds_threshold +evaluation_id: eval-00000000000099bb +result: + final_state: paused + completion_kind: admission_paused + actual_minutes: null + actual_files_touched: null + predicted_risk_materialized: false + completed_at_utc: null + envelope_enforcement: none diff --git a/tests/conformance/org_memory/README.md b/tests/conformance/org_memory/README.md new file mode 100644 index 0000000..267e161 --- /dev/null +++ b/tests/conformance/org_memory/README.md @@ -0,0 +1,22 @@ +# Org-memory debrief store conformance corpus + +Pinned decision contract for `oacp doctor`'s Org Memory category +(`check_org_memory` in `scripts/oacp_doctor.py`), which checks the +setup of the central debrief store specified in +`docs/protocol/org_memory.md` → "Debrief Store": directory presence, +canonical path layout, lingering staging artifacts, and irregular +entries. The doctor never opens debrief files — content and format +verification belong to the writer contract and git history, so no case +here exercises file contents. + +Each case under `cases//` holds: + +- `org-memory/` — a miniature store tree copied into a temp OACP home + by the runner (`tests/test_org_memory_doctor.py`). +- `expected.yaml` — the exact set of non-ok findings the checker must + emit (`findings: []` means the store must validate clean), each as + `name` + `severity` + optional `message_contains`. + +Finding names and severities here are pinned: changing them is a +behavior change to doctor's output contract and must update this corpus +in the same commit. diff --git a/tests/conformance/org_memory/cases/bad_layout/expected.yaml b/tests/conformance/org_memory/cases/bad_layout/expected.yaml new file mode 100644 index 0000000..a794d78 --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/expected.yaml @@ -0,0 +1,5 @@ +description: Wrong nesting depth, underscore filename, month-dir mismatch, leading-dot project, uppercase session. +findings: + - name: debriefs-layout + severity: error + message_contains: "5 of 6" diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md new file mode 100644 index 0000000..3b84380 --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/.hidden/2026/08/20260825-alice-abc12345.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: .hidden +agent: alice +runtime: claude +session: abc12345 +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: f40d183437c0c35d09035a7b54e31f94b454be00cdbe8e13200a31c60898398d +immutable: true +--- +# Session debrief + +Compatibility-identity session. diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md new file mode 100644 index 0000000..5c6f0ee --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: 1f3a9c2b +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: 6cb19ca755c8b264e8432c782ca59b6954d88fe880c4b82949e7f67723390864 +immutable: true +--- +# Session debrief + +Implemented the widget; tests green. diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md new file mode 100644 index 0000000..8e28157 --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825-alice-ABCD.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: ABCD +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: f40d183437c0c35d09035a7b54e31f94b454be00cdbe8e13200a31c60898398d +immutable: true +--- +# Session debrief + +Compatibility-identity session. diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md new file mode 100644 index 0000000..9fda75f --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/08/20260825_alice_abc12345.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: abc12345 +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: 6cb19ca755c8b264e8432c782ca59b6954d88fe880c4b82949e7f67723390864 +immutable: true +--- +# Session debrief + +Implemented the widget; tests green. diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md new file mode 100644 index 0000000..5701bc4 --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/2026/09/20260825-alice-def12345.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: def12345 +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: 6cb19ca755c8b264e8432c782ca59b6954d88fe880c4b82949e7f67723390864 +immutable: true +--- +# Session debrief + +Implemented the widget; tests green. diff --git a/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md new file mode 100644 index 0000000..9fda75f --- /dev/null +++ b/tests/conformance/org_memory/cases/bad_layout/org-memory/debriefs/demo-project/20260825-alice-abc12345.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: abc12345 +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: 6cb19ca755c8b264e8432c782ca59b6954d88fe880c4b82949e7f67723390864 +immutable: true +--- +# Session debrief + +Implemented the widget; tests green. diff --git a/tests/conformance/org_memory/cases/empty_store/expected.yaml b/tests/conformance/org_memory/cases/empty_store/expected.yaml new file mode 100644 index 0000000..b7c9448 --- /dev/null +++ b/tests/conformance/org_memory/cases/empty_store/expected.yaml @@ -0,0 +1,2 @@ +description: Fresh-init store — .gitkeep only, nothing to validate. +findings: [] diff --git a/tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep b/tests/conformance/org_memory/cases/empty_store/org-memory/debriefs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.yaml b/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.yaml new file mode 100644 index 0000000..2d2cef6 --- /dev/null +++ b/tests/conformance/org_memory/cases/missing_debriefs_dir/expected.yaml @@ -0,0 +1,5 @@ +description: org-memory exists but debriefs/ was never created. +findings: + - name: debriefs-dir + severity: warn + message_contains: "missing" diff --git a/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md b/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md new file mode 100644 index 0000000..26fb610 --- /dev/null +++ b/tests/conformance/org_memory/cases/missing_debriefs_dir/org-memory/recent.md @@ -0,0 +1 @@ +# Recent diff --git a/tests/conformance/org_memory/cases/staging_artifact/expected.yaml b/tests/conformance/org_memory/cases/staging_artifact/expected.yaml new file mode 100644 index 0000000..d0a4b6e --- /dev/null +++ b/tests/conformance/org_memory/cases/staging_artifact/expected.yaml @@ -0,0 +1,5 @@ +description: A lingering writer staging artifact beside a valid record — interrupted publication. +findings: + - name: debriefs-staging + severity: warn + message_contains: "interrupted publication" diff --git a/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 b/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 new file mode 100644 index 0000000..72d4134 --- /dev/null +++ b/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/.stage.20260825-alice-77xx88yy.md.a1b2c3 @@ -0,0 +1 @@ +partial staged bytes \ No newline at end of file diff --git a/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md b/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md new file mode 100644 index 0000000..b222f13 --- /dev/null +++ b/tests/conformance/org_memory/cases/staging_artifact/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: 1f3a9c2b +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: a9ec70f37ee6d325a05fee8206d65e2872eeb66c576377d0f271349007a09336 +immutable: true +--- +# Session debrief + +Interrupted-publication neighbor. diff --git a/tests/conformance/org_memory/cases/valid_store/expected.yaml b/tests/conformance/org_memory/cases/valid_store/expected.yaml new file mode 100644 index 0000000..025065e --- /dev/null +++ b/tests/conformance/org_memory/cases/valid_store/expected.yaml @@ -0,0 +1,2 @@ +description: Fully conforming store — canonical-grammar identities (hyphen/uppercase/dot/underscore agents and projects), unquoted and quoted timestamps. +findings: [] diff --git a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md new file mode 100644 index 0000000..2370740 --- /dev/null +++ b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/Demo_Project/2026/08/20260825-bob-ops-9f00aa11.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: Demo_Project +agent: bob-ops +runtime: claude +session: 9f00aa11 +started_utc: 2026-08-25T10:00:00Z +ended_utc: 2026-08-25T11:30:00Z +content_sha256: f40d183437c0c35d09035a7b54e31f94b454be00cdbe8e13200a31c60898398d +immutable: true +--- +# Session debrief + +Compatibility-identity session. diff --git a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md new file mode 100644 index 0000000..5c6f0ee --- /dev/null +++ b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo-project +agent: alice +runtime: claude +session: 1f3a9c2b +started_utc: 2026-08-25T20:04:11Z +ended_utc: 2026-08-25T22:01:47Z +content_sha256: 6cb19ca755c8b264e8432c782ca59b6954d88fe880c4b82949e7f67723390864 +immutable: true +--- +# Session debrief + +Implemented the widget; tests green. diff --git a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md new file mode 100644 index 0000000..1d94a01 --- /dev/null +++ b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/demo.project/2026/07/20260701-Alice_2.dev-00aa11bb.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: demo.project +agent: Alice_2.dev +runtime: claude +session: 00aa11bb +started_utc: 2026-07-01T09:00:00Z +ended_utc: 2026-07-01T09:45:00Z +content_sha256: f40d183437c0c35d09035a7b54e31f94b454be00cdbe8e13200a31c60898398d +immutable: true +--- +# Session debrief + +Compatibility-identity session. diff --git a/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md new file mode 100644 index 0000000..459d303 --- /dev/null +++ b/tests/conformance/org_memory/cases/valid_store/org-memory/debriefs/other-project/2026/12/20261203-bob-9e0d44aa.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +project: other-project +agent: bob +runtime: claude +session: 9e0d44aa +started_utc: "2026-12-03T08:15:00Z" +ended_utc: "2026-12-03T09:00:00Z" +content_sha256: bd281cfd095deb397218f7e24aba3ce38f39c20ba4f82eb6b9d7947aac547436 +immutable: true +--- +# Session debrief + +Reviewed the gadget; two findings filed. diff --git a/tests/test_add_agent.py b/tests/test_add_agent.py index 58e8e8a..e8386c1 100644 --- a/tests/test_add_agent.py +++ b/tests/test_add_agent.py @@ -4,6 +4,8 @@ from __future__ import annotations +from contextlib import redirect_stderr +from io import StringIO import sys import tempfile import unittest @@ -11,7 +13,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) -from add_agent import add_agent # noqa: E402 +from add_agent import add_agent, main # noqa: E402 class TestAddAgent(unittest.TestCase): @@ -66,6 +68,16 @@ def test_creates_status_and_card_with_runtime(self) -> None: # 5 gitkeeps + config.yaml + status.yaml + agent_card.yaml = 8 self.assertEqual(len(result["created_files"]), 8) + import yaml + + profile = yaml.safe_load( + (oacp_root / "agents" / "bob" / "profile.yaml").read_text( + encoding="utf-8" + ) + ) + self.assertEqual(profile["runtime"], "claude") + self.assertEqual(profile["projects"], ["demo"]) + def test_creates_cursor_status_and_card(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: oacp_root = self._make_project(Path(tmpdir)) @@ -142,6 +154,64 @@ def test_idempotent_no_overwrite(self) -> None: ) self.assertEqual(len(result2["created_files"]), 0) self.assertEqual(len(result2["skipped_files"]), 8) + self.assertEqual(result2["registry_update"]["action"], "unchanged") + + def test_registry_update_preserves_hand_edited_identity(self) -> None: + import yaml + + with tempfile.TemporaryDirectory() as tmpdir: + oacp_root = self._make_project(Path(tmpdir)) + profile_path = oacp_root / "agents" / "alice" / "profile.yaml" + profile_path.parent.mkdir(parents=True) + profile_path.write_text( + yaml.safe_dump( + { + "version": "0.2.0", + "name": "Alice Display", + "runtime": "human", + "model": "hand-edited-model", + "description": "Hand-edited description", + "projects": ["existing"], + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + add_agent("demo", "alice", oacp_root=oacp_root, runtime="codex") + + profile = yaml.safe_load(profile_path.read_text(encoding="utf-8")) + self.assertEqual(profile["name"], "Alice Display") + self.assertEqual(profile["runtime"], "human") + self.assertEqual(profile["model"], "hand-edited-model") + self.assertEqual(profile["description"], "Hand-edited description") + self.assertEqual(profile["projects"], ["existing", "demo"]) + + def test_malformed_registry_profile_is_a_clean_cli_error(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + oacp_root = self._make_project(Path(tmpdir)) + profile_path = oacp_root / "agents" / "alice" / "profile.yaml" + profile_path.parent.mkdir(parents=True) + profile_path.write_text( + "name: alice\nruntime: [unclosed\n", encoding="utf-8" + ) + stderr = StringIO() + + with redirect_stderr(stderr): + rc = main( + [ + "demo", + "alice", + "--runtime", + "claude", + "--oacp-dir", + str(oacp_root), + ] + ) + + self.assertEqual(rc, 1) + self.assertIn("invalid YAML", stderr.getvalue()) + self.assertNotIn("Traceback", stderr.getvalue()) def test_agent_name_max_length(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/test_agent_profile.py b/tests/test_agent_profile.py index 1bae330..967f40a 100644 --- a/tests/test_agent_profile.py +++ b/tests/test_agent_profile.py @@ -17,11 +17,15 @@ from agent_profile import ( # noqa: E402 cmd_init, cmd_list, + cmd_sync, cmd_show, + discover_project_memberships, load_global_profile, load_project_card, merge_profiles, resolve_agent_profile, + sync_agent_registry, + upsert_global_profile, ) @@ -336,9 +340,9 @@ def test_creates_global_profile(self) -> None: self.assertEqual(rc, 0) profile_path = root / "agents" / "claude" / "profile.yaml" self.assertTrue(profile_path.is_file()) - content = profile_path.read_text(encoding="utf-8") - self.assertIn('name: "claude"', content) - self.assertIn('runtime: "claude"', content) + content = yaml.safe_load(profile_path.read_text(encoding="utf-8")) + self.assertEqual(content["name"], "claude") + self.assertEqual(content["runtime"], "claude") def test_idempotent(self) -> None: """Running init twice doesn't overwrite existing profile.""" @@ -415,6 +419,7 @@ def test_global_agents(self) -> None: self.assertIn("claude", out) self.assertIn("codex", out) self.assertIn("global", out) + self.assertIn("runtime=claude", out) def test_project_agents(self) -> None: """Lists agents from a project.""" @@ -492,6 +497,140 @@ def test_agents_sorted_alphabetically(self) -> None: self.assertEqual(names, ["alpha", "mike", "zulu"]) +class TestAgentRegistry(unittest.TestCase): + def test_workspace_init_registers_custom_agents(self) -> None: + from init_project_workspace import initialize_workspace + + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + result = initialize_workspace( + "demo", oacp_root=root, agents=["alice", "bob"] + ) + + self.assertEqual(result["project_root"], root / "projects" / "demo") + alice = load_global_profile(root, "alice") + bob = load_global_profile(root, "bob") + assert alice is not None + assert bob is not None + self.assertEqual(alice["runtime"], "unknown") + self.assertEqual(alice["projects"], ["demo"]) + self.assertEqual(bob["projects"], ["demo"]) + + def test_upsert_preserves_identity_and_appends_memberships(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _global_profile(root, "claude", { + "name": "Claude Custom", + "runtime": "human", + "model": "custom-model", + "description": "Custom description", + "projects": ["alpha"], + }) + + result = upsert_global_profile( + root, + "claude", + "claude", + projects=["alpha", "beta"], + model="default-model", + description="Default description", + ) + + self.assertEqual(result["action"], "updated") + profile = load_global_profile(root, "claude") + self.assertIsNotNone(profile) + assert profile is not None + self.assertEqual(profile["name"], "Claude Custom") + self.assertEqual(profile["runtime"], "human") + self.assertEqual(profile["model"], "custom-model") + self.assertEqual(profile["description"], "Custom description") + self.assertEqual(profile["projects"], ["alpha", "beta"]) + + repeated = upsert_global_profile( + root, + "claude", + "claude", + projects=["beta"], + ) + self.assertEqual(repeated["action"], "unchanged") + + def test_sync_backfills_deduped_agents_and_is_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _project_card(root, "alpha", "claude", { + "name": "claude", + "runtime": "claude", + "model": "opus", + "description": "Claude agent", + }) + _project_card(root, "beta", "claude", { + "name": "claude", + "runtime": "claude", + }) + _project_card(root, "beta", "carol", { + "name": "carol", + "runtime": "claude", + }) + + memberships = discover_project_memberships(root) + self.assertEqual(memberships, { + "claude": ["alpha", "beta"], + "carol": ["beta"], + }) + + first = sync_agent_registry(root) + self.assertEqual(first["agents"], 2) + self.assertEqual(first["memberships"], 3) + self.assertEqual(first["created"], 2) + claude = load_global_profile(root, "claude") + carol = load_global_profile(root, "carol") + assert claude is not None + assert carol is not None + self.assertEqual(claude["projects"], ["alpha", "beta"]) + self.assertEqual(carol["runtime"], "claude") + + second = sync_agent_registry(root) + self.assertEqual(second["unchanged"], 2) + self.assertEqual(second["created"], 0) + self.assertEqual(second["updated"], 0) + + args = _make_args() + rc, out = _capture_stdout(cmd_sync, args, root) + self.assertEqual(rc, 0) + self.assertIn("2 agent(s), 3 project membership(s)", out) + + def test_malformed_profile_is_a_clean_sync_and_list_error(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + _project_card(root, "alpha", "claude", {"name": "claude"}) + profile_path = root / "agents" / "claude" / "profile.yaml" + profile_path.parent.mkdir(parents=True) + profile_path.write_text( + "name: claude\nruntime: [unclosed\n", encoding="utf-8" + ) + + sync_rc, sync_err = _capture_stderr(cmd_sync, _make_args(), root) + list_rc, list_err = _capture_stderr( + cmd_list, _make_args(project=None), root + ) + + self.assertEqual(sync_rc, 1) + self.assertEqual(list_rc, 1) + self.assertIn("invalid YAML", sync_err) + self.assertIn("invalid YAML", list_err) + self.assertNotIn("Traceback", sync_err + list_err) + + profile_path.write_text( + "name: claude\nruntime: claude\nprojects: invalid\n", + encoding="utf-8", + ) + schema_rc, schema_err = _capture_stderr( + cmd_list, _make_args(project=None), root + ) + self.assertEqual(schema_rc, 1) + self.assertIn("projects must be a list of strings", schema_err) + + # --------------------------------------------------------------------------- # Tests: cmd_show # --------------------------------------------------------------------------- diff --git a/tests/test_audit_record_conformance.py b/tests/test_audit_record_conformance.py new file mode 100644 index 0000000..4f5a203 --- /dev/null +++ b/tests/test_audit_record_conformance.py @@ -0,0 +1,89 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Executable runner for the audit-record integrity fixtures.""" + +from __future__ import annotations + +import shutil +import sys +from collections import Counter +from pathlib import Path + +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from finalize_autonomy_record import ( # noqa: E402 + FINDING_SEVERITIES, + sweep_audit_dir, +) + + +RECORDS_ROOT = Path(__file__).parent / "conformance" / "autonomy" / "records" + + +def _expected() -> dict: + data = yaml.safe_load( + (RECORDS_ROOT / "expected_findings.yaml").read_text(encoding="utf-8") + ) + assert isinstance(data, dict) and data + return data + + +def test_every_record_fixture_is_pinned() -> None: + expected = _expected() + fixture_names = { + path.name + for path in RECORDS_ROOT.glob("*.yaml") + if path.name != "expected_findings.yaml" + } + assert fixture_names == set(expected) + + +def test_sweep_matches_pinned_finding_codes(tmp_path: Path) -> None: + expected = _expected() + audit_dir = tmp_path / "autonomy_decisions" + audit_dir.mkdir() + for name in expected: + shutil.copy(RECORDS_ROOT / name, audit_dir / name) + + report = sweep_audit_dir(audit_dir) + + assert set(report["records"]) == set(expected) + for name, findings in sorted(report["records"].items()): + codes = Counter(finding["code"] for finding in findings) + assert codes == Counter(expected[name]), name + for finding in findings: + assert finding["severity"] == FINDING_SEVERITIES[finding["code"]] + + (duplicate_group,) = report["duplicate_groups"] + assert duplicate_group["files"] == [ + "duplicate_live_a.yaml", + "duplicate_live_b.yaml", + ] + + +def test_all_finding_codes_have_fixture_coverage() -> None: + """Every pinned error-severity code appears in at least one fixture. + + ``record_unparsable``, ``missing_completion_kind``, + ``terminal_paused_without_outcome``, ``decision_kind_incoherent``, + ``invalid_human_outcome``, ``off_enum_breach_basis``, and the advisory + ``terminal_missing_actuals`` + are covered by unit tests instead — their shapes are synthetic, not + corpus-observed. + """ + expected = _expected() + pinned_here = {code for codes in expected.values() for code in codes} + corpus_codes = { + "off_enum_completion_kind", + "off_enum_final_state", + "duplicate_logical_id", + "duplicate_yaml_key", + "paused_terminal_checkpoint_action", + "paused_terminal_completed", + "breached_empty_fields", + "noncanonical_checkpoint_axis", + "breach_basis_incoherent", + } + assert corpus_codes <= pinned_here diff --git a/tests/test_autonomy_gate.py b/tests/test_autonomy_gate.py index 6b6391c..1131213 100644 --- a/tests/test_autonomy_gate.py +++ b/tests/test_autonomy_gate.py @@ -12,7 +12,7 @@ import sys from datetime import datetime from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List import pytest import yaml @@ -20,10 +20,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) from autonomy_gate import ( # noqa: E402 + ADMISSION_AXES, + BREACH_BASES, + BREACH_SUB_BASES, PINNED_COMPLETION_KINDS, PINNED_REASON_CODES, RUNTIME_MODEL_ENV_VAR, _base_result, + admission_ledger_codes, canonical_policy_sha256, evaluate_autonomy, evaluate_threshold_checkpoint, @@ -122,9 +126,50 @@ def test_autonomy_gate_matches_conformance_fixtures(tmp_path: Path) -> None: == expected["co_occurring_reason_codes"] ), expected_path.name + if "admission_axes" in expected: + # The ledger is pinned exactly: an extra or missing axis entry + # is a contract change, not implementation detail. + assert decision["admission_axes"] == expected["admission_axes"], ( + expected_path.name + ) + if "task_profile" in expected: _assert_subset(expected["task_profile"], decision["task_profile"]) + if "scope_envelope_source" in expected: + # Every admitted envelope names where it came from; pinning the + # source pins the profileless default-envelope path itself. + assert ( + decision["scope_envelope_source"] == expected["scope_envelope_source"] + ), expected_path.name + + if "scope_envelope" in expected: + _assert_subset(expected["scope_envelope"], decision["scope_envelope"]) + + for hit in decision["matched_patterns"]: + assert set(hit) == { + "pattern", + "category", + "span", + "demotion_basis", + }, expected_path.name + assert set(hit["span"]) == {"start", "end"}, expected_path.name + start, end = hit["span"]["start"], hit["span"]["end"] + assert 0 <= start < end <= len(message["body"]), expected_path.name + assert hit["demotion_basis"], expected_path.name + + if decision.get("matched_pattern"): + blocking_hits = [ + hit + for hit in decision["matched_patterns"] + if hit["pattern"] == decision["matched_pattern"] + ] + assert blocking_hits, expected_path.name + assert any( + hit["demotion_basis"] in {"affirmative", "non_demotable"} + for hit in blocking_hits + ), expected_path.name + assert set(decision["reason_codes"]) <= PINNED_REASON_CODES assert "completed_at_utc" in decision["result"] @@ -264,6 +309,7 @@ def test_autonomy_gate_records_hash_for_always_pause_mode() -> None: assert decision["decision"] == "paused" assert decision["reason_codes"] == ["mode_always_pause"] assert decision["message_sha256"] == expected_hash + assert decision["result"]["work_started_at_utc"] is None def test_autonomy_gate_records_hash_for_malformed_config() -> None: @@ -317,6 +363,399 @@ def test_guardrails_fence_keeps_operative_terms_visible_as_advisories() -> None: assert "lexical_advisory" in decision["reason_codes"] +def _lexical_fp_decision(task_text: str) -> tuple[Dict[str, Any], str]: + config = _load_yaml(FIXTURE_ROOT / "configs" / "auto_review_standard.yaml") + message = _load_yaml(FIXTURE_ROOT / "messages" / "clean_task.yaml") + body = message["body"].replace( + "Update one documentation paragraph for clarity.", + task_text, + ) + message["body"] = body + return evaluate_autonomy(message, config), body + + +def _assert_lexical_hit( + decision: Dict[str, Any], + body: str, + pattern: str, + demotion_basis: str, +) -> None: + hits = [ + hit for hit in decision["matched_patterns"] if hit["pattern"] == pattern + ] + assert hits + for hit in hits: + assert set(hit) == {"pattern", "category", "span", "demotion_basis"} + assert hit["demotion_basis"] == demotion_basis + assert set(hit["span"]) == {"start", "end"} + start, end = hit["span"]["start"], hit["span"]["end"] + assert 0 <= start < end <= len(body) + + +def test_lexical_fp_merge_method_reference_fixture() -> None: + decision, body = _lexical_fp_decision( + "Document the repository's squash-only merge method." + ) + + assert decision["decision"] == "auto_accepted" + assert decision["reason_codes"][-2:] == ["lexical_advisory", "workspace_check_required"] + _assert_lexical_hit(decision, body, "merge", "reference_only") + + +@pytest.mark.parametrize( + "task_text", + [ + "Describe what pip install pulls into the runtime dependencies.", + "Explain how install/build is read as dependency-class behavior.", + ], +) +def test_lexical_fp_descriptive_install_reference_fixture(task_text: str) -> None: + decision, body = _lexical_fp_decision(task_text) + + assert decision["decision"] == "auto_accepted" + assert decision["reason_codes"][-2:] == ["lexical_advisory", "workspace_check_required"] + _assert_lexical_hit(decision, body, "install dependency", "reference_only") + + +def test_lexical_fp_negated_non_demotable_contexts_fixture() -> None: + decision, body = _lexical_fp_decision( + "Out of scope: anything on the public repo. Do not install dependencies." + ) + + assert decision["decision"] == "auto_accepted" + assert decision["reason_codes"][-2:] == ["lexical_advisory", "workspace_check_required"] + _assert_lexical_hit(decision, body, "public repo", "negated") + _assert_lexical_hit(decision, body, "install dependency", "negated") + + +def test_non_demotable_context_demotion_does_not_cross_clause() -> None: + decision, body = _lexical_fp_decision( + "Do not install a local tool. Install dependencies for the task." + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_external_side_effect"] + assert decision["matched_pattern"] == "install dependency" + _assert_lexical_hit(decision, body, "install dependency", "non_demotable") + + +def test_install_reference_does_not_demote_affirmative_match_in_same_clause() -> None: + decision, _body = _lexical_fp_decision( + "Install the new dependencies after you note what pip install pulls " + "for runtime dependencies" + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_external_side_effect"] + assert decision["matched_pattern"] == "install dependency" + install_hits = [ + hit + for hit in decision["matched_patterns"] + if hit["pattern"] == "install dependency" + ] + assert [hit["demotion_basis"] for hit in install_hits] == [ + "non_demotable", + "reference_only", + ] + + +def test_install_reference_does_not_demote_later_affirmative_match() -> None: + decision, _body = _lexical_fp_decision( + "Describe what pip install pulls for runtime dependencies before you " + "install new dependencies" + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_external_side_effect"] + assert decision["matched_pattern"] == "install dependency" + install_hits = [ + hit + for hit in decision["matched_patterns"] + if hit["pattern"] == "install dependency" + ] + assert [hit["demotion_basis"] for hit in install_hits] == [ + "reference_only", + "non_demotable", + ] + + +def test_public_repo_negation_does_not_cross_exception_in_same_clause() -> None: + decision, _body = _lexical_fp_decision( + "Out of scope: the public repo except publish to the public repo" + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_sensitive_scope"] + assert decision["matched_pattern"] == "public repo" + public_repo_hits = [ + hit + for hit in decision["matched_patterns"] + if hit["pattern"] == "public repo" + ] + assert [hit["demotion_basis"] for hit in public_repo_hits] == [ + "negated", + "non_demotable", + ] + + +def test_wrapped_public_repository_provenance_matches_hard_stop() -> None: + decision, body = _lexical_fp_decision( + "Out of scope: anything on the public\nrepository." + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_sensitive_scope"] + assert decision["matched_pattern"] == "public repo" + _assert_lexical_hit(decision, body, "public repo", "non_demotable") + + +def test_install_match_does_not_bridge_negated_and_affirmative_occurrences() -> None: + decision, body = _lexical_fp_decision( + "Do not install tooling except install the runtime dependencies" + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_external_side_effect"] + assert decision["matched_pattern"] == "install dependency" + _assert_lexical_hit(decision, body, "install dependency", "non_demotable") + + +def test_install_match_with_internal_negation_remains_hard() -> None: + decision, body = _lexical_fp_decision( + "Do not install tooling without runtime dependencies" + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_external_side_effect"] + assert decision["matched_pattern"] == "install dependency" + _assert_lexical_hit(decision, body, "install dependency", "non_demotable") + + +def test_one_negation_cannot_demote_two_install_dependency_occurrences() -> None: + decision, _body = _lexical_fp_decision( + "Do not install development dependencies while install runtime dependencies" + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_external_side_effect"] + assert decision["matched_pattern"] == "install dependency" + install_hits = [ + hit + for hit in decision["matched_patterns"] + if hit["pattern"] == "install dependency" + ] + assert [hit["demotion_basis"] for hit in install_hits] == [ + "negated", + "non_demotable", + ] + + +def test_fresh_negation_can_govern_later_install_dependency_occurrence() -> None: + decision, body = _lexical_fp_decision( + "Do not install development dependencies while do not install runtime " + "dependencies" + ) + + assert decision["decision"] == "auto_accepted" + assert decision["reason_codes"][-2:] == [ + "lexical_advisory", + "workspace_check_required", + ] + _assert_lexical_hit(decision, body, "install dependency", "negated") + + +@pytest.mark.parametrize( + "intervening_wording", + [ + "aside from", + "other than", + "apart from", + "besides", + "save for", + "excluding", + "though", + "whereas", + "yet", + "instead", + "unrecognized connective", + ], +) +def test_one_negation_cannot_demote_two_public_repo_occurrences( + intervening_wording: str, +) -> None: + decision, _body = _lexical_fp_decision( + "Out of scope: the public repo " + f"{intervening_wording} publish to the public repo" + ) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_sensitive_scope"] + assert decision["matched_pattern"] == "public repo" + public_repo_hits = [ + hit + for hit in decision["matched_patterns"] + if hit["pattern"] == "public repo" + ] + assert [hit["demotion_basis"] for hit in public_repo_hits] == [ + "negated", + "non_demotable", + ] + + +def test_fresh_negation_can_govern_later_public_repo_occurrence() -> None: + decision, body = _lexical_fp_decision( + "Out of scope: the public repo whereas do not publish to the public repo" + ) + + assert decision["decision"] == "auto_accepted" + assert decision["reason_codes"][-2:] == [ + "lexical_advisory", + "workspace_check_required", + ] + _assert_lexical_hit(decision, body, "public repo", "negated") + + +@pytest.mark.parametrize( + "task_text", + [ + "Do not touch the staging config, but publish the docs to the public repo", + "Do not touch the staging config, except publish the docs to the public repo", + "Do not touch the staging config, however publish the docs to the public repo", + "Out of scope: the staging config, but publish the docs to the public repo", + "Do not touch the staging config, aside from publish to the public repo", + "Do not touch the staging config, then publish to the public repo", + ], +) +def test_unrelated_negation_does_not_demote_first_public_repo_occurrence( + task_text: str, +) -> None: + decision, body = _lexical_fp_decision(task_text) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_sensitive_scope"] + assert decision["matched_pattern"] == "public repo" + _assert_lexical_hit(decision, body, "public repo", "non_demotable") + + +@pytest.mark.parametrize( + "task_text", + [ + "Do not review anything, but install the runtime dependencies", + "Do not change the lockfile except install the runtime dependencies", + "Avoid touching the lockfile, but install the runtime dependencies", + "Skip the cleanup step, then install the runtime dependencies", + ], +) +def test_unrelated_negation_does_not_demote_first_install_occurrence( + task_text: str, +) -> None: + decision, body = _lexical_fp_decision(task_text) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_external_side_effect"] + assert decision["matched_pattern"] == "install dependency" + _assert_lexical_hit(decision, body, "install dependency", "non_demotable") + + +@pytest.mark.parametrize( + ("task_text", "pattern"), + [ + ("Do not publish to the public repo", "public repo"), + ("Avoid using the public repo", "public repo"), + ( + "Do not run package installs that affect runtime dependencies", + "install dependency", + ), + ("Avoid installing runtime dependencies", "install dependency"), + ], +) +def test_direct_negation_still_governs_first_hard_stop_occurrence( + task_text: str, + pattern: str, +) -> None: + decision, body = _lexical_fp_decision(task_text) + + assert decision["decision"] == "auto_accepted" + assert decision["reason_codes"][-2:] == [ + "lexical_advisory", + "workspace_check_required", + ] + _assert_lexical_hit(decision, body, pattern, "negated") + + +@pytest.mark.parametrize( + ("task_text", "pattern", "reason_code"), + [ + ( + "Do not proceed without installing the runtime dependencies", + "install dependency", + "hard_stop_external_side_effect", + ), + ( + "There is no reason to skip installing the runtime dependencies", + "install dependency", + "hard_stop_external_side_effect", + ), + ( + "Do not skip installing the runtime dependencies", + "install dependency", + "hard_stop_external_side_effect", + ), + ( + "Do not avoid installing the runtime dependencies", + "install dependency", + "hard_stop_external_side_effect", + ), + ( + "Never skip installing the runtime dependencies", + "install dependency", + "hard_stop_external_side_effect", + ), + ( + "Do not exclude installing the runtime dependencies", + "install dependency", + "hard_stop_external_side_effect", + ), + ( + "Do not skip the public repo", + "public repo", + "hard_stop_sensitive_scope", + ), + ( + "Do not avoid the public repo", + "public repo", + "hard_stop_sensitive_scope", + ), + ( + "Do not exclude the public repo", + "public repo", + "hard_stop_sensitive_scope", + ), + ( + "Never avoid the public repo", + "public repo", + "hard_stop_sensitive_scope", + ), + ( + "Out of scope: do not publish to the public repo", + "public repo", + "hard_stop_sensitive_scope", + ), + ], +) +def test_stacked_negation_cannot_demote_first_hard_stop_occurrence( + task_text: str, + pattern: str, + reason_code: str, +) -> None: + decision, body = _lexical_fp_decision(task_text) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == [reason_code] + assert decision["matched_pattern"] == pattern + _assert_lexical_hit(decision, body, pattern, "non_demotable") + + @pytest.mark.parametrize( "task_text", [ @@ -1049,6 +1488,113 @@ def test_invalid_breach_basis_rejected() -> None: ) +# ── Checkpoint sub-basis (waiting_on_peer) ──────────────────────────────────── + + +def _time_breach_actuals(**overrides: Any) -> Dict[str, Any]: + actuals: Dict[str, Any] = {"actual_minutes": 20, "actual_files_touched": 1} + actuals.update(overrides) + return actuals + + +def test_waiting_on_peer_sub_basis_stamps_on_realized_time_breach() -> None: + checkpoint = evaluate_threshold_checkpoint( + _envelope(), + {"present": False}, + _time_breach_actuals(breach_sub_basis="waiting_on_peer"), + ) + assert checkpoint["breached"] is True + assert checkpoint["breached_fields"] == ["actual_minutes"] + assert checkpoint["breach_basis"] == "realized" + assert checkpoint["breach_sub_basis"] == "waiting_on_peer" + + +def test_sub_basis_is_null_on_every_other_checkpoint() -> None: + unbreached = evaluate_threshold_checkpoint( + _envelope(), + {"present": False}, + {"actual_minutes": 5, "actual_files_touched": 1}, + ) + assert unbreached["breach_sub_basis"] is None + breached = evaluate_threshold_checkpoint( + _envelope(), {"present": False}, _time_breach_actuals() + ) + assert breached["breach_basis"] == "realized" + assert breached["breach_sub_basis"] is None + unevaluated = evaluate_threshold_checkpoint(None, {"present": False}, None) + assert unevaluated["breach_sub_basis"] is None + + +@pytest.mark.parametrize( + ("actuals", "match"), + [ + # unbreached checkpoint: nothing to refine + ( + _time_breach_actuals(actual_minutes=5, breach_sub_basis="waiting_on_peer"), + "breached checkpoint", + ), + # files-only breach: the time axis did not overrun + ( + _time_breach_actuals( + actual_minutes=5, + actual_files_touched=3, + breach_sub_basis="waiting_on_peer", + ), + "time-axis", + ), + # prospective breach: nothing realized, so nothing was spent waiting + ( + { + "actual_minutes": 1, + "actual_files_touched": 0, + "declared_intent_fields": ["task_profile.merges_pr"], + "breach_sub_basis": "waiting_on_peer", + }, + "realized", + ), + # off-vocabulary value + (_time_breach_actuals(breach_sub_basis="reviewing"), "breach_sub_basis must be"), + ], +) +def test_sub_basis_rejected_where_it_cannot_apply( + actuals: Dict[str, Any], match: str +) -> None: + with pytest.raises(ValueError, match=match): + evaluate_threshold_checkpoint(_envelope(), {"present": False}, actuals) + + +def test_breach_basis_grammar_is_enumerated() -> None: + # The finite grammar, pinned: a new basis or sub-basis is a contract + # change that lands here together with the spec, never silently. + assert BREACH_BASES == ("declared_intent", "realized") + assert BREACH_SUB_BASES == ("waiting_on_peer",) + for basis in BREACH_BASES: + for sub_basis in BREACH_SUB_BASES: + actuals = _time_breach_actuals( + breach_basis=basis, breach_sub_basis=sub_basis + ) + if basis == "realized": + checkpoint = evaluate_threshold_checkpoint( + _envelope(), {"present": False}, actuals + ) + assert checkpoint["breach_basis"] == basis + assert checkpoint["breach_sub_basis"] == sub_basis + else: + # declared_intent on a time overrun is already inconsistent + # input; a sub-basis never rescues it. + with pytest.raises(ValueError): + evaluate_threshold_checkpoint( + _envelope(), {"present": False}, actuals + ) + for bad in ("", "WAITING_ON_PEER", "waiting-on-peer", "peer_wait", "realized"): + with pytest.raises(ValueError, match="breach_sub_basis"): + evaluate_threshold_checkpoint( + _envelope(), + {"present": False}, + _time_breach_actuals(breach_sub_basis=bad), + ) + + def test_unpinned_completion_kind_rejected() -> None: checkpoint = evaluate_threshold_checkpoint(None, {"present": False}, None) for kind in sorted(PINNED_COMPLETION_KINDS): @@ -1409,7 +1955,7 @@ def test_reauth_fresh_scopeless_receiver_approval_clears_pause_extent() -> None: policy=_REAUTH_POLICY, ) assert checkpoint["reauthorization"]["disposition"] == "resumed" - assert checkpoint["reauthorization"]["cleared_paused_at_utc"] == "2026-05-12T12:30:00Z" + assert checkpoint["reauthorization"]["cleared_paused_at_utc"] == "2026-05-12T12:50:00Z" def test_reauth_without_breach_records_not_required() -> None: @@ -2351,3 +2897,748 @@ def test_review_context_only_addressed_audits_do_not_consume_rounds( ) assert decision["decision"] == "auto_accepted" assert decision["review_continuation"]["effective_round"] == 2 + + +# ── evaluation identity and supersession ───────────────────────────────── + + +def _run_gate_cli(config_path: Path, message_path: Path, audit_dir: Path) -> int: + return autonomy_main([ + "--config", + str(config_path), + "--message", + str(message_path), + "--audit-dir", + str(audit_dir), + "--receiver", + "codex", + ]) + + +def test_audit_record_carries_evaluation_identity(tmp_path: Path) -> None: + config_path = FIXTURE_ROOT / "configs" / "auto_review_standard.yaml" + message_path = FIXTURE_ROOT / "messages" / "ambiguous_scope.yaml" + audit_dir = tmp_path / "audit" / "autonomy_decisions" + + assert _run_gate_cli(config_path, message_path, audit_dir) == 0 + + (record_path,) = audit_dir.glob("*.yaml") + record = _load_yaml(record_path) + assert re.fullmatch(r"eval-[0-9a-f]{16}", record["evaluation_id"]) + assert record["supersedes_evaluation_id"] is None + + +def test_identical_reevaluation_adopts_instead_of_duplicating( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config_path = FIXTURE_ROOT / "configs" / "auto_review_standard.yaml" + message_path = FIXTURE_ROOT / "messages" / "ambiguous_scope.yaml" + audit_dir = tmp_path / "audit" / "autonomy_decisions" + + assert _run_gate_cli(config_path, message_path, audit_dir) == 0 + capsys.readouterr() + (record_path,) = audit_dir.glob("*.yaml") + record = _load_yaml(record_path) + + assert _run_gate_cli(config_path, message_path, audit_dir) == 0 + output = capsys.readouterr() + decision = json.loads(output.out) + assert "adopted existing evaluation" in output.err + assert decision["evaluation_id"] == record["evaluation_id"] + assert decision["adopted_audit_record"] == str(record_path) + assert list(audit_dir.glob("*.yaml")) == [record_path] + + +def test_amended_message_supersedes_prior_evaluation( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + config_path = FIXTURE_ROOT / "configs" / "auto_review_standard.yaml" + message_path = tmp_path / "message.yaml" + shutil.copy(FIXTURE_ROOT / "messages" / "ambiguous_scope.yaml", message_path) + audit_dir = tmp_path / "audit" / "autonomy_decisions" + + assert _run_gate_cli(config_path, message_path, audit_dir) == 0 + capsys.readouterr() + (first_path,) = audit_dir.glob("*.yaml") + first = _load_yaml(first_path) + + amended = _load_yaml(message_path) + amended["body"] += "\n\nAmendment: narrow the sweep to docs/ only.\n" + message_path.write_text( + yaml.safe_dump(amended, sort_keys=False, allow_unicode=True), + encoding="utf-8", + ) + + assert _run_gate_cli(config_path, message_path, audit_dir) == 0 + output = capsys.readouterr() + assert "superseded 1 prior evaluation(s)" in output.err + decision = json.loads(output.out) + + records = {path: _load_yaml(path) for path in audit_dir.glob("*.yaml")} + assert len(records) == 2 + stale = records[first_path] + (successor,) = [rec for path, rec in records.items() if path != first_path] + assert stale["result"]["final_state"] == "superseded" + assert stale["result"]["completed_at_utc"] + assert stale["superseded_by_evaluation_id"] == successor["evaluation_id"] + assert successor["supersedes_evaluation_id"] == first["evaluation_id"] + assert decision["supersedes_evaluation_id"] == first["evaluation_id"] + + +# ── round-1 review regressions (F-001, F-006) ──────────────────────────── + + +def _minimal_paused_decision(message_sha: str) -> Dict[str, Any]: + return { + "decision": "paused", + "mode": "auto_review", + "reason_codes": ["estimated_minutes_exceeds_threshold"], + "scope_envelope": None, + "message_sha256": message_sha, + "policy_sha256": "p0licy", + "schema_version": 2, + "receiver": "codex", + "message_id": "msg-20260801010000-alice-race", + "result": { + "final_state": "paused", + "completion_kind": "admission_paused", + }, + } + + +def test_persist_evaluation_serializes_logical_identity(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Two concurrent different-byte evaluations leave exactly one live record. + + The sleep after the prior scan guarantees the scans overlap unless the + whole scan/write/supersede sequence is serialized by logical identity. + """ + import threading + import time + + import autonomy_gate + + audit_dir = tmp_path / "autonomy_decisions" + message = {"id": "msg-20260801010000-alice-race", "subject": "race"} + + real_scan = autonomy_gate.find_prior_evaluations + + def slow_scan(*args: Any, **kwargs: Any) -> Any: + result = real_scan(*args, **kwargs) + time.sleep(0.15) + return result + + monkeypatch.setattr(autonomy_gate, "find_prior_evaluations", slow_scan) + + outcomes: Dict[str, Dict[str, Any]] = {} + + def run(sha: str) -> None: + outcomes[sha] = autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision(sha), + config={}, + message=message, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + + threads = [threading.Thread(target=run, args=(sha,)) for sha in ("aa11", "bb22")] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + records = [ + yaml.safe_load(path.read_text(encoding="utf-8")) + for path in audit_dir.glob("*.yaml") + ] + assert len(records) == 2 + live = [r for r in records if r["result"]["final_state"] != "superseded"] + stale = [r for r in records if r["result"]["final_state"] == "superseded"] + assert len(live) == 1 + assert len(stale) == 1 + assert stale[0]["superseded_by_evaluation_id"] == live[0]["evaluation_id"] + + +def test_persist_evaluation_adopt_self_heals_crashed_transaction(tmp_path: Path) -> None: + """Adoption supersedes a live prior left behind by a crashed writer.""" + import autonomy_gate + + audit_dir = tmp_path / "autonomy_decisions" + message = {"id": "msg-20260801010000-alice-race", "subject": "race"} + + first = autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision("aa11"), + config={}, + message=message, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + # Simulate the crashed half-transaction: a second different-byte record + # written without its predecessor ever being superseded. + stale_path = Path(first["audit_path"]) + stale = yaml.safe_load(stale_path.read_text(encoding="utf-8")) + crashed = dict(stale) + crashed["message_sha256"] = "bb22" + crashed["evaluation_id"] = "eval-00000000000000bb" + crashed_path = audit_dir / "20990101T000000Z_msg-crashed.yaml" + crashed_path.write_text(yaml.safe_dump(crashed, sort_keys=False), encoding="utf-8") + + outcome = autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision("bb22"), + config={}, + message=message, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + assert outcome["action"] == "adopted" + assert outcome["evaluation_id"] == "eval-00000000000000bb" + healed = yaml.safe_load(stale_path.read_text(encoding="utf-8")) + assert healed["result"]["final_state"] == "superseded" + assert healed["superseded_by_evaluation_id"] == "eval-00000000000000bb" + + +def test_supersede_fails_closed_on_duplicate_key_evidence(tmp_path: Path) -> None: + """Automatic supersession must not normalize duplicate-key YAML.""" + from autonomy_gate import DuplicateKeyError, supersede_audit_record + + fixture = ( + FIXTURE_ROOT / "records" / "duplicate_yaml_key_logged_notes.yaml" + ) + target = tmp_path / fixture.name + shutil.copy(fixture, target) + original = target.read_bytes() + + with pytest.raises(DuplicateKeyError): + supersede_audit_record(target, superseded_by="eval-0123456789abcdef") + assert target.read_bytes() == original + + +def test_persist_evaluation_fails_closed_on_malformed_predecessor(tmp_path: Path) -> None: + """A live predecessor that cannot be superseded fails the transaction. + + No successor may be written and no success reported while ambiguous + predecessor evidence stays live — partial success recreates exactly + the duplicate-live shape the transaction exists to prevent. + """ + import autonomy_gate + + audit_dir = tmp_path / "autonomy_decisions" + audit_dir.mkdir(parents=True) + malformed = audit_dir / "20260801T010000Z_msg-race.yaml" + malformed.write_text( + "schema_version: 2\n" + "receiver: codex\n" + "message_id: msg-20260801010000-alice-race\n" + "message_sha256: aa11\n" + "policy_sha256: p0licy\n" + "decision: paused\n" + "logged_notes:\n" + "- code: lexical_advisory_declared\n" + " matched_pattern: merge\n" + "logged_notes: []\n" + "result:\n" + " final_state: paused\n" + " completion_kind: admission_paused\n", + encoding="utf-8", + ) + original = malformed.read_bytes() + + with pytest.raises(ValueError, match="logical-identity transaction failed"): + autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision("bb22"), + config={}, + message={"id": "msg-20260801010000-alice-race", "subject": "race"}, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + assert malformed.read_bytes() == original + remaining = sorted(path.name for path in audit_dir.glob("*.yaml")) + assert remaining == [malformed.name] + + +def test_persist_evaluation_oserror_after_write_rolls_back_successor( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A filesystem failure during supersession restores the pre-call state. + + Catching only validation errors would leave the just-written successor + live next to the never-closed predecessor — two live evaluations for + one logical identity. + """ + import autonomy_gate + + audit_dir = tmp_path / "autonomy_decisions" + message = {"id": "msg-20260801010000-alice-race", "subject": "race"} + + first = autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision("aa11"), + config={}, + message=message, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + predecessor_path = Path(first["audit_path"]) + original = predecessor_path.read_bytes() + + def broken_supersede(*args: Any, **kwargs: Any) -> None: + raise OSError("disk full") + + monkeypatch.setattr( + autonomy_gate, "_supersede_audit_record_locked", broken_supersede + ) + with pytest.raises(ValueError, match="pre-call state restored"): + autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision("bb22"), + config={}, + message=message, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + assert predecessor_path.read_bytes() == original + remaining = sorted(path.name for path in audit_dir.glob("*.yaml")) + assert remaining == [predecessor_path.name] + + +def test_persist_evaluation_partial_supersession_restores_all( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Failure after one of two predecessor writes restores both. + + Rolling back only the successor would leave the first predecessor + superseded_by an evaluation that no longer exists — the dangling + link the sweep flags as superseded_missing_successor. + """ + import autonomy_gate + + audit_dir = tmp_path / "autonomy_decisions" + message = {"id": "msg-20260801010000-alice-race", "subject": "race"} + + first = autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision("aa11"), + config={}, + message=message, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + first_path = Path(first["audit_path"]) + stale = yaml.safe_load(first_path.read_text(encoding="utf-8")) + crashed = dict(stale) + crashed["message_sha256"] = "cc33" + crashed["evaluation_id"] = "eval-00000000000000cc" + crashed_path = audit_dir / "20990101T000000Z_msg-crashed.yaml" + crashed_path.write_text(yaml.safe_dump(crashed, sort_keys=False), encoding="utf-8") + original_first = first_path.read_bytes() + original_crashed = crashed_path.read_bytes() + + real_supersede = autonomy_gate._supersede_audit_record_locked + calls = {"count": 0} + + def flaky_supersede(path: Path, **kwargs: Any) -> None: + calls["count"] += 1 + if calls["count"] >= 2: + raise ValueError("second predecessor write refused") + real_supersede(path, **kwargs) + + monkeypatch.setattr( + autonomy_gate, "_supersede_audit_record_locked", flaky_supersede + ) + with pytest.raises(ValueError, match="pre-call state restored"): + autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision("bb22"), + config={}, + message=message, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + assert calls["count"] == 2 + assert first_path.read_bytes() == original_first + assert crashed_path.read_bytes() == original_crashed + remaining = sorted(path.name for path in audit_dir.glob("*.yaml")) + assert remaining == sorted([first_path.name, crashed_path.name]) + + +def test_persist_evaluation_serializes_with_per_record_writer( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A conforming per-record writer is never erased by rollback. + + The transaction holds every affected record's locked_audit across + capture, mutation, and restore — a writer that takes the documented + per-record lock mid-transaction (human-outcome or message_auth + style) blocks until the rollback completes, and its committed update + lands on the restored record instead of being overwritten by it. + """ + import threading + import time + + import autonomy_gate + from _oacp_constants import atomic_replace_yaml, locked_audit + + audit_dir = tmp_path / "autonomy_decisions" + message = {"id": "msg-20260801010000-alice-race", "subject": "race"} + + first = autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision("aa11"), + config={}, + message=message, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + first_path = Path(first["audit_path"]) + crashed = dict(yaml.safe_load(first_path.read_text(encoding="utf-8"))) + crashed["message_sha256"] = "cc33" + crashed["evaluation_id"] = "eval-00000000000000cc" + crashed_path = audit_dir / "20990101T000000Z_msg-crashed.yaml" + crashed_path.write_text(yaml.safe_dump(crashed, sort_keys=False), encoding="utf-8") + + real_supersede = autonomy_gate._supersede_audit_record_locked + writer_started = threading.Event() + writer_done = threading.Event() + + def conforming_writer() -> None: + writer_started.set() + with locked_audit(first_path): + record = yaml.safe_load(first_path.read_text(encoding="utf-8")) + record["marker"] = "human-outcome-style-update" + atomic_replace_yaml(first_path, record) + writer_done.set() + + writer = threading.Thread(target=conforming_writer) + calls = {"count": 0} + + def flaky_supersede(path: Path, **kwargs: Any) -> None: + calls["count"] += 1 + if calls["count"] == 1: + real_supersede(path, **kwargs) + # Launch the writer mid-transaction: the held record lock must + # make it serialize, not interleave with the coming rollback. + writer.start() + writer_started.wait(timeout=5) + time.sleep(0.2) + assert not writer_done.is_set() + return + raise ValueError("second predecessor write refused") + + monkeypatch.setattr( + autonomy_gate, "_supersede_audit_record_locked", flaky_supersede + ) + with pytest.raises(ValueError, match="pre-call state restored"): + autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision("bb22"), + config={}, + message=message, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + writer.join(timeout=5) + assert writer_done.is_set() + final = yaml.safe_load(first_path.read_text(encoding="utf-8")) + assert final["marker"] == "human-outcome-style-update" + assert final["result"]["final_state"] == "paused" + + +def test_persist_evaluation_successor_writer_serializes_with_rollback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A writer targeting the newly published successor cannot be erased. + + The successor's lock is entered before publication and held through + predecessor mutation and rollback, so a conforming writer blocks + for the whole transaction — after a rollback it finds the record + gone (never authoritative) instead of committing an update the + rollback then deletes. + """ + import threading + import time + + import autonomy_gate + from _oacp_constants import atomic_replace_yaml, locked_audit + + audit_dir = tmp_path / "autonomy_decisions" + message = {"id": "msg-20260801010000-alice-race", "subject": "race"} + + first = autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision("aa11"), + config={}, + message=message, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + predecessor_path = Path(first["audit_path"]) + original = predecessor_path.read_bytes() + + writer_started = threading.Event() + writer_done = threading.Event() + writer_outcome: Dict[str, Any] = {} + + def successor_writer(successor_path: Path) -> None: + writer_started.set() + with locked_audit(successor_path): + try: + record = yaml.safe_load( + successor_path.read_text(encoding="utf-8") + ) + record["marker"] = "successor-update" + atomic_replace_yaml(successor_path, record) + writer_outcome["committed"] = True + except FileNotFoundError: + writer_outcome["committed"] = False + writer_done.set() + + threads: List[threading.Thread] = [] + + def failing_supersede(path: Path, **kwargs: Any) -> None: + known = {predecessor_path.name} + (successor_path,) = [ + candidate + for candidate in audit_dir.glob("*.yaml") + if candidate.name not in known + ] + thread = threading.Thread( + target=successor_writer, args=(successor_path,) + ) + threads.append(thread) + thread.start() + writer_started.wait(timeout=5) + time.sleep(0.2) + assert not writer_done.is_set() # blocked on the held successor lock + raise ValueError("predecessor write refused") + + monkeypatch.setattr( + autonomy_gate, "_supersede_audit_record_locked", failing_supersede + ) + with pytest.raises(ValueError, match="pre-call state restored"): + autonomy_gate.persist_evaluation( + audit_dir, + _minimal_paused_decision("bb22"), + config={}, + message=message, + message_path=Path("/dev/null"), + policy_path=Path("/dev/null"), + receiver="codex", + ) + threads[0].join(timeout=5) + assert writer_done.is_set() + assert writer_outcome["committed"] is False + assert predecessor_path.read_bytes() == original + remaining = sorted(path.name for path in audit_dir.glob("*.yaml")) + assert remaining == [predecessor_path.name] + + +@pytest.mark.parametrize( + "fixture", + [ + "always_pause_task", + "malformed_config_pauses", + "invalid_message_pauses", + "missing_task_profile_pauses", + "unparsable_task_profile_pauses", + ], +) +def test_admission_ledger_is_explicitly_not_evaluated_before_envelope( + fixture: str, +) -> None: + case = _load_yaml(FIXTURE_ROOT / "expected" / f"{fixture}.yaml") + decision = evaluate_autonomy( + _load_yaml(FIXTURE_ROOT / case["message"]), + _load_yaml(FIXTURE_ROOT / case["config"]), + receiver="codex", + ) + ledger = decision["admission_axes"] + # No envelope, nothing to evaluate against: the record says so instead + # of carrying empty lists that would read as "all passed". + assert ledger["evaluated"] is False + assert all(ledger[axis] is None for axis in ADMISSION_AXES) + assert ledger["declaration_errors"] is None + assert decision["co_occurring_reason_codes"] == [] + + +def test_checkpoint_pause_keeps_the_admission_ledger() -> None: + case = _load_yaml(FIXTURE_ROOT / "expected" / "checkpoint_breach_pauses.yaml") + decision = evaluate_autonomy( + _load_yaml(FIXTURE_ROOT / case["message"]), + _load_yaml(FIXTURE_ROOT / case["config"]), + actuals=_load_yaml(FIXTURE_ROOT / case["actuals"]), + receiver="codex", + ) + assert decision["reason_codes"] == ["threshold_checkpoint_breached"] + assert decision["result"]["completion_kind"] == "checkpoint_paused" + # Checkpoint reasons never displace admission evidence: the ledger is + # the admitted (all-pass) shape and stays separate from the checkpoint. + ledger = decision["admission_axes"] + assert ledger["evaluated"] is True + assert admission_ledger_codes(ledger) == [] + assert decision["co_occurring_reason_codes"] == [] + + +def test_lexical_hard_stop_records_the_evaluated_grant_block() -> None: + config = _load_yaml( + FIXTURE_ROOT / "configs" / "auto_review_continuation_enabled.yaml" + ) + message = _load_yaml(FIXTURE_ROOT / "messages" / "hard_stop_masking_threshold.yaml") + decision = evaluate_autonomy(message, config, receiver="codex") + assert decision["reason_codes"] == ["hard_stop_external_side_effect"] + # The grant is resolved ahead of Gate 3 now, so a lexical pause records + # the real evaluation rather than a disabled placeholder. + assert decision["continuation_grant"]["enabled"] is True + assert decision["continuation_grant"]["decision"] == "not_present" + assert decision["admission_axes"]["evaluated"] is True + assert set(decision["co_occurring_reason_codes"]) == set( + admission_ledger_codes(decision["admission_axes"]) + ) + + +def _reply_only_research_message(**edits: str) -> Dict[str, Any]: + """The reply-only research fixture with body substrings replaced.""" + message = _load_yaml(FIXTURE_ROOT / "messages" / "sensitive_content_reply_only.yaml") + body = message["body"] + for old, new in edits.items(): + assert old in body, old + body = body.replace(old, new) + message["body"] = body + return message + + +_REPLY_ONLY_NOTES = [ + {"code": "lexical_advisory_reply_only", "matched_pattern": "pricing"}, + {"code": "lexical_advisory_reply_only", "matched_pattern": "commercial"}, +] + + +def test_content_sensitivity_reply_only_shape_records_every_term() -> None: + config = _load_yaml(FIXTURE_ROOT / "configs" / "auto_review_standard.yaml") + + decision = evaluate_autonomy(_reply_only_research_message(), config) + + assert decision["decision"] == "auto_accepted" + assert "hard_stop_content_sensitivity" not in decision["reason_codes"] + assert "lexical_advisory" in decision["reason_codes"] + assert "matched_pattern" not in decision + # Both terms are recorded, not just the first match. + assert decision["logged_notes"] == _REPLY_ONLY_NOTES + + +@pytest.mark.parametrize( + "edits", + [ + # Fencing never demotes the category; the shape is what decides. + { + "Survey the hosted-tier landscape": "```oacp-guardrails\nDo not quote pricing.\n```\nSurvey the hosted-tier landscape" + }, + # Neither does a negation heading. + {"Survey the hosted-tier landscape": "Out of scope:\n- pricing changes.\n\nSurvey the hosted-tier landscape"}, + ], + ids=["guardrails_fence", "negation_heading"], +) +def test_content_sensitivity_carve_out_is_shape_not_wording(edits: Dict[str, str]) -> None: + config = _load_yaml(FIXTURE_ROOT / "configs" / "auto_review_standard.yaml") + + decision = evaluate_autonomy(_reply_only_research_message(**edits), config) + + assert decision["decision"] == "auto_accepted" + assert [ + note for note in decision["logged_notes"] if note["code"] == "lexical_advisory_reply_only" + ] == _REPLY_ONLY_NOTES + + +@pytest.mark.parametrize( + "edits", + [ + # A side-effect flag true (consistently declared) is another shape. + { + "external_side_effects: false": "external_side_effects: true", + "commits_changes: false": "commits_changes: true", + }, + # Omitting the reply-only declaration is not declaring it. + {" sends_oacp_reply_only: true\n": ""}, + # A contradictory profile (reply-only plus a commit) keeps the stop. + {"commits_changes: false": "commits_changes: true"}, + ], + ids=["side_effect_declared", "reply_only_omitted", "contradictory_profile"], +) +def test_content_sensitivity_other_profile_shapes_keep_the_hard_stop( + edits: Dict[str, str], +) -> None: + config = _load_yaml(FIXTURE_ROOT / "configs" / "auto_review_standard.yaml") + + decision = evaluate_autonomy(_reply_only_research_message(**edits), config) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_content_sensitivity"] + assert decision["matched_pattern"] == "pricing" + assert not [ + note for note in decision["logged_notes"] if note["code"] == "lexical_advisory_reply_only" + ] + + +def test_content_sensitivity_profileless_default_envelope_keeps_the_hard_stop() -> None: + """The default envelope is reply-only by bound but declares nothing.""" + config = _load_yaml(FIXTURE_ROOT / "configs" / "auto_review_standard.yaml") + message = _load_yaml(FIXTURE_ROOT / "messages" / "brainstorm_side_effect_verbs.yaml") + message["body"] = "Explore wording for a pricing page; reply with options only." + + decision = evaluate_autonomy(message, config) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == ["hard_stop_content_sensitivity"] + assert decision["matched_pattern"] == "pricing" + + + +@pytest.mark.parametrize( + ("edits", "reason_code", "matched_pattern"), + [ + ( + {"Survey the hosted-tier landscape": "Run rm -rf build first, then survey the hosted-tier landscape"}, + "hard_stop_destructive_command", + "rm -rf", + ), + ( + { + "Survey the hosted-tier landscape": "Update config.yaml, then survey the hosted-tier landscape", + "touches_auth_config_or_secrets: false": "touches_auth_config_or_secrets: true", + }, + "hard_stop_sensitive_scope", + "config", + ), + ], + ids=["destructive_token", "declared_sensitive_scope"], +) +def test_content_sensitivity_advisory_survives_earlier_hard_stops( + edits: Dict[str, str], reason_code: str, matched_pattern: str +) -> None: + """The reply-only advisory is evidence, recorded before any Gate-3 early + return: an earlier hard stop keeps its verdict and the notes survive.""" + config = _load_yaml(FIXTURE_ROOT / "configs" / "auto_review_standard.yaml") + + decision = evaluate_autonomy(_reply_only_research_message(**edits), config) + + assert decision["decision"] == "paused" + assert decision["reason_codes"] == [reason_code] + assert decision["matched_pattern"] == matched_pattern + assert [ + note for note in decision["logged_notes"] if note["code"] == "lexical_advisory_reply_only" + ] == _REPLY_ONLY_NOTES diff --git a/tests/test_autonomy_ledger_replay.py b/tests/test_autonomy_ledger_replay.py new file mode 100644 index 0000000..b293625 --- /dev/null +++ b/tests/test_autonomy_ledger_replay.py @@ -0,0 +1,229 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Executable admission-ledger replay: every envelope-derived axis is recorded. + +Each case in ``tests/conformance/autonomy/ledger_replay/corpus.yaml`` is an +anonymized reproduction of a fleet audit record whose evaluator early-out +left admission axes unrecorded. The runner rebuilds the message and the +receiver config, evaluates it, and pins three things at once: the verdict +is unchanged (``reason_codes`` and ``matched_pattern``), the ledger holds +exactly the expected axes, and every non-primary axis surfaces through +``co_occurring_reason_codes`` — zero missing axes across the corpus. + +The corpus's ``content_sensitivity`` section replays the reply-only +carve-out over every content-sensitivity hard stop of one window plus a +control: the runner pins which records now record the matched term as a +``lexical_advisory_reply_only`` note (verdict taken by the axes the hard +stop used to mask) and which keep the hard stop unchanged. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Dict, List + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from autonomy_gate import ( # noqa: E402 + admission_ledger_codes, + evaluate_autonomy, +) + +CORPUS_PATH = ( + Path(__file__).parent / "conformance" / "autonomy" / "ledger_replay" / "corpus.yaml" +) + + +def _corpus() -> Dict[str, Any]: + data = yaml.safe_load(CORPUS_PATH.read_text(encoding="utf-8")) + assert isinstance(data, dict) and data["cases"] + return data + + +def _config(policy: Dict[str, Any]) -> Dict[str, Any]: + return { + "autonomy": { + "default_mode": "auto_review", + "auto_review_thresholds": { + "max_estimated_minutes": policy["max_estimated_minutes"], + "max_expected_files_touched": policy["max_expected_files_touched"], + "destructive_ops": "pause", + "external_side_effects": policy["external_side_effects"], + "auth_config_or_secrets": "pause", + "dependency_changes": "pause", + "public_visibility": "pause", + "git_push_or_deploy": "pause", + }, + "allow_without_task_profile": ["brainstorm_request"], + "private_repo_allowlist": list(policy["private_repo_allowlist"]), + "continuation_grants": {"enabled": False}, + } + } + + +def _message(case: Dict[str, Any], index: int) -> Dict[str, Any]: + profile = yaml.safe_dump(case["task_profile"], sort_keys=False).rstrip("\n") + block = "\n".join(f" {line}" for line in profile.splitlines()) + body = f"## Task\n{case['body_line']}\n\ntask_profile:\n{block}\n" + return { + "id": f"msg-20260818000000-alice-{index:04d}", + "from": "alice", + "to": "codex", + "type": case.get("type", "task_request"), + "priority": "P2", + "created_at_utc": "2026-08-18T00:00:00Z", + "subject": f"Ledger replay {case['case']}", + "body": body, + } + + +def _evaluate(data: Dict[str, Any], case: Dict[str, Any], index: int) -> Dict[str, Any]: + return evaluate_autonomy( + _message(case, index), _config(data["policy"]), receiver="codex" + ) + + +_CORPUS = _corpus() +_CASES = list(enumerate(_CORPUS["cases"], start=1)) + + +@pytest.mark.parametrize( + "index, case", _CASES, ids=[case["case"] for _index, case in _CASES] +) +def test_ledger_replay_case_records_every_axis(index: int, case: Dict[str, Any]) -> None: + decision = _evaluate(_CORPUS, case, index) + + # Verdict identity: the ledger never changes what pauses or why. + assert decision["decision"] == "paused" + assert decision["reason_codes"] == case["expected_reason_codes"] + if "expected_matched_pattern" in case: + assert decision.get("matched_pattern") == case["expected_matched_pattern"] + + # Ledger completeness: exactly the axes the envelope implies, no more. + ledger = decision["admission_axes"] + assert ledger["evaluated"] is True + assert set(admission_ledger_codes(ledger)) == set(case["expected_axes"]) + + # Surface: every non-primary axis is a co-occurring reason code. + expected_co = set(case["expected_axes"]) - set(decision["reason_codes"]) + assert set(decision["co_occurring_reason_codes"]) == expected_co + assert decision["co_occurring_reason_codes"] == sorted(expected_co) + + +def test_ledger_replay_corpus_has_zero_missing_axes() -> None: + """Corpus-level replay: the unrecorded-axis count across all cases is 0.""" + missing: List[str] = [] + for index, case in _CASES: + decision = _evaluate(_CORPUS, case, index) + recorded = set(decision["reason_codes"]) + recorded |= set(decision.get("co_occurring_reason_codes") or []) + recorded |= set(admission_ledger_codes(decision.get("admission_axes") or {})) + missing.extend( + f"{case['case']}: {axis}" + for axis in case["expected_axes"] + if axis not in recorded + ) + assert not missing, f"{len(missing)} missing admission axes:\n" + "\n".join(missing) + + +def test_ledger_replay_corpus_documents_the_field_gap() -> None: + """Every case names at least one axis its field record failed to carry. + + The corpus reproduces 44 unrecorded qualitative axes plus one numeric + threshold masked by a receiver-side checkpoint overwrite. + """ + total = 0 + for _index, case in _CASES: + assert case["field_record_missing"], case["case"] + assert set(case["field_record_missing"]) <= set(case["expected_axes"]), case["case"] + total += len(case["field_record_missing"]) + assert total == 45 + + +_CAT5_CASES = list( + enumerate(_CORPUS["content_sensitivity"]["cases"], start=len(_CASES) + 1) +) + + +@pytest.mark.parametrize( + "index, case", _CAT5_CASES, ids=[case["case"] for _index, case in _CAT5_CASES] +) +def test_content_sensitivity_replay_case(index: int, case: Dict[str, Any]) -> None: + decision = _evaluate(_CORPUS, case, index) + + # Verdict: the carve-out moves the reason, never the pause itself, on + # this corpus — every record still has an axis that holds. + assert decision["decision"] == "paused" + assert decision["reason_codes"] == case["expected_reason_codes"] + if "expected_matched_pattern" in case: + assert decision.get("matched_pattern") == case["expected_matched_pattern"] + else: + assert "matched_pattern" not in decision + + # The category records, never silences: an advisory carries its basis + # (the note code) and the matched term; a hard stop records none. + advisories = [ + note + for note in decision["logged_notes"] + if note["code"] == "lexical_advisory_reply_only" + ] + assert advisories == case.get("expected_notes", []) + + # Ledger completeness is unchanged by the carve-out. + ledger = decision["admission_axes"] + assert ledger["evaluated"] is True + assert set(admission_ledger_codes(ledger)) == set(case["expected_axes"]) + expected_co = set(case["expected_axes"]) - set(decision["reason_codes"]) + assert set(decision.get("co_occurring_reason_codes") or []) == expected_co + + +def test_content_sensitivity_replay_summary() -> None: + """Corpus-level replay: exactly the declared reply-only records demote. + + Three of the six recorded hard stops declared the reply-only shape and + replay as advisories; the three that omitted ``sends_oacp_reply_only`` + and the commit-declaring control keep the recorded hard stop. + """ + advisory: List[str] = [] + unchanged: List[str] = [] + for index, case in _CAT5_CASES: + decision = _evaluate(_CORPUS, case, index) + assert case["recorded_reason_codes"] == ["hard_stop_content_sensitivity"] + if case.get("expected_notes"): + assert "hard_stop_content_sensitivity" not in decision["reason_codes"] + advisory.append(case["case"]) + else: + assert decision["reason_codes"] == case["recorded_reason_codes"] + unchanged.append(case["case"]) + assert advisory == ["cat5-01", "cat5-02", "cat5-03"] + assert unchanged == ["cat5-04", "cat5-05", "cat5-06", "cat5-control"] + + +def test_replayed_lexical_hits_have_complete_structured_provenance() -> None: + """Every lexical hit carries an exact span and an explicit disposition.""" + total_hits = 0 + cases_with_hits = 0 + for index, case in _CASES + _CAT5_CASES: + decision = _evaluate(_CORPUS, case, index) + body = _message(case, index)["body"] + hits = decision["matched_patterns"] + cases_with_hits += bool(hits) + total_hits += len(hits) + for hit in hits: + assert set(hit) == { + "pattern", + "category", + "span", + "demotion_basis", + } + assert set(hit["span"]) == {"start", "end"} + start, end = hit["span"]["start"], hit["span"]["end"] + assert 0 <= start < end <= len(body) + assert hit["demotion_basis"] + + assert cases_with_hits == 18 + assert total_hits == 18 diff --git a/tests/test_claude_envelope_hook.py b/tests/test_claude_envelope_hook.py index 4a70cfb..92e10d3 100644 --- a/tests/test_claude_envelope_hook.py +++ b/tests/test_claude_envelope_hook.py @@ -13,6 +13,7 @@ from typing import Any, Dict, Optional import pytest +import yaml sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) @@ -1751,6 +1752,28 @@ def test_envelope_clear_uses_newest_audit_record(tmp_path: Path) -> None: assert decision.action == "deny" +def test_envelope_clear_skips_superseded_records(tmp_path: Path) -> None: + # A superseded evaluation's authority transferred to its successor: a + # newest-but-superseded record must neither sanction the clear (live + # work continues under the older record) nor block it once the live + # record is terminal. + repo = _make_workspace(tmp_path) + _install_envelope(tmp_path, make_envelope()) + _write_audit_record(tmp_path, final_state="pending", stamp="20260713T000000Z") + _write_audit_record(tmp_path, final_state="superseded", stamp="20260714T000000Z") + decision = _process_bash(repo, CLEAR_CMD) + assert decision.action == "deny" + + +def test_envelope_clear_allowed_when_only_live_record_terminal(tmp_path: Path) -> None: + repo = _make_workspace(tmp_path) + _install_envelope(tmp_path, make_envelope()) + _write_audit_record(tmp_path, final_state="done", stamp="20260713T000000Z") + _write_audit_record(tmp_path, final_state="superseded", stamp="20260714T000000Z") + decision = _process_bash(repo, CLEAR_CMD) + assert decision.action == "allow" + + def test_envelope_clear_other_project_asks(tmp_path: Path) -> None: repo = _make_workspace(tmp_path) _install_envelope(tmp_path, make_envelope()) @@ -1818,18 +1841,31 @@ def _agent_dir(tmp_path: Path) -> Path: def test_audit_write_exempt_from_file_counter(tmp_path: Path) -> None: # Regression (soak shape): tight honest declare, budget already consumed, - # then the mandatory completion audit write — must not deny or count. + # then a bookkeeping audit write — must not deny or count. The completion + # record itself moved out of this exemption: `autonomy_decisions/` became + # authority once a recorded re-authorization could widen the live bound, + # and its writes go through the canonical CLI writers (see + # test_admission_audit_record_write_denied). repo = _make_workspace(tmp_path) target = _install_envelope(tmp_path, _exhausted_envelope()) - audit_path = ( - _agent_dir(tmp_path) / "audit" / "autonomy_decisions" / "x_msg-1.yaml" - ) + audit_path = _agent_dir(tmp_path) / "audit" / "x_msg-1.yaml" decision = _process_write(repo, str(audit_path)) assert decision.action == "allow" stored = load_envelope(target) assert stored["counters"]["files_touched"] == ["/repo/a.py", "/repo/b.py"] +def test_admission_audit_record_write_denied(tmp_path: Path) -> None: + repo = _make_workspace(tmp_path) + _install_envelope(tmp_path, _exhausted_envelope()) + audit_path = ( + _agent_dir(tmp_path) / "audit" / "autonomy_decisions" / "x_msg-1.yaml" + ) + decision = _process_write(repo, str(audit_path)) + assert decision.action == "deny" + assert "authority self-modification" in decision.reason + + def test_scratchpad_write_exempt_from_file_counter(tmp_path: Path) -> None: repo = _make_workspace(tmp_path) target = _install_envelope(tmp_path, _exhausted_envelope()) @@ -1901,15 +1937,27 @@ def test_trust_root_write_counted_when_auth_declared(tmp_path: Path) -> None: def test_bash_redirect_to_audit_dir_exempt(tmp_path: Path) -> None: repo = _make_workspace(tmp_path) target = _install_envelope(tmp_path, _exhausted_envelope()) - audit_path = ( - _agent_dir(tmp_path) / "audit" / "autonomy_decisions" / "y_msg-1.yaml" - ) + audit_path = _agent_dir(tmp_path) / "audit" / "notes.md" decision = _process_bash(repo, f"echo done > {audit_path}") assert decision.action == "allow" stored = load_envelope(target) assert stored["counters"]["files_touched"] == ["/repo/a.py", "/repo/b.py"] +def test_bash_redirect_to_admission_audit_record_denied(tmp_path: Path) -> None: + """`audit/autonomy_decisions/` is authority, not bookkeeping: a recorded + re-authorization widens the live bound, so a session able to write its own + record would be able to write its own grant.""" + repo = _make_workspace(tmp_path) + _install_envelope(tmp_path, _exhausted_envelope()) + audit_path = ( + _agent_dir(tmp_path) / "audit" / "autonomy_decisions" / "y_msg-1.yaml" + ) + decision = _process_bash(repo, f"echo done > {audit_path}") + assert decision.action == "deny" + assert "authority self-modification" in decision.reason + + def test_peer_agent_inbox_not_exempt(tmp_path: Path) -> None: # The exemption is receiver-scoped: another agent's inbox is task scope. repo = _make_workspace(tmp_path) @@ -2156,3 +2204,802 @@ def test_find_action_predicates_ask() -> None: assert bash("find . -name '*.tmp' -delete").action == "ask" assert bash("find . -name core -exec rm {} +").action == "ask" assert bash("find . -name '*.py' -type f").action == "allow" + + +# ── Heredoc bodies and variable redirect targets (phantom files_touched) ────── + + +PHANTOM_FILE_ROWS = [ + pytest.param( + "python3 - <<'PYEOF'\nprint(f\"{a:>8} -> {c.value:13} ok\")\nPYEOF", + id="heredoc_arrow_in_format_string", + ), + pytest.param( + "python3 - <<'PYEOF'\nprint(v, \"-> INSERTED id\", rid)\nPYEOF", + id="heredoc_arrow_in_string", + ), + pytest.param( + 'printf "x: nan\\n" > /scratchpad/probe/obs_nan.yaml', + id="literal_scratchpad_redirect", + ), + pytest.param( + "python3 - <<'PYEOF'\nst = Store(\":memory:\")\nPYEOF", + id="heredoc_without_redirect", + ), +] + + +@pytest.mark.parametrize("command", PHANTOM_FILE_ROWS) +def test_phantom_write_rows_are_not_counted(command: str) -> None: + """Nothing outside the exempt scratchpad is written: zero budget suffices.""" + decision = bash(command, make_envelope(expected_files_touched=0)) + assert decision.action == "allow", decision.reason + assert decision.new_files == [] + + +@pytest.mark.parametrize( + "command, expected", + [ + pytest.param( + "cat > notes.md <<'EOF'\nline -> arrow\nEOF", + ["/repo/notes.md"], + id="redirect_before_heredoc_operator", + ), + pytest.param( + "tee notes.md <<'EOF'\nline -> arrow\nEOF", + ["/repo/notes.md"], + id="tee_with_heredoc_body", + ), + ], +) +def test_real_write_on_heredoc_line_still_counted(command: str, expected: list) -> None: + """The operator line is shell; only the body is data.""" + decision = bash(command) + assert decision.action == "allow", decision.reason + assert decision.new_files == expected + + +def test_live_incident_heredoc_shape_allowed_with_zero_budget() -> None: + command = ( + "python3 - <<'PYEOF'\n" + "import json\n" + "for rid in ids:\n" + ' print(rid, "-> INSERTED")\n' + 'print("a -> b")\n' + "PYEOF" + ) + decision = bash(command, make_envelope(expected_files_touched=0)) + assert decision.action == "allow", decision.reason + assert decision.new_files == [] + + +def test_unterminated_heredoc_asks() -> None: + decision = bash("python3 - <<'PYEOF'\nprint('x')\n") + assert decision.action == "ask", decision.reason + assert "heredoc" in decision.reason + + +# ── Fail-closed boundaries of the phantom-count fix ────────────────────────── + + +@pytest.mark.parametrize( + "command", + [ + pytest.param( + "cat < None: + """An unquoted delimiter leaves the body subject to expansion: a command + or backtick substitution in it executes, so the body is not data.""" + decision = bash(command, make_envelope(expected_files_touched=0)) + assert decision.action == "ask", decision.reason + assert "heredoc" in decision.reason + + +@pytest.mark.parametrize( + "command", + [ + pytest.param("cat <<'EOF'\n$(touch not-run.md)\nEOF", id="single_quoted_delimiter"), + pytest.param('cat <<"EOF"\n`touch not-run.md`\nEOF', id="double_quoted_delimiter"), + pytest.param("cat <<\\EOF\n$(touch not-run.md)\nEOF", id="backslashed_delimiter"), + pytest.param("cat < ${HOME}\nEOF", id="expanding_body_no_substitution"), + ], +) +def test_literal_or_substitution_free_heredoc_bodies_allowed(command: str) -> None: + decision = bash(command, make_envelope(expected_files_touched=0)) + assert decision.action == "allow", decision.reason + assert decision.new_files == [] + + +@pytest.mark.parametrize( + "command, expected", + [ + pytest.param("touch -- ' arrow\nEOF', ["/repo/ None: + """shlex dequotes, so a literal operand spelled like redirection syntax is + still an operand; only the real `< "$OUT"', + id="var_redirect_assigned_in_same_command", + ), + pytest.param( + 'S=/scratchpad/probe; printf "x: nan\\n" > $S/obs_nan.yaml', + id="var_redirect_path_segment", + ), + pytest.param('echo x > "$OUT"', id="never_assigned"), + pytest.param('OUT=/scratchpad/x.log python3 -m pytest > "$OUT"', id="prefix_assignment"), + pytest.param('OUT=$TMPDIR/x.log; echo x > "$OUT"', id="assigned_from_a_variable"), + pytest.param('D=/scratchpad; F=probe.log; echo x > "${D}/$F"', id="braced_and_bare"), + pytest.param('OUT=/scratchpad/x; touch "$OUT"', id="writer_program_operand"), + pytest.param('cp notes.md "$DEST"', id="cp_destination"), + pytest.param('echo x > "$(mktemp)"', id="command_substitution_target"), + pytest.param("echo x > `mktemp`", id="backtick_target"), + pytest.param('OUT=/repo/notes.md; echo x > "$OUT"', id="task_scope_value_is_not_counted_but_asked"), + ], +) +def test_variable_write_target_asks(command: str) -> None: + """A target the shell would expand from state the classifier cannot see + escalates: neither counted as a cwd-relative phantom nor trusted.""" + decision = bash(command) + assert decision.action == "ask", decision.reason + assert "variable" in decision.reason + zero = bash(command, make_envelope(expected_files_touched=0)) + assert zero.action == "ask", zero.reason + + +@pytest.mark.parametrize( + "command", + [ + pytest.param('false && OUT=/scratchpad/skipped; touch "$OUT/actual.md"', id="and_conditional"), + pytest.param('true || OUT=/scratchpad/skipped; touch "$OUT/actual.md"', id="or_conditional"), + pytest.param('OUT=/scratchpad/x | cat; echo x > "$OUT"', id="pipeline_subshell"), + pytest.param('OUT=/scratchpad/x & echo x > "$OUT"', id="background_subshell"), + pytest.param('OUT=/scratchpad/a; true && OUT=/repo/b; echo x > "$OUT"', id="conditional_reassignment"), + pytest.param('OUT=/scratchpad/a; read OUT; echo x > "$OUT"', id="read_builtin"), + pytest.param('OUT=/scratchpad/a; export OUT=/repo/b; echo x > "$OUT"', id="export_builtin"), + pytest.param('OUT=/scratchpad/a; readonly OUT=/repo/actual.md; touch "$OUT"', id="readonly_builtin"), + pytest.param('OUT=/scratchpad/a; printf -v OUT /repo/b; echo x > "$OUT"', id="printf_v"), + pytest.param('OUT=/scratchpad/a; eval "OUT=/repo/b"; echo x > "$OUT"', id="eval"), + pytest.param('OUT=/scratchpad/a; for OUT in /repo/b; do echo x > "$OUT"; done', id="for_loop"), + pytest.param('OUT=/scratchpad; OUT+=../repo; echo x > "$OUT/x.md"', id="append_assignment"), + pytest.param( + 'OUT=/repo; echo "$(OUT=/scratchpad/safe)" "$(touch "$OUT/actual.md")"', + id="assignment_in_a_separate_substitution", + ), + ], +) +def test_shell_state_is_not_modeled(command: str) -> None: + """No assignment shape — conditional, subshell, builtin-mediated, or a + separate command substitution — resolves a variable target: the + classifier holds no shell-state model, so every such use escalates.""" + decision = bash(command, make_envelope(expected_files_touched=0)) + assert decision.action == "ask", decision.reason + # `eval` already escalates as shell indirection before any target is read. + assert "variable" in decision.reason or "indirection" in decision.reason + + +# ── Extraction coverage: every redirect / writer destination is collected ──── + + +@pytest.mark.parametrize( + "command", + [ + pytest.param('OUT=/repo/actual.md; echo x >| "$OUT"', id="noclobber_override_spaced"), + pytest.param('OUT=/repo/actual.md; echo x >|"$OUT"', id="noclobber_override_unspaced"), + pytest.param("echo x >| $OUT", id="noclobber_override_bare"), + pytest.param('cmd 2>| "$ERR"', id="noclobber_override_stderr"), + pytest.param('DEST=/repo; cp --target-directory="$DEST" /scratchpad/source.md', id="cp_target_directory_equals"), + pytest.param('DEST=/repo; cp --target-directory "$DEST" /scratchpad/source.md', id="cp_target_directory_separate"), + pytest.param('DEST=/repo; install -t "$DEST" /scratchpad/source.md', id="install_t"), + pytest.param('DEST=/repo; mv -t"$DEST" /scratchpad/source.md', id="mv_t_attached"), + pytest.param('DEST=/repo; cp -rt "$DEST" /scratchpad/source.md', id="cp_short_cluster_ending_in_t"), + pytest.param('install -m 644 -t "$DEST" /scratchpad/source.md', id="install_option_argument_before_t"), + pytest.param("echo x > >(tee $OUT)", id="process_substitution_writer_variable"), + ], +) +def test_uncollected_destination_syntaxes_now_ask(command: str) -> None: + """A destination the extractor never collected could not reach the + variable check: the noclobber override, GNU target-directory options, + and a writer inside a process substitution are collected now.""" + decision = bash(command, make_envelope(expected_files_touched=0)) + assert decision.action == "ask", decision.reason + assert "variable" in decision.reason + + +@pytest.mark.parametrize( + "command, expected", + [ + pytest.param("echo x >| notes.md", ["/repo/notes.md"], id="noclobber_literal"), + pytest.param("echo x >|notes.md", ["/repo/notes.md"], id="noclobber_literal_unspaced"), + pytest.param("cp -t docs notes.md", ["/repo/docs"], id="cp_t_literal"), + pytest.param("install --target-directory=docs notes.md", ["/repo/docs"], id="install_target_directory_literal"), + pytest.param("cat <(touch /repo/x.md)", ["/repo/x.md"], id="process_substitution_input_writer"), + pytest.param("echo x > >(tee /repo/x.md)", ["/repo/x.md"], id="process_substitution_output_writer"), + pytest.param("cp -- src dst", ["/repo/dst"], id="double_dash_ends_options"), + ], +) +def test_collected_literal_destinations_count(command: str, expected: list) -> None: + decision = bash(command) + assert decision.action == "allow", decision.reason + assert decision.new_files == expected + zero = bash(command, make_envelope(expected_files_touched=0)) + assert zero.action == "deny" + assert "expected 0, now 1" in zero.reason + + +@pytest.mark.parametrize( + "command", + [ + pytest.param("echo x >| /scratchpad/x", id="noclobber_scratchpad"), + pytest.param("cp --target-directory=/scratchpad/out notes.md", id="target_directory_scratchpad"), + pytest.param("diff <(git ls-files | sort) <(cat list)", id="read_only_process_substitutions"), + pytest.param("comm -23 <(git ls-files|sort) <(cat x|sort)", id="read_only_process_substitutions_with_pipes"), + pytest.param("echo x > >(cat)", id="process_substitution_without_writer"), + pytest.param("cmd 2>&1 | grep x", id="pipe_after_dup_redirect"), + pytest.param("cp -S.txt a /scratchpad/b", id="short_option_value_without_t"), + ], +) +def test_extraction_coverage_controls_allow(command: str) -> None: + decision = bash(command, make_envelope(expected_files_touched=0)) + assert decision.action == "allow", decision.reason + assert decision.new_files == [] + + +def test_nested_process_substitution_asks() -> None: + decision = bash("diff <(sort a) <($(cat list))") + assert decision.action == "ask", decision.reason + assert "process substitution" in decision.reason + + +# ── Option spellings the supported GNU writers accept ──────────────────────── + + +@pytest.mark.parametrize( + "command", + [ + pytest.param('DEST=/repo; cp --target-dir="$DEST" /scratchpad/source.md', id="cp_abbreviated_attached"), + pytest.param('cp --target-dir "$DEST" /scratchpad/source.md', id="cp_abbreviated_separate"), + pytest.param('install --t="$DEST" /scratchpad/source.md', id="install_shortest_prefix"), + pytest.param('mv --targ "$DEST" /scratchpad/source.md', id="mv_prefix"), + pytest.param('sed --in-place s/a/b/ "$F"', id="sed_long_in_place"), + pytest.param('sed --in-p=.bak s/a/b/ "$F"', id="sed_abbreviated_in_place_suffix"), + pytest.param('sed --i s/a/b/ "$F"', id="sed_shortest_in_place_prefix"), + pytest.param('sed -ni s/a/b/ "$F"', id="sed_cluster_with_i"), + pytest.param('sed -Ei.bak s/a/b/ "$F"', id="sed_cluster_with_i_and_suffix"), + pytest.param('sed -i -e s/a/b/ "$F"', id="sed_separate_expression"), + pytest.param('sed -i -es/a/b/ "$F"', id="sed_attached_expression"), + pytest.param('sed -ie s/a/b/ "$F"', id="sed_cluster_i_then_e"), + pytest.param('sed -i --expression=s/a/b/ "$F"', id="sed_long_expression"), + pytest.param('sed -i --expr s/a/b/ "$F"', id="sed_abbreviated_expression"), + pytest.param('sed -i -f prog.sed "$F"', id="sed_script_file"), + pytest.param('sed -i -l 80 s/a/b/ "$F"', id="sed_line_length_value_skipped"), + pytest.param('sed -i s/a/b/ -- "$F"', id="sed_double_dash"), + pytest.param('tee -- "$F"', id="tee_double_dash"), + ], +) +def test_accepted_option_spellings_reach_the_variable_check(command: str) -> None: + """GNU getopt_long accepts unambiguous long-option abbreviations and + short-option clusters; every spelling of a destination-bearing option + must expose its destination to the fail-closed check.""" + decision = bash(command, make_envelope(expected_files_touched=0)) + assert decision.action == "ask", decision.reason + assert "variable" in decision.reason + + +@pytest.mark.parametrize( + "command, expected", + [ + pytest.param("cp --target-dir=/repo/out /scratchpad/source.md", ["/repo/out"], id="cp_abbreviated_literal"), + pytest.param("install --t=docs notes.md", ["/repo/docs"], id="install_shortest_prefix_literal"), + pytest.param("cp --recursive src dst", ["/repo/dst"], id="unrelated_long_option_not_a_destination"), + pytest.param("cp --no-target-directory src dst", ["/repo/dst"], id="negated_option_not_a_destination"), + pytest.param("mv --suffix=.bak src dst", ["/repo/dst"], id="valued_long_option_not_a_destination"), + pytest.param("sed --in-place s/a/b/ notes.md", ["/repo/notes.md"], id="sed_long_in_place_literal"), + pytest.param("sed -i -e s/a/b/ -e s/c/d/ notes.md", ["/repo/notes.md"], id="sed_expressions_are_not_files"), + pytest.param("sed -i.bak s/a/b/ notes.md", ["/repo/notes.md"], id="sed_suffix_literal"), + pytest.param("sed -ni s/a/b/p notes.md", ["/repo/notes.md"], id="sed_cluster_literal"), + pytest.param("sed -i -l 80 s/a/b/ notes.md", ["/repo/notes.md"], id="sed_line_length_literal"), + pytest.param("sed -i -f prog.sed notes.md", ["/repo/notes.md"], id="sed_script_file_literal"), + pytest.param("touch -- -weird", ["/repo/-weird"], id="double_dash_operand_with_dash"), + ], +) +def test_accepted_option_spellings_count_literal_destinations(command: str, expected: list) -> None: + decision = bash(command) + assert decision.action == "allow", decision.reason + assert decision.new_files == expected + zero = bash(command, make_envelope(expected_files_touched=0)) + assert zero.action == "deny" + assert "expected 0, now 1" in zero.reason + + +@pytest.mark.parametrize( + "command", + [ + pytest.param("sed -ne s/a/b/p notes.md", id="sed_not_in_place"), + pytest.param("sed --expression=s/a/b/ notes.md", id="sed_long_expression_not_in_place"), + pytest.param("sed -i s/a/b/ /scratchpad/x", id="sed_in_place_scratchpad"), + pytest.param("install --t=/scratchpad/out notes.md", id="abbreviated_target_scratchpad"), + ], +) +def test_accepted_option_spellings_controls_allow(command: str) -> None: + decision = bash(command, make_envelope(expected_files_touched=0)) + assert decision.action == "allow", decision.reason + assert decision.new_files == [] + + +# ── The Bash output-redirect grammar, every spelling ───────────────────────── + + +@pytest.mark.parametrize( + "command", + [ + pytest.param('OUT=/repo/actual.md; echo x >& "$OUT"', id="combined_output_spaced"), + pytest.param('echo x >&"$OUT"', id="combined_output_unspaced"), + pytest.param("echo x >&$OUT", id="combined_output_bare"), + pytest.param('echo x 2>& "$ERR"', id="combined_output_numbered"), + pytest.param('echo x > "$O"', id="plain"), + pytest.param('echo x >> "$O"', id="append"), + pytest.param('echo x >| "$O"', id="noclobber_override"), + pytest.param('echo x &> "$O"', id="ampersand_combined"), + pytest.param('echo x &>> "$O"', id="ampersand_combined_append"), + pytest.param('echo x 2> "$O"', id="numbered"), + pytest.param('echo x 1>> "$O"', id="numbered_append"), + pytest.param('cmd 3<> "$O"', id="read_write"), + pytest.param('cmd {fd}> "$O"', id="named_descriptor"), + pytest.param('cmd {fd}>> "$O"', id="named_descriptor_append"), + pytest.param('cmd >"$O" 2>&1', id="file_then_dup"), + pytest.param('cmd 2>&1 >"$O"', id="dup_then_file"), + pytest.param('cmd &>"$O" None: + decision = bash(command, make_envelope(expected_files_touched=0)) + assert decision.action == "ask", decision.reason + assert "variable" in decision.reason + + +@pytest.mark.parametrize( + "command, expected", + [ + pytest.param("echo x >&/repo/actual.md", ["/repo/actual.md"], id="combined_output_literal_unspaced"), + pytest.param("echo x >& notes.md", ["/repo/notes.md"], id="combined_output_literal_spaced"), + pytest.param("echo x &> notes.md", ["/repo/notes.md"], id="ampersand_combined_literal"), + pytest.param("echo x &>>notes.md", ["/repo/notes.md"], id="ampersand_combined_append_literal"), + pytest.param("cmd 3<>notes.md", ["/repo/notes.md"], id="read_write_literal"), + pytest.param("cmd {fd}>notes.md", ["/repo/notes.md"], id="named_descriptor_literal"), + pytest.param("echo x > 1", ["/repo/1"], id="plain_redirect_to_a_file_named_1"), + pytest.param("echo x >> -", ["/repo/-"], id="append_to_a_file_named_dash"), + pytest.param("cmd >notes.md 2>&1", ["/repo/notes.md"], id="file_then_dup_literal"), + ], +) +def test_every_output_redirect_spelling_counts_literal_files(command: str, expected: list) -> None: + decision = bash(command) + assert decision.action == "allow", decision.reason + assert decision.new_files == expected + zero = bash(command, make_envelope(expected_files_touched=0)) + assert zero.action == "deny" + assert "expected 0, now 1" in zero.reason + + +@pytest.mark.parametrize( + "command", + [ + pytest.param("echo x >&2", id="dup_stdout_to_stderr"), + pytest.param("cmd 2>&1", id="dup_stderr_to_stdout"), + pytest.param("cmd >&-", id="close_stdout"), + pytest.param("cmd 2>&-", id="close_stderr"), + pytest.param("cmd 1>&3", id="dup_to_descriptor_3"), + pytest.param("cmd {fd}>&-", id="close_named_descriptor"), + pytest.param("cmd <&0", id="dup_input"), + pytest.param("cmd <&-", id="close_input"), + pytest.param("cmd 2>&1 | grep x", id="dup_then_pipe"), + pytest.param("cmd >&2 | tee /scratchpad/log", id="dup_then_pipe_to_scratchpad"), + pytest.param("cat < notes.md", id="input_redirect"), + pytest.param("echo x >& /scratchpad/x", id="combined_output_scratchpad"), + ], +) +def test_descriptor_duplication_and_closure_are_not_files(command: str) -> None: + decision = bash(command, make_envelope(expected_files_touched=0)) + assert decision.action == "allow", decision.reason + assert decision.new_files == [] + + +# ── Granted re-authorization: audit-record overlay at enforcement time ─────── + + +REAUTH_SHA = "0" * 64 + + +def _write_reauth_record( + tmp_path: Path, + *, + disposition: str = "resumed", + files: Optional[int] = 4, + minutes: Optional[int] = 90, + scope_less: bool = False, + message_sha256: str = REAUTH_SHA, + message_id: str = "msg-1", + stamp: str = "20260828T000000Z", + requested_files: Optional[int] = None, + final_state: str = "pending", +) -> Path: + """Write an audit record carrying a §E re-authorization block, shaped + exactly as the gate writes it (see ``_default_reauthorization_block``).""" + scope: Optional[Dict[str, Any]] = None + if not scope_less: + scope = {} + if files is not None: + scope["max_actual_files_touched"] = files + if minutes is not None: + scope["max_actual_minutes"] = minutes + requested = ( + {"max_actual_files_touched": requested_files} + if requested_files is not None + else None + ) + record = { + "message_id": message_id, + "receiver": "claude", + "message_sha256": message_sha256, + "result": { + "final_state": final_state, + "threshold_checkpoint": { + "evaluated": True, + "reauthorization": { + "presented": True, + "channel": "receiver_human", + "decision": "approved", + "disposition": disposition, + "requested_scope": requested, + "scope": scope, + }, + }, + }, + } + return _write_audit_record( + tmp_path, + message_id=message_id, + stamp=stamp, + body=yaml.safe_dump(record, sort_keys=False), + ) + + +def _at_ceiling(tmp_path: Path) -> Path: + """Envelope compiled for 2 files, both already spent.""" + envelope = make_envelope() # expected_files_touched: 2 + envelope["counters"]["files_touched"] = ["/repo/a.py", "/repo/b.py"] + return _install_envelope(tmp_path, envelope) + + +def test_granted_reauthorization_widens_the_live_file_bound( + tmp_path: Path, +) -> None: + """Pause at N, grant N+k, then a real (non-exempt) write of file N+1 + proceeds while N+k+1 still blocks. The envelope is never recompiled — + the grant is read from the audit record at enforcement time.""" + repo = _make_workspace(tmp_path) + target = _at_ceiling(tmp_path) + _write_reauth_record(tmp_path) # N=2 → granted 4 + + third = _process_write(repo, "c.py") + assert third.action == "allow", third.reason + fourth = _process_write(repo, "d.py") + assert fourth.action == "allow", fourth.reason + + fifth = _process_write(repo, "e.py") + assert fifth.action == "deny" + assert fifth.reason.startswith(hook.BLOCKED_OPENER) + assert "expected 4, now 5" in fifth.reason + + stored = load_envelope(target) + assert stored["constraints"]["expected_files_touched"] == 2 + assert len(stored["counters"]["files_touched"]) == 4 + + +def test_ceiling_blocks_without_a_granted_reauthorization(tmp_path: Path) -> None: + """The pre-fix behavior on the unchanged path: no grant, no widening.""" + repo = _make_workspace(tmp_path) + _at_ceiling(tmp_path) + decision = _process_write(repo, "c.py") + assert decision.action == "deny" + assert "expected 2, now 3" in decision.reason + + +def test_unresumed_reauthorization_does_not_widen(tmp_path: Path) -> None: + repo = _make_workspace(tmp_path) + _at_ceiling(tmp_path) + _write_reauth_record(tmp_path, disposition="insufficient") + decision = _process_write(repo, "c.py") + assert decision.action == "deny" + assert "expected 2, now 3" in decision.reason + + +def test_scopeless_reauthorization_does_not_widen(tmp_path: Path) -> None: + """A fresh scope-less approval clears exactly its own pause and records + nothing durable — it must not raise the bound for later writes.""" + repo = _make_workspace(tmp_path) + _at_ceiling(tmp_path) + _write_reauth_record(tmp_path, scope_less=True) + decision = _process_write(repo, "c.py") + assert decision.action == "deny" + assert "expected 2, now 3" in decision.reason + + +def test_requested_scope_never_widens_past_the_effective_grant( + tmp_path: Path, +) -> None: + """Only the gate's policy-capped ``scope`` governs; the sender's + ``requested_scope`` is provenance and must not reach enforcement.""" + repo = _make_workspace(tmp_path) + _at_ceiling(tmp_path) + _write_reauth_record( + tmp_path, + files=3, + requested_files=99, + ) + third = _process_write(repo, "c.py") + assert third.action == "allow", third.reason + fourth = _process_write(repo, "d.py") + assert fourth.action == "deny" + assert "expected 3, now 4" in fourth.reason + + +def test_grant_below_the_compiled_bound_never_narrows_it(tmp_path: Path) -> None: + repo = _make_workspace(tmp_path) + _install_envelope(tmp_path, make_envelope()) # 2 files, none spent + _write_reauth_record(tmp_path, files=1) + first = _process_write(repo, "a.py") + assert first.action == "allow", first.reason + second = _process_write(repo, "b.py") + assert second.action == "allow", second.reason + assert _process_write(repo, "c.py").action == "deny" + + +def test_reauthorization_for_other_message_bytes_does_not_widen( + tmp_path: Path, +) -> None: + """Content binding: a record whose ``message_sha256`` is not this + envelope's cannot widen it, even under a matching message id.""" + repo = _make_workspace(tmp_path) + _at_ceiling(tmp_path) + _write_reauth_record(tmp_path, message_sha256="1" * 64) + decision = _process_write(repo, "c.py") + assert decision.action == "deny" + assert "expected 2, now 3" in decision.reason + + +def test_superseded_record_does_not_widen(tmp_path: Path) -> None: + repo = _make_workspace(tmp_path) + _at_ceiling(tmp_path) + _write_reauth_record(tmp_path, final_state="superseded") + decision = _process_write(repo, "c.py") + assert decision.action == "deny" + assert "expected 2, now 3" in decision.reason + + +def test_unreadable_audit_record_leaves_the_bound_standing( + tmp_path: Path, +) -> None: + """Fail-closed direction for a widening overlay: an unparseable record + is skipped, so the compiled bound blocks rather than opening.""" + repo = _make_workspace(tmp_path) + _at_ceiling(tmp_path) + _write_audit_record(tmp_path, body="{ not: [valid, yaml\n") + decision = _process_write(repo, "c.py") + assert decision.action == "deny" + assert "expected 2, now 3" in decision.reason + + +def test_newest_matching_record_governs_the_overlay(tmp_path: Path) -> None: + repo = _make_workspace(tmp_path) + _at_ceiling(tmp_path) + _write_reauth_record( + tmp_path, stamp="20260828T000000Z", files=9 + ) + _write_reauth_record( + tmp_path, stamp="20260828T010000Z", files=3 + ) + third = _process_write(repo, "c.py") + assert third.action == "allow", third.reason + assert _process_write(repo, "d.py").action == "deny" + + +def test_overlay_is_not_read_before_the_ceiling_is_reached( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The audit directory is consulted lazily — an ordinary in-budget write + must not pay for it on the hot path.""" + repo = _make_workspace(tmp_path) + _install_envelope(tmp_path, make_envelope()) # 2 files, none spent + calls: list = [] + monkeypatch.setattr( + hook, + "_reauthorized_scope", + lambda context: calls.append(context) or None, + ) + assert _process_write(repo, "a.py").action == "allow" + assert calls == [] + assert _process_write(repo, "b.py").action == "allow" + assert calls == [] + assert _process_write(repo, "c.py").action == "deny" + assert len(calls) == 1 + + +def test_forged_audit_record_cannot_widen_the_live_bound(tmp_path: Path) -> None: + """Round-1 blocking finding (F-001): the write path that would let a + bounded session author its own grant must be closed at both ends — + the record write is denied, and a record planted out-of-band (another + process, a pre-existing file) still cannot widen without the canonical + identity fields.""" + repo = _make_workspace(tmp_path) + _at_ceiling(tmp_path) + forged = ( + _agent_dir(tmp_path) + / "audit" + / "autonomy_decisions" + / "zzzz_forged.yaml" + ) + + # End 1: the session cannot write the authority record at all. + blocked = _process_bash(repo, f"echo x > {forged}") + assert blocked.action == "deny" + assert "authority self-modification" in blocked.reason + for verb in ("tee", "cp /etc/hosts", "mv /etc/hosts"): + assert _process_bash(repo, f"{verb} {forged}").action == "deny" + assert _process_write(repo, str(forged)).action == "deny" + + # End 2: even planted out-of-band with this envelope's exact identity + # (message_id + receiver + message_sha256 are all readable from the + # envelope), the re-authorization state must be complete and coherent. + # A mapping that merely spells `disposition: resumed` does not widen. + forged.parent.mkdir(parents=True, exist_ok=True) + forged.write_text( + yaml.safe_dump( + { + "message_id": "msg-1", + "receiver": "claude", + "message_sha256": REAUTH_SHA, + "result": { + "final_state": "pending", + "human_outcome": {"recorded": False}, + "threshold_checkpoint": { + "reauthorization": { + "disposition": "resumed", + "scope": {"max_actual_files_touched": 999}, + } + }, + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + decision = _process_write(repo, "c.py") + assert decision.action == "deny", ( + "an unvalidated audit mapping widened the live bound without a " + "valid grant" + ) + assert "expected 2, now 3" in decision.reason + + +@pytest.mark.parametrize( + "mutation", + [ + pytest.param({"presented": False}, id="not_presented"), + pytest.param({"channel": "gh_comment"}, id="non_governing_channel"), + pytest.param({"channel": None}, id="no_channel"), + pytest.param({"decision": "declined"}, id="declined"), + pytest.param({"decision": "sure"}, id="unknown_decision"), + pytest.param({"decision": None}, id="no_decision"), + ], +) +def test_incomplete_reauthorization_state_does_not_widen( + tmp_path: Path, mutation: Dict[str, Any] +) -> None: + """The whole re-authorization block is validated, not `disposition` + alone — a partial or incoherent block widens nothing.""" + repo = _make_workspace(tmp_path) + _at_ceiling(tmp_path) + record_path = _write_reauth_record(tmp_path) + record = yaml.safe_load(record_path.read_text(encoding="utf-8")) + record["result"]["threshold_checkpoint"]["reauthorization"].update(mutation) + record_path.write_text(yaml.safe_dump(record, sort_keys=False), encoding="utf-8") + + decision = _process_write(repo, "c.py") + assert decision.action == "deny" + assert "expected 2, now 3" in decision.reason + + +@pytest.mark.parametrize( + "template", + [ + pytest.param("rm {rec}", id="rm"), + pytest.param("rm -f {rec}", id="rm_force"), + pytest.param("rm -- {rec}", id="rm_end_of_options"), + pytest.param("unlink {rec}", id="unlink"), + pytest.param("mv {rec} /tmp/stashed.yaml", id="mv_source"), + pytest.param("mv -t /tmp {rec}", id="mv_target_dir_source"), + pytest.param("cp {rec} /tmp/copied.yaml", id="cp_source"), + pytest.param("truncate -s 0 {rec}", id="truncate"), + pytest.param("shred {rec}", id="shred"), + pytest.param("ln -s /dev/null {rec}", id="ln_alias"), + pytest.param("install /etc/hosts {rec}", id="install_dest"), + pytest.param("tee {rec}", id="tee_dest"), + pytest.param("sed -i s/a/b/ {rec}", id="sed_in_place"), + pytest.param("echo x > {rec}", id="redirect"), + pytest.param("echo x >> {rec}", id="append_redirect"), + ], +) +def test_authority_record_mutations_denied_regardless_of_role( + tmp_path: Path, template: str +) -> None: + """Round-2 blocking finding: the write-target gate sees only + destinations, so `rm`/`unlink`/`mv`-as-source escaped it. Both gates now + consume the same authority-roots list — deleting or relocating a record + changes which record governs, so role cannot matter.""" + repo = _make_workspace(tmp_path) + _install_envelope(tmp_path, make_envelope()) + record = ( + _agent_dir(tmp_path) + / "audit" + / "autonomy_decisions" + / "20260828T000000Z_msg-1.yaml" + ) + record.parent.mkdir(parents=True, exist_ok=True) + record.write_text("message_id: msg-1\n", encoding="utf-8") + command = template.format(rec=record) + decision = _process_bash(repo, command) + assert decision.action == "deny", f"{command} -> {decision.action}" + assert "self-modification" in decision.reason + + +def test_authority_roots_cover_state_and_audit_together(tmp_path: Path) -> None: + """The two gates must never protect one surface and miss the other: + both read WorkspaceContext.authority_roots().""" + context = hook.WorkspaceContext( + oacp_root=tmp_path / "home", + project="test-proj", + receiver="claude", + message_id="msg-1", + message_sha256="0" * 64, + ) + roots = {label: path for label, path in context.authority_roots()} + assert set(roots) == {"envelope state", "autonomy audit record"} + assert roots["envelope state"] == context.state_dir() + assert roots["autonomy audit record"] == context.audit_dir() + + +def test_authority_mutation_with_expansion_escalates(tmp_path: Path) -> None: + repo = _make_workspace(tmp_path) + _install_envelope(tmp_path, make_envelope()) + audit_dir = _agent_dir(tmp_path) / "audit" / "autonomy_decisions" + decision = _process_bash(repo, f"rm {audit_dir}/*.yaml") + assert decision.action == "ask" + assert "expansion" in decision.reason diff --git a/tests/test_create_handoff_packet.py b/tests/test_create_handoff_packet.py deleted file mode 100644 index 299b1df..0000000 --- a/tests/test_create_handoff_packet.py +++ /dev/null @@ -1,155 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Kiloloop -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for create_handoff_packet.py.""" - -from __future__ import annotations - -import io -import json -import tempfile -import unittest -from pathlib import Path -from unittest import mock - -import sys - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) - -from create_handoff_packet import main, render_packet # noqa: E402 -from handoff_schema import validate_handoff_packet_text # noqa: E402 - - -class TestRenderPacket(unittest.TestCase): - def test_rendered_packet_matches_schema(self) -> None: - text = render_packet( - { - "source_agent": "codex", - "target_agent": "claude", - "intent": "Transfer #77 context", - "artifacts_to_review": ["PR #83"], - "definition_of_done": ["Open merge-ready PR"], - "suggested_next_steps": ["Continue implementation"], - } - ) - self.assertEqual(validate_handoff_packet_text(text), []) - - -class TestCreateHandoffPacketMain(unittest.TestCase): - def test_missing_project_returns_2(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - with mock.patch("sys.stderr", new_callable=io.StringIO) as stderr: - rc = main( - [ - "missing-project", - "--from", - "codex", - "--to", - "claude", - "--intent", - "test", - "--oacp-dir", - tmpdir, - ] - ) - self.assertEqual(rc, 2) - self.assertIn("project directory not found", stderr.getvalue()) - - def test_dry_run_writes_to_stdout(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - project_dir = Path(tmpdir) / "projects" / "test-project" - project_dir.mkdir(parents=True) - with mock.patch("sys.stdout", new_callable=io.StringIO) as stdout: - rc = main( - [ - "test-project", - "--from", - "codex", - "--to", - "claude", - "--intent", - "handoff", - "--oacp-dir", - tmpdir, - "--dry-run", - ] - ) - self.assertEqual(rc, 0) - output = stdout.getvalue() - self.assertIn("source_agent: \"codex\"", output) - self.assertIn("target_agent: \"claude\"", output) - - def test_json_dry_run_outputs_packet(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - project_dir = Path(tmpdir) / "projects" / "test-project" - project_dir.mkdir(parents=True) - with mock.patch("sys.stdout", new_callable=io.StringIO) as stdout: - rc = main( - [ - "test-project", - "--from", - "codex", - "--to", - "claude", - "--intent", - "handoff", - "--oacp-dir", - tmpdir, - "--dry-run", - "--json", - ] - ) - self.assertEqual(rc, 0) - payload = json.loads(stdout.getvalue()) - self.assertTrue(payload["dry_run"]) - self.assertIn("packet", payload) - - def test_write_output_file(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - project_dir = Path(tmpdir) / "projects" / "test-project" - project_dir.mkdir(parents=True) - out = Path(tmpdir) / "handoff.yaml" - with mock.patch("sys.stdout", new_callable=io.StringIO) as stdout: - rc = main( - [ - "test-project", - "--from", - "codex", - "--to", - "claude", - "--intent", - "handoff", - "--oacp-dir", - tmpdir, - "--output", - str(out), - ] - ) - self.assertEqual(rc, 0) - self.assertTrue(out.is_file()) - self.assertIn("OK:", stdout.getvalue()) - - def test_invalid_packet_returns_1(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - project_dir = Path(tmpdir) / "projects" / "test-project" - project_dir.mkdir(parents=True) - with mock.patch("sys.stderr", new_callable=io.StringIO) as stderr: - rc = main( - [ - "test-project", - "--from", - "codex", - "--to", - "codex", - "--intent", - "handoff", - "--oacp-dir", - tmpdir, - ] - ) - self.assertEqual(rc, 1) - self.assertIn("must differ", stderr.getvalue()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_finalize_autonomy_record.py b/tests/test_finalize_autonomy_record.py new file mode 100644 index 0000000..9a7e6af --- /dev/null +++ b/tests/test_finalize_autonomy_record.py @@ -0,0 +1,1362 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the autonomy audit terminal finalizer and validator.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, Dict, Optional + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from finalize_autonomy_record import ( # noqa: E402 + CANONICAL_CHECKPOINT_AXES, + DuplicateKeyError, + _predecessor_evaluation_id, + apply_checkpoint, + finalize_audit_record, + load_audit_strict, + main, + sweep_audit_dir, + validate_audit_record, +) +from autonomy_gate import evaluate_threshold_checkpoint # noqa: E402 + + +ENVELOPE = { + "estimated_minutes": 45, + "expected_files_touched": 5, + "risk_tier": "P1", + "target_repo": "acme/widgets", + "destructive_ops": False, + "external_side_effects": True, + "touches_auth_config_or_secrets": False, + "touches_dependencies": False, + "public_visibility": False, + "creates_or_updates_pr": True, + "comments_on_github": False, + "commits_changes": True, + "merges_pr": False, + "files_issues": False, + "sends_oacp_reply_only": False, + "continuation_grants": {}, +} + + +def _human_outcome(decided_at: str = "2026-08-01T01:10:00Z") -> Dict[str, Any]: + return { + "recorded": True, + "actor": "alice", + "decision": "approved", + "decided_at_utc": decided_at, + "decision_latency_seconds": 600, + "pause_reason_codes": ["expected_files_touched_exceeds_threshold"], + "grant": {"decision": "not_requested"}, + } + + +def _record( + *, + decision: str = "paused", + completion_kind: str = "admission_paused", + final_state: str = "paused", + message_id: str = "msg-20260801010000-alice-0001", + human_outcome: Optional[Dict[str, Any]] = None, + envelope: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + record: Dict[str, Any] = { + "schema_version": 2, + "spec_version": "0.4.3", + "created_at_utc": "2026-08-01T01:00:00Z", + "receiver": "claude", + "sender": "alice", + "message_id": message_id, + "message_sha256": "c0ffee", + "decision": decision, + "mode": "auto_review", + "reason_codes": ["expected_files_touched_exceeds_threshold"], + "scope_envelope": dict(envelope) if envelope is not None else dict(ENVELOPE), + "continuation_grant": {"decision": "not_present", "scope": None}, + "result": { + "final_state": final_state, + "completion_kind": completion_kind, + "actual_minutes": None, + "actual_files_touched": None, + "predicted_risk_materialized": False, + "completed_at_utc": None, + "envelope_enforcement": "none", + }, + } + if human_outcome is not None: + record["result"]["human_outcome"] = human_outcome + return record + + +def _write(tmp_path: Path, record: Dict[str, Any], name: str = "20260801T010000Z_a.yaml") -> Path: + path = tmp_path / name + path.write_text(yaml.safe_dump(record, sort_keys=False), encoding="utf-8") + return path + + +# ── strict loading ──────────────────────────────────────────────────────── + + +def test_strict_loader_rejects_duplicate_keys(tmp_path: Path) -> None: + path = tmp_path / "dup.yaml" + path.write_text( + "schema_version: 2\nlogged_notes:\n- code: x\nlogged_notes: []\n", + encoding="utf-8", + ) + with pytest.raises(DuplicateKeyError): + load_audit_strict(path) + + +# ── validator ───────────────────────────────────────────────────────────── + + +def test_clean_finalized_record_validates_clean() -> None: + record = _record(human_outcome=_human_outcome()) + record["result"].update({ + "final_state": "done", + "actual_minutes": 20, + "actual_files_touched": 2, + "completed_at_utc": "2026-08-01T02:00:00Z", + }) + assert validate_audit_record(record) == [] + + +def test_off_enum_completion_kind_maps_legacy_vocabulary() -> None: + record = _record(completion_kind="executed") + findings = validate_audit_record(record) + codes = [finding["code"] for finding in findings] + assert "off_enum_completion_kind" in codes + detail = next( + finding["detail"] + for finding in findings + if finding["code"] == "off_enum_completion_kind" + ) + assert "auto_accepted" in detail + + +def test_off_enum_final_state_flagged_with_mapping() -> None: + record = _record(final_state="completed") + findings = validate_audit_record(record) + codes = [finding["code"] for finding in findings] + assert "off_enum_final_state" in codes + + +def test_superseded_final_state_is_pinned_valid() -> None: + record = _record(final_state="superseded") + record["result"]["completed_at_utc"] = "2026-08-01T02:00:00Z" + record["superseded_by_evaluation_id"] = "eval-0123456789abcdef" + assert validate_audit_record(record) == [] + + +def test_superseded_without_successor_flagged() -> None: + record = _record(final_state="superseded") + record["result"]["completed_at_utc"] = "2026-08-01T02:00:00Z" + codes = [finding["code"] for finding in validate_audit_record(record)] + assert codes == ["superseded_missing_successor"] + + +def test_pending_final_state_is_valid_live_state() -> None: + record = _record(decision="auto_accepted", completion_kind="auto_accepted", final_state="pending") + assert validate_audit_record(record) == [] + + +def test_terminal_with_paused_checkpoint_action_flagged() -> None: + record = _record( + decision="auto_accepted", + completion_kind="checkpoint_paused", + final_state="done", + human_outcome=_human_outcome(), + ) + record["result"]["completed_at_utc"] = "2026-08-01T02:00:00Z" + record["result"]["actual_minutes"] = 50 + record["result"]["actual_files_touched"] = 6 + record["result"]["threshold_checkpoint"] = { + "evaluated": True, + "breached": True, + "breached_fields": ["actual_files_touched"], + "declaration_errors": [], + "paused_at_utc": "2026-08-01T01:30:00Z", + "action": "paused_for_reauthorization", + } + codes = [finding["code"] for finding in validate_audit_record(record)] + assert "paused_terminal_checkpoint_action" in codes + + +def test_live_state_with_completed_stamp_flagged() -> None: + record = _record(final_state="paused") + record["result"]["completed_at_utc"] = "2026-08-01T02:00:00Z" + codes = [finding["code"] for finding in validate_audit_record(record)] + assert "paused_terminal_completed" in codes + + +def test_done_paused_admission_without_outcome_flagged() -> None: + record = _record(final_state="done") + record["result"].update({ + "completed_at_utc": "2026-08-01T02:00:00Z", + "actual_minutes": 10, + "actual_files_touched": 1, + }) + codes = [finding["code"] for finding in validate_audit_record(record)] + assert "terminal_paused_without_outcome" in codes + + +def test_breached_with_empty_fields_flagged() -> None: + record = _record( + decision="auto_accepted", + completion_kind="checkpoint_paused", + final_state="paused", + ) + record["result"]["threshold_checkpoint"] = { + "evaluated": True, + "breached": True, + "breached_fields": [], + "declaration_errors": [], + "paused_at_utc": "2026-08-01T01:30:00Z", + "action": "paused_for_reauthorization", + } + codes = [finding["code"] for finding in validate_audit_record(record)] + assert "breached_empty_fields" in codes + + +def test_noncanonical_axis_names_are_advisory() -> None: + record = _record( + decision="auto_accepted", + completion_kind="checkpoint_paused", + final_state="paused", + ) + record["result"]["threshold_checkpoint"] = { + "evaluated": True, + "breached": True, + "breached_fields": ["files_touched_actual"], + "declaration_errors": [], + "paused_at_utc": "2026-08-01T01:30:00Z", + "action": "paused_for_reauthorization", + } + findings = validate_audit_record(record) + axis = [f for f in findings if f["code"] == "noncanonical_checkpoint_axis"] + assert axis and axis[0]["severity"] == "advisory" + + +def test_invalid_human_outcome_flagged() -> None: + outcome = _human_outcome() + outcome["decision"] = "acknowledged" + outcome["actor"] = "a b" + record = _record(human_outcome=outcome) + codes = [finding["code"] for finding in validate_audit_record(record)] + assert "invalid_human_outcome" in codes + + +def test_decision_kind_incoherence_flagged() -> None: + record = _record(decision="auto_accepted", completion_kind="admission_paused") + codes = [finding["code"] for finding in validate_audit_record(record)] + assert "decision_kind_incoherent" in codes + + +def test_canonical_axes_cover_evaluator_vocabulary() -> None: + assert "actual_minutes" in CANONICAL_CHECKPOINT_AXES + assert "side_effects_actual.merges_pr" in CANONICAL_CHECKPOINT_AXES + assert "task_profile.external_side_effects" in CANONICAL_CHECKPOINT_AXES + + +# ── sweep ───────────────────────────────────────────────────────────────── + + +def test_sweep_flags_duplicate_live_evaluations(tmp_path: Path) -> None: + first = _record(human_outcome=_human_outcome()) + second = _record(human_outcome=_human_outcome()) + _write(tmp_path, first, "20260801T010000Z_a.yaml") + _write(tmp_path, second, "20260801T020000Z_b.yaml") + report = sweep_audit_dir(tmp_path) + assert len(report["duplicate_groups"]) == 1 + assert report["duplicate_groups"][0]["files"] == [ + "20260801T010000Z_a.yaml", + "20260801T020000Z_b.yaml", + ] + + +def test_sweep_superseded_sibling_resolves_duplicate(tmp_path: Path) -> None: + first = _record(human_outcome=_human_outcome()) + second = _record(final_state="superseded") + second["result"]["completed_at_utc"] = "2026-08-01T02:00:00Z" + _write(tmp_path, first, "20260801T010000Z_a.yaml") + _write(tmp_path, second, "20260801T020000Z_b.yaml") + report = sweep_audit_dir(tmp_path) + assert report["duplicate_groups"] == [] + + +def test_sweep_reports_duplicate_yaml_key(tmp_path: Path) -> None: + (tmp_path / "dup.yaml").write_text( + "schema_version: 2\nlogged_notes:\n- code: x\nlogged_notes: []\n", + encoding="utf-8", + ) + report = sweep_audit_dir(tmp_path) + codes = [f["code"] for f in report["records"]["dup.yaml"]] + assert codes == ["duplicate_yaml_key"] + + +# ── checkpoint recording ────────────────────────────────────────────────── + + +def test_checkpoint_within_envelope_keeps_run_state() -> None: + record = _record(decision="auto_accepted", completion_kind="auto_accepted", final_state="done") + updated, paused = apply_checkpoint( + record, {"actual_minutes": 10, "actual_files_touched": 2} + ) + assert paused is False + assert updated["result"]["completion_kind"] == "auto_accepted" + assert updated["result"]["threshold_checkpoint"]["action"] == "within_declared_envelope" + + +def test_checkpoint_breach_writes_paused_shape() -> None: + record = _record(decision="auto_accepted", completion_kind="auto_accepted", final_state="done") + updated, paused = apply_checkpoint( + record, {"actual_minutes": 90, "actual_files_touched": 2} + ) + assert paused is True + result = updated["result"] + assert result["final_state"] == "paused" + assert result["completion_kind"] == "checkpoint_paused" + checkpoint = result["threshold_checkpoint"] + assert checkpoint["breached_fields"] == ["actual_minutes"] + assert checkpoint["paused_at_utc"] + assert checkpoint["action"] == "paused_for_reauthorization" + + +def test_checkpoint_requires_envelope() -> None: + record = _record() + record["scope_envelope"] = None + with pytest.raises(ValueError, match="scope_envelope"): + apply_checkpoint(record, {"actual_minutes": 1, "actual_files_touched": 0}) + + +# ── finalization ────────────────────────────────────────────────────────── + + +def test_finalize_done_records_terminal_checkpoint(tmp_path: Path) -> None: + record = _record(human_outcome=_human_outcome()) + path = _write(tmp_path, record) + updated, paused = finalize_audit_record( + path, + record, + final_state="done", + actuals={ + "actual_minutes": 20, + "actual_files_touched": 3, + "side_effects_actual": {"creates_or_updates_pr": True, "commits_changes": True}, + }, + reply_message_id="msg-20260801020000-claude-9999", + ) + assert paused is False + result = updated["result"] + assert result["final_state"] == "done" + assert result["completion_kind"] == "admission_paused" + assert result["actual_minutes"] == 20 + assert result["actual_files_touched"] == 3 + assert result["completed_at_utc"] + assert result["reply_message_id"] == "msg-20260801020000-claude-9999" + assert result["threshold_checkpoint"]["action"] == "within_declared_envelope" + assert updated["evaluation_id"].startswith("eval-") + assert validate_audit_record(updated) == [] + + +def test_finalize_done_refuses_unapproved_paused_admission(tmp_path: Path) -> None: + record = _record() + path = _write(tmp_path, record) + with pytest.raises(ValueError, match="human outcome"): + finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 5, "actual_files_touched": 1}, + ) + + +def test_finalize_done_pauses_on_terminal_breach(tmp_path: Path) -> None: + record = _record(human_outcome=_human_outcome()) + path = _write(tmp_path, record) + updated, paused = finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 20, "actual_files_touched": 9}, + ) + assert paused is True + assert updated["result"]["final_state"] == "paused" + assert updated["result"]["completion_kind"] == "checkpoint_paused" + + +def test_finalize_done_refuses_undeclared_realized_side_effect(tmp_path: Path) -> None: + record = _record(human_outcome=_human_outcome()) + path = _write(tmp_path, record) + updated, paused = finalize_audit_record( + path, + record, + final_state="done", + actuals={ + "actual_minutes": 5, + "actual_files_touched": 1, + "side_effects_actual": {"merges_pr": True}, + }, + ) + assert paused is True + checkpoint = updated["result"]["threshold_checkpoint"] + assert "side_effects_actual.merges_pr" in checkpoint["breached_fields"] + + +def test_finalize_reconciles_resolved_checkpoint(tmp_path: Path) -> None: + record = _record( + decision="auto_accepted", + completion_kind="checkpoint_paused", + final_state="paused", + ) + record["result"]["threshold_checkpoint"] = { + "evaluated": True, + "actual_minutes": 50, + "actual_files_touched": 3, + "side_effects_actual": {}, + "breached": True, + "breached_fields": ["actual_minutes"], + "declaration_errors": [], + "breach_basis": "realized", + "paused_at_utc": "2026-08-01T01:30:00Z", + "action": "paused_for_reauthorization", + "reauthorization": { + "presented": True, + "channel": "receiver_human", + "decision": "approved", + "disposition": "resumed", + }, + } + path = _write(tmp_path, record) + # A scope-less approval covers exactly the extent recorded at the + # pause, so the terminal actuals may not exceed it. + updated, paused = finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 50, "actual_files_touched": 3}, + ) + assert paused is False + result = updated["result"] + assert result["final_state"] == "done" + assert result["actual_minutes"] == 50 + checkpoint = result["threshold_checkpoint"] + assert checkpoint["action"] == "resumed_after_reauthorization" + assert validate_audit_record(updated) == [] + + +def test_finalize_done_refuses_unresolved_checkpoint(tmp_path: Path) -> None: + record = _record( + decision="auto_accepted", + completion_kind="checkpoint_paused", + final_state="paused", + ) + record["result"]["threshold_checkpoint"] = { + "evaluated": True, + "breached": True, + "breached_fields": ["actual_minutes"], + "declaration_errors": [], + "paused_at_utc": "2026-08-01T01:30:00Z", + "action": "paused_for_reauthorization", + "reauthorization": {"presented": False, "disposition": "unanswered"}, + } + path = _write(tmp_path, record) + with pytest.raises(ValueError, match="terminal"): + finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 50, "actual_files_touched": 3}, + ) + + +def test_finalize_done_refuses_live_duplicate_sibling(tmp_path: Path) -> None: + record = _record(human_outcome=_human_outcome()) + sibling = _record() + path = _write(tmp_path, record, "20260801T010000Z_a.yaml") + _write(tmp_path, sibling, "20260801T020000Z_b.yaml") + with pytest.raises(ValueError, match="duplicate logical id"): + finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 5, "actual_files_touched": 1}, + ) + + +def _write_successor( + tmp_path: Path, + evaluation_id: str = "eval-0123456789abcdef", + supersedes: Optional[str] = None, +) -> Path: + # Strict resolution: the successor shares the predecessor's logical + # identity (same _record() fields) and references it back. + successor = _record() + successor["evaluation_id"] = evaluation_id + successor["supersedes_evaluation_id"] = ( + supersedes if supersedes is not None + else _predecessor_evaluation_id(_record()) + ) + return _write(tmp_path, successor, "20260801T030000Z_successor.yaml") + + +def test_finalize_superseded_closes_stale_sibling(tmp_path: Path) -> None: + record = _record() + path = _write(tmp_path, record) + _write_successor(tmp_path) + updated, paused = finalize_audit_record( + path, + record, + final_state="superseded", + actuals={}, + superseded_by="eval-0123456789abcdef", + ) + assert paused is False + assert updated["result"]["final_state"] == "superseded" + assert updated["superseded_by_evaluation_id"] == "eval-0123456789abcdef" + assert updated["result"]["completed_at_utc"] + assert validate_audit_record(updated) == [] + + +def test_finalize_refuses_off_enum_completion_kind(tmp_path: Path) -> None: + record = _record(completion_kind="executed") + path = _write(tmp_path, record) + with pytest.raises(ValueError, match="off-enum"): + finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 5, "actual_files_touched": 1}, + ) + + +def test_finalize_refuses_closed_record_without_replace(tmp_path: Path) -> None: + record = _record(human_outcome=_human_outcome()) + record["result"].update({ + "final_state": "done", + "actual_minutes": 5, + "actual_files_touched": 1, + "completed_at_utc": "2026-08-01T02:00:00Z", + }) + path = _write(tmp_path, record) + with pytest.raises(ValueError, match="already closed"): + finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 5, "actual_files_touched": 1}, + ) + + +def test_finalize_error_state_allowed_without_outcome(tmp_path: Path) -> None: + record = _record() + path = _write(tmp_path, record) + updated, paused = finalize_audit_record( + path, + record, + final_state="error", + actuals={"actual_minutes": 3, "actual_files_touched": 0}, + ) + assert paused is False + assert updated["result"]["final_state"] == "error" + assert updated["result"]["actual_minutes"] == 3 + + +# ── CLI ─────────────────────────────────────────────────────────────────── + + +def test_cli_finalizes_done(tmp_path: Path) -> None: + record = _record(human_outcome=_human_outcome()) + path = _write(tmp_path, record) + rc = main([ + str(path), + "--final-state", "done", + "--actual-minutes", "20", + "--actual-files-touched", "3", + "--realized", "creates_or_updates_pr", + "--realized", "commits_changes", + "--reply-message-id", "msg-20260801020000-claude-9999", + ]) + assert rc == 0 + stored = yaml.safe_load(path.read_text(encoding="utf-8")) + assert stored["result"]["final_state"] == "done" + assert stored["result"]["reply_message_id"] == "msg-20260801020000-claude-9999" + + +def test_cli_started_at_derives_serialized_item_minutes(tmp_path: Path) -> None: + envelope = dict(ENVELOPE) + envelope["estimated_minutes"] = 30 + record = _record( + decision="auto_accepted", + completion_kind="auto_accepted", + final_state="done", + envelope=envelope, + ) + path = _write(tmp_path, record) + + rc = main([ + str(path), + "--final-state", "done", + "--started-at", "2026-08-01T01:20:00Z", + "--completed-at", "2026-08-01T01:45:00Z", + "--actual-files-touched", "1", + ]) + + assert rc == 0 + stored = yaml.safe_load(path.read_text(encoding="utf-8")) + result = stored["result"] + assert result["work_started_at_utc"] == "2026-08-01T01:20:00Z" + assert result["actual_minutes"] == 25 + assert result["threshold_checkpoint"]["breached"] is False + + +def test_cli_started_at_excludes_reauthorization_pause(tmp_path: Path) -> None: + record = _resolved_checkpoint_record() + checkpoint = _gate_checkpoint("approved") + record["result"]["threshold_checkpoint"] = checkpoint + assert checkpoint["reauthorization"]["cleared_paused_at_utc"] == ( + "2026-08-01T01:40:00Z" + ) + path = _write(tmp_path, record) + + rc = main([ + str(path), + "--final-state", "done", + "--started-at", "2026-08-01T01:20:00Z", + "--completed-at", "2026-08-01T02:00:00Z", + "--actual-files-touched", "3", + ]) + + assert rc == 0 + stored = yaml.safe_load(path.read_text(encoding="utf-8")) + assert stored["result"]["actual_minutes"] == 30 + + +def test_uncleared_pause_conformance_record_finalizes_error(tmp_path: Path) -> None: + record = _resolved_checkpoint_record() + checkpoint = _gate_checkpoint("declined") + record["result"]["threshold_checkpoint"] = checkpoint + assert checkpoint["action"] == "reauthorization_declined" + assert checkpoint["reauthorization"]["cleared_paused_at_utc"] is None + path = _write(tmp_path, record) + + rc = main([ + str(path), + "--final-state", "error", + "--started-at", "2026-08-01T01:20:00Z", + "--completed-at", "2026-08-01T02:00:00Z", + "--actual-files-touched", "3", + ]) + + assert rc == 0 + stored = yaml.safe_load(path.read_text(encoding="utf-8")) + assert stored["result"]["actual_minutes"] == 10 + assert validate_audit_record(stored) == [] + + +def test_validator_reports_inconsistent_work_clock() -> None: + record = _record( + decision="auto_accepted", + completion_kind="auto_accepted", + final_state="done", + ) + record["result"].update({ + "work_started_at_utc": "2026-08-01T00:55:00Z", + "actual_minutes": 25, + "actual_files_touched": 1, + "completed_at_utc": "2026-08-01T01:45:00Z", + }) + + codes = [finding["code"] for finding in validate_audit_record(record)] + + assert codes == [ + "work_started_before_admission", + "actual_minutes_inconsistent", + ] + + +def test_cli_checkpoint_breach_exits_4(tmp_path: Path) -> None: + record = _record(decision="auto_accepted", completion_kind="auto_accepted", final_state="done") + path = _write(tmp_path, record) + rc = main([ + str(path), + "--checkpoint", + "--actual-minutes", "90", + "--actual-files-touched", "2", + ]) + assert rc == 4 + stored = yaml.safe_load(path.read_text(encoding="utf-8")) + assert stored["result"]["final_state"] == "paused" + assert stored["result"]["completion_kind"] == "checkpoint_paused" + + +def test_cli_validate_reports_findings(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: + record = _record(completion_kind="executed") + path = _write(tmp_path, record) + rc = main([str(path), "--validate"]) + captured = capsys.readouterr() + assert rc == 2 + assert "off_enum" in captured.out or "off-enum" in captured.out + + +def test_cli_validate_sweep_clean_dir(tmp_path: Path) -> None: + record = _record(human_outcome=_human_outcome()) + path = _write(tmp_path, record) + rc = main([str(path), "--validate", "--sweep"]) + assert rc == 0 + + +# ── round-1 review regressions (F-002..F-005) ───────────────────────────── + + +@pytest.mark.parametrize("closed_shape", ["done", "error", "superseded"]) +def test_checkpoint_refuses_closed_records(tmp_path: Path, closed_shape: str) -> None: + record = _record( + decision="auto_accepted", + completion_kind="auto_accepted", + final_state=closed_shape, + ) + if closed_shape == "superseded": + record["superseded_by_evaluation_id"] = "eval-0123456789abcdef" + else: + record["result"]["completed_at_utc"] = "2026-08-01T02:00:00Z" + path = _write(tmp_path, record) + original = path.read_text(encoding="utf-8") + + with pytest.raises(ValueError, match="cannot reopen"): + apply_checkpoint(record, {"actual_minutes": 90, "actual_files_touched": 2}) + rc = main([ + str(path), + "--checkpoint", + "--actual-minutes", "90", + "--actual-files-touched", "2", + ]) + assert rc == 2 + assert path.read_text(encoding="utf-8") == original + + +def _resolved_checkpoint_record(reauth_scope: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + record = _record( + decision="auto_accepted", + completion_kind="checkpoint_paused", + final_state="paused", + ) + reauth: Dict[str, Any] = { + "presented": True, + "channel": "receiver_human", + "decision": "approved", + "decided_at_utc": "2026-08-01T01:40:00Z", + "disposition": "resumed", + } + if reauth_scope is not None: + reauth["scope"] = reauth_scope + record["result"]["threshold_checkpoint"] = { + "evaluated": True, + "actual_minutes": 50, + "actual_files_touched": 3, + "side_effects_actual": {}, + "breached": True, + "breached_fields": ["actual_minutes"], + "declaration_errors": [], + "breach_basis": "realized", + "paused_at_utc": "2026-08-01T01:30:00Z", + "action": "paused_for_reauthorization", + "reauthorization": reauth, + } + return record + + +def _gate_checkpoint(decision: str) -> Dict[str, Any]: + return evaluate_threshold_checkpoint( + ENVELOPE, + {"present": False}, + { + "actual_minutes": 50, + "actual_files_touched": 3, + "paused_at_utc": "2026-08-01T01:30:00Z", + "reauthorization": { + "receiver_human": { + "decision": decision, + "decided_at_utc": "2026-08-01T01:40:00Z", + }, + }, + }, + policy={"thresholds": {}}, + ) + + +# ── read-back enforcement of the breach-basis grammar ───────────────────── + + +def _finalized_time_breach_record(tmp_path: Path) -> Dict[str, Any]: + record = _resolved_checkpoint_record() + path = _write(tmp_path, record) + done, paused = finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 50, "actual_files_touched": 3}, + ) + assert paused is False + assert validate_audit_record(done) == [] + return done + + +def _clean_done_record() -> Dict[str, Any]: + record = _record(human_outcome=_human_outcome()) + record["result"].update({ + "final_state": "done", + "actual_minutes": 20, + "actual_files_touched": 2, + "completed_at_utc": "2026-08-01T02:00:00Z", + "threshold_checkpoint": { + "evaluated": True, + "actual_minutes": 20, + "actual_files_touched": 2, + "side_effects_actual": {}, + "breached": False, + "breached_fields": [], + "declaration_errors": [], + "breach_basis": None, + "breach_sub_basis": None, + "paused_at_utc": None, + "action": "not_evaluated", + }, + }) + assert validate_audit_record(record) == [] + return record + + +def test_validator_accepts_waiting_on_peer_on_realized_time_breach(tmp_path: Path) -> None: + done = _finalized_time_breach_record(tmp_path) + done["result"]["threshold_checkpoint"]["breach_sub_basis"] = "waiting_on_peer" + assert validate_audit_record(done) == [] + + +def test_validator_tolerates_legacy_null_basis_on_breach(tmp_path: Path) -> None: + # Records written before breach_basis existed carry null on a breach; + # they stay finding-free rather than turning the fleet sweep red. + done = _finalized_time_breach_record(tmp_path) + done["result"]["threshold_checkpoint"]["breach_basis"] = None + assert validate_audit_record(done) == [] + + +@pytest.mark.parametrize( + ("base", "patch", "code", "fragment"), + [ + ("breached", {"breach_sub_basis": "reviewing"}, "off_enum_breach_basis", "breach_sub_basis 'reviewing'"), + ("breached", {"breach_basis": "guessed"}, "off_enum_breach_basis", "breach_basis 'guessed'"), + ( + "breached", + {"breach_sub_basis": "waiting_on_peer", "breached_fields": ["actual_files_touched"]}, + "breach_basis_incoherent", + "actual_minutes", + ), + ( + "breached", + {"breach_sub_basis": "waiting_on_peer", "breach_basis": "declared_intent"}, + "breach_basis_incoherent", + "not realized", + ), + ("clean", {"breach_sub_basis": "waiting_on_peer"}, "breach_basis_incoherent", "not breached"), + ("clean", {"breach_basis": "realized"}, "breach_basis_incoherent", "unbreached"), + # enum-valid label on the opposite breach-source shape + ( + "breached", + {"breach_basis": "declared_intent"}, + "breach_basis_incoherent", + "realized axes ['actual_minutes']", + ), + ( + "breached", + { + "breach_basis": "realized", + "breached_fields": ["task_profile.merges_pr"], + "declaration_errors": ["task_profile.merges_pr"], + }, + "breach_basis_incoherent", + "prospective task_profile axes ['task_profile.merges_pr']", + ), + ( + "breached", + { + "breach_basis": "realized", + "breached_fields": ["actual_minutes"], + "declaration_errors": ["task_profile.merges_pr"], + }, + "breach_basis_incoherent", + "prospective task_profile axes", + ), + ( + "breached", + { + "breach_basis": "declared_intent", + "breached_fields": ["task_profile.merges_pr"], + "declaration_errors": ["task_profile.merges_pr"], + "side_effects_actual": {"merges_pr": True}, + }, + "breach_basis_incoherent", + "side_effects_actual realized ['merges_pr']", + ), + ( + "breached", + { + "breach_basis": "declared_intent", + "breached_fields": ["task_profile.merges_pr"], + "declaration_errors": ["task_profile.merges_pr"], + "predicted_risk_materialized": True, + }, + "breach_basis_incoherent", + "predicted_risk_materialized is true", + ), + ], +) +def test_validator_rejects_persisted_off_grammar_basis( + tmp_path: Path, base: str, patch: Dict[str, Any], code: str, fragment: str +) -> None: + record = _finalized_time_breach_record(tmp_path) if base == "breached" else _clean_done_record() + record["result"]["threshold_checkpoint"].update(patch) + findings = validate_audit_record(record) + # Every finding carries the expected code (a doubly-incoherent shape may + # legitimately report it twice), never a different one. + assert findings and {finding["code"] for finding in findings} == {code}, findings + assert all(finding["severity"] == "error" for finding in findings) + assert fragment in " | ".join(finding["detail"] for finding in findings) + + +def test_validator_accepts_coherent_declared_intent_shape(tmp_path: Path) -> None: + # The prospective shape the evaluator writes: task_profile.* fields, + # every realized effect false, materialization pinned false. + done = _finalized_time_breach_record(tmp_path) + done["result"]["threshold_checkpoint"].update({ + "breach_basis": "declared_intent", + "breached_fields": ["task_profile.merges_pr"], + "declaration_errors": ["task_profile.merges_pr"], + "side_effects_actual": {"merges_pr": False}, + "predicted_risk_materialized": False, + }) + assert validate_audit_record(done) == [] + + +def test_declared_intent_checkpoint_round_trips_through_read_back() -> None: + record = _record( + decision="auto_accepted", completion_kind="auto_accepted", final_state="pending" + ) + updated, paused = apply_checkpoint( + record, + { + "actual_minutes": 1, + "actual_files_touched": 0, + "declared_intent_fields": ["task_profile.merges_pr"], + "paused_at_utc": "2026-08-01T01:30:00Z", + }, + ) + assert paused is True + checkpoint = updated["result"]["threshold_checkpoint"] + assert checkpoint["breach_basis"] == "declared_intent" + assert checkpoint["breached_fields"] == ["task_profile.merges_pr"] + assert validate_audit_record(updated) == [] + + +def test_finalize_refuses_stored_off_enum_sub_basis(tmp_path: Path) -> None: + # The reconciliation path must not certify malformed stored evidence. + record = _resolved_checkpoint_record() + record["result"]["threshold_checkpoint"]["breach_sub_basis"] = "reviewing" + path = _write(tmp_path, record) + with pytest.raises(ValueError, match="breach_sub_basis 'reviewing'"): + finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 50, "actual_files_touched": 3}, + ) + + +def test_checkpoint_round_trips_waiting_on_peer_through_read_back() -> None: + # What the evaluator writes for a peer-wait time breach validates clean + # on read-back; the two sides of the grammar agree. + record = _record( + decision="auto_accepted", completion_kind="auto_accepted", final_state="pending" + ) + updated, paused = apply_checkpoint( + record, + { + "actual_minutes": 60, + "actual_files_touched": 1, + "breach_sub_basis": "waiting_on_peer", + "paused_at_utc": "2026-08-01T01:30:00Z", + }, + ) + assert paused is True + checkpoint = updated["result"]["threshold_checkpoint"] + assert checkpoint["breached_fields"] == ["actual_minutes"] + assert checkpoint["breach_basis"] == "realized" + assert checkpoint["breach_sub_basis"] == "waiting_on_peer" + assert validate_audit_record(updated) == [] + with pytest.raises(ValueError, match="breach_sub_basis"): + apply_checkpoint( + record, + {"actual_minutes": 60, "actual_files_touched": 1, "breach_sub_basis": "reviewing"}, + ) + + +def test_reconcile_refuses_newly_realized_uncovered_effect(tmp_path: Path) -> None: + record = _resolved_checkpoint_record() + path = _write(tmp_path, record) + with pytest.raises(ValueError, match="side_effects_actual.merges_pr"): + finalize_audit_record( + path, + record, + final_state="done", + actuals={ + "actual_minutes": 55, + "actual_files_touched": 4, + "side_effects_actual": {"merges_pr": True}, + }, + ) + + +def test_reconcile_refuses_numeric_beyond_reauthorized_budget(tmp_path: Path) -> None: + record = _resolved_checkpoint_record( + reauth_scope={"max_actual_minutes": 60, "max_actual_files_touched": 5} + ) + path = _write(tmp_path, record) + with pytest.raises(ValueError, match="exceeds the re-authorized budget"): + finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 65, "actual_files_touched": 4}, + ) + + +def test_reconcile_persists_complete_terminal_side_effects(tmp_path: Path) -> None: + record = _resolved_checkpoint_record() + path = _write(tmp_path, record) + updated, paused = finalize_audit_record( + path, + record, + final_state="done", + actuals={ + "actual_minutes": 50, + "actual_files_touched": 3, + "side_effects_actual": { + "creates_or_updates_pr": True, + "commits_changes": True, + }, + }, + ) + assert paused is False + checkpoint = updated["result"]["threshold_checkpoint"] + assert checkpoint["side_effects_actual"]["creates_or_updates_pr"] is True + assert checkpoint["side_effects_actual"]["commits_changes"] is True + assert checkpoint["side_effects_actual"]["merges_pr"] is False + assert checkpoint["action"] == "resumed_after_reauthorization" + assert validate_audit_record(updated) == [] + + +def test_reconcile_scope_less_numeric_growth_refused(tmp_path: Path) -> None: + record = _resolved_checkpoint_record() + path = _write(tmp_path, record) + for grown in (55, 500): + with pytest.raises(ValueError, match="scope-less approval cleared"): + finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": grown, "actual_files_touched": 3}, + ) + + +def test_reconcile_scoped_budget_allows_growth_within_budget(tmp_path: Path) -> None: + record = _resolved_checkpoint_record( + reauth_scope={"max_actual_minutes": 60, "max_actual_files_touched": 5} + ) + path = _write(tmp_path, record) + updated, paused = finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 55, "actual_files_touched": 4}, + ) + assert paused is False + assert updated["result"]["actual_minutes"] == 55 + + +def test_reconcile_prior_realized_effect_is_monotonic(tmp_path: Path) -> None: + record = _resolved_checkpoint_record() + record["result"]["threshold_checkpoint"]["side_effects_actual"] = { + "merges_pr": True + } + record["result"]["threshold_checkpoint"]["breached_fields"] = [ + "side_effects_actual.merges_pr" + ] + path = _write(tmp_path, record) + # Terminal actuals that omit the effect must carry the recorded true + # forward, never rewrite it to false. + updated, paused = finalize_audit_record( + path, + record, + final_state="done", + actuals={"actual_minutes": 50, "actual_files_touched": 3}, + ) + assert paused is False + checkpoint = updated["result"]["threshold_checkpoint"] + assert checkpoint["side_effects_actual"]["merges_pr"] is True + # An explicit terminal false against a recorded true is contradictory + # under-reporting and refuses. + fresh = _resolved_checkpoint_record() + fresh["result"]["threshold_checkpoint"]["side_effects_actual"] = { + "merges_pr": True + } + fresh_dir = tmp_path / "fresh" + fresh_dir.mkdir() + fresh_path = _write(fresh_dir, fresh) + with pytest.raises(ValueError, match="monotonic evidence"): + finalize_audit_record( + fresh_path, + fresh, + final_state="done", + actuals={ + "actual_minutes": 50, + "actual_files_touched": 3, + "side_effects_actual": {"merges_pr": False}, + }, + ) + + +def test_supersede_repairs_off_enum_legacy_record(tmp_path: Path) -> None: + record = _record(completion_kind="executed", final_state="completed") + record["result"]["completed_at_utc"] = "2026-08-01T02:00:00Z" + path = _write(tmp_path, record) + _write_successor(tmp_path) + rc = main([ + str(path), + "--final-state", "superseded", + "--superseded-by", "eval-0123456789abcdef", + ]) + assert rc == 0 + stored = yaml.safe_load(path.read_text(encoding="utf-8")) + assert stored["result"]["final_state"] == "superseded" + assert stored["result"]["completion_kind"] == "executed" + assert stored["result"]["legacy_final_state"] == "completed" + assert stored["superseded_by_evaluation_id"] == "eval-0123456789abcdef" + assert validate_audit_record(stored) == [] + + +def test_supersede_requires_wellformed_resolvable_successor(tmp_path: Path) -> None: + record = _record() + path = _write(tmp_path, record) + original = path.read_text(encoding="utf-8") + assert main([str(path), "--final-state", "superseded"]) == 2 + assert main([ + str(path), "--final-state", "superseded", "--superseded-by", "not-an-id" + ]) == 2 + # Well-formed but resolving to no record in the directory: an orphan + # chain, refused the same way. + assert main([ + str(path), + "--final-state", "superseded", + "--superseded-by", "eval-deadbeefdeadbeef", + ]) == 2 + assert path.read_text(encoding="utf-8") == original + + +def test_supersede_refuses_unrelated_identity_successor(tmp_path: Path) -> None: + """A record merely carrying the id is not a successor. + + An otherwise valid evaluation for a different (receiver, message_id) + must not satisfy successor resolution — authority never transfers + across logical identities. + """ + record = _record() + path = _write(tmp_path, record) + unrelated = _record() + unrelated["receiver"] = "other-receiver" + unrelated["message_id"] = "msg-20260801010000-alice-unrelated" + unrelated["evaluation_id"] = "eval-bbbbbbbbbbbbbbbb" + unrelated["supersedes_evaluation_id"] = _predecessor_evaluation_id(record) + _write(tmp_path, unrelated, "20260801T030000Z_unrelated.yaml") + original = path.read_text(encoding="utf-8") + rc = main([ + str(path), + "--final-state", "superseded", + "--superseded-by", "eval-bbbbbbbbbbbbbbbb", + ]) + assert rc == 2 + assert path.read_text(encoding="utf-8") == original + + +def test_supersede_refuses_ambiguous_successor(tmp_path: Path) -> None: + record = _record() + path = _write(tmp_path, record) + _write_successor(tmp_path) + duplicate = _record() + duplicate["evaluation_id"] = "eval-0123456789abcdef" + duplicate["supersedes_evaluation_id"] = _predecessor_evaluation_id(record) + _write(tmp_path, duplicate, "20260801T040000Z_duplicate_holder.yaml") + original = path.read_text(encoding="utf-8") + rc = main([ + str(path), + "--final-state", "superseded", + "--superseded-by", "eval-0123456789abcdef", + ]) + assert rc == 2 + assert path.read_text(encoding="utf-8") == original + + +def test_supersede_refuses_successor_without_back_reference(tmp_path: Path) -> None: + record = _record() + path = _write(tmp_path, record) + _write_successor(tmp_path, supersedes="eval-1111111111111111") + original = path.read_text(encoding="utf-8") + with pytest.raises(ValueError, match="does not reference this evaluation"): + finalize_audit_record( + path, + record, + final_state="superseded", + actuals={}, + superseded_by="eval-0123456789abcdef", + ) + assert path.read_text(encoding="utf-8") == original + + +def test_supersede_refuses_duplicate_key_successor(tmp_path: Path) -> None: + """Ambiguous successor evidence is refused before the predecessor closes. + + A permissive loader would let the later of two duplicate + supersedes_evaluation_id keys decide the back-reference; only strict + bytes may serve as successor evidence. + """ + record = _record() + path = _write(tmp_path, record) + pred_id = _predecessor_evaluation_id(record) + successor = _record() + successor["evaluation_id"] = "eval-0123456789abcdef" + successor["supersedes_evaluation_id"] = "eval-1111111111111111" + text = yaml.safe_dump(successor, sort_keys=False) + text += f"supersedes_evaluation_id: {pred_id}\n" + (tmp_path / "20260801T030000Z_successor.yaml").write_text( + text, encoding="utf-8" + ) + original = path.read_text(encoding="utf-8") + with pytest.raises(ValueError, match="ambiguous or unreadable bytes"): + finalize_audit_record( + path, + record, + final_state="superseded", + actuals={}, + superseded_by="eval-0123456789abcdef", + ) + assert path.read_text(encoding="utf-8") == original + + +def test_supersede_accepts_back_reference_via_superseded_ids_list( + tmp_path: Path, +) -> None: + """A multi-prior heal successor references older priors through the + superseded_evaluation_ids list rather than the single back-pointer.""" + record = _record() + path = _write(tmp_path, record) + successor = _record() + successor["evaluation_id"] = "eval-0123456789abcdef" + successor["supersedes_evaluation_id"] = "eval-2222222222222222" + successor["superseded_evaluation_ids"] = [ + "eval-2222222222222222", + _predecessor_evaluation_id(record), + ] + _write(tmp_path, successor, "20260801T030000Z_successor.yaml") + updated, paused = finalize_audit_record( + path, + record, + final_state="superseded", + actuals={}, + superseded_by="eval-0123456789abcdef", + ) + assert paused is False + assert updated["superseded_by_evaluation_id"] == "eval-0123456789abcdef" + + +def test_sweep_flags_unrelated_identity_successor(tmp_path: Path) -> None: + record = _record(final_state="superseded") + record["result"]["completed_at_utc"] = "2026-08-01T02:00:00Z" + record["superseded_by_evaluation_id"] = "eval-bbbbbbbbbbbbbbbb" + _write(tmp_path, record) + unrelated = _record() + unrelated["receiver"] = "other-receiver" + unrelated["message_id"] = "msg-20260801010000-alice-unrelated" + unrelated["evaluation_id"] = "eval-bbbbbbbbbbbbbbbb" + _write(tmp_path, unrelated, "20260801T030000Z_unrelated.yaml") + report = sweep_audit_dir(tmp_path) + codes = [ + finding["code"] + for findings in report["records"].values() + for finding in findings + ] + assert codes == ["superseded_missing_successor"] + + +def test_sweep_accepts_multi_prior_heal_chain(tmp_path: Path) -> None: + """Both predecessors of a multi-prior heal resolve through the + successor's superseded_evaluation_ids list — no findings.""" + older = _record(final_state="superseded") + older["result"]["completed_at_utc"] = "2026-08-01T02:00:00Z" + older["evaluation_id"] = "eval-2222222222222222" + older["superseded_by_evaluation_id"] = "eval-0123456789abcdef" + _write(tmp_path, older, "20260801T010000Z_older.yaml") + newer = _record(final_state="superseded") + newer["result"]["completed_at_utc"] = "2026-08-01T02:30:00Z" + newer["evaluation_id"] = "eval-3333333333333333" + newer["superseded_by_evaluation_id"] = "eval-0123456789abcdef" + _write(tmp_path, newer, "20260801T020000Z_newer.yaml") + survivor = _record() + survivor["evaluation_id"] = "eval-0123456789abcdef" + survivor["supersedes_evaluation_id"] = "eval-3333333333333333" + survivor["superseded_evaluation_ids"] = [ + "eval-2222222222222222", + "eval-3333333333333333", + ] + _write(tmp_path, survivor, "20260801T030000Z_survivor.yaml") + report = sweep_audit_dir(tmp_path) + codes = [ + finding["code"] + for findings in report["records"].values() + for finding in findings + ] + assert codes == [] + + +def test_sweep_flags_dangling_successor_chain(tmp_path: Path) -> None: + record = _record(final_state="superseded") + record["result"]["completed_at_utc"] = "2026-08-01T02:00:00Z" + record["superseded_by_evaluation_id"] = "eval-deadbeefdeadbeef" + _write(tmp_path, record) + report = sweep_audit_dir(tmp_path) + codes = [ + finding["code"] + for findings in report["records"].values() + for finding in findings + ] + assert codes == ["superseded_missing_successor"] + + +def test_resupersede_refused(tmp_path: Path) -> None: + record = _record(final_state="superseded") + record["superseded_by_evaluation_id"] = "eval-0123456789abcdef" + path = _write(tmp_path, record) + with pytest.raises(ValueError, match="already superseded"): + finalize_audit_record( + path, + record, + final_state="superseded", + actuals={}, + superseded_by="eval-fedcba9876543210", + ) diff --git a/tests/test_init_org_memory.py b/tests/test_init_org_memory.py new file mode 100644 index 0000000..fdaa76a --- /dev/null +++ b/tests/test_init_org_memory.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the org-memory initializer.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from init_org_memory import initialize_org_memory # noqa: E402 + + +def test_init_creates_full_layout(tmp_path: Path) -> None: + result = initialize_org_memory(tmp_path) + + org = tmp_path / "org-memory" + assert (org / "events").is_dir() + assert (org / "debriefs").is_dir() + assert (org / "debriefs" / ".gitkeep").is_file() + for scaffold in ("recent.md", "decisions.md", "rules.md"): + assert (org / scaffold).is_file() + assert "debriefs/.gitkeep" in result["created"] + + +def test_init_is_idempotent(tmp_path: Path) -> None: + initialize_org_memory(tmp_path) + result = initialize_org_memory(tmp_path) + + assert result["created"] == [] + assert "debriefs/.gitkeep" in result["skipped"] + + +def test_init_backfills_debriefs_on_existing_store(tmp_path: Path) -> None: + # An org-memory tree created before the debrief store existed gains + # debriefs/ on re-init without touching existing content. + org = tmp_path / "org-memory" + (org / "events").mkdir(parents=True) + (org / "recent.md").write_text("# Recent\nexisting\n", encoding="utf-8") + + result = initialize_org_memory(tmp_path) + + assert (org / "debriefs" / ".gitkeep").is_file() + assert (org / "recent.md").read_text(encoding="utf-8") == "# Recent\nexisting\n" + assert "recent.md" in result["skipped"] diff --git a/tests/test_memory_sync.py b/tests/test_memory_sync.py index 0cfe702..38846c8 100644 --- a/tests/test_memory_sync.py +++ b/tests/test_memory_sync.py @@ -300,3 +300,32 @@ def test_canonical_gitignore_denies_keystore_last() -> None: assert lines.index("keys/") > max( i for i, line in enumerate(lines) if line.startswith("!") ) + + +def test_debrief_paths_are_allowed_memory_paths() -> None: + # The central debrief store must sync cross-machine: every path under + # org-memory/debriefs/** is inside the memory allowlist. + from memory_sync import is_allowed_memory_path + + allowed = [ + "org-memory/debriefs/.gitkeep", + "org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md", + ] + for path in allowed: + assert is_allowed_memory_path(path), path + + +def test_canonical_gitignore_syncs_nested_debrief_files(tmp_path: Path) -> None: + # The canonical allowlist must actually stage deeply nested debrief + # files (project/year/month), not just top-level org-memory content. + repo = tmp_path / "oacp" + _git("init", str(repo)) + _configure_identity(repo) + _write(repo / ".gitignore", CANONICAL_MEMORY_GITIGNORE) + debrief = "org-memory/debriefs/demo-project/2026/08/20260825-alice-1f3a9c2b.md" + _write(repo / debrief, "---\nschema_version: 1\n---\nbody\n") + _write(repo / "org-memory" / "debriefs" / ".gitkeep", "") + _git("add", "-A", cwd=repo) + tracked = _git("ls-files", cwd=repo).splitlines() + assert debrief in tracked + assert "org-memory/debriefs/.gitkeep" in tracked diff --git a/tests/test_oacp_doctor.py b/tests/test_oacp_doctor.py index 3c53667..f6e8973 100644 --- a/tests/test_oacp_doctor.py +++ b/tests/test_oacp_doctor.py @@ -25,6 +25,7 @@ Severity, apply_fixes, check_autonomy, + check_agent_registry, check_agent_status, check_environment, check_inbox_health, @@ -447,6 +448,85 @@ def test_check_autonomy_reports_orphaned_policy_ref(self) -> None: policy_refs = next(r for r in cat.results if "policy-refs" in r.name) self.assertEqual(policy_refs.severity, Severity.warn) + def test_check_autonomy_audit_sweep_flags_off_enum_record(self) -> None: + with tempfile.TemporaryDirectory() as td: + project_dir = Path(td) + audit_dir = project_dir / "agents" / "codex" / "audit" / "autonomy_decisions" + audit_dir.mkdir(parents=True) + _write( + audit_dir / "20260512T132325Z_msg-demo.yaml", + ( + "schema_version: 2\n" + "receiver: codex\n" + "message_id: msg-demo\n" + "decision: auto_accepted\n" + "result:\n" + " final_state: done\n" + " completion_kind: executed\n" + ), + ) + import yaml + + cat = check_autonomy(project_dir, yaml_loader=yaml.safe_load) + integrity = next( + r for r in cat.results if "audit-integrity" in r.name + ) + self.assertEqual(integrity.severity, Severity.error) + self.assertIn("integrity errors", integrity.message) + + def test_check_autonomy_audit_sweep_flags_duplicate_live_records(self) -> None: + with tempfile.TemporaryDirectory() as td: + project_dir = Path(td) + audit_dir = project_dir / "agents" / "codex" / "audit" / "autonomy_decisions" + audit_dir.mkdir(parents=True) + record = ( + "schema_version: 2\n" + "receiver: codex\n" + "message_id: msg-demo\n" + "decision: paused\n" + "result:\n" + " final_state: paused\n" + " completion_kind: admission_paused\n" + ) + _write(audit_dir / "20260512T132325Z_msg-demo.yaml", record) + _write(audit_dir / "20260512T142325Z_msg-demo.yaml", record) + import yaml + + cat = check_autonomy(project_dir, yaml_loader=yaml.safe_load) + duplicates = next( + r for r in cat.results if "audit-duplicates" in r.name + ) + self.assertEqual(duplicates.severity, Severity.error) + + def test_check_autonomy_audit_sweep_clean_dir_reports_ok(self) -> None: + with tempfile.TemporaryDirectory() as td: + project_dir = Path(td) + audit_dir = project_dir / "agents" / "codex" / "audit" / "autonomy_decisions" + audit_dir.mkdir(parents=True) + _write( + audit_dir / "20260512T132325Z_msg-demo.yaml", + ( + "schema_version: 2\n" + "receiver: codex\n" + "message_id: msg-demo\n" + "decision: paused\n" + "result:\n" + " final_state: paused\n" + " completion_kind: admission_paused\n" + ), + ) + import yaml + + cat = check_autonomy(project_dir, yaml_loader=yaml.safe_load) + integrity = next( + r for r in cat.results if "audit-integrity" in r.name + ) + self.assertEqual(integrity.severity, Severity.ok) + duplicates = next( + r for r in cat.results if "audit-duplicates" in r.name + ) + self.assertEqual(duplicates.severity, Severity.ok) + class TestCheckAgentStatus(unittest.TestCase): def test_present_fresh_status(self) -> None: @@ -502,6 +582,52 @@ def test_missing_status(self) -> None: self.assertIn("not found", cat.results[0].message) +class TestCheckAgentRegistry(unittest.TestCase): + def test_missing_profiles_and_memberships_warn(self) -> None: + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "projects" / "alpha" / "agents" / "claude").mkdir( + parents=True + ) + (root / "projects" / "beta" / "agents" / "claude").mkdir( + parents=True + ) + _write( + root / "agents" / "claude" / "profile.yaml", + "name: claude\nruntime: claude\nprojects: [alpha]\n", + ) + + import yaml + + cat = check_agent_registry(root, yaml_loader=yaml.safe_load) + self.assertEqual(cat.name, "Agent Registry") + self.assertEqual(cat.results[0].severity, Severity.warn) + self.assertIn("beta", cat.results[0].message) + self.assertIn("oacp agent sync", cat.results[0].fix_hint) + + def test_synced_registry_is_clean(self) -> None: + from agent_profile import sync_agent_registry + + with tempfile.TemporaryDirectory() as td: + root = Path(td) + for project, agent in ( + ("alpha", "claude"), + ("beta", "claude"), + ("beta", "carol"), + ): + (root / "projects" / project / "agents" / agent).mkdir( + parents=True + ) + + sync_agent_registry(root) + + import yaml + + cat = check_agent_registry(root, yaml_loader=yaml.safe_load) + self.assertTrue(all(r.severity == Severity.ok for r in cat.results)) + self.assertIn("2 agent(s), 3 project membership(s)", cat.results[0].message) + + class TestSeverityAggregation(unittest.TestCase): def test_warn_only_no_errors(self) -> None: cats = [ @@ -637,11 +763,12 @@ def test_full_check_with_project(self) -> None: runner=runner, which_fn=which, ) - # Environment + Workspace + Inbox Health + Schemas + Autonomy - # + Agent Status + Trust Root = 7 - self.assertEqual(len(cats), 7) + # Environment + Agent Registry + Workspace + Inbox Health + Schemas + # + Autonomy + Agent Status + Trust Root = 8 + self.assertEqual(len(cats), 8) cat_names = [c.name for c in cats] self.assertIn("Environment", cat_names) + self.assertIn("Agent Registry", cat_names) self.assertIn("Workspace", cat_names) self.assertIn("Inbox Health", cat_names) self.assertIn("Schemas", cat_names) diff --git a/tests/test_org_memory_doctor.py b/tests/test_org_memory_doctor.py new file mode 100644 index 0000000..fcb3f8b --- /dev/null +++ b/tests/test_org_memory_doctor.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""Doctor setup checks for the org-memory debrief store. + +The doctor's contract is setup-level only: directory presence, canonical +path layout, staging leftovers, and irregular entries. It never opens +debrief files — content and format verification belong to the writer +contract and git history. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from oacp_doctor import Severity, check_org_memory # noqa: E402 + +CASES_DIR = Path(__file__).resolve().parent / "conformance" / "org_memory" / "cases" +CASE_NAMES = sorted( + p.name for p in CASES_DIR.iterdir() if p.is_dir() and not p.name.startswith(".") +) + + +@pytest.mark.parametrize("case_name", CASE_NAMES) +def test_conformance_case(case_name: str, tmp_path: Path) -> None: + case = CASES_DIR / case_name + expected = yaml.safe_load((case / "expected.yaml").read_text(encoding="utf-8")) + root = tmp_path / "oacp" + shutil.copytree(case / "org-memory", root / "org-memory") + + cat = check_org_memory(root) + + actual = sorted( + (r.name, r.severity.value) + for r in cat.results + if r.severity in (Severity.warn, Severity.error) + ) + wanted = sorted( + (finding["name"], finding["severity"]) + for finding in (expected.get("findings") or []) + ) + assert actual == wanted, [f"{r.name}:{r.severity.value}:{r.message}" for r in cat.results] + for finding in expected.get("findings") or []: + needle = finding.get("message_contains") + if needle: + assert any( + r.name == finding["name"] and needle in r.message + for r in cat.results + ), f"no {finding['name']} message containing {needle!r}" + + +# ── Unit checks ────────────────────────────────────────────────────────── + + +def _write_debrief(root: Path, name: str = "20260825-alice-1f3a9c2b.md") -> Path: + path = root / "org-memory" / "debriefs" / "demo-project" / "2026" / "08" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("---\nschema_version: 1\n---\nbody\n", encoding="utf-8") + return path + + +def _rows(root: Path) -> list: + return [(r.name, r.severity) for r in check_org_memory(root).results] + + +def test_canonical_layout_passes(tmp_path: Path) -> None: + root = tmp_path / "oacp" + root.mkdir() + _write_debrief(root) + + assert ("debriefs-layout", Severity.ok) in _rows(root) + + +@pytest.mark.skipif(os.geteuid() == 0, reason="permission bits ignored as root") +def test_content_is_never_opened(tmp_path: Path) -> None: + # Setup-only contract: a record whose CONTENT is unreadable is still a + # clean setup — the doctor must not open debrief files at all. + root = tmp_path / "oacp" + root.mkdir() + record = _write_debrief(root) + os.chmod(record, 0o000) + try: + rows = _rows(root) + finally: + os.chmod(record, 0o644) + assert ("debriefs-layout", Severity.ok) in rows + assert not any(name == "debriefs-unreadable" for name, _ in rows) + + +def test_staging_artifact_reported(tmp_path: Path) -> None: + root = tmp_path / "oacp" + root.mkdir() + real = _write_debrief(root) + (real.parent / ".stage.20260825-alice-1f3a9c2b.md.a1b2").write_text( + "partial", encoding="utf-8" + ) + + assert ("debriefs-staging", Severity.warn) in _rows(root) + + +def test_symlinked_record_flagged(tmp_path: Path) -> None: + root = tmp_path / "oacp" + root.mkdir() + real = _write_debrief(root) + (real.parent / "20260825-alice-99zz00aa.md").symlink_to(real) + + rows = _rows(root) + assert ("debriefs-irregular", Severity.error) in rows + # The regular record still passes the layout check. + assert ("debriefs-layout", Severity.ok) in rows + + +def test_symlinked_directory_flagged_and_not_traversed(tmp_path: Path) -> None: + root = tmp_path / "oacp" + root.mkdir() + _write_debrief(root) + outside = tmp_path / "outside" + outside.mkdir() + (root / "org-memory" / "debriefs" / "linked-project").symlink_to( + outside, target_is_directory=True + ) + + assert ("debriefs-irregular", Severity.error) in _rows(root) + + +@pytest.mark.skipif(os.geteuid() == 0, reason="permission bits ignored as root") +def test_unreadable_directory_is_not_a_clean_empty_store(tmp_path: Path) -> None: + root = tmp_path / "oacp" + root.mkdir() + real = _write_debrief(root) + blocked = real.parent.parent.parent # demo-project/ + os.chmod(blocked, 0o000) + try: + cat = check_org_memory(root) + finally: + os.chmod(blocked, 0o755) + rows = [(r.name, r.severity) for r in cat.results] + assert ("debriefs-unreadable", Severity.error) in rows + assert not any( + r.name == "debriefs-layout" and "empty store" in r.message + for r in cat.results + ) + + +def test_directory_classification_failure_surfaces( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # An is_symlink failure on a directory entry is reported, never raised. + root = tmp_path / "oacp" + root.mkdir() + _write_debrief(root) + real_is_symlink = Path.is_symlink + + def flaky(self): + if self.name == "demo-project": + raise PermissionError(13, "Permission denied", str(self)) + return real_is_symlink(self) + + monkeypatch.setattr(Path, "is_symlink", flaky) + cat = check_org_memory(root) + rows = [(r.name, r.severity) for r in cat.results] + assert ("debriefs-unreadable", Severity.error) in rows + assert not any( + r.name == "debriefs-layout" and "empty store" in r.message + for r in cat.results + ) + + +def test_record_classification_failure_surfaces( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A stat failure while classifying a record lands in the unreadable + # row; other records still pass. + root = tmp_path / "oacp" + root.mkdir() + _write_debrief(root) + victim = _write_debrief(root, "20260825-bob-77aa88bb.md") + real_stat = Path.stat + + def flaky(self, *args, **kwargs): + if self.name == victim.name: + raise PermissionError(13, "Permission denied", str(self)) + return real_stat(self, *args, **kwargs) + + monkeypatch.setattr(Path, "stat", flaky) + rows = _rows(root) + assert ("debriefs-unreadable", Severity.error) in rows + assert ("debriefs-layout", Severity.ok) in rows diff --git a/tests/test_package_content.py b/tests/test_package_content.py new file mode 100644 index 0000000..624b8df --- /dev/null +++ b/tests/test_package_content.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 + +"""Package-content test: build the wheel and assert the shipped file set. + +Requires `build` and `hatchling` (both pinned in the dev dependency group); +skipped when either is unavailable. The build runs with --no-isolation so the +pinned dev toolchain is what builds the asserted wheel. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +import zipfile +from pathlib import Path +from typing import Set + +import pytest + +pytest.importorskip("build") +pytest.importorskip("hatchling") + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPTS_DIR = REPO_ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +from preflight import _iter_script_files, parse_force_include # noqa: E402 + +KERNEL_DOCS = [ + "oacp/_protocol/inbox_outbox.md", + "oacp/_protocol/message_signing.md", + "oacp/_protocol/autonomy.md", + "oacp/_protocol/org_memory.md", +] +WIRE_TEMPLATE = "oacp/_templates/inbox_message.template.yaml" +REMOVED_SCRIPTS = [ + "oacp/_scripts/normalize_findings.py", + "oacp/_scripts/create_handoff_packet.py", + "oacp/_scripts/init_project_workspace.sh", +] + + +@pytest.fixture(scope="module") +def wheel_names(tmp_path_factory) -> Set[str]: + outdir = tmp_path_factory.mktemp("wheel") + completed = subprocess.run( + [ + sys.executable, + "-m", + "build", + "--wheel", + "--no-isolation", + "--outdir", + str(outdir), + str(REPO_ROOT), + ], + capture_output=True, + text=True, + ) + if completed.returncode != 0: + pytest.fail( + "wheel build failed:\n" + + completed.stdout[-2000:] + + completed.stderr[-2000:] + ) + wheels = list(outdir.glob("*.whl")) + assert len(wheels) == 1, f"expected exactly one wheel, got {wheels}" + with zipfile.ZipFile(wheels[0]) as zf: + return set(zf.namelist()) + + +class TestKernelDocsShipped: + def test_kernel_docs_in_wheel(self, wheel_names): + missing = [doc for doc in KERNEL_DOCS if doc not in wheel_names] + assert not missing, f"kernel docs missing from wheel: {missing}" + + def test_wire_template_in_wheel(self, wheel_names): + assert WIRE_TEMPLATE in wheel_names + + +class TestForceIncludeParity: + def test_every_force_include_destination_shipped(self, wheel_names): + entries, errors = parse_force_include(REPO_ROOT / "pyproject.toml") + assert not errors, errors + missing = [dst for _, dst in entries if dst not in wheel_names] + assert not missing, f"force-include destinations missing from wheel: {missing}" + + def test_every_script_on_disk_shipped(self, wheel_names): + # End-to-end closure of the scripts/ == force-include rule: every + # regular file under scripts/ must land in the wheel at the + # oacp/_scripts/ destination, independent of the table contents. + expected = { + "oacp/_scripts/" + rel[len("scripts/") :] + for rel in _iter_script_files(REPO_ROOT) + } + missing = sorted(expected - wheel_names) + assert not missing, f"scripts missing from wheel: {missing}" + + +class TestRemovedScriptsAbsent: + def test_removed_scripts_not_shipped(self, wheel_names): + present = [name for name in REMOVED_SCRIPTS if name in wheel_names] + assert not present, f"removed scripts still in wheel: {present}" + + +# Retained shipped tools and templates must not direct users to the retired +# entry points. Userland protocol docs are deliberately excluded — their +# content is handled by a separate documentation-packaging change. +RETIRED_REFERENCE_PATTERNS = [ + re.compile(r"init_project_workspace\.sh"), + re.compile(r"create_handoff_packet"), + re.compile(r"normalize_findings"), + re.compile(r"\bmake init\b"), + re.compile(r"\bmake handoff\b"), + re.compile(r"\bmake normalize\b"), +] + + +class TestNoRetiredReferences: + def test_shipped_tools_and_templates_free_of_retired_references(self): + hits = [] + roots = [REPO_ROOT / "scripts", REPO_ROOT / "templates"] + for root in roots: + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(REPO_ROOT).as_posix() + if "__pycache__" in rel: + continue + text = path.read_text(encoding="utf-8", errors="replace") + for lineno, line in enumerate(text.splitlines(), start=1): + for pattern in RETIRED_REFERENCE_PATTERNS: + if pattern.search(line): + hits.append(f"{rel}:{lineno}: {line.strip()[:80]}") + assert not hits, "retired-script references in shipped files:\n" + "\n".join( + hits + ) diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 3fc9a51..783f98c 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -16,7 +16,9 @@ from preflight import ( # noqa: E402 check_conflict_markers, + check_packaging_boundary, check_yaml_syntax, + parse_force_include, run_preflight, validate_makefile_phony, ) @@ -27,6 +29,12 @@ def _write(path: Path, content: str) -> None: path.write_text(content, encoding="utf-8") +def _write_force_include(repo: Path, entries: Sequence[str]) -> None: + lines = ["[tool.hatch.build.targets.wheel.force-include]"] + lines.extend(entries) + _write(repo / "pyproject.toml", "\n".join(lines) + "\n") + + class TestValidateMakefilePhony(unittest.TestCase): def test_detects_missing_phony_entry(self) -> None: with tempfile.TemporaryDirectory() as td: @@ -115,6 +123,121 @@ def fake_loader(text: str): self.assertIn("bad.yaml", result.details) +class TestPackagingBoundary(unittest.TestCase): + def test_matching_boundary_passes(self) -> None: + with tempfile.TemporaryDirectory() as td: + repo = Path(td) + _write(repo / "scripts" / "a.py", "print('ok')\n") + _write(repo / "docs" / "protocol" / "spec.md", "# spec\n") + _write_force_include( + repo, + [ + '"scripts/a.py" = "oacp/_scripts/a.py"', + '"docs/protocol/spec.md" = "oacp/_protocol/spec.md"', + ], + ) + + result = check_packaging_boundary(repo) + self.assertTrue(result.passed, result.details) + + def test_unpackaged_script_fails(self) -> None: + with tempfile.TemporaryDirectory() as td: + repo = Path(td) + _write(repo / "scripts" / "a.py", "print('ok')\n") + _write(repo / "scripts" / "orphan.py", "print('ok')\n") + _write_force_include(repo, ['"scripts/a.py" = "oacp/_scripts/a.py"']) + + result = check_packaging_boundary(repo) + self.assertFalse(result.passed) + self.assertIn("scripts/orphan.py", result.details) + self.assertIn("missing from force-include", result.details) + + def test_force_include_without_file_fails(self) -> None: + with tempfile.TemporaryDirectory() as td: + repo = Path(td) + _write(repo / "scripts" / "a.py", "print('ok')\n") + _write_force_include( + repo, + [ + '"scripts/a.py" = "oacp/_scripts/a.py"', + '"scripts/ghost.py" = "oacp/_scripts/ghost.py"', + ], + ) + + result = check_packaging_boundary(repo) + self.assertFalse(result.passed) + self.assertIn("scripts/ghost.py", result.details) + self.assertIn("no file on disk", result.details) + + def test_missing_table_fails(self) -> None: + with tempfile.TemporaryDirectory() as td: + repo = Path(td) + _write(repo / "scripts" / "a.py", "print('ok')\n") + _write(repo / "pyproject.toml", "[project]\nname = 'x'\n") + + result = check_packaging_boundary(repo) + self.assertFalse(result.passed) + self.assertIn("missing", result.details) + + def test_missing_pyproject_fails(self) -> None: + with tempfile.TemporaryDirectory() as td: + result = check_packaging_boundary(Path(td)) + self.assertFalse(result.passed) + self.assertIn("pyproject.toml not found", result.details) + + def test_duplicate_source_reported(self) -> None: + with tempfile.TemporaryDirectory() as td: + repo = Path(td) + _write(repo / "scripts" / "a.py", "print('ok')\n") + _write_force_include( + repo, + [ + '"scripts/a.py" = "oacp/_scripts/a.py"', + '"scripts/a.py" = "oacp/_scripts/duplicate.py"', + ], + ) + + result = check_packaging_boundary(repo) + self.assertFalse(result.passed) + self.assertIn("duplicate force-include source", result.details) + + def test_unparseable_line_reported(self) -> None: + with tempfile.TemporaryDirectory() as td: + repo = Path(td) + _write(repo / "scripts" / "a.py", "print('ok')\n") + _write_force_include( + repo, + [ + '"scripts/a.py" = "oacp/_scripts/a.py"', + "not-a-valid-entry", + ], + ) + + result = check_packaging_boundary(repo) + self.assertFalse(result.passed) + self.assertIn("unparseable force-include line", result.details) + + def test_parse_stops_at_next_table(self) -> None: + with tempfile.TemporaryDirectory() as td: + repo = Path(td) + _write( + repo / "pyproject.toml", + "\n".join( + [ + "[tool.hatch.build.targets.wheel.force-include]", + '"scripts/a.py" = "oacp/_scripts/a.py"', + "[tool.other]", + '"scripts/ignored.py" = "oacp/_scripts/ignored.py"', + ] + ) + + "\n", + ) + + entries, errors = parse_force_include(repo / "pyproject.toml") + self.assertEqual(errors, []) + self.assertEqual(entries, [("scripts/a.py", "oacp/_scripts/a.py")]) + + class TestRunPreflight(unittest.TestCase): def test_full_mode_runs_make_test(self) -> None: with tempfile.TemporaryDirectory() as td: @@ -135,6 +258,13 @@ def test_full_mode_runs_make_test(self) -> None: _write(repo / "docs" / "protocol" / "sample.yaml", "name: proto\n") _write(repo / "scripts" / "a.py", "print('ok')\n") _write(repo / "scripts" / "a.sh", "#!/usr/bin/env bash\necho ok\n") + _write_force_include( + repo, + [ + '"scripts/a.py" = "oacp/_scripts/a.py"', + '"scripts/a.sh" = "oacp/_scripts/a.sh"', + ], + ) calls: List[List[str]] = [] @@ -181,6 +311,13 @@ def test_fast_mode_skips_make_test(self) -> None: _write(repo / "docs" / "protocol" / "sample.yaml", "name: proto\n") _write(repo / "scripts" / "a.py", "print('ok')\n") _write(repo / "scripts" / "a.sh", "#!/usr/bin/env bash\necho ok\n") + _write_force_include( + repo, + [ + '"scripts/a.py" = "oacp/_scripts/a.py"', + '"scripts/a.sh" = "oacp/_scripts/a.sh"', + ], + ) calls: List[List[str]] = [] diff --git a/tests/test_readme_commands.py b/tests/test_readme_commands.py new file mode 100644 index 0000000..3a7ea5e --- /dev/null +++ b/tests/test_readme_commands.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 + +"""Drift guard: the README command table must match ``oacp --help``. + +The table is generated by ``scripts/gen_readme_commands.py`` (``make docs``); +these tests fail when README.md falls behind the CLI help text, when the help +text falls behind the dispatcher, or when the generator's check mode stops +reporting drift. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) +sys.path.insert(0, str(REPO_ROOT)) + +from gen_readme_commands import ( # noqa: E402 + BEGIN_MARKER, + END_MARKER, + README_PATH, + extract_block, + load_help_text, + main, + parse_commands, + render_block, +) +from oacp.cli import SCRIPT_NAMES # noqa: E402 + + +def _readme_text() -> str: + return README_PATH.read_text(encoding="utf-8") + + +def test_readme_command_table_matches_help_text() -> None: + expected = render_block(parse_commands(load_help_text())) + actual = extract_block(_readme_text()) + assert actual == expected, ( + "README.md command table is behind oacp --help; run `make docs`" + ) + + +def test_help_text_matches_executed_cli_help() -> None: + # The executed comparison: what `oacp --help` actually prints from this + # checkout, not the constant alone. + proc = subprocess.run( + [sys.executable, "-m", "oacp.cli", "--help"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ) + assert parse_commands(proc.stdout) == parse_commands(load_help_text()) + + +def test_help_text_lists_exactly_the_dispatchable_commands() -> None: + names = [name for name, _ in parse_commands(load_help_text())] + assert len(names) == len(set(names)), f"duplicate command in HELP_TEXT: {names}" + assert sorted(names) == sorted(SCRIPT_NAMES) + + +def test_parse_commands_rejects_help_text_without_commands_block() -> None: + with pytest.raises(ValueError): + parse_commands("Usage: oacp \n\nExamples:\n oacp doctor\n") + + +def test_check_mode_reports_drift_and_write_mode_repairs_it(tmp_path: Path) -> None: + expected = render_block(parse_commands(load_help_text())) + stale_rows = "\n".join(expected.splitlines()[1:5]) # header + first two rows + stale_block = "\n".join([BEGIN_MARKER, stale_rows, END_MARKER]) + readme = tmp_path / "README.md" + readme.write_text( + _readme_text().replace(extract_block(_readme_text()), stale_block, 1), + encoding="utf-8", + ) + + assert main(["--check", "--readme", str(readme)]) == 1 + assert main(["--write", "--readme", str(readme)]) == 0 + assert extract_block(readme.read_text(encoding="utf-8")) == expected + assert main(["--check", "--readme", str(readme)]) == 0 diff --git a/tests/test_record_autonomy_outcome.py b/tests/test_record_autonomy_outcome.py index 6638c31..c455915 100644 --- a/tests/test_record_autonomy_outcome.py +++ b/tests/test_record_autonomy_outcome.py @@ -550,7 +550,7 @@ def test_cli_locks_the_read_modify_write_sequence( ) lock_state = {"held": False} real_load = outcome_recorder._load_mapping - real_write = outcome_recorder._atomic_write_yaml + real_write = outcome_recorder.atomic_replace_yaml # The lock now lives in the shared stable-audit-lock helper # (_oacp_constants.locked_audit), which imports fcntl at call time — @@ -576,7 +576,7 @@ def checked_write(path: Path, data: Dict[str, Any]) -> None: monkeypatch.setattr(fcntl, "flock", fake_flock) monkeypatch.setattr(outcome_recorder, "_load_mapping", checked_load) - monkeypatch.setattr(outcome_recorder, "_atomic_write_yaml", checked_write) + monkeypatch.setattr(outcome_recorder, "atomic_replace_yaml", checked_write) code = main([ str(audit_path), diff --git a/tests/test_send_inbox_message.py b/tests/test_send_inbox_message.py index de4fedc..004bdbf 100644 --- a/tests/test_send_inbox_message.py +++ b/tests/test_send_inbox_message.py @@ -22,6 +22,7 @@ _atomic_write_text, build_message_dict, generate_filename, + generate_conversation_id, generate_message_id, generate_timestamp, infer_current_runtime, @@ -34,7 +35,7 @@ main, ) -from validate_message import validate_message_dict # noqa: E402 +from validate_message import CONVERSATION_ID_RE, validate_message_dict # noqa: E402 def _write_agent_card( @@ -82,6 +83,17 @@ def test_format(self): self.assertRegex(ts, r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") +class TestGenerateConversationId(unittest.TestCase): + @mock.patch("send_inbox_message.secrets.randbelow", return_value=42) + def test_format(self, _randbelow): + conversation_id = generate_conversation_id("claude") + self.assertRegex( + conversation_id, + r"^conv-\d{8}-claude-000042$", + ) + self.assertRegex(conversation_id, CONVERSATION_ID_RE) + + class TestGenerateFilename(unittest.TestCase): def test_basic(self): fn = generate_filename("claude", "task_request") @@ -1173,6 +1185,77 @@ def test_broadcast_handoff_rejected(self): class TestInReplyTo(unittest.TestCase): + @mock.patch("send_inbox_message.generate_conversation_id") + def test_explicit_conversation_id_wins_without_minting(self, mint): + with tempfile.TemporaryDirectory() as tmpdir: + hub_dir = Path(tmpdir) + explicit = "conv-20260828-iris-09" + report = send_message( + project="test-project", + sender="claude", + recipient="codex", + msg_type="question", + subject="Existing thread", + body="Question", + conversation_id=explicit, + oacp_dir=hub_dir, + ) + + from validate_message import _parse_simple_yaml + + data = _parse_simple_yaml( + Path(report["inbox_path"]).read_text(encoding="utf-8") + ) + self.assertEqual(data["conversation_id"], explicit) + mint.assert_not_called() + + @mock.patch( + "send_inbox_message.generate_conversation_id", + return_value="conv-20260828-claude-000042", + ) + def test_parentless_send_mints_conversation_and_reply_inherits_it(self, mint): + """A new thread gets an ID once; its reply inherits that exact ID.""" + with tempfile.TemporaryDirectory() as tmpdir: + hub_dir = Path(tmpdir) + parent = send_message( + project="test-project", + sender="claude", + recipient="codex", + msg_type="question", + subject="New thread", + body="Question", + oacp_dir=hub_dir, + ) + + from validate_message import _parse_simple_yaml + + parent_data = _parse_simple_yaml( + Path(parent["inbox_path"]).read_text(encoding="utf-8") + ) + conversation_id = parent_data["conversation_id"] + self.assertRegex( + conversation_id, + r"^conv-\d{8}-claude-\d{6}$", + ) + self.assertEqual(validate_message_dict(parent_data), []) + + reply = send_message( + project="test-project", + sender="codex", + recipient="claude", + msg_type="notification", + subject="Re: New thread", + body="Answer", + oacp_dir=hub_dir, + in_reply_to=parent["message_id"], + ) + reply_data = _parse_simple_yaml( + Path(reply["inbox_path"]).read_text(encoding="utf-8") + ) + self.assertEqual(reply_data["conversation_id"], conversation_id) + self.assertEqual(reply_data["parent_message_id"], parent["message_id"]) + mint.assert_called_once_with("claude") + def test_in_reply_to_inherits_conversation(self): """--in-reply-to should find parent and copy conversation_id.""" with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/test_update_workspace.py b/tests/test_update_workspace.py index e118f7a..94a401f 100644 --- a/tests/test_update_workspace.py +++ b/tests/test_update_workspace.py @@ -86,7 +86,7 @@ def test_repo_without_value_errors(self): class TestDirectoryCreation(unittest.TestCase): - """Creates missing dirs that init_project_workspace.sh defines.""" + """Creates missing dirs that the workspace layout (`oacp init`) defines.""" def test_creates_state_dir(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_workspace_discovery.py b/tests/test_workspace_discovery.py index 55d0566..8acd2c0 100644 --- a/tests/test_workspace_discovery.py +++ b/tests/test_workspace_discovery.py @@ -14,24 +14,9 @@ from pathlib import Path SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts" -INIT_SCRIPT = str(SCRIPTS_DIR / "init_project_workspace.sh") UPDATE_SCRIPT = str(SCRIPTS_DIR / "update_workspace.sh") -def run_init(tmp: str, project: str, *extra_args: str) -> subprocess.CompletedProcess: - """Run init_project_workspace.sh in a temp hub root.""" - hub_root = os.path.join(tmp, "hub") - os.makedirs(os.path.join(hub_root, "projects"), exist_ok=True) - env = os.environ.copy() - env["OACP_HOME"] = hub_root - return subprocess.run( - ["bash", INIT_SCRIPT, project, *extra_args], - capture_output=True, - text=True, - env=env, - ) - - def run_update(project_root: str, *extra_args: str) -> subprocess.CompletedProcess: """Run update_workspace.sh against a temp workspace.""" project_name = os.path.basename(project_root) @@ -53,108 +38,6 @@ def make_workspace(tmp: str, project: str = "testproj") -> str: return project_root -class TestInitCreatesWorkspaceJson(unittest.TestCase): - """init_project_workspace.sh creates workspace.json with correct fields.""" - - def test_workspace_json_created(self): - with tempfile.TemporaryDirectory() as tmp: - result = run_init(tmp, "myproj") - self.assertEqual(result.returncode, 0, result.stderr) - ws_json = os.path.join(tmp, "hub", "projects", "myproj", "workspace.json") - self.assertTrue(os.path.isfile(ws_json)) - - def test_workspace_json_has_expected_keys(self): - with tempfile.TemporaryDirectory() as tmp: - run_init(tmp, "myproj") - ws_json = os.path.join(tmp, "hub", "projects", "myproj", "workspace.json") - with open(ws_json) as f: - data = json.load(f) - expected_keys = {"project_name", "repo_path", "created_at", "updated_at", "spec_version"} - self.assertEqual(set(data.keys()), expected_keys) - - def test_workspace_json_project_name(self): - with tempfile.TemporaryDirectory() as tmp: - run_init(tmp, "myproj") - ws_json = os.path.join(tmp, "hub", "projects", "myproj", "workspace.json") - with open(ws_json) as f: - data = json.load(f) - self.assertEqual(data["project_name"], "myproj") - - def test_workspace_json_repo_path_null_without_repo(self): - with tempfile.TemporaryDirectory() as tmp: - run_init(tmp, "myproj") - ws_json = os.path.join(tmp, "hub", "projects", "myproj", "workspace.json") - with open(ws_json) as f: - data = json.load(f) - self.assertIsNone(data["repo_path"]) - - def test_workspace_json_repo_path_set_with_repo(self): - with tempfile.TemporaryDirectory() as tmp: - repo = os.path.join(tmp, "repo") - os.makedirs(repo) - run_init(tmp, "myproj", "--repo", repo) - ws_json = os.path.join(tmp, "hub", "projects", "myproj", "workspace.json") - with open(ws_json) as f: - data = json.load(f) - self.assertEqual(data["repo_path"], str(Path(repo).resolve())) - - def test_workspace_json_timestamps_are_iso(self): - with tempfile.TemporaryDirectory() as tmp: - run_init(tmp, "myproj") - ws_json = os.path.join(tmp, "hub", "projects", "myproj", "workspace.json") - with open(ws_json) as f: - data = json.load(f) - # Should contain T separator (ISO 8601) - self.assertIn("T", data["created_at"]) - self.assertIn("T", data["updated_at"]) - - def test_workspace_json_spec_version(self): - with tempfile.TemporaryDirectory() as tmp: - run_init(tmp, "myproj") - ws_json = os.path.join(tmp, "hub", "projects", "myproj", "workspace.json") - with open(ws_json) as f: - data = json.load(f) - # Should be the protocol spec version the tooling implements - self.assertIsInstance(data["spec_version"], str) - self.assertRegex(data["spec_version"], r"^\d+\.\d+\.\d+$") - self.assertNotIn("standards_version", data) - - def test_workspace_json_is_valid_json(self): - with tempfile.TemporaryDirectory() as tmp: - run_init(tmp, "myproj") - ws_json = os.path.join(tmp, "hub", "projects", "myproj", "workspace.json") - with open(ws_json) as f: - # Should not raise - data = json.load(f) - self.assertIsInstance(data, dict) - - -class TestInitNoLongerCreatesOacpMarker(unittest.TestCase): - """init no longer creates .oacp in repo root (runtimes symlink workspace.json instead).""" - - def test_oacp_marker_not_created_with_repo(self): - with tempfile.TemporaryDirectory() as tmp: - repo = os.path.join(tmp, "repo") - os.makedirs(repo) - result = run_init(tmp, "myproj", "--repo", repo) - self.assertEqual(result.returncode, 0, result.stderr) - oacp_file = os.path.join(repo, ".oacp") - self.assertFalse(os.path.isfile(oacp_file)) - - def test_oacp_marker_not_created_without_repo(self): - with tempfile.TemporaryDirectory() as tmp: - run_init(tmp, "myproj") - ws_root = os.path.join(tmp, "hub", "projects", "myproj") - self.assertFalse(os.path.isfile(os.path.join(ws_root, ".oacp"))) - - def test_prints_symlink_hint_with_repo(self): - with tempfile.TemporaryDirectory() as tmp: - repo = os.path.join(tmp, "repo") - os.makedirs(repo) - result = run_init(tmp, "myproj", "--repo", repo) - self.assertIn("ln -sf", result.stdout) - - class TestUpdateBumpsWorkspaceJson(unittest.TestCase): """update_workspace.sh updates timestamps in existing workspace.json."""