From 4a750b84730308cc99ffef00c7f54ce973beae58 Mon Sep 17 00:00:00 2001 From: Oranquelui <156574031+Oranquelui@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:39:25 +0900 Subject: [PATCH] Add v0.2 verification packets --- CHANGELOG.md | 15 +++ README.md | 64 ++++++--- docs/architecture.md | 31 +++-- docs/backlog.md | 44 ++++--- docs/local-testing.md | 15 ++- docs/mvp.md | 13 +- patchrail/cli/main.py | 30 +++++ patchrail/cli/render.py | 48 +++++++ patchrail/core/service.py | 106 ++++++++++++++- patchrail/models/entities.py | 36 ++++++ patchrail/packet/__init__.py | 1 + patchrail/packet/service.py | 162 +++++++++++++++++++++++ patchrail/storage/filesystem.py | 15 +++ patchrail/verification/__init__.py | 1 + patchrail/verification/service.py | 64 +++++++++ pyproject.toml | 21 ++- scripts/check_release.sh | 97 ++++++++++++++ scripts/local_smoke_test.sh | 22 +++- tests/test_cli_flow.py | 2 +- tests/test_install_script.py | 18 +++ tests/test_layer_harness_contract.py | 30 +++++ tests/test_project_metadata.py | 22 +++- tests/test_verification_packet.py | 186 +++++++++++++++++++++++++++ 23 files changed, 975 insertions(+), 68 deletions(-) create mode 100644 patchrail/packet/__init__.py create mode 100644 patchrail/packet/service.py create mode 100644 patchrail/verification/__init__.py create mode 100644 patchrail/verification/service.py create mode 100755 scripts/check_release.sh mode change 100644 => 100755 scripts/local_smoke_test.sh create mode 100644 tests/test_verification_packet.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b0861..c7af93e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to this project will be documented in this file. +## v0.2.0 - Unreleased + +### Added + +- `patchrail verify` for recording operator-specified verification commands, exit codes, elapsed time, and stdout/stderr output paths. +- `patchrail list verifications` and `patchrail list review-queue` for local review triage across AI coding runs. +- `patchrail packet show|export` for Markdown and JSON approval packets built from existing local records. +- `patchrail setup project --guided` for Delivery Contract-oriented Future, Ontology, and Product prompt scaffolds while preserving existing brief storage. +- Release check script for build, isolated wheel install, CLI help, and local smoke verification. + +### Changed + +- Public project metadata and README now position Patchrail around AI coding verification and approval packets. +- Local smoke testing now includes verify and packet export. + ## v0.1.0 - 2026-04-19 Initial public MVP release. diff --git a/README.md b/README.md index 64981f1..a2b780e 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,26 @@ # Patchrail -Patchrail is a local-first supervised coding-agent control plane for future-anchored product execution. It stays focused on a CLI-first, headless-core-first workflow that records `task -> plan -> run -> review -> approval` as explicit local state transitions, together with artifact bundles, decision traces, and approval ledgers. +Patchrail is a local-first verification and approval packet CLI for AI coding agent work. Run Claude, Codex, Cursor, or another coding workflow, then use Patchrail to prove what changed, what was verified, and whether the result is ready for human approval. ![Patchrail terminal loading screen](patchrail-start.jpg) Terminal loading/start screen rendered by `patchrail start`. Patchrail is intentionally usable from a terminal before any hosted dashboard exists. -Patchrail keeps coding-agent supervision in a local CLI instead of hiding planning, review, approval, and artifacts behind a backend runtime. +Patchrail keeps coding-agent supervision in a local CLI instead of hiding plans, runs, verification output, review decisions, approval ledgers, and artifacts behind a backend runtime. Japanese usage notes live in [README.ja.md](README.ja.md). ## Development Purpose -Patchrail is being developed to make supervised coding-agent work auditable before, during, and after implementation. Modern coding agents can move from a vague instruction to repository changes very quickly; the hard part is proving what the operator meant, what boundaries were agreed, what the executor actually did, and why a human approved the result. +Patchrail is being developed to make AI-coded changes auditable before, during, and after implementation. Modern coding agents can move from a vague instruction to repository changes very quickly; the hard part is proving what the operator meant, what changed, which checks ran, and why a human approved or rejected the result. The core purpose is to preserve that chain as local, inspectable records: ```text -human intent -> planning briefs -> plan snapshot -> runner execution -> harness evidence -> review -> approval +human intent -> delivery contract -> plan snapshot -> runner execution -> verification evidence -> review -> approval packet ``` -This is why Patchrail starts with a CLI and headless core rather than a dashboard. The first requirement is not presentation. The first requirement is a reliable local record that can be checked, diffed, tested, and reviewed without trusting a remote service. +This is why Patchrail starts with a CLI and headless core rather than a dashboard. The first requirement is not presentation. The first requirement is a reliable local record that can be checked, diffed, tested, packaged, and reviewed without trusting a remote service. ## Why This Is Necessary @@ -49,8 +49,9 @@ Patchrail demonstrates a practical safety boundary for coding agents in customer 2. Attach future, ontology, and product briefs before implementation begins. 3. Store a canonical plan that references those briefs. 4. Run an executor behind an explicit runner assignment. -5. Review the persisted run artifacts before any final approval. -6. Record the human approval decision and ledgers locally. +5. Run operator-specified verification commands against the completed work. +6. Export a review-ready approval packet before any final approval. +7. Record the human approval decision and ledgers locally. The point is not to make an agent autonomous by default. The point is to make the handoff between human intent, agent execution, review evidence, and final approval inspectable from disk. @@ -61,29 +62,38 @@ For a public-facing walkthrough, see [Supervised Agent Rollout](docs/case-studie - Keep the canonical workflow record in Patchrail rather than in a backend runtime. - Preserve clear role separation across planner, reviewer, executor, and human approver. - Constrain present implementation work with operator-defined completion, ontology, and scope documents rather than letting the next tool call decide the shape of the product. -- Make approval boundaries, fallback approvals, artifacts, and decision traces inspectable from disk. +- Make approval boundaries, fallback approvals, artifacts, verification results, packets, and decision traces inspectable from disk. - Support optional workflow backends, including LangGraph, without handing over canonical state ownership. -## Phase 1 Direction +## v0.2 Direction -The next planning layer is aimed at turning Patchrail into a supervised control plane for product definition before execution starts. +The next public release focuses on AI coding verification, not generic agent orchestration. The flagship loop is: -Phase 1 is structured around two onboarding passes: +```bash +patchrail run --task-id --runner auto +patchrail verify --run-id --command "pytest -q" +patchrail list review-queue +patchrail packet show --task-id +``` + +Verification records store the command, working directory, exit code, elapsed time, stdout path, and stderr path under `.patchrail/`. Approval packets gather the task, Delivery Contract, plan, run, runner assignment, artifact bundle, verification results, review verdict, approval decision, and unresolved gaps into Markdown or JSON. + +The planning layer remains structured around two onboarding passes: - `machine/runtime onboarding`: select the provider set, access modes, and workflow backend that this machine can supervise safely -- `project/planning onboarding`: define the task, `Future Completion Brief`, `Ontology Brief`, `Product Brief`, and then generate the canonical plan +- `project/planning onboarding`: define the task, Delivery Contract prompts, `Future Completion Brief`, `Ontology Brief`, `Product Brief`, and then generate the canonical plan -The brief sequence is intentionally ordered: +The public concept is the Delivery Contract. The stored compatibility model is still called `brief`, and the sequence is intentionally ordered: 1. `Future Completion Brief` / prediction layer: describe what should be true in the future, the invariants that must hold, the failure conditions, and the non-goals. 2. `Ontology Brief` / reality-boundary layer: define what exists, what does not exist, who owns what, and where approval or artifact boundaries sit. 3. `Product Brief` / post-implementation acceptance layer: define the user problem, the MVP boundary, and what must be true after implementation for users and operators. 4. `Plan` / execution-translation layer: convert those constraints into executable implementation steps that Patchrail can supervise. -5. `Harness` / post-implementation evidence layer: after executor run and before review, capture execution summary, diff summary, stdout/stderr, invocation, runner trace, and artifact metadata. +5. `Verification` / post-implementation evidence layer: after executor run and before review, capture command results, stdout/stderr paths, exit codes, elapsed time, runner trace, and artifact metadata. Patchrail continues to own the canonical `Task`, `Plan`, `Run`, `ReviewResult`, `ApprovalRecord`, ledgers, and artifact bundles. The future, ontology, and product briefs are plan-scoped companion artifacts or metadata, not a second canonical state machine. -The first local path for that layer is available through: +The first local path for that contract layer is available through: ```bash patchrail brief create --task-id --kind future --file future.md @@ -105,7 +115,16 @@ The goal is triangulated planning with explicit traces, not generic multi-model ## Install CLI -From the repository root: +Public install: + +```bash +brew install pipx +pipx ensurepath +pipx install patchrail +patchrail --help +``` + +From a source checkout: ```bash cd /path/to/Patchrail @@ -124,24 +143,27 @@ cd /path/to/Patchrail sh scripts/install_cli.sh --python "$(command -v python3.13)" --with-langgraph ``` -The `patchrail` command is exposed through the package entrypoint. `scripts/install_cli.sh` only installs that entrypoint via `pipx`. If your default `python3` is older than 3.12, pass an explicit interpreter path such as `--python /opt/homebrew/bin/python3.13`. +The `patchrail` command is exposed through the package entrypoint. Public users can install it with `pipx install patchrail`; source checkout users can run `scripts/install_cli.sh` for an editable install. If your default `python3` is older than 3.12, pass an explicit interpreter path such as `--python /opt/homebrew/bin/python3.13`. Patchrail defaults to human-readable CLI output. Use `patchrail --json ...` only for automation and scripting. -`patchrail setup` is the first-run CLI path. It bootstraps runtime config, runs role preflight checks, and returns concrete next commands. Use `patchrail setup project --title ... --description ...` to create a task plus editable planning brief scaffolds. Edit those files, persist them with `patchrail brief create ...`, and only then create the canonical plan. +`patchrail setup` is the first-run CLI path. It bootstraps runtime config, runs role preflight checks, and returns concrete next commands. Use `patchrail setup project --guided --title ... --description ...` to create a task plus editable Delivery Contract scaffolds. Edit those files, persist them with `patchrail brief create ...`, and only then create the canonical plan. ## Quickstart ```bash cd /path/to/Patchrail -sh scripts/install_cli.sh --python "$(command -v python3.13)" +pipx install patchrail patchrail setup -patchrail setup project --title "First task" --description "Describe the supervised work" +patchrail setup project --guided --title "First task" --description "Describe the supervised work" # edit the generated future/ontology/product files, then persist each edited brief patchrail brief create --task-id --kind future --file patchrail brief create --task-id --kind ontology --file patchrail brief create --task-id --kind product --file patchrail plan --task-id --auto +patchrail run --task-id --runner auto +patchrail verify --run-id --command "pytest -q" +patchrail packet show --task-id ``` `patchrail start` opens the interactive shell in TTY sessions. Use `patchrail start --once` to render the home screen and exit immediately. @@ -239,6 +261,8 @@ patchrail list plans patchrail list runs patchrail list reviews patchrail list approvals +patchrail list verifications --task-id +patchrail list review-queue patchrail list fallback-requests patchrail list preflight-snapshots patchrail list artifact-bundles --has-trace diff --git a/docs/architecture.md b/docs/architecture.md index ecf9064..43545fb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,9 +1,9 @@ # Patchrail Architecture ## System Overview -Patchrail is a local-first control plane that records supervised coding-agent workflows as explicit state transitions. The MVP is a headless core with a thin CLI wrapper. It accepts a task, stores a plan, resolves role assignments through a provider and access-mode policy, records a run, persists an artifact bundle, captures a review result, and requires an explicit human approval or rejection before completion. +Patchrail is a local-first control plane that records supervised coding-agent workflows as explicit state transitions. The MVP is a headless core with a thin CLI wrapper. It accepts a task, stores a plan, resolves role assignments through a provider and access-mode policy, records a run, persists an artifact bundle, captures verification evidence, captures a review result, and requires an explicit human approval or rejection before completion. -The current MVP proves the canonical workflow record and approval boundary. The first planning layer extends the front of that workflow so operators can define what finished looks like, which concepts are real, and what scope is in-bounds before implementation begins. That layer remains subordinate to the existing canonical state machine rather than replacing it. +The current public direction puts AI coding verification at the center: after Claude, Codex, Cursor, or another executor changes code, Patchrail records which verification commands ran and packages the result into an approval packet. The planning layer extends the front of that workflow so operators can define what finished looks like, which concepts are real, and what scope is in-bounds before implementation begins. That layer remains subordinate to the existing canonical state machine rather than replacing it. Patchrail uses the word "layer" narrowly: @@ -13,22 +13,24 @@ Patchrail uses the word "layer" narrowly: | Reality boundary | `Ontology Brief` | before implementation | Define what exists, who owns it, and where approval/artifact boundaries sit. | | Post-implementation acceptance | `Product Brief` | defined before implementation, checked after implementation | State what must be true for users and operators after the implementation exists. | | Execution translation | canonical `Plan` | before run | Convert the three briefs into executable steps and snapshot the brief references immutably. | -| Post-implementation evidence | Harness / `ArtifactBundle` | after executor run, before review | Capture execution summary, diff summary, stdout/stderr, invocation, runner trace, and artifact metadata for reviewer and human approval. | +| Post-implementation evidence | `ArtifactBundle` / `VerificationRecord` | after executor run, before review | Capture execution summary, diff summary, stdout/stderr, invocation, runner trace, artifact metadata, verification commands, exit codes, and command output paths for reviewer and human approval. | -So the short answer is: Future is prediction; Product is the after-implementation acceptance layer; Harness is the after-implementation evidence capture layer. +So the short answer is: Future is prediction; Product is the after-implementation acceptance layer; Verification is the after-implementation evidence capture layer. ## Core Modules -- `patchrail.cli`: `argparse`-based command surface plus a thin render layer for setup, task, planning brief, config, start, doctor, preflight, plan, run, status, review, approval, fallback approval, list, logs, and artifacts commands. Human-readable output is the operator default; `--json` preserves machine-readable automation output. +- `patchrail.cli`: `argparse`-based command surface plus a thin render layer for setup, task, planning brief, config, start, doctor, preflight, plan, run, verify, packet, status, review, approval, fallback approval, list, logs, and artifacts commands. Human-readable output is the operator default; `--json` preserves machine-readable automation output. - `patchrail.cli.shell`: a small interactive shell wrapper for `patchrail start` in TTY sessions. It reuses the canonical CLI parser and service layer instead of adding a second state machine. - `patchrail.core`: orchestration services, role assignment resolution, preflight logic, ID generation, state transition validation, domain errors, and future hook contracts. - `patchrail.workflows`: pluggable auto plan/review backend contract plus the default local backend and an optional LangGraph-backed planner/reviewer scaffold. -- `patchrail.models`: dataclasses and enums for `Task`, `Plan`, plan-scoped planning brief references, `Run`, `RunnerAssignment`, `ReviewResult`, `ApprovalRecord`, `FallbackApprovalRequest`, `PreflightSnapshot`, `ArtifactBundle`, `DecisionTrace`, and `CostMetrics`. -- `patchrail.storage`: filesystem persistence for JSON records, role-policy config, JSONL ledgers, planning brief companion artifacts, and artifact lookup. +- `patchrail.models`: dataclasses and enums for `Task`, `Plan`, plan-scoped planning brief references, `Run`, `RunnerAssignment`, `VerificationRecord`, `ReviewResult`, `ApprovalRecord`, `FallbackApprovalRequest`, `PreflightSnapshot`, `ArtifactBundle`, `DecisionTrace`, and `CostMetrics`. +- `patchrail.storage`: filesystem persistence for JSON records, role-policy config, JSONL ledgers, planning brief companion artifacts, verification command output, and artifact lookup. - `patchrail.runners`: runner interface, shell-backed local harness execution, API-backed executor runners, and Claude-backed subscription executor runners. - `patchrail.providers`: minimal HTTP adapters for provider-backed executor calls and workflow backends that need direct provider completion calls. - `patchrail.review`: review persistence and review-to-approval boundary handling. - `patchrail.approval`: explicit task approval and fallback approval request handling plus ledger appends. - `patchrail.artifacts`: artifact bundle creation and lookup. +- `patchrail.verification`: operator-specified verification command execution and `VerificationRecord` persistence. +- `patchrail.packet`: approval packet read model and Markdown/JSON rendering. ## Phase 1 Planning Direction The first planning layer adds future-anchored planning without creating new top-level canonical records. @@ -90,8 +92,10 @@ Hard rules: - `Plan` belongs to a task, must exist before a run can start, and stores the resolved planner assignment plus preflight evidence. Auto-generated plans may also store auxiliary workflow backend metadata, but the canonical plan record remains Patchrail-owned. - A `Plan` may also reference a `Future Completion Brief`, `Ontology Brief`, `Product Brief`, and future triangulated planning metadata as companion artifacts. Those supporting documents do not replace the canonical plan record. - `Run` records runner assignment, elapsed time, synthetic output, artifact bundle identity, and the resolved executor assignment plus preflight evidence. +- `VerificationRecord` records a command run against a completed run, including task id, run id, cwd, exit code, status, stdout/stderr artifact paths, elapsed seconds, and timestamp. It does not change the canonical task lifecycle yet. - `ReviewResult` records the reviewer verdict, rationale, and the resolved reviewer assignment plus preflight evidence. Auto-generated reviews may also store auxiliary workflow backend metadata, but approval meaning remains outside the backend. - `ApprovalRecord` records the human decision and rationale after review. +- `ApprovalPacket` is a generated read model, not a canonical lifecycle entity. It gathers task, Delivery Contract references, plan, run, artifact bundle, verification results, review, approval, and unresolved gaps for review-ready export. - `FallbackApprovalRequest` records a human-reviewed exception request when role resolution needs a blocked fallback. - `PreflightSnapshot` records a standalone phase-resolution snapshot so operator audits can inspect preflight attempts independently from plan/run/review records. - `DecisionTrace` is append-only and captures meaningful transitions with timestamps and summaries. @@ -214,6 +218,9 @@ Filesystem layout: - `.patchrail/approvals/.json` - `.patchrail/fallback_requests/.json` - `.patchrail/preflight_snapshots/.json` +- `.patchrail/verifications/.json` +- `.patchrail/verification_outputs//stdout.log` +- `.patchrail/verification_outputs//stderr.log` - `.patchrail/artifacts//bundle.json` - `.patchrail/artifacts//stdout.log` - `.patchrail/artifacts//stderr.log` @@ -236,6 +243,10 @@ Read-side navigation: - `patchrail list runs [--task-id ]` - `patchrail list reviews [--task-id ]` - `patchrail list approvals [--task-id ]` +- `patchrail list verifications [--task-id ] [--run-id ]` +- `patchrail list review-queue` +- `patchrail packet show --task-id [--format markdown|json]` +- `patchrail packet export --task-id --output [--format markdown|json]` - `patchrail list fallback-requests [--task-id ]` - `patchrail list preflight-snapshots [--task-id ]` - `patchrail list artifact-bundles [--task-id ] [--logical-kind ] [--has-trace]` @@ -253,8 +264,10 @@ Read-side navigation: - Artifact bundles now include manifest-style metadata per file, including logical kind, media type, collection status, digest, and byte size. - Runner adapters may also return an optional structured trace, which Patchrail persists as another artifact without giving the runner ownership of the canonical run record. - Read-side lookup stays Patchrail-owned: the latest bundle is exposed through `status`, and historical bundle queries stay under the CLI list surface. -9. `review` resolves the reviewer candidate, optionally auto-generates review content through the selected workflow backend, stores the review result with rationale and preflight evidence plus any auxiliary workflow metadata, updates the task to `awaiting_approval`, and appends a decision trace with rationale. -10. `approve` or `reject` stores an approval record, appends both decision and approval ledger entries, and moves the task to its final state. +9. `verify` runs an operator-specified shell command for a run, stores command output under `.patchrail/verification_outputs/`, stores a `VerificationRecord`, and appends a decision trace without changing the task lifecycle. +10. `review` resolves the reviewer candidate, optionally auto-generates review content through the selected workflow backend, stores the review result with rationale and preflight evidence plus any auxiliary workflow metadata, updates the task to `awaiting_approval`, and appends a decision trace with rationale. +11. `approve` or `reject` stores an approval record, appends both decision and approval ledger entries, and moves the task to its final state. +12. `packet show|export` generates a Markdown or JSON approval packet from existing local records and surfaces unresolved gaps such as missing verification, failed verification, missing review, or missing approval. ## Deferred Hook Contract Future infra-ops support is represented as a hook seam, not an automation system: diff --git a/docs/backlog.md b/docs/backlog.md index a502e57..cb965ea 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -1,18 +1,21 @@ # Patchrail Backlog ## Next Milestones -1. Expand the Phase 1 planning layer beyond the first brief artifact path: split onboarding into `machine/runtime onboarding` and `project/planning onboarding`, and guide the `Future Completion Brief -> Ontology Brief -> Product Brief -> Plan` sequence without changing the canonical state machine. -2. Add triangulated planning inputs across `Codex / OpenAI`, `Claude`, and `Grok`, keeping their roles distinct and storing only Patchrail-owned canonical plan output plus companion planning artifacts or metadata. -3. Expand the optional workflow backend seam beneath that planning layer: deepen the LangGraph planner path, add a richer reviewer workflow, and extend `plan --auto` / `review --auto` without moving canonical records out of Patchrail. -4. Promote isolated run workspaces from simple manifest folders into real execution sandboxes with stronger provenance and an explicit runner path contract for task, plan, output, artifacts, and trace handoff. -5. Capture richer diffs and review summaries from executor outputs, building on the current manifest-style artifact metadata and initial structured runner trace support. -6. Build on the new artifact-bundle history view with richer filters and history surfaces so the local store stays usable as record volume grows. -7. Add finer-grained approval policies such as time-bounded fallback approvals and role-specific approvers. -8. Evolve the runner trace schema beyond the current `trace.json` payload without drifting into a generic eval framework. +1. Harden `patchrail verify`, `patchrail list review-queue`, and `patchrail packet show|export` as the v0.2 portfolio surface for AI coding verification. +2. Add richer verification presets and command groups while keeping commands operator-specified and locally auditable. +3. Improve approval packets with diff summaries, verification grouping, and clearer unresolved-gap explanations. +4. Expand the Delivery Contract setup path beyond the first guided brief artifact templates without changing the canonical state machine. +5. Add triangulated planning inputs across `Codex / OpenAI`, `Claude`, and `Grok`, keeping their roles distinct and storing only Patchrail-owned canonical plan output plus companion planning artifacts or metadata. +6. Expand the optional workflow backend seam beneath that planning layer: deepen the LangGraph planner path, add a richer reviewer workflow, and extend `plan --auto` / `review --auto` without moving canonical records out of Patchrail. +7. Promote isolated run workspaces from simple manifest folders into real execution sandboxes with stronger provenance and an explicit runner path contract for task, plan, output, artifacts, verification, and trace handoff. +8. Capture richer diffs and review summaries from executor outputs, building on the current manifest-style artifact metadata, verification records, and initial structured runner trace support. +9. Build on the new artifact-bundle, verification, and review-queue views with richer filters and history surfaces so the local store stays usable as record volume grows. +10. Add finer-grained approval policies such as time-bounded fallback approvals and role-specific approvers. +11. Evolve the runner trace schema beyond the current `trace.json` payload without drifting into a generic eval framework. ## Deferred Features - Dashboard or browser-based review UI. -- Harbor-style task or dataset registry, package publishing, or distribution workflow. +- Harbor-style task or dataset registry. - Job or trial bulk evaluation orchestration across remote runtimes. - Generic provider marketplace behavior. - Billing, quotas, or cloud tenancy concerns. @@ -22,17 +25,18 @@ - Full bilingual CLI surface beyond the current human-readable default output, explicit `--json` automation mode, and English error baseline. ## Continuation Queue -1. Promote `patchrail setup project` from scaffold generation into guided capture for the existing `future`, `ontology`, and `product` planning brief artifacts. +1. Add a smoke flow that demonstrates `task -> plan -> run -> verify -> review -> approve -> packet export`. 2. Decide whether `patchrail start` should remain a shell launcher or delegate first-run users into the `patchrail setup` runtime/project path. -3. Add triangulated planner candidate generation and comparison so Patchrail can record why the canonical plan was chosen rather than just which backend produced it. -4. Promote the optional `langgraph` backend from planner/reviewer scaffolding into richer subordinate workflow state without handing over approval, ledger, or artifact ownership. -5. Introduce real diff ingestion so review results can reference concrete outputs instead of summary-only placeholders. -6. Evolve the current optional runner trace schema beyond the MVP `trace.json` payload, borrowing the useful parts of Harbor-style trajectory data without drifting into a generic eval or RL format. -7. Formalize the runner workspace path contract and reserved environment variables for task, plan, output, artifact, and trace exchange. -8. Add time-bounded or one-shot semantics for fallback approvals once real adapters exist. -9. Add richer list filters and historical views for large local stores beyond the current artifact-bundle and task-scoped read-side queries. -10. Convert hook placeholders into event subscribers after approval and audit semantics are stable. +3. Add explicit packet sections for changed files and diff evidence after real diff ingestion lands. +4. Promote guided Delivery Contract capture beyond templates while keeping the existing `brief` records for compatibility. +5. Add triangulated planner candidate generation and comparison so Patchrail can record why the canonical plan was chosen rather than just which backend produced it. +6. Promote the optional `langgraph` backend from planner/reviewer scaffolding into richer subordinate workflow state without handing over approval, ledger, or artifact ownership. +7. Evolve the current optional runner trace schema beyond the MVP `trace.json` payload, borrowing the useful parts of Harbor-style trajectory data without drifting into a generic eval or RL format. +8. Formalize the runner workspace path contract and reserved environment variables for task, plan, output, artifact, verification, and trace exchange. +9. Add time-bounded or one-shot semantics for fallback approvals once real adapters exist. +10. Add richer list filters and historical views for large local stores beyond the current artifact-bundle, verification, and task-scoped read-side queries. +11. Convert hook placeholders into event subscribers after approval and audit semantics are stable. ## Iteration Review -- This iteration establishes the durable contract, local storage layout, core state machine, CLI skeleton, isolated run workspaces, built-in local smoke-testing path for both `local` and `real` presets, a first role ontology with local and real role-policy presets, provider-aware preflight checks, executor API runners with explicit `--access-mode api` selection, live Claude and Codex subscription executor runners, live Codex subscription reviewer automation, a workflow-engine seam for auto-generated `plan --auto` and `review --auto` flows, an optional LangGraph backend scaffold, manifest-style artifact metadata with digests and logical kinds, an initial optional `trace.json` runner artifact, an explicit fallback approval request path, standalone preflight snapshots, list-oriented CLI navigation for the main local records including artifact-bundle history, `setup`/`start`/`doctor` onboarding commands, a minimal interactive `patchrail start` shell for TTY sessions, human-readable default CLI output with explicit `--json` automation mode, and a first local planning brief artifact layer attached to canonical plan records. -- The next session should preserve the current ontology and approval boundaries while improving guided editing for future-anchored planning, dual-pass onboarding, and triangulated multi-provider plan generation before broadening executor sophistication or read-side surfaces. +- This iteration establishes the durable contract, local storage layout, core state machine, CLI skeleton, isolated run workspaces, built-in local smoke-testing path for both `local` and `real` presets, a first role ontology with local and real role-policy presets, provider-aware preflight checks, executor API runners with explicit `--access-mode api` selection, live Claude and Codex subscription executor runners, live Codex subscription reviewer automation, a workflow-engine seam for auto-generated `plan --auto` and `review --auto` flows, an optional LangGraph backend scaffold, manifest-style artifact metadata with digests and logical kinds, an initial optional `trace.json` runner artifact, an explicit fallback approval request path, standalone preflight snapshots, verification records, approval packet read models, review-queue grouping, list-oriented CLI navigation for the main local records including artifact-bundle and verification history, `setup`/`start`/`doctor` onboarding commands, a minimal interactive `patchrail start` shell for TTY sessions, human-readable default CLI output with explicit `--json` automation mode, and a guided Delivery Contract template path attached to canonical plan records through existing brief storage. +- The next session should preserve the current ontology and approval boundaries while improving verification presets, packet evidence quality, guided Delivery Contract capture, and public distribution checks before broadening executor sophistication. diff --git a/docs/local-testing.md b/docs/local-testing.md index 7dad324..6a3f15b 100644 --- a/docs/local-testing.md +++ b/docs/local-testing.md @@ -13,6 +13,7 @@ patchrail --help patchrail setup patchrail start patchrail start --once +sh scripts/check_release.sh --dry-run --python "$(command -v python3.13)" ``` optional LangGraph runtime まで同じ install 導線で入れる場合: @@ -21,7 +22,7 @@ sh scripts/install_cli.sh --python "$(command -v python3.13)" --with-langgraph ``` default output は人間向け summary です。machine-readable な JSON が必要な場合は `patchrail --json ...` を使います。`scripts/local_smoke_test.sh` は内部でこの mode を使います。 -`patchrail setup` は first-run 用の導線です。runtime config を作成し、preflight summary と次の具体コマンドを返します。`patchrail setup project --title ... --description ...` は task と `future / ontology / product` brief scaffold を作成します。 +`patchrail setup` は first-run 用の導線です。runtime config を作成し、preflight summary と次の具体コマンドを返します。`patchrail setup project --guided --title ... --description ...` は task と Delivery Contract 用の `future / ontology / product` brief scaffold を作成します。 `patchrail start` は TTY では interactive shell に入り、`patchrail start --once` は splash を 1 回だけ描画します。 ## Fastest Path @@ -30,7 +31,7 @@ default output は人間向け summary です。machine-readable な JSON が必 cd /path/to/Patchrail sh scripts/install_cli.sh --python "$(command -v python3.13)" patchrail setup -patchrail setup project --title "First task" --description "Describe the supervised work" +patchrail setup project --guided --title "First task" --description "Describe the supervised work" patchrail start ``` `patchrail start` に入った後は `doctor`, `list tasks`, `task create ...`, `status --task-id ...`, `exit` をそのまま打てます。slash shortcut は `/help`, `/doctor`, `/tasks`, `/start`, `/exit` を使えます。 @@ -68,6 +69,8 @@ patchrail list plans patchrail list runs patchrail list reviews patchrail list approvals +patchrail list verifications +patchrail list review-queue patchrail list fallback-requests patchrail list preflight-snapshots patchrail list artifact-bundles --has-trace @@ -80,6 +83,7 @@ patchrail --json status --task-id - `task create` - `plan` - `run --runner auto` +- `verify --command "python -c 'print(\"verification ok\")'"` - `review` - `approve` @@ -180,8 +184,11 @@ patchrail plan \ --step "Run harness" patchrail run --task-id --runner auto +patchrail verify --run-id --command "pytest -q" patchrail review --run-id --verdict pass --summary "Looks good" patchrail approve --task-id --rationale "Local test passed" +patchrail packet show --task-id +patchrail packet export --task-id --output packet.md patchrail status --task-id patchrail artifacts --run-id patchrail logs --run-id @@ -189,6 +196,8 @@ patchrail list artifact-bundles --task-id patchrail list tasks patchrail list plans --task-id patchrail list runs --task-id +patchrail list verifications --task-id +patchrail list review-queue patchrail list reviews --task-id patchrail list approvals --task-id patchrail list preflight-snapshots --task-id @@ -234,6 +243,8 @@ sh scripts/local_smoke_test.sh - `.patchrail/approvals/` - `.patchrail/fallback_requests/` - `.patchrail/preflight_snapshots/` +- `.patchrail/verifications/` +- `.patchrail/verification_outputs/` - `.patchrail/config/role-policy.json` - `.patchrail/config/workflow-backend.json` - `.patchrail/artifacts//` diff --git a/docs/mvp.md b/docs/mvp.md index 93e5a23..c8cea39 100644 --- a/docs/mvp.md +++ b/docs/mvp.md @@ -1,10 +1,10 @@ # Patchrail MVP ## Goal -Prove Patchrail’s thesis with the narrowest possible supervised workflow: a task can be created, planned, resolved through role-aware policy and preflight, executed locally, reviewed, explicitly approved or rejected, and safely resumed from local disk alone. +Prove Patchrail’s thesis with the narrowest possible supervised workflow: a task can be created, planned, resolved through role-aware policy and preflight, executed locally, verified with operator-specified checks, reviewed, explicitly approved or rejected, exported as an approval packet, and safely resumed from local disk alone. ## In Scope -- CLI commands for setup bootstrap, config bootstrap, preflight inspection, task creation, planning brief scaffolding, planning, execution, status, review, approval, rejection, logs, and artifact lookup. +- CLI commands for setup bootstrap, config bootstrap, preflight inspection, task creation, Delivery Contract scaffolding through existing planning brief storage, planning, execution, verification, packet export, review-queue listing, status, review, approval, rejection, logs, and artifact lookup. - A CLI onboarding shell via `patchrail start`, with `patchrail start --once` as the non-interactive splash path. - Filesystem persistence under `.patchrail/` or `PATCHRAIL_HOME`. - CLI-visible workflow backend selection persisted under local config, with `local` as default and `langgraph` as an optional backend. @@ -12,6 +12,8 @@ Prove Patchrail’s thesis with the narrowest possible supervised workflow: a ta - Supervised `plan --auto` and `review --auto` paths behind a pluggable workflow backend seam, with canonical records still owned by Patchrail. - Deterministic local harness execution through shell-backed commands, with policy-resolved candidates supplying the concrete command. - Artifact bundle persistence from day one. +- Verification record persistence from day one for command, cwd, exit code, stdout/stderr output paths, elapsed time, task id, and run id. +- Approval packet generation as a read model rather than a new lifecycle entity. - Per-run isolated workspaces containing task, plan, and runner output manifests. - A repo-local smoke path via `scripts/local_smoke_test.sh` and `patchrail.runners.local_harness`. - Local policy config plus persisted `ResolvedAssignment`, `PreflightResult`, and `FallbackEvent` data in plan, review, and run records. @@ -21,13 +23,17 @@ Prove Patchrail’s thesis with the narrowest possible supervised workflow: a ta ## Acceptance Criteria - `patchrail config init` creates a local role-policy document and persists the selected workflow backend. - `patchrail setup` bootstraps runtime config, reports preflight status, and returns concrete next commands. -- `patchrail setup project` creates or reuses a task and writes editable local Future Completion, Ontology, and Product brief scaffolds; operators persist edited brief content with `patchrail brief create` before plan creation. +- `patchrail setup project --guided` creates or reuses a task and writes editable local Delivery Contract prompts for Future Completion, Ontology, and Product brief scaffolds; operators persist edited brief content with `patchrail brief create` before plan creation. - `patchrail start` can bootstrap config and keep an operator in a TTY shell without introducing a second canonical state model. - `patchrail preflight` reports role candidate readiness from local state and environment only. - `patchrail task create` creates a task and persists it locally. - `patchrail plan` resolves and persists a planner assignment, stores a plan, and moves the task to `planned`. - `patchrail plan --auto` routes through the configured workflow backend without changing Patchrail's canonical plan record ownership. - `patchrail run` requires an existing plan, resolves and persists an executor assignment, stores a run, writes artifact files plus invocation metadata, creates an isolated workspace, and moves the task to `review_pending`. +- `patchrail verify --run-id --command ""` runs an operator-specified verification command, stores stdout/stderr artifacts, records exit code and elapsed time, and does not mutate the canonical task lifecycle. +- `patchrail list verifications` filters verification records by task or run. +- `patchrail list review-queue` groups tasks by missing verification, failed verification, review readiness, approval readiness, and approved state. +- `patchrail packet show|export` renders Markdown or JSON approval packets from existing local records and surfaces unresolved gaps. - `patchrail review` requires a completed run, resolves and persists a reviewer assignment, stores the verdict and rationale, and moves the task to `awaiting_approval`. - `patchrail review --auto` routes through the configured workflow backend without changing approval, ledger, or artifact ownership. - `patchrail approve` and `patchrail reject` require a completed review and store human rationale plus ledger entries. @@ -40,6 +46,7 @@ Prove Patchrail’s thesis with the narrowest possible supervised workflow: a ta - Background workers or distributed execution. - Worktree management beyond future runner needs. - Web dashboards or GUI review surfaces. +- Autonomous verification command selection. - Fully autonomous planning/execution, autonomous approvals, or merge automation. - LangGraph-driven executor orchestration or backend-owned approval state. - LangGraph Studio or any dashboard-first operator workflow. diff --git a/patchrail/cli/main.py b/patchrail/cli/main.py index 2657768..04a9e90 100644 --- a/patchrail/cli/main.py +++ b/patchrail/cli/main.py @@ -50,6 +50,7 @@ def build_parser() -> argparse.ArgumentParser: setup_parser.add_argument("--quick", action="store_true") setup_parser.add_argument("--non-interactive", action="store_true") setup_parser.add_argument("--reset", action="store_true") + setup_parser.add_argument("--guided", action="store_true") start_parser = subparsers.add_parser("start") start_parser.add_argument("--preset", choices=["local", "real"], default="local") @@ -109,6 +110,20 @@ def build_parser() -> argparse.ArgumentParser: artifacts_parser = subparsers.add_parser("artifacts") artifacts_parser.add_argument("--run-id", required=True) + verify_parser = subparsers.add_parser("verify") + verify_parser.add_argument("--run-id", required=True) + verify_parser.add_argument("--command", dest="verify_command", required=True) + + packet_parser = subparsers.add_parser("packet") + packet_subparsers = packet_parser.add_subparsers(dest="packet_command", required=True) + packet_show = packet_subparsers.add_parser("show") + packet_show.add_argument("--task-id", required=True) + packet_show.add_argument("--format", choices=["markdown", "json"], default="markdown") + packet_export = packet_subparsers.add_parser("export") + packet_export.add_argument("--task-id", required=True) + packet_export.add_argument("--output", required=True) + packet_export.add_argument("--format", choices=["markdown", "json"], default="markdown") + list_parser = subparsers.add_parser("list") list_subparsers = list_parser.add_subparsers(dest="list_command", required=True) list_subparsers.add_parser("tasks") @@ -128,6 +143,10 @@ def build_parser() -> argparse.ArgumentParser: list_artifact_bundles.add_argument("--task-id") list_artifact_bundles.add_argument("--logical-kind") list_artifact_bundles.add_argument("--has-trace", action="store_true") + list_verifications = list_subparsers.add_parser("verifications") + list_verifications.add_argument("--task-id") + list_verifications.add_argument("--run-id") + list_subparsers.add_parser("review-queue") return parser @@ -158,6 +177,7 @@ def execute(args: argparse.Namespace, app: PatchrailApp | None = None) -> dict[s reset=args.reset, quick=args.quick, non_interactive=args.non_interactive, + guided=args.guided, ) if args.command == "start": return app.start(preset=args.preset, workflow_backend=args.workflow_backend) @@ -197,6 +217,12 @@ def execute(args: argparse.Namespace, app: PatchrailApp | None = None) -> dict[s return app.get_logs(run_id=args.run_id) if args.command == "artifacts": return app.get_artifacts(run_id=args.run_id) + if args.command == "verify": + return app.verify_run(run_id=args.run_id, command=args.verify_command) + if args.command == "packet" and args.packet_command == "show": + return app.show_packet(task_id=args.task_id, output_format=args.format) + if args.command == "packet" and args.packet_command == "export": + return app.export_packet(task_id=args.task_id, output_path=args.output, output_format=args.format) if args.command == "list" and args.list_command == "tasks": return app.list_tasks() if args.command == "list" and args.list_command == "plans": @@ -217,6 +243,10 @@ def execute(args: argparse.Namespace, app: PatchrailApp | None = None) -> dict[s logical_kind=args.logical_kind, has_trace=args.has_trace, ) + if args.command == "list" and args.list_command == "verifications": + return app.list_verifications(task_id=args.task_id, run_id=args.run_id) + if args.command == "list" and args.list_command == "review-queue": + return app.list_review_queue() raise PatchrailError("Unsupported command.") diff --git a/patchrail/cli/render.py b/patchrail/cli/render.py index 3bf2835..d097ef1 100644 --- a/patchrail/cli/render.py +++ b/patchrail/cli/render.py @@ -36,6 +36,10 @@ def render_payload(args: Any, payload: dict[str, Any]) -> str: return _render_logs(payload) if args.command == "artifacts": return _render_artifacts(payload) + if args.command == "verify": + return _render_verification(payload) + if args.command == "packet": + return _render_packet(getattr(args, "packet_command", ""), payload) if args.command == "list": return _render_list(getattr(args, "list_command", ""), payload) return _render_unknown(payload) @@ -395,7 +399,37 @@ def _render_artifacts(payload: dict[str, Any]) -> str: return "\n".join(lines) +def _render_verification(payload: dict[str, Any]) -> str: + verification = payload["verification"] + return "\n".join( + [ + f"Recorded verification {verification['id']} for run {verification['run_id']}", + f"Status: {verification['status']}", + f"Exit code: {verification['exit_code']}", + f"Command: {verification['command']}", + f"Stdout: {verification['stdout_path']}", + f"Stderr: {verification['stderr_path']}", + ] + ) + + +def _render_packet(packet_command: str, payload: dict[str, Any]) -> str: + if packet_command == "show": + return payload["content"].rstrip() + if packet_command == "export": + return "\n".join( + [ + "Exported approval packet", + f"Format: {payload['format']}", + f"Output: {payload['output_path']}", + ] + ) + return _render_unknown(payload) + + def _render_list(list_command: str, payload: dict[str, Any]) -> str: + if list_command == "review-queue": + return _render_review_queue(payload) key = { "tasks": "tasks", "plans": "plans", @@ -405,6 +439,7 @@ def _render_list(list_command: str, payload: dict[str, Any]) -> str: "fallback-requests": "fallback_requests", "preflight-snapshots": "preflight_snapshots", "artifact-bundles": "artifact_bundles", + "verifications": "verifications", }[list_command] items = payload.get(key, []) title = key.replace("_", " ") @@ -416,6 +451,17 @@ def _render_list(list_command: str, payload: dict[str, Any]) -> str: return "\n".join(lines) +def _render_review_queue(payload: dict[str, Any]) -> str: + queue = payload["review_queue"] + lines = ["Review Queue"] + for group in ("needs_verification", "failed_verification", "ready_for_review", "awaiting_approval", "approved"): + items = queue[group] + lines.append(f"{group}: {len(items)}") + for item in items: + lines.append(f" {item['id']} | {item['state']} | {item['title']}") + return "\n".join(lines) + + def _list_item_summary(key: str, item: dict[str, Any]) -> str: if key == "tasks": return f"{item['id']} | {item['state']} | {item['title']}" @@ -433,6 +479,8 @@ def _list_item_summary(key: str, item: dict[str, Any]) -> str: return f"{item['id']} | task={item['task_id']} | {item['phase']} | {item['role']}" if key == "artifact_bundles": return f"{item['run_id']} | {item['summary']}" + if key == "verifications": + return f"{item['id']} | run={item['run_id']} | {item['status']} | exit={item['exit_code']}" return str(item) diff --git a/patchrail/core/service.py b/patchrail/core/service.py index d1f18e6..ea03d28 100644 --- a/patchrail/core/service.py +++ b/patchrail/core/service.py @@ -33,12 +33,14 @@ serialize, ) from patchrail.models.roles import AccessMode, Provider, Role +from patchrail.packet.service import ApprovalPacketService from patchrail.review.service import ReviewService from patchrail.runners.api import build_api_runner from patchrail.runners.subscription import build_subscription_runner from patchrail.runners.stub import build_runner from patchrail.storage.config_store import ConfigStore from patchrail.storage.filesystem import FilesystemStore +from patchrail.verification.service import VerificationService from patchrail.workflows import WorkflowEngine, build_workflow_engine _BRIEF_KIND_ORDER = { @@ -57,6 +59,8 @@ def __init__(self, store: FilesystemStore) -> None: self.review = ReviewService(store) self.approval = ApprovalService(store) self.fallback_approval = FallbackApprovalService(store) + self.verification = VerificationService(store) + self.packet = ApprovalPacketService(store) self._workflow_engine_instance: WorkflowEngine | None = None @classmethod @@ -140,6 +144,7 @@ def setup( reset: bool = False, quick: bool = False, non_interactive: bool = False, + guided: bool = False, ) -> dict[str, Any]: if scope == "runtime": return self._setup_runtime( @@ -160,6 +165,7 @@ def setup( reset=reset, quick=quick, non_interactive=non_interactive, + guided=guided, ) raise PatchrailError(f"Unknown setup scope '{scope}'.") @@ -247,7 +253,7 @@ def _setup_runtime( "quick": quick, "non_interactive": non_interactive, "next_steps": [ - 'patchrail setup project --title "First task" --description "Describe the supervised work"', + 'patchrail setup project --guided --title "First task" --description "Describe the supervised work"', "patchrail doctor", "sh scripts/local_smoke_test.sh", ], @@ -265,6 +271,7 @@ def _setup_project( reset: bool, quick: bool, non_interactive: bool, + guided: bool, ) -> dict[str, Any]: runtime_payload = self._setup_runtime( preset=preset, @@ -287,7 +294,7 @@ def _setup_project( template_path = brief_directory / f"{task.id}-{kind.value}.md" template_paths[kind.value] = str(template_path) if not template_path.exists(): - template_path.write_text(self._brief_template(kind=kind, task=task)) + template_path.write_text(self._brief_template(kind=kind, task=task, guided=guided)) return { "setup": { @@ -299,6 +306,7 @@ def _setup_project( "brief_files": template_paths, "quick": quick, "non_interactive": non_interactive, + "guided": guided, "next_steps": [ f"Edit the generated brief files under {brief_directory}", f"patchrail brief create --task-id {task.id} --kind future --file {template_paths['future']}", @@ -662,6 +670,37 @@ def get_artifacts(self, run_id: str) -> dict[str, Any]: self.store.load_run(run_id) return {"artifact_bundle": serialize(self.artifacts.get_bundle(run_id))} + def verify_run(self, run_id: str, command: str) -> dict[str, Any]: + verification = self.verification.run_verification(run_id=run_id, command=command) + self._append_trace( + verification.task_id, + "run.verified", + f"Recorded verification {verification.id} for run {verification.run_id}.", + metadata={ + "verification_id": verification.id, + "run_id": verification.run_id, + "command": verification.command, + "exit_code": verification.exit_code, + "status": verification.status.value, + }, + ) + return {"verification": serialize(verification)} + + def list_verifications(self, task_id: str | None = None, run_id: str | None = None) -> dict[str, Any]: + return { + "verifications": serialize( + self.verification.list_verifications(task_id=task_id, run_id=run_id) + ) + } + + def show_packet(self, task_id: str, output_format: str) -> dict[str, Any]: + packet = self.packet.build_packet(task_id) + content = self.packet.render_json(packet) if output_format == "json" else self.packet.render_markdown(packet) + return {"packet": packet, "content": content, "format": output_format} + + def export_packet(self, task_id: str, output_path: str, output_format: str) -> dict[str, Any]: + return self.packet.export_packet(task_id=task_id, output_path=output_path, output_format=output_format) + def list_tasks(self) -> dict[str, Any]: return {"tasks": serialize(self.store.list_tasks())} @@ -724,6 +763,36 @@ def list_artifact_bundles( ] return {"artifact_bundles": serialize(bundles)} + def list_review_queue(self) -> dict[str, Any]: + groups: dict[str, list[Any]] = { + "needs_verification": [], + "failed_verification": [], + "ready_for_review": [], + "awaiting_approval": [], + "approved": [], + } + verifications_by_run: dict[str, list[Any]] = {} + for verification in self.store.list_verifications(): + verifications_by_run.setdefault(verification.run_id, []).append(verification) + + for task in self.store.list_tasks(): + if task.state == TaskState.APPROVED: + groups["approved"].append(task) + continue + if task.state == TaskState.AWAITING_APPROVAL: + groups["awaiting_approval"].append(task) + continue + if task.state != TaskState.REVIEW_PENDING or task.latest_run_id is None: + continue + latest_verification = (verifications_by_run.get(task.latest_run_id) or [None])[0] + if latest_verification is None: + groups["needs_verification"].append(task) + elif latest_verification.status.value == "failed": + groups["failed_verification"].append(task) + else: + groups["ready_for_review"].append(task) + return {"review_queue": serialize(groups)} + def _briefs_for_task(self, task_id: str) -> list[PlanningBrief]: briefs = [brief for brief in self.store.list_planning_briefs() if brief.task_id == task_id] return sorted(briefs, key=lambda brief: (_BRIEF_KIND_ORDER[brief.kind], brief.created_at)) @@ -738,7 +807,7 @@ def _brief_kind(self, kind_name: str) -> BriefKind: def _merge_brief_reference(self, existing: list[Any], brief: PlanningBrief) -> list[Any]: return [reference for reference in existing if reference.id != brief.id] + [brief.to_reference()] - def _brief_template(self, kind: BriefKind, task: Task) -> str: + def _brief_template(self, kind: BriefKind, task: Task, guided: bool = False) -> str: spec = PLANNING_LAYER_BY_KIND[kind.value] lines = [ f"# {spec.title}", @@ -756,8 +825,39 @@ def _brief_template(self, kind: BriefKind, task: Task) -> str: ] for heading in spec.headings: lines.extend([f"## {heading}", "", "- TBD", ""]) + if guided: + lines.extend(self._guided_contract_prompts(kind)) return "\n".join(lines) + def _guided_contract_prompts(self, kind: BriefKind) -> list[str]: + prompts_by_kind = { + BriefKind.FUTURE: [ + "## Delivery Contract Prompts", + "", + "- What exact future state should the AI-coded change produce?", + "- Which observable behavior proves the change is complete?", + "- What must remain unchanged even if the agent finds adjacent work?", + "", + ], + BriefKind.ONTOLOGY: [ + "## Delivery Contract Prompts", + "", + "- What business or product rule would make a passing test still wrong?", + "- Which entities, owners, credentials, or external systems are real boundaries?", + "- What must the agent not invent, rename, migrate, delete, or broaden?", + "", + ], + BriefKind.PRODUCT: [ + "## Delivery Contract Prompts", + "", + "- What evidence must the approval packet show before a human can approve?", + "- Which tests, commands, screenshots, or logs should Patchrail verification capture?", + "- What user-visible outcome or operator workflow must be checked after implementation?", + "", + ], + } + return prompts_by_kind[kind] + def _append_trace( self, task_id: str, diff --git a/patchrail/models/entities.py b/patchrail/models/entities.py index a06cdfa..e049b37 100644 --- a/patchrail/models/entities.py +++ b/patchrail/models/entities.py @@ -54,6 +54,11 @@ class BriefKind(StrEnum): PRODUCT = "product" +class VerificationStatus(StrEnum): + PASSED = "passed" + FAILED = "failed" + + @dataclass(slots=True) class CostMetrics: prompt_tokens: int @@ -406,6 +411,37 @@ def from_dict(cls, payload: dict[str, Any]) -> PreflightSnapshot: ) +@dataclass(slots=True) +class VerificationRecord: + id: str + task_id: str + run_id: str + command: str + cwd: str + exit_code: int + status: VerificationStatus + stdout_path: str + stderr_path: str + elapsed_seconds: float + created_at: str + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> VerificationRecord: + return cls( + id=payload["id"], + task_id=payload["task_id"], + run_id=payload["run_id"], + command=payload["command"], + cwd=payload["cwd"], + exit_code=int(payload["exit_code"]), + status=VerificationStatus(payload["status"]), + stdout_path=payload["stdout_path"], + stderr_path=payload["stderr_path"], + elapsed_seconds=float(payload["elapsed_seconds"]), + created_at=payload["created_at"], + ) + + @dataclass(slots=True) class DecisionTrace: id: str diff --git a/patchrail/packet/__init__.py b/patchrail/packet/__init__.py new file mode 100644 index 0000000..cbe0fc9 --- /dev/null +++ b/patchrail/packet/__init__.py @@ -0,0 +1 @@ +"""Approval packet read-model package.""" diff --git a/patchrail/packet/service.py b/patchrail/packet/service.py new file mode 100644 index 0000000..9255347 --- /dev/null +++ b/patchrail/packet/service.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from patchrail.models.entities import VerificationStatus, serialize +from patchrail.storage.filesystem import FilesystemStore + + +class ApprovalPacketService: + def __init__(self, store: FilesystemStore) -> None: + self.store = store + + def build_packet(self, task_id: str) -> dict[str, Any]: + task = self.store.load_task(task_id) + plan = self.store.load_plan(task.plan_id) if task.plan_id else None + latest_run = self.store.load_run(task.latest_run_id) if task.latest_run_id else None + latest_bundle = self.store.load_artifact_bundle(task.latest_run_id) if task.latest_run_id else None + latest_review = self.store.load_review(task.latest_review_id) if task.latest_review_id else None + latest_approval = self.store.load_approval(task.latest_approval_id) if task.latest_approval_id else None + verifications = [ + verification + for verification in self.store.list_verifications() + if latest_run is not None and verification.run_id == latest_run.id + ] + latest_verification = verifications[0] if verifications else None + + return { + "task": serialize(task), + "delivery_contract": { + "planning_briefs": serialize(plan.planning_briefs) if plan else [], + "status": "captured" if plan and plan.planning_briefs else "not_captured", + }, + "plan": serialize(plan) if plan else None, + "run": serialize(latest_run) if latest_run else None, + "artifact_bundle": serialize(latest_bundle) if latest_bundle else None, + "verifications": serialize(verifications), + "latest_verification": serialize(latest_verification) if latest_verification else None, + "review": serialize(latest_review) if latest_review else None, + "approval": serialize(latest_approval) if latest_approval else None, + "unresolved_gaps": self._unresolved_gaps( + has_run=latest_run is not None, + latest_verification=latest_verification, + has_review=latest_review is not None, + has_approval=latest_approval is not None, + ), + } + + def render_markdown(self, packet: dict[str, Any]) -> str: + task = packet["task"] + plan = packet.get("plan") + run = packet.get("run") + bundle = packet.get("artifact_bundle") + verifications = packet.get("verifications", []) + review = packet.get("review") + approval = packet.get("approval") + gaps = packet.get("unresolved_gaps", []) + lines = [ + "# Patchrail Approval Packet", + "", + "## Task", + f"- ID: {task['id']}", + f"- Title: {task['title']}", + f"- State: {task['state']}", + f"- Description: {task['description']}", + "", + "## Delivery Contract", + ] + briefs = packet["delivery_contract"]["planning_briefs"] + if briefs: + for brief in briefs: + lines.append(f"- {brief['kind']}: {brief['id']} ({brief['sha256']})") + else: + lines.append("- No planning briefs captured for this task.") + lines.extend(["", "## Plan"]) + if plan: + lines.append(f"- ID: {plan['id']}") + lines.append(f"- Summary: {plan['summary']}") + for index, step in enumerate(plan["steps"], start=1): + lines.append(f"- Step {index}: {step}") + else: + lines.append("- No plan recorded.") + lines.extend(["", "## Run"]) + if run: + lines.append(f"- ID: {run['id']}") + lines.append(f"- Runner: {run['runner_assignment']['runner_name']}") + lines.append(f"- Exit code: {run['exit_code']}") + lines.append(f"- Workspace: {run['workspace_path']}") + else: + lines.append("- No run recorded.") + lines.extend(["", "## Artifacts"]) + if bundle: + lines.append(f"- Bundle: {bundle['run_id']}") + artifacts = bundle.get("artifacts", {}) + for name, path in sorted(bundle["files"].items()): + artifact = artifacts.get(name) + if artifact: + lines.append(f"- {name}: {path} [{artifact['logical_kind']}]") + else: + lines.append(f"- {name}: {path}") + else: + lines.append("- No artifact bundle recorded.") + lines.extend(["", "## Verification"]) + if verifications: + for verification in verifications: + lines.append( + f"- {verification['id']}: {verification['status']} " + f"(exit {verification['exit_code']}) `{verification['command']}`" + ) + else: + lines.append("- No verification recorded for the latest run.") + lines.extend(["", "## Review"]) + if review: + lines.append(f"- {review['id']}: {review['verdict']} - {review['summary']}") + else: + lines.append("- No review recorded.") + lines.extend(["", "## Approval"]) + if approval: + lines.append(f"- {approval['id']}: {approval['decision']} - {approval['rationale']}") + else: + lines.append("- No approval decision recorded.") + lines.extend(["", "## Unresolved Gaps"]) + if gaps: + for gap in gaps: + lines.append(f"- {gap}") + else: + lines.append("- None.") + return "\n".join(lines) + "\n" + + def render_json(self, packet: dict[str, Any]) -> str: + return json.dumps(packet, indent=2, sort_keys=True) + "\n" + + def export_packet(self, task_id: str, output_path: str, output_format: str) -> dict[str, Any]: + packet = self.build_packet(task_id) + content = self.render_json(packet) if output_format == "json" else self.render_markdown(packet) + path = Path(output_path).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return {"packet": packet, "content": content, "output_path": str(path), "format": output_format} + + def _unresolved_gaps( + self, + *, + has_run: bool, + latest_verification: Any, + has_review: bool, + has_approval: bool, + ) -> list[str]: + gaps: list[str] = [] + if not has_run: + gaps.append("No run recorded.") + return gaps + if latest_verification is None: + gaps.append("No verification recorded for latest run.") + elif latest_verification.status == VerificationStatus.FAILED: + gaps.append("Latest verification failed.") + if not has_review: + gaps.append("No review recorded.") + if not has_approval: + gaps.append("No approval decision recorded.") + return gaps diff --git a/patchrail/storage/filesystem.py b/patchrail/storage/filesystem.py index 3feefb8..8aff476 100644 --- a/patchrail/storage/filesystem.py +++ b/patchrail/storage/filesystem.py @@ -17,6 +17,7 @@ ReviewResult, Run, Task, + VerificationRecord, serialize, ) @@ -42,6 +43,8 @@ def _ensure_layout(self) -> None: "approvals", "fallback_requests", "preflight_snapshots", + "verifications", + "verification_outputs", "artifacts", "workspaces", "ledgers", @@ -133,6 +136,18 @@ def list_preflight_snapshots(self) -> list[PreflightSnapshot]: "created_at", ) + def save_verification(self, verification: VerificationRecord) -> None: + self._write_json(self.root / "verifications" / f"{verification.id}.json", serialize(verification)) + + def load_verification(self, verification_id: str) -> VerificationRecord: + return VerificationRecord.from_dict(self._read_json(self.root / "verifications" / f"{verification_id}.json")) + + def list_verifications(self) -> list[VerificationRecord]: + return self._list_records(self.root / "verifications", VerificationRecord.from_dict, "created_at") + + def verification_output_dir(self, verification_id: str) -> Path: + return self.root / "verification_outputs" / verification_id + def save_artifact_bundle(self, bundle: ArtifactBundle) -> None: bundle_dir = self.artifact_dir(bundle.run_id) bundle_dir.mkdir(parents=True, exist_ok=True) diff --git a/patchrail/verification/__init__.py b/patchrail/verification/__init__.py new file mode 100644 index 0000000..8640401 --- /dev/null +++ b/patchrail/verification/__init__.py @@ -0,0 +1 @@ +"""Verification service package.""" diff --git a/patchrail/verification/service.py b/patchrail/verification/service.py new file mode 100644 index 0000000..18bae73 --- /dev/null +++ b/patchrail/verification/service.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import subprocess +import time +from pathlib import Path + +from patchrail.core.ids import generate_id, utc_now +from patchrail.models.entities import VerificationRecord, VerificationStatus +from patchrail.storage.filesystem import FilesystemStore + + +class VerificationService: + def __init__(self, store: FilesystemStore) -> None: + self.store = store + + def run_verification(self, run_id: str, command: str, cwd: Path | None = None) -> VerificationRecord: + run = self.store.load_run(run_id) + verification_id = generate_id("verification") + output_dir = self.store.verification_output_dir(verification_id) + output_dir.mkdir(parents=True, exist_ok=True) + stdout_path = output_dir / "stdout.log" + stderr_path = output_dir / "stderr.log" + working_dir = cwd or Path.cwd() + + started = time.perf_counter() + completed = subprocess.run( + command, + shell=True, + cwd=working_dir, + capture_output=True, + text=True, + check=False, + ) + elapsed_seconds = time.perf_counter() - started + + stdout_path.write_text(completed.stdout) + stderr_path.write_text(completed.stderr) + verification = VerificationRecord( + id=verification_id, + task_id=run.task_id, + run_id=run.id, + command=command, + cwd=str(working_dir), + exit_code=completed.returncode, + status=VerificationStatus.PASSED if completed.returncode == 0 else VerificationStatus.FAILED, + stdout_path=str(stdout_path), + stderr_path=str(stderr_path), + elapsed_seconds=elapsed_seconds, + created_at=utc_now(), + ) + self.store.save_verification(verification) + return verification + + def list_verifications( + self, + task_id: str | None = None, + run_id: str | None = None, + ) -> list[VerificationRecord]: + verifications = self.store.list_verifications() + if task_id is not None: + verifications = [verification for verification in verifications if verification.task_id == task_id] + if run_id is not None: + verifications = [verification for verification in verifications if verification.run_id == run_id] + return verifications diff --git a/pyproject.toml b/pyproject.toml index 6982fe8..05e45cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,28 @@ [build-system] -requires = ["setuptools>=68"] +requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" [project] name = "patchrail" -version = "0.1.0" -description = "Local-first supervised coding-agent control plane" +version = "0.2.0" +description = "Local verification and approval packets for AI coding agent work" readme = "README.md" requires-python = ">=3.12" authors = [{ name = "Patchrail" }] -license = { text = "MIT" } -classifiers = ["License :: OSI Approved :: MIT License"] +license = "MIT" +license-files = ["LICENSE"] +keywords = ["ai-coding", "verification", "approval", "coding-agents", "cli"] +classifiers = [ + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Quality Assurance", +] dependencies = [] +[project.urls] +Repository = "https://github.com/louistoyozaki/Patchrail" +Documentation = "https://github.com/louistoyozaki/Patchrail#readme" + [project.optional-dependencies] langgraph = ["langgraph"] @@ -21,7 +31,6 @@ patchrail = "patchrail.cli.main:run" [tool.setuptools] include-package-data = false -license-files = ["LICENSE"] [tool.setuptools.packages.find] include = ["patchrail*"] diff --git a/scripts/check_release.sh b/scripts/check_release.sh new file mode 100755 index 0000000..471ced8 --- /dev/null +++ b/scripts/check_release.sh @@ -0,0 +1,97 @@ +#!/bin/sh +set -eu + +usage() { + cat <<'EOF' +Build and verify the Patchrail release package. + +Usage: + sh scripts/check_release.sh [--python /path/to/python3.13] [--dry-run] + +Options: + --python PATH Python 3.12+ interpreter to use for build and install checks. + --dry-run Print the commands without executing them. + -h, --help Show this help text. +EOF +} + +python_bin="${PATCHRAIL_PYTHON:-python3}" +dry_run=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --python) + shift + if [ "$#" -eq 0 ]; then + echo "Missing value for --python" >&2 + exit 1 + fi + python_bin="$1" + ;; + --dry-run) + dry_run=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +tmp_dir="${PATCHRAIL_RELEASE_TMP:-}" + +build_cmd="$python_bin -m build" +venv_cmd="$python_bin -m venv .patchrail-release-venv" +install_cmd=".patchrail-release-venv/bin/python -m pip install dist/patchrail-*.whl" +help_cmd=".patchrail-release-venv/bin/patchrail --help" +smoke_cmd="PATH=.patchrail-release-venv/bin:\$PATH PYTHON_BIN=.patchrail-release-venv/bin/python PATCHRAIL_HOME=.patchrail-release-home sh scripts/local_smoke_test.sh" + +if [ "$dry_run" -eq 1 ]; then + printf '%s\n' "cd $repo_root" + printf '%s\n' "$build_cmd" + printf '%s\n' "$venv_cmd" + printf '%s\n' "$install_cmd" + printf '%s\n' "$help_cmd" + printf '%s\n' "$smoke_cmd" + exit 0 +fi + +if [ ! -x "$python_bin" ] && ! command -v "$python_bin" >/dev/null 2>&1; then + echo "Python interpreter not found: $python_bin" >&2 + exit 1 +fi + +if ! "$python_bin" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)'; then + echo "Patchrail release checks require Python 3.12+." >&2 + exit 1 +fi + +if [ -z "$tmp_dir" ]; then + tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/patchrail-release.XXXXXX") + trap 'rm -rf "$tmp_dir"' EXIT INT TERM +fi + +cd "$repo_root" +rm -rf dist +"$python_bin" -m build + +release_venv="$tmp_dir/venv" +release_home="$tmp_dir/patchrail-home" +"$python_bin" -m venv "$release_venv" + +wheel_path=$(find dist -name 'patchrail-*.whl' -type f | sort | tail -n 1) +if [ -z "$wheel_path" ]; then + echo "No Patchrail wheel found under dist/." >&2 + exit 1 +fi + +"$release_venv/bin/python" -m pip install "$wheel_path" +PATCHRAIL_HOME="$release_home" "$release_venv/bin/patchrail" --help >/dev/null +PATH="$release_venv/bin:$PATH" PYTHON_BIN="$release_venv/bin/python" PATCHRAIL_HOME="$release_home" sh scripts/local_smoke_test.sh diff --git a/scripts/local_smoke_test.sh b/scripts/local_smoke_test.sh old mode 100644 new mode 100755 index 1522c5e..7d649ca --- a/scripts/local_smoke_test.sh +++ b/scripts/local_smoke_test.sh @@ -14,6 +14,12 @@ PATCHRAIL_AUTO_PLAN=${PATCHRAIL_AUTO_PLAN:-0} PATCHRAIL_PLAN_ACCESS_MODE=${PATCHRAIL_PLAN_ACCESS_MODE:-auto} PATCHRAIL_AUTO_REVIEW=${PATCHRAIL_AUTO_REVIEW:-0} PATCHRAIL_REVIEW_ACCESS_MODE=${PATCHRAIL_REVIEW_ACCESS_MODE:-auto} +PATCHRAIL_VERIFY_COMMAND=${PATCHRAIL_VERIFY_COMMAND:-} +PATCHRAIL_PACKET_OUTPUT=${PATCHRAIL_PACKET_OUTPUT:-"$PATCHRAIL_HOME/smoke-approval-packet.md"} + +if [ -z "$PATCHRAIL_VERIFY_COMMAND" ]; then + PATCHRAIL_VERIFY_COMMAND="$PYTHON_BIN -c 'print(\"verification ok\")'" +fi if [ -z "$PATCHRAIL_AUTO_APPROVE_FALLBACK" ]; then if [ "$PATCHRAIL_CONFIG_PRESET" = "real" ]; then @@ -113,6 +119,14 @@ fi run_id=$(printf '%s' "$run_output" | json_query run id) +verify_output=$(run_patchrail verify --run-id "$run_id" --command "$PATCHRAIL_VERIFY_COMMAND") +verification_id=$(printf '%s' "$verify_output" | json_query verification id) +verification_status=$(printf '%s' "$verify_output" | json_query verification status) +if [ "$verification_status" != "passed" ]; then + printf '%s\n' "$verify_output" >&2 + exit 1 +fi + if [ "$PATCHRAIL_AUTO_REVIEW" = "1" ]; then run_patchrail review \ --run-id "$run_id" \ @@ -129,9 +143,13 @@ run_patchrail approve \ --task-id "$task_id" \ --rationale "Local smoke flow passed" >/dev/null +mkdir -p "$(dirname "$PATCHRAIL_PACKET_OUTPUT")" +run_patchrail packet export --task-id "$task_id" --output "$PATCHRAIL_PACKET_OUTPUT" >/dev/null +run_patchrail packet show --task-id "$task_id" >/dev/null + status_output=$(run_patchrail status --task-id "$task_id") final_state=$(printf '%s' "$status_output" | json_query task state) -printf 'Local smoke flow completed: preset=%s workflow_backend=%s task=%s run=%s state=%s fallback_approved=%s auto_plan=%s auto_review=%s\n' \ - "$PATCHRAIL_CONFIG_PRESET" "$PATCHRAIL_WORKFLOW_BACKEND" "$task_id" "$run_id" "$final_state" "$fallback_approved" "$PATCHRAIL_AUTO_PLAN" "$PATCHRAIL_AUTO_REVIEW" +printf 'Local smoke flow completed: preset=%s workflow_backend=%s task=%s run=%s verification=%s verification_status=%s state=%s fallback_approved=%s auto_plan=%s auto_review=%s packet=%s\n' \ + "$PATCHRAIL_CONFIG_PRESET" "$PATCHRAIL_WORKFLOW_BACKEND" "$task_id" "$run_id" "$verification_id" "$verification_status" "$final_state" "$fallback_approved" "$PATCHRAIL_AUTO_PLAN" "$PATCHRAIL_AUTO_REVIEW" "$PATCHRAIL_PACKET_OUTPUT" printf 'PATCHRAIL_HOME=%s\n' "$PATCHRAIL_HOME" diff --git a/tests/test_cli_flow.py b/tests/test_cli_flow.py index cec0e7d..f5b2d77 100644 --- a/tests/test_cli_flow.py +++ b/tests/test_cli_flow.py @@ -74,7 +74,7 @@ def test_setup_runtime_bootstraps_config_and_reports_setup_next_steps( assert setup["workflow_backend"] == "local" assert setup["preflight"]["planner"]["selected_candidate"]["candidate_name"] == "claude_subscription_planner" assert setup["next_steps"] == [ - 'patchrail setup project --title "First task" --description "Describe the supervised work"', + 'patchrail setup project --guided --title "First task" --description "Describe the supervised work"', "patchrail doctor", "sh scripts/local_smoke_test.sh", ] diff --git a/tests/test_install_script.py b/tests/test_install_script.py index 3298c69..49fb8d4 100644 --- a/tests/test_install_script.py +++ b/tests/test_install_script.py @@ -8,6 +8,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] INSTALL_SCRIPT = REPO_ROOT / "scripts" / "install_cli.sh" +RELEASE_CHECK_SCRIPT = REPO_ROOT / "scripts" / "check_release.sh" def test_install_script_dry_run_prints_base_pipx_install_command() -> None: @@ -42,6 +43,23 @@ def test_install_script_dry_run_prints_langgraph_injection_command() -> None: assert "pipx inject patchrail langgraph" in completed.stdout +def test_release_check_dry_run_prints_build_install_and_smoke_commands() -> None: + completed = subprocess.run( + ["/bin/sh", str(RELEASE_CHECK_SCRIPT), "--dry-run", "--python", "python3.13"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0 + assert completed.stderr == "" + assert "python3.13 -m build" in completed.stdout + assert "pip install" in completed.stdout + assert "patchrail --help" in completed.stdout + assert "sh scripts/local_smoke_test.sh" in completed.stdout + + def test_install_script_runs_pipx_from_safe_directory_and_reinstalls_when_present( tmp_path: Path, ) -> None: diff --git a/tests/test_layer_harness_contract.py b/tests/test_layer_harness_contract.py index 5bbfe4c..62e7ca9 100644 --- a/tests/test_layer_harness_contract.py +++ b/tests/test_layer_harness_contract.py @@ -76,6 +76,36 @@ def test_setup_project_templates_embed_layer_purpose_and_timing( assert "Timing: define before implementation; verify after implementation" in templates["product"] +def test_setup_project_guided_templates_frame_briefs_as_delivery_contract( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + patchrail_home = tmp_path / ".patchrail" + monkeypatch.setenv("PATCHRAIL_HOME", str(patchrail_home)) + + exit_code, payload = run_cli( + [ + "setup", + "project", + "--title", + "Review bottleneck", + "--description", + "Make AI generated changes easier to verify", + "--guided", + ], + capsys, + ) + + assert exit_code == 0 + templates = {kind: Path(path).read_text() for kind, path in payload["setup"]["brief_files"].items()} + assert "Delivery Contract" in templates["future"] + assert "What exact future state should the AI-coded change produce?" in templates["future"] + assert "What business or product rule would make a passing test still wrong?" in templates["ontology"] + assert "What evidence must the approval packet show before a human can approve?" in templates["product"] + assert payload["setup"]["guided"] is True + + def test_local_harness_trace_declares_harness_contract(tmp_path: Path) -> None: task_file = tmp_path / "task.json" plan_file = tmp_path / "plan.json" diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index db9faed..ae163a0 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -22,8 +22,23 @@ def test_repo_includes_mit_license_file() -> None: def test_pyproject_declares_mit_license_metadata() -> None: project = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text())["project"] - assert project["license"] == {"text": "MIT"} - assert "License :: OSI Approved :: MIT License" in project["classifiers"] + assert project["license"] == "MIT" + assert project["license-files"] == ["LICENSE"] + assert "License :: OSI Approved :: MIT License" not in project["classifiers"] + + +def test_pyproject_declares_public_pypi_metadata() -> None: + project = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text())["project"] + + assert project["version"] == "0.2.0" + assert project["description"] == "Local verification and approval packets for AI coding agent work" + assert "ai-coding" in project["keywords"] + assert "verification" in project["keywords"] + assert "approval" in project["keywords"] + assert "Programming Language :: Python :: 3.12" in project["classifiers"] + assert "Topic :: Software Development :: Quality Assurance" in project["classifiers"] + assert project["urls"]["Repository"] == "https://github.com/louistoyozaki/Patchrail" + assert project["urls"]["Documentation"] == "https://github.com/louistoyozaki/Patchrail#readme" def test_readme_mentions_mit_license() -> None: @@ -39,6 +54,9 @@ def test_english_readme_exists_without_japanese_text() -> None: assert not JAPANESE_TEXT_RE.search(readme) assert "README.ja.md" in readme assert "patchrail-start.jpg" in readme + assert "pipx install patchrail" in readme + assert "patchrail packet show" in readme + assert "patchrail verify" in readme def test_readme_screenshot_exists_at_repo_root() -> None: diff --git a/tests/test_verification_packet.py b/tests/test_verification_packet.py new file mode 100644 index 0000000..284a49a --- /dev/null +++ b/tests/test_verification_packet.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from patchrail.cli.main import main + + +def run_cli(args: list[str], capsys): + exit_code = main(["--json", *args]) + captured = capsys.readouterr() + payload = json.loads(captured.out) if captured.out.strip() else {} + return exit_code, payload + + +def run_cli_text(args: list[str], capsys): + exit_code = main(args) + captured = capsys.readouterr() + return exit_code, captured.out, captured.err + + +def create_completed_run(tmp_path: Path, monkeypatch, capsys) -> tuple[str, str]: + monkeypatch.setenv("PATCHRAIL_HOME", str(tmp_path / ".patchrail")) + assert run_cli(["config", "init"], capsys)[0] == 0 + exit_code, task_payload = run_cli( + ["task", "create", "--title", "Verify AI output", "--description", "Check generated code"], + capsys, + ) + assert exit_code == 0 + task_id = task_payload["task"]["id"] + assert run_cli( + [ + "plan", + "--task-id", + task_id, + "--summary", + "Run and verify the agent output", + "--step", + "Execute local harness", + ], + capsys, + )[0] == 0 + exit_code, run_payload = run_cli(["run", "--task-id", task_id, "--runner", "auto"], capsys) + assert exit_code == 0 + return task_id, run_payload["run"]["id"] + + +def test_verify_records_successful_command_and_lists_by_task_and_run( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + task_id, run_id = create_completed_run(tmp_path, monkeypatch, capsys) + + exit_code, payload = run_cli( + [ + "verify", + "--run-id", + run_id, + "--command", + f'"{sys.executable}" -c "print(\'verification ok\')"', + ], + capsys, + ) + + assert exit_code == 0 + verification = payload["verification"] + assert verification["task_id"] == task_id + assert verification["run_id"] == run_id + assert verification["status"] == "passed" + assert verification["exit_code"] == 0 + assert verification["command"].endswith("print('verification ok')\"") + assert Path(verification["stdout_path"]).read_text() == "verification ok\n" + assert Path(verification["stderr_path"]).read_text() == "" + + assert run_cli(["list", "verifications", "--task-id", task_id], capsys)[1]["verifications"] == [verification] + assert run_cli(["list", "verifications", "--run-id", run_id], capsys)[1]["verifications"] == [verification] + + +def test_verify_records_failed_command_without_crashing( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + _, run_id = create_completed_run(tmp_path, monkeypatch, capsys) + + exit_code, payload = run_cli( + [ + "verify", + "--run-id", + run_id, + "--command", + f'"{sys.executable}" -c "import sys; print(\'bad\'); sys.exit(3)"', + ], + capsys, + ) + + assert exit_code == 0 + verification = payload["verification"] + assert verification["status"] == "failed" + assert verification["exit_code"] == 3 + assert Path(verification["stdout_path"]).read_text() == "bad\n" + + +def test_packet_show_and_export_include_verification_and_unresolved_gaps( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + task_id, run_id = create_completed_run(tmp_path, monkeypatch, capsys) + assert run_cli( + [ + "verify", + "--run-id", + run_id, + "--command", + f'"{sys.executable}" -c "print(\'verification ok\')"', + ], + capsys, + )[0] == 0 + + exit_code, stdout, stderr = run_cli_text( + ["packet", "show", "--task-id", task_id, "--format", "markdown"], + capsys, + ) + + assert exit_code == 0 + assert stderr == "" + assert "# Patchrail Approval Packet" in stdout + assert "## Delivery Contract" in stdout + assert "## Verification" in stdout + assert "passed" in stdout + assert "No review recorded." in stdout + assert "No approval decision recorded." in stdout + + output_path = tmp_path / "packet.md" + exit_code, payload = run_cli( + ["packet", "export", "--task-id", task_id, "--output", str(output_path), "--format", "markdown"], + capsys, + ) + + assert exit_code == 0 + assert payload["output_path"] == str(output_path) + assert output_path.read_text() == stdout + + +def test_review_queue_groups_tasks_by_latest_verification_status( + tmp_path: Path, + monkeypatch, + capsys, +) -> None: + ready_task_id, ready_run_id = create_completed_run(tmp_path, monkeypatch, capsys) + assert run_cli( + [ + "verify", + "--run-id", + ready_run_id, + "--command", + f'"{sys.executable}" -c "print(\'ok\')"', + ], + capsys, + )[0] == 0 + + needs_task_id, _ = create_completed_run(tmp_path, monkeypatch, capsys) + failed_task_id, failed_run_id = create_completed_run(tmp_path, monkeypatch, capsys) + assert run_cli( + [ + "verify", + "--run-id", + failed_run_id, + "--command", + f'"{sys.executable}" -c "import sys; sys.exit(2)"', + ], + capsys, + )[0] == 0 + + exit_code, payload = run_cli(["list", "review-queue"], capsys) + + assert exit_code == 0 + queue = payload["review_queue"] + assert [task["id"] for task in queue["ready_for_review"]] == [ready_task_id] + assert [task["id"] for task in queue["needs_verification"]] == [needs_task_id] + assert [task["id"] for task in queue["failed_verification"]] == [failed_task_id] + assert queue["awaiting_approval"] == [] + assert queue["approved"] == []