From 54f6d4e2b637fb6f777f3fe94142ecab5d6a2338 Mon Sep 17 00:00:00 2001 From: DandreYang <13072547+Dandre126@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:10:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(=E4=BA=A4=E4=BB=98=E7=89=A9=E7=90=86):?= =?UTF-8?q?=20=E8=90=BD=E5=9C=B0=20Proof=E3=80=81Capability=20=E4=B8=8E?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 merge 对错保持 0.6 真值,并把便携核验收成「Proof Bundle + 调用方 git 对象」的完整性结论。 --- .github/workflows/ci.yml | 1 + .github/workflows/pypi-publish.yml | 6 + README.de.md | 6 +- README.es.md | 6 +- README.fr.md | 6 +- README.ko.md | 6 +- README.md | 6 +- README.pt-BR.md | 6 +- README.ru.md | 6 +- README.zh-CN.md | 6 +- ...6-delivery-physics-and-capability-plane.md | 73 ++ docs/architecture.md | 9 + docs/designs/delivery-physics.md | 458 +++++++++ ...livery-physics-adversarial-review-board.md | 804 ++++++++++++++++ ...implementation-adversarial-review-board.md | 904 ++++++++++++++++++ plans/delivery-physics-implementation.md | 361 +++++++ pyproject.toml | 5 +- src/dyro/capability/__init__.py | 35 + src/dyro/capability/cards.py | 134 +++ src/dyro/capability/models.py | 123 +++ src/dyro/capability/probe.py | 96 ++ src/dyro/capability/store.py | 84 ++ src/dyro/cli.py | 335 ++++++- src/dyro/config.py | 8 +- src/dyro/console/read_model.py | 5 + src/dyro/continuation/attention.py | 1 + src/dyro/continuation/models.py | 1 + src/dyro/continuation/planner.py | 15 +- src/dyro/continuation/snapshot.py | 12 + src/dyro/continuation/supervision.py | 4 + src/dyro/graph.py | 14 +- src/dyro/host/__init__.py | 43 + src/dyro/host/compile.py | 382 ++++++++ src/dyro/host/doctor.py | 203 ++++ src/dyro/host/models.py | 69 ++ src/dyro/observations.py | 3 + src/dyro/proof/__init__.py | 48 + src/dyro/proof/bundle.py | 340 +++++++ src/dyro/proof/decay.py | 113 +++ src/dyro/proof/derive.py | 568 +++++++++++ src/dyro/proof/evaluate.py | 197 ++++ src/dyro/proof/models.py | 157 +++ src/dyro/proof/project.py | 83 ++ src/dyro/tasks.py | 9 +- tests/test_capability.py | 204 ++++ tests/test_console_overview.py | 1 + tests/test_console_read_model.py | 17 + tests/test_host.py | 391 ++++++++ tests/test_proof_a1_boundary.py | 59 ++ tests/test_proof_bundle.py | 261 +++++ tests/test_proof_cli.py | 279 ++++++ tests/test_proof_decay.py | 379 ++++++++ tests/test_proof_derive.py | 256 +++++ tests/test_readme_identity.py | 32 + tests/test_release_gates.py | 20 + tests/test_terminology.py | 14 + tools/verify_bundle_stranger.py | 99 ++ tools/verify_release_gates.py | 78 ++ uv.lock | 98 +- 59 files changed, 7874 insertions(+), 65 deletions(-) create mode 100644 docs/adr/0006-delivery-physics-and-capability-plane.md create mode 100644 docs/designs/delivery-physics.md create mode 100644 docs/superpowers/reviews/2026-08-15-delivery-physics-adversarial-review-board.md create mode 100644 docs/superpowers/reviews/2026-08-15-delivery-physics-implementation-adversarial-review-board.md create mode 100644 plans/delivery-physics-implementation.md create mode 100644 src/dyro/capability/__init__.py create mode 100644 src/dyro/capability/cards.py create mode 100644 src/dyro/capability/models.py create mode 100644 src/dyro/capability/probe.py create mode 100644 src/dyro/capability/store.py create mode 100644 src/dyro/host/__init__.py create mode 100644 src/dyro/host/compile.py create mode 100644 src/dyro/host/doctor.py create mode 100644 src/dyro/host/models.py create mode 100644 src/dyro/proof/__init__.py create mode 100644 src/dyro/proof/bundle.py create mode 100644 src/dyro/proof/decay.py create mode 100644 src/dyro/proof/derive.py create mode 100644 src/dyro/proof/evaluate.py create mode 100644 src/dyro/proof/models.py create mode 100644 src/dyro/proof/project.py create mode 100644 tests/test_capability.py create mode 100644 tests/test_host.py create mode 100644 tests/test_proof_a1_boundary.py create mode 100644 tests/test_proof_bundle.py create mode 100644 tests/test_proof_cli.py create mode 100644 tests/test_proof_decay.py create mode 100644 tests/test_proof_derive.py create mode 100644 tests/test_readme_identity.py create mode 100644 tests/test_release_gates.py create mode 100644 tools/verify_bundle_stranger.py create mode 100644 tools/verify_release_gates.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1d885b..8f14a8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,7 @@ jobs: "$smoke_root/sdist-venv/bin/pip" install /tmp/dyro-dist/dyro-*.tar.gz "$smoke_root/sdist-venv/bin/python" -I -c "import dyro.continuation" "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" + "$smoke_root/sdist-venv/bin/python" "$GITHUB_WORKSPACE/tools/verify_bundle_stranger.py" "$smoke_root/sdist-venv/bin/dyro" windows-dispatch-import: name: Windows dispatch import / fail-closed smoke diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 7e15a0b..31c0994 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -70,6 +70,11 @@ jobs: raise SystemExit(f"release tag {actual!r} must equal {expected!r}") PY + - name: Refuse 1.0.0 without delivery-physics gates + env: + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + run: uv run python tools/verify_release_gates.py --release-tag "$RELEASE_TAG" + - name: Build distributions run: uv run python -m build @@ -90,6 +95,7 @@ jobs: "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/sdist-dispatch-home" "$smoke_root/sdist-venv/bin/dyro" dispatch doctor >"$smoke_root/sdist-doctor.json" uv run python -c "import json; json.load(open('$smoke_root/sdist-doctor.json'))" + "$smoke_root/sdist-venv/bin/python" "$GITHUB_WORKSPACE/tools/verify_bundle_stranger.py" "$smoke_root/sdist-venv/bin/dyro" - name: Validate distribution metadata run: uv run python -m twine check --strict dist/dyro-*.whl dist/dyro-*.tar.gz diff --git a/README.de.md b/README.de.md index 4f89368..e889076 100644 --- a/README.de.md +++ b/README.de.md @@ -4,9 +4,13 @@ **DyroEngineeringFlow · `dyro` CLI** ist eine local-first Plattform für Engineering-Automatisierung und Delivery-Steuerung in Multi-Repository-Teams. Sie vereint Entwicklungslinien, Git-Worktrees, Agent-Launcher, Task-Gates, unabhängige Reviews und Merge-Audit in einer versionierten Workspace-Konfiguration. +**Dyro ist eine local-first Delivery-Physik-Engine für mehrere Repositories: Sie entscheidet, was wahr werden kann, wann diese Wahrheit zerfällt, und wer die Welt ändern darf.** + +`dyro proof verify` bindet den aktuellen Workspace neu. `dyro proof verify-bundle` prüft nur portable Integrität: der Aufrufer muss jedes `--git-dir` mit den gepinnten Objekten übergeben (Vereinigungssuche, keine Repo-Map). Fehlende Objekte sind `inconclusive`. Integrity `live` ist nicht merge. + **Engineering von der Aufgabe bis zur Auslieferung in Bewegung halten.** -Nicht an Codex, Claude oder eine Fachdomäne gekoppelt. Jedes Team liefert ein `dyro.toml`-Profile für Repositories, Layouts, Agent-Adapter und Delivery-Policy; Geschäftsregeln, Modellkosten und Release-Praktiken bleiben im Profile. +Jedes Team liefert ein `dyro.toml`-Profile für Repositories, Layouts, Agent-Adapter und Delivery-Policy; Geschäftsregeln, Modellkosten und Release-Praktiken bleiben im Profile. ## Was erzwungen wird diff --git a/README.es.md b/README.es.md index c180d5b..53dfb6b 100644 --- a/README.es.md +++ b/README.es.md @@ -4,9 +4,13 @@ **DyroEngineeringFlow · `dyro` CLI** es una plataforma local-first de automatización de ingeniería y control de entrega para equipos multi-repositorio. Integra líneas de desarrollo, Git worktrees, lanzadores de agentes, puertas de tarea, revisión independiente y auditoría de merge en una configuración de workspace versionada. +**Dyro es un motor de física de entrega local-first para varios repositorios: decide qué puede volverse verdadero, cuándo decae esa verdad y quién puede cambiar el mundo.** + +`dyro proof verify` reenlaza el workspace actual. `dyro proof verify-bundle` solo comprueba integridad portable: el llamador debe pasar cada `--git-dir` que contenga los objetos fijados (búsqueda en unión, no un mapa de repos). Sin objetos el resultado es `inconclusive`. Integrity `live` no es merge. + **Mantén la ingeniería en movimiento de la tarea a la entrega.** -No está acoplado a Codex, Claude ni a ningún dominio de negocio. Cada equipo aporta un Profile `dyro.toml` para repositorios, layouts, adaptadores de agente y política de entrega; las reglas de negocio, el coste de modelos y las prácticas de release permanecen en ese Profile. +Cada equipo aporta un Profile `dyro.toml` para repositorios, layouts, adaptadores de agente y política de entrega; las reglas de negocio, el coste de modelos y las prácticas de release permanecen en ese Profile. ## Lo que impone diff --git a/README.fr.md b/README.fr.md index d7b81b3..193b9a6 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,9 +4,13 @@ **DyroEngineeringFlow · `dyro` CLI** est une plateforme local-first d'automatisation d'ingénierie et de contrôle de livraison pour les équipes multi-dépôts. Elle regroupe lignes de développement, Git worktrees, lanceurs d'agents, portes de tâches, revue indépendante et audit de fusion dans une configuration d'espace de travail versionnée. +**Dyro est un moteur de physique de livraison local-first pour dépôts multiples : il décide ce qui peut devenir vrai, quand cette vérité se dégrade, et qui peut changer le monde.** + +`dyro proof verify` relie l'espace de travail courant. `dyro proof verify-bundle` ne vérifie que l'intégrité portable : l'appelant doit fournir chaque `--git-dir` contenant les objets épinglés (recherche en union, pas une carte de dépôts). Sans objets le résultat est `inconclusive`. Integrity `live` n'est pas merge. + **Faire avancer l'ingénierie de la tâche à la livraison.** -Non couplé à Codex, Claude ou un domaine métier. Chaque équipe fournit un Profile `dyro.toml` pour les dépôts, layouts, adaptateurs d'agent et politique de livraison ; les règles métier, le coût des modèles et les pratiques de release restent dans ce Profile. +Chaque équipe fournit un Profile `dyro.toml` pour les dépôts, layouts, adaptateurs d'agent et politique de livraison ; les règles métier, le coût des modèles et les pratiques de release restent dans ce Profile. ## Ce qu'il impose diff --git a/README.ko.md b/README.ko.md index 1ead232..3734dea 100644 --- a/README.ko.md +++ b/README.ko.md @@ -4,9 +4,13 @@ **DyroEngineeringFlow · `dyro` CLI**는 여러 저장소를 사용하는 팀을 위한 로컬 우선 엔지니어링 자동화 및 배포 제어 플랫폼입니다. 개발 라인, Git worktree, Agent 실행, 작업 게이트, 독립 검토, 병합 감사를 버전 관리 가능한 워크스페이스 설정으로 통합합니다. +**Dyro는 로컬 우선 멀티 저장소 전달 물리 엔진입니다. 무엇이 참이 될 수 있는지, 그 참이 언제 감쇠하는지, 누가 세계를 바꿀 수 있는지를 결정합니다.** + +`dyro proof verify`는 현재 워크스페이스를 다시 묶습니다. `dyro proof verify-bundle`은 이식 가능한 무결성만 검사합니다. 호출자는 고정된 객체가 있는 모든 `--git-dir`을 넘겨야 합니다(합집합 검색, repo 맵 아님). 객체가 없으면 `inconclusive`입니다. 무결성 `live`는 merge가 아닙니다. + **작업에서 배포까지 엔지니어링이 계속 흐르게 합니다.** -Codex, Claude 또는 특정 비즈니스 도메인에 종속되지 않습니다. 각 팀은 `dyro.toml` Profile에서 저장소, 레이아웃, Agent adapter, 배포 정책을 정의하며, 비즈니스 규칙·모델 비용·릴리스 관행은 Profile에 둡니다. +각 팀은 `dyro.toml` Profile에서 저장소, 레이아웃, Agent adapter, 배포 정책을 정의하며, 비즈니스 규칙·모델 비용·릴리스 관행은 Profile에 둡니다. ## 강제하는 것 diff --git a/README.md b/README.md index a4d54a4..e5b01cd 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ **DyroEngineeringFlow · `dyro` CLI** is a local-first engineering automation and delivery control platform for multi-repository teams. It brings development lines, Git worktrees, agent launchers, task gates, independent review, and merge audit into versioned workspace configuration. +**Dyro is a local-first multi-repository delivery physics engine: it decides what can become true, when that truth decays, and who may change the world.** + +`dyro proof verify` rebinds the current workspace. `dyro proof verify-bundle` checks portable integrity only: the caller must pass every `--git-dir` that contains the pinned objects (union search, not a repo map). Missing objects are `inconclusive`. Integrity `live` is not merge. + **Keep engineering moving from task to delivery.** ![Dyro — multi-repository delivery control](docs/assets/dyro-github-cover-long-running-work-v5.png) -DyroEngineeringFlow is not coupled to Codex, Claude, or any business domain. Each team supplies a `dyro.toml` Profile for repositories, layouts, agent adapters, and delivery policy; business rules, model cost, and release practices stay in that Profile. +Each team supplies a `dyro.toml` Profile for repositories, layouts, agent adapters, and delivery policy; business rules, model cost, and release practices stay in that Profile. ## Local web Console diff --git a/README.pt-BR.md b/README.pt-BR.md index d2cefb3..3540fca 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -4,9 +4,13 @@ **DyroEngineeringFlow · `dyro` CLI** é uma plataforma local-first de automação de engenharia e controle de entrega para equipes multi-repositório. Une linhas de desenvolvimento, Git worktrees, lançadores de agentes, portões de tarefa, revisão independente e auditoria de merge em uma configuração de workspace versionada. +**Dyro é um motor de física de entrega local-first para vários repositórios: decide o que pode se tornar verdadeiro, quando essa verdade decai e quem pode mudar o mundo.** + +`dyro proof verify` religa o workspace atual. `dyro proof verify-bundle` verifica só a integridade portátil: o chamador deve passar cada `--git-dir` que contém os objetos fixados (busca em união, não um mapa de repos). Sem objetos o resultado é `inconclusive`. Integrity `live` não é merge. + **Manter a engenharia em movimento da tarefa à entrega.** -Não está acoplado a Codex, Claude ou a qualquer domínio de negócio. Cada equipe fornece um Profile `dyro.toml` para repositórios, layouts, adaptadores de agente e política de entrega; regras de negócio, custo de modelos e práticas de release ficam nesse Profile. +Cada equipe fornece um Profile `dyro.toml` para repositórios, layouts, adaptadores de agente e política de entrega; regras de negócio, custo de modelos e práticas de release ficam nesse Profile. ## O que impõe diff --git a/README.ru.md b/README.ru.md index 3ad89c6..fda166f 100644 --- a/README.ru.md +++ b/README.ru.md @@ -4,9 +4,13 @@ **DyroEngineeringFlow · `dyro` CLI** — local-first платформа автоматизации инженерии и контроля поставки для multi-repository команд. Она объединяет линии разработки, Git worktree, запуск агентов, gate задач, независимый review и аудит merge в версионируемой конфигурации workspace. +**Dyro — локальный движок физики поставки для нескольких репозиториев: он решает, что может стать истиной, когда эта истина затухает и кто может менять мир.** + +`dyro proof verify` заново привязывает текущий workspace. `dyro proof verify-bundle` проверяет только переносимую целостность: вызывающий должен передать каждый `--git-dir` с закреплёнными объектами (поиск по объединению, не карта репозиториев). Без объектов результат `inconclusive`. Integrity `live` — это не merge. + **Держать инженерию в движении от задачи до поставки.** -Не привязан к Codex, Claude или бизнес-домену. Каждая команда задаёт Profile `dyro.toml` для репозиториев, layout, адаптеров агентов и политики поставки; бизнес-правила, стоимость моделей и практики релиза остаются в Profile. +Каждая команда задаёт Profile `dyro.toml` для репозиториев, layout, адаптеров агентов и политики поставки; бизнес-правила, стоимость моделей и практики релиза остаются в Profile. ## Что обеспечивается diff --git a/README.zh-CN.md b/README.zh-CN.md index b735e72..544dc92 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,9 +4,13 @@ **DyroEngineeringFlow · `dyro` CLI** 是面向多仓团队的本地优先工程自动化与交付控制平台。它将开发线、Git worktree、Agent 启动、任务门禁、独立复核和合并审计统一到可版本化的工作区配置中。 +**Dyro 是本地优先的多仓交付物理引擎:决定什么能变成真,真值何时衰减,以及谁有权改世界。** + +`dyro proof verify` 重绑当前工作区。`dyro proof verify-bundle` 只核便携完整性:调用方必须传入包含已钉对象的每个 `--git-dir`(并集查找,不是仓库映射)。缺对象为 `inconclusive`。完整性 `live` 不是 merge。 + **让工程从任务到交付持续流转。** -DyroEngineeringFlow 不绑定 Codex、Claude 或任何业务领域。每个团队通过 `dyro.toml` Profile 定义仓库、目录布局、Agent adapter 与交付策略;业务规则、模型成本和发布实践始终留在各自的 Profile 中。 +每个团队通过 `dyro.toml` Profile 定义仓库、目录布局、Agent adapter 与交付策略;业务规则、模型成本和发布实践始终留在各自的 Profile 中。 ## 本地 Web 控制台 diff --git a/docs/adr/0006-delivery-physics-and-capability-plane.md b/docs/adr/0006-delivery-physics-and-capability-plane.md new file mode 100644 index 0000000..d49170d --- /dev/null +++ b/docs/adr/0006-delivery-physics-and-capability-plane.md @@ -0,0 +1,73 @@ +# ADR-0006:交付物理学、能力卡与宿主编译 + +- 状态:提案(2026-08-15);权威投影锁定为 B;衰减语义锁定为 A1;可携带核验锁定为 B1 +- 决策者:产品选择 B / A1 / B1 已写入本 ADR;其余条目仍待维护者确认合并 +- 关联: + - [交付物理学设计](../designs/delivery-physics.md) + - [实施计划](../../plans/delivery-physics-implementation.md) + - [架构](../architecture.md) + - [ADR-0002 派发](0002-optional-local-agent-dispatch.md) + - [ADR-0004 续航](0004-native-continuation-engine.md) + - [ADR-0005 Console](0005-local-web-console.md) + +## 背景 + +`0.6.0` 已经具备 TaskGraph、receipt-bound review、Objective 续航和只读 Console。行业同时出现三类热门形态:舰队 UI、提示词超市、工单驱动的隔离执行。 + +若把其中任何一类直接做进 Core,会重演「编排库 = 控制面」或「提示词仓库 = 产品」的错误,并让 Dyro 变成追随者。 + +真正的缺口不是「再启动一种 agent」,而是: + +1. 已有证据物理学是隐式的,不能被第三方复验,也不能被编译到宿主; +2. adapter 只有 argv,没有隔离证明、证明边界和意图格; +3. 宿主 skill 若靠手写,会过期、会列出用户没有的后端、会暗示宿主自己 merge。 + +## 决策 + +1. Dyro 的产品身份锁定为 **本地优先的多仓交付物理引擎**,不是 agent、不是舰队、不是 skill 超市。 +2. 抽出 **Proof Object** 作为已验证事实的统一投影。它不取代 `task.toml`、receipt、review 绑定或 Continuation journal。 +3. 每个 Proof 带 **衰减函数**。substrate 变化后事实死亡;不确定不得写成通过。`decay(review_verdict)` 全量等于 `_valid_review_acceptance`;`decay(signoff)` 全量等于 `_valid_external_signoff`。`SchedulerSnapshot` 只把 merge 相关的 `live` Proof 投影进已有进展字段,不计入 trigger;journal 不把 proofs 当 PASS。生产 `BudgetUsage` 在 `0.7` 不因 Proof 新开 no-progress 耗尽。 +4. 用 **Capability Card** 统一 agent / gate / reviewer / trigger / tool。`0.7` 仍只读 `[adapters.*]`;`0.8` 才运行时升级为 Card,缺省 `cannot_prove` 至少包含 `done` 与 `merge`。 +5. 增加 **Host Compiler**:把定律与本机可用 Card 编译为宿主投影(`SKILL.md` 与可选拦截文件)。编译器只收缩权威,不扩大权威。 +6. 所有 mutation 落入操作格 `observe | execute | review | sign | integrate | publish`。有效权威仍是策略 ∩ 合约 ∩ 租约 ∩ 任务权限 ∩ 图约束。 +7. 议题跟踪器若接入,只能作为 Trigger provider,不能成为交付原子,也不能完成 Task。 +8. **权威投影锁定为 B**:所有宿主必编译 skill / 规则;仅当宿主 Card 能证明拦截表面时,再投影由操作格编译的 deny hook。没有拦截表面不得拒绝 compile。Hook 不得宣传为 OS 隔离。详见设计第 8 节。 +9. **`0.7` 衰减锁定为 A1**:对 merge / 下游释放的接受与拒绝,必须与 `0.6.0` 现有绑定检查同真值。Proof 只提供投影与 `PROOF_DECAYED` reason code,不是第二套门。`merge_task` / `check_dispatchable` 不读 Proof store。下游只投影 `_assert_dependency_integrated`;decayed review 不加严 ready set。任务仓 dirty:`0.6` 已拒绝,`0.7` 保持拒绝,不放松、不叠门。开发线 dirty / 错分支保持 `_prepare_merge` 现有错,不得标成 `PROOF_DECAYED`。不把 `git revert` 当成祖先断裂。 +10. **`1.0` 可携带核验锁定为 B1**:`verify-bundle` 核验完整性,不核验身份,也不承诺与当前工作区 `proof verify` / `task merge` 同一套 `live` / `decayed`。输入是 Proof Bundle + 调用方提供的 git 对象。捆内不塞 git 对象库。缺 procedure、缺 substrate、缺 git 对象、或缺已声明的签名密钥 → `inconclusive`,不得写成 `live`。无 `--current-heads` 时不得报与 merge 相同的衰减结论。 + +## 否决项 + +- 把 dispatch、模型投票、视频、截图、Trigger 摘要升级为 Proof。 +- 把 issue 跟踪器或看板当作 TaskGraph 的真源。 +- 在 Core 内置角色技能包或提示词市场。 +- 给 Console 或编译后的宿主 skill 以 merge / push / signoff 实权。 +- 将 `cwd`、CLI 只读档或宿主拦截 hook 宣传为 OS 隔离。 +- 因宿主缺少拦截表面而拒绝 `host compile`。 +- 用命令名黑名单代替操作格来生成 deny hook。 +- 未审计命令自动获得 `execute` intent。 +- 在 Proof Bundle 中写入绝对路径、凭据、prompt、adapter 环境或 git 对象库。 +- 把 `0.7` 衰减做成与现有 merge / 下游检查不同真值的第二套门。 +- 把 `0.7` 写成「不拒绝任务仓 dirty」(那是放松 `0.6`,不是「不加严」)。 +- 按 decayed review 加严下游 ready set,或让 `merge_task` / `check_dispatchable` 读取 Proof store。 +- 把 `git revert` 宣传或实现成祖先断裂。 +- 把 `verify-bundle` 写成身份证明,或在缺签名密钥时返回 `live`。 +- 把 `verify-bundle` 的完整性结论写成与控制面当前 `live` / `decayed` 相同。 +- 把 `task evidence` ZIP 或 git 对象库当作 Proof Bundle。 +- 为对齐外部产品而复制其领域语言或角色剧场。 +- 在仓库内保存「我们学了谁 / 对标谁」的对照附录。 + +## 后果 + +- 产品叙事从「启动 agent」转为「核验完成」。 +- `0.7` 起增加 `dyro proof list/show/verify` 与衰减 reason code,不要求用户改 Task 清单。Console Proof 展示默认进 `0.8`。`export` 可在 `0.7` 以 experimental 提供;`verify-bundle` 硬门禁与 `schema_version = 1` 锁在 `1.0`。 +- `0.8` 起 adapter 配置向 Card 迁移,旧 Profile 仍可加载。 +- `0.9` 起宿主投影可重算、可 doctor;过期投影阻断自动 mutation。默认只写当前工作区;`--user` 才写用户级目录。`tools.json` / PATH 发现不是可执行 Card。 +- `1.0` 的对外承诺是:陌生人拿着 Proof Bundle 和自己提供的 git 对象,能得到与源机**相同的完整性结论**(字节仍在、钉死 SHA 可解析)。这不是身份证明,也不是「现在工作区还能 merge」。 +- 实施成本是新的投影层与兼容层,而不是第二套调度器。 + +## 兼容 + +- 无 Proof 命令的工作区行为与 `0.6.0` 相同。`0.7` 的 `task merge` 与下游释放对错不变;只多可查询的 Proof 与 `PROOF_DECAYED` 人话。任务仓 dirty 在 `0.6` 已拒绝,`0.7` 保持。 +- 历史 review 仍按既有绑定字段核验;`proof list` / `verify` **每次**从现有文件重派生,不改写原件。缺绑定字段 → `inconclusive`,不伪造 `live`。store 可重建,不是展示真源。 +- Proof `contract_hash` 按 subject 拆:task 面 kind 用 attempt `task_contract_sha256`(缺则空);`action_receipt` 用 Objective `contract_sha256`。不把两个合约哈希混成一个字段。 +- `v0.6.x` 只接受与本 ADR 不冲突的维护修复,不提前合并 Host Compiler。 diff --git a/docs/architecture.md b/docs/architecture.md index 151bc69..539786d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -215,6 +215,13 @@ Ed25519 信任根位于 `.dyro/trust/ed25519/execution/`、`.dyro/trust/ed25519/ 11. Change Set 只记录干净开发线的精确提交组合;`changeset verify` 会拒绝 dirty、分支或 HEAD 漂移。具体发布平台、promotion 与 forward-port 由 Profile 扩展执行并回写其证据。 12. 下游调度不仅要求依赖任务为 `done`,还要求依赖的逐仓 task HEAD 已进入所属开发线;状态完成不能代替代码集成。 13. 外部执行的后续 attempt 必须继承同一 run、递增 attempt number,并绑定前一 attempt 与回答摘要;同一编号不可被不同证据重写。 +14. 任何界面或宿主投影不得把 decayed / inconclusive Proof 显示为通过。 +15. Capability Card 缺少 `cannot_prove` 时,默认至少包含 `done` 与 `merge`。 +16. Host Compiler 的输出哈希必须可重算;手改投影在 doctor 中失败,不在运行时「尽量兼容」。 +17. Proof Bundle 不得包含工作区绝对路径、remote URL 中的凭据、adapter 环境、prompt 或 answer。 +18. `verify-bundle` 在缺 procedure、缺 substrate、缺调用方 git 对象、或缺已声明的签名密钥时返回 `inconclusive`,退出码与 `live` 区分。捆内不塞 git 对象。该 `live` 是完整性结论,不是「现在能否 merge」。 +19. 发现到的未审计命令不得写入可执行 Card,也不得被 Objective 自动选中。 +20. 密钥缺席:Card 不得声明看起来像秘密的环境变量名;需要认证的工具使用用户已登录的本机 CLI 会话,或独立的本机经纪,不把 token 写进 Profile。 ## 与 Graph Engineering 的关系(可选读) @@ -234,4 +241,6 @@ Dyro 的交付拓扑与之**实质相近**:TaskGraph(`depends_on` / conflict 未来的 adapter、通知、签名规则、发布平台与审批系统应使用 Python entry point 或独立 Profile 扩展包接入;不要把某个组织的策略加入 core 默认行为。 +`0.7` 起先把已有证据物理学抽成可复验的 Proof(衰减与现有 merge / 下游检查同真值;`proof verify` 看当前工作区,`verify-bundle` 只核完整性,两套结论不得混称)。`0.8` 再把 argv adapter 升级为 Capability Card,并做 Console Proof 只读展示。`0.9` 把定律编译为只收缩权威的宿主投影。`1.0` 的可携带核验是 Proof Bundle 加调用方提供的 git 对象,核验完整性而不是身份,也不承诺与当前 merge 同一套 `live`。这不另造 TaskGraph 或完成状态机;见 [`交付物理学`](designs/delivery-physics.md) 与 [`ADR-0006`](adr/0006-delivery-physics-and-capability-plane.md)。 + 开发者侧的可选本地多 Agent 派发(五段式任务契约、注入前机密守卫、locator 核验、隔离 patch)与上述控制面分层并列,随 `dyro` 安装包分发(`dyro dispatch` / `import experiments.local_agent_dispatch`),但**不**替代 gates/合并;见 [`ADR-0002`](adr/0002-optional-local-agent-dispatch.md)、[`多智能体编排纪律`](agent-orchestration-discipline.md) 与 [`可选本地 Agent 派发设计`](designs/optional-local-agent-dispatch.md)。 diff --git a/docs/designs/delivery-physics.md b/docs/designs/delivery-physics.md new file mode 100644 index 0000000..9361b8f --- /dev/null +++ b/docs/designs/delivery-physics.md @@ -0,0 +1,458 @@ +# Dyro 交付物理学与能力平面 + +状态:提案;2026-08-15 锁定 A1 / B1 +目标版本:`0.7.0` 起分阶段落地,`1.0.0` 收口产品身份 +适用范围:Dyro Core、Profile、Host Compiler、Witness;不改写已发布的 TaskGraph / Objective / Console 权威语义 + +已锁定: + +- **A1**:`0.7` 衰减是现有 merge / 下游绑定检查的投影。接受与拒绝与 `0.6.0` **同真值**,只多 `PROOF_DECAYED`。任务仓 dirty:`0.6` 已拒绝(`_collect_task_heads`),`0.7` 保持拒绝;不叠第二道 Proof 门,也不放松。不把 `git revert` 当祖先断裂。 +- **B1**:`1.0` 的 `verify-bundle` 核验完整性:Proof Bundle + **调用方提供的** git 对象。捆内不塞对象库。核验完整性,不核验身份,**不承诺**与当前工作区 `proof verify` / `task merge` 得到同一套 `live` / `decayed`。缺 procedure、缺 substrate、缺 git 对象、或缺已声明的签名密钥 → `inconclusive`。 + +关联: + +- [架构与 Profile 契约](../architecture.md) +- [ADR-0004 续航引擎](../adr/0004-native-continuation-engine.md) +- [ADR-0006 交付物理学](../adr/0006-delivery-physics-and-capability-plane.md) +- [实施计划](../../plans/delivery-physics-implementation.md) + +--- + +## 0. 一句话 + +**Dyro 是本地优先的多仓交付物理引擎:决定什么能变成真,真值何时衰减,以及谁有权改世界。** + +完成是外部可观察的物理事实,不是对话里的承诺。 + +--- + +## 1. 产品判断 + +### 1.1 我们已经赢在哪一层 + +到 `0.6.0`,Dyro 已经不是「任务列表 + 启动器」: + +- 交付原子是 **开发线**,不是仓库,也不是 issue。 +- 任务属于且只属于一条线;下游只在精确 HEAD 进入该线后才被释放。 +- Agent 自报不是证据;gate、receipt、review、signoff、merge 是私有流程。 +- Objective 在时间上续航,但不复制 Task,不另造 backlog。 +- Home / Console 只有导航和注意力,没有交付权。 +- dispatch 只产建议;Witness 让删除和改写可被发现。 + +这些不是功能清单,是一套已经在跑的**物理学**。行业里大多数热门项目还停在「把更多 agent 塞进更多 worktree」。 + +### 1.2 我们还没有赢的原因 + +物理学是隐式的。它散落在 `review.md` 绑定、`task-heads.json`、attempt、ledger、以及每次重建的 `SchedulerSnapshot` 里。外人看到的是 CLI,看不到定律。隐式定律无法被引用、无法被第三方复验、无法被编译到宿主上。**第一名属于能把定律说清楚、并让别人按同一套定律核验的人。** + +### 1.3 我们拒绝的跟跑形态 + +| 形态 | 为什么是跟跑 | Dyro 的落点 | +| --- | --- | --- | +| 舰队 UI | 把并行会话当产品。谁的界面更能并排开窗口,谁也没有交付真理。 | Console 只读;并行来自 ready set 与冲突组。 | +| 提示词超市 | 内容过时、膨胀、污染上下文。Core 若变成提示词仓库,就失去机制身份。 | 必经门在 Core;宿主只拿收缩后的投影。 | +| 工单即真源 | 管理工作是对的;把议题跟踪器当成原子是错的。 | 议题若接入,只是 Trigger,不是 Line,也不是 Proof。 | +| 角色剧场 | 拟人角色掩盖「一个冲突组只有一个写入者」。 | 只用 line、task、proof、review、signoff。 | +| 模型编排 | 协调环里放 LLM,计划不可复放。 | 调度是纯函数;协调环无模型。 | +| 第二份计划图 | 另造一份工作流清单,与 TaskGraph 双写漂移。 | `task.toml` 与编译后的 TaskGraph 是唯一交付图。 | +| 粘滞的完成故事 | 视频、摘要、状态字被当成交接物。 | Proof 带核验程序,且会衰减;不确定不得写成通过。 | + +--- + +## 2. 四条定律(架构灵魂) + +定律是 Core 不变量。Profile 不能放宽它们。Host Compiler 只能把它们投影得更窄。 + +### 定律 I · 外部真值 + +一句话在被独立程序、针对钉死的 substrate 复现之前,不是事实。 + +- Agent 文本、多模型投票、Trigger 摘要、Console 展示,都是声称。 +- 事实必须带 **核验程序**(Core 内置或 Profile argv)和 **substrate 绑定**(HEAD、plan hash、attempt、合约哈希)。 +- 不确定就是不确定。禁止把 missing / unparseable / 环境不足升级为通过。 +- 默认 `dyro proof verify` 是 **rebind**(衰减 + 绑定重算),不是 replay。它的 `live` 表示当前工作区 substrate 上绑定仍成立,**不是** procedure 已复现。`gate_log` 未加 `--rerun-procedure` 时,不得叙事成「门禁仍通过」或输出 `procedure_reproduced=true`。 + +### 定律 II · 衰减 + +真值不粘滞。substrate 一动,事实死亡。状态字符串不得比它的绑定活得更久。 + +衰减的**展示**与 merge / 下游的**拒绝**不是同一句话。哪条影响哪扇门,必须与 `0.6.0` 源码同构: + +- `review_verdict` 在 `_valid_review_acceptance` 为假时失效:receipt SHA、`task-heads.json` SHA、`attempt_id` / `plan_sha256` 绑定、以及 local 下 `_assert_task_heads_current`(含任务仓 porcelain dirty)。这是 **merge / review-acceptance** 路径。 +- `signoff` 在 `_valid_external_signoff` 为假时失效(再绑 review / heads / attempt / plan;策略开启时含 Ed25519)。 +- 开发线 dirty 与错分支由 `_prepare_merge` 硬编码拒绝。`policy.require_clean_merge` 只能为 true,是 schema 不变量,不是运行时开关。这两类错误**不得**标成 `PROOF_DECAYED`。 +- 下游释放只投影 `_assert_dependency_integrated`(`git merge-base --is-ancestor`)。decayed review、任务仓 dirty、开发线 dirty **都不**加严 ready set。 +- `gate_log` 在 gate argv 哈希或被测树内容哈希变化后**展示**为 `decayed`。`0.7` merge **不**因这条新拒绝;现有 merge 本来就不重跑 gate。 +- Trigger 观察有 TTL(`0.8+` 才派生);过期只唤醒规划,不解除依赖,也不进入 `progress_fingerprint`。 +- Objective 在 Task 合约或依赖闭包漂移后必须 reconcile,才能再 mutation。 + +衰减不是 cron。衰减是证据上的熵。续航引擎的时钟首先用来**宣布死亡**,其次才用来唤醒。展示衰减 ≠ 新的 merge 拒绝。 + +### 定律 III · 单一写入者 + +一个 `conflict_group` 里恰好有一个写入者。其余角色是见证者或导航者。 + +- 并行来自 ready set 与冲突组,不来自「再开五个 agent」。 +- dispatch、Witness、Console 都不是写入者。 +- review adapter **不是开发线或任务源码的写入者**;它必须能写 `review.md`。 +- 宿主 agent 可以把 `dyro next` 的命令读给人看;它不能扩大命令的权限。 + +### 定律 IV · 编译后的权威 + +Agent 拿到的是法律的投影,不是法律的钥匙。编译器可以收缩权威,绝不能扩大。 + +- Core 拥有 mutation。 +- Host Compiler 生成的 skill / hook / `AGENTS.md` 只能观察,或打印已经过权威交集批准的下一条命令。 +- 投影里出现 `merge` / `push` / `signoff` 字样,不等于宿主获得了这些权。权仍在 CLI 的确认与策略交集里。 + +--- + +## 3. 分层:物理、能力、投影 + +```text + ┌─ Home / Console ──────────┐ + │ 导航 · 注意力 · 零交付权 │ + └────────────▲──────────────┘ + │ 只读投影 +┌─ Host Compiler ──────────────────────────────────┐ +│ 把定律编译成 SKILL.md / hooks / AGENTS.md │ +│ 只含本机可用能力 · 只收缩不扩张 │ +└────────────────────────▲─────────────────────────┘ + │ Capability Card +┌─ Capability Plane ───────────────────────────────┐ +│ Agent · Gate · Reviewer · Trigger · Tool │ +│ 声明能做什么、不能证明什么、能证明何种隔离 │ +└────────────────────────▲─────────────────────────┘ + │ Proof Object +┌─ Core Physics ───────────────────────────────────┐ +│ Line · Task · Attempt · Proof · Decay · Integrate│ +│ Objective / Snapshot / Plan / Lease(已落地) │ +└────────────────────────▲─────────────────────────┘ + │ + Profile(团队法律,不进 Core) +``` + +三层禁止塌缩: + +| 层 | 可以 | 禁止 | +| --- | --- | --- | +| Core Physics | 判定真值、衰减、集成、授权交集 | 内嵌客户仓库名、模型价、业务检查单 | +| Capability Plane | 描述本机能力与证明边界 | 把「已发现的 CLI」自动升级为可执行 adapter | +| Host Compiler | 投影定律到宿主 | 给宿主 merge/push/signoff 实权 | +| Home / Console | 让人看见下一件该做的事 | 成为第二份状态库或调度器 | + +--- + +## 4. 新原语 + +这些原语**抽取**已有隐式结构,不另造平行真源。`task.toml`、compiled TaskGraph、receipt、review 绑定、以及每次重建的 `SchedulerSnapshot` 仍然是权威。`ContinuationSnapshot` 是未实例化的死类型,不是采样或 journal 真源。 + +### 4.1 Proof Object(证明物) + +Proof 是带衰减函数的、可哈希寻址的事实记录。它不是又一份 `review.md`,而是所有已验证事实的统一投影。 + +```text +Proof + id 稳定 ID(kind + subject + generation + 无时钟身份载荷) + kind 见下表;0.7 只派生标了「0.7」的五种 + subject task_id | line_id | changeset_id | objective_id + substrate repo heads · plan_sha256 · attempt_id · contract_hash + procedure 可复现的核验程序(内置或 argv) + bytes_sha256 被核验字节的哈希 + produced_at 只取记录内字段;缺则空。禁止 mtime / 「现在」 + declared_key_ids 导出时已声明的签名密钥 ID;未声明则为空 + policy_require_signed 导出时的 require_signed_review / require_signed_signoff 快照 + decay 见 4.2 + status live | decayed | inconclusive | revoked +``` + +`generation` 使用已有证据世代 ID(或本地 attempt 世代)。身份哈希不含 `produced_at`、mtime 或「现在」。`produced_at` 只取记录内已有字段:`signoff.json` 的 `signed_at`、signed review JSON 的 `created_at`、action receipt 的 `created_at`。`review.md`、receipt、gate 日志、`integration_heads` 无记录内时间 → 空,不伪造,也不把文件系统 mtime 写进身份。 + +`contract_hash` 按 subject 拆,已锁定:task 面 kind(`gate_log` / `review_verdict` / `signoff` / `integration_heads`)用 attempt 的 `task_contract_sha256`(缺则该字段空,不得伪造);`action_receipt` 用 Objective 的 `contract_sha256`。`proof list` / `verify` 每次全量重派生;store 只是可丢弃缓存,不是展示真源。 + +映射到现状: + +| 已有物 | Proof kind | 列车 | +| --- | --- | --- | +| 编排器重跑的 gate 日志 + receipt | `gate_log` | 0.7 派生 | +| `review.md` + receipt/heads/attempt/plan 绑定 | `review_verdict` | 0.7 派生 | +| `signoff.json` | `signoff` | 0.7 派生 | +| 依赖 HEAD 已是线 HEAD 祖先 | `integration_heads` | 0.7 派生 | +| Continuation Action receipt | `action_receipt` | 0.7 派生;不进 `proof list --task` | +| 外部 evidence ZIP 世代 | `external_bundle` | 已有证据包的投影,不是 P6 Proof Bundle 的别名 | +| TriggerObservation | `trigger_observation` | 0.8+;字段跟 `next_probe_at`,不发明 `valid_until` | + +源路径、id 公式、substrate 与 `produced_at` 规则见实施计划**附录 A**。 + +产品命令: + +```bash +dyro proof list --task API-101 +dyro proof list --objective OBJ-1 +dyro proof show +dyro proof verify +dyro proof export --bundle /tmp/API-101.proof.zip +dyro proof export --task API-101 --bundle /tmp/API-101.proof.zip +dyro proof verify-bundle /tmp/API-101.proof.zip --git-dir /path/to/objects +``` + +`export` 的位置参数是 **proof-id**。按任务批量导出必须用 `--task`,与位置参数互斥。`proof list --task` **不含** `action_receipt`(它在 Objective `action-receipts/`);要列 receipt 用 `--objective`。 + +两条核验命令、两套结论: + +| 命令 | substrate | 结论含义 | +| --- | --- | --- | +| `proof verify` | **当前工作区** | `decay(proof, current_workspace_substrate)`。默认 rebind,不重跑 gate argv。 | +| `verify-bundle` | 捆内**钉死**的 substrate + `--git-dir` | **完整性**:字节哈希 + 钉死 SHA 在调用方对象库可解析。 | + +`verify` 的 `--rerun-procedure` 才重跑,且必须 dry-run 或隔离。`verify-bundle` 无 `--current-heads` 时不得报与 merge 相同的衰减结论;缺省只能得出完整性意义上的 `live` 或 `inconclusive`,不能假装「现在还能 merge」。捆内不塞 git 对象,不含绝对路径、凭据、adapter env、prompt。拒绝把 `task evidence build` 的 ZIP 布局当成 Proof Bundle。缺 procedure、缺 substrate、缺调用方 git 对象、或缺已声明的签名密钥(导出时 `policy_require_signed=true` 且 `declared_key_ids` 为空)→ `inconclusive`,不得写成 `live`。这不是身份证明;Ed25519 信任根仍在现有 trust store。 + +视频、agent 摘要、多模型「都觉得可以」**不是** Proof kind。它们最多进入 dispatch 的建议信封。 + +### 4.2 Decay Clock(衰减钟) + +每个 live Proof 带一个纯函数: + +```text +decay(proof, current_substrate, clock) -> live | decayed | inconclusive +``` + +规则(Core 固定,Profile 只能加严)。**0.7 对 merge / 下游释放的接受与拒绝,必须与 `0.6.0` 现有绑定检查同真值**(A1)。衰减是这些检查的投影和 reason code,不是第二套门。`merge_task` 与 `check_dispatchable` **不**读取 Proof store。 + +谓词必须全量投影,禁止只比「task HEAD ≠ 绑定 HEAD」: + +| kind | `live` 当且仅当 | `decayed` | `inconclusive` | +| --- | --- | --- | --- | +| `review_verdict` | `_valid_review_acceptance` 为真 | 绑定/HEAD/哈希变了,或 local 任务仓 dirty(已在 `_collect_task_heads`) | 缺 `review.md` / receipt / `task-heads.json`,或不可解析 | +| `signoff` | `_valid_external_signoff` 为真 | review / heads / attempt / plan 失绑,或策略开启时签名失效 | 缺 `signoff.json`、缺工具、缺已声明密钥 | +| `gate_log` | argv 哈希与被测树内容哈希仍匹配(**仅展示**) | 上述哈希变化(**仅展示**;merge 不新拒绝) | 缺日志 / 缺 generation | +| `integration_heads` | `_assert_dependency_integrated` 为真 | 线 HEAD 不再是证明 commit 的后代(reset / 换历史) | 缺 git / 缺 `task-heads.json` | +| `action_receipt` | 对应 receipt 字节与 journal 字段仍在 | 字段或世代被替换 | 缺文件 | + +补充: + +1. 开发线 dirty / 错分支保持 `_prepare_merge` 现有错。禁止标成 `PROOF_DECAYED`。`require_clean_merge` 只是加载期不变量。 +2. `0.6` **已经**拒绝任务仓 dirty;`0.7` 保持。Proof 可把该失败投影为 `review_verdict` 的 `decayed` / `inconclusive`,不得改为 accept,也不得再叠第二道门。 +3. `git revert` 仍留下后代提交,`integration_heads` **不**因此衰减。 +4. `trigger_observation`:`0.8+` 才派生。若派生,用现有 `next_probe_at`,只影响唤醒,不影响完成,也不进入 `progress_fingerprint`。 +5. 用户或策略显式撤销 → `revoked`。这不是 `decay()` 的返回值。 + +planner 在构造 **`SchedulerSnapshot`** 时评估衰减(不是未使用的 `ContinuationSnapshot`)。`progress_fingerprint` 的纯函数契约继续忽略 trigger;该函数已锁,但生产 `_budget_usage` **尚未**接线 `decide_no_progress`。`0.7` 不把 Proof 接入生产 `BudgetUsage`,不新开 no-progress 自动耗尽。merge 相关 live Proof 若投影,只进已有 `effective_evidence` / `integration_heads`,不并排再加一层。 + +`PROOF_DECAYED` **仅当**对应 Proof 从 `live` → `decayed` 且该衰减挡住的是 **merge** 人话。不得用它命名线 dirty / 错分支 / push 失败,也不得用它 block 下游 ready set。状态字段本身仍不是放行证据。 + +别人把「证明」写成交接故事;我们的 Proof 会过期。 + +### 4.3 Capability Card(能力卡) + +今天的 `[adapters.codex]` 只有 argv。本机发现的 `opencode` / `cursor-agent` 若未审计,进不了执行面。Capability Card 统一描述**任何可被 Core 调用或拒绝的能力**。 + +```toml +[[capabilities]] +id = "codex" +kind = "agent" +preset = "codex" + +launch = ["codex", "-C", "{workspace}"] +read = ["codex", "exec", "--sandbox", "workspace-write", "{prompt}"] +write = ["codex", "exec", "--sandbox", "workspace-write", "{prompt}"] + +attested_isolation = "cwd" # none | cwd | worktree | os_sandbox | external_runner +trusted_usage = false # 不能证明用量则禁止硬限额自动跑 +can_prove = [] # 只能填 Proof kind;空表示输出不能当完成证据 +cannot_prove = ["done", "merge", "security", "product_acceptance"] +intents = ["observe", "execute"] +hosts = ["cli"] # cli = Dyro 启动的 adapter;不是宿主 skill 目录 +``` + +字段语义: + +| 字段 | 含义 | +| --- | --- | +| `kind` | `agent` / `gate` / `reviewer` / `trigger` / `tool` | +| `attested_isolation` | 能力**自称且可被 doctor 探测**的隔离上限。`cwd` 不是 OS 隔离。`strict` dispatch 仍要求 `os_sandbox` 或 `external_runner`。 | +| `can_prove` | 它的输出里,哪些可以变成 Proof。只填 Proof kind,不填 dispatch 词汇。 | +| `cannot_prove` | 即使它写了「已完成」,Core 也不得采信。 | +| `intents` | 它可请求的操作格:`observe` `execute` `review` `sign` `integrate` `publish`。 | +| `trusted_usage` | 是否能返回可核验用量。false 时 hard-limit 自动执行 fail-closed。 | +| `hosts` | 允许被编译到哪些宿主表面。 | + +兼容:`0.7` 仍只读 `[adapters.*]`,不解析 `[[capabilities]]`。`0.8` 才运行时升级为 Card,缺省 `cannot_prove = ["done","merge"]`,`attested_isolation = "cwd"`。`dyro agent add` 在 0.8 继续工作,内部写 Card。 + +未审计的本机命令可以出现在 `dyro tool list` 和 Host Compiler 的「已发现未集成」区,**不能**获得 `execute` intent。 + +### 4.4 Host Compiler(宿主编译器) + +常见编译器让 agent **更能干**。我们的编译器让 agent **更听话、更小、更不容易越权**。产品面叫宿主投影:给已审计宿主编译出 Skills 与可选 deny hook。 + +输入: + +- 当前工作区的 live Capability Cards; +- 本机探测结果(已安装 / 已登录 / 未集成); +- 用户路由偏好(不得指向未集成后端); +- 四条定律的固定投影文本。 + +输出(按宿主 Card 声明的目录,原子替换): + +- `SKILL.md`:YAML 头(`name`、`description`,description 含负例)+ 何时调用 `dyro` + 禁止事项; +- 可选宿主规则片段:只含「交付以 Dyro 为准」和本机可用能力表;路径由 Card 声明,不在 Core 写死; +- 当宿主 Card 能证明拦截表面时,再投影一份由操作格编译出的 deny hook(见第 8 节)。 + +硬规则: + +1. 只渲染本机可用且已审计的 Card。 +2. 负例必须出现在 description 里(「不要用 git merge 结束任务」「不要把测试通过写成 done」)。 +3. 编译产物不含绝对路径、remote、凭据、adapter env。 +4. 编译器哈希写入 `.dyro/host-projections/.toml`;宿主文件被手改后 `dyro host doctor` fail-closed。 +5. 投影过期(Card 变更、工具消失、策略收紧)后,下一次 `objective` mutation 前必须重编译或显式跳过并记录 attention。 + +```bash +dyro host compile +dyro host status +dyro host doctor +``` + +Skill 健康检查并入 `dyro host doctor`:能发现、能拒绝过期投影,不当作「装得越多越好」。默认只写**当前工作区**下的投影目录。写用户级目录(例如 Codex 的 home skills)必须显式 `--user`,并在 doctor 标 `scope=user`。 + +--- + +## 5. 操作格(Intent Lattice) + +所有写动作落入六格。Capability、Objective、ActivationLease、Task 合约、工作区策略的交集决定有效格。 + +```text +observe → status / graph / proof show / console +execute → task run / dispatch(建议) +review → 独立主体,绑定 live Proof +sign → 外部签收;执行主体不可签自己 +integrate → 事务式本地 merge;仍走 `_valid_review_acceptance`,不读 Proof store +publish → push / 发布;第一版仍显式,且默认关 +``` + +这不是命令名黑名单,也不是 prompt 里的权限档。它是 **Core 状态机的对外语言**。Host Compiler 只能把宿主放到 `observe`,外加「打印一条已批准的用户命令」。 + +--- + +## 6. 与已落地子系统的关系 + +| 子系统 | 保持 | 本设计增加 | +| --- | --- | --- | +| TaskGraph / 状态机 | 唯一交付图 | Proof 投影;0.7 衰减与现有 merge / 祖先检查同真值,只多 reason code | +| Objective / Continuation | 快照、计划、租约、预算 | `SchedulerSnapshot` 纳入 live/decayed Proof 投影;reason code `PROOF_DECAYED`(attention / 人话,默认不 block 下游)。journal 不存 proofs 当 PASS | +| dispatch | 建议、locator、租约 | Card 的 `attested_isolation` 替代口头 strict | +| Console / Home | 只读;summary 零新 git I/O | `0.8` 起展示 Proof 状态与衰减原因,不展示 argv/路径。`0.7` 用 `dyro proof list` 与 `dyro objective attention` | +| Witness | 追加哈希链 | Proof export 与 ledger 事件对齐;不把 Witness 当完成证据 | +| Blueprint / join | SHA 钉死的线 | 新队友得到的投影由本机 Card 编译,不携带源机工具清单 | +| Tool catalog | 打开工作区 ≠ 执行权 | 发现结果喂给 Compiler,不喂给 scheduler | + +不新增第二份 backlog、第二份图、第二份完成状态机。 + +--- + +## 7. 用户体验 + +默认路径仍然是少概念: + +```text +dyro 看见线、目标、一条安全下一步 +dyro next 打印唯一安全命令 +dyro continue 按租约推进(已有) +dyro proof verify 需要争辩「到底做没做完」时 +dyro host compile 换机器或换工具后 +``` + +新人不必先懂 Proof 或 Card。他们撞上衰减时,attention 说的是人话:「复核已经失效,因为 api 的 HEAD 动了。下一步:重新复核,不要合并。」 + +专家路径: + +```bash +dyro proof export --bundle ./API-101.proof.zip +dyro proof export --task API-101 --bundle ./API-101.proof.zip +dyro objective attention +dyro capability test codex +dyro host doctor --format json +``` + +--- + +## 8. 权威投影:锁定为 B + +已选定 **skill 必编译,hook 按宿主能力可选投影**。不再保留 A/C 作为并行实现。 + +### 为什么不是 A + +A 诚实,但 fort 只建了一半。宿主若直接 `git merge` 进开发线 worktree,Core 的 merge 预检能发现脏状态,可线已经被改写。定律 IV 要求编译器在能收缩权威的地方收缩;只写负例是把已知的机械漏洞重新交给 prompt。 + +### 为什么不是 C + +C 看起来最严,其实是假完美。 + +1. Hook 不是 OS 边界。换路径、换包装脚本、走 IDE 内部 API,都能绕过。把 compile 建立在 hook 上,等于把「拦截点」宣传成隔离证明,和「`cwd` 不是沙箱」是同一类错误。 +2. 强制 hook 会把无拦截表面的宿主逐出投影,Dyro 会绑死在少数几家工具上,变成跟跑者。 +3. 不支持 hook 的宿主仍需要定律投影。拒绝 compile 等于把它们推向手写、过期、越权的 skill。 + +### B 的精确形状 + +`project_host_authority()` 只做这一条: + +1. **所有宿主**都编译 skill / 规则投影(定律、负例、本机可用表、只打印已批准的 `dyro` 命令)。 +2. **仅当**该宿主的 Capability Card 声明并被 `capability test` 证明存在 hook 表面时,再编译一份 deny hook。 +3. Deny 清单从操作格编译,不从命令名黑名单手写:拦截未获授权的 `integrate` / `publish`,以及直接改 `.dyro/`。具体 argv 是投影,意图格才是真源。 +4. 没有 hook 的宿主:`host compile` 仍成功,`host status` 标记 `authority_projection=skill_only`,doctor 不因此失败。 +5. 曾经编译过 hook 的宿主,hook 文件被删或哈希漂移:`host doctor` fail-closed,自动 mutation 降为 plan-only。 +6. 对外文案禁止把 hook 说成沙箱、隔离或「已阻止越权」。它是座椅安全带,不是车身。 + +Core 仍是唯一 mutation 权。Hook 挡不住的越权,仍由 dirty / HEAD 漂移 / decayed Proof 在交付门失败。 + +--- + +## 9. 安全不变量(本设计新增) + +在架构文档既有 13 条之上增加: + +14. 任何界面或宿主投影不得把 decayed / inconclusive Proof 显示为通过。 +15. Capability Card 缺少 `cannot_prove` 时,默认至少包含 `done` 与 `merge`。 +16. Host Compiler 的输出哈希必须可重算;手改投影在 doctor 中失败,不在运行时「尽量兼容」。 +17. Proof Bundle 不得包含工作区绝对路径、remote URL 中的凭据、adapter 环境、prompt 或 answer。 +18. `verify-bundle` 在缺 procedure、缺 substrate、缺调用方 git 对象、或缺已声明的签名密钥时返回 `inconclusive`,退出码与 `live` 区分。捆内不塞 git 对象。该 `live` 是完整性结论,不是「现在能否 merge」。 +19. 发现到的未审计命令不得写入可执行 Card,也不得被 Objective 自动选中。 +20. 密钥缺席:Card 不得声明看起来像秘密的环境变量名;需要认证的工具使用用户已登录的本机 CLI 会话,或独立的本机经纪,不把 token 写进 Profile。 + +--- + +## 10. 非目标 + +- 不把议题跟踪器做成 Core。它们若出现,只能是 Trigger provider,且观察结果不能完成 Task。 +- 不实现提示词市场、角色包、或把技能数量当产品。 +- 不实现舰队桌面 IDE、手机遥控、浏览器写权。 +- 不把外部工具协议提升为控制协议。工具协议只连工具;完成权留在 CLI。 +- 不在第一阶段自动 push、自动发布、自动创建远端仓库。 +- 不把 dispatch 结果、模型共识、视频、截图升级为 Proof。 +- 不把容器或云沙箱做成 Core 依赖。隔离后端继续走 Card 声明与 entry point。 +- 不在仓库内保存「我们学了谁 / 对标谁」的对照附录。反模式用机制描述即可。 +- 不把 `git revert` 当成祖先断裂。祖先检查只回答「提交是否仍在历史上」。 +- 不把 Proof Bundle 做成自含 git 对象库。1.0 核验完整性,不核验身份。 +- 不把 `verify-bundle` 的完整性结论说成与当前工作区 `proof verify` / `task merge` 同一套 `live` / `decayed`。 +- 不把 `0.7` 写成「不拒绝任务仓 dirty」。那是对 `0.6` 的假描述;保持拒绝即可。 +- 不把 `task evidence` ZIP 当作 Proof Bundle。 + +--- + +## 11. 为何这能争第一,而不是追第四 + +市场会继续比模型、比 star、比谁的 TUI 能并排开十二个 session。那条赛道没有终点,也没有我们的存量优势。 + +我们的不可复制点是已经连在一起的四件事: + +1. **多仓开发线**是时间原子; +2. **证据绑定 HEAD** 且集成才释放下游; +3. **续航在图上走**,不在对话里走; +4. **投影只收缩权威**。 + +把这四件事收成 Proof、Card、Compiler,Dyro 的品类就从「agent 工具」锁成 **Delivery Physics**。后来者可以抄命令名,抄不走你们已经强制的衰减与跨仓祖先检查。 + +第一名的判据不是日活 agent 数,而是: + +> 一个没参加过这次开发的人,拿着 Proof Bundle 和**自己提供的** git 对象,能否独立得出与源机**相同的完整性结论**(字节仍在、钉死 SHA 可解析、已声明密钥仍在)。这不是身份证明,也不是「现在工作区还能 merge」。当前能否 merge 只由工作区上的 `proof verify` / 现有绑定检查回答。 + +谁先让这句话成立,谁就定义了品类。 diff --git a/docs/superpowers/reviews/2026-08-15-delivery-physics-adversarial-review-board.md b/docs/superpowers/reviews/2026-08-15-delivery-physics-adversarial-review-board.md new file mode 100644 index 0000000..0120274 --- /dev/null +++ b/docs/superpowers/reviews/2026-08-15-delivery-physics-adversarial-review-board.md @@ -0,0 +1,804 @@ +# 交付物理学设计与实施计划对抗式复核审查委员会 + +日期:2026-08-15 + +范围: + +- 仓库:`/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow` +- 模块:Core Physics 投影、Capability Plane、Host Compiler、续航快照、merge / 下游释放 +- 基线:已发布 `0.6.0` 语义;本列车目标 `0.7` → `1.0` + +审查材料: + +- `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/docs/designs/delivery-physics.md` +- `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/docs/adr/0006-delivery-physics-and-capability-plane.md` +- `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/plans/delivery-physics-implementation.md` +- `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/docs/architecture.md` + +SSOT(源码优先于文档): + +- `src/dyro/tasks.py`(`merge_task`、`_valid_review_acceptance`、`_assert_dependency_integrated`、`_prepare_merge`) +- `src/dyro/continuation/models.py`、`snapshot.py`、`budgets.py`、`planner.py` +- `src/dyro/config.py`、`profile.py`、`tooling.py`、`terminology.py`、`cli.py` +- `src/dyro/evidence.py`、`graph.py` + +评审团与模型映射(审查员身份 ≠ 产品对标): + +- Codex:具名模型不可用,改派 Auto(`inherit`)— 源码契约、merge / review / 续航同真值 +- Grok:`cursor-grok-4.6-xhigh-fast` — 决策有效性、fail-closed、品类与 A1/B1 承重 +- Agy:Gemini / Sonnet 额度耗尽,改派 `composer-2.5-fast` — 计划可执行性、CLI、版本列车、落地地雷 +- Cursor:本机主控独立段 — 三份文档互洽与实现入口 + +固定决策(除非源码证明其错误,否则不得重开): + +- 产品身份:本地优先的多仓交付物理引擎,不是 agent / 舰队 / skill 超市。 +- 权威投影锁定为 B。 +- **A1**:`0.7` 衰减是现有 merge / 下游绑定检查的投影;对错同真值,只多 `PROOF_DECAYED`。 +- **B1**:`verify-bundle` = Proof Bundle + 调用方 git 对象;核验完整性,不核验身份。 +- 不在仓库内保存「我们学了谁 / 对标谁」的对照附录。 +- 可以写 Codex / Claude / OpenCode / Skills;不要写成某编排器的开源替代。 + +开放微决策(请表态,不要另开产品线): + +1. `0.7` 的 `proof verify` 默认是否只做衰减+绑定重算(文档已写是),源码侧有无必须先重跑 gate 的既有契约? +2. P7 Console 是否必须进 `0.7` 出口,还是可延到 `0.8` 而不破坏 A1? +3. 宿主投影默认只写当前工作区(文档已写是)是否与现有 `dyro agent` / tool 发现路径冲突? + +## 规则 + +1. 审查员只写自己的签名章节。他人不得改写、缩写或润色该章。 +2. 源码、现有 schema、现有测试优先于设计稿和计划。 +3. 不可由当前源码或材料证实的项目标为 `须人工核`。 +4. 发现使用 P0 / P1 / P2。每条必须有路径证据,并尝试反证。 +5. 禁止只用同一种文本搜索作为验证。优先打开函数、测试和状态机。 +6. 不报告纯文风偏好。优先:错误 merge、假 live、第二份真源、权限扩大、1.0 核验物撒谎、计划不可执行。 + +--- + +# Codex Review Section + +Reviewer: Codex +Time: 2026-08-15 +Verdict: Conditional Go + +## Contract Consistency + +**P0 · P5 把下游释放接到 `_valid_review_acceptance` / decayed review,与 0.6 真源分裂。** +证据:`plans/delivery-physics-implementation.md:105` 标题「merge / 下游释放拒绝 decayed review」;同文件 `184-187`「task merge 与下游释放仍走现有 `_valid_review_acceptance` / `_assert_dependency_integrated` / 开发线 dirty 预检」。 +源码下游只要求 `status == done` + 记录 HEAD 仍是线 HEAD 祖先,不重验 review: + +- `src/dyro/tasks.py:552-556` `check_dispatchable`(`run`/`claim` 入口) +- `src/dyro/tasks.py:604-605` `plan_tasks` → snapshot + readiness +- `src/dyro/continuation/snapshot.py:167-186` `_integration_state` 只调 `_assert_dependency_integrated` +- `src/dyro/continuation/planner.py:137-154` `integration_state != "integrated"` → `TASK_INTEGRATION_PENDING` +- `src/dyro/continuation/store.py:966-973` Objective 完成同门 + +`done` 是粘滞状态文件;`test_tasks.py:151-169` 只锁 **merge** 必须重验 binding,证明 0.6 已把「状态字 ≠ 放行」限定在 merge,而不是下游。 +影响:若按 P5 字面给下游加上 decayed review 拒绝,ready set 会比 0.6 更窄,直接违反锁定 A1。 +自驳:若「/」意为 merge 走 review、下游走 ancestor,则 P5 正文仍把三个检查并列写在「merge 与下游」主语下,实现者会做成第二套门。定律 II(`docs/designs/delivery-physics.md:86`)「HEAD 移动 / 工作区变脏 / attempt 替换 → PASS 失效」若套到下游,同样加严。 +修复:下游只投影 `_assert_dependency_integrated`;`review_verdict` decayed 只许进 merge 人话 / `PROOF_DECAYED` attention,不得改 `build_task_readiness`。 +验收:夹具「`done` + `review.md` 被撕 / 任务仓 HEAD 已漂,但 `task-heads.json` 仍是线祖先」→ 下游仍 ready;仅 ancestor 失败 → 仍只报 `TASK_INTEGRATION_PENDING`。 + +**P0 · 锁定文案「不加严任务仓 dirty(A2 已否决)」与定律 II、与 merge 真源三方打架。** +证据:设计 `delivery-physics.md:8-9,206`;计划 `158,271`;ADR `0006:35`。定律 II 同文件 `86` 又写「工作区变脏 → PASS 失效」。 +源码:任务仓 dirty(HEAD 未变)**已经**拒绝 local merge: + +```905:909:src/dyro/tasks.py + dirty = require_ok( + git(destination, "status", "--porcelain=v1", "-uall"), f"读取 {repo_id} 任务 worktree 状态" + ).stdout.strip() + if dirty: + raise DyroError(f"任务 worktree 不干净,必须先提交全部改动:{destination}") +``` + +调用链:`_collect_task_heads` ← `_assert_task_heads_current:989-995` ← local `_valid_review_acceptance:1152-1156` ← `merge_task:2057-2059`;`_prepare_merge:1931` 再查一次。开发线 dirty 是另一道硬编码预检(`1937-1939`),与 `require_clean_merge` 运行时开关无关(该旗只能为 true,`config.py:216-217`)。 +影响:按 A2 字面「0.7 不拒绝任务仓 dirty」会**放松** 0.6,违反 A1「同真值」。P2 验收同时要求「同真值夹具绿」和「dirty 不做成 merge 拒绝」(计划 `160` vs `158`),夹具无法同时成立。 +自驳:「不加严」可被善意读成「不要再叠一层 Proof 门」。但 §7 把「任务仓 dirty 也拒绝 merge」标成已否决的新规则,这是对当前物理的假描述,不是「保持原样」。 +修复:删除 A2 否决句;写明 0.6 已拒绝任务仓 dirty;0.7 保持拒绝;Proof 可投影为 `decayed`/`inconclusive`,不得改为 accept。定律 II 的「脏」限定为 **merge/review-acceptance 路径的任务仓 dirty + 开发线 dirty**,不得延伸到下游。 +验收:新增锁夹具:`done` + 任务仓 dirty + HEAD 未变 → merge reject,错误集与 0.6 相同。 + +**P1 · P4 把 proofs 写进未使用的 `ContinuationSnapshot`,并暗示 journal 持久化,制造第二真源。** +证据:计划 `177-178`。`ContinuationSnapshot`(`models.py:267-277`)只在 `continuation/__init__.py` 导出,生产采样/计划用的是 `SchedulerSnapshot`(`snapshot.py:52-73,189-290`;`planner.py:248`)。Scheduler 快照每次重建,不进 Objective journal。`SchedulerReadProjection` 硬锁 `schema_version == 1`(`models.py:238-239`)。 +影响:按 P4 改死类型则 proofs 进不了 planner;若再 bump projection schema 或把 proofs 写入 journal 当 PASS,会绕过 `review.md` / `task-heads.json`。 +自驳:他们可能只是用错了类型名。即便如此,计划未点名必须改的闭集:`ReasonCode`(`models.py:76-95`)、`attention.py:304-322`、`tasks.py:620-632` `_schedule_block_reason`。漏改则 `PROOF_DECAYED` 会落到默认 `WAITING` / 英文裸码。 +修复:P4 目标改为 `SchedulerSnapshot`(或独立、可全量重建的 Proof 投影);journal 不存 proofs;`schema_version` 保持 1,新字段缺省空且不进 merge 真源。 +验收:同一 substrate 快照哈希稳定;merge 仍只读原始绑定字段。 + +## Source Evidence Accuracy + +**已核对为真(A1 核心投影):** + +| 文档声称 | 源码 | +| --- | --- | +| merge 不重跑 gate | `merge_task:2053-2063` 无 `run_gates`;gate 只在 `run_task:1403` / `answer_task:1639` / 显式 `dyro task gates`(`cli.py:1419-1422`) | +| 集成 = `git merge-base --is-ancestor`;`revert` 不裂祖先 | `tasks.py:973-986`;revert 仍留后代,`--is-ancestor` 为 0 | +| review 绑定 attempt/plan/receipt/heads | `_valid_review_acceptance:1133-1161` + `provenance.py:569-582` | +| signoff 再绑一层且依赖有效 review | `_valid_external_signoff:1063-1130`;`merge_task:2061-2062` | +| Trigger 用 `next_probe_at`,不进 fingerprint | `models.py:151-157`;`budgets.py:244-246,435-446` | +| `[adapters.*]` 只有 argv | `config.py:41-46,244-252` | +| 未审计命令可发现、不获执行权 | `cli.py:360-381` 只允许写 codex preset;`home.py:373-375`「尚未集成」;`test_hub.py:595-616`;`test_cli.py:126` | +| Console summary 不探 Git | `observations.py:150` `inspect_integration=False`;`test_console_read_model.py:129-159` | + +**P2 · 把 `progress_fingerprint` 写成已在跑的物理学。** +证据:设计 `delivery-physics.md:91,213`;计划 `179`。`decide_no_progress` 只出现在 `tests/test_continuation_budgets.py`。生产 `_budget_usage`(`store.py:606-616`)从不设 `no_progress_cycles`(默认 0)。 +影响:0.7 若把 live Proof 投影进 `effective_evidence` 并接线 no-progress,会**新开**预算耗尽路径,不是「只多 reason code」。 +自驳:纯函数契约本身正确(忽略 trigger)。假的是「已经在调度环里」。 +修复:0.7 不要把 Proof 接入生产 `BudgetUsage`;文档改成「已锁的纯函数,生产未接线」。 +验收:现有 Trigger 抖动测试仍绿,且 0.7 不新增 no-progress 自动耗尽。 + +**P2 · `require_clean_merge` 被写成 merge dirty 的运行时开关。** +证据:设计 `206`。旗在加载期锁死 true(`config.py:216-217`);线脏拒绝是 `_prepare_merge:1937-1939` 硬编码。 +影响:实现者可能去做「读配置再 decay」,引入可关的第二路径。 +修复:写「线 dirty 预检硬编码;该旗只是 schema 不变量」。 + +## Decision Validity + +锁定项与源码对齐,**不要重开**:产品 = 本地优先多仓交付物理引擎;权威投影 B;B1 = bundle + 调用方 git 对象、完整性非身份;无竞品对照附录;宿主名可用。A1 **核心**(merge/下游对错不变,只多 `PROOF_DECAYED`)成立。源码证伪的是 A1 骑手「不加严任务仓 dirty」和 P5 对下游的扩门,不是 A1 本身。 + +`verify-bundle` 缺密钥 → `inconclusive` 与现有 trust store(架构 `184-185`)不冲突。`external_bundle` ≠ P6 ZIP(设计 `179`、计划 `148`)成立。 + +## Plan Executability + +P0–P3、P5 正文(若按上面改过)可执行:先派生、纯函数 decay、CLI 默认不跑 gate、merge 仍走现有函数。 + +不可按原文开写的切片: + +1. **P5** — 见上,先改合同再编码。 +2. **P4** — 改错类型 + 漏改 ReasonCode 闭集。 +3. **P7** — 未对接 C01。若在 `capture_workspace_read_snapshot` 里为 Proof 跑 `merge-base`/HEAD,会打破 `test_console_read_model.py:129-159`,也违反设计 `4`「不改写已发布 Console 权威语义」。 +4. **P2 夹具** — 「同真值」与「dirty 不拒绝」互斥,必须先改验收句。 + +## Scope And Risk + +0.7 出口写死 P1–P7(计划 `294`)。P7 是展示,不是 A1 交付门。P6 把 1.0 B1 命令提前进 0.7 可接受(schema 仍到 P13 才锁),但不要把 P7 绑死在 A1 上。 + +Host Compiler(0.9)与现有发现面并行,不自动冲突,但输入必须收窄,见微决策 3。 + +## Go/No-Go + +**Conditional Go。** A1/B1/B 方向对;当前文本有两处若照做会改 0.6 对错。先改合同,再开 P2/P5。未改之前 **No-Go for P2/P5 implementation**。 + +### 微决策建议 + +1. **`proof verify` 默认必须只做 decay + 绑定重算,不重跑 gate。** + 源码没有「verify ⇒ 重跑 gate」的控制面契约。`changeset verify` 查脏/分支/HEAD(`changesets.py:134+`);证据 **导入** 核验 `gates.json` 哈希(`tasks.py:1465`),不在控制机重跑;重跑只存在于 `run_task` / `dyro task gates` / runner 侧 `evidence.build`。默认 `--rerun-procedure` 才跑,且必须 dry-run 或隔离。 + +2. **Console P7 可以滑到 0.8,不破坏 A1。** + A1 只约束 merge/下游真值。P7 是只读投影。0.7 出口应改为 P1–P6(或 P1–P5 + P6)。若 0.7 仍做 P7:summary 路径不得新开 git probe;衰减展示走 `not_inspected` 或独立 inspect。 + +3. **工作区本地 host 投影与现有发现路径不固有冲突,前提是编译器只读已审计 adapter/Card。** + 现有三面:`config.adapters`(执行权,`config.py:244-252`);`tooling.py` + `registry_home()/tools.json`(`dyro open` / tool 偏好);`DISCOVERABLE_AGENTS` + PATH(`home.py:40-44,353-375`)。另有实验 dispatch 写 `~/.dyro/local-agent-dispatch/skills/SKILL.md`(`experiments/local_agent_dispatch/paths.py:101-102`)。默认写 `.dyro/host-projections/` 不打架。冲突条件:把 `tools.json`/PATH 当可执行 Card;或 `--user` 覆盖 dispatch/`~/.codex/skills` 且 doctor 双写。P10 必须保持。 + +## Required Fixes + +1. **P0** 重写 P5 / 定律 II 下游句:下游只投影 `_assert_dependency_integrated`;decayed review 不加严 ready set。验收见上。 +2. **P0** 删除「A2 已否决 / 0.7 不拒绝任务仓 dirty」。改为「0.6 已拒绝,0.7 保持」。P2 夹具与 `_valid_review_acceptance` 同真值,并补 dirty-after-done merge 锁。 +3. **P1** P4 改 `SchedulerSnapshot`(或可重建投影);不把 proofs 写入 journal;`ReasonCode` + `attention.py` + `_schedule_block_reason` 同步加 `PROOF_DECAYED`(attention / 人话,默认不 block 下游)。 +4. **P1** P7 滑到 0.8,或写明 C01:summary 零 git I/O。0.7 出口去掉对 P7 的硬依赖。 +5. **P2** 文档收回「fingerprint 已在生产宣布死亡」;0.7 不把 Proof 接入 `BudgetUsage`。 +6. **P2** P0 文档切片把架构不变量 14–20 回写 `architecture.md`,避免设计/架构双清单。 + +未改 1–2 之前,不得合并 P2/P5 实现。 + +--- + +# Grok Review Section + +Reviewer: Grok +Time: 2026-08-15 +Verdict: Conditional Go + +锁定的 A1 / B1 / 投影 B 方向对,源码也已经有 merge 重绑、下游祖先检查、Ed25519 trust store、外部术语 denylist。当前文本仍不能让 A1 承住 `0.7` 同真值,也不能让 B1 承住「与控制面相同的 `live`」——会做出第二真源、假 `live`、以及 `1.0` 核验谎言。先改契约再开工。 + +## Contract Consistency + +**P0 · A1 的枚举衰减规则 ≠ 现有 merge 谓词(假 `live` / 第二真源)** +- 严重度:P0 +- 证据:设计 4.2 只写 `review_verdict` / `signoff`:`task HEAD ≠ 绑定 HEAD → decayed`。源码 `merge_task`(`src/dyro/tasks.py:2053-2062`)拒绝集是:`status==done` ∧ `_valid_review_acceptance` ∧(可选)`_valid_external_signoff` ∧ `_prepare_merge`。`_valid_review_acceptance`(`1133-1161`)还检查 receipt SHA、`task-heads.json` SHA、`validate_review_binding`(`attempt_id`/`plan_sha256`,`provenance.py:569-582`)、local 下 `_assert_task_heads_current`。`_valid_external_signoff`(`1063-1130`)再绑 `review_sha256` / attempt / plan,并在策略开启时走 Ed25519。`_prepare_merge`(`1921-1942`)另拒开发线 dirty 与错分支。测试已证明拆掉绑定字段后 merge 失败:`tests/test_tasks.py:151-169`。 +- 影响:按 4.2 实现时,receipt/attempt/plan/signoff 漂移后 Proof 仍可 `live`,merge 拒绝 → CLI/Console 第二真源。若执行者按 P5 标题用 `proof.status` 当门,方向反转,缓存 `live` 可能放行。 +- 反驳尝试:P2 写了「与 `_valid_review_acceptance` / `_assert_dependency_integrated` 同真值」。这两函数仍不含 signoff、line dirty、错分支;且 4.2 枚举比 `_valid_review_acceptance` 更窄。标题与夹具不能互相覆盖。 +- 修复:把 `decay(review_verdict)` 定义为 `_valid_review_acceptance` 的全量投影;`decay(signoff)` 定义为 `_valid_external_signoff` 的全量投影。`False` 必须拆成 `decayed`(绑定/HEAD/哈希变了)与 `inconclusive`(缺文件/缺工具/不可解析)。line dirty / 错分支保持现有 merge 错,禁止标成 `PROOF_DECAYED`。 +- 验收:同一组 `0.6` 夹具上,`proof verify` 的 `live` 集合 = `merge_task` 在「忽略 dirty/branch 之后」的接受集合;receipt 改写、binding 删除、signoff 失绑不得出现 `live`。 + +**P0 · B1 承不住设计 §11 / ADR 的「与控制面相同」句(`1.0` 核验谎言)** +- 严重度:P0 +- 证据:设计 §11:`拿着 Proof Bundle 和自己提供的 git 对象,能否独立得出与控制面相同的 live / decayed / inconclusive`。B1 / 设计 4.1:`verify-bundle` 核的是「捆内钉死的 substrate + 调用方 git 对象」。控制面 `proof verify` / merge 用的是**当前工作区** substrate(`tasks.py:2057`、`1133-1161`、`973-986`)。钉死快照上对象存在 ⇒ 多为 `live`;控制面在 line HEAD 已走后 ⇒ `decayed`。二者不可能同函数。`architecture.md:237` 已诚实写成「完整性而不是身份」;§11 把完整性说成与控制面同结论。 +- 影响:P13 CI 用固定 git 夹具会绿,对外仍像「陌生人能复验现在是否完成」。这是假 `live`,不是完整性。 +- 反驳尝试:「完整性结论」= 历史自洽,不是现在能否 merge。ADR 后果段比 §11 谨慎,但 §11 仍是品类判据;P6 验收写「与源机相同的完整性结论」却不定义 current substrate。未改 §11 就不能交货。 +- 修复:删掉「与控制面相同的 `live`」。写成两条命令、两套结论:`verify` = `decay(proof, current_workspace_substrate)`;`verify-bundle` = 完整性(字节哈希 + 钉死 SHA 在 `--git-dir` 可解析)。`verify-bundle` 若要衰减,必须另传 `--current-heads`,缺省只能 `live|inconclusive`,不能假装与 merge 同真值。 +- 验收:同一 bundle,源机 HEAD 已移动时:工作区 `verify` → `decayed`;裸 `verify-bundle` 不得报与 merge 相同的 `decayed`,除非传入当前 heads。 + +**P1 · P5 标题把下游与 review 重绑焊在一起(A1 加严)** +- 严重度:P1 +- 证据:计划 P5:`task merge 与下游释放仍走现有 _valid_review_acceptance / _assert_dependency_integrated / 开发线 dirty 预检`。源码下游只走祖先:`check_dispatchable` → `_assert_dependency_integrated`(`tasks.py:552-556`、`973-986`);`snapshot._integration_state`(`snapshot.py:167-186`)。下游**不**调用 `_valid_review_acceptance`。dirty 只在 `_prepare_merge`,不在下游。 +- 影响:按字面实现会把 review 重绑/dirty 加进下游,`0.7` 比 `0.6` 更严,A1 破。 +- 反驳尝试:「仍走现有」可读成「各走各的」。斜线列举足够让执行者写成一个门。 +- 修复:拆句。merge = `_valid_review_acceptance` + 可选 signoff + `_prepare_merge`。下游 = 只 `_assert_dependency_integrated`。禁止 `if proof.status != live: block downstream`。 +- 验收:done 但未 merge 的依赖:下游仍只因祖先失败;review.md 被掏空不得成为新的下游拒绝(`0.6` 也不会)。 + +**P1 · 定律 I 与默认 `verify` 冲突(品类裂缝,不是 agent 平台)** +- 严重度:P1 +- 证据:定律 I:事实须「独立程序、针对钉死 substrate **复现**」。设计 4.1 / 计划 P3:`verify` 默认衰减+绑定重算,不重跑 gate argv。`merge_task` 也从不重跑 gate。 +- 影响:`gate_log=live` 只表示哈希还在,不表示测试还能过。默认 `verify` 是重绑器,不是核验程序。A1 下 merge 安全;`1.0`「可复验事实」被掏空。 +- 反驳尝试:review 的 procedure 就是重绑,默认 `verify` 对 `review_verdict` 成立。对 `gate_log` 不成立。 +- 修复:默认 `verify` 可保持 decay+rebind(开放微决策 1 的唯一不破 A1 的答)。必须写明:`live` = 当前 substrate 上绑定仍成立,不是 procedure 已复现。`gate_log` 未 `--rerun-procedure` 不得叙事成「门禁仍通过」。 +- 验收:文档与 CLI help 区分 `rebind` / `replay`;未 replay 的 `gate_log` JSON 不得出现 `procedure_reproduced=true`。 + +## Source Evidence Accuracy + +SSOT 核对(源码胜文档): + +| 声称 | 源码 | 结论 | +| --- | --- | --- | +| merge/review/signoff 已重绑 | `merge_task` `2053-2062`;`_valid_review_acceptance` `1133-1161`;`_valid_external_signoff` `1063-1130`;`test_merge_revalidates_accepted_review_binding` | 成立。比设计 4.2 更宽。 | +| 下游祖先检查已存在 | `git merge-base --is-ancestor` at `tasks.py:978-986`;planner 用 `integration_state` | 成立。`git revert` 仍是后代,A1 不把 revert 当断裂,与源码一致。 | +| `progress_fingerprint` 已忽略 trigger | `budgets.py:435-446` 只哈希 task_states / integration_heads / decisions / effective_evidence | **函数契约成立,生产未接线**。`_budget_usage`(`store.py:606-616`)从不设 `no_progress_cycles`;`decide_no_progress` 只出现在 `tests/test_continuation_budgets.py`。设计把「已在跑的物理学」说满了。 | +| Ed25519 trust store 在;Proof 模型可能无签名 | `signing.py:68` `.dyro/trust/ed25519/`;`config.py:56-58` `require_signed_*`;设计 4.1 Proof 字段无 `signature` / `key_id` / `require_signed_*` | 成立。B1「缺已声明的签名密钥 → inconclusive」在无声明槽时恒为假,fail-closed 是死条款。 | +| `tooling.py` 已发现 opencode / cursor-agent | `tooling.py:104-129`;状态在 `registry_home()/tools.json`(`tooling.py:191`,`hub.py:35-47`) | 成立。这是**用户级启动目录**,不是工作区 Card,也不是审计后的 execute。 | +| `terminology.py` 要求 EXTERNAL denylist | `load_terminology_policy` `92-114`:无 env/文件则报错;文件必须在仓库外 `70-77` | 成立。计划 P0 把禁词写在计划里当说明,CI 仍须外部策略;树内无策略时扫描 fail-closed,不是静默放行。 | +| `task explain` 与调度同真值 | `graph.py:170-225` 只看依赖 `done`,不调用 `_assert_dependency_integrated` | **不成立**。`0.6` 已有「explain 可跑、一跑失败」。P5 已点名,但未钉死必须调用同一函数。 | +| Proof / capability / hostproj 模块 | 仓库无 `src/dyro/proof/` | 成立(尚未落地)。 | +| `examples/polyrepo` 可黄金哈希 | 仅有 `examples/polyrepo/dyro.toml`,无 receipt/review 夹具 | P1 验收目前不可执行。 | +| `contract_hash` substrate | 存在 `task_contract_sha256` 与 Objective `contract_sha256` | 须人工核:Proof 用哪一个。 | + +**P1 · Proof 无签名槽,B1 的密钥缺席条款是空操作** +- 严重度:P1 +- 证据:设计 4.1 字段表;`require_signed_signoff` 路径在 `tasks.py:1076-1081`、`1889-1893`。 +- 影响:未声明密钥的伪造 bundle + 调用方对象库 → `live`。控制面在 `require_signed_*=true` 时会拒。再叠加 §11「相同结论」= 假 `live`。B1「不核验身份」本身可成立,但不能再承诺与控制面同状态。 +- 反驳尝试:身份本就不在 B1。成立;所以必须改承诺,或给 Proof 加 `declared_key_ids` + `policy_require_signed` 快照,缺密钥 → `inconclusive`。 +- 修复:二选一,禁止两句并存。 +- 验收:带 `require_signed_review=true` 的源机导出:bundle 无 key 声明时 `verify-bundle != live`,或文档明确 `live` ≠ 可 merge。 + +**P2 · `progress_fingerprint` 被写成已落地续航定律** +- 严重度:P2 +- 证据:见上表。 +- 影响:P4「继续忽略 trigger」在未接线函数上会空绿。 +- 修复:P4 须写清:先把 `decide_no_progress` 接到 `reserve_supervised_objective_action`,再投影 Proof;或承认 0.7 只保证函数契约,不保证 mutation 环。 +- 验收:生产路径上 Trigger 抖动不重置 no-progress 的测试,不得只打纯函数。 + +## Decision Validity + +固定决策不重开。攻击的是「文本能否承住承诺」。 + +A1 作为策略成立:`0.7` 必须是投影,不能当第二道门。当前 4.2 + P5 标题不能执行该策略。 + +B1 作为策略成立:捆内不塞对象库、不核身份,避免假完美与体积爆炸。当前 §11 / ADR「与控制面相同」把 B1 说成它做不到的事。`architecture.md:237` 的表述可保留。 + +投影 B 成立:hook 不是 OS 边界(与 `cwd` 不是沙箱同类)。不评成 agent 平台。 + +**开放微决策(只答有效性,不扩 scope)** + +1. **默认 `proof verify` = decay+rebind only?** + 必须是。否则 A1 会加严(merge 不重跑 gate)。但这只能叫 `rebind`,不能叫定律 I 的「复现」。`--rerun-procedure` 另出口、须隔离。把默认 `verify` 的 `live` 当成「测试仍过」= 假 `live`。 + +2. **Console P7 能否滑到 `0.8`?** + 物理学上能。`0.7` 出口写了 `P1–P7`,要滑必须改出口。对抗约束:P7 **不得**先于 P2/P5 同真值落地,否则只读 UI 先广播假 `live`(违设计不变量 14)。可滑 P7,不可滑 P5。 + +3. **工作区宿主投影 vs 现有 tool/agent 路径?** + 必须默认工作区:`.dyro/host-projections/`。`tooling.py` / `tools.json` 在 `registry_home()`(用户级),`dyro open` / `tool install` 是启动目录,不是 Capability Card。Host Compiler 读 `tools.json` 当已审计 Card = 第二能力真源,且会把 PATH 发现写进工作区法律。`--user` 才写 Codex home skills。P10 发现-only 必须保持。 + +**P1 · `PROOF_DECAYED` 吞掉非衰减拒绝(假 `decayed`)** +- 严重度:P1 +- 证据:设计 4.2:「衰减若挡住 merge 或下游,生成 `PROOF_DECAYED`」。line dirty 在 `_prepare_merge:1937-1939`,不是 review 死亡。 +- 影响:脏开发线被说成「复核失效」,人被赶去重做 review,merge 其实只要求干净工作区。 +- 修复:`PROOF_DECAYED` 仅当对应 Proof 从 `live`→`decayed`。dirty/branch/push 用现有错误。 +- 验收:仅脏 line、绑定仍成立时,不得出现 `PROOF_DECAYED`。 + +## Plan Executability + +**P1 · P5 可被读成「用 Proof 拒绝」或「只加 reason code」** +- 严重度:P1 +- 证据:标题 `交付门拒绝衰减` vs 正文 `仍走现有 _valid_review_acceptance` vs 风险表 `merge 仍读原始绑定字段再走 decay`。三句可推出:新谓词、旧谓词、旧+decay 串联。 +- 影响:串联时 decay 不全则双门漂移;新谓词则缓存是第二 PASS(计划自己禁止,标题在引诱)。 +- 修复:P5 唯一允许的代码形状:`merge_task` 仍只调现有函数;decay 在 explain/attention 命名已发生的拒绝。禁止 `if not proof.live: raise`。 +- 验收:diff 中 `merge_task` / `check_dispatchable` 不新增 Proof store 读取。 + +**P1 · `task explain` 已与调度分叉;P5 未钉死函数** +- 严重度:P1 +- 证据:`graph.py:181-186` vs `tasks.py:552-556`。P5:`task explain 与调度入口必须和真正挡下游的检查一致`。 +- 影响:用 `proof.status`「对齐」会把第二真源写进 explain。 +- 修复:`explain_task` 对每个 `done` 依赖调用 `_assert_dependency_integrated`(或复用 `integration_state`),不读 Proof 缓存。 +- 验收:未 merge 的 done 依赖:`explain.dispatchable=false`,reason 与 `check_dispatchable` 同一祖先错误。 + +**P1 · P1「黄金哈希」与 `produced_at` 冲突** +- 严重度:P1 +- 证据:设计 4.1:`produced_at` 取源文件已有时间戳,缺则空,不伪造。`review.md` 无时间字段;`signoff.json` 有 `signed_at`(`tasks.py:1876`);signed review JSON 有 `created_at`(`reviews.py:50`);receipt 通常无。mtime 会进黄金哈希且跨 checkout 不稳。`examples/polyrepo` 无证据文件。 +- 影响:P1 验收一开始就红,或把 mtime 写进身份(违「身份哈希不含现在」)。 +- 修复:`produced_at` 只取记录内字段;`review.md` / receipt 为空。黄金哈希用无时钟字段的身份。夹具用 `tests/` 现有 bound review,不用空的 polyrepo。 +- 验收:两次派生、不同 mtime,身份哈希相同。 + +**P2 · 现有 evidence ZIP 与 P6 Proof Bundle 易撞名** +- 严重度:P2 +- 证据:`evidence.py` 已有 ZIP(receipt/gates/heads/provenance);设计把 `external_bundle` 与 P6 分开。CLI 尚无 `proof`。 +- 影响:执行者复用 `task evidence` 导入路径,会把执行包当 Proof Bundle,或把 git 对象塞进 ZIP(否决 B2)。 +- 修复:P6 新命令、新 media type、拒绝 evidence ZIP 布局。 +- 验收:对 `task evidence build` 的 ZIP 跑 `verify-bundle` → `inconclusive`,不是 `live`。 + +## Scope And Risk + +范围没有滑向 agent 平台。Host Compiler 是收缩投影;Card `can_prove` 为空则输出不能当完成证据。这条保住。 + +真风险是品类谎言,不是功能不够: + +- 第二真源:`src/dyro/proof/store.py` 缓存 + Console/explain 读状态,而 merge 读文件。计划禁止「缓存当第二份 PASS」,未写失效键(源文件哈希/世代)。须人工核执行者是否每次 `list/verify` 全量重派生。 +- 假 `live`:不全的 decay、默认不 replay 的 `gate_log`、无密钥槽的 bundle、`verify-bundle` 对钉死 substrate 报 `live`。 +- `1.0` 谎言:§11 品类句 > B1 能力。P13 冻的是这句话就会把谎言锁进 `1.0.0`。 +- 能力平面分叉:`[adapters.*]`、`tools.json`(用户级)、未来 `[[capabilities]]`、宿主 skill。0.7 不解析 Card 是对的;0.9 若用 tooling 探测当审计,fail-closed 破。 +- `architecture.md` 不变量仍是 1–13;设计 14–20 未进架构。P0 若只合设计/ADR/计划,架构仍说旧话。 +- 双品类名:`architecture.md:225` `delivery control plane` vs 设计 `Delivery Physics`。术语允许两者。不是 agent 平台,但是叙事未锁死。不因此 No-Go。 + +预演失败(计划未覆盖): + +1. 执行者按 4.2 只比 HEAD,receipt 被换 → Proof `live`,merge 拒。 +2. 执行者按 P5 标题读 `proof.status`,脏缓存放行 merge。 +3. 陌生人 `verify-bundle` 得 `live`,源机 line 已 reset → 以为完成仍在。 +4. `require_signed_*=true` 工作区导出无密钥的 bundle → 外机 `live`。 +5. P7 先于 P5 展示 `live`。 +6. Host Compiler 把 `tools.json` 里的 opencode 写成可执行投影。 +7. P4 把 `proofs[]` 写进 journal 后当下一 tick 的真源,不重算 decay。 + +## Go/No-Go + +**Conditional Go** + +不重开 A1 / B1 / 投影 B / 本地优先多仓。不建议做成 agent 平台。 + +不能 Go:§11 与 4.2 原样冻结则 `0.7` 必出第二真源,`1.0` 必出核验谎言。 +不能 No-Go:策略本身与源码方向一致;缺口在契约完整性和验收,可用下面 Required Fixes 补,不必换方案。 + +`0.7` 开工门槛:P0 两条 + P5 拆句 + decay 全量投影。 +`1.0` 标签门槛:改掉「与控制面相同的 `live`」;`verify` / `verify-bundle` 分家。 +P7 可改到 `0.8`,但不得早于同真值门。默认 verify=rebind 可接受,必须改 `live` 语义。宿主投影默认工作区,禁止把 `tooling.py` 当 Card 真源。 + +## Required Fixes + +1. **P0** 重写设计 4.2 / P2 / P5:`review_verdict` / `signoff` 的 decay 全量等于 `_valid_review_acceptance` / `_valid_external_signoff`;列出 receipt、`task-heads` 文件哈希、attempt/plan binding、local HEAD、签名策略。`inconclusive` vs `decayed` 表。dirty/branch ≠ `PROOF_DECAYED`。 +2. **P0** 改设计 §11、ADR 后果、P6/P13 验收:B1 只保证完整性,不保证与当前控制面 `live/decayed` 相同。`verify-bundle` 无 `--current-heads` 不得声称衰减结论。 +3. **P0** P5 禁止 `merge_task` / `check_dispatchable` 读取 Proof store。下游只保留祖先检查。 +4. **P1** Proof 模型增加 `declared_key_ids` + 导出时的 `require_signed_*` 快照,或删除「缺已声明密钥 → inconclusive」并禁止 §11 同结论句。 +5. **P1** 定律 I / CLI:默认 `verify` 的 `live` = rebind holds;`gate_log` 未 replay 不得当测试通过。 +6. **P1** `explain_task` 调用 `_assert_dependency_integrated`(或 `integration_state`),不读 Proof。 +7. **P1** `produced_at` 只取记录字段;黄金哈希夹具用 `tests/` bound review,不用空 polyrepo。 +8. **P1** P6 拒绝 `task evidence` ZIP 布局;不把 git 对象放入 bundle。 +9. **P1** P11:投影默认工作区;`tools.json` / PATH 发现 = `discovered_unintegrated`;`--user` 才写用户级 skill。 +10. **P2** P0 把不变量 14–20 写入 `architecture.md`;P4 写明 fingerprint 是否接入 mutation;P7 若延期则改 `0.7` 出口。 +11. **须人工核**:Proof `contract_hash` 对应 attempt `task_contract_sha256` 还是 Objective `contract_sha256`;`proof list` 是否每次重派生。未核前禁止把 store 当展示真源。 + +--- + +# Agy Review Section + +Reviewer: Agy +Time: 2026-08-15 +Verdict: **Conditional Go** + +## Contract Consistency + +| # | 严重度 | 证据 | 影响 | 反驳尝试 | 修复 | 验收 | +|---|--------|------|------|----------|------|------| +| C1 | **P0** | 计划 P4:`ContinuationSnapshot 增加 proofs[]`(`plans/delivery-physics-implementation.md:177`)。源码里 `ContinuationSnapshot` 在 `continuation/models.py:267-277` **从未实例化**;实际采样用的是 `SchedulerSnapshot`(`continuation/snapshot.py:53-74`),进展指纹用的是 `ProgressFacts`(`continuation/budgets.py:244-265`),且 **未接入** supervision 主路径。 | 实施者明天会在错误类型上挂 `proofs[]`,或双写 digest,破坏 snapshot hash 稳定性与 P4 验收。 | 设计 §4.2 写「planner 构造 Snapshot」可理解为概念快照;但计划 **点名** `ContinuationSnapshot`,与源码 SSOT 冲突,反驳不成立。 | P4 改 SSOT 映射:`SchedulerSnapshot._payload` + `ProgressFacts` 装配点 +(若需要)`SchedulerReadProjection`;**删除或标注** `ContinuationSnapshot` 为 dead type;写明 digest 字段与 `schema_version` bump 位置(`snapshot.py:94` vs `planner.py:201`)。 | 同一 substrate 下 `build_scheduler_snapshot` digest 稳定;旧 journal/无 proof 字段 → 空数组兼容;`test_continuation_budgets` 仍绿。 | +| C2 | **P1** | 计划 P7:`Console / dyro attention`(`:199`)。CLI 仅有 `dyro objective attention`(`cli.py:2469-2474`),无顶层 `attention`。 | P7 验收与 CLI 帮助无法对齐;Console 若等新命令会空转。 | 设计 §7 未写 `dyro attention` 顶层命令;仅计划笔误?但 P7 验收绑定该字符串。 | P7 统一为 `dyro objective attention` + Console read_model;若需 task 级 attention,单列 P7b 与 `task explain` 关系。 | `dyro objective attention ` JSON 含 `PROOF_DECAYED`;Console 只读夹具绿;文档/计划去掉 phantom CLI。 | +| C3 | **P1** | 设计 §4.1:`dyro proof export `(`:188`)vs 专家路径 `dyro proof export API-101 --bundle`(`:340`)。P3 未定义 export;P6 未澄清 `` 语义。 | P6 可能实现 proof-id 导出,而 UX/文档期望 task-id 批量导出,或相反。 | `--task` 过滤已在 P3 `proof list`;export 可能默认同 task。但 `:188` 与 `:340` 参数类型仍矛盾。 | P6 锁定一种:`export --task ` 或 `export `;设计 `:340` 改一致;CLI 互斥校验。 | 单任务多 proof 导出行为有表驱动测试;help 与 design 同形。 | +| C4 | **P1** | 版本列车:`0.7.0` 用户可见 `dyro proof *`(计划 `:47`);`1.0.0` 才「Bundle schema 稳定」(`:50`)。但 **0.7 出口** 要求 P1–P7 绿(`:294`);P6 含 `verify-bundle` + B1 语义(`:189-195`);P13 才锁 `schema_version = 1`(`:253`)。 | 0.7 要么提前承诺 1.0 级 bundle 契约(无法 semver 演进),要么 0.7 带 unstable bundle 却用 B1 验收——implementer 不知哪条是真。 | 可称 0.7 bundle 为 preview。但与 ADR B1「1.0 可携带核验」及 P13 冻结重复,preview 与 product 承诺混淆。 | 拆列车:0.7 P3 仅 `list/show/verify`;P6 `export` 可进 0.7;`verify-bundle` + schema=1 归 1.0/P13;或 0.7 出口改为 P1–P5+P7。 | 0.7 tag 检查不含 `verify-bundle` 硬门禁,或文档明确 0.7 bundle 为 experimental。 | +| C5 | **P1** | 计划 P5 标题「交付门**拒绝**衰减」(`:105`)vs A1/ADR「只投影、不加严」(ADR `:35`,计划 `:11`)。 | 工程师可能在 `merge_task` / `check_dispatchable` **新增** decay 拒绝分支,违反 A1。 | 正文 `:183-186` 已写「仍走现有 API」。标题 alone 不应 override——但标题会进 PR 描述。 | P5 改名为「交付门 decay **投影**(A1)」;验收首条引用 `_valid_review_acceptance` / `_assert_dependency_integrated` 集合 **不变**。 | 0.6 夹具 merge/下游 accept/reject 集合 bitwise 相同;仅多 proof 字段与 reason。 | + +## Source Evidence Accuracy + +| # | 严重度 | 证据 | 影响 | 反驳尝试 | 修复 | 验收 | +|---|--------|------|------|----------|------|------| +| S1 | **P1** | P5 要求 `task explain` 与调度入口一致(`:186`)。`graph.explain_task`(`:162-225`)**不**调用 `_assert_dependency_integrated`;`check_dispatchable` 会(`tasks.py:545-556`)。 | `explain` 报 YES、`task run`/`next` 因未集成 fail——P5 验收必红;P7 Console 若吃 explain 图会撒谎。 | 架构 `:132` 说 explain 解释「为何可调度」——当前本就不含集成;这是 **已知缺口**,计划 P5 正要修。 | P5 在 `explain_task` 或共享 helper 复用 `build_scheduler_snapshot` 的 `integration_state`;JSON 增加 `integration` 原因。 | 依赖 done 未 merge 时 explain `dispatchable=false` 且 reason 与 `check_dispatchable` 同文案。 | +| S2 | **P1** | P1 kind 闭集含 `integration_heads`(计划 `:147`)。设计映射为「依赖 HEAD 已是线 HEAD 祖先」(`delivery-physics.md:177-178`)——**非持久文件**,由 `git merge-base` 即时判定(`tasks.py:973-986`)。 | `derive.py` 无稳定 `subject`/`substrate`/`produced_at` 规则;P1 黄金哈希不可定义。 | 可派生为 synthetic proof,substrate=当前 line HEADs + 依赖 task-heads。但计划未写,generation 与 id 公式缺失。 | P1 增「derive 算法」小节:`integration_heads` 的 id、何时 materialize、缺 git → `inconclusive`;与 `_assert_dependency_integrated` 同 git 调用。 | 表驱动:integrated/pending/not_inspected 三态与 scheduler 一致。 | +| S3 | **P1** | P1 `gate_log`:本地 gate 写 `gate-{n}.log`(`tasks.py:1318-1322`);外部证据为 `gates.json` + `gates/*.log`(`evidence.py:102-111`, `tasks.py:1513-1515`)。计划未列路径。 | 漏派生一半执行模式;或误读 ledger 为 gate 真源(ledger 非绑定证据)。 | 设计 `:174` 写「gate 日志 + receipt」——可含两者。 | `derive.py` 明确:local 扫描 task 目录 + evidence generation;argv 哈希取自 task.toml gate 定义;**不**把 ledger 当 PASS。 | local/external 夹具各派生一条 `gate_log`;argv 变更 → decayed 展示,merge 仍不受影响(A1)。 | +| S4 | **P2** | P1 含 `action_receipt`(计划 `:147`)。Receipt 存 Objective 目录 `action-receipts/`(`action_journal.py:26`),**非** task 目录。 | `dyro proof list --task` 是否应含 objective receipt?implementer 会扫错树或漏 kind。 | 设计 `:178` 列 Continuation Action receipt——属 Objective 面。 | 明确:`proof list --task` **不含** action_receipt;`--objective` 或 0.8+ 再暴露;P1 可先 derive 不挂 CLI。 | task 过滤不返回 action_receipt;objective 路径单独测试。 | +| S5 | **P2** | P1 验收:`examples/polyrepo` 黄金哈希(`:152`)。polyrepo 仅 `dyro.toml`(无 task 证据树)。 | P1 PR 无法用 polyrepo 自证;验收空转。 | 计划同句有「现有测试夹具」——polyrepo 非唯一。 | 验收改为 **必须** 引用 `tests/test_tasks.py` 等 evidence 夹具 + 新建 `tests/test_proof_derive.py`;polyrepo 降为 smoke。 | 夹具目录派生 SHA 稳定;polyrepo 仅 `proof list` 不 crash。 | +| S6 | **P2** | A1:`review` 衰减与 `_valid_review_acceptance` 同真值(`tasks.py:1133-1161`)。本地还调 `_assert_task_heads_current`(含 **task worktree dirty** 拒绝,`:905-909`)。设计 A1 说不加严 **merge** 对「task dirty HEAD 不变」——与 review 绑定检查是两条线。 | decay 若只 mirror merge 预检会 **under-decay** review;若 over-decay 会违反 A1。 | merge 前也调 `_valid_review_acceptance`(`:2057`);task dirty 已挡 merge。decay 应 mirror review 路径,非仅 line dirty(`:1937-1939`)。 | P2 夹具分表:`review_verdict` decay ← `_valid_review_acceptance`;line dirty ← `_prepare_merge`;禁止混为一表。 | 表驱动 P2 测试命名对应源码函数;merge 夹具 0.6 集合不变。 | + +## Decision Validity + +| # | 严重度 | 证据 | 影响 | 反驳尝试 | 修复 | 验收 | +|---|--------|------|------|----------|------|------| +| D1 | **P2** | 开放微决策 #1:`verify` 默认 decay+rebind。设计 `:192`、P3 `:171` 一致。源码 merge 不重跑 gate(`:2057-2063`);`_valid_review_acceptance` 做 hash/HEAD 重绑。 | 无冲突;可锁定。 | 试图找「verify 必须 rerun gate」契约——未找到。 | **锁定** 默认 decay+rebind;`--rerun-procedure` 仅诊断。 | P3 默认 verify 无 gate 子进程;`--rerun-procedure` 需 dry-run/隔离。 | +| D2 | **P2** | 开放 #2:Console P7 slip 0.8。计划 0.7 出口含 P7(`:294`);P7 可并行 P4(`:129`)。 | slip 会简化 0.7,但与 marketing「attention 人话」不一致。 | Console 已有 read_model(`console/read_model.py`),增量可行。 | **建议不 slip**;若 slip,改 0.7 出口为 P1–P5 并同步 ADR 后果。 | 文档、计划、ADR 三处版本表一致。 | +| D3 | **P2** | 开放 #3:workspace-local host 投影 vs `tool`/`agent` 路径。P11 `:232-233` 已锁 workspace 默认;`home.py`/`tooling.py` 仍管 discover/launch(`:353-413`)。 | 0.7–0.9 无冲突;P10 前 dual surface 可接受。 | Host Compiler 0.9 才写盘;0.7 不碰。 | P11 明确:`host compile` 读 `config.adapters` + `capability test`;**不**写 `~/.codex/skills` 除非 `--user`。 | P11 验收 doctor `scope=workspace|user`。 | +| D4 | **P1** | 设计 §4.2:`progress_fingerprint` 纳入 live Proof(`:213`)。`ProgressFacts` 仅测试使用(`test_continuation_budgets.py`);`progress_fingerprint` 不含 proof 字段(`budgets.py:439-445`)。 | P4 若只改 `ContinuationSnapshot` 则 **完全不生效**。 | 设计意图是投影进 `effective_evidence`/`integration_heads`,非新层——与 S1/C1 同源。 | P4 在 **ProgressFacts 装配点**(新建,likely supervision/planner 交界)投影 live proof digest;**不**改 fingerprint 输入集合语义。 | Trigger 抖动不改变 fingerprint;merge 相关 live→decayed 会改变 fingerprint(与现有 delivery 事实一致)。 | + +## Plan Executability + +**P1 明天能否开工:** 可以,但必须先消 C1、S2、S3 的 derive 规格,否则 `proof/models.py` + `derive.py` 会猜。 + +| Phase | 可执行性 | 阻塞 | +|-------|----------|------| +| P1 | 条件可执行 | `integration_heads`/`gate_log` 映射、polyrepo 验收 | +| P2 | 依赖 P1 id/substrate | 夹具必须钉 `_valid_review_acceptance` / `_assert_dependency_integrated` | +| P3 | 清晰 | 新 subparser;无现有 `proof` 冲突 | +| P4 | **高风险** | C1/D4:改错 snapshot 类型 | +| P5 | 清晰但易做错 | S1 explain 缺口;C5 标题误导 | +| P6 | 条件 | C3/C4 CLI 与版本列车 | +| P7 | 条件 | C2 attention 路径;`ReasonCode.PROOF_DECAYED` 需进 `attention.py` 分类(`:305-319` 无 hook) | + +**PR 依赖图:** P2∥P3 可行;P7∥P4 可行——但 P7 依赖 P4 reason code 与 attention 映射,不是纯 UI。 + +**0.7 不含 Card/Compiler:** 与 `config.py` adapters-only(`:244-252`)一致;`tooling.py` 发现链 **不** 进 0.7 执行面——符合 ADR。 + +## Scope And Risk + +| 风险 | 严重度 | 说明 | +|------|--------|------| +| 双 registry(`TOOL_DEFINITIONS` vs `config.adapters`) | P2(0.8) | `home.py:40-44` 与 `config.adapters` 并行;0.7 不迁移,但 P7 Console 勿把 tool READY 展示为 proof/gate 通过 | +| Proof store 变第二 PASS | P1 | 计划 `:88` 已禁;merge 必须仍读 `review.md` 绑定(`tasks.py:2057`) | +| Snapshot schema break | P0 | 错改 `SchedulerSnapshot` payload 会波及 objective tick hash、Console digest | +| `verify-bundle` 身份越界 | P2 | B1 已锁;缺 git → `inconclusive`(设计 `:192`)——与 signing 域分离,implementer 勿复用 `verify_record` | +| Terminology P0 | P2 | `terminology.py` 强制外部策略(`:114`);P0 合并 doc 不碰代码——CI 须外置 denylist,计划 `:141-143` 正确 | + +## Go/No-Go + +**Conditional Go:** P1 可启动,但在改代码前必须完成计划文档级修正(C1/C2/C4/S2/S3)与 P4 目标类型澄清。否则高概率在错误 snapshot、错误 CLI、错误 derive 源上浪费 0.7 第一个 PR。 + +A1 / B1 / Projection B **未被源码反驳**;衰减与 merge 真值源在 `tasks.py` 已存在,Proof 层应是投影而非第二道门——计划正文对此一致,但 P5 标题与 P4 类型命名构成实施噪声。 + +## Required Fixes + +1. **P0** — 重写 P4 目标:`SchedulerSnapshot` + `ProgressFacts` + `ReasonCode.PROOF_DECAYED`;废弃或迁移 `ContinuationSnapshot` 计划表述(C1, D4)。 +2. **P0** — 发布 derive 规格附录:`gate_log` / `review_verdict` / `signoff` / `integration_heads` / `action_receipt` 各 kind 的源路径、id 公式、substrate、`produced_at` 规则(S2, S3, S4)。 +3. **P1** — 统一 proof export CLI 与 design `:188`/`:340`(C3)。 +4. **P1** — 澄清 0.7 vs 1.0 的 `verify-bundle` 与 bundle schema 边界(C4)。 +5. **P1** — P5 增 `explain_task` 集成检查或显式引用 scheduler integration_state(S1);rename P5 标题(C5)。 +6. **P1** — P7 改为 `dyro objective attention`;`attention.py` 为 `PROOF_DECAYED` 指定 `AttentionKind`(C2)。 +7. **P2** — P1 验收绑定真实 test fixtures,弱化 polyrepo(S5)。 +8. **P2** — 锁定微决策 #1(D1);#2/#3 写入计划决策表,避免 implementer 自行解释(D2, D3)。 + +--- + +# Cursor Review Section + +Reviewer: Cursor(主控独立段;填写时未见 Codex / Grok / Agy 结论) +Time: 2026-08-15 +Verdict: **Conditional Go** — 品类、A1、B1、投影 B 可执行;同一份设计里定律 II 仍用未限定的「失效」,实现者会做成 A2。 + +## Contract Consistency + +三份文档在列车、CLI、kind 闭集、verify 默认行为上已对齐。残留合同裂缝在**定律层 vs 锁定层**: + +### P1-C1:定律 II 仍把「脏工作区 / gate argv 变」写成无条件失效 + +**证据:** + +- 设计文首与 §4.2 锁定 A1:`0.7` merge / 下游与 `0.6.0` 同真值;任务仓 dirty 不加严;`gate_log` 展示 decayed 但不新拒绝 merge。 +- 同一文件定律 II(约 L86–L87)仍写:「PASS review 在 task HEAD 移动、**工作区变脏**、或 attempt 被替换后失效」「gate receipt 在 **gate argv**、仓库内容或合约哈希变化后失效」。 +- 源码 `merge_task`(`src/dyro/tasks.py:2053-2063`)只重验 `_valid_review_acceptance` 与可选 signoff,**不**重跑 gate。 +- `_valid_review_acceptance`(`tasks.py:1133-1161`)比的是 receipt/heads 哈希与绑定,外加本地 `_assert_task_heads_current`。任务仓 porcelain dirty 且 HEAD 未变时,现有 merge 仍通过。 +- `_prepare_merge`(`tasks.py:1937-1939`)拒绝的是**开发线** dirty,不是任务仓 dirty。 + +**影响:** 先读定律再读 §4.2 的实现者,会把 A2 做进 `0.7`,破坏 ADR 兼容条。 + +**反证尝试:** 定律可以表示「物理上已死」,§4.2 只约束 merge 门。若 `proof list` 显示 decayed、merge 仍按旧检查放行,两套语言会同时出现在 CLI 上,用户会以为系统自相矛盾。这不能当反证成功;必须在定律 II 写明「展示衰减 ≠ 新的 merge 拒绝」。 + +**修复:** 定律 II 四条改成与 §4.2 / A1 同构:哪条影响 merge,哪条只影响展示,哪条是 0.8+。 + +**验收:** 定律 II 不再出现未限定的「工作区变脏 → 失效」;P2 夹具证明任务仓 dirty + HEAD 不变时 merge 仍与 0.6 相同。 + +### P2-C2:定律 III 把 review adapter 写成「不是写入者」 + +**证据:** `read` adapter 必须写出 `review.md`(`architecture.md` L78)。定律 III(约 L98)把 review adapter 与 dispatch / Console 并列「都不是写入者」。 + +**影响:** 实现者可能禁止复核进程写 `review.md`,或把复核误判为开发线写入者。 + +**反证:** 在「开发线 / 任务源码」语义下,复核确实不是线写入者。但文档没写这个限定。 + +**修复:** 改成「不是开发线或任务源码的写入者;复核只写 `review.md`」。 + +## Source Evidence Accuracy + +已用函数体核对、不是只搜字符串: + +| 文档声称 | 源码 | 结论 | +| --- | --- | --- | +| merge 重验 PASS + 当前 HEAD | `merge_task` + `_valid_review_acceptance` | 成立 | +| 下游要祖先,不只看 `done` | `_assert_dependency_integrated` 用 `merge-base --is-ancestor`;continuation snapshot 采样 integration | 成立 | +| `explain_task` 与调度同真值 | `graph.py:181-186` 只看依赖 `done`,不看祖先 | **文档 P5 已要求修 explain**;源码现状仍裂 | +| `progress_fingerprint` 忽略 Trigger | `budgets.py:435-446` 只哈希 task_states / integration_heads / decisions / effective_evidence | 成立 | +| `0.7` 无 proof/capability/host CLI | `cli.py` 仅有 `console` / `agent` / `tool`,无这三项 | 成立,计划是新增 | +| 术语策略在仓库外 | `terminology.py:98-114` | 成立;P0 已改成外部策略 | +| tool catalog 已发现 opencode | `tooling.py` `TOOL_DEFINITIONS` | 成立;P10 已写不得升 execute | + +### P1-C3:`explain` 与 `run` 对下游集成的合同仍裂 + +**证据:** `explain_task` 在依赖为 `done` 时不调用 `_assert_dependency_integrated`。`run_task` / continuation snapshot 会挡。计划 P5 已写要修。 + +**影响:** 0.7 若只投影 Proof 到 attention、不改 explain,用户仍会看到「可调度」然后一跑失败。这不是 A1 加严,是把已有裂口继续展示。 + +**反证:** A1 说对错不变。explain 今天就错,保持不变也符合字面 A1。但 P5 自己要求修 explain,所以这是计划内必做,不是新范围。 + +**验收:** `task explain` 在依赖 done 但未进线时给出与 `TASK_INTEGRATION_PENDING` 同类原因。 + +## Decision Validity + +A1 / B1 / 投影 B 与源码相容,不重开。 + +开放微决策(Cursor 表态): + +1. **`proof verify` 默认衰减+绑定重算。** 源码没有任何「verify 必须重跑 gate」的契约。merge 也不重跑 gate。重跑是可选诊断,不是 0.7 门。 +2. **P7 可滑到 0.8。** A1 的承重是 merge / 下游真值,不是 Console。`0.7` 出口用 `dyro proof list` + attention 即可。Console 只读展示不破坏 A1,但会扩大 0.7 表面积。 +3. **工作区本地宿主投影与现有 agent/tool 路径不冲突。** `dyro agent add` 写的是 Profile adapter;`tool list/install/open` 不写 skill 目录。默认不写 `~/.codex` 是对的。 + +### P2-C4:操作格未映射已有 mutation + +`task claim` / `evidence import` / `answer` / `changeset` 已是写动作,格子里没有。0.7 不实现格子也能做 Proof。不挡 P1。应在 0.8 Card 前补映射,避免 host deny hook 漏拦。 + +## Plan Executability + +P1–P5 在 A1 下可开工:派生五种 kind、纯函数 decay、默认 verify 不重跑、fingerprint 不双计、P5 投影现有 API。 + +P6 的 B1 形状已可写测试:有 git 夹具 → 与源机同结论;无 git 对象 → `inconclusive`。 + +### P2-C5:P7 进 0.7 出口偏肥 + +计划 §9 仍写「0.7 = P1–P7」。与微决策 2 冲突。建议 0.7 = P1–P5(+可选 P3 CLI),P6 可并行但不挡 0.7 身份,P7 延到 0.8。**须人工核**产品是否坚持「0.7 就要能在 Console 看见 Proof」。 + +## Scope And Risk + +- 最大剩余风险:定律 II 把 A1 从实现者手里抢回去。 +- 次风险:`proof list` 显示 `gate_log=decayed` 而 merge 仍过,若无人话解释,会被当成 bug。 +- 1.0 B1 不会撒谎,只要 CI 夹具带 git 对象、缺对象测 inconclusive。 +- 未发现把 dispatch / 视频升成 Proof 的回潮。 + +## Go/No-Go + +**Conditional Go** 开 P1 代码。前置:只改文档,把定律 II(及定律 III 的写入者句)收成与 A1 同构。不改产品方向,不重开 A1/B1/B。 + +## Required Fixes + +1. **P1-C1** 定律 II 与 A1 同构(必须先于 P1 实现)。 +2. **P1-C3** P5 落地时修 `task explain`(已在计划,保持)。 +3. **P2-C2** 定律 III 限定「源码写入者」。 +4. **P2-C4** 0.8 前补操作格映射。 +5. **P2-C5** 决定 P7 是否离开 0.7 出口。 + +--- + +# Final Arbitration + +Arbiter: Cursor(主控) +Time: 2026-08-15 + +## 1. Final Verdict + +- May implementation start: **Conditional Go — 只许先改文档契约;业务代码 No-Go,直到下列 P0 关闭。** +- Required preconditions: 设计 / ADR / 计划把衰减、下游、dirty、P4 类型、`verify`/`verify-bundle` 分家写到与源码同构;再发 derive 规格附录。 +- Blocking reasons: 按原文实现会改 `0.6` 对错(P5 扩下游、A2 骑手放松 dirty),或做出假 `live`(§4.2 窄枚举、§11 同结论句),或改错快照类型。 + +四人均为 **Conditional Go**。品类、A1 核心、B1、投影 B **不重开**。源码证伪的是骑手与计划用词,不是策略本身。 + +## 2. Repo / Module Go-No-Go + +| Repo/Module | Spec | Plan | Verdict | Reason | +| --- | --- | --- | --- | --- | +| 设计 `delivery-physics.md` | 定律 II / §4.2 / §11 与 A1/B1 打架 | — | **No-Go 冻结** | 先改契约 | +| ADR-0006 | 决策 9 骑手假描述 dirty;后果段「相同完整性结论」易读成同 `live` | — | **Conditional Go** | 改骑手与 §11 对齐句 | +| 计划 P1 derive | 缺 kind 源路径 / id 公式 | 验收绑空 polyrepo | **Conditional Go** | 附录齐了才能写 `proof/` | +| 计划 P2/P5 | — | 标题扩门;dirty 夹具互斥 | **No-Go 实现** | 未改合同不得合入 | +| 计划 P3 CLI | verify=rebind 已齐 | 无 `proof` 冲突 | **Go**(文档 P0 后) | 默认不跑 gate | +| 计划 P4 | 点名死类型 | journal/schema 暗示 | **No-Go 实现** | 改挂 `SchedulerSnapshot` | +| 计划 P6/P13 | B1 策略对 | 0.7 出口绑 `verify-bundle` | **Conditional Go** | export 可进 0.7;schema=1 与硬核验归 1.0 | +| 计划 P7 Console | 非 A1 门 | phantom `dyro attention` | **滑到 0.8** | 见微决策 2 | +| Card / Host Compiler | 0.8/0.9 | 不进 0.7 | **Go 保持** | 与 `config.adapters` 一致 | +| `src/dyro/proof/` 业务代码 | — | — | **No-Go** | 文档 P0 未关 | + +## 3. P0 Required Fixes + +### P0-F1: `decay(review_verdict|signoff)` = 全量现有谓词 + +Evidence: + +- 设计 §4.2 只写 `task HEAD ≠ 绑定 HEAD`(`delivery-physics.md:206`)。 +- 源码 `_valid_review_acceptance`(`tasks.py:1133-1161`)还查 receipt SHA、`task-heads.json` SHA、`validate_review_binding`(attempt/plan)、local `_assert_task_heads_current`。 +- `_valid_external_signoff`(`tasks.py:1063-1130`)再绑 review/heads/attempt/plan,策略开启时走 Ed25519。 +- Grok 签名段 P0;主控复核函数体成立。 + +Decision: + +- `decay(review_verdict)` := `_valid_review_acceptance` 全量投影。 +- `decay(signoff)` := `_valid_external_signoff` 全量投影。 +- `False` 拆 `decayed`(绑定/HEAD/哈希变了)与 `inconclusive`(缺文件/缺工具/不可解析)。 +- 开发线 dirty / 错分支保持 `_prepare_merge` 现有错,**禁止**标成 `PROOF_DECAYED`。 + +Acceptance: + +- 同一组 `0.6` 夹具:`proof verify` 的 `live` 集合 = `merge_task` 在「忽略线 dirty/错分支之后」的接受集合。 +- receipt 改写、binding 删除、signoff 失绑不得 `live`。 +- 仅脏开发线、绑定仍成立 → 现有 merge 错,无 `PROOF_DECAYED`。 + +### P0-F2: `verify` 与 `verify-bundle` 分家;删「与控制面相同的 live」 + +Evidence: + +- 设计 §11(`delivery-physics.md:428`)与 ADR 后果(`0006:61`)写「与控制面相同的 `live` / `decayed` / `inconclusive`」。 +- B1 / §4.1:`verify-bundle` 核的是钉死 substrate + 调用方 git 对象;控制面 `verify`/merge 用当前工作区。 +- HEAD 已走后二者必然分叉。Grok P0;`architecture.md`「完整性不是身份」可留。 + +Decision: + +- `proof verify` = `decay(proof, current_workspace_substrate)`。 +- `verify-bundle` = 完整性(字节哈希 + 钉死 SHA 在 `--git-dir` 可解析)。无 `--current-heads` 不得报与 merge 相同的衰减结论。 +- 删掉品类句里「与控制面相同的 `live`」。可写「相同的完整性结论」,并定义完整性 ≠ 现在能否 merge。 + +Acceptance: + +- 同一 bundle,源机 HEAD 已移动:工作区 `verify` → `decayed`;裸 `verify-bundle` 不得自动报 `decayed`。 + +### P0-F3: P5 下游只投影祖先检查;merge/dispatch 不读 Proof store + +Evidence: + +- 计划图 `:105`「merge / 下游释放拒绝 decayed review」;P5 正文把三个检查并列在「merge 与下游」下(`:184-187`)。 +- 源码下游只走 `_assert_dependency_integrated`(`check_dispatchable:552-556`,`snapshot.py:167-186`)。不调 `_valid_review_acceptance`,不查 dirty。 +- Codex P0、Grok P1、Agy C5。主控复核成立。升 P0:按标题实现会缩窄 ready set,破 A1。 + +Decision: + +- P5 改名为「交付门 decay **投影**(A1)」。 +- merge = `_valid_review_acceptance` + 可选 signoff + `_prepare_merge`。 +- 下游 = 只 `_assert_dependency_integrated`。禁止 `if proof.status != live: block downstream`。 +- `merge_task` / `check_dispatchable` **不**读 Proof store。 +- `explain_task` 对每个 `done` 依赖复用同一祖先检查(修 0.6 已有裂口;`graph.py:181-186` 只看 `done`)。 + +Acceptance: + +- `done` + `review.md` 被撕 / 任务 HEAD 已漂,但 `task-heads.json` 仍是线祖先 → 下游仍 ready。 +- 仅 ancestor 失败 → 只报 `TASK_INTEGRATION_PENDING`;`explain.dispatchable=false` 且文案与 `check_dispatchable` 同类。 +- `0.6` merge/下游 accept/reject 集合不变。 + +### P0-F4: 删除「A2 已否决 / 0.7 不拒绝任务仓 dirty」 + +Evidence: + +- 设计文首 A1、§4.2:206、计划 `:158,:271`、ADR 决策 9 均写「不加严任务仓 dirty」。 +- 源码 `_collect_task_heads`(`tasks.py:905-909`)在 porcelain dirty 时抛错;local `_valid_review_acceptance` 与 `_prepare_merge` 都走到这里。 +- Codex P0、Agy S6。**Cursor 独立段「dirty + HEAD 未变时 merge 仍通过」被源码证伪,本仲裁作废该事实,不改写 Cursor 原文。** +- A1 **核心**(与 0.6 同真值)优先于骑手。骑手建立在错误源码阅读上。 + +Decision: + +- 不重开 A1 核心,不放松 0.6。 +- 删除 A2 否决句与「0.7 不拒绝任务仓 dirty」。 +- 写明:0.6 **已经**拒绝任务仓 dirty;0.7 保持;Proof 只投影,不得改为 accept,也不得再叠第二道门。 +- 定律 II 的「脏」限定为 **merge / review-acceptance 路径**(任务仓 dirty + 开发线 dirty),**不得**延伸到下游。 + +Acceptance: + +- 锁夹具:`done` + 任务仓 dirty + HEAD 未变 → merge reject,错误集与 0.6 相同。 +- P2 夹具分表:`review_verdict` ← `_valid_review_acceptance`;线 dirty ← `_prepare_merge`。禁止「同真值」与「dirty 不拒绝」互斥验收并存。 + +### P0-F5: P4 改挂 `SchedulerSnapshot` / `ProgressFacts`;journal 不存 proofs + +Evidence: + +- 计划 `:177` 点名 `ContinuationSnapshot`。该类型无任何 `ContinuationSnapshot(` 实例化;生产采样是 `SchedulerSnapshot`(`snapshot.py:53-74`)。 +- `decide_no_progress` 只出现在 `tests/test_continuation_budgets.py`;生产 `_budget_usage` 不设 `no_progress_cycles`。 +- Agy C1/D4 P0;Codex P1。升 P0:改错类型则 P4 空转或打爆 digest。 + +Decision: + +- P4 目标:`SchedulerSnapshot._payload` + `ProgressFacts` 装配点 + `ReasonCode.PROOF_DECAYED`。 +- 标注或废弃计划中的 `ContinuationSnapshot`。 +- journal **不**持久化 proofs 当 PASS;`schema_version` 保持 1,新字段缺省空。 +- 0.7 **不**把 Proof 接入生产 `BudgetUsage` / no-progress 耗尽。 +- 同步 `attention.py` 与 `_schedule_block_reason`;默认 **不**用该码 block 下游。 + +Acceptance: + +- 同一 substrate 下 `build_scheduler_snapshot` digest 稳定;旧 journal 无 proof 字段兼容。 +- `test_continuation_budgets` 仍绿;0.7 不新增 no-progress 自动耗尽。 + +### P0-F6: 发布 derive 规格后再写 `proof/` 代码 + +Evidence: + +- `integration_heads` 无持久文件,由 `git merge-base --is-ancestor` 即时判定(`tasks.py:973-986`)。 +- `gate_log` 本地 `gate-{n}.log` vs 外部 `gates.json` + `gates/*.log`。 +- `action_receipt` 在 Objective `action-receipts/`,不在 task 目录。 +- `examples/polyrepo` 仅有 `dyro.toml`。Agy S2/S3/S4/S5。 + +Decision: + +- P1 附录写清五种 kind 的源路径、id 公式、substrate、`produced_at`(只取记录内字段,禁止 mtime 进身份哈希)。 +- `proof list --task` **不含** `action_receipt`。 +- 黄金哈希夹具用 `tests/` 现有 bound review,polyrepo 降为 smoke。 + +Acceptance: + +- 表驱动:`integration_heads` 三态与 scheduler 一致;local/external 各一条 `gate_log`;两次派生、不同 mtime,身份哈希相同。 + +## 4. P1 / P2 + +**P1(文档修订时一并改,不挡「先改合同」但挡对应切片开工):** + +1. `proof export` 锁定一种 id 语义(proof-id 或 `--task`);设计 `:188`/`:340` 同形。 +2. 0.7 vs 1.0:P3 = `list/show/verify`;P6 `export` 可进 0.7 且标 experimental;`verify-bundle` 硬门禁 + `schema_version = 1` 归 1.0/P13。 +3. P7 命令改为 `dyro objective attention`;去掉 phantom `dyro attention`。 +4. 定律 I / CLI:默认 `verify` 的 `live` = rebind holds,不是 procedure 已复现;未 `--rerun-procedure` 的 `gate_log` 不得 `procedure_reproduced=true`。 +5. Proof 签名槽:增加 `declared_key_ids` + 导出时 `require_signed_*` 快照,**或**删除「缺已声明密钥 → inconclusive」并禁止与控制面同结论。二选一。 +6. P6 拒绝 `task evidence` ZIP 布局;不把 git 对象放入 bundle。 +7. 定律 III:review adapter 不是开发线/任务源码写入者,但必须能写 `review.md`。 +8. P11:默认 `.dyro/host-projections/`;`tools.json` / PATH = `discovered_unintegrated`;`--user` 才写用户级 skill。 + +**P2:** + +1. `require_clean_merge` 写明为 schema 不变量,线脏预检硬编码。 +2. P0 文档切片把不变量 14–20 回写 `architecture.md`。 +3. 0.8 前补操作格与已有 mutation(`claim` / `evidence import` / `answer` / `changeset`)映射。 + +## 5. Open Micro-Decisions + +| # | 决议 | 依据 | +| --- | --- | --- | +| 1. `proof verify` 默认 | **锁定 decay + rebind,不重跑 gate。** `--rerun-procedure` 仅诊断,须 dry-run/隔离。 | 四人一致;`merge_task` 无 `run_gates` | +| 2. P7 是否进 0.7 | **滑到 0.8。** 0.7 出口改为 P1–P5 + P3 CLI;P6 export 可选、experimental。若产品坚持 0.7 做 Console:summary **零新 git I/O**,衰减走 `not_inspected` 或独立 inspect。 | Cursor/Grok/Codex 可滑;Agy 建议留下。A1 不依赖 Console;先滑以降低假 `live` 广播面。 | +| 3. 宿主投影默认工作区 | **锁定。** 与 `agent`/`tool` 发现面不固有冲突。禁止把 `tools.json`/PATH 当 Card。 | 四人一致 | + +## 6. Instructions For The Execution Agent + +```text +First revise the spec/plan. Do not implement business code yet. + +Read: +/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/docs/superpowers/reviews/2026-08-15-delivery-physics-adversarial-review-board.md +Focus on: Final Arbitration P0-F1 … P0-F6. + +Must close in: +- docs/designs/delivery-physics.md +- docs/adr/0006-delivery-physics-and-capability-plane.md +- plans/delivery-physics-implementation.md +- docs/architecture.md(不变量 14–20 可与 P0 文档切片同 PR) + +Do not: +- edit other reviewers' original sections +- start src/dyro/proof/ or change merge_task / check_dispatchable +- leave P0 as "implementation note" +- reopen A1 core / B1 / 投影 B / 品类 +- treat Cursor 独立段关于「dirty merge 仍通过」为事实 + +Write back: +- files and sections changed +- status for each P0: closed / open / needs user decision +- updated Go/No-Go +``` + +## 7. Conditions To Start Implementation + +关闭 P0-F1…F6 的文档 diff 后: + +- **可开:** P1 derive + 测试夹具(非 polyrepo 黄金哈希)、P3 CLI。 +- **仍不可开:** P2/P5 实现(须合同已改且夹具不再互斥);P4(须类型映射已改)。 +- **0.7 身份:** P1–P5 + P3;P6 export 可选;P7 默认不进。 + +## 8. Requires Human Verification + +- ~~Proof `contract_hash` 用哪一个。~~ **已锁定(2026-08-15 用户确认)**:task 面 → attempt `task_contract_sha256`;`action_receipt` → Objective `contract_sha256`。 +- ~~`proof list` / `verify` 是否每次重派生。~~ **已锁定**:每次全量重派生;store 不是 PASS。 +- 若产品坚持「0.7 就要在 Console 看见 Proof」,覆盖微决策 2,但仍须 C01(summary 零新 git I/O)。 +- 若用户本意是**放松** 0.6 的任务仓 dirty 拒绝:须显式改口;本仲裁按 A1 核心「同真值」解释为保持拒绝。 + +Final signature: Cursor(主控) +四人签名已齐。Cursor 独立段填写时未见另外三份;本仲裁是唯一合并意见。 + +--- + +## 9. Execution writeback(2026-08-15) + +文档契约已按本仲裁修订。未写 `src/dyro/proof/`,未改 `merge_task` / `check_dispatchable`。 + +| P0 | 状态 | +| --- | --- | +| P0-F1 全量 decay 谓词 | **closed**(设计 §4.2 表 + 定律 II + ADR 决策 9 + 计划 P2) | +| P0-F2 verify / verify-bundle 分家 | **closed**(设计 §4.1 / §11 + ADR 决策 10 / 后果 + 计划 P6/P13) | +| P0-F3 P5 下游只投影祖先 | **closed**(计划 P5 更名与验收;设计定律 II / §4.2) | +| P0-F4 删除 A2 dirty 假描述 | **closed**(三份文首 + 计划延后表;保持 0.6 拒绝) | +| P0-F5 P4 改挂 SchedulerSnapshot | **closed**(设计 §4 / §6 + 计划 P4) | +| P0-F6 derive 规格附录 | **closed**(计划附录 A;设计 §4.1 指向该附录) | + +P1 文档项(export id、列车、`dyro objective attention`、rebind `live`、签名槽、evidence ZIP、定律 III、P11 scope)已写入设计/计划。P2:`require_clean_merge` 不变量已写;架构不变量 14–20 已回写 `architecture.md`。操作格与已有 mutation 映射仍待 0.8。 + +更新后 Go/No-Go:文档 **Go**。P1 derive + P3 CLI **可开**。P2/P4/P5 **实现仍 No-Go,直到对应 PR 按已改合同写夹具**(合同本身已改)。 + +须人工核已关(用户确认按推荐):`contract_hash` 按 subject 拆;`list`/`verify` 每次重派生。仍开:P7 是否被产品改回 0.7;是否放松任务仓 dirty(默认不放松)。 diff --git a/docs/superpowers/reviews/2026-08-15-delivery-physics-implementation-adversarial-review-board.md b/docs/superpowers/reviews/2026-08-15-delivery-physics-implementation-adversarial-review-board.md new file mode 100644 index 0000000..e47391e --- /dev/null +++ b/docs/superpowers/reviews/2026-08-15-delivery-physics-implementation-adversarial-review-board.md @@ -0,0 +1,904 @@ +# 交付物理学实现对抗式复核审查委员会 + +日期:2026-08-15 + +范围: + +- 仓库:`/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow` +- 模块:已落地的 `0.7` Proof / 衰减、`0.8` Capability / Console、`0.9` Host Compiler、`1.0` `verify-bundle` +- 基线:已发布 `0.6.0` 语义;本树实现尚未升版本号 + +审查材料: + +- 实现:`src/dyro/proof/`、`src/dyro/capability/`、`src/dyro/host/`、`src/dyro/cli.py`、`src/dyro/tasks.py`、`src/dyro/graph.py`、`src/dyro/continuation/`、`src/dyro/console/read_model.py` +- 测试:`tests/test_proof_*.py`、`tests/test_capability.py`、`tests/test_host.py`、`tests/test_readme_identity.py`、`tests/test_release_gates.py` +- 发布:`.github/workflows/ci.yml`、`.github/workflows/pypi-publish.yml`、`tools/verify_bundle_stranger.py`、`tools/verify_release_gates.py` +- 叙事:`README.md` 与各语言 README、`pyproject.toml`(仍为 `0.6.0`) + +SSOT(源码优先于文档与先前评审): + +- `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/src/dyro/tasks.py` +- `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/src/dyro/proof/bundle.py` +- `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/src/dyro/proof/evaluate.py` +- `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/src/dyro/host/doctor.py` +- `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/src/dyro/capability/probe.py` +- `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/plans/delivery-physics-implementation.md` +- `/Users/dandre/DyroProjects/DyroEngineeringFlow/versions/dev_0814/dyroengineeringflow/docs/superpowers/reviews/2026-08-15-delivery-physics-adversarial-review-board.md`(设计评审;本轮审实现,不重开已锁决策,除非源码证伪) + +模式:Code review mode。材料是已落地实现,不是待批设计。 + +固定决策(除非源码证明其错误,否则不得重开): + +- 产品身份:本地优先的多仓交付物理引擎。 +- 权威投影 B:必编译 skill;仅当 hook 表面被证明时再投影 deny hook;hook 不是 OS 隔离。 +- **A1**:衰减是现有 merge / 下游绑定检查的投影。`merge_task` / `check_dispatchable` 不读 Proof store。 +- **B1**:`verify-bundle` 核完整性,不核身份,不承诺与当前工作区 `verify` / merge 同一套 `live` / `decayed`。 +- 任务仓 dirty:保持 `0.6` 拒绝。 +- `ContinuationSnapshot` 是死类型。 +- 不把 `tools.json` / PATH 当已审计 Card。 +- 不把 hook 写成沙箱。 +- 不在产品面写对标附录。 + +开放微决策(请表态,不要另开产品线): + +1. deny hook 只写在 `.dyro/host-projections/`,不写入 Card 声明的 `hook_surface`,是否构成「已投影但宿主读不到」的假权威? +2. 包版本仍是 `0.6.0`,但 `verify-bundle` 与 README 身份句已按 `1.0` 落地,对外是否构成版本谎言? +3. 单/`多` `--git-dir` 是否足够覆盖多仓对象库,还是必须在 1.0 叙事里写明「调用方负责备齐对象」? + +## 规则 + +1. 审查员只写自己的签名章节。他人不得改写、缩写或润色该章。 +2. 源码、现有 schema、现有测试优先于设计稿和计划。 +3. 无法从源码或所供材料证明的主张标 `须人工核`。 +4. 发现使用 P0 / P1 / P2。 +5. 本轮以缺陷、回归、安全、契约破裂、缺失测试为先;风格问题除非造成可测风险,否则不报。 + +--- + +# Cursor Review Section + +Reviewer: Cursor(主控独立核源,不继承其他席位) +Time: 2026-08-15 +Verdict: Conditional Go + +## Contract Consistency + +A1 在 merge / dispatch 主路径上成立:`tasks.py` 的 `merge_task`(2058–2068)与 `check_dispatchable`(545–556)只调用 `_valid_review_acceptance` / `_valid_external_signoff` / `_assert_dependency_integrated`,全文件无 `list_proofs` / `evaluate_proofs` 导入。`PROOF_DECAYED` 只出现在既有拒绝的人话里(2063–2067)。 + +P5:`graph.py` `_explain_with_config`(195–198)对 `done` 依赖调用 `_assert_dependency_integrated`。 + +P4:`SchedulerSnapshot.decayed_merge_subjects`(snapshot.py:70)在 `inspect_proofs` 为真时填充(280–285);`_payload`(83–93)不含该字段,digest 不因 Proof 检查而变。`live_merge_evidence` 只在测试中拼进 `ProgressFacts`,生产 `BudgetUsage` 未接线。`ContinuationSnapshot` 仍是死类型。 + +B1:`verify_bundle`(bundle.py:63–119)默认不做 workspace decay;无 `--current-heads` 时 `_integrity_of` 只返回 `live` 或 `inconclusive`(184–185)。CLI JSON `mode=integrity`。`task evidence` ZIP 无 `manifest.json` 时走 `NOT_PROOF_BUNDLE`。 + +P7:`observations.py` Console 采样 `inspect_integration=False`(151, 284),因此 `inspect_proofs` 默认亦为 False;`proof_inspection` 恒为 `not_inspected`(208, 358)。 + +P10:`_adapter_argv` 在 `mode=="write"` 且 Card `intents` 无 `execute` 时拒绝(tasks.py:1180–1181)。 + +P12:`apply_supervised_wave` 在取 OwnerLease 之前调用 `assert_projections_allow_mutation`(supervision.py:390–392)。无 manifest 时 `compiled=False` 放行(doctor.py:73–75)。 + +## Source Evidence Accuracy + +已用当前源码核对,不是计划转述。 + +## Decision Validity + +锁定决策未被实现推翻。开放微决策的源码事实: + +1. deny hook 只写 `host-projections//deny-hook.json`(compile.py:336–339)。`hook_surface` 只作存在性证明(probe.py:64–76)。宿主进程不会自动加载该文件。 +2. `pyproject.toml` version 仍为 `0.6.0`。`verify-bundle` 与 README 身份句已按 1.0 契约落地。`verify_release_gates.py` 仅在 tag/version 为 1.0.0 时强制门禁。 +3. `--git-dir` 可重复;对象在任一库可解析即可。无 pin 的 Proof(如仅有 `bytes_sha256` 的 `action_receipt`)在缺 `--git-dir` 时仍可 `live`(bundle.py:178–185)。 + +## Plan Executability + +P1–P13 主路径有测试。缺口见 Required Fixes。 + +## Scope And Risk + +- 完整性 `live` 被误读成「现在能 merge」:CLI 有 `mode=integrity` 与帮助文案,README 身份句未解释两套结论。 +- Host doctor 只挡 `objective apply`,不挡 `task run` / `task merge`。与 P12「mutation tick / ActivationLease」字面一致,但比「所有 mutation」窄。 +- `[[capabilities]]` 无 launch 的 Card 在 `test_capability` 中永不可执行(probe.py:46–50),host 可用表只含 adapter 探测通过者。 + +## Go/No-Go + +Conditional Go:可以当 0.7–0.9 实现与 1.0 **能力**收口,但在升 `1.0.0` 版本号之前必须关闭下列 P1(P0 未看到会改 0.6 merge 对错的破裂)。不得在当前 `0.6.0` 包号下对外宣称已发布 1.0。 + +## Required Fixes + +### P1 · 无 git pin 的 bundle 在缺 `--git-dir` 时可以 `live` + +证据:`bundle.py` `_integrity_of` 仅当 `shas` 非空才要求 `git_dirs`。`action_receipt` 常无 `repo_heads`。 + +决策:1.0 叙事写「必须提供 git 对象」。缺 `--git-dir` 时整捆不得 `live`(全部 `inconclusive`),或 CLI 无 `--git-dir` 直接拒绝。 + +验收:只含 `action_receipt` 的 bundle,无 `--git-dir` → 退出码 3,无 `live`。 + +### P1 · deny hook 未投影到已证明表面 + +证据:compile 只写投影树;`hook_surface` 不被写入。 + +决策:产品上二选一并写进 CLI/doctor:要么声明「hook 是可搬运的策略文件,宿主需自己指向投影路径」;要么在表面是目录时写入 `hook_surface/dyro-deny.json`。不得暗示宿主已经在拦截 `integrate`/`publish`。 + +### P1 · 版本号与 1.0 能力不同步 + +证据:`pyproject.toml` `0.6.0`;README 已锁身份句;`verify-bundle` 已是硬门禁。 + +决策:对外发布前升到与列车一致的版本,或在 README 标明「实现已含 1.0 核验能力,包版本未升」。禁止用 0.6.0 轮子暗示 1.0 已发布。 + +### P2 · bundle 级失败伪造成 `review_verdict` + +证据:`_bundle_inconclusive`(bundle.py:276–287)kind 固定为 `REVIEW_VERDICT`。 + +决策:使用明确的 bundle 失败 subject/kind 展示,避免 JSON 消费者以为存在一条复核 Proof。 + +### P2 · Host doctor 不管 `task run`/`merge` + +证据:仅 `apply_supervised_wave` 调用 `assert_projections_allow_mutation`。 + +决策:若产品要「手改投影不能偷偷继续自动跑」,写明范围是受监督 apply;或把同一检查扩到其他 mutation 入口。 + +--- + + +# Code Reviewer Review Section + +Reviewer: code-reviewer +Time: 2026-08-15 +Verdict: Conditional Go + +## Contract Consistency + +A1 holds on the merge / downstream write path. `tasks.py` does not import `dyro.proof`. `merge_task` (2058–2068) still only calls `_valid_review_acceptance`, optional `_valid_external_signoff`, then `_prepare_merge`. `check_dispatchable` (545–556) still only walks decisions, `done`, and `_assert_dependency_integrated`. `PROOF_DECAYED` is extra wording on those same rejects, plus planner attention (`planner.py` 272–281). `build_task_readiness` (70–176) does not read `decayed_merge_subjects`. Torn `review.md` still leaves downstream dispatchable when the ancestor holds (`test_proof_decay.py` 358–366). Line dirty stays `_prepare_merge`’s “开发线仓库不干净”, not `PROOF_DECAYED` (303–313). + +B1 is split correctly at the CLI: workspace `verify` is `mode=rebind`; `verify-bundle` is `mode=integrity` (`cli.py` 1919–1951). No `--current-heads` cannot emit `decayed` (`bundle.py` 183–184; `test_proof_cli.py` 141–145, 208–229). Evidence ZIP and in-zip git layouts are inconclusive (`bundle.py` 80–89, 230–237). + +The 1.0 integrity contract is not closed. `_valid_manifest` (224–227) only checks `kind` + `schema_version`. `verify_bundle` (99–102) skips the member digest when `proof_sha256[id]` is missing or empty, so a rewritten `proofs/*.json` can still become `live` if procedure / substrate / caller objects pass. That is not “integrity of Proof Bundle + caller git objects”. + +`★ Insight ─────────────────────────────────────` +A1 is a *projection* rule: merge keeps the 0.6 predicates; Proof only names the same False. B1 is a *different* predicate: zip bytes + caller objects. The bug is treating a present JSON member as attested when the manifest no longer binds its digest. +`─────────────────────────────────────────────────` + +## Source Evidence Accuracy + +Derive identity omits clock/mtime (`models.py` 68–70; `test_proof_derive.py` 138–145). `list_proofs --task` excludes `action_receipt` (`derive.py` 34–40). `contract_hash` is attempt `task_contract_sha256` vs Objective `contract_sha256` (`derive.py` 120, 449–454). `ContinuationSnapshot` is still a dead type with no `proofs[]` (`models.py` 268–278). `SchedulerSnapshot._payload` (83–128) does not include `decayed_merge_subjects`, so the digest does not become a second PASS. Console summary hard-codes `proof_inspection=not_inspected` (`observations.py` 208) and adds no Proof git I/O. + +`force_inconclusive` is a no-op: both branches set `INCONCLUSIVE` (`derive.py` 374). Evaluate then overwrites from predicates that return `False` rather than `None` for missing/unparseable files (`evaluate.py` 49–50, 98–102; `_valid_review_acceptance` 1137–1147 returns `False` on missing receipt/review). Plan/ADR: 缺文件 / 不可解析 → `inconclusive`. Tests only assert derive-time inconclusive (`test_proof_derive.py` 130–136); after `list`/`verify` the same torn review is `decayed` (`test_proof_decay.py` 281–287). Does not forge `live`. Does not change merge truth. + +`_integrity_of` requires `--git-dir` only when `repo_heads` contain SHAs (`bundle.py` 177–184). CLI help says `--git-dir` 缺省不得报 live (`cli.py` 2838–2842). A proof with procedure + `bytes_sha256` and empty heads can be `live` with no git objects. 须人工核 how often exported task proofs lack heads; the reviewed-task export path does pin heads and fail-closes without `--git-dir`. + +Host compile never reads `tools.json`. PATH hits stay `discovered_unintegrated` (`probe.py` 19–36) and cannot `run_task` (`test_capability.py` 143–167). Default write is `config.root / .dyro/host-projections/`; `--user` writes `registry_home()/host-projections/` (`compile.py` 67–70). Never-compiled does not block apply (`doctor.py` 70–74; `test_host.py` 287–297). Stale compiled raises before lease/Task APIs (`supervision.py` 390–392; `test_host.py` 299–310). Message says “已降为 plan-only”; the wave is aborted, not rewritten to plan-only actions. No `ActivationLease` type exists in this tree. 须人工核 any out-of-tree automatic mutation tick. + +## Decision Validity + +Locked decisions that source confirms: A1; dirty task workspace still rejected via `_collect_task_heads` inside local `_valid_review_acceptance`; P4 target types; Authority B compile-always (OpenCode fixture without hook is `skill_only`, `test_host.py` 140–161); fake/absolute `hook_surface` does not write a hook (163–197); hook text is intent-lattice JSON, not a sandbox (`compile.py` 300–314). + +Open decisions (position only): + +1. **Deny hook only under `host-projections/` is a false-authority gap, not an A1/B1 break.** `capability test` only proves the Card path exists (`probe.py` 64–76). Compile writes `.dyro/host-projections//deny-hook.json` (`compile.py` 335–340), never the Card’s `hook_surface`. Doctor then attests `skill_and_hook` against *that* artifact (`doctor.py` 154–162). No in-tree host loader consumes `deny-hook.json`. 须人工核 whether any external host maps that file. Until then `authority_projection=skill_and_hook` is an artifact flag. Locked “hook is not OS isolation” stops this from being P0. + +2. **Yes: shipping this tree as 0.6.0 is a version lie.** `pyproject.toml` is still `0.6.0`. README identity sentences, `schema_version = 1` export, and non-experimental `verify-bundle` have landed (`test_proof_cli.py` 116–117). Published 0.6.0 did not have those commands or that identity. `tools/verify_release_gates.py` 50–52 **skips** the 1.0 file gates while version ≠ `1.0.0`, so this tree can be tagged/published as 0.6.x with 1.0 contracts and a green “skip”. Treating 1.0 as releasable requires `version = "1.0.0"`. Publishing this tree as 0.6.0 is also forbidden by the plan’s “不回写 0.6.0 已发布语义”. + +3. **Repeatable `--git-dir` is enough as a mechanism; 1.0 docs must say the caller assembles objects.** `action=append` plus `any(cat-file)` (`bundle.py` 263–264) does not map `repo_id` → object store and does not read workspace layout. Missing objects → `inconclusive`. That is correct B1 fail-closed. Docs/help must not imply Dyro will gather polyrepo objects. `_SHA_RE` allows 7-char abbreviations (`bundle.py` 22); short SHAs plus `any()` across dirs can resolve in the wrong store. Require full hex (40 or 64) before calling this 1.0-portable. + +## Plan Executability + +P1–P5, P8–P12a, and the P13 identity strings are present and tested at the happy path. P7 Console Proof is correctly parked (`not_inspected`). P4 production `BudgetUsage` is not wired (`live_merge_evidence` only used in `test_proof_decay.py`). + +Missing tests that leave the P0 unguarded: no case deletes `manifest.proof_sha256` (or one id) and asserts `inconclusive`, not `live`. `test_proof_cli.py` 124 only checks the key exists on a honest export. + +P13 “sdist 干净环境陌生人核验” is not what runs. `tools/verify_bundle_stranger.py` builds a hand-made zip and invokes whatever argv the test passes (`tests/test_proof_bundle.py` 76–80: `sys.executable -m dyro`). That is in-tree CLI, not an sdist install. `verify_release_gates.py` is substring presence, not behavior. + +`agent add` still writes `[adapters.*]` (`cli.py` 840–851), not `[[capabilities]]`. Load-time upgrade still yields Cards. 0.8 P9 wording is unmet; 1.0 behavior is compatible. Not a merge-truth break. + +须人工核: bitwise accept/reject vs the published 0.6.0 sdist. This tree’s `merge_task` / `check_dispatchable` control flow matches the locked A1 shape; a binary/sdist diff was not in the audit set. + +## Scope And Risk + +Scope stayed on the projection train: Proof, Cards, host skill, portable verify. Merge/downstream predicates were not replaced. Risk that blocks 1.0 is the verify-bundle digest skip (stranger can rewrite pins) and the 0.6.0 identity (users and release gates cannot tell which contract they installed). Residual risk: `skill_and_hook` overclaim; short SHA / unmapped polyrepo `--git-dir`; missing-file Proofs labeled `decayed` instead of `inconclusive`. Those do not change 0.6 merge/downstream truth. + +## Go/No-Go + +**Conditional Go** for treating 1.0 as releasable. + +Not Go: B1 integrity is bypassable without `proof_sha256`, and the package still claims 0.6.0. +Not No-Go: A1 is intact; dirty workspace still rejected; never-compiled host projections do not block apply; PATH/`tools.json` cannot execute; workspace `verify` and `verify-bundle` are separate conclusions on the tested export path. + +Do not tag `v1.0.0` or publish this tree as 0.6.0 until Required Fixes P0 are done. + +## Required Fixes + +**P0 — `verify-bundle` must fail closed when a member digest is missing** +File: `src/dyro/proof/bundle.py` 99–102, 224–227. +B1: integrity of the Proof Bundle. `if expected and …` treats omitted/empty `proof_sha256` as success, then `_integrity_of` can return `live`. +Fix: `_valid_manifest` must require `proof_ids` to be a non-empty string list and `proof_sha256` to be a dict with a non-empty hex digest for every id. If a digest is missing or mismatched → `BUNDLE_BYTES_MISMATCH` / `inconclusive`, never `live`. Add a test that rewrites `proofs/.json` after deleting that digest and asserts not `live`. + +**P0 — do not release this tree as 0.6.0; do not call it 1.0 until the version is 1.0.0** +Files: `pyproject.toml` 7; `tools/verify_release_gates.py` 50–52. +1.0 identity + `verify-bundle` already landed under `version = "0.6.0"`, and the 1.0 gate no-ops until the number changes. +Fix: set `project.version` to `1.0.0` only after the P0 digest fix; refuse any 0.6.x publish of this tree. Keep the skip only for unrelated 0.6.x maintenance lines that do not contain `src/dyro/proof/bundle.py`. + +**P1 — missing / unparseable evidence must stay `inconclusive` after evaluate** +Files: `src/dyro/proof/derive.py` 374; `src/dyro/proof/evaluate.py` 49–65, 98–102. +ADR/plan: 缺文件、缺工具、不可解析 → `inconclusive`. `_valid_review_acceptance` / `_valid_external_signoff` return `False` for those cases, so `list`/`verify` report `decayed`. +Fix: either make `_predicate` distinguish missing/unparseable (`None`) from failed rebind (`False`), or honor `force_inconclusive` in `evaluate_proof` and skip the predicate. Extend `test_missing_review_binding_is_inconclusive` through `evaluate_proofs` / `proof verify`. + +**P1 — `skill_and_hook` must not imply the host loaded the deny hook** +Files: `src/dyro/host/compile.py` 194–209, 335–340; `src/dyro/capability/probe.py` 64–76. +Deny hook is written only under `host-projections/`, not `hook_surface`. +Fix (pick one, do not open a new product line): document in compile/doctor JSON that `skill_and_hook` means “artifact written beside SKILL.md, not installed at hook_surface”; or stop emitting `skill_and_hook` until a host-specific install path exists. Do not write into `hook_surface` unless a later decision explicitly expands compile authority. + +**P1 — portable git pins must be full object IDs; docs must say the caller assembles stores** +Files: `src/dyro/proof/bundle.py` 22, 177–184, 263–268; `src/dyro/cli.py` 2838–2842. +`--git-dir` repeat is the right mechanism. 7-char `any()` lookup is not polyrepo-safe. Help text “缺省不得报 live” is false for empty `repo_heads`. +Fix: accept only 40- or 64-char hex; keep missing objects `inconclusive`; state in verify-bundle help that the caller must pass every object store that contains the pinned SHAs. Align help with the empty-heads case (inconclusive if the bundle declared heads; do not claim a blanket “no git-dir ⇒ never live” unless you also require `--git-dir` whenever the zip exists). + +**P2 — P13 stranger job is not an sdist install** +Files: `tools/verify_bundle_stranger.py`; `tests/test_proof_bundle.py` 76–80. +Plan P13 asked for a clean sdist environment. Current job is in-tree `python -m dyro`. +Fix before a 1.0 tag: install the sdist into a throwaway env, then run the same fixture. Not required to restore A1. + + +# Security Reviewer Review Section + +Reviewer: security-reviewer +Time: 2026-08-15 +Verdict: **No-Go** + +## Contract Consistency + +B1 / ADR-0006 / P6 and the CLI help agree: `verify-bundle` is integrity, not identity; missing procedure / substrate / git objects / required declared keys must be `inconclusive`; no `--git-dir` must not be `live`; evidence ZIPs must not be `live`; hook is not a sandbox; PATH discovery is not execute. + +Source matches the **labels** (`mode=integrity`, help text「不是身份证明」, skill negatives, `discovered_unintegrated` never compiled into execute). Source **breaks the live/fail-closed predicates**. + +| Contract | Source | +|---|---| +| P6 / CLI `--git-dir`「缺省不得报 live」 | `_integrity_of` returns `LIVE` when `repo_heads` SHAs are empty, even with `git_dirs=()` (`bundle.py:178-184`) | +| Bundle byte integrity | `proof_sha256` is optional; empty/missing skips the hash (`bundle.py:99-102`, `_valid_manifest` only checks `kind` + `schema_version` at `224-227`) | +| 缺已声明的签名密钥 → inconclusive | Only `value == "true"` (`bundle.py:220-221`); JSON `true` becomes `"True"` via `str(value)` (`bundle.py:151`) and does **not** require keys | +| Evidence ZIP → inconclusive | Holds for real `task evidence` layout (`bundle.py:80-81`, `230-232`; `test_proof_cli.py:194-206`). Adding `manifest.json` disables the evidence detector; that is forging, not accidental accept | +| Authority B: hook is not a sandbox | CLI/help and hook JSON omit “sandbox”. Hook is written only under `host-projections/`, never to `hook_surface` (`compile.py:338-340`) | +| PATH ≠ executor | `discover_unintegrated` is list-only (`probe.py:19-36`); `available` requires `execute` + adapter probe (`compile.py:187-193`) | +| Never-compiled compatibility | Intentional: `assert_projections_allow_mutation` returns if no `*.toml` (`doctor.py:70-74`; `test_host.py:287-297`) | +| A1 merge/dispatch unread Proof store | No `merge_task` / `check_dispatchable` read of the bundle path in this tree (out of this mutation-gate slice; not reopened) | + +`process.run` is argv, no shell (`process.py:18-50`). `git --git-dir=` / `git -C` / `cat-file -e` / `merge-base` get a path or a SHA matching `^[0-9a-f]{7,64}$`. No command-injection gadget in this slice. ZIP members are read, not extracted; `_read_member` rejects absolute / `..` (`bundle.py:240-250`). Classic zip-slip write is not present. + +## Source Evidence Accuracy + +Proven from this tree only (not inherited): + +1. **False `live` without caller git objects (P0).** + `_has_substrate` is true for `bytes_sha256` / `plan_sha256` / `attempt_id` / `contract_hash` with **no** heads (`bundle.py:210-217`). Then `shas=[]` skips `MISSING_GIT` and, with `current_heads is None`, returns `LIVE` + `STILL_BOUND` (`bundle.py:178-184`). + Honest path: gate/review export when `task-heads.json` is absent (`derive.py:464-467`, `132-187`, `191+`). Stranger CI/fixture always pins a head (`verify_bundle_stranger.py:50-51`), so it does not catch this. + CLI still says「缺省不得报 live」(`cli.py:2838-2842`). + +2. **Integrity hash is advisory (P0).** + `if expected and sha256(raw) != expected` (`bundle.py:99-102`). A stranger-supplied ZIP with valid `kind`/`schema_version` and omitted `proof_sha256` is parsed and can exit 0 (`verify_exit_code` all-`live` → 0, `project.py:73-79`). + +3. **Require-signed + missing keys can still be `live` (P0).** + Honest export writes `"true"`/`"false"` strings (`derive.py:413-417`). Verify stringifies arbitrary JSON (`bundle.py:151`). `{"require_signed_review": true}` + `declared_key_ids: []` does not hit `MISSING_DECLARED_KEYS`. Declared key **IDs** are never bound to a keyring (correct for B1-not-identity; must stay labeled). + +4. **Mutation gate is fail-open on “no manifests”.** + `compiled = bool(manifests)` and manifests are only `*.toml` (`doctor.py:58, 114-123`). Only caller is `apply_supervised_wave` (`supervision.py:390-392`). `task run` / `task merge` are ungated. `ActivationLease` **does not exist** under `src/` — P12 “若存在 ActivationLease” is N/A in this tree. + Deleting `*.toml` while leaving `SKILL.md` / `deny-hook.json` reports 未编译 and apply proceeds. Whether any host actually loads `.dyro/host-projections/` is **须人工核**; the Dyro apply predicate is source-proven. + +5. **Host skill contract is mostly held, with one injection surface.** + Available rows require `Intent.EXECUTE` and `test_capability.executable` (`compile.py:187-193`). Observe-only cards are omitted. `_FORBIDDEN_SKILL_MARKERS` + “不要执行” (`compile.py:317-332`) hold for compiler-owned text. `cannot_prove` is interpolated raw into the markdown table (`compile.py:266-267`) and is **not** `validate_id`-constrained (`cards.py:69, 129-134`). A card can smuggle `dyro objective apply` / extra fences that are not in the denylist. Needs write access to `dyro.toml` (already a trusted plane). + +6. **Secrets in export/skill/hook.** + `proof_payload` has ids, hashes, procedure strings, `declared_key_ids`, policy snapshot (`project.py:15-39`). Gate argv/cwd are hashed, not exported (`derive.py:138, 482-484`). `[[capabilities]]` rejects `env` (`cards.py:52-53`). Adapter has no `env` field (`config.py:42-46, 245-253`). Compile JSON emits hashes/relpaths, not skill body (`cli.py:950-961`). Hook JSON is deny lattice only (`compile.py:300-314`). No hardcoded credentials in the reviewed files. `capability test` prints probed `executable` paths (`cli.py:931-945`) — local path disclosure, not bundle leak. + +7. **Dependencies.** + OSV query on locked runtime: `cryptography==49.0.0` → GHSA-g6cj-pr64-35w5 / CVE-2026-69247 (PKCS#7 EnvelopedData oracle, fixed in 50.0.0). Dyro `signing.py` uses Ed25519 only, not `pkcs7_decrypt_*`. Reachability through this feature set is **须人工核 / likely unused**. `rfc8785==0.1.4`, `cffi==2.1.0`, `pycparser==3.0`: 0 vulns. `pip-audit` could not run in this environment (venv `ensurepip` abort). + +## Decision Validity + +**Open 1 — hook only under `host-projections/`, not `hook_surface`.** +Valid under Authority B. Writing the deny file into the host’s real hook pipeline would *expand* mutation surface. Security implication: `authority_projection=skill_and_hook` is a **compiled artifact label**, not an armed intercept. Doctor `FRESH` + `skill_and_hook` does not mean integrate/publish are denied at the host. CLI already says 不是沙箱 / 不是隔离. Do not reopen B; do not advertise the hook as enforcement. + +**Open 2 — version still `0.6.0` with 1.0-shaped commands.** +`pyproject.toml:7` is `0.6.0`. `verify-bundle` + frozen `schema_version=1` + unlabeled export (`test_proof_cli.py:116` asserts export is **not** “experimental”) are 1.0/P13 shapes. `verify_release_gates.py:50-51` **skips** 1.0 gates on non-1.0.0. Trust implication: a 0.6.0 wheel already exposes the portable-integrity API, and that API can return `live` incorrectly (P0 above). This is a labeling/trust defect, not a reason to reopen the version number by itself. + +**Open 3 — caller `--git-dir` / object-existence vs identity.** +Do **not** reopen B1. Object existence (`cat-file -e` in **any** provided git-dir, `bundle.py:263-273`) is integrity, not “this SHA is the named repo’s commit on the sender’s machine.” Repo keys are not bound to a specific `--git-dir`. That hole must stay labeled (`mode=integrity`). Additional integrity weakness (not identity): `_SHA_RE` allows 7-char abbreviations (`bundle.py:22, 181`). Keep B1; tighten abbreviation if 1.0 “钉死 SHA 可解析” means a specific object. + +## Plan Executability + +| Plan gate | Executable as written? | +|---|---| +| P6「不提供 git 对象时为 inconclusive」 | **No** — empty-heads / hashless-substrate proofs are `live` | +| P6 evidence ZIP | **Yes** for stock evidence layout | +| P10 PATH discovery | **Yes** — not an executor; `capability add` is explicit | +| P11 skill-only `dyro next` | **Mostly** — broken by `cannot_prove` interpolation | +| P12 stale/tamper blocks apply | **Yes** when `*.toml` remains; **escape hatch** when tomls are removed | +| P12a hook optional | **Yes** — missing/fake/absolute `hook_surface` → `skill_only` (`compile.py:64-76`, tests in `test_host.py`) | +| P13 stranger CI | **Partial** — `ci.yml` wheel-smoke runs `verify_bundle_stranger.py`; `pypi-publish.yml` smoke does **not** | +| P13 1.0 tag refuse | String-presence gates only (`verify_release_gates.py`); does not execute stranger verify or the P0 predicates | + +The 1.0 “stranger + caller git objects ⇒ same integrity conclusion” story is implementable, but current `verify_bundle` acceptance is wider than the story. + +## Scope And Risk + +**Overall risk: HIGH** (1.0 portable-integrity claim is not fail-closed). + +- **Blast radius of P0 `live`:** remote/untrusted ZIP + local CLI/`CI` consumer that keys off `status==live` or exit 0. No git objects required. No manifest byte hash required. Can look like a successful stranger verify. Does not grant merge (A1 still holds) — it **lies about integrity**. +- **Blast radius of mutation gate:** local workspace operator (or anything that can unlink `.dyro/host-projections/*.toml`). Blocks only `objective apply`, not `task run`/`merge`. Never-compiled remains a fixed compatibility choice. +- **Command injection / zip-slip write / PATH-as-executor / adapter-env-in-bundle:** not found in this slice. +- **`--git-dir` identity:** labeled; caller can point at any readable object store they already control. Local tool, not a remote identity assertion. + +## Go/No-Go + +**No-Go for 1.0 releasability.** +`verify-bundle` is the 1.0 hard gate and can report `live` without caller git objects, without per-proof `proof_sha256`, and without declared keys when require-signed is a JSON boolean. That is integrity theater. Host/capability planes are closer to contract (PATH, observe-only, hook-not-sandbox) and are not the release blocker. + +Not Conditional Go: the 1.0 product sentence is already false in source. After the P0s below, this becomes Conditional Go (P1s can ride a 1.0.0 RC). + +## Required Fixes + +### P0 — `verify-bundle` must not be `live` without caller git objects + +`bundle.py` `_integrity_of` / CLI help / P6 acceptance. + +```python +# BAD (current): empty shas ⇒ skip git, then LIVE +shas = [sha for _repo, sha in proof.substrate.repo_heads if sha] +if shas and not git_dirs: + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=MISSING_GIT) + +# GOOD: no caller object store ⇒ never live (matches cli.py help) +if not git_dirs: + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=MISSING_GIT) +if not shas: + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=MISSING_GIT) +``` + +Add a unit test: procedure + `bytes_sha256`/`plan_sha256`, **no** `repo_heads`, `git_dirs=()` → `inconclusive` / `missing_git`, exit 3. The current stranger fixture must stay `live` only **with** `--git-dir`. + +### P0 — require `proof_sha256` for every `proof_id` + +```python +# BAD +expected = (manifest.get("proof_sha256") or {}).get(proof_id) +if expected and hashlib.sha256(raw.encode("utf-8")).hexdigest() != expected: + ... + +# GOOD +digests = manifest.get("proof_sha256") +if not isinstance(digests, dict) or proof_id not in digests: + proofs.append(_bundle_inconclusive(BUNDLE_BYTES_MISMATCH, f"缺少 {proof_id} 哈希")) + continue +if hashlib.sha256(raw.encode("utf-8")).hexdigest() != digests[proof_id]: + proofs.append(_bundle_inconclusive(BUNDLE_BYTES_MISMATCH, f"{proof_id} 字节哈希漂移")) + continue +``` + +`_valid_manifest` must require `proof_ids: list[str]` and a digest for each id. + +### P0 — require-signed must fail-closed on missing keys + +```python +# BAD +return any(value == "true" for _key, value in proof.policy_require_signed) + +# GOOD +def _flag_on(value: str) -> bool: + return value in {"true", "True", "1", "yes"} # or reject non-{true,false} at parse + +# and in proof_from_payload: only accept bool or "true"/"false"; +# bool True must become "true", not str(True)=="True" +``` + +Test: `policy_require_signed: {"require_signed_review": true}` + empty `declared_key_ids` → `missing_declared_keys`, never `live`. + +### P1 — mutation-gate escape hatch (do not reopen never-compiled) + +If any `SKILL.md` / `deny-hook.json` exists under `host-projections/` without a valid matching `*.toml`, treat as `compiled=True` and `INVALID`/`TAMPERED`, not 未编译. Keep “zero projection files ⇒ allow apply”. + +### P1 — host skill markdown injection + +Escape or reject `cannot_prove` / table fields that contain newlines, backticks, or `_FORBIDDEN_SKILL_MARKERS`. Compiler must not be bypassable by a Card string. + +### P1 — integrity hardening that stays inside B1 + +- Require full `^[0-9a-f]{40}$` or `{64}$` for pins and `--current-heads` (drop 7-char `cat-file` abbreviations). +- Keep “object exists in caller git-dir ≠ identity” labeled; do not bind this to merge `live`. +- Run `tools/verify_bundle_stranger.py` on the **publish** job, not only `ci.yml` wheel-smoke. +- Do not ship 1.0-shaped `verify-bundle` as if it were already the 1.0 guarantee while version is `0.6.0` and P0s are open (docs/help: experimental / not 1.0-hard, or bump only after P0s). + +### P2 + +- Zip member size cap (local zip-bomb). +- `authority_projection=skill_and_hook` copy: “compiled deny file; not installed on hook_surface; not a sandbox.” +- Bump `cryptography` to `>=50.0.0` (CVE-2026-69247); unused PKCS#7 path in this slice. +- `inspect_projections` empty-findings `ok=True` when `compiled=False` is fine; do not let that JSON be read as “projections healthy.” + +**须人工核:** whether any supported host auto-loads `.dyro/host-projections/**/SKILL.md`; whether any 1.0 consumer ignores `mode` and trusts `status==live`; whether `objective apply` is the only 1.0 “automatic mutation” path they will claim. + + +# Critic Review Section + +Reviewer: critic +Time: 2026-08-15 +Verdict: **No-Go** + +Mode: ADVERSARIAL (3+ MAJOR after first pass; adjacent host/publish/derive paths were then hunted). A1/B1 as *design locks* are not reopened. Source was checked for *implementation* violations only. + +--- + +## Contract Consistency + +**A1 is kept.** `merge_task` / `check_dispatchable` still call `_valid_review_acceptance` / `_valid_external_signoff` / `_assert_dependency_integrated` / `_prepare_merge`. `tasks.py` has no Proof import. `build_task_readiness` does not read `decayed_merge_subjects`. Planner emits `PROOF_DECAYED` as attention only. Console summary forces `inspect_integration=False` → `inspect_proofs=False` and hard-codes `proof_inspection=not_inspected`. `live_merge_evidence` is not wired into production `BudgetUsage`. Line dirty stays `_prepare_merge` text, not `PROOF_DECAYED`. + +**B1’s two-command split is kept.** `proof verify` prints `mode=rebind`. `verify-bundle` prints `mode=integrity`. Tests prove workspace `decayed` + bare bundle `live` on the same ZIP. `--current-heads` is the only decay path. Bundle ZIP has no git object DB. + +**B1’s fail-closed clause is not kept.** ADR-0006 decision 10 and the 1.0 SSOT say missing git objects ⇒ `inconclusive`, never `live`. Implementation only does that when pinned SHAs exist: + +```177:184:dyroengineeringflow/src/dyro/proof/bundle.py + shas = [sha for _repo, sha in proof.substrate.repo_heads if sha] + if shas and not git_dirs: + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=MISSING_GIT) + ... + if current_heads is None: + return replace(proof, status=ProofStatus.LIVE, decay_reason=STILL_BOUND) +``` + +CLI help states the opposite: `"缺省不得报 live"` (`cli.py:2842`). `_has_substrate` is true on `bytes_sha256` / `plan_sha256` / `attempt_id` alone (`bundle.py:210-217`). Derive will export a `review_verdict` with empty `repo_heads` when `task-heads.json` is missing (`derive.py:464-467`, `245-254`). That bundle is `live` with no `--git-dir`. + +**Authority B is not reopened, but `skill_and_hook` is a label lie.** Proven `hook_surface` is only an existence probe. Deny hook is written to `.dyro/host-projections//deny-hook.json`, never copied onto the declared surface (`compile.py:194-223`, `335-342`; `test_host.py:199-218` creates `hooks/surface` then asserts the hook under `projection_root`). Adapter upgrades never set `hook_surface` (`cards.py:17-32`). `card_payload` still echoes the *declared* path (`probe.py:91`), not the proven one. + +--- + +## Source Evidence Accuracy + +| Claim | Source | Accurate? | +| --- | --- | --- | +| `merge_task` / `check_dispatchable` do not read Proof store | `tasks.py:545-556`, `2058-2068`; no `proof` import | Yes | +| `PROOF_DECAYED` is extra copy, not a new reject set | `tasks.py:2062-2067`; `test_proof_decay.py:281-366` | Yes for accept/reject. Copy is sloppy: *any* failed review/signoff is labeled `PROOF_DECAYED`, including never-valid review | +| Two commands, two conclusions | `cli.py:1911-1954`, `2833-2848`; `project.py:42-50`; `test_proof_cli.py:208-229` | Yes | +| `schema_version = 1` | `bundle.py:19, 47-52` | Yes | +| Stranger + caller git ⇒ integrity `live`; no git ⇒ not `live` | CI `ci.yml:91`; `verify_bundle_stranger.py`; `test_proof_cli.py:141-145` | Yes **only** for SHA-bearing bundles | +| Identity sentence in every README language | `test_readme_identity.py:9-27`; `README.md:7` | Phrase present. Lede still says “delivery control platform” / “agent launchers” (`README.md:5`) | +| `verify-bundle` is a 1.0 hard gate | `verify_release_gates.py:50-55`; `pypi-publish.yml:73-76` | **No.** Gate **skips** unless version/tag is already `1.0.0`. “Evidence” is substring presence. P5 marker is `_assert_dependency_integrated` in `graph.py` — 0.6 code, not A1 | +| Missing P5/P6-export/P12 refuses `1.0.0` | same | **Theater.** Deleting A1 (wire `merge_task` to Proof) still passes if the strings remain | +| P13 stranger-from-sdist on **publish** artifacts | `pypi-publish.yml:81-97` vs `ci.yml:91` | **No.** Publish sdist smoke does dispatch doctor / assets only. Stranger script is CI-only | +| Package is 0.7 / 0.8 / 0.9 / 1.0 | `pyproject.toml:7` `version = "0.6.0"` | **No** | +| README documents caller git objects | `README.md` — zero hits for `proof`, `verify-bundle`, `git-dir` | **No** | +| Export is 0.7 experimental | `cli.py:2828`; `test_proof_cli.py:116` `assertNotIn("experimental")` | Code treats it as frozen 1.0 schema on a 0.6.0 package | + +`store.py` from the plan module map was never created. That is fine: list/verify re-derive (`derive.py:34-53`). Do not treat the missing file as a lock break. + +--- + +## Decision Validity + +**A1/B1/authority B/product category:** do not reopen. Implementation kept A1 and the B1 *split*. It missed B1 *fail-closed* for SHA-less proofs. + +**Open 1 — hook not copied onto `hook_surface`:** product-dishonest. `authority_projection=skill_and_hook` means “a JSON sidecar exists in Dyro’s projection dir because a relative path existed.” The host intercept directory is untouched. CLI correctly refuses to call this a sandbox (`cli.py:2487-2512`). The authority enum still overclaims. + +**Open 2 — 0.6.0 vs 1.0 capability:** ship lie either direction. This tree implements 0.7–1.0 commands, freezes `schema_version=1`, and prints the 1.0 identity sentence, while `pyproject.toml` is `0.6.0`. ADR-0006: `v0.6.x` is maintenance-only and must not absorb Host Compiler. Publishing this as `0.6.0` is a lie. Tagging `1.0.0` without bumping version is impossible (`pypi-publish.yml:67-70`) and would still be a lie because the 1.0 brake is fake and README cannot teach the stranger contract. + +**Open 3 — caller must bring git objects:** documented at CLI help, **not** at README. 1.0 SSOT required identity sentences (done) *and* a stranger who can reproduce integrity. A stranger who only reads README never learns `--git-dir`. That is not 1.0 product surface. + +--- + +## Plan Executability + +P1–P12a code exists and A1 workspace tests are real (`test_proof_decay.py`). P13 as written is **not** what landed: + +- Plan P13: “从 sdist 安装的干净环境…跑 `verify-bundle`” on **release artifacts**. Landed in CI only. +- Plan P13: “缺 P5/P6-export/P12 任一证据,发布工作流拒绝打 `1.0.0`”. Landed as `marker in file.read_text()`. +- Plan P6 ZIP list includes “被核验字节”. Export writes only proof JSON + `bytes_sha256` (`bundle.py:35-60`). Source wins; this matches locked B1 (hash + resolvable SHA), not the ZIP shopping list. Not scored as A1/B1 break. +- `_build` is a tautology: `INCONCLUSIVE if force_inconclusive else INCONCLUSIVE` (`derive.py:374`). Harmless because evaluate overwrites status. Dead parameter. + +An executor following only the plan’s 1.0 exit checklist and then reading `verify_release_gates.py` would believe the tag brake exists. It does not encode the exits. + +--- + +## Scope And Risk + +Blast radius if this is called 1.0-releasable: + +1. **False 1.0 tag.** Bump `pyproject` to `1.0.0`, keep the magic strings, break A1 in `merge_task`, update the few decay tests, gate still prints `1.0 gates present`. No AST lock that `tasks.py` must not import `dyro.proof`. +2. **False integrity `live`.** SHA-less / heads-missing export + no `--git-dir` ⇒ exit 0, `status=live`, `mode=integrity`. Help text says that cannot happen. +3. **False hook authority.** `skill_and_hook` while the proven surface has no deny file. Host `git merge` is unchanged. Design already says Core is the real gate — the enum still sells a second seatbelt that was never buckled. +4. **Version confusion.** PyPI `0.6.0` with `verify-bundle` / `host compile` / `capability` / 1.0 identity. Or a `1.0.0` tag whose “hard gate” never ran stranger-from-sdist on the publish job. + +Merge-truth for current `task merge` / downstream ready set is **not** the lie. The 1.0 *tag* is. + +--- + +## Go/No-Go + +**No-Go for calling this 1.0-releasable.** + +A1 merge-truth: Go (do not reopen). +B1 two-command split: Go. +B1 missing-git fail-closed + 1.0 packaging/docs/gates: No-Go. + +Claiming 1.0-readiness today is a lie: version is `0.6.0`, the 1.0 brake is a string scan that skips on this version, publish does not run the stranger job, README cannot state the caller-git contract, and `verify-bundle` can still print `live` without `--git-dir`. + +--- + +## Required Fixes + +### P0 — would make a 1.0 tag or merge-truth claim false + +1. **Do not tag `1.0.0` and do not publish this tree as `0.6.0`.** `pyproject.toml:7` is `0.6.0`. README already ships the 1.0 identity sentence. CLI freezes `schema_version=1` and asserts export is not experimental (`test_proof_cli.py:116`). Either number is a ship lie until version, gates, and README match one train car. + - Fix: pick a version, bump `pyproject.toml`, and make the publish job fail closed on that number’s real exits. `0.6.x` must not contain Proof/Card/Compiler. + +2. **`verify-bundle` without caller git objects must not be `live`.** `_integrity_of` (`bundle.py:177-184`) + CLI help (`cli.py:2842`) + ADR-0006 decision 10 + 1.0 SSOT are inconsistent. Empty `repo_heads` + any `bytes_sha256`/`plan_sha256` ⇒ `live` with `git_dirs=()`. + - Fix: if `git_dirs` is empty, return `inconclusive`/`missing_git` unconditionally. Add a test that exports a heads-missing `review_verdict` and asserts no `--git-dir` ⇒ not `live`. Keep the existing SHA-bearing stranger test. + +3. **1.0 refuse-tag gate is not a gate.** `verify_release_gates.py:10-22,50-55` skips on `0.6.0` and accepts `def verify_bundle` / `_assert_dependency_integrated` as “P5 evidence”. `pypi-publish.yml:81-97` never runs `tools/verify_bundle_stranger.py` against the sdist `dyro`. + - Fix: on tag `v1.0.0` / version `1.0.0`, fail unless (a) sdist-installed `dyro proof verify-bundle` on the fixture is `mode=integrity` and not `decayed`, (b) same command without `--git-dir` is not `live`, (c) `tasks.py` AST/import lock: `merge_task` / `check_dispatchable` do not reference `dyro.proof`. Delete the `graph.py` substring as P5 evidence. + +### P1 + +4. **README (all languages) must state the two-command contract.** Identity phrase is not enough. A 1.0 stranger who only reads `README.md` never sees `--git-dir`, integrity ≠ merge, or `inconclusive` on missing objects. CLI help is not the 1.0 surface. + - Fix: one short subsection in every `README*.md`, same meaning, covered by `test_readme_identity.py` (not just the physics noun). + +5. **`skill_and_hook` must not be claimed unless the deny hook is installed on the proven surface, or the enum/docs must say `projection_sidecar`.** `compile.py:208-222` writes `.dyro/host-projections/.../deny-hook.json`. `hooks/surface` stays empty. `card_from_adapter` never copies a hook path (`cards.py:17-32`). + - Fix (pick one): copy/link the rendered hook onto the proven relative surface, **or** never set `AUTHORITY_SKILL_AND_HOOK` unless that copy succeeds, **or** rename the status and document that hook files are Dyro-side only. `capability list` JSON must not show an unproven declared `hook_surface` as if tested (`probe.py:79-91` vs `64-76`). + +6. **Lock A1 in tests the way dispatch locks merge.** `test_dispatch_boundary.py` already AST-bans `merge_task` in dispatch. There is no equivalent that `tasks.py` cannot import `dyro.proof`. Without it, a later PR can make Proof a second merge gate and still pass `verify_release_gates`. + - Fix: AST/import test on `merge_task` / `check_dispatchable` / `_prepare_merge`. + +7. **Manifest integrity must require `proof_sha256`.** `bundle.py:99-101` skips the byte check when the field is missing; `_valid_manifest` (`224-227`) only checks `kind` + `schema_version`. + - Fix: missing/empty `proof_sha256` ⇒ `inconclusive`, not `live`. + +### P2 + +8. JSON `status=live` plus `mode=integrity` is enough for a careful reader; it is not enough for a script that only checks `status`. Add `conclusion: integrity` / `merge_equivalent: false` if you want automation-safe B1. Not required to keep the lock. +9. `merge_task` stamps `PROOF_DECAYED` on every failed review/signoff (`tasks.py:2063-2067`), including never-bound review. Design: only live→decayed merge copy. Change the message or only attach the code when a derived review Proof is actually `decayed`. +10. README lede still sells “delivery control platform” and “agent launchers” (`README.md:5`) beside the physics identity (`README.md:7`). Identity test only `assertIn` the physics noun. +11. `derive.py:374` tautology — delete `force_inconclusive` or make it real. +12. Evidence ZIP detector (`bundle.py:230-232`) misses `receipt.md` + `manifest.json` together. Fail closed if evidence markers appear anywhere. + +--- + +**Pre-commitment vs found:** expected A1 leak (absent), B1 command collapse (absent), version lie (present), CLI/JSON merge confusion (help is good; README silent; help overclaims), missing 1.0 tests (present), hook-surface honesty (present). A1 is the part that is actually solid. The 1.0 sticker is not. + + +# Architect Review Section + +Reviewer: architect +Time: 2026-08-15 +Verdict: Conditional Go + +Locked A1 / B1 / authority B still hold on the merge, ready-set, and host-mutation paths. The implementation is freezeable after bounded contract fixes. It is not a 1.0 freeze today because portable verify and hook-authority labels can still emit the wrong 3-state, and export still carries workspace rebind status inside the stranger artifact. + +## Contract Consistency + +Producer → evaluate → CLI verify is wired, but the 3-state is not preserved across the seam. + +- `list_proofs()` always re-derives, then calls `evaluate_proofs()` (`derive.py:26-53`). There is no Proof store and no merge-truth cache. That matches locked micro-decision 5. +- `derive._build()` forces `ProofStatus.INCONCLUSIVE` on both branches (`derive.py:374`). Derive never forges `live`. P1 tests assert that (`tests/test_proof_derive.py:125-136`). +- `evaluate_proof()` then discards derive status and maps 0.6 booleans: `False` → `decayed`, exception → `inconclusive` (`evaluate.py:49-66`, `evaluate.py:98-102`). Missing / unbound `review.md` therefore becomes `decayed` on `dyro proof verify`, even though Appendix A and `test_missing_review_binding_is_inconclusive` require `inconclusive` at derive time. CLI verify and derive no longer share a 3-state contract. +- `merge_task()` / `check_dispatchable()` do not import or read Proof (`tasks.py:545-556`, `tasks.py:2058-2068`). `build_task_readiness()` never consults `decayed_merge_subjects` (`planner.py:70-176`). `PROOF_DECAYED` is attention-only (`planner.py:272-281`). A1 ready-set contract holds. +- `task explain` reuses `_assert_dependency_integrated()` (`graph.py:195-198`), not Proof cache. Matches P5. +- Export → stranger verify is a different function, but the ZIP is built from already-evaluated workspace Proofs (`cli.py:1926-1937` → `export_bundle()` → `proof_payload()` at `bundle.py:43-46` / `project.py:15-38`). The portable bytes contain workspace `status` / `decay_reason` / `observed_at`. `verify_bundle()` ignores those fields and recomputes integrity (`bundle.py:149`, `bundle.py:165-185`), and CLI prints `mode=integrity` (`cli.py:1940-1951`). Conclusions are computationally split; the artifact still mixes them. +- `verify-bundle` without `--current-heads` cannot emit `decayed` (`bundle.py:183-184`). Evidence ZIP → `inconclusive` (`bundle.py:80-81`, `bundle.py:230-232`). Heads-present + no `--git-dir` → `missing_git` (`bundle.py:177-179`). That is the B1 happy path. +- B1 hole: git is required only when `repo_heads` contain SHAs. Headless proofs (`action_receipt`, `gate_log` with empty heads) can be `live` with `git_dirs=()`. CLI help says the opposite (`cli.py:2838-2842`). Architecture invariant 18 is absolute; this implementation is SHA-conditional. +- `_requires_declared_keys()` is `any(policy == "true")` (`bundle.py:220-221`). `_policy_snapshot()` stamps both `require_signed_review` and `require_signed_signoff` onto every kind (`derive.py:413-418`). A signed-review workspace therefore makes `gate_log` / `integration_heads` `inconclusive` for missing keys they never declare. +- Multi-repo assembly is a bag of `--git-dir` values. `_object_exists()` / `_is_ancestor()` succeed if any dir resolves the SHA (`bundle.py:263-268`). There is no `repo_id → git-dir` map. Open decision 3 is unresolved in code; integrity is union-search, not per-repo binding. +- Adapter → Card → `task run` is closed: `run_task` reads `config.adapters` and refuses Cards without `execute` (`tasks.py:1174-1181`). `discover_unintegrated()` never writes Cards (`capability/probe.py:19-36`). `install_tool()` does not write Cards (`tooling.py:282-313`). PATH execute fail-closed is real. +- Host compile → doctor → `apply_supervised_wave` is closed: `assert_projections_allow_mutation()` no-ops when `not report.compiled` (`host/doctor.py:70-74`); supervision calls it before the lease (`supervision.py:390-392`). Never-compiled does not block apply. Stale compiled does. +- Console summary does not probe git for Proof: `capture_workspace_read_snapshot()` uses `inspect_integration=False` (`observations.py:281-285`), which forces `inspect_proofs=False` (`snapshot.py:205-206`), and hard-codes `proof_inspection="not_inspected"` (`observations.py:358`). `test_summary_capture_does_not_evaluate_proofs` locks this. + +## Source Evidence Accuracy + +Source wins over the plan’s module map. Several plan names are stale; behavior is not. + +| Plan name | Source | +| --- | --- | +| `proof/verify.py`, `proof/store.py` | `evaluate.py` + `project.py`; no store | +| `hostproj/` | `host/` | +| `capability/{migrate,registry,attest,cli}.py` | `cards.py`, `store.py`, `probe.py`, `cli.py` | + +Accurate against locked decisions: + +- Kind closed set is 0.7’s five (`proof/models.py:14-19`). +- `contract_hash` is split: task kinds from attempt `task_contract_sha256` (`derive.py:449-454`); `action_receipt` from Objective `contract_sha256` (`derive.py:117-120`). +- `proof list --task` excludes `action_receipt` (`derive.py:34`, `derive.py:56-57`). +- Identity omits `produced_at` / mtime / now (`models.py:68-70`). +- `ContinuationSnapshot` is still a dead type (`continuation/models.py:268-278`; no constructor call sites). P4 landed on `SchedulerSnapshot.decayed_merge_subjects` (`snapshot.py:70`, `snapshot.py:279-301`). Digest payload stays `schema_version = 1` and omits proofs (`snapshot.py:94-128`). +- `live_merge_evidence()` is unused. Production `BudgetUsage` / `decide_no_progress` are not Proof-wired. That matches the P4 deferral, not a miss. +- Journal / confirmation hash omit `decayed_merge_subjects` (`supervision.py:125-165`). Proofs are not a second PASS. +- `cannot_prove` always re-injects `done, merge` (`capability/models.py:87-90`). +- Host skill forbids execute markers and only prints `` `dyro next` `` (`host/compile.py:317-332`, `host/compile.py:279-287`). +- Line dirty / wrong branch stay `_prepare_merge` errors without `PROOF_DECAYED` (`tasks.py:1926-1947`). Task-worktree dirty stays inside `_valid_review_acceptance` via `_collect_task_heads` (`tasks.py:900-911`, `tasks.py:1154-1158`), which P2 explicitly assigned to `review_verdict`. + +Inaccurate or over-claimed: + +- Plan P9: `agent add` “改为写 Card”. Source still writes `[adapters.*]` (`cli.py:840-851`, `profile.py:63-76`). Runtime upgrade still works (`cards.py:17-32`, `config.py:254-257`). Dual write path, not a second execute plane. +- Plan P6: export “experimental”. CLI help and `test_proof_cli.py:116` forbid the word. Source has already treated export as 1.0-shaped. +- `host/compile.py:194-209` + `capability/probe.py:64-76`: “proven hook surface” is `Path.exists()` on a workspace-relative path. An empty dir (`tests/test_host.py:199-214`) or `hook_surface = "."` / `"dyro.toml"` proves hook authority. Destination is always `.dyro/host-projections//deny-hook.json` (`compile.py:335-343`), never `hook_surface`. +- `derive._observe_integration()` writes `extra.integration_state` (`derive.py:339-351`); `evaluate_proof()` does not refresh it (`evaluate.py:66`). Payload `extra` can disagree with `status`. +- `verify_release_gates.py:10-14` is a string-presence gate (`def verify_bundle`, `_assert_dependency_integrated`). It cannot see the 3-state or git-dir holes. + +## Decision Validity + +Do not reopen A1, B1, or authority B. Source does not prove them wrong. It proves several implementations are weaker than those locks. + +- **A1 still matches.** Merge / downstream accept-sets are the 0.6 predicates. Proof is a name for those rejects plus attention. `plan_tasks()` does extra Proof I/O (`snapshot.py:280-285`) but does not shrink the ready set. No second gate. +- **B1 still matches on the headed path.** Integrity `live` ≠ workspace `verify` / merge. `--current-heads` is the only decay switch. Bundle refuses git object layout (`bundle.py:88-89`, `bundle.py:235-237`). +- **B1 is not fully implemented for “缺调用方 git 对象”.** Headless proofs can be integrity-`live` with no `--git-dir`. That is an impl gap, not a reason to reopen B1. +- **Authority B still matches the compile/doctor shape.** No hook → compile succeeds, `authority_projection=skill_only` (`compile.py:208-209`, `tests/test_host.py:163-180`). Missing compiled hook → doctor fail-closed (`doctor.py:154-156`). Never-compiled must not, and does not, block apply. +- **Authority B is not host-enforced.** Deny hook is a Dyro projection file. Nothing installs it at `hook_surface`. `skill_and_hook` is a label over a file the host runtime never loads. That is open decision 1, already answered in source as “proof = exists(); dest = projection tree.” Weak proof is the defect, not the destination choice. +- **0.6.0 vs 1.0 commands (open 2).** `pyproject.toml:7` is `0.6.0`. `proof verify-bundle`, `capability *`, `host compile/doctor` are already shipped. Release gates no-op until version/tag is `1.0.0` (`tools/verify_release_gates.py:50-52`). Architecture can freeze; the package must not be labeled 1.0 until the P1s below are closed. +- **Multi-repo (open 3).** Union `--git-dir` is an implementable 1.0 rule if documented. It is not a per-repo object assembly. Do not claim polyrepo stranger-verify is repo-bound. + +## Plan Executability + +The train is executable from source, not from the plan’s file list. + +- P1–P5, P8–P12a, P13 command surface exist and have tests. +- P7 Console Proof display is the 0.8 stub: field present, always `not_inspected`. That is the locked slide, not a miss. +- P9 `agent add` → Card is not done. Compatible via adapter upgrade. Do not block freeze on it; do not document it as done. +- P6 experimental label is already dropped. Treat export + `schema_version = 1` as the 1.0 shape the tests already freeze (`tests/test_proof_cli.py:110-145`). +- Plan §4 still tells an implementer to create `store.py` / `verify.py` / `hostproj/`. Following the plan file now would fork the tree. Freeze the source map; rewrite the plan names or mark them historical. +- `verify_release_gates.py` is not an architecture proof. A 1.0 tag check that only greps symbols will go green while the P1 contract holes remain. + +## Scope And Risk + +Authority leaks, second gates, false live — scored against source: + +| Risk | Verdict | Evidence | +| --- | --- | --- | +| Proof store as merge truth | Absent | No `store.py`; `merge_task` / `check_dispatchable` / `build_task_readiness` do not read Proof | +| PATH / `tools.json` execute | Closed | Discovery is `discovered_unintegrated`; `run_task` requires audited adapter/Card | +| Never-compiled blocks apply | Closed | `assert_projections_allow_mutation()` returns on `not compiled` | +| Second downstream gate | Closed | `PROOF_DECAYED` is attention only | +| Console summary git/Proof probe | Closed | `inspect_proofs` follows `inspect_integration=False` | +| False integrity `live` | Open (headed path closed; headless path open) | `bundle.py:177-184` | +| Workspace status inside bundle | Open | `export_bundle` serializes evaluated `proof_payload` | +| Missing bind shown as `decayed` | Open | `evaluate.py:98-102` vs Appendix A | +| Signed policy applied to unsigned kinds | Open | `derive.py:413-418` + `bundle.py:220-221` | +| Fake hook surface → `skill_and_hook` | Open | `probe.py:64-76` `path.exists()` | +| Hook claimed as host seatbelt | Residual | Hook never written to `hook_surface`; Core remains the only mutation gate (`designs/delivery-physics.md:419`) | +| `agent add` vs Card | Residual dual path | Runtime still fail-closed | + +No layer currently lets Console, host skill, or Proof cache authorize merge / execute / apply. The remaining failures are mislabeled 3-states and over-claimed hook authority, not a new write plane. + +## Go/No-Go + +**Conditional Go for 1.0 architecture freeze.** + +A1, B1 (headed), and authority B (compile/doctor/apply) are source-true and must stay locked. Freeze after the P1 fixes below. Do not freeze while export can show workspace `decayed` next to integrity `live`, while `verify-bundle` can `live` without caller git on headless proofs, or while `hook_surface="."` can mint `skill_and_hook`. + +Antithesis (steelman for No-Go): a stranger can open the ZIP, read `"status": "decayed"`, then run `verify-bundle --git-dir` and get `live`; or export only `action_receipt`s and get `live` with no git. That is exactly the “two conclusions mixed / false live” failure mode B1 was written to prevent. I still reject No-Go because merge/ready-set/apply authority is not leaking, and the fixes are local to `export_bundle` / `_integrity_of` / `evaluate_proof` / `_proven_hook_surface`. + +Tradeoff tension: requiring `--git-dir` for every integrity `live` makes `action_receipt` bundles awkward (no git substrate). SHA-conditional git is more honest to those kinds and weaker than the CLI/B1 sentence. Pick one sentence and make CLI, ADR, and `_integrity_of` identical. Do not leave all three in force. + +## Required Fixes + +### P0 + +None. Merge truth, ready set, PATH execute, and never-compiled→apply are not broken. + +### P1 + +1. **Preserve inconclusive through evaluate.** In `evaluate_proof()` (`evaluate.py:37-66`), if derive extras already record `missing` / `unparseable`, or `_valid_review_acceptance` / `_valid_external_signoff` failed because files/bindings are absent (not because a present bind drifted), return `INCONCLUSIVE`, not `DECAYED`. Keep `False` from a complete 0.6 predicate as `DECAYED`. Add a CLI/evaluate test that mirrors `test_missing_review_binding_is_inconclusive` after `list_proofs()`. + +2. **Stop embedding workspace rebind in the portable bundle.** `export_bundle()` (`bundle.py:35-60`) must serialize `status=inconclusive` (and empty `decay_reason` / `observed_at`) or omit those fields. `cmd_proof_export` may keep `list_proofs()` for selection, but the ZIP cannot carry workspace `live`/`decayed`. Stranger `cat proofs/*.json` and `verify-bundle` must not disagree. + +3. **Make `verify-bundle` + caller git one rule.** Either (a) empty `git_dirs` ⇒ every proof `inconclusive` / `missing_git` (matches `cli.py:2842` and `tools/verify_bundle_stranger.py:89-93`), or (b) keep SHA-conditional git and change the CLI help + invariant 18 to “缺被引用的 git 对象”. I recommend (a) for freeze: B1’s input pair is Bundle + caller git. Add a headless-proof test that today would go `live`. + +4. **Stamp `policy_require_signed` per kind.** `review_verdict` → `require_signed_review`; `signoff` → `require_signed_signoff`; other kinds omit or force `false` (`derive.py:413-418`, `bundle.py:220-221`). A `require_signed_review=true` workspace must not fail `gate_log` integrity for missing keys. + +5. **Tighten hook-surface proof.** `_proven_hook_surface()` (`probe.py:64-76`) must reject `.`, directories that are not a declared hook root, and ordinary workspace files (`dyro.toml`, `.dyro/`). Missing and absolute already fail (`tests/test_host.py:163-197`). Add `hook_surface = "."` and `hook_surface = "dyro.toml"` as must-not-write-hook cases. Keep destination in the projection tree (do not reopen B); stop minting `skill_and_hook` from `exists()`. + +### P2 + +6. Document open decision 3 as “union of `--git-dir`; no `repo_id` map” or add `--git-dir repo=path`. Do not claim per-repo stranger assembly. + +7. Rewrite plan §4 names to `host/`, `evaluate.py`, `project.py`, `cards.py`. Delete the implication that `store.py` / `verify.py` still need to be created. + +8. Either make `agent add` append `[[capabilities]]` (P9 text) or document it as a compatible adapter writer. Dual path is safe today, confusing tomorrow. + +9. Refresh `substrate.extra.integration_state` in `evaluate_proof()` or drop it from `proof_payload` so JSON cannot show `integrated` next to `decayed`. + +10. Keep package version `0.6.0` until P1.1–P1.5 land. Do not treat `verify_release_gates.py` symbol greps as architecture evidence. Export is already non-experimental in source; say that in the 1.0 train, not in a 0.7 experimental footnote. + +--- + +# Final Arbitration + +Arbiter: Cursor(主控;核源后合并,不改写各席原文) +Time: 2026-08-15 + +## 1. Final Verdict + +- May implementation start: **可以修 P0,不可宣称 1.0 可发布** +- Required preconditions: 关闭下列 P0 之后,才允许把包版本升到 `1.0.0` 或对外说 1.0 +- Blocking reasons: `verify-bundle` 在缺调用方 git、缺 `proof_sha256`、以及 require-signed 被写成 JSON 布尔时仍可报 `live`;当前包号仍是 `0.6.0`,1.0 发布门禁是字符串扫描且对本版本 skip +- 合入/发布:`阻断发布 1.0`。不阻断继续修完整性。不重开 A1 / B1 / 权威投影 B + +席位对照(原文保留在各自签名段): + +| 席位 | 对 1.0 可发布 | 对 A1 merge 真值 | +| --- | --- | --- | +| Cursor | Conditional Go(升号前收 P1) | 成立 | +| code-reviewer | Conditional Go,P0 未关不得打 tag | 成立 | +| security-reviewer | **No-Go** | 成立(本切片未重开) | +| critic | **No-Go** | Go,勿重开 | +| architect | Conditional Go(架构可冻,包不可标 1.0) | 成立 | + +终裁取更严的可发布结论:**No-Go for 1.0 releasability**。取全体一致的 merge 结论:**A1 Go**。 + +## 2. Repo / Module Go-No-Go + +| Repo/Module | Spec | Plan | Verdict | Reason | +| --- | --- | --- | --- | --- | +| `src/dyro/tasks.py` merge / dispatch | A1 | P2/P4/P5 | **Go** | 不读 Proof;ready-set 不缩;线 dirty 不标 `PROOF_DECAYED` | +| `src/dyro/proof/evaluate.py` workspace verify | 三态 | P1/P2 | Conditional Go | 缺文件被 0.6 `False` 打成 `decayed`,不伪造 `live`,不改 merge | +| `src/dyro/proof/bundle.py` verify-bundle | B1 | P6/P13 | **No-Go** | 无 pin / 无 digest / JSON `true` 可 `live` | +| `src/dyro/capability/` + `host/` | 权威 B | P8–P12a | Conditional Go | PATH 不能执行;从未 compile 不挡 apply;`skill_and_hook` 是产物标签 | +| 发布 / `pyproject.toml` / gates | 1.0 身份 | P13 | **No-Go** | 版本 `0.6.0`;门禁 skip;publish 未跑 sdist 陌生人核验 | +| 总体 | 交付物理引擎 | P1–P13 | **No-Go(1.0) / Conditional Go(修完整性)** | merge 真值未裂;1.0 句子已假 | + +## 3. P0 Required Fixes + +源码已复核。下列 P0 成立。未成立的「A1 第二道闸」已驳回。 + +### P0-F1: 缺调用方 git 对象不得 `live` + +Evidence: + +- `src/dyro/proof/bundle.py` `_integrity_of`:`shas` 为空则跳过 `MISSING_GIT`,`current_heads is None` 时直接 `LIVE` +- `_has_substrate` 在仅有 `bytes_sha256` / `plan_sha256` / `attempt_id` / `contract_hash` 时为真 +- `src/dyro/cli.py` 帮助:「缺省不得报 live」 +- 诚实导出:`task-heads.json` 缺失时 `review_verdict` 的 `repo_heads` 可空(`derive.py`) + +Decision: + +- `git_dirs` 为空 ⇒ 每条 Proof `inconclusive` / `missing_git`,整捆不得 `live` +- 有 `git_dirs` 但该条没有可解析 pin:同样 `inconclusive`(不要用「有字节哈希就算完整性 live」绕过 B1 的输入对:Bundle + 调用方 git) +- 不重开 B1:这不是身份核验 + +Acceptance: + +- 只有 `action_receipt` / 无 `repo_heads` 的 bundle,`git_dirs=()` → 退出码 3,无 `live` +- 现有带 SHA 的陌生人夹具:有 `--git-dir` 仍可 `live`;无 `--git-dir` 不得 `live` + +### P0-F2: `proof_sha256` 对每个 `proof_id` 强制 + +Evidence: + +- `verify_bundle`:`if expected and sha256 != expected`;缺字段跳过哈希 +- `_valid_manifest` 只查 `kind` + `schema_version` + +Decision: + +- `_valid_manifest` 要求非空 `proof_ids: list[str]`,且 `proof_sha256` 对每个 id 有非空 hex +- 缺 digest / 不匹配 → `BUNDLE_BYTES_MISMATCH` / `inconclusive`,永不 `live` + +Acceptance: + +- 删掉某 id 的 digest 再改 `proofs/.json` → 不得 `live` + +### P0-F3: require-signed 必须 fail-closed + +Evidence: + +- `proof_from_payload`:`str(value)` 把 JSON `true` 变成 `"True"` +- `_requires_declared_keys` 只认 `value == "true"` +- 诚实导出写的是 `"true"` / `"false"` 字符串;陌生人/手改 ZIP 用布尔即可绕过缺密钥 + +Decision: + +- 只接受 `true`/`false`(布尔或小写字符串);其它值 `inconclusive` +- 该 kind 需要签名且 `declared_key_ids` 为空 → `missing_declared_keys` +- `policy_require_signed` 按 kind 盖戳:`review_verdict` ← `require_signed_review`;`signoff` ← `require_signed_signoff`;其它 kind 不得因工作区签复核策略而缺密钥失败(architect P1-4,并入本条,避免修完布尔又误伤 `gate_log`) + +Acceptance: + +- `{"require_signed_review": true}` + 空 `declared_key_ids` → 不得 `live` +- `require_signed_review=true` 的工作区导出的 `gate_log` 不得因缺密钥而 `inconclusive` + +### P0-F4: 不得把本树标成已发布 0.6.0,也不得在 P0-F1–F3 未关时打 `1.0.0` + +Evidence: + +- `pyproject.toml` `version = "0.6.0"` +- `tools/verify_release_gates.py` 在非 `1.0.0` 时 skip +- 门禁是子串存在,不是行为;`pypi-publish` 的 sdist smoke 不跑 `verify_bundle_stranger.py` + +Decision: + +- 本树禁止再当 `0.6.x` 发布(已含 Proof / Card / Compiler / `verify-bundle`) +- 包版本保持 `0.6.0` **直到** P0-F1–F3 落地;然后一次升到 `1.0.0` +- `1.0.0` 门禁必须跑:sdist 安装后的 `verify-bundle` 夹具;无 `--git-dir` 不得 `live`;`tasks.py` 的 `merge_task` / `check_dispatchable` 不得引用 `dyro.proof` +- 删掉把 `graph.py` 里 `_assert_dependency_integrated` 当 P5 证据的子串检查 + +Acceptance: + +- 未升号前:`verify_release_gates.py` 对本树若被当成 0.6 发布应失败(或发布作业根本不发这棵树) +- 升号后:缺 P0 行为测试则拒绝 tag + +## 4. P1 / P2 + +### P1(1.0.0 前应收,可与 P0 同 PR) + +1. **缺文件 / 不可解析在 evaluate 后仍为 `inconclusive`。** `_valid_review_acceptance` 对缺 `review.md` 返回 `False`,`evaluate_proof` 打成 `decayed`。区分 absent/`None` 与完整谓词失败/`False`。CLI 测试须覆盖 `list_proofs()` 之后,不只 derive。 +2. **便携 ZIP 不得携带工作区 rebind 结论。** `export_bundle` 经 `proof_payload` 写入 `status` / `decay_reason` / `observed_at`。陌生人 `cat` 与 `verify-bundle` 会打架。导出时这些字段置 `inconclusive` / 空,或省略。 +3. **`skill_and_hook` 不得暗示宿主已加载 deny hook。** 去向仍是投影树(不重开 B,不写 `hook_surface`,除非日后单独立项)。二选一:文案写明「产物写在 SKILL.md 旁,未安装到 hook_surface」;或在安装路径存在前不要发 `skill_and_hook`。收紧 `_proven_hook_surface`:拒绝 `.`、普通文件、`dyro.toml`、非声明的 hook 根。 +4. **pin 必须是完整对象 ID。** `_SHA_RE` 现为 7–64;多 `--git-dir` 的 `any(cat-file)` 在短 SHA 下会串库。只接受 40 或 64 hex。帮助写明:调用方备齐对象库;`--git-dir` 是并集,不是 `repo_id` 映射。 +5. **README 各国语言写清两套命令。** 身份句不够。陌生人只读 README 必须看到:`--git-dir`、完整性 ≠ merge、缺对象 → `inconclusive`。 +6. **A1 用 AST/import 锁死。** `merge_task` / `check_dispatchable` / `_prepare_merge` 不得引用 `dyro.proof`。 +7. **删 `*.toml` 留下 SKILL/hook 不得再当「未编译」。** 零投影文件仍放行 apply(锁定兼容)。有产物无有效 manifest → `compiled=True` 且 `TAMPERED`。 +8. **Host skill 表格字段转义。** `cannot_prove` 等不得把换行 / 反引号 / 禁止执行标记打进 SKILL.md。 + +### P2 + +- `_bundle_inconclusive` 不要伪造 `REVIEW_VERDICT` kind +- Host doctor 范围写明:只挡受监督 `apply`,不管 `task run` / `merge` +- `agent add` 仍写 `[adapters.*]`:标明兼容升级,或改写 `[[capabilities]]` +- 计划 §4 文件名改成 `host/` / `evaluate.py` / `project.py` / `cards.py` +- `integration_state` 与 `status` 不得打架 +- `force_inconclusive` 恒真:删或做实 +- ZIP 成员大小上限 +- `cryptography>=50.0.0`(CVE-2026-69247;本树只用 Ed25519,可达性低) +- 证据 ZIP 若同时带 `manifest.json` 与 evidence 标记:fail-closed +- JSON 可加 `conclusion: integrity` / `merge_equivalent: false`,防只看 `status` 的脚本 + +驳回 / 降级: + +- 「A1 已破 / Proof 成第二道 merge 闸」:源码不支持 +- 「必须把 deny hook 写进 `hook_surface`」:会扩大变异面,重开 B;不当 P0 +- 「短 SHA / 并集 `--git-dir` 是身份洞」:B1 本就不是身份;收紧到 P1 +- 「从未 compile 放行 apply 是漏洞」:锁定兼容;只修「删 toml 逃逸」 + +## 5. Open Micro-Decisions + +1. **deny hook 只写投影树:** 去向正确,标签过满。假权威在 `skill_and_hook` 与 `exists()` 证明,不在「没写到 hook_surface」。保持去向;改标签或文案。 +2. **`0.6.0` vs 1.0 能力:** 两个方向都是发布谎言。本树不得当 0.6.x 发;也不得在 P0 未关时标 1.0。版本号先不动,修完 P0 再升。 +3. **可重复 `--git-dir`:** 机制够用。1.0 必须写「调用方备齐对象;并集查找;不是 `repo_id` 绑定」。不要宣称多仓陌生人核验是按仓装配。 + +## 6. Instructions For The Execution Agent + +先改 `bundle.py` + 测试,再动 host/evaluate/README,最后才碰 `pyproject.toml` 版本号。 + +Must close: + +- P0-F1 空 `git_dirs` / 无 pin → 不得 `live` +- P0-F2 每个 id 强制 `proof_sha256` +- P0-F3 require-signed 解析 + 按 kind 盖戳 +- 对应单测:无 git 的 headless bundle;删 digest 后改 JSON;JSON `true` + 空密钥 + +Do not: + +- 改各审查员签名段 +- 让 `merge_task` / `check_dispatchable` 读 Proof +- 把 deny hook 写进 `hook_surface`(除非用户单独立项) +- 现在就把版本升到 `1.0.0` +- 把 `git revert` 当祖先断裂 +- 新写设计/ADR 文档;测试与必要 CLI 帮助除外 + +Write back: + +- 每个 P0:closed / open +- 新增测试路径 +- 是否仍保持 `version = "0.6.0"` + +Validation: + +```text +cd dyroengineeringflow +uv run pytest tests/test_proof_bundle.py tests/test_proof_cli.py tests/test_proof_decay.py tests/test_host.py tests/test_capability.py -q +uv run python tools/verify_bundle_stranger.py +``` + +## 7. Conditions To Start Implementation + +用户明确说「按终裁修」或「继续修 P0」即可开工。未说之前不要改业务代码、不要 commit、不要升版本。 + +## 8. Requires Human Verification + +- 任一受支持宿主是否会自动加载 `.dyro/host-projections/**/SKILL.md` 或 `deny-hook.json` +- 是否有 1.0 消费者只看 `status==live`、忽略 `mode=integrity` +- 本树相对已发布 `0.6.0` sdist 的 bitwise merge 对错(控制流已核,二进制 diff 未做) +- `objective apply` 是否仍是他们对外说的唯一「自动变异」入口 + +Final signature: Cursor + diff --git a/plans/delivery-physics-implementation.md b/plans/delivery-physics-implementation.md new file mode 100644 index 0000000..89ebfca --- /dev/null +++ b/plans/delivery-physics-implementation.md @@ -0,0 +1,361 @@ +# Dyro 交付物理学实施计划 + +状态:待批准;2026-08-15 锁定 A1 / B1;同日对抗评审仲裁已收口契约 +设计:[`docs/designs/delivery-physics.md`](../docs/designs/delivery-physics.md) +ADR:[`docs/adr/0006-delivery-physics-and-capability-plane.md`](../docs/adr/0006-delivery-physics-and-capability-plane.md) +仲裁:[`docs/superpowers/reviews/2026-08-15-delivery-physics-adversarial-review-board.md`](../docs/superpowers/reviews/2026-08-15-delivery-physics-adversarial-review-board.md) +基线:`0.6.0` 已发布的 TaskGraph、证据绑定、Objective、只读 Console +默认策略:先抽出投影,再衰减进调度,再换 Card,最后编译宿主;每一阶段未绿之前,下一阶段保持关闭 + +已锁定: + +- **A1**:`0.7` 的 merge / 下游对错与 `0.6.0` 相同。Proof 只投影现有绑定检查,只多 `PROOF_DECAYED`。`merge_task` / `check_dispatchable` 不读 Proof store。 +- **B1**:`verify-bundle` = Proof Bundle + 调用方 git 对象。核验完整性,不核验身份,不承诺与当前工作区 `verify` / merge 同一套 `live` / `decayed`。捆内不塞对象库。 +- 任务仓 dirty:`0.6` 已拒绝(`_collect_task_heads`),`0.7` 保持。不是「已否决的新规则」。 +- `ContinuationSnapshot` 是死类型。P4 只改 `SchedulerSnapshot` / `ProgressFacts`。 + +已锁定微决策: + +| # | 决议 | +| --- | --- | +| 1 | `proof verify` 默认 decay + rebind,不重跑 gate。`--rerun-procedure` 仅诊断,须 dry-run/隔离。 | +| 2 | Console P7 **滑到 0.8**。`0.7` 出口 = P1–P5 + P3 CLI。P6 `export` 可选、experimental。 | +| 3 | 宿主投影默认当前工作区。`tools.json` / PATH = `discovered_unintegrated`。`--user` 才写用户级 skill。 | +| 4 | `contract_hash` 按 subject 拆:task 面 kind → attempt `task_contract_sha256`(缺则空);`action_receipt` → Objective `contract_sha256`。 | +| 5 | `proof list` / `verify` 每次全量重派生。store 可丢弃,不是展示真源。 | + +--- + +## 1. 最终交付结果 + +完成本计划后,Dyro `1.0.0` 对外可证明: + +1. 已有 receipt / review / heads / signoff / action receipt 可被列为 Proof,且不复制真源; +2. `dyro proof verify` 对**当前工作区**做衰减与绑定重算(rebind,不是 replay);`verify-bundle` 用 bundle + 调用方 git 对象做**完整性**复验,两套结论不得混称; +3. `0.7` 衰减与现有 merge / 下游检查同真值;只把已发生的拒绝用 `PROOF_DECAYED` 说清楚,不另造拒绝条件,不加严下游 ready set; +4. Capability Card 取代「只有 argv 的 adapter」,旧 Profile 仍能加载; +5. Host Compiler 只把本机已审计能力投影为宿主 `SKILL.md`;过期投影阻断自动 mutation; +6. README 与术语扫描把产品身份锁在 Delivery Physics,而不是 agent 编排; +7. Console / Home 在 `0.8` 显示 Proof 状态与衰减原因,仍无写权;`0.7` 用 `dyro proof list` 与 `dyro objective attention`。 + +--- + +## 2. 实施原则 + +- 每个 PR 只做一个可回滚切片;依赖未合并前,后续分支不重写其代码。 +- 先写失败测试,再写最小实现,再跑全量回归。 +- 新增 mutation 必须有 dry-run、锁、ledger、失败恢复。 +- 不直接改写 Task 质量门状态;`0.7` 衰减只投影现有受保护 API 的拒绝,不增加新的 merge / 下游拒绝条件。 +- 不把设计文档里的否决项当成「以后再说的优化」。 +- 权威投影已锁定为 B:P11 先交 skill;P12a 只对已证明 hook 表面的宿主投影 deny hook。禁止因无 hook 拒绝 compile。 +- 不修改用户当前无关 checkout;不把业务仓库名写进 Core 测试以外的夹具。 +- 未关闭仲裁 P0 文档项之前,不得合并 P2 / P4 / P5 实现。 + +--- + +## 3. 版本列车 + +| 版本 | 主题 | 对用户可见 | 关闭条件未满足时 | +| --- | --- | --- | --- | +| `0.6.x` | 身份冻结 | 文档 + ADR + 术语扫描 | 不合并 Proof/Card/Compiler 代码 | +| `0.7.0` | Proof 与衰减 | `dyro proof list/show/verify`;可选 experimental `export`;`dyro objective attention` 可含 `PROOF_DECAYED` | 不改 adapter schema;`verify-bundle` 不是 0.7 硬门禁 | +| `0.8.0` | Capability Card + Console Proof | `dyro capability *`;`agent add` 写 Card;Console 只读展示 Proof | 不编译宿主文件 | +| `0.9.0` | Host Compiler | `dyro host compile/status/doctor` | 不承诺 1.0 对外核验 | +| `1.0.0` | 可携带核验 | Proof Bundle `schema_version = 1`;`verify-bundle` 硬门禁;叙事锁死 | 缺一项不得标 1.0 | + +`0.6.x` 继续只接受维护修复。本计划的实现从独立开发线切入,不回写 `0.6.0` 的已发布语义。 + +--- + +## 4. 模块地图 + +```text +src/dyro/proof/ + models.py Proof、DecayDecision、BundleManifest + derive.py 从 receipt/review/heads/signoff/action 派生存活对象(见附录 A) + decay.py 纯函数 decay(proof, substrate, clock) + evaluate.py 默认衰减与绑定重算(工作区 rebind) + project.py Proof 展示投影;list/verify 默认重派生,无 store + bundle.py 导出/导入 ZIP;调用方 git 对象做完整性核验;拒绝 evidence ZIP 布局 + +src/dyro/capability/ + models.py CapabilityCard、IsolationClass、Intent + cards.py adapters.* → Card 的只读升级 + store.py 已审计 Card 写入 + probe.py doctor 可执行探测;PATH 发现不进入 execute + cli.py capability list/add/test + +src/dyro/host/ + compile.py 输入 Cards + 探测 → 投影树与 SKILL.md + doctor.py 重算哈希;手改 fail-closed + models.py 投影 manifest / authority 标签 + +现有模块保持职责: + tasks.py / reviews.py / evidence*.py / provenance.py 真源 + continuation/snapshot.py + budgets.py SchedulerSnapshot / ProgressFacts + continuation/models.py ReasonCode.PROOF_DECAYED;ContinuationSnapshot 保持死类型 + continuation/attention.py PROOF_DECAYED → AttentionKind.NEEDS_USER + console/read_model.py 0.8 只读展示;summary 零新 git I/O + tooling.py 发现结果供给 Compiler;不得当 Card + profile.py / config.py 加载旧 adapters +``` + +禁止把 Proof 缓存写成可以绕过 review 绑定的第二份 PASS。 + +--- + +## 5. PR 依赖图 + +```text +P0 文档与术语冻结 + │ +P1 Proof 模型与从现有文件派生 + │ +P2 Decay 纯函数 + 单测夹具 + │ +P3 proof CLI(list/show/verify) + │ +P4 SchedulerSnapshot 纳入 Proof 投影;reason code PROOF_DECAYED + │ +P5 交付门 decay 投影(A1) + │ +P6 Proof Bundle export(0.7 experimental) + │ +P7 Console/Home 只读展示(默认 0.8) + │ +P8 Capability 模型 + adapters 迁移 + │ +P9 capability CLI + attest/doctor + │ +P10 未审计命令保持发现-only(含 OpenCode 探测,不给 execute) + │ +P11 Host Compiler 核心 + SKILL.md + │ +P12 host doctor + 过期投影阻断自动 mutation + │ +P12a 可选 deny hook(仅已证明 hook 表面的宿主) + │ +P13 1.0 叙事、schema 冻结、verify-bundle 硬门禁 +``` + +并行允许: + +- P2 可在 P1 模型冻结后与 P3 的 CLI 骨架并行,但 P3 的 verify 必须等 P2。 +- P7 不得早于 P2/P5 同真值落地;默认等 0.8,不得等待 P6。 +- P8 不得早于 P5:先保证旧 adapter 世界里衰减已经生效。 +- P11 不得早于 P9。P12a 不得早于 P12;无 hook 宿主上 P12a 必须仍使 compile 成功。 +- P6 `verify-bundle` 实现可与 P6 export 同文件,但 0.7 tag **不**以其为硬门禁。 + +--- + +## 6. 分 PR 说明 + +### P0 · 文档与术语冻结 + +- 合并本设计、ADR-0006、本计划(含本仲裁修订)。 +- 术语扫描只禁精确短语:`multi-agent platform`、`skill marketplace`、`open-source alternative`。现有 `multi-agent dispatch` 不在禁列。 +- 策略文件必须在仓库外(现有 `dyro terminology check` 约束);CI 用环境或外部文件,不把对照表写进树。 +- 允许词:`delivery control plane`、`delivery physics`、`proof`、`capability card`。 +- 架构不变量 14–20 回写 `architecture.md`,避免设计/架构双清单。 +- 验收:文档进树;外部策略扫描绿;无代码行为变化。 + +### P1 · Proof 派生 + +- 只读扫描现有任务目录,派生 `gate_log` / `review_verdict` / `signoff` / `integration_heads` / `action_receipt`。这是 `0.7` 的 kind 闭集。算法见**附录 A**。 +- 不派生 `trigger_observation`(0.8+)或把 `external_bundle` 当成新 ZIP;后者若出现,只是已有 evidence ZIP 世代的投影。 +- 不改写 `review.md`、receipt、ledger。**不**把 ledger 当 gate PASS。 +- `produced_at` 只取记录内字段;`generation` 用证据世代或 attempt 世代。身份哈希不含「现在」、mtime、`produced_at`。 +- 缺绑定字段 → `inconclusive`,不伪造 live。 +- `proof list --task` **不含** `action_receipt`。 +- 验收:必须引用 `tests/test_tasks.py` 等现有 evidence / bound-review 夹具 + 新建 `tests/test_proof_derive.py`。`examples/polyrepo` 仅 smoke(`proof list` 不 crash),**不是**黄金哈希源。两次派生、不同 mtime,身份哈希相同。 + +### P2 · Decay + +- 纯函数,禁止读时钟以外的全局状态;时钟由调用方注入。 +- **分表**,禁止混为一表: + - `review_verdict` ← `_valid_review_acceptance`(receipt / `task-heads.json` / attempt / plan / local `_assert_task_heads_current`,含任务仓 dirty)。 + - `signoff` ← `_valid_external_signoff`。 + - 开发线 dirty / 错分支 ← `_prepare_merge`;现有 merge 错,**不是** `PROOF_DECAYED`。 + - 下游祖先 ← `_assert_dependency_integrated`。 +- 不把 `git revert` 当成祖先断裂。 +- 任务仓 dirty + HEAD 未变:merge **拒绝**,错误集与 0.6 相同。补锁夹具。 +- `gate_log` 内容哈希变化可以展示为 decayed;`0.7` merge 不因这条新拒绝。 +- 验收:表驱动测试命名对应源码函数;无 I/O;与现有谓词同真值的夹具必须绿。不得同时要求「dirty 不拒绝」。 + +### P3 · `dyro proof` CLI + +```text +dyro proof list [--task ID] [--objective ID] [--line ID] +dyro proof show +dyro proof verify [--dry-run] +``` + +- JSON 与人话共用同一 projection。`live` = 当前 substrate 上 rebind 成立,不是 procedure 已复现。 +- `verify` 默认做衰减与绑定重算,不重跑 gate argv,不改 Task 状态机,无 gate 子进程。 +- `--rerun-procedure` 才重跑,且必须 dry-run 或隔离;ledger 只记 rerun 或状态翻转。未 replay 的 `gate_log` JSON 不得出现 `procedure_reproduced=true`。 +- 验收:默认 verify 无 gate 副作用;dry-run 无写;失败退出码区分 decayed / inconclusive / error。 + +### P4 · 续航快照 + +- 目标类型是 **`SchedulerSnapshot._payload`** 与 **`ProgressFacts` 装配点**(supervision / planner 交界)。**禁止**给未实例化的 `ContinuationSnapshot` 加 `proofs[]`。 +- journal **不**持久化 proofs 当 PASS。`SchedulerReadProjection.schema_version` 保持 `1`;新字段缺省空,不进 merge 真源。 +- planner / `ReasonCode` / `attention.py` / `_schedule_block_reason` 同步加 `PROOF_DECAYED`。attention 映射 `AttentionKind.NEEDS_USER`。默认 **不**用该码 block 下游。 +- `progress_fingerprint` 继续忽略 trigger 类 Proof。`0.7` **不**把 Proof 接入生产 `BudgetUsage`,不新开 no-progress 自动耗尽。文档承认:`decide_no_progress` 是已锁纯函数,生产未接线。 +- 验收:同一 substrate 下 `build_scheduler_snapshot` digest 稳定;旧 journal 无 proof 字段兼容;`test_continuation_budgets` 仍绿;衰减后下一 tick 可出现 attention,不自动重跑 agent。 + +### P5 · 交付门 decay 投影(A1) + +- `task merge` 仍只走 `_valid_review_acceptance` + 可选 `_valid_external_signoff` + `_prepare_merge`。 +- 下游释放仍只走 `_assert_dependency_integrated`。禁止 `if proof.status != live: block downstream`,禁止改 `build_task_readiness` 的接受集合。 +- Proof 只给这些**已经发生**的检查一个 `live` / `decayed` 名字。`PROOF_DECAYED` 仅用于 merge 人话 / attention。 +- `task explain` 对每个 `done` 依赖复用同一祖先检查(`_assert_dependency_integrated` 或 snapshot `integration_state`),**不**读 Proof 缓存。这是修 0.6 已有裂口,不是加严。 +- 验收首条:`_valid_review_acceptance` / `_assert_dependency_integrated` 的 accept/reject 集合与 0.6 **bitwise 相同**。夹具「`done` + `review.md` 被撕 / 任务仓 HEAD 已漂,但 `task-heads.json` 仍是线祖先」→ 下游仍 ready;仅 ancestor 失败 → 只报 `TASK_INTEGRATION_PENDING`;`explain.dispatchable=false` 且文案与 `check_dispatchable` 同类。diff 中 `merge_task` / `check_dispatchable` 不新增 Proof store 读取。 + +### P6 · Proof Bundle(B1:完整性,不是身份) + +- ZIP:manifest、被核验字节、procedure 描述、钉死的 heads/hashes、`declared_key_ids`、`policy_require_signed` 快照。 +- 剥离路径、凭据、prompt、adapter env。**不**放入 git 对象库。 +- CLI: + +```text +dyro proof export --bundle PATH +dyro proof export --task ID --bundle PATH +``` + + 位置参数是 proof-id;`--task` 批量导出。二者互斥。help 与设计同形。 +- 拒绝 `task evidence build` 的 ZIP 布局:对其跑 `verify-bundle` → `inconclusive`,不是 `live`。 +- `verify-bundle` 必须由调用方提供 git 对象(`--git-dir` 或测试夹具里的 bare repo)。无 `--current-heads` 不得报与 merge 相同的衰减结论。 +- 缺 procedure、缺 substrate、缺 git 对象、或缺已声明的签名密钥 → `inconclusive`,不得 `live`。 +- **列车:** `export` 可进 0.7,标 experimental。`verify-bundle` 硬门禁与 `schema_version = 1` 归 1.0 / P13。 +- 验收:单任务多 proof 导出有表驱动测试;干净环境带固定 git 夹具得到与源机相同的**完整性**结论(不是「现在能否 merge」);不提供 git 对象时为 `inconclusive`;机密扫描零命中。 + +### P7 · 只读展示(默认 0.8) + +- `dyro objective attention ` 与 Console read_model 显示 Proof 状态与稳定 reason。**无**顶层 `dyro attention`。 +- 不展示 argv、绝对路径、日志正文。 +- 若产品坚持 0.7 做 Console:`capture_workspace_read_snapshot` / summary **零新 git I/O**;衰减展示走 `not_inspected` 或独立 inspect。打破 `test_console_read_model.py` 的「summary 不探 Git」即为回归。 +- 验收:既有 Console 只读攻击夹具仍绿;浏览器无新写入口;`dyro objective attention` JSON 在 merge 相关 decay 时可含 `PROOF_DECAYED`。 + +### P8 · Capability 迁移 + +- `Config.adapters` 仍可解析。 +- 运行时升级为 Card;缺省 isolation=`cwd`,`cannot_prove+=done,merge`。 +- `dyro.toml` 可开始写 `[[capabilities]]`;两者共存时 ID 冲突 fail-closed。 +- 验收:旧 examples 零改动仍能 `doctor`;新 schema 有正反解析测试。 + +### P9 · capability CLI + +```text +dyro capability list +dyro capability add --preset ... +dyro capability test +``` + +- `test` 做登录/可执行探测,不启动交付。hook 表面若存在,写入同一份报告字段,不另造 `--host-id`。 +- `agent add` 仍写 `[adapters.*]`;运行时升级为 Card。写 `[[capabilities]]` 用 `capability add`。 +- 验收:`noop` / `codex` preset 行为与 0.6 兼容。 + +### P10 · 发现但不执行 + +- 探测 `opencode`、`cursor-agent` 等,标记 `discovered_unintegrated`。 +- Objective / `task run` 不得因探测成功而选中它们。 +- `dyro tool list` / `tool install` / `tool default` / `dyro open` 不得写入可执行 Card,也不得被 Objective 选中。0.8 Card 只包 adapters。 +- 验收:PATH 里有假 `opencode` 可执行文件时,自动执行仍 fail-closed。 + +### P11 · Host Compiler + +- 渲染 Agent Skills `SKILL.md`:定律摘要、本机可用表、负例、只打印 `dyro next` 命令。 +- 输入:`config.adapters` + `capability test`。**不**把 `registry_home()/tools.json` 或 PATH 发现当已审计 Card。 +- 默认只写**当前工作区** `.dyro/host-projections/`。用户级目录(Codex home skills 等)必须显式 `--user`,doctor 标 `scope=workspace|user`。路径来自探测,不写死客户名。 +- 原子替换 + 投影清单哈希。 +- 验收:无可用 Card 时产物不含任何 execute 暗示;有 Card 消失后重编译删除对应段落;doctor 报告含 `scope`。 + +### P12 · 投影医生 + +- `dyro host doctor` 重算哈希。 +- 过期或手改 → 非零退出;若存在 ActivationLease,下一 mutation tick fail-closed 为 plan-only。 +- 验收:改一个字节的 SKILL.md 即失败;修复后恢复。 + +### P12a · 可选 deny hook + +- 仅当宿主 Card 声明并被 `capability test` 证明存在 hook 表面时,编译 deny hook。 +- Deny 从操作格生成:未授权的 `integrate` / `publish`,以及写入 `.dyro/`。 +- 无 hook 宿主:compile 成功,`authority_projection=skill_only`,doctor 不失败。 +- 已投影 hook 被删或哈希漂移:doctor fail-closed。 +- 文档与 CLI 帮助不得把 hook 写成沙箱或隔离。 +- 验收:假 hook 表面不得触发 hook 文件;无 hook 的 OpenCode 夹具仍能 compile。 + +### P13 · 1.0 门禁 + +- Bundle schema 锁 `schema_version = 1`。 +- 发布工件含「陌生人核验」CI:从 sdist 安装的干净环境,用夹具 git 对象跑 `verify-bundle`,断言**完整性**结论,不断言与源机当前 HEAD 的 merge 对错相同。 +- README 各语言同步身份句,术语扫描覆盖翻译文件。 +- 验收:缺 P5/P6-export/P12 任一证据,发布工作流拒绝打 `1.0.0` 标签。`verify-bundle` 是 1.0 硬门禁。 + +--- + +## 7. 明确延后(不是本列车) + +| 项 | 延后原因 | +| --- | --- | +| OpenCode / Cursor 的经审计 execute adapter | 需要独立协议审计,不属于物理学抽出 | +| 把 hook 做成所有宿主的强制门槛 | 已否决(选项 C);P12a 保持可选 | +| CI / Linear Trigger provider | 已有 Trigger 扩展点;观察不得完成任务 | +| HMAC 审计链 | Witness 已有哈希链;重复造链没有产品增量 | +| 自动 push / 发布 | 1.0 仍显式 | +| Skill 投影评测 | 先有稳定投影,再谈评测 | +| 沙箱 backend entry point | Card 先能声明 isolation,再插拔实现 | +| Console Proof 展示(P7) | 默认 0.8;不破坏 A1 | +| 检测 revert 是否撤掉了变更 | 不是祖先问题;另立规则后再做 | +| Bundle 自含 git 对象库 | B2,已否决;调用方提供对象 | +| `trigger_observation` 派生 | 0.8+;字段用 `next_probe_at` | +| 生产接线 `decide_no_progress` | 0.7 只保证纯函数契约 | +| 放松任务仓 dirty 拒绝 | 会改 0.6 对错;除非产品显式改口 | + +--- + +## 8. 风险与对策 + +| 风险 | 对策 | +| --- | --- | +| Proof 缓存被当成第二份 PASS | 缓存可全量重建;list/verify 默认重派生;merge 仍读原始绑定字段 | +| 用户以为 `host compile` 等于授权 merge | 产物只含 observe + 打印命令;文档与负例双写 | +| Card 迁移弄坏旧 Profile | 只读升级;冲突 fail-closed;examples 零改动测试 | +| 衰减导致「合法工作无法合并」 | A1:0.7 对错与现在相同;人话指向现有修复命令 | +| 把 revert 当成祖先断裂 | 不实现;祖先检查只问提交是否仍在历史上 | +| 以为 bundle 自带 git 对象 | B1:调用方提供对象;缺对象 → inconclusive | +| 以为 `verify-bundle live` = 现在能 merge | 两条命令两套结论;无 `--current-heads` 不报衰减 | +| 改错快照类型 / 写入 journal | P4 只碰 `SchedulerSnapshot`;journal 不存 proofs | + +--- + +## 9. 阶段出口 + +**0.7 可发布:** P1–P5 + P3 绿;P6 `export` 可选且标 experimental;P7 **不是**硬依赖。旧工作区不改 toml 即可 `proof list`;merge / 下游对错与 0.6 相同,merge 错误路径只多 `PROOF_DECAYED` 人话。0.7 tag 检查**不含** `verify-bundle` 硬门禁。 + +**0.8 可发布:** P7–P10 绿;旧 adapters 仍跑;未审计命令不能进自动执行;Console 只读展示 Proof 且 summary 无新 git probe。 + +**0.9 可发布:** P11–P12a 绿;换机器重编译后 doctor 通过;手改投影不能偷偷继续自动跑;无 hook 宿主仍能 compile,且文案不把 hook 写成隔离。 + +**1.0 可发布:** P13 绿;干净环境用调用方 git 夹具复验 bundle,**完整性**结论与源机导出时一致;缺 git 对象为 `inconclusive`;身份句在所有 README 语言中一致。 + +任一出口的「绿」指:单测、现有 unittest 全量、ruff 基线、术语扫描、以及该阶段新增的 fail-closed 夹具。 + +--- + +## 附录 A · 0.7 Proof derive 规格 + +身份:`id = sha256(kind || subject || generation || identity_payload)`。`identity_payload` **不含** `produced_at`、mtime、`now`。`list` / `verify` 默认全量重派生;没有 Proof store。 + +`contract_hash`(已锁定):task 面 kind 用 attempt `task_contract_sha256`(缺则空,不伪造);`action_receipt` 用 Objective `contract_sha256`。`list` / `verify` 每次全量重派生;store 不得当展示真源。 + +| kind | 源路径 | subject | substrate | produced_at | generation / 何时物化 | decay | +| --- | --- | --- | --- | --- | --- | --- | +| `gate_log`(local) | `{task.directory}/logs/gate-{n}.log`(`_capture` 真源)+ `receipt.md`。argv 哈希取自 `task.toml` 的 gate 定义,**不**读 ledger | `task_id` | 被测树 heads + gate argv 哈希 + contract_hash | 空(日志无记录内时间) | 证据世代或 attempt。derive 时扫描 `logs/` 与任务根 | argv 或树内容哈希变 → **展示** `decayed`;merge 不新拒绝 | +| `gate_log`(external) | 当前 evidence generation:`gates.json` + `gates/gate-{n}.log`(`evidence.py` / `tasks.py` 导入路径) | `task_id` | 同上 | 空,除非记录内已有时间字段 | 同上 | 同上 | +| `review_verdict` | `review.md` + `receipt.md` + `task-heads.json` + attempt/plan 绑定 | `task_id` | receipt SHA、`task-heads.json` SHA、`attempt_id`、`plan_sha256`、local 当前 heads | 空(`review.md` 无时间字段)。signed review JSON 的 `created_at` 仅可展示,不进身份哈希 | 绑定的 attempt | `_valid_review_acceptance` 全量 | +| `signoff` | `signoff.json` | `task_id` | `review_sha256`、receipt、heads、attempt、plan | `signed_at` | 同 attempt | `_valid_external_signoff` 全量 | +| `integration_heads` | **无持久文件**。即时 `git merge-base --is-ancestor HEAD`,与 `_assert_dependency_integrated` 同一调用 | 被检查的依赖 `task_id`。列下游任务时按 `depends_on` 展开 | 当前线 HEADs + 依赖 `task-heads.json` | 空 | derive / verify 时物化,不写盘当真源。缺 git → `inconclusive` | 祖先成立 → `live`;reset / 换历史 → `decayed`;`git revert` 不衰减。三态与 scheduler `integration_state` 一致 | +| `action_receipt` | Objective 目录 `action-receipts/`(`action_journal.py`),**不是** task 目录 | `objective_id` | intent / authority / budget 字段 | `created_at` | journal 世代 | 字段或世代被替换 → `decayed`。`proof list --task` **不返回**;`--objective` 或 0.8+ 再暴露 CLI | + +缺文件、缺工具、不可解析 → `inconclusive`,不得 `live`。 diff --git a/pyproject.toml b/pyproject.toml index 04c9ec4..b640388 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "DyroEngineeringFlow: local-first automation and delivery control readme = "README.md" requires-python = ">=3.11" dependencies = [ - "cryptography>=44.0.0", + "cryptography>=50.0.0", "rfc8785>=0.1.4", ] authors = [{ name = "DandreYang" }] @@ -47,9 +47,12 @@ dev = ["build==1.3.0", "ruff==0.15.2", "twine==6.2.0"] [tool.setuptools] packages = [ "dyro", + "dyro.capability", "dyro.console", "dyro.console.assets", "dyro.continuation", + "dyro.host", + "dyro.proof", "experiments", "experiments.local_agent_dispatch", "experiments.local_agent_dispatch.adapters", diff --git a/src/dyro/capability/__init__.py b/src/dyro/capability/__init__.py new file mode 100644 index 0000000..5958382 --- /dev/null +++ b/src/dyro/capability/__init__.py @@ -0,0 +1,35 @@ +"""Capability plane: audited Cards only. PATH discovery is not execute.""" + +from .cards import card_from_adapter, merge_capability_plane, parse_capability_tables +from .models import ( + CapabilityCard, + CapabilityKind, + CapabilityTestReport, + DiscoveredTool, + Isolation, +) +from .probe import ( + card_payload, + discover_unintegrated, + runtime_cards, + test_capability, +) +from .store import append_capability, card_from_command, card_from_preset + +__all__ = ( + "CapabilityCard", + "CapabilityKind", + "CapabilityTestReport", + "DiscoveredTool", + "Isolation", + "append_capability", + "card_from_adapter", + "card_from_command", + "card_from_preset", + "card_payload", + "discover_unintegrated", + "merge_capability_plane", + "parse_capability_tables", + "runtime_cards", + "test_capability", +) diff --git a/src/dyro/capability/cards.py b/src/dyro/capability/cards.py new file mode 100644 index 0000000..80d57aa --- /dev/null +++ b/src/dyro/capability/cards.py @@ -0,0 +1,134 @@ +"""Parse [[capabilities]], upgrade [adapters.*], and refuse ID collisions.""" + +from __future__ import annotations + +from typing import Any, Mapping + +from ..config import Adapter, validate_id +from ..errors import ValidationError +from .models import ( + DEFAULT_CANNOT_PROVE, + CapabilityCard, + CapabilityKind, + Isolation, +) + + +def card_from_adapter(adapter: Adapter) -> CapabilityCard: + """Runtime upgrade. Missing Card fields stay fail-closed.""" + return CapabilityCard( + id=adapter.id, + kind=CapabilityKind.AGENT, + launch=adapter.launch, + read=adapter.read, + write=adapter.write, + attested_isolation=Isolation.CWD, + trusted_usage=False, + can_prove=(), + cannot_prove=DEFAULT_CANNOT_PROVE, + intents=("observe", "execute"), + hosts=("cli",), + source="adapter", + ) + + +def parse_capability_tables(raw: Any) -> dict[str, CapabilityCard]: + if raw in (None, {}): + return {} + if not isinstance(raw, list): + raise ValidationError("capabilities 必须是 [[capabilities]] 数组,不能是 [capabilities] 表") + cards: dict[str, CapabilityCard] = {} + for index, entry in enumerate(raw): + card = parse_capability_entry(entry, label=f"capabilities[{index}]") + if card.id in cards: + raise ValidationError(f"capabilities 重复 ID:{card.id}") + cards[card.id] = card + return cards + + +def parse_capability_entry(entry: Any, *, label: str) -> CapabilityCard: + if not isinstance(entry, dict): + raise ValidationError(f"{label} 必须是表") + if "env" in entry: + raise ValidationError(f"{label} 不得声明环境变量;认证使用本机已登录会话") + card_id = validate_id(str(entry.get("id", "") or ""), f"{label}.id") + kind_raw = str(entry.get("kind", "agent") or "agent") + try: + kind = CapabilityKind(kind_raw) + except ValueError as exc: + raise ValidationError(f"{label}.kind 无效:{kind_raw}") from exc + isolation_raw = str(entry.get("attested_isolation", "cwd") or "cwd") + try: + isolation = Isolation(isolation_raw) + except ValueError as exc: + raise ValidationError(f"{label}.attested_isolation 无效:{isolation_raw}") from exc + launch = _argv(entry.get("launch"), f"{label}.launch", required=kind is CapabilityKind.AGENT) + read = _argv(entry.get("read", entry.get("launch")), f"{label}.read", required=kind is CapabilityKind.AGENT) + write = _argv(entry.get("write", entry.get("launch")), f"{label}.write", required=kind is CapabilityKind.AGENT) + can_prove = _string_list(entry.get("can_prove", []), f"{label}.can_prove") + cannot_prove = _string_list(entry.get("cannot_prove", list(DEFAULT_CANNOT_PROVE)), f"{label}.cannot_prove") + intents = _string_list(entry.get("intents", ["observe", "execute"]), f"{label}.intents") + hosts = _string_list(entry.get("hosts", ["cli"]), f"{label}.hosts") + preset = entry.get("preset", "") + if preset is None: + preset = "" + if not isinstance(preset, str): + raise ValidationError(f"{label}.preset 必须是字符串") + hook_surface = entry.get("hook_surface", "") + if hook_surface is None: + hook_surface = "" + if not isinstance(hook_surface, str): + raise ValidationError(f"{label}.hook_surface 必须是字符串") + trusted = entry.get("trusted_usage", False) + if not isinstance(trusted, bool): + raise ValidationError(f"{label}.trusted_usage 必须是布尔值") + return CapabilityCard( + id=card_id, + kind=kind, + launch=launch, + read=read, + write=write, + preset=preset, + attested_isolation=isolation, + trusted_usage=trusted, + can_prove=can_prove, + cannot_prove=cannot_prove, + intents=intents, + hosts=hosts, + source="capabilities", + hook_surface=hook_surface, + ) + + +def merge_capability_plane( + adapters: Mapping[str, Adapter], + cards: Mapping[str, CapabilityCard], +) -> tuple[dict[str, Adapter], dict[str, CapabilityCard]]: + overlap = sorted(set(adapters) & set(cards)) + if overlap: + raise ValidationError(f"adapters 与 capabilities ID 冲突:{', '.join(overlap)}") + merged_adapters = dict(adapters) + for card in cards.values(): + if card.launch and card.read and card.write: + merged_adapters[card.id] = Adapter(card.id, card.launch, card.read, card.write) + merged_cards = {adapter_id: card_from_adapter(adapter) for adapter_id, adapter in adapters.items()} + merged_cards.update(cards) + return merged_adapters, merged_cards + + +def _argv(value: Any, label: str, *, required: bool) -> tuple[str, ...]: + if value is None: + if required: + raise ValidationError(f"{label} 必须是非空 argv 数组") + return () + if not isinstance(value, list) or not value or not all(isinstance(item, str) and item for item in value): + raise ValidationError(f"{label} 必须是非空 argv 数组") + return tuple(value) + + +def _string_list(value: Any, label: str) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): + raise ValidationError(f"{label} 必须是字符串数组") + return tuple(value) diff --git a/src/dyro/capability/models.py b/src/dyro/capability/models.py new file mode 100644 index 0000000..faf4bce --- /dev/null +++ b/src/dyro/capability/models.py @@ -0,0 +1,123 @@ +"""Capability Card types. Adapters upgrade into Cards; discovery is not a Card.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from ..errors import ValidationError + + +class CapabilityKind(str, Enum): + AGENT = "agent" + GATE = "gate" + REVIEWER = "reviewer" + TRIGGER = "trigger" + TOOL = "tool" + + +class Isolation(str, Enum): + NONE = "none" + CWD = "cwd" + WORKTREE = "worktree" + OS_SANDBOX = "os_sandbox" + EXTERNAL_RUNNER = "external_runner" + + +class Intent(str, Enum): + OBSERVE = "observe" + EXECUTE = "execute" + REVIEW = "review" + SIGN = "sign" + INTEGRATE = "integrate" + PUBLISH = "publish" + + +DEFAULT_CANNOT_PROVE = ("done", "merge") +PROOF_KINDS = frozenset( + {"gate_log", "review_verdict", "signoff", "integration_heads", "action_receipt"} +) +CARD_SOURCES = frozenset({"adapter", "capabilities"}) + + +def _frozen_strings(value: Any, label: str) -> tuple[str, ...]: + if isinstance(value, (str, bytes)): + raise TypeError(f"{label} 必须是集合,不能是字符串") + items = tuple(value) + if not all(isinstance(item, str) for item in items): + raise TypeError(f"{label} 必须只包含字符串") + return items + + +def _frozen_argv(value: Any, label: str) -> tuple[str, ...]: + items = _frozen_strings(value, label) + if items and not all(item for item in items): + raise ValidationError(f"{label} 不能包含空参数") + return items + + +@dataclass(frozen=True) +class CapabilityCard: + id: str + kind: CapabilityKind + launch: tuple[str, ...] + read: tuple[str, ...] + write: tuple[str, ...] + preset: str = "" + attested_isolation: Isolation = Isolation.CWD + trusted_usage: bool = False + can_prove: tuple[str, ...] = () + cannot_prove: tuple[str, ...] = DEFAULT_CANNOT_PROVE + intents: tuple[str, ...] = (Intent.OBSERVE.value, Intent.EXECUTE.value) + hosts: tuple[str, ...] = ("cli",) + source: str = "capabilities" + hook_surface: str = "" + + def __post_init__(self) -> None: + if not isinstance(self.kind, CapabilityKind): + raise TypeError("CapabilityCard.kind 必须是 CapabilityKind") + if not isinstance(self.attested_isolation, Isolation): + raise TypeError("CapabilityCard.attested_isolation 必须是 Isolation") + if self.source not in CARD_SOURCES: + raise ValidationError(f"CapabilityCard.source 无效:{self.source}") + object.__setattr__(self, "launch", _frozen_argv(self.launch, "launch")) + object.__setattr__(self, "read", _frozen_argv(self.read, "read")) + object.__setattr__(self, "write", _frozen_argv(self.write, "write")) + object.__setattr__(self, "can_prove", _frozen_strings(self.can_prove, "can_prove")) + cannot_prove = _frozen_strings(self.cannot_prove, "cannot_prove") + missing = [item for item in DEFAULT_CANNOT_PROVE if item not in cannot_prove] + object.__setattr__(self, "cannot_prove", cannot_prove + tuple(missing)) + object.__setattr__(self, "intents", _frozen_strings(self.intents, "intents")) + object.__setattr__(self, "hosts", _frozen_strings(self.hosts, "hosts")) + unknown_prove = [item for item in self.can_prove if item not in PROOF_KINDS] + if unknown_prove: + raise ValidationError(f"can_prove 只能填写 Proof kind:{', '.join(unknown_prove)}") + unknown_intents = [item for item in self.intents if item not in {intent.value for intent in Intent}] + if unknown_intents: + raise ValidationError(f"intents 无效:{', '.join(unknown_intents)}") + if not self.hosts: + raise ValidationError("hosts 不能为空") + if not isinstance(self.trusted_usage, bool): + raise TypeError("trusted_usage 必须是布尔值") + if not isinstance(self.hook_surface, str) or not isinstance(self.preset, str): + raise TypeError("hook_surface 与 preset 必须是字符串") + + +@dataclass(frozen=True) +class DiscoveredTool: + id: str + command: str + state: str = "discovered_unintegrated" + + +@dataclass(frozen=True) +class CapabilityTestReport: + id: str + source: str + executable: bool + logged_in: bool | None + hook_surface: str + attested_isolation: str + cannot_prove: tuple[str, ...] + checks: tuple[tuple[str, bool, str], ...] diff --git a/src/dyro/capability/probe.py b/src/dyro/capability/probe.py new file mode 100644 index 0000000..5954152 --- /dev/null +++ b/src/dyro/capability/probe.py @@ -0,0 +1,96 @@ +"""Probe Cards and list PATH discoveries. Discovery never becomes execute.""" + +from __future__ import annotations + +from pathlib import Path +import shutil + +from ..config import Config +from ..errors import DyroError +from ..profile import test_adapter +from ..tooling import TOOL_DEFINITIONS +from .models import CapabilityCard, CapabilityTestReport, DiscoveredTool + + +def runtime_cards(config: Config) -> dict[str, CapabilityCard]: + return dict(config.capabilities) + + +def discover_unintegrated(config: Config) -> tuple[DiscoveredTool, ...]: + """PATH / catalog hits that are not audited Cards. No execute intent.""" + cards = runtime_cards(config) + configured_commands = { + Path(card.launch[0]).name + for card in cards.values() + if card.launch + } + found: list[DiscoveredTool] = [] + for definition in TOOL_DEFINITIONS: + if definition.interface == "desktop": + continue + if definition.id in cards or definition.command in configured_commands: + continue + if shutil.which(definition.command) is None: + continue + found.append(DiscoveredTool(id=definition.id, command=definition.command)) + return tuple(found) + + +def test_capability(config: Config, card_id: str) -> CapabilityTestReport: + """Login / executable probe only. Does not start delivery or write Cards.""" + cards = runtime_cards(config) + try: + card = cards[card_id] + except KeyError as exc: + raise DyroError(f"未配置 Capability:{card_id}") from exc + if card_id in config.adapters: + checks = test_adapter(config, card_id) + else: + checks = () + executable = bool(checks) and all(available for _mode, available, _exe in checks) + hook_surface = _proven_hook_surface(config, card) + return CapabilityTestReport( + id=card.id, + source=card.source, + executable=executable, + logged_in=None, + hook_surface=hook_surface, + attested_isolation=card.attested_isolation.value, + cannot_prove=card.cannot_prove, + checks=checks, + ) + + +def _proven_hook_surface(config: Config, card: CapabilityCard) -> str: + declared = card.hook_surface.strip() + if not declared: + return "" + surface = Path(declared) + if surface.is_absolute() or ".." in surface.parts or surface.parts == (".",): + return "" + if declared in {".", "dyro.toml"} or declared.startswith(".dyro"): + return "" + path = (config.root / surface).resolve() + try: + path.relative_to(config.root.resolve()) + except ValueError: + return "" + if not path.is_dir() or path == config.root.resolve(): + return "" + return declared + + +def card_payload(card: CapabilityCard) -> dict[str, object]: + return { + "id": card.id, + "kind": card.kind.value, + "source": card.source, + "preset": card.preset, + "attested_isolation": card.attested_isolation.value, + "trusted_usage": card.trusted_usage, + "can_prove": list(card.can_prove), + "cannot_prove": list(card.cannot_prove), + "intents": list(card.intents), + "hosts": list(card.hosts), + "hook_surface": card.hook_surface, + } diff --git a/src/dyro/capability/store.py b/src/dyro/capability/store.py new file mode 100644 index 0000000..1a89ff4 --- /dev/null +++ b/src/dyro/capability/store.py @@ -0,0 +1,84 @@ +"""Append Capability Cards to dyro.toml without rewriting the rest.""" + +from __future__ import annotations + +import json + +from ..config import CONFIG_NAME, Config, load, validate_id +from ..errors import DyroError, ValidationError +from ..profile import command_adapter, preset_adapter +from ..state import atomic_write_text, exclusive_lock +from .cards import card_from_adapter +from .models import CapabilityCard, CapabilityKind, Isolation + + +def card_from_preset(card_id: str, preset: str) -> CapabilityCard: + adapter = preset_adapter(card_id, preset) + card = card_from_adapter(adapter) + return CapabilityCard( + id=card.id, + kind=CapabilityKind.AGENT, + launch=card.launch, + read=card.read, + write=card.write, + preset=preset, + attested_isolation=Isolation.CWD, + trusted_usage=False, + can_prove=(), + cannot_prove=card.cannot_prove, + intents=card.intents, + hosts=card.hosts, + source="capabilities", + ) + + +def card_from_command(card_id: str, argv: list[str] | tuple[str, ...]) -> CapabilityCard: + adapter = command_adapter(card_id, argv) + card = card_from_adapter(adapter) + return CapabilityCard( + id=card.id, + kind=card.kind, + launch=card.launch, + read=card.read, + write=card.write, + attested_isolation=card.attested_isolation, + cannot_prove=card.cannot_prove, + intents=card.intents, + hosts=card.hosts, + source="capabilities", + ) + + +def append_capability(config: Config, card: CapabilityCard, *, dry_run: bool = False) -> None: + validate_id(card.id, "capability id") + if dry_run: + return + with exclusive_lock(config.root / ".dyro" / "profile.lock"): + current = load(config.root) + if card.id in current.adapters or card.id in current.capabilities: + raise DyroError(f"Capability 或 adapter 已配置:{card.id}") + if card.kind is CapabilityKind.AGENT and not (card.launch and card.read and card.write): + raise ValidationError(f"capabilities.{card.id} 的 agent 必须提供 launch/read/write") + lines = [ + "[[capabilities]]", + f"id = {json.dumps(card.id, ensure_ascii=False)}", + f"kind = {json.dumps(card.kind.value, ensure_ascii=False)}", + ] + if card.preset: + lines.append(f"preset = {json.dumps(card.preset, ensure_ascii=False)}") + lines.extend( + [ + f"launch = {json.dumps(list(card.launch), ensure_ascii=False)}", + f"read = {json.dumps(list(card.read), ensure_ascii=False)}", + f"write = {json.dumps(list(card.write), ensure_ascii=False)}", + f"attested_isolation = {json.dumps(card.attested_isolation.value, ensure_ascii=False)}", + f"trusted_usage = {'true' if card.trusted_usage else 'false'}", + f"can_prove = {json.dumps(list(card.can_prove), ensure_ascii=False)}", + f"cannot_prove = {json.dumps(list(card.cannot_prove), ensure_ascii=False)}", + f"intents = {json.dumps(list(card.intents), ensure_ascii=False)}", + f"hosts = {json.dumps(list(card.hosts), ensure_ascii=False)}", + ] + ) + config_file = current.root / CONFIG_NAME + content = config_file.read_text(encoding="utf-8").rstrip() + "\n\n" + "\n".join(lines) + "\n" + atomic_write_text(config_file, content) diff --git a/src/dyro/cli.py b/src/dyro/cli.py index 79b26a2..01f879e 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -866,6 +866,155 @@ def cmd_agent_discover(args: argparse.Namespace) -> None: print_agent_discovery(_config(args)) +def cmd_capability_list(args: argparse.Namespace) -> None: + from .capability import card_payload, discover_unintegrated, runtime_cards + + config = _config(args) + cards = [] + for card in runtime_cards(config).values(): + payload = card_payload(card) + payload["hook_surface_declared"] = payload.get("hook_surface", "") + payload["hook_proven"] = False + cards.append(payload) + discovered = [ + {"id": item.id, "command": item.command, "state": item.state} + for item in discover_unintegrated(config) + ] + if args.format == "json": + print( + json.dumps( + {"schema_version": 1, "cards": cards, "discovered_unintegrated": discovered}, + ensure_ascii=False, + sort_keys=True, + indent=2, + ) + ) + return + if not cards and not discovered: + print("暂无 Capability Card") + return + for card in cards: + print( + f"{card['id']:16} {card['kind']:10} {card['source']:14} " + f"isolation={card['attested_isolation']} cannot_prove={','.join(card['cannot_prove'])}" + ) + for item in discovered: + print(f"{item['id']:16} discovered {item['state']} command={item['command']}") + + +def cmd_capability_add(args: argparse.Namespace) -> None: + from .capability import append_capability, card_from_command, card_from_preset + + config = _config(args) + if args.preset: + card = card_from_preset(args.id, args.preset) + else: + try: + command = shlex.split(args.command) + except ValueError as exc: + raise DyroError(f"Capability command 解析失败:{exc}") from exc + card = card_from_command(args.id, command) + append_capability(config, card, dry_run=args.dry_run) + print(f"{'DRY RUN: 将添加' if args.dry_run else '已添加'} Capability Card:{card.id}") + + +def cmd_capability_test(args: argparse.Namespace) -> None: + from .capability import test_capability + + report = test_capability(_config(args), args.id) + if args.format == "json": + print( + json.dumps( + { + "id": report.id, + "source": report.source, + "executable": report.executable, + "logged_in": report.logged_in, + "hook_surface": report.hook_surface, + "attested_isolation": report.attested_isolation, + "cannot_prove": list(report.cannot_prove), + "checks": [ + {"mode": mode, "available": available, "executable": executable} + for mode, available, executable in report.checks + ], + }, + ensure_ascii=False, + sort_keys=True, + indent=2, + ) + ) + else: + print(f"{report.id} source={report.source} executable={report.executable}") + print(f"isolation={report.attested_isolation} hook_surface={report.hook_surface or '-'}") + for mode, available, executable in report.checks: + print(f"{'PASS' if available else 'FAIL'} {report.id}.{mode}: {executable}") + if not report.executable: + raise DyroError(f"Capability 不可用:{report.id}") + + +def _host_projection_payload(item) -> dict[str, object]: + return { + "authority_projection": item.authority_projection, + "hook_installed_on_surface": False, + "hook_note": "deny hook 写在投影树 SKILL.md 旁,未安装到 hook_surface,不是宿主拦截", + "hook_relpath": item.hook_relpath, + "hook_sha256": item.hook_sha256, + "host": item.host, + "input_sha256": item.input_sha256, + "manifest_relpath": item.manifest_relpath, + "scope": item.scope, + "skill_relpath": item.skill_relpath, + "skill_sha256": item.skill_sha256, + } + + +def cmd_host_compile(args: argparse.Namespace) -> None: + from .host import compile_hosts + + projections = compile_hosts(_config(args), user=args.user, dry_run=args.dry_run) + prefix = "DRY RUN: " if args.dry_run else "" + if args.format == "json": + print( + json.dumps( + { + "dry_run": args.dry_run, + "projections": [_host_projection_payload(item) for item in projections], + "schema_version": 1, + "scope": "user" if args.user else "workspace", + }, + ensure_ascii=False, + sort_keys=True, + indent=2, + ) + ) + return + print(f"{prefix}已编译宿主投影 scope={'user' if args.user else 'workspace'}") + for item in projections: + print(f"{item.host} {item.authority_projection}") + + +def cmd_host_status(args: argparse.Namespace) -> None: + from .host import doctor_payload, inspect_projections, render_doctor_text + + report = inspect_projections(_config(args), user=args.user) + if args.format == "json": + print(json.dumps(doctor_payload(report), ensure_ascii=False, sort_keys=True, indent=2)) + return + print(render_doctor_text(report), end="") + + +def cmd_host_doctor(args: argparse.Namespace) -> None: + from .host import doctor_payload, inspect_projections, render_doctor_text + + report = inspect_projections(_config(args), user=args.user) + if args.format == "json": + print(json.dumps(doctor_payload(report), ensure_ascii=False, sort_keys=True, indent=2)) + else: + print(render_doctor_text(report), end="") + if not report.ok: + raise DyroError("宿主投影过期或被手改") + + def cmd_tool_list(args: argparse.Namespace) -> None: resolved = resolve_home_config( root=getattr(args, "root", None), @@ -1731,6 +1880,87 @@ def cmd_task_merge(args: argparse.Namespace) -> None: print(f"{'DRY RUN: ' if args.dry_run else ''}已合并 {task.id}" + (" 并推送" if args.push else "")) +def _proofs_from_args(args: argparse.Namespace, *, proof_id: str | None = None): + from .proof import list_proofs + + config = _config(args) + proofs = list_proofs( + config, + task_id=getattr(args, "task", None), + objective_id=getattr(args, "objective", None), + line_id=getattr(args, "line", None), + ) + if proof_id: + matched = tuple(proof for proof in proofs if proof.id == proof_id) + if not matched: + raise DyroError(f"Proof 不存在:{proof_id}") + return matched + return proofs + + +def _print_proofs(args: argparse.Namespace, proofs, *, mode: str = "") -> None: + from .proof import render_proofs_json, render_proofs_text + + if getattr(args, "format", "text") == "json": + print(render_proofs_json(proofs, mode=mode or "rebind")) + return + print(render_proofs_text(proofs, mode=mode), end="") + + +def cmd_proof_list(args: argparse.Namespace) -> None: + _print_proofs(args, _proofs_from_args(args)) + + +def cmd_proof_show(args: argparse.Namespace) -> None: + _print_proofs(args, _proofs_from_args(args, proof_id=args.proof_id)) + + +def cmd_proof_verify(args: argparse.Namespace) -> None: + from .proof import verify_exit_code + + if getattr(args, "rerun_procedure", False): + raise DyroError( + "--rerun-procedure 必须在隔离 runner 中重放;0.7 未提供隔离重跑," + "未 replay 不得声称 procedure_reproduced" + ) + proofs = _proofs_from_args(args, proof_id=getattr(args, "proof_id", None)) + _print_proofs(args, proofs, mode="rebind") + code = verify_exit_code(proofs) + if code: + raise SystemExit(code) + + +def cmd_proof_export(args: argparse.Namespace) -> None: + from pathlib import Path + + from .proof import export_bundle + + if bool(args.proof_id) == bool(args.task): + raise ValidationError("proof export 的位置参数 proof-id 与 --task 互斥,且必须提供其一") + proofs = _proofs_from_args(args, proof_id=args.proof_id) + if not proofs: + raise DyroError("没有可导出的 Proof") + path = export_bundle(proofs, Path(args.bundle)) + print(f"已导出 {len(proofs)} 条 Proof 到 {path}") + + +def cmd_proof_verify_bundle(args: argparse.Namespace) -> None: + from pathlib import Path + + from .proof import load_current_heads, verify_bundle, verify_exit_code + from .proof.bundle import INTEGRITY_MODE + + if not args.bundle: + raise DyroError("verify-bundle 需要 Proof Bundle 路径") + heads = load_current_heads(Path(args.current_heads)) if args.current_heads else None + git_dirs = tuple(Path(item) for item in (args.git_dir or ())) + proofs = verify_bundle(Path(args.bundle), git_dirs=git_dirs, current_heads=heads) + _print_proofs(args, proofs, mode=INTEGRITY_MODE) + code = verify_exit_code(proofs) + if code: + raise SystemExit(code) + + def cmd_task_decisions(args: argparse.Namespace) -> None: items = decisions(_config(args)) if not items: @@ -2237,12 +2467,68 @@ def build_parser() -> argparse.ArgumentParser: agent_sub.add_parser("discover", help="检测本机 Agent,并区分已配置与尚未集成").set_defaults( func=cmd_agent_discover ) - agent_add = agent_sub.add_parser("add", help="通过预设或命令登记 Agent,无需编辑 TOML") + agent_add = agent_sub.add_parser( + "add", + help="写入 [adapters.*];运行时升级为 Card,不写 [[capabilities]]", + ) agent_add.add_argument("id") agent_source = agent_add.add_mutually_exclusive_group(required=True) agent_source.add_argument("--preset", choices=("codex", "noop")) agent_source.add_argument("--command", help="作为 launch/read/write 的 argv 命令行;不会经 shell 执行") agent_add.set_defaults(func=cmd_agent_add) + capability = sub.add_parser("capability", help="审计后的 Capability Card;PATH 发现不能执行") + capability_sub = capability.add_subparsers(dest="capability_command", required=True) + capability_list = capability_sub.add_parser("list", help="列出已审计 Card 与 discovered_unintegrated") + capability_list.add_argument("--format", choices=("text", "json"), default="text") + capability_list.set_defaults(func=cmd_capability_list) + capability_add = capability_sub.add_parser("add", help="写入 [[capabilities]],不写 PATH 发现") + capability_add.add_argument("id") + capability_source = capability_add.add_mutually_exclusive_group(required=True) + capability_source.add_argument("--preset", choices=("codex", "noop")) + capability_source.add_argument("--command", help="作为 launch/read/write 的 argv;不会经 shell 执行") + capability_add.set_defaults(func=cmd_capability_add) + capability_test = capability_sub.add_parser("test", help="探测可执行/登录,不启动交付") + capability_test.add_argument("id") + capability_test.add_argument("--format", choices=("text", "json"), default="text") + capability_test.set_defaults(func=cmd_capability_test) + + host = sub.add_parser( + "host", + help="编译并核验宿主投影(skill;deny hook 不是沙箱,只挡受监督 apply)", + ) + host_sub = host.add_subparsers(dest="host_command", required=True) + host_compile = host_sub.add_parser( + "compile", + help="把定律与已审计 Card 编译为工作区 skill;deny hook 不是沙箱。--user 才写用户级", + ) + host_compile.add_argument( + "--user", + action="store_true", + help="写入用户级 host-projections;默认只写当前工作区", + ) + host_compile.add_argument("--dry-run", action="store_true") + host_compile.add_argument("--format", choices=("text", "json"), default="text") + host_compile.set_defaults(func=cmd_host_compile) + host_status = host_sub.add_parser("status", help="查看已编译投影是否仍与当前 Card 一致") + host_status.add_argument( + "--user", + action="store_true", + help="核验用户级投影", + ) + host_status.add_argument("--format", choices=("text", "json"), default="text") + host_status.set_defaults(func=cmd_host_status) + host_doctor = host_sub.add_parser( + "doctor", + help="重算投影哈希;手改或过期则失败。只挡受监督 apply,不管 task run / merge。deny hook 不是隔离边界", + ) + host_doctor.add_argument( + "--user", + action="store_true", + help="核验用户级投影", + ) + host_doctor.add_argument("--format", choices=("text", "json"), default="text") + host_doctor.set_defaults(func=cmd_host_doctor) + agent_test = agent_sub.add_parser("test", help="仅检查 adapter 可执行文件是否可用,不启动 Agent") agent_test.add_argument("id") agent_test.set_defaults(func=cmd_agent_test) @@ -2525,6 +2811,53 @@ def build_parser() -> argparse.ArgumentParser: trigger_signal.add_argument("--format", choices=("text", "json"), default="text") trigger_signal.set_defaults(func=cmd_trigger_signal) + proof = sub.add_parser("proof", help="只读派生并核验交付 Proof(rebind,不是 replay)") + proof_sub = proof.add_subparsers(dest="proof_command", required=True) + proof_list = proof_sub.add_parser("list", help="从当前工作区全量重派生 Proof") + proof_list.add_argument("--task") + proof_list.add_argument("--objective") + proof_list.add_argument("--line") + proof_list.add_argument("--format", choices=("text", "json"), default="text") + proof_list.set_defaults(func=cmd_proof_list) + proof_show = proof_sub.add_parser("show", help="显示一条重派生的 Proof") + proof_show.add_argument("proof_id") + proof_show.add_argument("--format", choices=("text", "json"), default="text") + proof_show.set_defaults(func=cmd_proof_show) + proof_verify = proof_sub.add_parser("verify", help="对当前工作区做衰减与绑定重算,不重跑 gate") + proof_verify.add_argument("proof_id", nargs="?") + proof_verify.add_argument("--task") + proof_verify.add_argument("--objective") + proof_verify.add_argument("--line") + proof_verify.add_argument("--format", choices=("text", "json"), default="text") + proof_verify.add_argument( + "--rerun-procedure", + action="store_true", + help="0.7 拒绝:隔离 replay 尚未提供", + ) + proof_verify.set_defaults(func=cmd_proof_verify) + proof_export = proof_sub.add_parser("export", help="导出 Proof Bundle(schema_version=1,不含 git 对象)") + proof_export.add_argument("proof_id", nargs="?") + proof_export.add_argument("--task") + proof_export.add_argument("--bundle", required=True, help="输出 .zip 路径") + proof_export.set_defaults(func=cmd_proof_export) + proof_verify_bundle = proof_sub.add_parser( + "verify-bundle", + help="核验 bundle 完整性;需要调用方 --git-dir,不是当前工作区 verify,也不是身份证明,不是 merge", + ) + proof_verify_bundle.add_argument("bundle") + proof_verify_bundle.add_argument( + "--git-dir", + action="append", + default=[], + help="调用方必须传入包含已钉 SHA 的对象库;可重复,按并集查找,不是 repo_id 映射。缺省或无 pin 不得报 live", + ) + proof_verify_bundle.add_argument( + "--current-heads", + help="可选 JSON:提供后才允许衰减结论,不得与 merge 混称", + ) + proof_verify_bundle.add_argument("--format", choices=("text", "json"), default="text") + proof_verify_bundle.set_defaults(func=cmd_proof_verify_bundle) + task = sub.add_parser("task", help="任务编排") task_sub = task.add_subparsers(dest="task_command", required=True) task_graph = task_sub.add_parser("graph", help="编译、校验或渲染任务图") diff --git a/src/dyro/config.py b/src/dyro/config.py index 5086d98..4e4e798 100644 --- a/src/dyro/config.py +++ b/src/dyro/config.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path import re import tomllib @@ -71,6 +71,7 @@ class Config: adapters: dict[str, Adapter] policy: Policy recommended_tool: str = "" + capabilities: dict[str, object] = field(default_factory=dict) @property def task_specs_dir(self) -> Path: @@ -250,6 +251,10 @@ def load(root: Path | None = None) -> Config: write = _argv(entry.get("write", entry.get("command")), f"adapters.{adapter_id}.write") launch = _argv(entry.get("launch", entry.get("command", entry.get("write"))), f"adapters.{adapter_id}.launch") adapters[adapter_id] = Adapter(adapter_id, launch, read, write) + from .capability.cards import merge_capability_plane, parse_capability_tables + + cards = parse_capability_tables(raw.get("capabilities")) + adapters, cards = merge_capability_plane(adapters, cards) return Config( workspace, name, @@ -258,6 +263,7 @@ def load(root: Path | None = None) -> Config: adapters, policy, recommended_tool, + cards, ) diff --git a/src/dyro/console/read_model.py b/src/dyro/console/read_model.py index 0c90b01..9b43850 100644 --- a/src/dyro/console/read_model.py +++ b/src/dyro/console/read_model.py @@ -36,6 +36,11 @@ def _data(snapshot: WorkspaceReadSnapshot) -> dict[str, object]: "name": safe_title(snapshot.workspace_name), "workspace_revision": safe_sha256(snapshot.workspace_revision), "completeness": snapshot.completeness if snapshot.completeness in {"complete", "partial", "unavailable"} else "unavailable", + "proof_inspection": ( + snapshot.proof_inspection + if snapshot.proof_inspection in {"not_inspected", "inspected"} + else "not_inspected" + ), }, "task_status_counts": dict(sorted(status_counts.items())), "lines": [ diff --git a/src/dyro/continuation/attention.py b/src/dyro/continuation/attention.py index c09ce36..8df5ffd 100644 --- a/src/dyro/continuation/attention.py +++ b/src/dyro/continuation/attention.py @@ -311,6 +311,7 @@ def _kind_for_blocked(action: PlannedAction) -> AttentionKind: ReasonCode.OBJECTIVE_SCOPE_CONFLICT, ReasonCode.ACTIVATION_REQUIRED, ReasonCode.POLICY_DISALLOWS_OPERATION, + ReasonCode.PROOF_DECAYED, }: return AttentionKind.NEEDS_USER if action.reason in { diff --git a/src/dyro/continuation/models.py b/src/dyro/continuation/models.py index e8c8ab6..540f628 100644 --- a/src/dyro/continuation/models.py +++ b/src/dyro/continuation/models.py @@ -93,6 +93,7 @@ class ReasonCode(str, Enum): OBJECTIVE_PAUSED = "OBJECTIVE_PAUSED" ACTIVATION_REQUIRED = "ACTIVATION_REQUIRED" POLICY_DISALLOWS_OPERATION = "POLICY_DISALLOWS_OPERATION" + PROOF_DECAYED = "PROOF_DECAYED" class TriggerState(str, Enum): diff --git a/src/dyro/continuation/planner.py b/src/dyro/continuation/planner.py index 4b392c0..0ba08e3 100644 --- a/src/dyro/continuation/planner.py +++ b/src/dyro/continuation/planner.py @@ -269,6 +269,17 @@ def build_continuation_plan(snapshot: SchedulerSnapshot) -> ContinuationPlan: ) return _build_plan(snapshot, PlanCompletion.INCOMPLETE, (action,), attention=(attention,)) by_id = snapshot.tasks_by_id + decayed_attention = tuple( + AttentionItem( + id=f"proof-decayed:{task_id}", + kind=AttentionKind.NEEDS_USER, + subject_id=task_id, + reason=ReasonCode.PROOF_DECAYED, + facts=_facts(status="decayed"), + ) + for task_id in snapshot.decayed_merge_subjects + if task_id in snapshot.objective_scope or task_id in snapshot.objective_targets + ) target_complete = all( target in by_id and by_id[target].status == "done" @@ -277,12 +288,12 @@ def build_continuation_plan(snapshot: SchedulerSnapshot) -> ContinuationPlan: ) if target_complete: action = _action(ActionKind.COMPLETE, snapshot.objective_id, ReasonCode.TARGETS_INTEGRATED) - return _build_plan(snapshot, PlanCompletion.COMPLETE, (action,)) + return _build_plan(snapshot, PlanCompletion.COMPLETE, (action,), attention=decayed_attention) scope = tuple(sorted(set(snapshot.objective_scope) & set(snapshot.candidate_ids))) readiness = build_task_readiness(snapshot, candidate_ids=scope) selected: list[PlannedAction] = [] blocked = list(readiness.blocked) - attention: list[AttentionItem] = [] + attention: list[AttentionItem] = list(decayed_attention) execute_allowed = ( snapshot.objective_requested_mode != "observe" and "execute" in snapshot.objective_operations diff --git a/src/dyro/continuation/snapshot.py b/src/dyro/continuation/snapshot.py index 40d6c04..0fbd6fa 100644 --- a/src/dyro/continuation/snapshot.py +++ b/src/dyro/continuation/snapshot.py @@ -67,6 +67,7 @@ class SchedulerSnapshot: objective_requested_mode: str = "" objective_operations: tuple[str, ...] = () objective_drifted: bool = False + decayed_merge_subjects: tuple[str, ...] = () @property def tasks_by_id(self) -> dict[str, SchedulerTaskSnapshot]: @@ -192,6 +193,7 @@ def build_scheduler_snapshot( objective: StoredObjective | None = None, candidates: Iterable[Task] | None = None, inspect_integration: bool = True, + inspect_proofs: bool | None = None, clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), ) -> SchedulerSnapshot: """Read the task graph exactly once and publish canonical, immutable facts.""" @@ -200,6 +202,8 @@ def build_scheduler_snapshot( if issues: details = "; ".join(issue.message for issue in issues[:5]) raise ValidationError(f"任务图结构无效:{details}") + if inspect_proofs is None: + inspect_proofs = inspect_integration observed_at = _utc(clock()) known_tasks = tuple(sorted(graph.known_tasks, key=lambda item: item.id)) known_by_id = {task.id: task for task in known_tasks} @@ -272,6 +276,13 @@ def build_scheduler_snapshot( ) ) ).hexdigest() + decayed_subjects: tuple[str, ...] = () + if inspect_proofs: + done_tasks = tuple(item.task for item in tasks if item.status == "done") + if done_tasks: + from ..proof.evaluate import decayed_merge_subjects + + decayed_subjects = decayed_merge_subjects(config, done_tasks) return SchedulerSnapshot( observed_at=observed_at, tasks=tasks, @@ -287,4 +298,5 @@ def build_scheduler_snapshot( objective_requested_mode="" if objective is None else objective.objective.requested_mode.value, objective_operations=() if objective is None else tuple(item.value for item in objective.objective.operations), objective_drifted=objective_drifted, + decayed_merge_subjects=decayed_subjects, ) diff --git a/src/dyro/continuation/supervision.py b/src/dyro/continuation/supervision.py index 4ec9a0d..74996b7 100644 --- a/src/dyro/continuation/supervision.py +++ b/src/dyro/continuation/supervision.py @@ -387,6 +387,10 @@ def apply_supervised_wave( ): raise DyroError("确认后的 wave 已发生语义变化;请重新运行 objective apply --dry-run") + from ..host.doctor import assert_projections_allow_mutation + + assert_projections_allow_mutation(config) + acquired_at = _utc(clock()) grant = acquire_objective_owner_lease( config, diff --git a/src/dyro/graph.py b/src/dyro/graph.py index cd86120..fd3dda7 100644 --- a/src/dyro/graph.py +++ b/src/dyro/graph.py @@ -5,7 +5,14 @@ from .config import Config from .errors import DyroError -from .tasks import Task, decisions, external_claim_active, list_tasks, status +from .tasks import ( + Task, + _assert_dependency_integrated, + decisions, + external_claim_active, + list_tasks, + status, +) @dataclass(frozen=True) @@ -184,6 +191,11 @@ def _explain_with_config(config: Config, graph: TaskGraph, task: Task) -> dict[s dependencies.append({"id": dependency, "status": dependency_status}) if dependency_status != "done": reasons.append(f"依赖 {dependency} 尚未完成,当前状态为 {dependency_status}") + elif dependency_task is not None: + try: + _assert_dependency_integrated(config, dependency_task) + except DyroError as exc: + reasons.append(str(exc)) for decision_id in task.blocked_on: decision_status = graph.decisions.get(decision_id, "missing") diff --git a/src/dyro/host/__init__.py b/src/dyro/host/__init__.py new file mode 100644 index 0000000..4f3718e --- /dev/null +++ b/src/dyro/host/__init__.py @@ -0,0 +1,43 @@ +"""Host compiler: project law into a skill. Hooks are optional, not isolation.""" + +from .compile import ( + collect_compiler_input, + compile_hosts, + hosts_to_compile, + projection_root, + render_deny_hook, + render_skill, +) +from .doctor import ( + assert_projections_allow_mutation, + doctor_payload, + inspect_projections, + render_doctor_text, +) +from .models import ( + AUTHORITY_SKILL_AND_HOOK, + AUTHORITY_SKILL_ONLY, + HostDoctorReport, + HostFinding, + HostManifest, + HostProjection, +) + +__all__ = ( + "AUTHORITY_SKILL_AND_HOOK", + "AUTHORITY_SKILL_ONLY", + "HostDoctorReport", + "HostFinding", + "HostManifest", + "HostProjection", + "assert_projections_allow_mutation", + "collect_compiler_input", + "compile_hosts", + "doctor_payload", + "hosts_to_compile", + "inspect_projections", + "projection_root", + "render_deny_hook", + "render_doctor_text", + "render_skill", +) diff --git a/src/dyro/host/compile.py b/src/dyro/host/compile.py new file mode 100644 index 0000000..8c172a3 --- /dev/null +++ b/src/dyro/host/compile.py @@ -0,0 +1,382 @@ +"""Compile audited Cards into a host skill. Paths come from probe, not vendors.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +from pathlib import Path + +from ..canonical import canonical_json_bytes, canonical_json_text +from ..capability.models import CapabilityCard, CapabilityTestReport, DiscoveredTool, Intent +from ..capability.probe import ( + card_payload, + discover_unintegrated, + runtime_cards, + test_capability, +) +from ..config import Config, validate_id +from ..errors import DyroError, ValidationError +from ..hub import registry_home +from ..state import atomic_write_text, exclusive_lock +from .models import ( + AUTHORITY_SKILL_AND_HOOK, + AUTHORITY_SKILL_ONLY, + DEFAULT_HOST, + HOOK_NAME, + HOOK_SIDECAR_NOTE, + SCHEMA_VERSION, + SCOPE_USER, + SCOPE_WORKSPACE, + SKILL_NAME, + HostManifest, + HostProjection, +) + + +WORKSPACE_PROJECTIONS = Path(".dyro") / "host-projections" +DENIED_INTENTS = (Intent.INTEGRATE.value, Intent.PUBLISH.value) +DENIED_PATHS = (".dyro/",) +DESCRIPTION_NEGATIVES = ( + "不要用 git merge 结束任务。", + "不要把测试通过写成 done。", +) +_FORBIDDEN_SKILL_MARKERS = ( + "dyro task", + "execute_task", + "task run", + "/usr/", + "/users/", + "/home/", + "/tmp/", + "~/", + "https://", + "http://", + "git@", +) + + +@dataclass(frozen=True) +class CompilerInput: + cards: tuple[CapabilityCard, ...] + reports: tuple[CapabilityTestReport, ...] + discovered: tuple[DiscoveredTool, ...] + payload: dict[str, object] + digest: str + + +def projection_root(config: Config, *, user: bool) -> Path: + if user: + return registry_home() / "host-projections" / validate_id(config.name, "workspace name") + return config.root / WORKSPACE_PROJECTIONS + + +def projection_scope(*, user: bool) -> str: + return SCOPE_USER if user else SCOPE_WORKSPACE + + +def sha256_text(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def collect_compiler_input(config: Config) -> CompilerInput: + cards = tuple(sorted(runtime_cards(config).values(), key=lambda card: card.id)) + reports = tuple(test_capability(config, card.id) for card in cards) + discovered = discover_unintegrated(config) + payload = { + "cards": [card_payload(card) for card in cards], + "discovered": [item.id for item in discovered], + "tests": [ + { + "executable": report.executable, + "hook_surface": report.hook_surface, + "id": report.id, + } + for report in reports + ], + } + return CompilerInput( + cards=cards, + reports=reports, + discovered=discovered, + payload=payload, + digest=hashlib.sha256(canonical_json_bytes(payload)).hexdigest(), + ) + + +def hosts_to_compile(source: CompilerInput | Config) -> tuple[str, ...]: + cards = source.cards if isinstance(source, CompilerInput) else runtime_cards(source).values() + hosts = {validate_id(host, "host") for card in cards for host in card.hosts} + return tuple(sorted(hosts)) or (DEFAULT_HOST,) + + +def compile_hosts( + config: Config, + *, + user: bool = False, + dry_run: bool = False, +) -> tuple[HostProjection, ...]: + """Render one skill per host. Deny hooks are optional and never a sandbox.""" + source = collect_compiler_input(config) + scope = projection_scope(user=user) + root = projection_root(config, user=user) + planned = tuple( + _plan_host(host, source, scope=scope) + for host in hosts_to_compile(source) + ) + if dry_run: + return planned + lock = (registry_home() / "host.lock") if user else (config.root / ".dyro" / "host.lock") + with exclusive_lock(lock): + written = tuple( + _write_host(root, projection) for projection in planned + ) + _remove_stale_hosts(root, {item.host for item in written}) + return written + + +def render_manifest(manifest: HostManifest) -> str: + return ( + f"schema_version = {manifest.schema_version}\n" + f"host = {json.dumps(manifest.host, ensure_ascii=False)}\n" + f"scope = {json.dumps(manifest.scope, ensure_ascii=False)}\n" + f"authority_projection = {json.dumps(manifest.authority_projection, ensure_ascii=False)}\n" + f"skill_sha256 = {json.dumps(manifest.skill_sha256, ensure_ascii=False)}\n" + f"hook_sha256 = {json.dumps(manifest.hook_sha256, ensure_ascii=False)}\n" + f"input_sha256 = {json.dumps(manifest.input_sha256, ensure_ascii=False)}\n" + ) + + +def parse_manifest(path: Path) -> HostManifest: + import tomllib + + try: + raw = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise ValidationError(f"宿主投影清单无法读取:{path.name}") from exc + if not isinstance(raw, dict): + raise ValidationError(f"宿主投影清单必须是表:{path.name}") + try: + schema_version = int(raw["schema_version"]) + host = validate_id(str(raw["host"]), "host") + scope = str(raw["scope"]) + authority = str(raw["authority_projection"]) + skill_sha256 = str(raw["skill_sha256"]) + hook_sha256 = str(raw.get("hook_sha256", "") or "") + input_sha256 = str(raw["input_sha256"]) + except (KeyError, TypeError, ValueError, ValidationError) as exc: + raise ValidationError(f"宿主投影清单字段无效:{path.name}") from exc + if schema_version != SCHEMA_VERSION: + raise ValidationError(f"宿主投影清单 schema_version 不受支持:{path.name}") + if scope not in {SCOPE_WORKSPACE, SCOPE_USER}: + raise ValidationError(f"宿主投影清单 scope 无效:{path.name}") + if authority not in {AUTHORITY_SKILL_ONLY, AUTHORITY_SKILL_AND_HOOK}: + raise ValidationError(f"宿主投影清单 authority_projection 无效:{path.name}") + return HostManifest( + schema_version=schema_version, + host=host, + scope=scope, + authority_projection=authority, + skill_sha256=skill_sha256, + hook_sha256=hook_sha256, + input_sha256=input_sha256, + ) + + +def _plan_host(host: str, source: CompilerInput, *, scope: str) -> HostProjection: + reports = {report.id: report for report in source.reports} + available = tuple( + card + for card in source.cards + if host in card.hosts + and Intent.EXECUTE.value in card.intents + and reports[card.id].executable + ) + hook_surface = next( + ( + reports[card.id].hook_surface + for card in source.cards + if host in card.hosts and reports[card.id].hook_surface + ), + "", + ) + skill_text = render_skill( + host, + available=available, + discovered=source.discovered, + ) + _assert_skill_contracts(skill_text, available=available) + hook_text = render_deny_hook(source.cards, host=host) if hook_surface else "" + authority = AUTHORITY_SKILL_AND_HOOK if hook_text else AUTHORITY_SKILL_ONLY + skill_sha256 = sha256_text(skill_text) + hook_sha256 = sha256_text(hook_text) if hook_text else "" + return HostProjection( + host=host, + scope=scope, + authority_projection=authority, + skill_text=skill_text, + hook_text=hook_text, + skill_sha256=skill_sha256, + hook_sha256=hook_sha256, + input_sha256=source.digest, + skill_relpath=f"{host}/{SKILL_NAME}", + hook_relpath=f"{host}/{HOOK_NAME}" if hook_text else "", + manifest_relpath=f"{host}.toml", + ) + + +def render_skill( + host: str, + *, + available: tuple[CapabilityCard, ...], + discovered: tuple[DiscoveredTool, ...], +) -> str: + description = ( + "只观察 Dyro 交付状态并打印已批准的下一步。" + + "".join(DESCRIPTION_NEGATIVES) + ) + lines = [ + "---", + "name: dyro-delivery", + "description: >", + f" {description}", + "---", + "", + "# Dyro 宿主投影", + "", + f"投影面:{host}", + "", + "## 定律", + "", + "1. 外部真源:git 对象与 Objective 状态是交付真源;宿主对话不是。", + "2. 衰减:合并与下游绑定以当前对象库为准;过期证据不能当活证据。", + "3. 单写者:同一 Objective 同时只有一个 mutation 写者。", + "4. 编译权威:宿主只能观察,并打印已批准的 `dyro next`。", + "", + "## 本机可用能力", + "", + ] + if available: + lines.extend( + [ + "| id | attested | cannot_prove |", + "| --- | --- | --- |", + ] + ) + for card in available: + lines.append( + f"| {_skill_cell(card.id)} | {_skill_cell(card.attested_isolation.value)} | {_skill_cell(', '.join(card.cannot_prove))} |" + ) + else: + lines.append("本机没有已审计且可探测的 Capability Card。不要执行任务。") + lines.extend(["", "## 已发现未集成", ""]) + if discovered: + lines.append("这些不是已审计 Card,不能当作执行器。") + lines.append("") + for item in discovered: + lines.append(f"- {item.id}") + else: + lines.append("无。") + lines.extend( + [ + "", + "## 允许的命令", + "", + "只打印:", + "", + "`dyro next`", + "", + "## 禁止", + "", + f"- {DESCRIPTION_NEGATIVES[0]}", + f"- {DESCRIPTION_NEGATIVES[1]}", + "- 不要写入 `.dyro/`。", + "- 不要 integrate 或 publish。", + f"- {HOOK_SIDECAR_NOTE}。", + "", + ] + ) + return "\n".join(lines) + + +def render_deny_hook(cards: tuple[CapabilityCard, ...], *, host: str) -> str: + authorized = { + intent + for card in cards + if host in card.hosts + for intent in card.intents + } + denied = [intent for intent in DENIED_INTENTS if intent not in authorized] + payload = { + "denied_intents": denied, + "denied_paths": list(DENIED_PATHS), + "kind": "dyro.host.deny_hook", + "schema_version": SCHEMA_VERSION, + } + return canonical_json_text(payload) + "\n" + + +def _skill_cell(value: str) -> str: + cleaned = " ".join(str(value).split()).replace("|", "/").replace("`", "'") + lowered = cleaned.lower() + for marker in _FORBIDDEN_SKILL_MARKERS: + if marker in lowered: + raise DyroError("Capability Card 字段不能写入未批准命令") + return cleaned + + +def _assert_skill_contracts( + skill_text: str, + *, + available: tuple[CapabilityCard, ...], +) -> None: + lowered = skill_text.lower() + for marker in DESCRIPTION_NEGATIVES: + if marker not in skill_text: + raise DyroError("宿主 skill 的 description 必须包含负例") + if "`dyro next`" not in skill_text: + raise DyroError("宿主 skill 只能批准 dyro next") + for marker in _FORBIDDEN_SKILL_MARKERS: + if marker in lowered: + raise DyroError("宿主投影不能包含绝对路径、remote 或未批准命令") + if not available and "不要执行" not in skill_text: + raise DyroError("无可用 Card 时宿主 skill 不能暗示执行") + + +def _write_host(root: Path, projection: HostProjection) -> HostProjection: + skill_path = root / projection.skill_relpath + atomic_write_text(skill_path, projection.skill_text) + hook_path = root / projection.host / HOOK_NAME + if projection.hook_text: + atomic_write_text(hook_path, projection.hook_text) + elif hook_path.is_file(): + hook_path.unlink() + manifest = HostManifest( + schema_version=SCHEMA_VERSION, + host=projection.host, + scope=projection.scope, + authority_projection=projection.authority_projection, + skill_sha256=projection.skill_sha256, + hook_sha256=projection.hook_sha256, + input_sha256=projection.input_sha256, + ) + atomic_write_text(root / projection.manifest_relpath, render_manifest(manifest)) + return projection + + +def _remove_stale_hosts(root: Path, keep: set[str]) -> None: + if not root.is_dir(): + return + for manifest_path in root.glob("*.toml"): + host = manifest_path.stem + if host in keep: + continue + manifest_path.unlink(missing_ok=True) + host_dir = root / host + for name in (SKILL_NAME, HOOK_NAME): + (host_dir / name).unlink(missing_ok=True) + if host_dir.is_dir(): + try: + host_dir.rmdir() + except OSError: + pass diff --git a/src/dyro/host/doctor.py b/src/dyro/host/doctor.py new file mode 100644 index 0000000..1ebadbb --- /dev/null +++ b/src/dyro/host/doctor.py @@ -0,0 +1,203 @@ +"""Recompute host projection hashes. Stale compiled output is fail-closed.""" + +from __future__ import annotations + +from pathlib import Path + +from ..config import Config +from ..errors import DyroError, ValidationError +from .compile import ( + collect_compiler_input, + hosts_to_compile, + parse_manifest, + projection_root, + projection_scope, + sha256_text, +) +from .models import ( + AUTHORITY_SKILL_AND_HOOK, + FINDING_EXPIRED, + FINDING_FRESH, + FINDING_INVALID, + FINDING_MISSING_HOOK, + FINDING_TAMPERED, + FINDING_UNEXPECTED_HOOK, + HOOK_NAME, + HOOK_SIDECAR_NOTE, + SCHEMA_VERSION, + SKILL_NAME, + HostDoctorReport, + HostFinding, + HostManifest, +) + + +def inspect_projections(config: Config, *, user: bool = False) -> HostDoctorReport: + """Hash current files and Cards. Missing manifests do not fail.""" + scope = projection_scope(user=user) + root = projection_root(config, user=user) + source = collect_compiler_input(config) + live_hosts = set(hosts_to_compile(source)) + manifests = _load_manifests(root) + findings: list[HostFinding] = [] + if manifests: + extra = sorted(set(manifests) - live_hosts) + missing = sorted(live_hosts - set(manifests)) + if extra or missing: + findings.append( + HostFinding( + host="*", + scope=scope, + ok=False, + code=FINDING_EXPIRED, + authority_projection="", + message="宿主集合与当前 Card 不一致;请重新 host compile", + ) + ) + for host, loaded in manifests.items(): + findings.append(_check_manifest(root, loaded, source.digest, scope=scope)) + orphans = _orphan_hosts(root, set(manifests)) + for host in orphans: + findings.append( + HostFinding( + host=host, + scope=scope, + ok=False, + code=FINDING_TAMPERED, + authority_projection="", + message="投影产物缺少有效 manifest;请重新 host compile", + ) + ) + compiled = bool(manifests) or bool(orphans) + ok = all(item.ok for item in findings) + return HostDoctorReport( + schema_version=SCHEMA_VERSION, + scope=scope, + compiled=compiled, + ok=ok, + input_sha256=source.digest, + findings=tuple(findings), + ) + + +def assert_projections_allow_mutation(config: Config) -> None: + """Block the next mutation tick only when a compiled projection is stale.""" + report = inspect_projections(config, user=False) + if not report.compiled: + return + if not report.ok: + raise DyroError( + "宿主投影过期或被手改;本次 mutation 已降为 plan-only。请运行 dyro host compile" + ) + + +def doctor_payload(report: HostDoctorReport) -> dict[str, object]: + return { + "compiled": report.compiled, + "hook_enforcement": "projection_sidecar", + "hook_note": HOOK_SIDECAR_NOTE, + "findings": [ + { + "authority_projection": item.authority_projection, + "code": item.code, + "host": item.host, + "message": item.message, + "ok": item.ok, + "scope": item.scope, + } + for item in report.findings + ], + "input_sha256": report.input_sha256, + "ok": report.ok, + "schema_version": report.schema_version, + "scope": report.scope, + } + + +def render_doctor_text(report: HostDoctorReport) -> str: + if not report.compiled: + return f"未编译宿主投影 scope={report.scope}\n" + lines = [f"{'PASS' if report.ok else 'FAIL'} scope={report.scope}"] + for item in report.findings: + mark = "PASS" if item.ok else "FAIL" + lines.append( + f"{mark} {item.host} {item.code} {item.authority_projection or '-'} {item.message}" + ) + return "\n".join(lines) + "\n" + + +def _orphan_hosts(root: Path, known: set[str]) -> tuple[str, ...]: + if not root.is_dir(): + return () + found: set[str] = set() + for path in root.iterdir(): + if not path.is_dir() or path.name in known: + continue + if (path / SKILL_NAME).is_file() or (path / HOOK_NAME).is_file(): + found.add(path.name) + return tuple(sorted(found)) + + +def _load_manifests(root: Path) -> dict[str, HostManifest | ValidationError]: + if not root.is_dir(): + return {} + loaded: dict[str, HostManifest | ValidationError] = {} + for path in sorted(root.glob("*.toml")): + try: + loaded[path.stem] = parse_manifest(path) + except ValidationError as exc: + loaded[path.stem] = exc + return loaded + + +def _check_manifest( + root: Path, + loaded: HostManifest | ValidationError, + input_digest: str, + *, + scope: str, +) -> HostFinding: + if isinstance(loaded, ValidationError): + return HostFinding( + host="?", + scope=scope, + ok=False, + code=FINDING_INVALID, + authority_projection="", + message=str(loaded), + ) + skill_path = root / loaded.host / SKILL_NAME + hook_path = root / loaded.host / HOOK_NAME + if not skill_path.is_file(): + return _finding(loaded, scope, FINDING_TAMPERED, "SKILL.md 缺失") + try: + skill_text = skill_path.read_text(encoding="utf-8") + except OSError: + return _finding(loaded, scope, FINDING_TAMPERED, "SKILL.md 无法读取") + if sha256_text(skill_text) != loaded.skill_sha256: + return _finding(loaded, scope, FINDING_TAMPERED, "SKILL.md 哈希漂移") + if loaded.input_sha256 != input_digest: + return _finding(loaded, scope, FINDING_EXPIRED, "投影输入已过期") + if loaded.authority_projection == AUTHORITY_SKILL_AND_HOOK: + if not hook_path.is_file(): + return _finding(loaded, scope, FINDING_MISSING_HOOK, "已投影 deny hook 缺失") + try: + hook_text = hook_path.read_text(encoding="utf-8") + except OSError: + return _finding(loaded, scope, FINDING_MISSING_HOOK, "deny hook 无法读取") + if sha256_text(hook_text) != loaded.hook_sha256: + return _finding(loaded, scope, FINDING_TAMPERED, "deny hook 哈希漂移") + elif hook_path.is_file(): + return _finding(loaded, scope, FINDING_UNEXPECTED_HOOK, "skill_only 不应存在 deny hook") + return _finding(loaded, scope, FINDING_FRESH, "投影与当前 Card 一致") + + +def _finding(manifest: HostManifest, scope: str, code: str, message: str) -> HostFinding: + return HostFinding( + host=manifest.host, + scope=scope, + ok=code == FINDING_FRESH, + code=code, + authority_projection=manifest.authority_projection, + message=message, + ) diff --git a/src/dyro/host/models.py b/src/dyro/host/models.py new file mode 100644 index 0000000..7832df0 --- /dev/null +++ b/src/dyro/host/models.py @@ -0,0 +1,69 @@ +"""Host projection records. A compiled skill is authority, not a sandbox.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +SCHEMA_VERSION = 1 +AUTHORITY_SKILL_ONLY = "skill_only" +AUTHORITY_SKILL_AND_HOOK = "skill_and_hook" +SCOPE_WORKSPACE = "workspace" +SCOPE_USER = "user" +SKILL_NAME = "SKILL.md" +HOOK_NAME = "deny-hook.json" +DEFAULT_HOST = "cli" +HOOK_SIDECAR_NOTE = "deny hook 写在投影树 SKILL.md 旁,未安装到 hook_surface,不是宿主拦截" + +FINDING_FRESH = "FRESH" +FINDING_TAMPERED = "TAMPERED" +FINDING_EXPIRED = "EXPIRED" +FINDING_MISSING_HOOK = "MISSING_HOOK" +FINDING_UNEXPECTED_HOOK = "UNEXPECTED_HOOK" +FINDING_INVALID = "INVALID" + + +@dataclass(frozen=True) +class HostManifest: + schema_version: int + host: str + scope: str + authority_projection: str + skill_sha256: str + hook_sha256: str + input_sha256: str + + +@dataclass(frozen=True) +class HostProjection: + host: str + scope: str + authority_projection: str + skill_text: str + hook_text: str + skill_sha256: str + hook_sha256: str + input_sha256: str + skill_relpath: str + hook_relpath: str + manifest_relpath: str + + +@dataclass(frozen=True) +class HostFinding: + host: str + scope: str + ok: bool + code: str + authority_projection: str + message: str + + +@dataclass(frozen=True) +class HostDoctorReport: + schema_version: int + scope: str + compiled: bool + ok: bool + input_sha256: str + findings: tuple[HostFinding, ...] diff --git a/src/dyro/observations.py b/src/dyro/observations.py index 07e10bb..c72d398 100644 --- a/src/dyro/observations.py +++ b/src/dyro/observations.py @@ -110,6 +110,7 @@ class WorkspaceReadSnapshot: workspace_revision: str source_digests: tuple[tuple[str, str], ...] completeness: str + proof_inspection: str lines: tuple[WorkspaceLineObservation, ...] tasks: tuple[WorkspaceTaskObservation, ...] objectives: tuple[WorkspaceObjectiveObservation, ...] @@ -204,6 +205,7 @@ def _revision_payload( return { "schema_version": READ_SNAPSHOT_SCHEMA_VERSION, "workspace_name": workspace_name, + "proof_inspection": "not_inspected", "lines": [ { "id": item.id, @@ -353,6 +355,7 @@ def sample_clock() -> datetime: workspace_revision=revision, source_digests=frozen_sources, completeness="complete" if not frozen_failures else "partial", + proof_inspection="not_inspected", lines=lines, tasks=tasks, objectives=objectives, diff --git a/src/dyro/proof/__init__.py b/src/dyro/proof/__init__.py new file mode 100644 index 0000000..913dfdb --- /dev/null +++ b/src/dyro/proof/__init__.py @@ -0,0 +1,48 @@ +"""Read-only Proof projection. Not a second PASS, merge, or gate.""" + +from .bundle import export_bundle, load_current_heads, verify_bundle +from .decay import decay +from .derive import derive_objective_proofs, derive_task_proofs, list_proofs +from .evaluate import decayed_merge_subjects, evaluate_proof, evaluate_proofs, live_merge_evidence +from .models import ( + DecayDecision, + ObservedSubstrate, + Proof, + ProofKind, + ProofStatus, + ProofSubstrate, + proof_identity_sha256, +) +from .project import ( + proof_payload, + proofs_payload, + render_proofs_json, + render_proofs_text, + verify_exit_code, +) + +__all__ = ( + "DecayDecision", + "ObservedSubstrate", + "Proof", + "ProofKind", + "ProofStatus", + "ProofSubstrate", + "decay", + "decayed_merge_subjects", + "derive_objective_proofs", + "derive_task_proofs", + "evaluate_proof", + "evaluate_proofs", + "export_bundle", + "list_proofs", + "live_merge_evidence", + "load_current_heads", + "proof_identity_sha256", + "proof_payload", + "proofs_payload", + "render_proofs_json", + "render_proofs_text", + "verify_bundle", + "verify_exit_code", +) diff --git a/src/dyro/proof/bundle.py b/src/dyro/proof/bundle.py new file mode 100644 index 0000000..db3d568 --- /dev/null +++ b/src/dyro/proof/bundle.py @@ -0,0 +1,340 @@ +"""Proof Bundle export and integrity verify. Not identity, not workspace decay.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path, PurePosixPath +import hashlib +import json +import re +import zipfile + +from ..errors import DyroError, ValidationError +from ..process import run +from .models import Proof, ProofKind, ProofStatus, ProofSubstrate +from .project import proof_payload + + +BUNDLE_KIND = "dyro.proof.bundle" +BUNDLE_SCHEMA_VERSION = 1 +INTEGRITY_MODE = "integrity" +EVIDENCE_MARKERS = frozenset({"receipt.md", "provenance.json", "gates.json", "task-heads.json"}) +_SHA_RE = re.compile(r"^[0-9a-f]{40}(?:[0-9a-f]{24})?$") +_HEX_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") +MAX_MEMBER_BYTES = 8 * 1024 * 1024 + +MISSING_GIT = "missing_git_objects" +MISSING_PROCEDURE = "missing_procedure" +MISSING_SUBSTRATE = "missing_substrate" +MISSING_DECLARED_KEYS = "missing_declared_keys" +NOT_PROOF_BUNDLE = "not_proof_bundle" +OBJECT_UNRESOLVED = "object_unresolved" +BUNDLE_BYTES_MISMATCH = "bundle_bytes_mismatch" +CURRENT_HEADS = "current_heads" +STILL_BOUND = "still_bound" +INVALID_POLICY = "invalid_policy" + +_SIGNED_POLICY_BY_KIND = { + ProofKind.REVIEW_VERDICT: "require_signed_review", + ProofKind.SIGNOFF: "require_signed_signoff", +} + + +def export_bundle(proofs: tuple[Proof, ...], destination: Path) -> Path: + """Write a schema_version=1 ZIP. No git object database, paths, or credentials.""" + if destination.suffix != ".zip": + raise DyroError("proof export --bundle 必须是 .zip 路径") + destination.parent.mkdir(parents=True, exist_ok=True) + files: dict[str, str] = {} + digest: dict[str, str] = {} + for proof in proofs: + body = json.dumps(_portable_payload(proof), ensure_ascii=False, sort_keys=True, indent=2) + "\n" + name = f"proofs/{proof.id}.json" + files[name] = body + digest[proof.id] = hashlib.sha256(body.encode("utf-8")).hexdigest() + manifest = { + "kind": BUNDLE_KIND, + "proof_ids": [proof.id for proof in proofs], + "proof_sha256": digest, + "schema_version": BUNDLE_SCHEMA_VERSION, + } + with zipfile.ZipFile(destination, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr( + "manifest.json", + json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n", + ) + for name, body in files.items(): + archive.writestr(name, body) + return destination + + +def verify_bundle( + bundle: Path, + *, + git_dirs: tuple[Path, ...] = (), + current_heads: dict[str, str] | None = None, +) -> tuple[Proof, ...]: + """Integrity of a portable bundle plus caller git objects. + + Without ``current_heads`` the result is only ``live`` or ``inconclusive``. + That conclusion is not workspace ``proof verify`` and not merge. + Empty ``git_dirs`` or a proof without a resolvable pin cannot be ``live``. + """ + try: + archive = zipfile.ZipFile(bundle) + except (OSError, zipfile.BadZipFile) as exc: + raise DyroError(f"无法打开 Proof Bundle:{bundle}") from exc + with archive: + names = set(archive.namelist()) + if _looks_like_evidence_zip(names): + return (_bundle_inconclusive(NOT_PROOF_BUNDLE, "不是 schema_version=1 的 Proof Bundle"),) + if "manifest.json" not in names: + return (_bundle_inconclusive(NOT_PROOF_BUNDLE, "不是 schema_version=1 的 Proof Bundle"),) + try: + manifest = json.loads(_read_member(archive, "manifest.json")) + except (UnicodeDecodeError, json.JSONDecodeError, DyroError): + return (_bundle_inconclusive(NOT_PROOF_BUNDLE, "manifest.json 无法解析"),) + if not _valid_manifest(manifest): + return (_bundle_inconclusive(NOT_PROOF_BUNDLE, "Proof Bundle schema_version 必须为 1"),) + if any(_git_layout_member(name) for name in names): + return (_bundle_inconclusive(NOT_PROOF_BUNDLE, "捆内不得包含 git 对象库"),) + resolved = tuple(_resolve_git_dir(path) for path in git_dirs) + proofs: list[Proof] = [] + digests = manifest.get("proof_sha256") + for proof_id in manifest["proof_ids"]: + member = f"proofs/{proof_id}.json" + try: + raw = _read_member(archive, member) + except DyroError: + proofs.append(_bundle_inconclusive(BUNDLE_BYTES_MISMATCH, f"缺少 {member}")) + continue + if not isinstance(digests, dict) or proof_id not in digests: + proofs.append(_bundle_inconclusive(BUNDLE_BYTES_MISMATCH, f"缺少 {proof_id} 哈希")) + continue + expected = digests[proof_id] + if not isinstance(expected, str) or not expected: + proofs.append(_bundle_inconclusive(BUNDLE_BYTES_MISMATCH, f"缺少 {proof_id} 哈希")) + continue + if hashlib.sha256(raw.encode("utf-8")).hexdigest() != expected: + proofs.append(_bundle_inconclusive(BUNDLE_BYTES_MISMATCH, f"{proof_id} 字节哈希漂移")) + continue + try: + payload = json.loads(raw) + proof = proof_from_payload(payload) + except (json.JSONDecodeError, KeyError, TypeError, ValueError, ValidationError): + proofs.append(_bundle_inconclusive(NOT_PROOF_BUNDLE, f"{proof_id} 无法重建")) + continue + proofs.append( + _integrity_of( + proof, + git_dirs=resolved, + current_heads=current_heads, + ) + ) + if not proofs: + return (_bundle_inconclusive(MISSING_SUBSTRATE, "Proof Bundle 为空"),) + return tuple(proofs) + + +def proof_from_payload(raw: object) -> Proof: + if not isinstance(raw, dict): + raise ValidationError("Proof 载荷必须是对象") + substrate_raw = raw.get("substrate") + if not isinstance(substrate_raw, dict): + raise ValidationError("Proof substrate 必须是对象") + repo_heads = substrate_raw.get("repo_heads") or {} + extra = substrate_raw.get("extra") or {} + if not isinstance(repo_heads, dict) or not isinstance(extra, dict): + raise ValidationError("Proof substrate.repo_heads 与 extra 必须是对象") + policy = raw.get("policy_require_signed") or {} + if not isinstance(policy, dict): + raise ValidationError("policy_require_signed 必须是对象") + keys = raw.get("declared_key_ids") or () + return Proof( + id=str(raw["id"]), + kind=ProofKind(str(raw["kind"])), + subject=str(raw["subject"]), + substrate=ProofSubstrate( + repo_heads=tuple((str(key), str(value)) for key, value in repo_heads.items()), + plan_sha256=str(substrate_raw.get("plan_sha256") or ""), + attempt_id=str(substrate_raw.get("attempt_id") or ""), + contract_hash=str(substrate_raw.get("contract_hash") or ""), + extra=tuple((str(key), str(value)) for key, value in extra.items()), + ), + procedure=str(raw.get("procedure") or ""), + bytes_sha256=str(raw.get("bytes_sha256") or ""), + generation=str(raw.get("generation") or ""), + status=ProofStatus.INCONCLUSIVE, + declared_key_ids=tuple(str(item) for item in keys), + policy_require_signed=tuple((str(key), _policy_flag(value)) for key, value in policy.items()), + ) + + +def load_current_heads(path: Path) -> dict[str, str]: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise DyroError(f"无法读取 --current-heads:{path}") from exc + if not isinstance(raw, dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in raw.items()): + raise ValidationError("--current-heads 必须是仓库 ID 到 git SHA 的 JSON 对象") + heads = {key: value.strip() for key, value in raw.items()} + for sha in heads.values(): + if sha and not _SHA_RE.fullmatch(sha): + raise ValidationError("--current-heads 的 SHA 必须是 40 或 64 位小写十六进制") + return heads + + +def _portable_payload(proof: Proof) -> dict[str, object]: + payload = proof_payload(proof) + payload["status"] = ProofStatus.INCONCLUSIVE.value + payload["decay_reason"] = "" + payload["observed_at"] = "" + return payload + + +def _policy_flag(value: object) -> str: + if value is True or value == "true": + return "true" + if value is False or value == "false": + return "false" + raise ValidationError("policy_require_signed 只能是 true 或 false") + + +def _integrity_of( + proof: Proof, + *, + git_dirs: tuple[Path, ...], + current_heads: dict[str, str] | None, +) -> Proof: + if not proof.procedure.strip(): + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=MISSING_PROCEDURE) + if not _has_substrate(proof): + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=MISSING_SUBSTRATE) + if _requires_declared_keys(proof) and not proof.declared_key_ids: + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=MISSING_DECLARED_KEYS) + shas = [sha for _repo, sha in proof.substrate.repo_heads if sha] + if not git_dirs: + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=MISSING_GIT) + if not shas: + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=MISSING_GIT) + for sha in shas: + if not _SHA_RE.fullmatch(sha) or not _object_exists(git_dirs, sha): + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=OBJECT_UNRESOLVED) + if current_heads is None: + return replace(proof, status=ProofStatus.LIVE, decay_reason=STILL_BOUND) + return _decay_against_current_heads(proof, git_dirs=git_dirs, current_heads=current_heads) + + +def _decay_against_current_heads( + proof: Proof, + *, + git_dirs: tuple[Path, ...], + current_heads: dict[str, str], +) -> Proof: + if not proof.substrate.repo_heads: + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=MISSING_GIT) + for repo, pinned in proof.substrate.repo_heads: + current = current_heads.get(repo, "").strip() + if not current: + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=CURRENT_HEADS) + if not _SHA_RE.fullmatch(current) or not _object_exists(git_dirs, current): + return replace(proof, status=ProofStatus.INCONCLUSIVE, decay_reason=OBJECT_UNRESOLVED) + if proof.kind is ProofKind.INTEGRATION_HEADS: + if not _is_ancestor(git_dirs, pinned, current): + return replace(proof, status=ProofStatus.DECAYED, decay_reason=CURRENT_HEADS) + elif pinned != current: + return replace(proof, status=ProofStatus.DECAYED, decay_reason=CURRENT_HEADS) + return replace(proof, status=ProofStatus.LIVE, decay_reason=STILL_BOUND) + + +def _has_substrate(proof: Proof) -> bool: + if proof.substrate.repo_heads: + return True + if proof.bytes_sha256: + return True + if proof.substrate.plan_sha256 or proof.substrate.attempt_id or proof.substrate.contract_hash: + return True + return False + + +def _requires_declared_keys(proof: Proof) -> bool: + wanted = _SIGNED_POLICY_BY_KIND.get(proof.kind) + if wanted is None: + return False + return any(key == wanted and value == "true" for key, value in proof.policy_require_signed) + + +def _valid_manifest(raw: object) -> bool: + if not isinstance(raw, dict): + return False + if raw.get("kind") != BUNDLE_KIND or raw.get("schema_version") != BUNDLE_SCHEMA_VERSION: + return False + ids = raw.get("proof_ids") + digests = raw.get("proof_sha256") + if not isinstance(ids, list) or not all(isinstance(item, str) and item for item in ids): + return False + if not isinstance(digests, dict): + return False + return True + + +def _looks_like_evidence_zip(names: set[str]) -> bool: + top = {PurePosixPath(name).parts[0] for name in names if name and not name.endswith("/")} + return bool(top & EVIDENCE_MARKERS) + + +def _git_layout_member(name: str) -> bool: + parts = PurePosixPath(name).parts + return "objects" in parts or ".git" in parts or name.startswith("pack/") + + +def _read_member(archive: zipfile.ZipFile, name: str) -> str: + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or not name: + raise DyroError("Proof Bundle 包含不安全路径") + try: + info = archive.getinfo(name) + except KeyError as exc: + raise DyroError(f"Proof Bundle 缺少 {name}") from exc + if info.is_dir(): + raise DyroError(f"Proof Bundle 成员必须是文件:{name}") + if info.file_size > MAX_MEMBER_BYTES: + raise DyroError(f"Proof Bundle 成员过大:{name}") + return archive.read(name).decode("utf-8") + + +def _resolve_git_dir(path: Path) -> Path: + candidate = path.expanduser() + if (candidate / "objects").is_dir() and (candidate / "HEAD").exists(): + return candidate + result = run(("git", "-C", str(candidate), "rev-parse", "--absolute-git-dir"), timeout=30) + if result.code != 0 or not result.stdout.strip(): + raise DyroError(f"verify-bundle 的 --git-dir 必须指向调用方 git 对象库:{path}") + return Path(result.stdout.strip()) + + +def _object_exists(git_dirs: tuple[Path, ...], sha: str) -> bool: + return any(_git_dir_ok(git_dir, "cat-file", "-e", sha) for git_dir in git_dirs) + + +def _is_ancestor(git_dirs: tuple[Path, ...], pinned: str, current: str) -> bool: + return any(_git_dir_ok(git_dir, "merge-base", "--is-ancestor", pinned, current) for git_dir in git_dirs) + + +def _git_dir_ok(git_dir: Path, *args: str) -> bool: + result = run(("git", f"--git-dir={git_dir}", *args), timeout=30) + return result.code == 0 + + +def _bundle_inconclusive(reason: str, subject: str) -> Proof: + return Proof( + id=hashlib.sha256(f"{reason}:{subject}".encode("utf-8")).hexdigest(), + kind=ProofKind.BUNDLE_FAILURE, + subject=subject, + substrate=ProofSubstrate(), + procedure="", + bytes_sha256="", + generation="", + status=ProofStatus.INCONCLUSIVE, + decay_reason=reason, + ) diff --git a/src/dyro/proof/decay.py b/src/dyro/proof/decay.py new file mode 100644 index 0000000..2f7e139 --- /dev/null +++ b/src/dyro/proof/decay.py @@ -0,0 +1,113 @@ +"""Pure Proof decay. Callers inject substrate, predicates, and clock.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from .models import DecayDecision, ObservedSubstrate, Proof, ProofKind, ProofStatus + +# Reason tokens name the 0.6 predicate they project. Tests match these strings. +REVIEW_ACCEPTANCE = "review_acceptance" +EXTERNAL_SIGNOFF = "external_signoff" +DEPENDENCY_INTEGRATED = "dependency_integrated" +GATE_BYTES = "gate_bytes" +GATE_ARGV = "gate_argv" +ACTION_RECEIPT_BYTES = "action_receipt_bytes" +CURRENT_SUBSTRATE_MISSING = "current_substrate_missing" +PREDICATE_INCONCLUSIVE = "predicate_inconclusive" +STILL_BOUND = "still_bound" +LINE_PREPARE_NOT_DECAY = "line_prepare_not_decay" + + +def _observed_at(clock: datetime) -> str: + if clock.tzinfo is None: + raise TypeError("decay clock 必须带时区") + return clock.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _decision(status: ProofStatus, reason: str, clock: datetime) -> DecayDecision: + return DecayDecision(status=status, reason=reason, observed_at=_observed_at(clock)) + + +def _from_predicate(ok: bool | None, *, live_reason: str, decay_reason: str, clock: datetime) -> DecayDecision: + if ok is None: + return _decision(ProofStatus.INCONCLUSIVE, PREDICATE_INCONCLUSIVE, clock) + if ok: + return _decision(ProofStatus.LIVE, live_reason, clock) + return _decision(ProofStatus.DECAYED, decay_reason, clock) + + +def decay( + proof: Proof, + current: ObservedSubstrate | None, + *, + clock: datetime, + review_ok: bool | None = None, + signoff_ok: bool | None = None, + integration_ok: bool | None = None, + line_prepare_ok: bool | None = None, +) -> DecayDecision: + """Project live/decayed/inconclusive from injected facts. + + ``line_prepare_ok`` is accepted so callers can record a ``_prepare_merge`` + failure. It never produces ``PROOF_DECAYED``; dirty or wrong-branch lines + stay merge errors. + """ + if not isinstance(proof, Proof): + raise TypeError("proof 必须是 Proof") + if current is not None and not isinstance(current, ObservedSubstrate): + raise TypeError("current 必须是 ObservedSubstrate 或 None") + if line_prepare_ok is False and proof.kind is ProofKind.INTEGRATION_HEADS: + # Explicitly ignore prepare-merge failures for this kind. + pass + + if proof.kind is ProofKind.REVIEW_VERDICT: + return _from_predicate( + review_ok, + live_reason=STILL_BOUND, + decay_reason=REVIEW_ACCEPTANCE, + clock=clock, + ) + if proof.kind is ProofKind.SIGNOFF: + return _from_predicate( + signoff_ok, + live_reason=STILL_BOUND, + decay_reason=EXTERNAL_SIGNOFF, + clock=clock, + ) + if proof.kind is ProofKind.INTEGRATION_HEADS: + return _from_predicate( + integration_ok, + live_reason=STILL_BOUND, + decay_reason=DEPENDENCY_INTEGRATED, + clock=clock, + ) + if proof.kind is ProofKind.GATE_LOG: + return _bytes_kind( + proof, + current, + clock=clock, + mismatch_reason=GATE_BYTES, + argv_reason=GATE_ARGV, + ) + if proof.kind is ProofKind.ACTION_RECEIPT: + return _bytes_kind(proof, current, clock=clock, mismatch_reason=ACTION_RECEIPT_BYTES) + return _decision(ProofStatus.INCONCLUSIVE, PREDICATE_INCONCLUSIVE, clock) + + +def _bytes_kind( + proof: Proof, + current: ObservedSubstrate | None, + *, + clock: datetime, + mismatch_reason: str, + argv_reason: str = "", +) -> DecayDecision: + if current is None or not current.present: + return _decision(ProofStatus.INCONCLUSIVE, CURRENT_SUBSTRATE_MISSING, clock) + recorded_argv = dict(proof.substrate.extra).get("argv_sha256", "") + if argv_reason and current.argv_sha256 and recorded_argv and current.argv_sha256 != recorded_argv: + return _decision(ProofStatus.DECAYED, argv_reason, clock) + if current.bytes_sha256 != proof.bytes_sha256: + return _decision(ProofStatus.DECAYED, mismatch_reason, clock) + return _decision(ProofStatus.LIVE, STILL_BOUND, clock) diff --git a/src/dyro/proof/derive.py b/src/dyro/proof/derive.py new file mode 100644 index 0000000..1f80c72 --- /dev/null +++ b/src/dyro/proof/derive.py @@ -0,0 +1,568 @@ +"""Derive 0.7 Proofs from existing task and Objective files. Never writes sources.""" + +from __future__ import annotations + +from pathlib import Path +import hashlib +import json +import re +from typing import Iterable + +from ..canonical import canonical_json_bytes +from ..config import Config +from ..errors import DyroError, ValidationError +from ..evidence_store import current_evidence_directory, resolve_evidence_path +from ..process import run +from ..provenance import latest_execution_attempt, review_binding +from ..signing import signature_key_id +from ..tasks import TASK_HEADS_FILE, Task, list_tasks, load_task +from ..workspace import get_line, line_repository_path +from .models import Proof, ProofKind, ProofStatus, ProofSubstrate, proof_identity_sha256 + +_GATE_LOG_RE = re.compile(r"^gate-(\d+)\.log$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +def list_proofs( + config: Config, + *, + task_id: str | None = None, + objective_id: str | None = None, + line_id: str | None = None, + evaluate: bool = True, +) -> tuple[Proof, ...]: + """Rebuild Proofs from disk. `--task` never includes action_receipt.""" + if task_id and objective_id: + raise ValidationError("proof list 的 --task 与 --objective 互斥") + if objective_id: + derived = derive_objective_proofs(config, objective_id) + elif task_id: + derived = derive_task_proofs(config, load_task(config, task_id)) + else: + tasks = list_tasks(config) + if line_id: + tasks = [task for task in tasks if task.line == line_id] + proofs: list[Proof] = [] + for task in tasks: + proofs.extend(derive_task_proofs(config, task)) + derived = tuple(_dedupe(proofs)) + if not evaluate: + return derived + from .evaluate import evaluate_proofs + + return evaluate_proofs(config, derived) + + +def derive_task_proofs(config: Config, task: Task) -> tuple[Proof, ...]: + """Task-scoped 0.7 kinds. Does not scan Objective action-receipts/.""" + proofs: list[Proof] = [] + proofs.extend(_derive_gate_logs(config, task)) + review = _derive_review_verdict(config, task) + if review is not None: + proofs.append(review) + signoff = _derive_signoff(config, task) + if signoff is not None: + proofs.append(signoff) + subjects = [task] + for dependency in task.depends_on: + try: + subjects.append(load_task(config, dependency)) + except (DyroError, ValidationError): + proofs.append( + _inconclusive( + config, + kind=ProofKind.INTEGRATION_HEADS, + subject=dependency, + generation="", + identity={"line": task.line, "missing_dependency": "true"}, + procedure="git merge-base --is-ancestor", + extra=(("integration_state", "inconclusive"),), + ) + ) + for subject in subjects: + integration = _derive_integration_heads(config, subject) + if integration is not None: + proofs.append(integration) + return tuple(_dedupe(proofs)) + + +def derive_objective_proofs(config: Config, objective_id: str) -> tuple[Proof, ...]: + from ..continuation.store import get_objective, list_objective_actions + + record = get_objective(config, objective_id) + proofs: list[Proof] = [] + for action in list_objective_actions(config, objective_id): + receipt = action.receipt + if receipt is None: + continue + payload = { + "action_id": receipt.action_id, + "authority_sha256": action.intent.authority_sha256, + "idempotency_key": receipt.idempotency_key, + "owner_generation": receipt.owner_generation, + "status": receipt.status.value, + "summary": receipt.summary, + } + generation = str(receipt.owner_generation) + produced_at = receipt.recorded_at.isoformat().replace("+00:00", "Z") + proofs.append( + _build( + config, + kind=ProofKind.ACTION_RECEIPT, + subject=objective_id, + generation=generation, + identity={"action_id": receipt.action_id}, + procedure="action journal receipt bytes", + bytes_sha256=_sha256_json(payload), + substrate=ProofSubstrate( + plan_sha256=action.intent.plan_sha256, + attempt_id=receipt.action_id, + contract_hash=record.contract_sha256, + extra=( + ("authority_sha256", action.intent.authority_sha256), + ("owner_generation", generation), + ), + ), + produced_at=produced_at, + ) + ) + return tuple(proofs) + + +def _derive_gate_logs(config: Config, task: Task) -> tuple[Proof, ...]: + generation = _task_generation(task) + argv_hash = _gate_argv_hash(task) + contract_hash = _task_contract_hash(task) + attempt_id, plan_sha256 = _attempt_binding(task) + heads = _recorded_heads(task) + policy_extra = (("argv_sha256", argv_hash),) + + evidence = _safe_current_evidence(task.directory) + if evidence is not None: + gates_json = evidence / "gates.json" + if gates_json.is_file(): + logs = sorted((evidence / "gates").glob("gate-*.log")) if (evidence / "gates").is_dir() else [] + attested = _attested_bytes([gates_json, *logs]) + produced_at = _record_timestamp(gates_json) + return ( + _build( + config, + kind=ProofKind.GATE_LOG, + subject=task.id, + generation=generation or evidence.name, + identity={"mode": "external"}, + procedure="gates.json + gates/gate-n.log; argv from task.toml", + bytes_sha256=_sha256_bytes(attested), + substrate=ProofSubstrate( + repo_heads=heads, + plan_sha256=plan_sha256, + attempt_id=attempt_id, + contract_hash=contract_hash, + extra=policy_extra, + ), + produced_at=produced_at, + ), + ) + + local_logs = _local_gate_logs(task.directory) + if not local_logs: + return () + attested = _attested_bytes(local_logs) + return ( + _build( + config, + kind=ProofKind.GATE_LOG, + subject=task.id, + generation=generation, + identity={"mode": "local"}, + procedure="logs/gate-n.log; argv from task.toml; not ledger", + bytes_sha256=_sha256_bytes(attested), + substrate=ProofSubstrate( + repo_heads=heads, + plan_sha256=plan_sha256, + attempt_id=attempt_id, + contract_hash=contract_hash, + extra=policy_extra, + ), + ), + ) + + +def _derive_review_verdict(config: Config, task: Task) -> Proof | None: + review_path = task.directory / "review.md" + receipt_path = _safe_evidence_path(task.directory, "receipt.md") + heads_path = _safe_evidence_path(task.directory, TASK_HEADS_FILE) + if review_path.is_file() or (receipt_path is not None and receipt_path.is_file()) or ( + heads_path is not None and heads_path.is_file() + ): + pass + else: + return None + + attempt_id, plan_sha256 = _attempt_binding(task) + generation = attempt_id or _task_generation(task) + extras: list[tuple[str, str]] = [] + review_bytes = b"" + if not review_path.is_file(): + extras.append(("missing", "review.md")) + else: + review_bytes = review_path.read_bytes() + extras.append(("review_sha256", hashlib.sha256(review_bytes).hexdigest())) + receipt_hash = _file_digest(receipt_path) + heads_hash = _file_digest(heads_path) + if receipt_hash: + extras.append(("receipt_sha256", receipt_hash)) + else: + extras.append(("missing", "receipt.md")) + if heads_hash: + extras.append(("task_heads_sha256", heads_hash)) + else: + extras.append(("missing", TASK_HEADS_FILE)) + if attempt_id: + extras.append(("attempt_id", attempt_id)) + else: + extras.append(("missing", "attempt_id")) + + produced_at = "" + key_ids: tuple[str, ...] = () + identity_path = task.directory / "review-identity.json" + if identity_path.is_file(): + produced_at, key_ids = _signed_review_fields(identity_path) + + attested = review_bytes or (receipt_path.read_bytes() if receipt_path and receipt_path.is_file() else b"") + return _build( + config, + kind=ProofKind.REVIEW_VERDICT, + subject=task.id, + generation=generation, + identity={}, + procedure="review.md + receipt + task-heads + attempt/plan binding", + bytes_sha256=_sha256_bytes(attested), + substrate=ProofSubstrate( + repo_heads=_recorded_heads(task), + plan_sha256=plan_sha256, + attempt_id=attempt_id, + contract_hash=_task_contract_hash(task), + extra=tuple(extras), + ), + produced_at=produced_at, + declared_key_ids=key_ids, + ) + + +def _derive_signoff(config: Config, task: Task) -> Proof | None: + path = task.directory / "signoff.json" + if not path.is_file(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return _inconclusive( + config, + kind=ProofKind.SIGNOFF, + subject=task.id, + generation=_task_generation(task), + identity={}, + procedure="signoff.json rebind", + extra=(("unparseable", "signoff.json"),), + ) + if not isinstance(payload, dict): + return _inconclusive( + config, + kind=ProofKind.SIGNOFF, + subject=task.id, + generation=_task_generation(task), + identity={}, + procedure="signoff.json rebind", + extra=(("unparseable", "signoff.json"),), + ) + attempt_id = str(payload.get("attempt_id", "") or "") + plan_sha256 = str(payload.get("plan_sha256", "") or "") + produced_at = str(payload.get("signed_at", "") or "") + key_id = signature_key_id(payload) + extras = [ + ("review_sha256", str(payload.get("review_sha256", "") or "")), + ("receipt_sha256", str(payload.get("receipt_sha256", "") or "")), + ("task_heads_sha256", str(payload.get("task_heads_sha256", "") or "")), + ] + return _build( + config, + kind=ProofKind.SIGNOFF, + subject=task.id, + generation=attempt_id or _task_generation(task), + identity={}, + procedure="signoff.json rebind", + bytes_sha256=_sha256_bytes(path.read_bytes()), + substrate=ProofSubstrate( + repo_heads=_recorded_heads(task), + plan_sha256=plan_sha256, + attempt_id=attempt_id, + contract_hash=_task_contract_hash(task), + extra=tuple(extras), + ), + produced_at=produced_at, + declared_key_ids=(key_id,) if key_id else (), + ) + + +def _derive_integration_heads(config: Config, task: Task) -> Proof | None: + heads_path = _safe_evidence_path(task.directory, TASK_HEADS_FILE) + if heads_path is None or not heads_path.is_file(): + return None + heads = _recorded_heads(task) + generation = hashlib.sha256(heads_path.read_bytes()).hexdigest() if heads else "" + state = _observe_integration(config, task, heads) + return _build( + config, + kind=ProofKind.INTEGRATION_HEADS, + subject=task.id, + generation=generation, + identity={"line": task.line}, + procedure="git merge-base --is-ancestor", + bytes_sha256=_sha256_bytes(heads_path.read_bytes()), + substrate=ProofSubstrate( + repo_heads=heads, + plan_sha256="", + attempt_id="", + contract_hash=_task_contract_hash(task), + extra=(("integration_state", state),), + ), + ) + + +def _observe_integration(config: Config, task: Task, heads: tuple[tuple[str, str], ...]) -> str: + if not heads: + return "inconclusive" + try: + line = get_line(config, task.line) + except (DyroError, ValidationError): + return "inconclusive" + for repo_id, task_head in heads: + destination = line_repository_path(config, line, repo_id) + result = run(("git", "merge-base", "--is-ancestor", task_head, "HEAD"), cwd=destination) + if result.code != 0: + return "pending" + return "integrated" + + +def _build( + config: Config, + *, + kind: ProofKind, + subject: str, + generation: str, + identity: dict[str, object], + procedure: str, + bytes_sha256: str, + substrate: ProofSubstrate, + produced_at: str = "", + declared_key_ids: tuple[str, ...] = (), +) -> Proof: + proof_id = proof_identity_sha256( + kind=kind, + subject=subject, + generation=generation, + identity_payload=identity, + ) + return Proof( + id=proof_id, + kind=kind, + subject=subject, + substrate=substrate, + procedure=procedure, + bytes_sha256=bytes_sha256, + generation=generation, + status=ProofStatus.INCONCLUSIVE, + produced_at=produced_at, + declared_key_ids=declared_key_ids, + policy_require_signed=_policy_snapshot(config, kind), + ) + + +def _inconclusive( + config: Config, + *, + kind: ProofKind, + subject: str, + generation: str, + identity: dict[str, object], + procedure: str, + extra: tuple[tuple[str, str], ...] = (), +) -> Proof: + return _build( + config, + kind=kind, + subject=subject, + generation=generation, + identity=identity, + procedure=procedure, + bytes_sha256="", + substrate=ProofSubstrate(extra=extra), + ) + + +def _policy_snapshot(config: Config, kind: ProofKind) -> tuple[tuple[str, str], ...]: + policy = config.policy + if kind is ProofKind.REVIEW_VERDICT: + return ( + ("require_signed_review", "true" if getattr(policy, "require_signed_review", False) else "false"), + ) + if kind is ProofKind.SIGNOFF: + return ( + ("require_signed_signoff", "true" if getattr(policy, "require_signed_signoff", False) else "false"), + ) + return () + + +def _task_generation(task: Task) -> str: + evidence = _safe_current_evidence(task.directory) + if evidence is not None: + return evidence.name + attempt_id, _ = _attempt_binding(task) + if attempt_id: + return attempt_id + latest = _safe_latest_attempt(task.directory) + if latest and isinstance(latest.get("attempt_id"), str): + return str(latest["attempt_id"]) + return "" + + +def _attempt_binding(task: Task) -> tuple[str, str]: + try: + binding = review_binding(task.directory) + except (ValidationError, OSError): + binding = None + if binding is not None: + return binding + latest = _safe_latest_attempt(task.directory) + if latest is None: + return "", "" + attempt_id = str(latest.get("attempt_id", "") or "") + plan_sha256 = str(latest.get("plan_sha256", "") or "") + return attempt_id, plan_sha256 if _SHA256_RE.fullmatch(plan_sha256) else "" + + +def _task_contract_hash(task: Task) -> str: + latest = _safe_latest_attempt(task.directory) + if latest is None: + return "" + digest = str(latest.get("task_contract_sha256", "") or "") + return digest if _SHA256_RE.fullmatch(digest) else "" + + +def _safe_latest_attempt(task_directory: Path) -> dict[str, object] | None: + try: + return latest_execution_attempt(task_directory) + except (ValidationError, OSError): + return None + + +def _recorded_heads(task: Task) -> tuple[tuple[str, str], ...]: + path = _safe_evidence_path(task.directory, TASK_HEADS_FILE) + if path is None or not path.is_file(): + return () + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return () + repositories = payload.get("repositories") if isinstance(payload, dict) else None + if not isinstance(repositories, dict): + return () + heads: list[tuple[str, str]] = [] + for repo_id, head in sorted(repositories.items()): + if isinstance(repo_id, str) and isinstance(head, str): + heads.append((repo_id, head.lower())) + return tuple(heads) + + +def _gate_argv_hash(task: Task) -> str: + payload = [{"name": gate.name, "argv": list(gate.argv), "cwd": gate.cwd} for gate in task.gates] + return hashlib.sha256(canonical_json_bytes(payload)).hexdigest() + + +def _local_gate_logs(task_directory: Path) -> list[Path]: + candidates: list[Path] = [] + for directory in (task_directory / "logs", task_directory): + if not directory.is_dir(): + continue + candidates.extend( + path for path in directory.iterdir() if path.is_file() and _GATE_LOG_RE.fullmatch(path.name) + ) + return sorted(candidates, key=lambda path: (path.parent.name, path.name)) + + +def _safe_current_evidence(task_directory: Path) -> Path | None: + try: + return current_evidence_directory(task_directory) + except (ValidationError, OSError): + return None + + +def _safe_evidence_path(task_directory: Path, relative: str) -> Path | None: + try: + return resolve_evidence_path(task_directory, relative) + except (ValidationError, OSError): + candidate = task_directory / relative + return candidate if candidate.is_file() else None + + +def _file_digest(path: Path | None) -> str: + if path is None or not path.is_file(): + return "" + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _attested_bytes(paths: Iterable[Path]) -> bytes: + chunks: list[bytes] = [] + for path in paths: + chunks.append(path.name.encode("utf-8")) + chunks.append(b"\0") + chunks.append(path.read_bytes()) + chunks.append(b"\n") + return b"".join(chunks) + + +def _sha256_bytes(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def _sha256_json(value: object) -> str: + return hashlib.sha256(canonical_json_bytes(value)).hexdigest() + + +def _record_timestamp(path: Path) -> str: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return "" + if not isinstance(payload, dict): + return "" + for key in ("produced_at", "created_at", "signed_at", "recorded_at"): + value = payload.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def _signed_review_fields(path: Path) -> tuple[str, tuple[str, ...]]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return "", () + if not isinstance(payload, dict): + return "", () + created_at = str(payload.get("created_at", "") or "") + key_id = payload.get("key_id") + keys = (str(key_id),) if isinstance(key_id, str) and key_id else () + return created_at, keys + + +def _dedupe(proofs: Iterable[Proof]) -> list[Proof]: + seen: set[str] = set() + unique: list[Proof] = [] + for proof in proofs: + if proof.id in seen: + continue + seen.add(proof.id) + unique.append(proof) + return unique diff --git a/src/dyro/proof/evaluate.py b/src/dyro/proof/evaluate.py new file mode 100644 index 0000000..5653b64 --- /dev/null +++ b/src/dyro/proof/evaluate.py @@ -0,0 +1,197 @@ +"""I/O layer: run existing 0.6 predicates, then call pure decay().""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime, timezone +from typing import Callable, Iterable + +from ..config import Config +from ..errors import DyroError, ValidationError +from ..evidence_store import resolve_evidence_path +from ..tasks import ( + TASK_HEADS_FILE, + Task, + _assert_dependency_integrated, + _valid_external_signoff, + _valid_review_acceptance, + load_task, +) +from .decay import decay +from .derive import derive_objective_proofs, derive_task_proofs +from .models import ObservedSubstrate, Proof, ProofKind, ProofStatus + +_MERGE_KINDS = frozenset({ProofKind.REVIEW_VERDICT, ProofKind.SIGNOFF}) +_Clock = Callable[[], datetime] + + +def evaluate_proofs( + config: Config, + proofs: Iterable[Proof], + *, + clock: _Clock | None = None, +) -> tuple[Proof, ...]: + now = clock or (lambda: datetime.now(timezone.utc)) + observed = now() + return tuple(evaluate_proof(config, proof, clock=lambda: observed) for proof in proofs) + + +def evaluate_proof( + config: Config, + proof: Proof, + *, + clock: _Clock | None = None, +) -> Proof: + observed_at = (clock or (lambda: datetime.now(timezone.utc)))() + review_ok: bool | None = None + signoff_ok: bool | None = None + integration_ok: bool | None = None + current: ObservedSubstrate | None = None + + if proof.kind is ProofKind.REVIEW_VERDICT: + review_ok = _review_ok(config, proof) + elif proof.kind is ProofKind.SIGNOFF: + signoff_ok = _signoff_ok(config, proof) + elif proof.kind is ProofKind.INTEGRATION_HEADS: + integration_ok = _integration_ok(config, proof) + elif proof.kind in {ProofKind.GATE_LOG, ProofKind.ACTION_RECEIPT}: + current = _current_bytes(config, proof) + + decision = decay( + proof, + current, + clock=observed_at, + review_ok=review_ok, + signoff_ok=signoff_ok, + integration_ok=integration_ok, + ) + updated = replace(proof, status=decision.status, decay_reason=decision.reason, observed_at=decision.observed_at) + return _refresh_integration_state(updated) + + +def decayed_merge_subjects(config: Config, tasks: Iterable[Task]) -> tuple[str, ...]: + """Task ids whose merge-relevant Proofs are decayed. Does not block downstream.""" + found: list[str] = [] + for task in tasks: + try: + proofs = evaluate_proofs(config, derive_task_proofs(config, task)) + except (DyroError, ValidationError, OSError): + continue + if any(proof.kind in _MERGE_KINDS and proof.status is ProofStatus.DECAYED for proof in proofs): + found.append(task.id) + return tuple(sorted(found)) + + +def live_merge_evidence(proofs: Iterable[Proof]) -> tuple[tuple[str, str], ...]: + """Pairs for ProgressFacts.effective_evidence. Trigger-class kinds stay out.""" + return tuple( + sorted( + (proof.subject, proof.id) + for proof in proofs + if proof.status is ProofStatus.LIVE + and proof.kind in {ProofKind.REVIEW_VERDICT, ProofKind.SIGNOFF, ProofKind.INTEGRATION_HEADS} + ) + ) + + +def _subject_task(config: Config, proof: Proof) -> Task: + return load_task(config, proof.subject) + + +def _incomplete_evidence(proof: Proof) -> bool: + return any(key in {"missing", "unparseable"} for key, _value in proof.substrate.extra) + + +def _review_ok(config: Config, proof: Proof) -> bool | None: + if _incomplete_evidence(proof) or not _review_files_present(config, proof): + return None + return _predicate(lambda: _valid_review_acceptance(config, _subject_task(config, proof))) + + +def _signoff_ok(config: Config, proof: Proof) -> bool | None: + if _incomplete_evidence(proof) or not _signoff_file_present(config, proof): + return None + return _predicate(lambda: _valid_external_signoff(config, _subject_task(config, proof))) + + +def _review_files_present(config: Config, proof: Proof) -> bool: + try: + task = _subject_task(config, proof) + except (DyroError, ValidationError): + return False + if not (task.directory / "review.md").is_file(): + return False + try: + resolve_evidence_path(task.directory, "receipt.md") + resolve_evidence_path(task.directory, TASK_HEADS_FILE) + except DyroError: + return False + return True + + +def _signoff_file_present(config: Config, proof: Proof) -> bool: + try: + task = _subject_task(config, proof) + except (DyroError, ValidationError): + return False + return (task.directory / "signoff.json").is_file() + + +def _refresh_integration_state(proof: Proof) -> Proof: + if proof.kind is not ProofKind.INTEGRATION_HEADS: + return proof + mapped = { + ProofStatus.LIVE: "integrated", + ProofStatus.DECAYED: "pending", + ProofStatus.INCONCLUSIVE: "inconclusive", + }.get(proof.status) + if mapped is None: + return proof + extra = tuple( + (key, mapped if key == "integration_state" else value) + for key, value in proof.substrate.extra + ) + if not any(key == "integration_state" for key, _value in extra): + extra = extra + (("integration_state", mapped),) + return replace(proof, substrate=replace(proof.substrate, extra=extra)) + + +def _predicate(probe: Callable[[], bool]) -> bool | None: + try: + return bool(probe()) + except (DyroError, ValidationError, OSError, KeyError): + return None + + +def _integration_ok(config: Config, proof: Proof) -> bool | None: + try: + task = _subject_task(config, proof) + except (DyroError, ValidationError): + return None + try: + _assert_dependency_integrated(config, task) + return True + except DyroError as exc: + if "尚未集成" in str(exc): + return False + return None + except (ValidationError, OSError): + return None + + +def _current_bytes(config: Config, proof: Proof) -> ObservedSubstrate | None: + try: + if proof.kind is ProofKind.ACTION_RECEIPT: + fresh = derive_objective_proofs(config, proof.subject) + else: + fresh = derive_task_proofs(config, _subject_task(config, proof)) + except (DyroError, ValidationError, OSError): + return None + match = next((item for item in fresh if item.id == proof.id), None) + if match is None: + return None + return ObservedSubstrate( + bytes_sha256=match.bytes_sha256, + argv_sha256=dict(match.substrate.extra).get("argv_sha256", ""), + present=True, + ) diff --git a/src/dyro/proof/models.py b/src/dyro/proof/models.py new file mode 100644 index 0000000..4290d3b --- /dev/null +++ b/src/dyro/proof/models.py @@ -0,0 +1,157 @@ +"""Immutable Proof records. Identity hashes contain no clock or mtime.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +import hashlib +from typing import Any, Mapping + +from ..canonical import canonical_json_bytes +from ..errors import ValidationError + + +class ProofKind(str, Enum): + GATE_LOG = "gate_log" + REVIEW_VERDICT = "review_verdict" + SIGNOFF = "signoff" + INTEGRATION_HEADS = "integration_heads" + ACTION_RECEIPT = "action_receipt" + BUNDLE_FAILURE = "bundle_failure" + + +class ProofStatus(str, Enum): + LIVE = "live" + DECAYED = "decayed" + INCONCLUSIVE = "inconclusive" + REVOKED = "revoked" + + +_KIND_VALUES = {item.value for item in ProofKind} + + +def _frozen_pairs(value: Any, label: str) -> tuple[tuple[str, str], ...]: + if isinstance(value, (str, bytes)): + raise TypeError(f"{label} 必须是集合,不能是字符串") + pairs: list[tuple[str, str]] = [] + for item in value: + if not isinstance(item, (tuple, list)) or len(item) != 2: + raise TypeError(f"{label} 必须只包含两个字符串组成的键值对") + key, mapped = item + if not isinstance(key, str) or not isinstance(mapped, str): + raise TypeError(f"{label} 必须只包含两个字符串组成的键值对") + pairs.append((key, mapped)) + return tuple(pairs) + + +def _frozen_strings(value: Any, label: str) -> tuple[str, ...]: + if isinstance(value, (str, bytes)): + raise TypeError(f"{label} 必须是集合,不能是字符串") + items = tuple(value) + if not all(isinstance(item, str) for item in items): + raise TypeError(f"{label} 必须只包含字符串") + return items + + +def proof_identity_sha256( + *, + kind: ProofKind | str, + subject: str, + generation: str, + identity_payload: Mapping[str, object], +) -> str: + """Return the stable Proof id. Payload must omit produced_at, mtime, and now.""" + kind_value = kind.value if isinstance(kind, ProofKind) else kind + if kind_value not in _KIND_VALUES: + raise ValidationError(f"Proof kind 无效:{kind_value}") + if not subject: + raise ValidationError("Proof subject 不能为空") + forbidden = {"produced_at", "mtime", "now", "observed_at"} + if forbidden.intersection(identity_payload): + raise ValidationError("Proof 身份载荷不能包含时钟或 mtime 字段") + return hashlib.sha256( + canonical_json_bytes( + { + "kind": kind_value, + "subject": subject, + "generation": generation, + "identity": dict(identity_payload), + } + ) + ).hexdigest() + + +@dataclass(frozen=True) +class ProofSubstrate: + repo_heads: tuple[tuple[str, str], ...] = () + plan_sha256: str = "" + attempt_id: str = "" + contract_hash: str = "" + extra: tuple[tuple[str, str], ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "repo_heads", _frozen_pairs(self.repo_heads, "ProofSubstrate.repo_heads")) + object.__setattr__(self, "extra", _frozen_pairs(self.extra, "ProofSubstrate.extra")) + if not isinstance(self.plan_sha256, str) or not isinstance(self.attempt_id, str): + raise TypeError("ProofSubstrate.plan_sha256 与 attempt_id 必须是字符串") + if not isinstance(self.contract_hash, str): + raise TypeError("ProofSubstrate.contract_hash 必须是字符串") + + +@dataclass(frozen=True) +class Proof: + id: str + kind: ProofKind + subject: str + substrate: ProofSubstrate + procedure: str + bytes_sha256: str + generation: str + status: ProofStatus + produced_at: str = "" + declared_key_ids: tuple[str, ...] = () + policy_require_signed: tuple[tuple[str, str], ...] = () + decay_reason: str = "" + observed_at: str = "" + + def __post_init__(self) -> None: + if not isinstance(self.kind, ProofKind): + raise TypeError("Proof.kind 必须是 ProofKind") + if not isinstance(self.status, ProofStatus): + raise TypeError("Proof.status 必须是 ProofStatus") + if not isinstance(self.substrate, ProofSubstrate): + raise TypeError("Proof.substrate 必须是 ProofSubstrate") + if not self.id or not self.subject: + raise ValidationError("Proof id 与 subject 不能为空") + object.__setattr__(self, "declared_key_ids", _frozen_strings(self.declared_key_ids, "Proof.declared_key_ids")) + object.__setattr__( + self, + "policy_require_signed", + _frozen_pairs(self.policy_require_signed, "Proof.policy_require_signed"), + ) + for value, label in ( + (self.procedure, "Proof.procedure"), + (self.bytes_sha256, "Proof.bytes_sha256"), + (self.generation, "Proof.generation"), + (self.produced_at, "Proof.produced_at"), + (self.decay_reason, "Proof.decay_reason"), + (self.observed_at, "Proof.observed_at"), + ): + if not isinstance(value, str): + raise TypeError(f"{label} 必须是字符串") + + +@dataclass(frozen=True) +class ObservedSubstrate: + """Caller-injected current bytes. decay() never reads the workspace.""" + + bytes_sha256: str = "" + argv_sha256: str = "" + present: bool = True + + +@dataclass(frozen=True) +class DecayDecision: + status: ProofStatus + reason: str + observed_at: str diff --git a/src/dyro/proof/project.py b/src/dyro/proof/project.py new file mode 100644 index 0000000..ebbec1d --- /dev/null +++ b/src/dyro/proof/project.py @@ -0,0 +1,83 @@ +"""Shared Proof projection for text and JSON. Never claims procedure replay.""" + +from __future__ import annotations + +import json + +from .models import Proof, ProofStatus + +VERIFY_EXIT_OK = 0 +VERIFY_EXIT_DECAYED = 1 +VERIFY_EXIT_ERROR = 2 +VERIFY_EXIT_INCONCLUSIVE = 3 + + +def proof_payload(proof: Proof, *, procedure_reproduced: bool = False) -> dict[str, object]: + if procedure_reproduced: + raise ValueError("未 replay 不得声称 procedure_reproduced") + return { + "id": proof.id, + "kind": proof.kind.value, + "subject": proof.subject, + "status": proof.status.value, + "generation": proof.generation, + "procedure": proof.procedure, + "bytes_sha256": proof.bytes_sha256, + "produced_at": proof.produced_at, + "decay_reason": proof.decay_reason, + "observed_at": proof.observed_at, + "declared_key_ids": list(proof.declared_key_ids), + "policy_require_signed": dict(proof.policy_require_signed), + "substrate": { + "repo_heads": dict(proof.substrate.repo_heads), + "plan_sha256": proof.substrate.plan_sha256, + "attempt_id": proof.substrate.attempt_id, + "contract_hash": proof.substrate.contract_hash, + "extra": dict(proof.substrate.extra), + }, + "procedure_reproduced": False, + } + + +def proofs_payload(proofs: tuple[Proof, ...], *, mode: str = "rebind") -> dict[str, object]: + counts = {status.value: 0 for status in ProofStatus} + for proof in proofs: + counts[proof.status.value] += 1 + payload: dict[str, object] = { + "schema_version": 1, + "mode": mode, + "proofs": [proof_payload(proof) for proof in proofs], + "summary": counts, + } + if mode == "integrity": + payload["conclusion"] = "integrity" + payload["merge_equivalent"] = False + return payload + + +def render_proofs_json(proofs: tuple[Proof, ...], *, mode: str = "rebind") -> str: + return json.dumps(proofs_payload(proofs, mode=mode), ensure_ascii=False, sort_keys=True, indent=2) + + +def render_proofs_text(proofs: tuple[Proof, ...], *, mode: str = "") -> str: + lines: list[str] = [] + if mode: + lines.append(f"mode: {mode}") + lines.append("procedure_reproduced: false") + if not proofs: + lines.append("暂无 Proof") + return "\n".join(lines) + "\n" + for proof in proofs: + lines.append(f"{proof.kind.value:20} {proof.status.value:13} {proof.subject:24} {proof.id}") + if proof.decay_reason: + lines.append(f" {proof.decay_reason}") + return "\n".join(lines) + "\n" + + +def verify_exit_code(proofs: tuple[Proof, ...]) -> int: + statuses = {proof.status for proof in proofs} + if ProofStatus.DECAYED in statuses: + return VERIFY_EXIT_DECAYED + if ProofStatus.INCONCLUSIVE in statuses or ProofStatus.REVOKED in statuses: + return VERIFY_EXIT_INCONCLUSIVE + return VERIFY_EXIT_OK diff --git a/src/dyro/tasks.py b/src/dyro/tasks.py index 0c422de..c2ae970 100644 --- a/src/dyro/tasks.py +++ b/src/dyro/tasks.py @@ -629,6 +629,8 @@ def _schedule_block_reason(reason: str, facts: dict[str, str]) -> str: return f"任务与活跃任务 {facts.get('active_task_ids', '')} 共用冲突组 {facts.get('conflict_group', '')}" if reason == "EXTERNAL_CLAIM_ACTIVE": return "任务已有有效的外部执行 claim" + if reason == "PROOF_DECAYED": + return "任务的 merge 绑定已衰减(PROOF_DECAYED),当前工作区无法按原复核合并" return reason @@ -1174,6 +1176,9 @@ def _adapter_argv(config: Config, agent: str, mode: str, *, workspace: Path, pro adapter = config.adapters[agent] except KeyError as exc: raise ValidationError(f"任务 {task.id} 使用的 Agent adapter 未配置:{agent}") from exc + card = getattr(config, "capabilities", {}).get(agent) + if card is not None and mode == "write" and "execute" not in getattr(card, "intents", ()): + raise DyroError(f"Capability {agent} 未授予 execute,不能作为任务执行器") template = adapter.write if mode == "write" else adapter.read return expand_argv(template, workspace=workspace, root=config.root, prompt=prompt, task=task.id, line=task.line) @@ -2056,10 +2061,10 @@ def merge_task(config: Config, task: Task, *, push: bool = False, dry_run: bool raise DyroError(f"仅 done 任务可合并:{task.id}") if not _valid_review_acceptance(config, task): raise DyroError( - "仅具有有效的独立复核、当前回执与任务 HEAD 绑定的 done 任务可合并" + "仅具有有效的独立复核、当前回执与任务 HEAD 绑定的 done 任务可合并(PROOF_DECAYED)" ) if config.policy.require_external_signoff and not _valid_external_signoff(config, task): - raise DyroError("当前 Profile 要求有效的外部签收后才能合并") + raise DyroError("当前 Profile 要求有效的外部签收后才能合并(PROOF_DECAYED)") _merge_task_repositories(config, task, push=push, dry_run=dry_run) diff --git a/tests/test_capability.py b/tests/test_capability.py new file mode 100644 index 0000000..cba19cd --- /dev/null +++ b/tests/test_capability.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +import json +import os +import stat +import tempfile +import unittest + +from dyro.capability import discover_unintegrated, runtime_cards +from dyro.cli import main +from dyro.config import load +from dyro.errors import DyroError, ValidationError +from dyro.tasks import load_task, run_task, task_template +from dyro.workspace import create_line, doctor + +from .support import WorkspaceCase + + +class CapabilityCardTests(WorkspaceCase): + def test_adapters_upgrade_to_cards_with_fail_closed_defaults(self) -> None: + config = load(self.root) + cards = runtime_cards(config) + self.assertIn("noop", cards) + card = cards["noop"] + self.assertEqual(card.source, "adapter") + self.assertEqual(card.attested_isolation.value, "cwd") + self.assertEqual(card.cannot_prove, ("done", "merge")) + self.assertIn("execute", card.intents) + + def test_capabilities_table_parses_and_synthesizes_adapter(self) -> None: + path = self.root / "dyro.toml" + path.write_text( + path.read_text(encoding="utf-8") + + """ + +[[capabilities]] +id = "reviewer" +kind = "agent" +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +can_prove = ["review_verdict"] +""", + encoding="utf-8", + ) + config = load(self.root) + self.assertIn("reviewer", config.adapters) + card = config.capabilities["reviewer"] + self.assertEqual(card.source, "capabilities") + self.assertIn("done", card.cannot_prove) + self.assertIn("merge", card.cannot_prove) + self.assertEqual(card.can_prove, ("review_verdict",)) + + def test_adapter_and_capability_id_conflict_is_fail_closed(self) -> None: + path = self.root / "dyro.toml" + path.write_text( + path.read_text(encoding="utf-8") + + """ + +[[capabilities]] +id = "noop" +kind = "agent" +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +""", + encoding="utf-8", + ) + with self.assertRaisesRegex(ValidationError, "ID 冲突"): + load(self.root) + + def test_rejects_env_and_invalid_can_prove(self) -> None: + path = self.root / "dyro.toml" + original = path.read_text(encoding="utf-8") + path.write_text( + original + + """ + +[[capabilities]] +id = "secretive" +kind = "agent" +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +env = { API_TOKEN = "nope" } +""", + encoding="utf-8", + ) + with self.assertRaisesRegex(ValidationError, "环境变量"): + load(self.root) + path.write_text( + original + + """ + +[[capabilities]] +id = "badprove" +kind = "agent" +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +can_prove = ["dispatch"] +""", + encoding="utf-8", + ) + with self.assertRaisesRegex(ValidationError, "Proof kind"): + load(self.root) + + def test_polyrepo_example_still_doctors_without_toml_changes(self) -> None: + root = Path(__file__).resolve().parents[1] / "examples" / "polyrepo" + config = load(root) + self.assertIn("codex", config.adapters) + self.assertEqual(config.capabilities["codex"].source, "adapter") + findings = doctor(config) + self.assertTrue(findings) + self.assertTrue(any("workspace root" in item for item in findings)) + + def test_capability_cli_add_and_test_noop(self) -> None: + stdout = StringIO() + with redirect_stdout(stdout): + main(["--root", str(self.root), "capability", "add", "local-true", "--preset", "noop"]) + self.assertIn("Capability Card", stdout.getvalue()) + config = load(self.root) + self.assertEqual(config.capabilities["local-true"].source, "capabilities") + self.assertIn("local-true", config.adapters) + code_out = StringIO() + with redirect_stdout(code_out): + main(["--root", str(self.root), "capability", "test", "local-true", "--format", "json"]) + report = json.loads(code_out.getvalue()) + self.assertTrue(report["executable"]) + self.assertEqual(report["hook_surface"], "") + self.assertIn("done", report["cannot_prove"]) + + def test_capability_add_conflicts_with_existing_adapter(self) -> None: + stderr = StringIO() + with redirect_stderr(stderr), self.assertRaises(SystemExit) as raised: + main(["--root", str(self.root), "capability", "add", "noop", "--preset", "noop"]) + self.assertEqual(raised.exception.code, 2) + self.assertIn("已配置", stderr.getvalue()) + + def test_discovered_unintegrated_cannot_execute(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + fake_bin = Path(tmp) + fake = fake_bin / "opencode" + fake.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + fake.chmod(fake.stat().st_mode | stat.S_IEXEC) + env = os.environ.copy() + env["PATH"] = f"{fake_bin}{os.pathsep}{env.get('PATH', '')}" + previous = os.environ.get("PATH") + os.environ["PATH"] = env["PATH"] + try: + config = load(self.root) + discovered = discover_unintegrated(config) + self.assertTrue(any(item.id == "opencode" for item in discovered)) + self.assertNotIn("opencode", runtime_cards(config)) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / "TASK-OPEN" + task_path.mkdir(parents=True) + spec = task_template("TASK-OPEN", "unintegrated", "alpha", "api", "services/api") + spec = spec.replace('agent = "codex"', 'agent = "opencode"') + task_path.joinpath("task.toml").write_text(spec, encoding="utf-8") + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") + with self.assertRaisesRegex(ValidationError, "未配置"): + run_task(config, load_task(config, "TASK-OPEN")) + finally: + if previous is None: + os.environ.pop("PATH", None) + else: + os.environ["PATH"] = previous + + def test_observe_only_card_cannot_be_task_executor(self) -> None: + path = self.root / "dyro.toml" + path.write_text( + path.read_text(encoding="utf-8") + + """ + +[[capabilities]] +id = "watcher" +kind = "agent" +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +intents = ["observe"] +""", + encoding="utf-8", + ) + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / "TASK-WATCH" + task_path.mkdir(parents=True) + spec = task_template("TASK-WATCH", "observe only", "alpha", "api", "services/api") + spec = spec.replace('agent = "codex"', 'agent = "watcher"') + task_path.joinpath("task.toml").write_text(spec, encoding="utf-8") + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") + with self.assertRaisesRegex(DyroError, "未授予 execute"): + run_task(config, load_task(config, "TASK-WATCH")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_console_overview.py b/tests/test_console_overview.py index 8964bc9..3c5f47e 100644 --- a/tests/test_console_overview.py +++ b/tests/test_console_overview.py @@ -40,6 +40,7 @@ def _snapshot( workspace_revision="a" * 64, source_digests=(("tasks", "b" * 64),), completeness="partial" if partial else "complete", + proof_inspection="not_inspected", lines=( WorkspaceLineObservation( id="alpha", diff --git a/tests/test_console_read_model.py b/tests/test_console_read_model.py index 7791871..17dc568 100644 --- a/tests/test_console_read_model.py +++ b/tests/test_console_read_model.py @@ -157,6 +157,23 @@ def test_summary_capture_does_not_start_an_integration_probe(self) -> None: snapshot.objectives[0].blocked_actions[0].reason, "TASK_INTEGRATION_PENDING", ) + self.assertEqual(snapshot.proof_inspection, "not_inspected") + envelope = workspace_envelope(snapshot) + self.assertEqual(envelope["data"]["workspace"]["proof_inspection"], "not_inspected") + + def test_summary_capture_does_not_evaluate_proofs(self) -> None: + self._objective() + with patch( + "dyro.proof.evaluate.evaluate_proofs", + side_effect=AssertionError("Console summary must not evaluate Proofs"), + ) as evaluate: + snapshot = capture_workspace_read_snapshot( + self.config, + clock=lambda: datetime(2026, 8, 4, 12, 0, tzinfo=timezone.utc), + ) + self.assertFalse(evaluate.called) + self.assertEqual(snapshot.proof_inspection, "not_inspected") + self.assertFalse(any(item.reason == "PROOF_DECAYED" for item in snapshot.objectives[0].attention)) def test_envelope_returns_a_deeply_fresh_json_value(self) -> None: envelope = ConsoleEnvelope( diff --git a/tests/test_host.py b/tests/test_host.py new file mode 100644 index 0000000..5a41ed0 --- /dev/null +++ b/tests/test_host.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +from contextlib import redirect_stderr, redirect_stdout +from datetime import datetime, timezone +from io import StringIO +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from dyro.cli import main +from dyro.config import load +from dyro.continuation.store import create_objective +from dyro.continuation.supervision import apply_supervised_wave, build_supervised_wave +from dyro.errors import DyroError +from dyro.host import ( + AUTHORITY_SKILL_AND_HOOK, + AUTHORITY_SKILL_ONLY, + assert_projections_allow_mutation, + compile_hosts, + inspect_projections, + projection_root, +) +from dyro.host.compile import HOOK_NAME, SKILL_NAME +from dyro.tasks import task_template +from dyro.workspace import create_line + +from .support import WorkspaceCase + + +def _append_toml(root: Path, fragment: str) -> None: + path = root / "dyro.toml" + path.write_text(path.read_text(encoding="utf-8") + fragment, encoding="utf-8") + + +def _strip_noop_adapter(root: Path) -> None: + path = root / "dyro.toml" + text = path.read_text(encoding="utf-8") + path.write_text( + text.replace( + """ +[adapters.noop] +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +""", + "\n", + ), + encoding="utf-8", + ) + + +def _objective_contract() -> str: + return """schema_version = 1 +id = "release" +title = "Release" +line = "alpha" +targets = ["TASK-A"] + +[continuation] +requested_mode = "supervised" +operations = ["execute", "review"] + +[budget] +max_actions = 20 +max_attempts_per_task = 2 +max_failures = 3 +max_no_progress_cycles = 2 +max_parallel = 1 +""" + + +class HostCompilerTests(WorkspaceCase): + def _skill(self, host: str = "cli", *, user: bool = False) -> str: + root = projection_root(load(self.root), user=user) + return (root / host / SKILL_NAME).read_text(encoding="utf-8") + + def test_compile_writes_workspace_skill_without_execute_commands(self) -> None: + stdout = StringIO() + with redirect_stdout(stdout): + main(["--root", str(self.root), "host", "compile", "--format", "json"]) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["scope"], "workspace") + self.assertEqual(payload["projections"][0]["authority_projection"], AUTHORITY_SKILL_ONLY) + self.assertNotIn(str(self.root), stdout.getvalue()) + skill = self._skill() + self.assertIn("不要用 git merge 结束任务", skill) + self.assertIn("不要把测试通过写成 done", skill) + self.assertIn("`dyro next`", skill) + self.assertIn("| noop |", skill) + self.assertNotIn("dyro task", skill) + self.assertNotIn("execute_task", skill) + self.assertNotIn("/usr/bin", skill) + self.assertNotIn(str(self.root), skill) + self.assertFalse((projection_root(load(self.root), user=False) / "cli" / HOOK_NAME).exists()) + self.assertTrue((self.root / ".dyro" / "host-projections" / "cli.toml").is_file()) + + def test_no_available_card_has_no_execute_implication(self) -> None: + _strip_noop_adapter(self.root) + compile_hosts(load(self.root)) + skill = self._skill() + self.assertIn("不要执行", skill) + self.assertNotIn("| noop |", skill) + self.assertNotIn("dyro task", skill.lower()) + self.assertNotIn("execute_task", skill) + self.assertNotIn("task run", skill) + + def test_removed_card_disappears_on_recompile(self) -> None: + _append_toml( + self.root, + """ + +[adapters.extra] +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +""", + ) + compile_hosts(load(self.root)) + self.assertIn("| extra |", self._skill()) + path = self.root / "dyro.toml" + path.write_text( + path.read_text(encoding="utf-8").replace( + """ +[adapters.extra] +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +""", + "\n", + ), + encoding="utf-8", + ) + compile_hosts(load(self.root)) + self.assertNotIn("| extra |", self._skill()) + self.assertIn("| noop |", self._skill()) + + def test_opencode_fixture_without_hook_compiles_skill_only(self) -> None: + _append_toml( + self.root, + """ + +[[capabilities]] +id = "opencode-host" +kind = "tool" +hosts = ["opencode"] +intents = ["observe"] +""", + ) + projections = {item.host: item for item in compile_hosts(load(self.root))} + self.assertEqual(projections["opencode"].authority_projection, AUTHORITY_SKILL_ONLY) + self.assertEqual(projections["opencode"].hook_relpath, "") + root = projection_root(load(self.root), user=False) + self.assertFalse((root / "opencode" / HOOK_NAME).exists()) + self.assertTrue((root / "opencode" / SKILL_NAME).is_file()) + _strip_host_card(self.root) + compile_hosts(load(self.root)) + self.assertFalse((root / "opencode.toml").exists()) + self.assertFalse((root / "opencode" / SKILL_NAME).exists()) + + def test_fake_hook_surface_does_not_write_hook(self) -> None: + _append_toml( + self.root, + """ + +[[capabilities]] +id = "opencode-host" +kind = "tool" +hosts = ["opencode"] +intents = ["observe"] +hook_surface = "missing-hooks/opencode" +""", + ) + compile_hosts(load(self.root)) + root = projection_root(load(self.root), user=False) + self.assertFalse((root / "opencode" / HOOK_NAME).exists()) + manifest = (root / "opencode.toml").read_text(encoding="utf-8") + self.assertIn(AUTHORITY_SKILL_ONLY, manifest) + + def test_dot_hook_surface_does_not_write_hook(self) -> None: + _append_toml( + self.root, + """ + +[[capabilities]] +id = "opencode-host" +kind = "tool" +hosts = ["opencode"] +intents = ["observe"] +hook_surface = "." +""", + ) + compile_hosts(load(self.root)) + root = projection_root(load(self.root), user=False) + self.assertFalse((root / "opencode" / HOOK_NAME).exists()) + self.assertIn(AUTHORITY_SKILL_ONLY, (root / "opencode.toml").read_text(encoding="utf-8")) + + def test_toml_hook_surface_does_not_write_hook(self) -> None: + _append_toml( + self.root, + """ + +[[capabilities]] +id = "opencode-host" +kind = "tool" +hosts = ["opencode"] +intents = ["observe"] +hook_surface = "dyro.toml" +""", + ) + compile_hosts(load(self.root)) + root = projection_root(load(self.root), user=False) + self.assertFalse((root / "opencode" / HOOK_NAME).exists()) + self.assertIn(AUTHORITY_SKILL_ONLY, (root / "opencode.toml").read_text(encoding="utf-8")) + + def test_absolute_hook_surface_does_not_write_hook(self) -> None: + _append_toml( + self.root, + """ + +[[capabilities]] +id = "opencode-host" +kind = "tool" +hosts = ["opencode"] +intents = ["observe"] +hook_surface = "/tmp/hooks" +""", + ) + compile_hosts(load(self.root)) + root = projection_root(load(self.root), user=False) + self.assertFalse((root / "opencode" / HOOK_NAME).exists()) + + def test_proven_hook_surface_writes_deny_hook_from_intent_lattice(self) -> None: + (self.root / "hooks" / "surface").mkdir(parents=True) + _append_toml( + self.root, + """ + +[[capabilities]] +id = "opencode-host" +kind = "tool" +hosts = ["opencode"] +intents = ["observe"] +hook_surface = "hooks/surface" +""", + ) + projections = {item.host: item for item in compile_hosts(load(self.root))} + self.assertEqual(projections["opencode"].authority_projection, AUTHORITY_SKILL_AND_HOOK) + hook_path = projection_root(load(self.root), user=False) / "opencode" / HOOK_NAME + hook = json.loads(hook_path.read_text(encoding="utf-8")) + self.assertEqual(hook["denied_intents"], ["integrate", "publish"]) + self.assertEqual(hook["denied_paths"], [".dyro/"]) + self.assertNotIn("sandbox", hook_path.read_text(encoding="utf-8").lower()) + report = inspect_projections(load(self.root)) + self.assertTrue(report.ok) + hook_path.unlink() + stale = inspect_projections(load(self.root)) + self.assertFalse(stale.ok) + self.assertTrue(any(item.code == "MISSING_HOOK" for item in stale.findings)) + + def test_card_change_expires_projection_until_recompile(self) -> None: + compile_hosts(load(self.root)) + self.assertTrue(inspect_projections(load(self.root)).ok) + _append_toml( + self.root, + """ + +[adapters.extra] +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +""", + ) + expired = inspect_projections(load(self.root)) + self.assertFalse(expired.ok) + self.assertTrue(any(item.code == "EXPIRED" for item in expired.findings)) + compile_hosts(load(self.root)) + self.assertTrue(inspect_projections(load(self.root)).ok) + + def test_doctor_fails_on_one_byte_tamper_and_compile_repairs(self) -> None: + compile_hosts(load(self.root)) + skill_path = projection_root(load(self.root), user=False) / "cli" / SKILL_NAME + skill_path.write_text(skill_path.read_text(encoding="utf-8") + " ", encoding="utf-8") + stderr = StringIO() + stdout = StringIO() + with redirect_stdout(stdout), redirect_stderr(stderr), self.assertRaises(SystemExit) as raised: + main(["--root", str(self.root), "host", "doctor", "--format", "json"]) + self.assertEqual(raised.exception.code, 2) + payload = json.loads(stdout.getvalue()) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["scope"], "workspace") + self.assertTrue(any(item["code"] == "TAMPERED" for item in payload["findings"])) + compile_hosts(load(self.root)) + repaired = inspect_projections(load(self.root)) + self.assertTrue(repaired.ok) + self.assertTrue(repaired.compiled) + + def test_user_scope_writes_registry_home_and_doctor_reports_user(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-home-") as home: + previous = os.environ.get("DYRO_HOME") + os.environ["DYRO_HOME"] = home + try: + main(["--root", str(self.root), "host", "compile", "--user"]) + expected = Path(home) / "host-projections" / "test-workspace" / "cli" / SKILL_NAME + self.assertTrue(expected.is_file()) + self.assertFalse((self.root / ".dyro" / "host-projections" / "cli" / SKILL_NAME).exists()) + stdout = StringIO() + with redirect_stdout(stdout): + main(["--root", str(self.root), "host", "doctor", "--user", "--format", "json"]) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["scope"], "user") + self.assertTrue(payload["ok"]) + workspace = inspect_projections(load(self.root), user=False) + self.assertFalse(workspace.compiled) + finally: + if previous is None: + os.environ.pop("DYRO_HOME", None) + else: + os.environ["DYRO_HOME"] = previous + + def test_orphan_skill_without_manifest_blocks_apply(self) -> None: + compile_hosts(load(self.root)) + root = projection_root(load(self.root), user=False) + (root / "cli.toml").unlink() + report = inspect_projections(load(self.root)) + self.assertTrue(report.compiled) + self.assertFalse(report.ok) + self.assertTrue(any(item.code == "TAMPERED" for item in report.findings)) + with self.assertRaisesRegex(DyroError, "plan-only"): + assert_projections_allow_mutation(load(self.root)) + + def test_never_compiled_does_not_block_apply(self) -> None: + assert_projections_allow_mutation(load(self.root)) + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + self._write_task(config) + create_objective(config, _objective_contract()) + now = datetime(2026, 8, 15, 4, 0, tzinfo=timezone.utc) + wave = build_supervised_wave(config, "release", clock=lambda: now) + with patch("dyro.continuation.supervision.run_task", return_value="review"): + outcomes = apply_supervised_wave(config, wave, clock=lambda: now) + self.assertEqual(len(outcomes), 1) + + def test_stale_projection_fail_closes_apply_to_plan_only(self) -> None: + compile_hosts(load(self.root)) + skill_path = projection_root(load(self.root), user=False) / "cli" / SKILL_NAME + skill_path.write_text(skill_path.read_text(encoding="utf-8") + "x", encoding="utf-8") + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + self._write_task(config) + create_objective(config, _objective_contract()) + now = datetime(2026, 8, 15, 4, 0, tzinfo=timezone.utc) + wave = build_supervised_wave(config, "release", clock=lambda: now) + with self.assertRaisesRegex(DyroError, "plan-only"): + apply_supervised_wave(config, wave, clock=lambda: now) + + def test_host_help_does_not_call_hook_a_sandbox(self) -> None: + stdout = StringIO() + with redirect_stdout(stdout), self.assertRaises(SystemExit) as raised: + main(["host", "--help"]) + self.assertEqual(raised.exception.code, 0) + help_text = stdout.getvalue() + self.assertIn("不是沙箱", help_text) + self.assertIn("不是隔离", help_text) + + def _write_task(self, config) -> None: + directory = config.task_specs_dir / "TASK-A" + directory.mkdir(parents=True) + directory.joinpath("task.toml").write_text( + task_template("TASK-A", "Task A", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ), + encoding="utf-8", + ) + directory.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + + +def _strip_host_card(root: Path) -> None: + path = root / "dyro.toml" + text = path.read_text(encoding="utf-8") + marker = "\n[[capabilities]]\nid = \"opencode-host\"" + index = text.find(marker) + if index < 0: + raise AssertionError("opencode-host card missing") + path.write_text(text[:index] + "\n", encoding="utf-8") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_proof_a1_boundary.py b/tests/test_proof_a1_boundary.py new file mode 100644 index 0000000..a989ff6 --- /dev/null +++ b/tests/test_proof_a1_boundary.py @@ -0,0 +1,59 @@ +"""A1 lock: merge / dispatch predicates must not import or name dyro.proof.""" + +from __future__ import annotations + +import ast +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +TASKS = ROOT / "src" / "dyro" / "tasks.py" +BANNED_MODULES = {"dyro.proof"} +BANNED_NAMES = {"list_proofs", "evaluate_proofs", "evaluate_proof", "verify_bundle"} +PROTECTED = {"merge_task", "check_dispatchable", "_prepare_merge"} + + +def _function_nodes(tree: ast.AST) -> dict[str, ast.FunctionDef | ast.AsyncFunctionDef]: + found: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {} + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in PROTECTED: + found[node.name] = node + return found + + +class ProofA1BoundaryTests(unittest.TestCase): + def test_tasks_module_does_not_import_proof(self) -> None: + source = TASKS.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(TASKS)) + offenders: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "dyro.proof" or alias.name.startswith("dyro.proof."): + offenders.append(alias.name) + elif isinstance(node, ast.ImportFrom): + mod = node.module or "" + if mod == "dyro.proof" or mod.startswith("dyro.proof."): + offenders.append(mod) + if mod in {".proof", "proof"}: + offenders.append(mod) + self.assertEqual(offenders, []) + self.assertNotIn("dyro.proof", source) + + def test_merge_and_dispatch_do_not_name_proof_apis(self) -> None: + tree = ast.parse(TASKS.read_text(encoding="utf-8"), filename=str(TASKS)) + functions = _function_nodes(tree) + self.assertEqual(set(functions), PROTECTED) + offenders: list[str] = [] + for name, fn in functions.items(): + for node in ast.walk(fn): + if isinstance(node, ast.Name) and node.id in BANNED_NAMES: + offenders.append(f"{name}:{node.id}") + if isinstance(node, ast.Attribute) and node.attr in BANNED_NAMES: + offenders.append(f"{name}:{node.attr}") + self.assertEqual(offenders, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_proof_bundle.py b/tests/test_proof_bundle.py new file mode 100644 index 0000000..14ff334 --- /dev/null +++ b/tests/test_proof_bundle.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +import zipfile + +from dyro.proof.bundle import export_bundle, proof_from_payload, verify_bundle +from dyro.proof.models import Proof, ProofKind, ProofStatus, ProofSubstrate +from dyro.proof.project import VERIFY_EXIT_INCONCLUSIVE, verify_exit_code + + +def _git_head(root: Path) -> tuple[Path, str]: + work = root / "work" + work.mkdir() + subprocess.run(["git", "init", "-b", "main"], cwd=work, check=True, stdout=subprocess.PIPE) + subprocess.run(["git", "config", "user.name", "T"], cwd=work, check=True) + subprocess.run(["git", "config", "user.email", "t@example.com"], cwd=work, check=True) + (work / "README").write_text("x\n", encoding="utf-8") + subprocess.run(["git", "add", "README"], cwd=work, check=True) + subprocess.run(["git", "commit", "-m", "x"], cwd=work, check=True, stdout=subprocess.PIPE) + sha = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=work, + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.strip() + return work, sha + + +def _proof( + *, + kind: ProofKind = ProofKind.REVIEW_VERDICT, + require_signed: bool = False, + heads: tuple[tuple[str, str], ...] = (), + status: ProofStatus = ProofStatus.INCONCLUSIVE, + decay_reason: str = "", +) -> Proof: + policy = () + if kind is ProofKind.REVIEW_VERDICT: + policy = (("require_signed_review", "true" if require_signed else "false"),) + elif kind is ProofKind.SIGNOFF: + policy = (("require_signed_signoff", "true" if require_signed else "false"),) + return Proof( + id="a" * 64, + kind=kind, + subject="TASK-A", + substrate=ProofSubstrate(repo_heads=heads, plan_sha256="plan"), + procedure="review.md rebind", + bytes_sha256="b" * 64, + generation="1", + status=status, + decay_reason=decay_reason, + policy_require_signed=policy, + declared_key_ids=(), + ) + + +class ProofBundleIntegrityTests(unittest.TestCase): + def test_signed_policy_without_declared_keys_is_inconclusive(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: + bundle = Path(tmp) / "signed.zip" + export_bundle((_proof(require_signed=True),), bundle) + proofs = verify_bundle(bundle, git_dirs=()) + self.assertEqual(proofs[0].status, ProofStatus.INCONCLUSIVE) + self.assertEqual(proofs[0].decay_reason, "missing_declared_keys") + self.assertEqual(verify_exit_code(proofs), VERIFY_EXIT_INCONCLUSIVE) + + def test_wrong_schema_version_is_inconclusive(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: + bundle = Path(tmp) / "v2.zip" + with zipfile.ZipFile(bundle, "w") as archive: + archive.writestr( + "manifest.json", + json.dumps({"kind": "dyro.proof.bundle", "schema_version": 2, "proof_ids": []}) + "\n", + ) + proofs = verify_bundle(bundle, git_dirs=()) + self.assertEqual(proofs[0].status, ProofStatus.INCONCLUSIVE) + self.assertNotEqual(proofs[0].status, ProofStatus.LIVE) + + def test_payload_round_trip_preserves_pins(self) -> None: + proof = _proof(heads=(("api", "abc1234"),)) + restored = proof_from_payload( + { + "id": proof.id, + "kind": proof.kind.value, + "subject": proof.subject, + "procedure": proof.procedure, + "bytes_sha256": proof.bytes_sha256, + "generation": proof.generation, + "declared_key_ids": [], + "policy_require_signed": {"require_signed_review": "false"}, + "substrate": { + "repo_heads": {"api": "abc1234"}, + "plan_sha256": "plan", + "attempt_id": "", + "contract_hash": "", + "extra": {}, + }, + } + ) + self.assertEqual(restored.substrate.repo_heads, (("api", "abc1234"),)) + + def test_stranger_script_uses_caller_git_objects(self) -> None: + script = Path(__file__).resolve().parents[1] / "tools" / "verify_bundle_stranger.py" + completed = subprocess.run( + [sys.executable, str(script), sys.executable, "-m", "dyro"], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + payload = json.loads(completed.stdout) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["mode"], "integrity") + + def test_headless_receipt_without_git_dir_is_not_live(self) -> None: + receipt = Proof( + id="c" * 64, + kind=ProofKind.ACTION_RECEIPT, + subject="OBJ", + substrate=ProofSubstrate(plan_sha256="plan", attempt_id="act"), + procedure="action journal receipt bytes", + bytes_sha256="d" * 64, + generation="1", + status=ProofStatus.INCONCLUSIVE, + ) + with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: + bundle = Path(tmp) / "headless.zip" + export_bundle((receipt,), bundle) + proofs = verify_bundle(bundle, git_dirs=()) + self.assertEqual(proofs[0].status, ProofStatus.INCONCLUSIVE) + self.assertEqual(proofs[0].decay_reason, "missing_git_objects") + self.assertIsNot(proofs[0].status, ProofStatus.LIVE) + self.assertEqual(verify_exit_code(proofs), VERIFY_EXIT_INCONCLUSIVE) + + def test_missing_proof_digest_is_not_live(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: + root = Path(tmp) + work, sha = _git_head(root) + bundle = root / "digest.zip" + export_bundle((_proof(heads=(("api", sha),)),), bundle) + with zipfile.ZipFile(bundle) as archive: + manifest = json.loads(archive.read("manifest.json")) + body = archive.read(f"proofs/{'a' * 64}.json") + payload = json.loads(body) + payload["procedure"] = "tampered" + rewritten = root / "rewritten.zip" + manifest["proof_sha256"] = {} + with zipfile.ZipFile(rewritten, "w") as archive: + archive.writestr("manifest.json", json.dumps(manifest) + "\n") + archive.writestr(f"proofs/{'a' * 64}.json", json.dumps(payload) + "\n") + proofs = verify_bundle(rewritten, git_dirs=(work,)) + self.assertTrue(all(item.status is not ProofStatus.LIVE for item in proofs)) + self.assertEqual(proofs[0].decay_reason, "bundle_bytes_mismatch") + + def test_json_boolean_require_signed_without_keys_is_inconclusive(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: + root = Path(tmp) + work, sha = _git_head(root) + proof_id = "e" * 64 + payload = { + "bytes_sha256": "f" * 64, + "declared_key_ids": [], + "generation": "1", + "id": proof_id, + "kind": "review_verdict", + "policy_require_signed": {"require_signed_review": True}, + "procedure": "review.md rebind", + "subject": "TASK-A", + "substrate": { + "attempt_id": "", + "contract_hash": "", + "extra": {}, + "plan_sha256": "plan", + "repo_heads": {"api": sha}, + }, + } + body = json.dumps(payload, sort_keys=True) + "\n" + bundle = root / "bool.zip" + manifest = { + "kind": "dyro.proof.bundle", + "proof_ids": [proof_id], + "proof_sha256": {proof_id: hashlib.sha256(body.encode("utf-8")).hexdigest()}, + "schema_version": 1, + } + with zipfile.ZipFile(bundle, "w") as archive: + archive.writestr("manifest.json", json.dumps(manifest) + "\n") + archive.writestr(f"proofs/{proof_id}.json", body) + proofs = verify_bundle(bundle, git_dirs=(work,)) + self.assertEqual(proofs[0].status, ProofStatus.INCONCLUSIVE) + self.assertEqual(proofs[0].decay_reason, "missing_declared_keys") + self.assertIsNot(proofs[0].status, ProofStatus.LIVE) + + def test_gate_log_ignores_workspace_signed_review_policy(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: + root = Path(tmp) + work, sha = _git_head(root) + proof = Proof( + id="a" * 64, + kind=ProofKind.GATE_LOG, + subject="TASK-A", + substrate=ProofSubstrate(repo_heads=(("api", sha),), plan_sha256="plan"), + procedure="logs/gate-n.log", + bytes_sha256="b" * 64, + generation="1", + status=ProofStatus.INCONCLUSIVE, + policy_require_signed=(("require_signed_review", "true"),), + declared_key_ids=(), + ) + bundle = root / "gate.zip" + export_bundle((proof,), bundle) + proofs = verify_bundle(bundle, git_dirs=(work,)) + self.assertEqual(proofs[0].status, ProofStatus.LIVE) + + def test_export_strips_workspace_rebind_status(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: + bundle = Path(tmp) / "portable.zip" + export_bundle( + (_proof(status=ProofStatus.DECAYED, decay_reason="review_acceptance"),), + bundle, + ) + with zipfile.ZipFile(bundle) as archive: + payload = json.loads(archive.read(f"proofs/{'a' * 64}.json")) + self.assertEqual(payload["status"], "inconclusive") + self.assertEqual(payload["decay_reason"], "") + self.assertEqual(payload["observed_at"], "") + + def test_evidence_markers_with_manifest_are_inconclusive(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: + bundle = Path(tmp) / "mixed.zip" + with zipfile.ZipFile(bundle, "w") as archive: + archive.writestr("receipt.md", "result: DONE\n") + archive.writestr( + "manifest.json", + json.dumps({"kind": "dyro.proof.bundle", "schema_version": 1, "proof_ids": []}) + "\n", + ) + proofs = verify_bundle(bundle, git_dirs=()) + self.assertEqual(proofs[0].status, ProofStatus.INCONCLUSIVE) + self.assertEqual(proofs[0].kind, ProofKind.BUNDLE_FAILURE) + self.assertIsNot(proofs[0].status, ProofStatus.LIVE) + + def test_short_sha_is_unresolved(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: + root = Path(tmp) + work, sha = _git_head(root) + bundle = root / "short.zip" + export_bundle((_proof(heads=(("api", sha[:7]),)),), bundle) + proofs = verify_bundle(bundle, git_dirs=(work,)) + self.assertEqual(proofs[0].status, ProofStatus.INCONCLUSIVE) + self.assertEqual(proofs[0].decay_reason, "object_unresolved") + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_proof_cli.py b/tests/test_proof_cli.py new file mode 100644 index 0000000..6e33871 --- /dev/null +++ b/tests/test_proof_cli.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from pathlib import Path +import hashlib +import json +import zipfile + +from dyro.cli import main +from dyro.config import load +from dyro.proof.project import ( + VERIFY_EXIT_DECAYED, + VERIFY_EXIT_ERROR, + VERIFY_EXIT_INCONCLUSIVE, + VERIFY_EXIT_OK, +) +from dyro.provenance import review_binding +from dyro.tasks import load_task, review_task, run_task, task_template +from dyro.workspace import create_line + +from .support import WorkspaceCase + + +def _write_bound_review(task_path: Path) -> None: + receipt_hash = hashlib.sha256(task_path.joinpath("receipt.md").read_bytes()).hexdigest() + heads_hash = hashlib.sha256(task_path.joinpath("task-heads.json").read_bytes()).hexdigest() + binding = review_binding(task_path) + provenance = ( + f"attempt_id: {binding[0]}\nplan_sha256: {binding[1]}\n" if binding is not None else "" + ) + task_path.joinpath("review.md").write_text( + f"verdict: PASS\nreceipt_sha256: {receipt_hash}\ntask_heads_sha256: {heads_hash}\n{provenance}", + encoding="utf-8", + ) + + +class ProofCliTests(WorkspaceCase): + def _reviewed_task(self, task_id: str = "TASK-CLI"): + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / task_id + task_path.mkdir(parents=True) + task_path.joinpath("task.toml").write_text( + task_template(task_id, "cli", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ), + encoding="utf-8", + ) + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") + task = load_task(config, task_id) + self.assertEqual(run_task(config, task), "review") + _write_bound_review(task_path) + self.assertEqual(review_task(config, task), "done") + return task_id + + def _run(self, argv: list[str]) -> tuple[int, str, str]: + stdout = StringIO() + stderr = StringIO() + with redirect_stdout(stdout), redirect_stderr(stderr): + try: + main(["--root", str(self.root), *argv]) + code = 0 + except SystemExit as exc: + code = 0 if exc.code is None else int(exc.code) + return code, stdout.getvalue(), stderr.getvalue() + + def test_list_and_verify_rebind_without_gate_side_effects(self) -> None: + task_id = self._reviewed_task() + logs_before = list((self.root / ".dyro").rglob("ledger.jsonl")) + ledger = self.root / ".dyro/ledger.jsonl" + before = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" + code, out, _err = self._run(["proof", "list", "--task", task_id, "--format", "json"]) + self.assertEqual(code, 0) + payload = json.loads(out) + self.assertEqual(payload["schema_version"], 1) + kinds = {item["kind"] for item in payload["proofs"]} + self.assertIn("review_verdict", kinds) + self.assertNotIn("action_receipt", kinds) + self.assertTrue(all(item["procedure_reproduced"] is False for item in payload["proofs"])) + review = next(item for item in payload["proofs"] if item["kind"] == "review_verdict") + self.assertEqual(review["status"], "live") + + code, verify_out, _ = self._run( + ["proof", "verify", review["id"], "--format", "json"] + ) + self.assertEqual(code, VERIFY_EXIT_OK) + verified = json.loads(verify_out) + self.assertEqual(verified["mode"], "rebind") + self.assertFalse(verified["proofs"][0]["procedure_reproduced"]) + after = ledger.read_text(encoding="utf-8") if ledger.is_file() else "" + self.assertEqual(before, after) + self.assertEqual(logs_before, list((self.root / ".dyro").rglob("ledger.jsonl"))) + + def test_verify_decayed_exit_code_and_rerun_refused(self) -> None: + task_id = self._reviewed_task("TASK-DECAY-CLI") + task_dir = load(self.root).task_specs_dir / task_id + task_dir.joinpath("review.md").write_text("verdict: PASS\n", encoding="utf-8") + code, out, _ = self._run(["proof", "verify", "--task", task_id, "--format", "json"]) + self.assertEqual(code, VERIFY_EXIT_DECAYED) + payload = json.loads(out) + review = next(item for item in payload["proofs"] if item["kind"] == "review_verdict") + self.assertEqual(review["status"], "decayed") + + code, _out, err = self._run(["proof", "verify", "--rerun-procedure", "--task", task_id]) + self.assertEqual(code, VERIFY_EXIT_ERROR) + self.assertIn("未提供隔离重跑", err) + + def test_export_schema_is_frozen_and_contains_no_git_objects(self) -> None: + task_id = self._reviewed_task("TASK-EXPORT") + bundle = self.root / "out" / "proofs.zip" + code, out, _ = self._run(["proof", "export", "--task", task_id, "--bundle", str(bundle)]) + self.assertEqual(code, 0) + self.assertIn("已导出", out) + self.assertNotIn("experimental", out) + self.assertTrue(bundle.is_file()) + with zipfile.ZipFile(bundle) as archive: + names = set(archive.namelist()) + self.assertIn("manifest.json", names) + manifest = json.loads(archive.read("manifest.json")) + self.assertEqual(manifest["schema_version"], 1) + self.assertEqual(manifest["kind"], "dyro.proof.bundle") + self.assertIn("proof_sha256", manifest) + self.assertNotIn("objects", names) + self.assertFalse(any(part in {"objects", ".git"} for name in names for part in name.split("/"))) + blob = b"".join(archive.read(name) for name in names).decode("utf-8", errors="ignore") + self.assertNotIn(str(self.root), blob) + self.assertNotIn("/usr/bin", blob) + + code, verify_out, _ = self._run( + ["proof", "verify-bundle", str(bundle), "--git-dir", str(self.anchor), "--format", "json"] + ) + self.assertEqual(code, VERIFY_EXIT_OK) + payload = json.loads(verify_out) + self.assertEqual(payload["mode"], "integrity") + self.assertEqual(payload["conclusion"], "integrity") + self.assertFalse(payload["merge_equivalent"]) + self.assertTrue(payload["proofs"]) + self.assertTrue(all(item["status"] == "live" for item in payload["proofs"])) + self.assertTrue(all(item["procedure_reproduced"] is False for item in payload["proofs"])) + with zipfile.ZipFile(bundle) as archive: + exported = json.loads(archive.read(next(name for name in archive.namelist() if name.startswith("proofs/")))) + self.assertEqual(exported["status"], "inconclusive") + + code, missing_out, _ = self._run(["proof", "verify-bundle", str(bundle), "--format", "json"]) + self.assertEqual(code, VERIFY_EXIT_INCONCLUSIVE) + missing = json.loads(missing_out) + self.assertTrue(any(item["status"] == "inconclusive" for item in missing["proofs"])) + self.assertFalse(any(item["status"] == "decayed" for item in missing["proofs"])) + + def test_objective_attention_json_includes_proof_decayed(self) -> None: + task_id = self._reviewed_task("TASK-ATTN") + load(self.root).task_specs_dir.joinpath(task_id, "review.md").write_text( + "verdict: PASS\n", encoding="utf-8" + ) + main( + [ + "--root", + str(self.root), + "objective", + "start", + "--id", + "release", + "--title", + "Release", + "--line", + "alpha", + "--targets", + task_id, + "--yes", + ] + ) + code, out, _err = self._run(["objective", "attention", "release", "--format", "json"]) + self.assertEqual(code, 0) + payload = json.loads(out) + reasons = [item["reason"] for item in payload["items"]] + self.assertIn("PROOF_DECAYED", reasons) + self.assertNotIn("argv", json.dumps(payload)) + + def test_export_proof_id_and_task_are_mutex(self) -> None: + task_id = self._reviewed_task("TASK-MUTEX") + code, out, _ = self._run(["proof", "list", "--task", task_id, "--format", "json"]) + proof_id = json.loads(out)["proofs"][0]["id"] + code, _out, err = self._run( + [ + "proof", + "export", + proof_id, + "--task", + task_id, + "--bundle", + str(self.root / "x.zip"), + ] + ) + self.assertEqual(code, VERIFY_EXIT_ERROR) + self.assertIn("互斥", err) + + def test_verify_bundle_rejects_evidence_zip_as_inconclusive(self) -> None: + evidence = self.root / "evidence.zip" + with zipfile.ZipFile(evidence, "w") as archive: + archive.writestr("receipt.md", "result: DONE\n") + archive.writestr("provenance.json", "{}\n") + archive.writestr("gates.json", '{"schema_version":1,"gates":[]}\n') + code, out, _ = self._run( + ["proof", "verify-bundle", str(evidence), "--git-dir", str(self.anchor), "--format", "json"] + ) + self.assertEqual(code, VERIFY_EXIT_INCONCLUSIVE) + payload = json.loads(out) + self.assertTrue(all(item["status"] == "inconclusive" for item in payload["proofs"])) + self.assertFalse(any(item["status"] == "live" for item in payload["proofs"])) + + def test_missing_review_file_stays_inconclusive_after_list(self) -> None: + task_id = self._reviewed_task("TASK-MISSING-REVIEW") + (load(self.root).task_specs_dir / task_id / "review.md").unlink() + code, out, _ = self._run(["proof", "verify", "--task", task_id, "--format", "json"]) + self.assertEqual(code, VERIFY_EXIT_INCONCLUSIVE) + payload = json.loads(out) + review = next(item for item in payload["proofs"] if item["kind"] == "review_verdict") + self.assertEqual(review["status"], "inconclusive") + self.assertNotEqual(review["status"], "decayed") + + def test_workspace_verify_and_verify_bundle_are_separate_conclusions(self) -> None: + from .support import shell + + task_id = self._reviewed_task("TASK-TWO-CONCLUSIONS") + bundle = self.root / "two.zip" + self.assertEqual(self._run(["proof", "export", "--task", task_id, "--bundle", str(bundle)])[0], 0) + task_dir = load(self.root).task_specs_dir / task_id + task_dir.joinpath("review.md").write_text("verdict: PASS\n", encoding="utf-8") + code, verify_out, _ = self._run(["proof", "verify", "--task", task_id, "--format", "json"]) + self.assertEqual(code, VERIFY_EXIT_DECAYED) + workspace = json.loads(verify_out) + review = next(item for item in workspace["proofs"] if item["kind"] == "review_verdict") + self.assertEqual(review["status"], "decayed") + + code, bundle_out, _ = self._run( + ["proof", "verify-bundle", str(bundle), "--git-dir", str(self.anchor), "--format", "json"] + ) + self.assertEqual(code, VERIFY_EXIT_OK) + integrity = json.loads(bundle_out) + self.assertEqual(integrity["mode"], "integrity") + self.assertFalse(any(item["status"] == "decayed" for item in integrity["proofs"])) + self.assertTrue(all(item["status"] == "live" for item in integrity["proofs"])) + + shell("git", "commit", "--allow-empty", "-m", "move head", cwd=self.anchor) + moved = shell_head(self.anchor) + heads = self.root / "current-heads.json" + heads.write_text(json.dumps({"api": moved}), encoding="utf-8") + code, moved_out, _ = self._run( + [ + "proof", + "verify-bundle", + str(bundle), + "--git-dir", + str(self.anchor), + "--current-heads", + str(heads), + "--format", + "json", + ] + ) + self.assertEqual(code, VERIFY_EXIT_DECAYED) + with_heads = json.loads(moved_out) + review = next(item for item in with_heads["proofs"] if item["kind"] == "review_verdict") + self.assertEqual(review["status"], "decayed") + + +def shell_head(repo: Path) -> str: + import subprocess + + completed = subprocess.run( + ("git", "rev-parse", "HEAD"), + cwd=repo, + check=True, + stdout=subprocess.PIPE, + text=True, + ) + return completed.stdout.strip() diff --git a/tests/test_proof_decay.py b/tests/test_proof_decay.py new file mode 100644 index 0000000..0bfd285 --- /dev/null +++ b/tests/test_proof_decay.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +import hashlib +import unittest + +from dyro.config import load +from dyro.continuation.budgets import ProgressFacts, progress_fingerprint +from dyro.continuation.models import ActionKind, AttentionKind, PlanCompletion, ReasonCode +from dyro.continuation.planner import build_continuation_plan, build_task_readiness +from dyro.continuation.snapshot import SchedulerSnapshot, SchedulerTaskSnapshot +from dyro.errors import DyroError +from dyro.graph import explain_task +from dyro.proof.decay import ( + ACTION_RECEIPT_BYTES, + CURRENT_SUBSTRATE_MISSING, + DEPENDENCY_INTEGRATED, + EXTERNAL_SIGNOFF, + GATE_ARGV, + GATE_BYTES, + LINE_PREPARE_NOT_DECAY, + PREDICATE_INCONCLUSIVE, + REVIEW_ACCEPTANCE, + STILL_BOUND, + decay, +) +from dyro.proof.derive import derive_task_proofs, list_proofs +from dyro.proof.evaluate import evaluate_proofs, live_merge_evidence +from dyro.proof.models import ObservedSubstrate, Proof, ProofKind, ProofStatus, ProofSubstrate +from dyro.provenance import review_binding +from dyro.tasks import ( + _valid_review_acceptance, + check_dispatchable, + load_task, + merge_task, + review_task, + run_task, + task_template, +) +from dyro.workspace import create_line + +from .support import WorkspaceCase, shell + +CLOCK = datetime(2026, 8, 15, 5, 20, tzinfo=timezone.utc) + + +def _proof(kind: ProofKind, *, bytes_sha256: str = "aa", extra: tuple[tuple[str, str], ...] = ()) -> Proof: + return Proof( + id="proof-" + kind.value, + kind=kind, + subject="TASK-A", + substrate=ProofSubstrate(extra=extra), + procedure="test", + bytes_sha256=bytes_sha256, + generation="g1", + status=ProofStatus.INCONCLUSIVE, + ) + + +class ProofDecayPureTests(unittest.TestCase): + def test_review_verdict_projects_valid_review_acceptance(self) -> None: + proof = _proof(ProofKind.REVIEW_VERDICT) + live = decay(proof, None, clock=CLOCK, review_ok=True) + dead = decay(proof, None, clock=CLOCK, review_ok=False) + unknown = decay(proof, None, clock=CLOCK, review_ok=None) + self.assertEqual(live.status, ProofStatus.LIVE) + self.assertEqual(live.reason, STILL_BOUND) + self.assertEqual(dead.status, ProofStatus.DECAYED) + self.assertEqual(dead.reason, REVIEW_ACCEPTANCE) + self.assertEqual(unknown.status, ProofStatus.INCONCLUSIVE) + self.assertEqual(unknown.reason, PREDICATE_INCONCLUSIVE) + + def test_signoff_projects_valid_external_signoff(self) -> None: + proof = _proof(ProofKind.SIGNOFF) + self.assertEqual(decay(proof, None, clock=CLOCK, signoff_ok=True).status, ProofStatus.LIVE) + decision = decay(proof, None, clock=CLOCK, signoff_ok=False) + self.assertEqual(decision.status, ProofStatus.DECAYED) + self.assertEqual(decision.reason, EXTERNAL_SIGNOFF) + + def test_integration_projects_assert_dependency_integrated(self) -> None: + proof = _proof(ProofKind.INTEGRATION_HEADS) + self.assertEqual(decay(proof, None, clock=CLOCK, integration_ok=True).status, ProofStatus.LIVE) + decision = decay(proof, None, clock=CLOCK, integration_ok=False) + self.assertEqual(decision.status, ProofStatus.DECAYED) + self.assertEqual(decision.reason, DEPENDENCY_INTEGRATED) + + def test_line_prepare_merge_is_not_proof_decayed(self) -> None: + proof = _proof(ProofKind.INTEGRATION_HEADS) + decision = decay( + proof, + None, + clock=CLOCK, + integration_ok=True, + line_prepare_ok=False, + ) + self.assertEqual(decision.status, ProofStatus.LIVE) + self.assertNotEqual(decision.reason, LINE_PREPARE_NOT_DECAY) + self.assertNotEqual(decision.status, ProofStatus.DECAYED) + + def test_git_revert_is_not_ancestor_break(self) -> None: + proof = _proof(ProofKind.INTEGRATION_HEADS) + decision = decay(proof, None, clock=CLOCK, integration_ok=True) + self.assertEqual(decision.status, ProofStatus.LIVE) + + def test_gate_log_hash_change_is_display_decayed(self) -> None: + proof = _proof(ProofKind.GATE_LOG, bytes_sha256="aa", extra=(("argv_sha256", "argv1"),)) + same = decay(proof, ObservedSubstrate("aa", "argv1"), clock=CLOCK) + changed = decay(proof, ObservedSubstrate("bb", "argv1"), clock=CLOCK) + argv = decay(proof, ObservedSubstrate("aa", "argv2"), clock=CLOCK) + missing = decay(proof, None, clock=CLOCK) + self.assertEqual(same.status, ProofStatus.LIVE) + self.assertEqual(changed.status, ProofStatus.DECAYED) + self.assertEqual(changed.reason, GATE_BYTES) + self.assertEqual(argv.status, ProofStatus.DECAYED) + self.assertEqual(argv.reason, GATE_ARGV) + self.assertEqual(missing.status, ProofStatus.INCONCLUSIVE) + self.assertEqual(missing.reason, CURRENT_SUBSTRATE_MISSING) + + def test_action_receipt_byte_change_is_decayed(self) -> None: + proof = _proof(ProofKind.ACTION_RECEIPT, bytes_sha256="old") + decision = decay(proof, ObservedSubstrate("new"), clock=CLOCK) + self.assertEqual(decision.status, ProofStatus.DECAYED) + self.assertEqual(decision.reason, ACTION_RECEIPT_BYTES) + + def test_clock_is_injected_and_identity_untouched(self) -> None: + proof = _proof(ProofKind.REVIEW_VERDICT) + decision = decay(proof, None, clock=CLOCK, review_ok=True) + self.assertEqual(decision.observed_at, "2026-08-15T05:20:00Z") + self.assertEqual(proof.status, ProofStatus.INCONCLUSIVE) + + def test_live_merge_evidence_changes_fingerprint_without_new_field(self) -> None: + base = ProgressFacts(task_states=(("TASK-A", "done"),), effective_evidence=(("TASK-A", "receipt-1"),)) + live = Proof( + id="b" * 64, + kind=ProofKind.REVIEW_VERDICT, + subject="TASK-A", + substrate=ProofSubstrate(), + procedure="review", + bytes_sha256="aa", + generation="g1", + status=ProofStatus.LIVE, + ) + decayed = Proof( + id="c" * 64, + kind=ProofKind.REVIEW_VERDICT, + subject="TASK-A", + substrate=ProofSubstrate(), + procedure="review", + bytes_sha256="aa", + generation="g1", + status=ProofStatus.DECAYED, + ) + triggerish = Proof( + id="d" * 64, + kind=ProofKind.ACTION_RECEIPT, + subject="release", + substrate=ProofSubstrate(), + procedure="receipt", + bytes_sha256="aa", + generation="g1", + status=ProofStatus.LIVE, + ) + with_live = ProgressFacts( + task_states=base.task_states, + effective_evidence=base.effective_evidence + live_merge_evidence((live, triggerish)), + ) + with_decayed = ProgressFacts( + task_states=base.task_states, + effective_evidence=base.effective_evidence + live_merge_evidence((decayed, triggerish)), + ) + self.assertNotEqual(progress_fingerprint(base), progress_fingerprint(with_live)) + self.assertEqual(progress_fingerprint(base), progress_fingerprint(with_decayed)) + + def test_planner_emits_proof_decayed_attention_without_blocking_downstream(self) -> None: + with self._temp_snapshot() as snapshot: + plan = build_continuation_plan(snapshot) + readiness = build_task_readiness(snapshot) + self.assertEqual(plan.completion, PlanCompletion.INCOMPLETE) + self.assertTrue(any(item.reason is ReasonCode.PROOF_DECAYED for item in plan.attention)) + self.assertTrue(any(item.kind is AttentionKind.NEEDS_USER for item in plan.attention)) + self.assertFalse(any(action.reason is ReasonCode.PROOF_DECAYED for action in plan.blocked)) + self.assertEqual([task.id for task in readiness.ready], ["TASK-B"]) + self.assertFalse(any(action.kind is ActionKind.EXECUTE_TASK and action.reason is ReasonCode.PROOF_DECAYED for action in readiness.blocked)) + + def _temp_snapshot(self): + from tempfile import TemporaryDirectory + + class _Guard: + def __enter__(self_inner): + self_inner.tmp = TemporaryDirectory() + root = Path(self_inner.tmp.name) + done = Taskish(root, "TASK-A") + ready = Taskish(root, "TASK-B", depends_on=("TASK-A",)) + snapshot = SchedulerSnapshot( + observed_at=CLOCK, + tasks=( + SchedulerTaskSnapshot(done, "done", False, "integrated"), + SchedulerTaskSnapshot(ready, "backlog", False, "not_required"), + ), + decisions=(), + execution_mode="local", + candidate_ids=("TASK-A", "TASK-B"), + snapshot_sha256="a" * 64, + objective_id="release", + objective_revision=1, + objective_state="active", + objective_scope=("TASK-A", "TASK-B"), + objective_targets=("TASK-B",), + objective_requested_mode="supervised", + objective_operations=("execute", "review"), + decayed_merge_subjects=("TASK-A",), + ) + self_inner.snapshot = snapshot + return snapshot + + def __exit__(self_inner, *args): + self_inner.tmp.cleanup() + + return _Guard() + + +def Taskish(root: Path, task_id: str, *, depends_on: tuple[str, ...] = ()): + from dyro.tasks import Task + + return Task( + id=task_id, + title=task_id, + line="alpha", + risk="write", + executor="noop", + reviewer="noop", + repositories=("api",), + depends_on=depends_on, + directory=root / task_id, + ) + + +def _write_bound_review(task_path: Path) -> None: + receipt_hash = hashlib.sha256(task_path.joinpath("receipt.md").read_bytes()).hexdigest() + heads_hash = hashlib.sha256(task_path.joinpath("task-heads.json").read_bytes()).hexdigest() + binding = review_binding(task_path) + provenance = ( + f"attempt_id: {binding[0]}\nplan_sha256: {binding[1]}\n" if binding is not None else "" + ) + task_path.joinpath("review.md").write_text( + f"verdict: PASS\nreceipt_sha256: {receipt_hash}\ntask_heads_sha256: {heads_hash}\n{provenance}", + encoding="utf-8", + ) + + +class ProofDecayWorkspaceTests(WorkspaceCase): + def _reviewed_task(self, task_id: str): + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / task_id + task_path.mkdir(parents=True) + task_path.joinpath("task.toml").write_text( + task_template(task_id, "decay", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ), + encoding="utf-8", + ) + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") + task = load_task(config, task_id) + self.assertEqual(run_task(config, task), "review") + _write_bound_review(task_path) + self.assertEqual(review_task(config, task), "done") + return config, load_task(config, task_id) + + def test_evaluate_review_matches_valid_review_acceptance(self) -> None: + config, task = self._reviewed_task("TASK-LIVE") + self.assertTrue(_valid_review_acceptance(config, task)) + proofs = evaluate_proofs(config, derive_task_proofs(config, task)) + review = next(proof for proof in proofs if proof.kind is ProofKind.REVIEW_VERDICT) + self.assertEqual(review.status, ProofStatus.LIVE) + integration = next(proof for proof in proofs if proof.kind is ProofKind.INTEGRATION_HEADS) + self.assertEqual(integration.status, ProofStatus.LIVE) + + def test_torn_review_is_decayed_and_merge_still_refuses(self) -> None: + config, task = self._reviewed_task("TASK-TORN") + task.directory.joinpath("review.md").write_text("verdict: PASS\n", encoding="utf-8") + self.assertFalse(_valid_review_acceptance(config, task)) + proofs = evaluate_proofs(config, derive_task_proofs(config, task)) + review = next(proof for proof in proofs if proof.kind is ProofKind.REVIEW_VERDICT) + self.assertEqual(review.status, ProofStatus.DECAYED) + self.assertEqual(review.decay_reason, REVIEW_ACCEPTANCE) + with self.assertRaisesRegex(DyroError, r"有效的独立复核.*PROOF_DECAYED"): + merge_task(config, task) + + def test_missing_review_file_is_inconclusive_after_list(self) -> None: + config, task = self._reviewed_task("TASK-ABSENT-REVIEW") + task.directory.joinpath("review.md").unlink() + self.assertFalse(_valid_review_acceptance(config, task)) + listed = list_proofs(config, task_id=task.id) + review = next(proof for proof in listed if proof.kind is ProofKind.REVIEW_VERDICT) + self.assertEqual(review.status, ProofStatus.INCONCLUSIVE) + self.assertIsNot(review.status, ProofStatus.DECAYED) + + def test_dirty_task_worktree_without_head_change_still_refuses_merge(self) -> None: + config, task = self._reviewed_task("TASK-DIRTY-HEAD") + worktree = self.root / "worktrees/alpha/TASK-DIRTY-HEAD/services/api" + worktree.joinpath("DIRTY.txt").write_text("stay dirty\n", encoding="utf-8") + self.assertFalse(_valid_review_acceptance(config, task)) + proofs = evaluate_proofs(config, derive_task_proofs(config, task)) + review = next(proof for proof in proofs if proof.kind is ProofKind.REVIEW_VERDICT) + self.assertEqual(review.status, ProofStatus.DECAYED) + with self.assertRaisesRegex(DyroError, "有效的独立复核"): + merge_task(config, task) + + def test_line_dirty_is_prepare_merge_not_proof_decayed(self) -> None: + config, task = self._reviewed_task("TASK-LINE-DIRTY") + line_repo = self.root / "versions/alpha/services/api" + line_repo.joinpath("LINE-DIRTY.txt").write_text("dirty line\n", encoding="utf-8") + proofs = evaluate_proofs(config, derive_task_proofs(config, task)) + review = next(proof for proof in proofs if proof.kind is ProofKind.REVIEW_VERDICT) + integration = next(proof for proof in proofs if proof.kind is ProofKind.INTEGRATION_HEADS) + self.assertEqual(review.status, ProofStatus.LIVE) + self.assertEqual(integration.status, ProofStatus.LIVE) + with self.assertRaisesRegex(DyroError, "开发线仓库不干净"): + merge_task(config, task) + + def _downstream(self, config, task_id: str, dependency: str): + path = config.task_specs_dir / task_id + path.mkdir(parents=True) + spec = task_template(task_id, "downstream", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ) + spec = spec.replace("depends_on = []", f'depends_on = ["{dependency}"]') + path.joinpath("task.toml").write_text(spec, encoding="utf-8") + path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + return load_task(config, task_id) + + def test_explain_blocks_on_unintegrated_done_dependency(self) -> None: + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / "TASK-UP" + task_path.mkdir(parents=True) + task_path.joinpath("task.toml").write_text( + task_template("TASK-UP", "upstream", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ), + encoding="utf-8", + ) + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + task_path.joinpath("receipt.md").write_text("result: QUESTION\n", encoding="utf-8") + task = load_task(config, "TASK-UP") + from dyro.tasks import answer_task + + self.assertEqual(run_task(config, task), "waiting_answer") + repository = self.root / "worktrees/alpha/TASK-UP/services/api" + repository.joinpath("UNMERGED.md").write_text("pending\n", encoding="utf-8") + shell("git", "add", "UNMERGED.md", cwd=repository) + shell("git", "commit", "-m", "feat: unmerged", cwd=repository) + task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") + self.assertEqual(answer_task(config, task, "continue"), "review") + _write_bound_review(task_path) + self.assertEqual(review_task(config, load_task(config, "TASK-UP")), "done") + downstream = self._downstream(config, "TASK-DOWN", "TASK-UP") + report = explain_task(config, "TASK-DOWN") + self.assertFalse(report["dispatchable"]) + self.assertTrue(any("尚未集成" in reason for reason in report["reasons"])) + with self.assertRaisesRegex(DyroError, "尚未集成"): + check_dispatchable(config, downstream) + + def test_torn_review_does_not_block_downstream_when_ancestor_holds(self) -> None: + config, integrated = self._reviewed_task("TASK-INT") + self._downstream(config, "TASK-DOWN2", "TASK-INT") + integrated.directory.joinpath("review.md").write_text("verdict: PASS\n", encoding="utf-8") + check_dispatchable(config, load_task(config, "TASK-DOWN2")) + torn = explain_task(config, "TASK-DOWN2") + self.assertTrue(torn["dispatchable"]) + with self.assertRaisesRegex(DyroError, "PROOF_DECAYED"): + merge_task(config, load_task(config, "TASK-INT")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_proof_derive.py b/tests/test_proof_derive.py new file mode 100644 index 0000000..a75e7e4 --- /dev/null +++ b/tests/test_proof_derive.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from dyro.config import load +from dyro.continuation.actions import ( + ActionIntent, + ActionReceipt, + ActionStatus, + acquire_owner_lease, + record_action_receipt, + reserve_action, + start_action, +) +from dyro.continuation.budgets import BudgetReservation +from dyro.continuation.models import ActionKind +from dyro.continuation.objective_storage import open_objective_directory +from dyro.continuation.store import create_objective +from dyro.evidence_store import publish_evidence_generation +from dyro.proof.derive import derive_objective_proofs, derive_task_proofs, list_proofs +from dyro.proof.models import ProofKind, ProofStatus +from dyro.provenance import review_binding +from dyro.tasks import answer_task, load_task, review_task, run_task, task_template +from dyro.workspace import create_line + +from .support import WorkspaceCase, shell + + +def _write_bound_review(task_path: Path) -> None: + receipt_hash = hashlib.sha256(task_path.joinpath("receipt.md").read_bytes()).hexdigest() + heads_hash = hashlib.sha256(task_path.joinpath("task-heads.json").read_bytes()).hexdigest() + binding = review_binding(task_path) + provenance = ( + f"attempt_id: {binding[0]}\nplan_sha256: {binding[1]}\n" if binding is not None else "" + ) + task_path.joinpath("review.md").write_text( + f"verdict: PASS\nreceipt_sha256: {receipt_hash}\ntask_heads_sha256: {heads_hash}\n{provenance}", + encoding="utf-8", + ) + + +def _objective_contract(objective_id: str, target: str) -> str: + return f'''schema_version = 1 +id = "{objective_id}" +title = "Objective {objective_id}" +line = "alpha" +targets = ["{target}"] + +[continuation] +requested_mode = "supervised" +operations = ["execute", "review"] +''' + + +class ProofDeriveTests(WorkspaceCase): + def _reviewed_task(self, task_id: str = "TASK-PROOF"): + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / task_id + task_path.mkdir(parents=True) + task_path.joinpath("task.toml").write_text( + task_template(task_id, "proof derive", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ), + encoding="utf-8", + ) + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") + task = load_task(config, task_id) + self.assertEqual(run_task(config, task), "review") + _write_bound_review(task_path) + self.assertEqual(review_task(config, task), "done") + return config, load_task(config, task_id) + + def test_bound_review_fixture_derives_review_gate_and_integration(self) -> None: + config, task = self._reviewed_task() + proofs = derive_task_proofs(config, task) + kinds = {proof.kind for proof in proofs} + self.assertIn(ProofKind.GATE_LOG, kinds) + self.assertIn(ProofKind.REVIEW_VERDICT, kinds) + self.assertIn(ProofKind.INTEGRATION_HEADS, kinds) + self.assertNotIn(ProofKind.ACTION_RECEIPT, kinds) + review = next(proof for proof in proofs if proof.kind is ProofKind.REVIEW_VERDICT) + self.assertEqual(review.subject, task.id) + self.assertTrue(review.substrate.attempt_id) + self.assertTrue(review.substrate.contract_hash) + self.assertEqual(review.produced_at, "") + self.assertEqual(review.status, ProofStatus.INCONCLUSIVE) + gate = next(proof for proof in proofs if proof.kind is ProofKind.GATE_LOG) + self.assertTrue(dict(gate.substrate.extra).get("argv_sha256")) + integration = next(proof for proof in proofs if proof.kind is ProofKind.INTEGRATION_HEADS) + # No extra task commit: recorded heads already sit on the line, so ancestor holds. + self.assertEqual(dict(integration.substrate.extra)["integration_state"], "integrated") + + def test_unmerged_task_commit_marks_integration_pending(self) -> None: + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / "TASK-PENDING" + task_path.mkdir(parents=True) + task_path.joinpath("task.toml").write_text( + task_template("TASK-PENDING", "unmerged heads", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ), + encoding="utf-8", + ) + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + task_path.joinpath("receipt.md").write_text("result: QUESTION\n", encoding="utf-8") + task = load_task(config, "TASK-PENDING") + self.assertEqual(run_task(config, task), "waiting_answer") + repository = self.root / "worktrees/alpha/TASK-PENDING/services/api" + repository.joinpath("PROOF.md").write_text("drift\n", encoding="utf-8") + shell("git", "add", "PROOF.md", cwd=repository) + shell("git", "commit", "-m", "feat: unmerged", cwd=repository) + task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") + self.assertEqual(answer_task(config, task, "continue"), "review") + _write_bound_review(task_path) + self.assertEqual(review_task(config, task), "done") + proofs = derive_task_proofs(config, load_task(config, "TASK-PENDING")) + integration = next(proof for proof in proofs if proof.kind is ProofKind.INTEGRATION_HEADS) + self.assertEqual(dict(integration.substrate.extra)["integration_state"], "pending") + + def test_p1_never_forges_live(self) -> None: + config, task = self._reviewed_task("TASK-NO-LIVE") + for proof in derive_task_proofs(config, task): + self.assertIsNot(proof.status, ProofStatus.LIVE) + + def test_missing_review_binding_is_inconclusive(self) -> None: + config, task = self._reviewed_task("TASK-MISSING") + task.directory.joinpath("review.md").write_text("verdict: PASS\n", encoding="utf-8") + proofs = derive_task_proofs(config, task) + review = next(proof for proof in proofs if proof.kind is ProofKind.REVIEW_VERDICT) + self.assertEqual(review.status, ProofStatus.INCONCLUSIVE) + self.assertIsNot(review.status, ProofStatus.LIVE) + + def test_identity_stable_across_mtime(self) -> None: + config, task = self._reviewed_task("TASK-MTIME") + first = {proof.kind: proof.id for proof in derive_task_proofs(config, task)} + review = task.directory / "review.md" + review.touch() + (task.directory / "receipt.md").touch() + second = {proof.kind: proof.id for proof in derive_task_proofs(config, task)} + self.assertEqual(first, second) + + def test_signoff_uses_signed_at_not_mtime(self) -> None: + config, task = self._reviewed_task("TASK-SIGNOFF") + signed_at = "2026-08-15T00:00:00+00:00" + task.directory.joinpath("signoff.json").write_text( + json.dumps( + { + "task_id": task.id, + "approver": "owner", + "receipt_sha256": "a" * 64, + "task_heads_sha256": "b" * 64, + "review_sha256": "c" * 64, + "attempt_id": "attempt-1", + "plan_sha256": "d" * 64, + "signed_at": signed_at, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + proofs = derive_task_proofs(config, task) + signoff = next(proof for proof in proofs if proof.kind is ProofKind.SIGNOFF) + self.assertEqual(signoff.produced_at, signed_at) + before = signoff.id + task.directory.joinpath("signoff.json").touch() + again = next(proof for proof in derive_task_proofs(config, task) if proof.kind is ProofKind.SIGNOFF) + self.assertEqual(again.id, before) + + def test_external_generation_derives_one_gate_log(self) -> None: + config, task = self._reviewed_task("TASK-EXT-GATE") + for leftover in task.directory.glob("gate-*.log"): + leftover.unlink() + publish_evidence_generation( + task.directory, + "attempt-ext", + { + "receipt.md": b"result: DONE\n", + "task-heads.json": task.directory.joinpath("task-heads.json").read_bytes(), + "gates.json": b'{"schema_version":1,"gates":[]}\n', + "gates/gate-1.log": b"ok\n", + }, + ) + proofs = [proof for proof in derive_task_proofs(config, task) if proof.kind is ProofKind.GATE_LOG] + self.assertEqual(len(proofs), 1) + self.assertEqual(proofs[0].generation, "attempt-ext") + + def test_task_filter_excludes_action_receipt(self) -> None: + config, task = self._reviewed_task("TASK-A") + create_objective(config, _objective_contract("release", "TASK-A")) + listed = list_proofs(config, task_id="TASK-A") + self.assertFalse(any(proof.kind is ProofKind.ACTION_RECEIPT for proof in listed)) + + def test_objective_path_derives_action_receipt(self) -> None: + config, _task = self._reviewed_task("TASK-A") + record = create_objective(config, _objective_contract("release", "TASK-A")) + now = datetime(2026, 8, 15, 4, 0, tzinfo=timezone.utc) + with open_objective_directory(config, "release") as directory: + grant = acquire_owner_lease( + directory, + objective_id="release", + now=now, + ttl_seconds=30, + pid=123, + process_start="boot-1", + owner_token="1" * 64, + ) + intent = ActionIntent( + action_id="action-1", + objective_id="release", + objective_revision=record.revision, + objective_event_seq=record.event_seq, + objective_event_sha256=record.event_sha256, + scope_sha256=record.scope_sha256, + snapshot_sha256="a" * 64, + plan_sha256="b" * 64, + operation=ActionKind.EXECUTE_TASK, + subject_id="TASK-A", + owner_generation=grant.lease.generation, + expected_operation_generation=0, + authority_sha256="c" * 64, + budget_reservation=BudgetReservation("release", "TASK-A"), + created_at=now, + ) + reserve_action(directory, intent) + start_action(directory, action_id="action-1", grant=grant, now=now + timedelta(seconds=1)) + record_action_receipt( + directory, + ActionReceipt( + action_id="action-1", + idempotency_key=intent.idempotency_key, + owner_generation=grant.lease.generation, + status=ActionStatus.SUCCEEDED, + summary="gate-passed", + recorded_at=now + timedelta(seconds=3), + ), + ) + proofs = derive_objective_proofs(config, "release") + self.assertTrue(proofs) + receipt = proofs[0] + self.assertEqual(receipt.kind, ProofKind.ACTION_RECEIPT) + self.assertEqual(receipt.subject, "release") + self.assertEqual(receipt.substrate.contract_hash, record.contract_sha256) + self.assertTrue(receipt.produced_at) + self.assertNotIn(receipt, list_proofs(config, task_id="TASK-A")) + + def test_polyrepo_example_lists_empty_without_crash(self) -> None: + root = Path(__file__).resolve().parents[1] / "examples" / "polyrepo" + config = load(root) + self.assertEqual(list_proofs(config), ()) diff --git a/tests/test_readme_identity.py b/tests/test_readme_identity.py new file mode 100644 index 0000000..c7fd465 --- /dev/null +++ b/tests/test_readme_identity.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + +IDENTITY = { + "README.md": "delivery physics engine", + "README.zh-CN.md": "交付物理引擎", + "README.ko.md": "전달 물리 엔진", + "README.es.md": "física de entrega", + "README.fr.md": "physique de livraison", + "README.de.md": "Delivery-Physik-Engine", + "README.pt-BR.md": "física de entrega", + "README.ru.md": "физики поставки", +} + + +class ReadmeIdentityTests(unittest.TestCase): + def test_every_readme_language_locks_delivery_physics(self) -> None: + found = {path.name for path in ROOT.glob("README*.md")} + self.assertEqual(found, set(IDENTITY)) + for name, marker in IDENTITY.items(): + text = (ROOT / name).read_text(encoding="utf-8") + self.assertIn(marker, text, msg=name) + self.assertIn("verify-bundle", text, msg=name) + self.assertIn("--git-dir", text, msg=name) + self.assertIn("inconclusive", text, msg=name) + self.assertNotIn("Symphony", text) + self.assertNotIn("Gas Town", text) diff --git a/tests/test_release_gates.py b/tests/test_release_gates.py new file mode 100644 index 0000000..51aba78 --- /dev/null +++ b/tests/test_release_gates.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.append(str(ROOT / "tools")) + +from verify_release_gates import main, missing_gates # noqa: E402 + + +class ReleaseGateTests(unittest.TestCase): + def test_current_tree_has_1_0_evidence(self) -> None: + self.assertEqual(missing_gates(ROOT), []) + + def test_physics_train_refuses_0_6_publish_tag(self) -> None: + with self.assertRaises(SystemExit) as raised: + main(["--root", str(ROOT), "--release-tag", "v0.6.0"]) + self.assertIn("0.6", str(raised.exception)) diff --git a/tests/test_terminology.py b/tests/test_terminology.py index e951378..626ca6d 100644 --- a/tests/test_terminology.py +++ b/tests/test_terminology.py @@ -88,6 +88,20 @@ def test_base_ref_cannot_be_parsed_as_a_git_write_option(self) -> None: self.assertFalse(unexpected_output.exists()) + def test_scan_covers_readme_translation_files(self) -> None: + import hashlib + + external_policy = self.base / "policy.txt" + external_policy.write_text("marker-i18n\n", encoding="utf-8") + policy = load_terminology_policy(self.root, policy_file=external_policy) + self.root.joinpath("README.zh-CN.md").write_text("marker-i18n\n", encoding="utf-8") + self.root.joinpath("README.es.md").write_text("safe\n", encoding="utf-8") + result = scan_terminology(self.root, policy, base_ref="HEAD") + translated = hashlib.sha256(b"README.zh-CN.md").hexdigest()[:16] + spanish = hashlib.sha256(b"README.es.md").hexdigest()[:16] + self.assertTrue(any(translated in item for item in result.violations)) + self.assertFalse(any(spanish in item for item in result.violations)) + def test_policy_requires_one_external_input(self) -> None: with self.assertRaisesRegex(ValidationError, "未配置"): load_terminology_policy(self.root, environ={}) diff --git a/tools/verify_bundle_stranger.py b/tools/verify_bundle_stranger.py new file mode 100644 index 0000000..c3609eb --- /dev/null +++ b/tools/verify_bundle_stranger.py @@ -0,0 +1,99 @@ +"""Stranger-style integrity check: sdist-installed dyro + caller git objects.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import zipfile + + +def _run(argv: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run(argv, cwd=cwd, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def _write_fixture(root: Path) -> tuple[Path, Path]: + work = root / "work" + work.mkdir() + _run(["git", "init", "-b", "main"], cwd=work) + _run(["git", "config", "user.name", "Stranger"], cwd=work) + _run(["git", "config", "user.email", "stranger@example.com"], cwd=work) + (work / "README").write_text("fixture\n", encoding="utf-8") + _run(["git", "add", "README"], cwd=work) + _run(["git", "commit", "-m", "fixture"], cwd=work) + sha = _run(["git", "rev-parse", "HEAD"], cwd=work).stdout.strip() + repo = root / "objects.git" + _run(["git", "clone", "--bare", str(work), str(repo)]) + proof_id = hashlib.sha256(b"stranger-fixture").hexdigest() + payload = { + "bytes_sha256": hashlib.sha256(b"fixture").hexdigest(), + "decay_reason": "", + "declared_key_ids": [], + "generation": "1", + "id": proof_id, + "kind": "review_verdict", + "observed_at": "", + "policy_require_signed": {"require_signed_review": "false"}, + "procedure": "stranger fixture", + "procedure_reproduced": False, + "produced_at": "", + "status": "inconclusive", + "subject": "TASK-STRANGER", + "substrate": { + "attempt_id": "", + "contract_hash": "", + "extra": {}, + "plan_sha256": "", + "repo_heads": {"api": sha}, + }, + } + body = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + bundle = root / "fixture.proof.zip" + manifest = { + "kind": "dyro.proof.bundle", + "proof_ids": [proof_id], + "proof_sha256": {proof_id: hashlib.sha256(body.encode("utf-8")).hexdigest()}, + "schema_version": 1, + } + with zipfile.ZipFile(bundle, "w") as archive: + archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n") + archive.writestr(f"proofs/{proof_id}.json", body) + return bundle, repo + + +def main(argv: list[str] | None = None) -> int: + dyro = list(argv) if argv else ["dyro"] + with tempfile.TemporaryDirectory(prefix="dyro-stranger-") as tmp: + root = Path(tmp) + bundle, git_dir = _write_fixture(root) + live = _run( + [*dyro, "proof", "verify-bundle", str(bundle), "--git-dir", str(git_dir), "--format", "json"] + ) + payload = json.loads(live.stdout) + if payload.get("mode") != "integrity": + raise SystemExit("stranger verify-bundle must report mode=integrity") + if any(item.get("status") != "live" for item in payload.get("proofs", [])): + raise SystemExit(f"expected integrity live, got {payload}") + if any(item.get("status") == "decayed" for item in payload.get("proofs", [])): + raise SystemExit("bare verify-bundle must not report decayed") + missing = subprocess.run( + [*dyro, "proof", "verify-bundle", str(bundle), "--format", "json"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if missing.returncode == 0: + raise SystemExit("verify-bundle without --git-dir must not be live") + absent = json.loads(missing.stdout or "{}") + if any(item.get("status") == "live" for item in absent.get("proofs", [])): + raise SystemExit("missing git objects must be inconclusive, not live") + print(json.dumps({"ok": True, "mode": "integrity", "live": True}, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/verify_release_gates.py b/tools/verify_release_gates.py new file mode 100644 index 0000000..d2ae720 --- /dev/null +++ b/tools/verify_release_gates.py @@ -0,0 +1,78 @@ +"""Refuse a 1.0.0 release tag when P6-export / P12 / verify-bundle evidence is missing. + +A 0.6.x tag of this physics train is also refused: the tree already contains +Proof / Card / Compiler / verify-bundle. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import tomllib + + +GATES = ( + ("P6-export", Path("src/dyro/proof/bundle.py"), "def export_bundle"), + ("P12", Path("src/dyro/host/doctor.py"), "def assert_projections_allow_mutation"), + ("P13-verify-bundle", Path("src/dyro/proof/bundle.py"), "def verify_bundle"), + ("P13-identity-en", Path("README.md"), "delivery physics engine"), + ("P13-identity-zh", Path("README.zh-CN.md"), "交付物理引擎"), + ("P13-identity-ko", Path("README.ko.md"), "전달 물리 엔진"), + ("P13-identity-es", Path("README.es.md"), "física de entrega"), + ("P13-identity-fr", Path("README.fr.md"), "physique de livraison"), + ("P13-identity-de", Path("README.de.md"), "Delivery-Physik-Engine"), + ("P13-identity-pt", Path("README.pt-BR.md"), "física de entrega"), + ("P13-identity-ru", Path("README.ru.md"), "физики поставки"), + ("P13-bundle-contract-en", Path("README.md"), "verify-bundle"), + ("P13-a1-lock", Path("tests/test_proof_a1_boundary.py"), "dyro.proof"), + ("P13-stranger", Path("tools/verify_bundle_stranger.py"), "without --git-dir must not be live"), + ("P0-missing-git", Path("src/dyro/proof/bundle.py"), "if not git_dirs:"), +) + + +def _version(root: Path) -> str: + metadata = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + return str(metadata["project"]["version"]) + + +def _is_physics_train(root: Path) -> bool: + bundle = root / "src/dyro/proof/bundle.py" + return bundle.is_file() and "def verify_bundle" in bundle.read_text(encoding="utf-8") + + +def missing_gates(root: Path) -> list[str]: + missing: list[str] = [] + for name, path, marker in GATES: + target = root / path + if not target.is_file() or marker not in target.read_text(encoding="utf-8"): + missing.append(name) + if (root / "src/dyro/proof/bundle.py").read_text(encoding="utf-8").find("def refuse_verify_bundle") >= 0: + missing.append("P13-refuse-removed") + return missing + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".") + parser.add_argument("--release-tag", default="") + args = parser.parse_args(argv) + root = Path(args.root).resolve() + version = _version(root) + tag = args.release_tag.strip() + if version.startswith("0.6") and _is_physics_train(root): + if tag.startswith("v0.6") or tag.startswith("0.6"): + raise SystemExit("拒绝:本树已含 Proof/Card/Compiler,不得作为 0.6.x 发布") + print(f"skip 1.0 gates: version={version} tag={tag or '-'}; do not publish this tree as 0.6.x") + return 0 + if version != "1.0.0" and tag not in {"v1.0.0", "1.0.0"}: + print(f"skip 1.0 gates: version={version} tag={tag or '-'}") + return 0 + missing = missing_gates(root) + if missing: + raise SystemExit("拒绝 1.0.0:缺少 " + ", ".join(missing)) + print("1.0 gates present") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index af17ffa..300c4cd 100644 --- a/uv.lock +++ b/uv.lock @@ -217,58 +217,58 @@ wheels = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, - { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, - { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, - { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, ] [[package]] @@ -299,7 +299,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "build", marker = "extra == 'dev'", specifier = "==1.3.0" }, - { name = "cryptography", specifier = ">=44.0.0" }, + { name = "cryptography", specifier = ">=50.0.0" }, { name = "rfc8785", specifier = ">=0.1.4" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.2" }, { name = "twine", marker = "extra == 'dev'", specifier = "==6.2.0" }, From 08dcadaa00a1dd2e361d8ddc223004b4a3cd47b1 Mon Sep 17 00:00:00 2001 From: DandreYang <13072547+Dandre126@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:25:52 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(=E4=BA=A4=E4=BB=98=E7=89=A9=E7=90=86):?= =?UTF-8?q?=20=E5=85=B3=E4=B8=8A=20Card=20=E5=86=99=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=B9=B6=E5=87=86=E5=A4=87=200.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 有 Card 无 execute 时拒绝 dispatch / Peer Wave 写;包号离开已发布的 0.6.9,作为 0.7.0 物理列车发布。 --- CHANGELOG.md | 14 ++++ ...6-delivery-physics-and-capability-plane.md | 11 ++-- ...hase-0.md => 0007-agent-bridge-phase-0.md} | 2 +- docs/architecture.md | 2 +- .../agent-bridge-operation-inventory.md | 6 +- .../agent-bridge-phase-0-acceptance.md | 4 +- docs/designs/agent-bridge-protocol.md | 6 +- docs/designs/delivery-physics.md | 8 +-- plans/delivery-physics-implementation.md | 2 +- plans/dyro-agent-bridge-phase-0.md | 2 +- pyproject.toml | 2 +- src/dyro/capability/__init__.py | 18 ++++- src/dyro/capability/cards.py | 21 +++++- src/dyro/cli.py | 14 +++- src/dyro/continuation/supervision.py | 6 +- src/dyro/host/compile.py | 2 +- src/dyro/peer_wave.py | 30 ++++++++- src/dyro/proof/bundle.py | 11 +++- src/dyro/task_dispatch.py | 2 +- src/dyro/tasks.py | 41 +++++++++--- tests/test_capability.py | 66 ++++++++++++++++++- tests/test_host.py | 19 +++++- tests/test_peer_wave.py | 36 +++++++++- tests/test_proof_a1_boundary.py | 8 ++- tests/test_proof_bundle.py | 42 +++++++++++- tests/test_proof_cli.py | 5 ++ tests/test_proof_decay.py | 60 ++++++++++++++++- tests/test_release_gates.py | 27 +++++++- tools/verify_release_gates.py | 36 +++++++--- uv.lock | 2 +- 30 files changed, 445 insertions(+), 60 deletions(-) rename docs/adr/{0006-agent-bridge-phase-0.md => 0007-agent-bridge-phase-0.md} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab2b9b..e7fcfec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +## 0.7.0 - 2026-08-16 + +- First delivery-physics release: Proof objects, Capability Cards, the Host + Compiler, and `verify-bundle`. This is not the published `0.6.9` Peer Wave + train and is not `1.0.0`. +- A Card without `execute` refuses write dispatch, Peer Wave binding, and + `task run`. PATH discovery is not a Card. A dispatch-ready provider without a + Card remains the explicit second write door from 0.6.9. +- Core bound contracts no longer silently set `allow_unconfined_provider`. +- `merge_task` still refuses missing review or signoff files, but only a failed + complete predicate is labeled `PROOF_DECAYED`. +- Console summary stays `proof_inspection=not_inspected`; `dyro objective + attention` is the Proof-rebinding entry. + ## 0.6.9 - 2026-08-15 - Make multi-harness writes a Core peer wave: each task worktree has one diff --git a/docs/adr/0006-delivery-physics-and-capability-plane.md b/docs/adr/0006-delivery-physics-and-capability-plane.md index d49170d..55bb065 100644 --- a/docs/adr/0006-delivery-physics-and-capability-plane.md +++ b/docs/adr/0006-delivery-physics-and-capability-plane.md @@ -27,13 +27,14 @@ 1. Dyro 的产品身份锁定为 **本地优先的多仓交付物理引擎**,不是 agent、不是舰队、不是 skill 超市。 2. 抽出 **Proof Object** 作为已验证事实的统一投影。它不取代 `task.toml`、receipt、review 绑定或 Continuation journal。 3. 每个 Proof 带 **衰减函数**。substrate 变化后事实死亡;不确定不得写成通过。`decay(review_verdict)` 全量等于 `_valid_review_acceptance`;`decay(signoff)` 全量等于 `_valid_external_signoff`。`SchedulerSnapshot` 只把 merge 相关的 `live` Proof 投影进已有进展字段,不计入 trigger;journal 不把 proofs 当 PASS。生产 `BudgetUsage` 在 `0.7` 不因 Proof 新开 no-progress 耗尽。 -4. 用 **Capability Card** 统一 agent / gate / reviewer / trigger / tool。`0.7` 仍只读 `[adapters.*]`;`0.8` 才运行时升级为 Card,缺省 `cannot_prove` 至少包含 `done` 与 `merge`。 +4. 用 **Capability Card** 统一 agent / gate / reviewer / trigger / tool。`0.7.0` 解析 `[[capabilities]]` 并升级 `[adapters.*]`,缺省 `cannot_prove` 至少包含 `done` 与 `merge`。 5. 增加 **Host Compiler**:把定律与本机可用 Card 编译为宿主投影(`SKILL.md` 与可选拦截文件)。编译器只收缩权威,不扩大权威。 6. 所有 mutation 落入操作格 `observe | execute | review | sign | integrate | publish`。有效权威仍是策略 ∩ 合约 ∩ 租约 ∩ 任务权限 ∩ 图约束。 7. 议题跟踪器若接入,只能作为 Trigger provider,不能成为交付原子,也不能完成 Task。 8. **权威投影锁定为 B**:所有宿主必编译 skill / 规则;仅当宿主 Card 能证明拦截表面时,再投影由操作格编译的 deny hook。没有拦截表面不得拒绝 compile。Hook 不得宣传为 OS 隔离。详见设计第 8 节。 9. **`0.7` 衰减锁定为 A1**:对 merge / 下游释放的接受与拒绝,必须与 `0.6.0` 现有绑定检查同真值。Proof 只提供投影与 `PROOF_DECAYED` reason code,不是第二套门。`merge_task` / `check_dispatchable` 不读 Proof store。下游只投影 `_assert_dependency_integrated`;decayed review 不加严 ready set。任务仓 dirty:`0.6` 已拒绝,`0.7` 保持拒绝,不放松、不叠门。开发线 dirty / 错分支保持 `_prepare_merge` 现有错,不得标成 `PROOF_DECAYED`。不把 `git revert` 当成祖先断裂。 10. **`1.0` 可携带核验锁定为 B1**:`verify-bundle` 核验完整性,不核验身份,也不承诺与当前工作区 `proof verify` / `task merge` 同一套 `live` / `decayed`。输入是 Proof Bundle + 调用方提供的 git 对象。捆内不塞 git 对象库。缺 procedure、缺 substrate、缺 git 对象、或缺已声明的签名密钥 → `inconclusive`,不得写成 `live`。无 `--current-heads` 时不得报与 merge 相同的衰减结论。 +11. **写路径两扇门**:有 Card 时,argv adapter、`run_task_bound_dispatch` 与 Peer Wave 写绑定必须同受 `execute` 门。无 Card 的 dispatch 就绪是 0.6.9 已存在的第二扇门(显式允许),不是已审计 Card。PATH / 发现不是 Card。不得同时声称「PATH 发现不能执行」与「dispatch 就绪即可写」。 ## 否决项 @@ -45,6 +46,8 @@ - 因宿主缺少拦截表面而拒绝 `host compile`。 - 用命令名黑名单代替操作格来生成 deny hook。 - 未审计命令自动获得 `execute` intent。 +- 有 Card 无 `execute` 时,仍允许 dispatch / Peer Wave 写。 +- 把无 Card 的 dispatch 就绪写成已审计 Card,或同时写「PATH 发现不能执行」与「第二扇门可写」。 - 在 Proof Bundle 中写入绝对路径、凭据、prompt、adapter 环境或 git 对象库。 - 把 `0.7` 衰减做成与现有 merge / 下游检查不同真值的第二套门。 - 把 `0.7` 写成「不拒绝任务仓 dirty」(那是放松 `0.6`,不是「不加严」)。 @@ -59,10 +62,8 @@ ## 后果 - 产品叙事从「启动 agent」转为「核验完成」。 -- `0.7` 起增加 `dyro proof list/show/verify` 与衰减 reason code,不要求用户改 Task 清单。Console Proof 展示默认进 `0.8`。`export` 可在 `0.7` 以 experimental 提供;`verify-bundle` 硬门禁与 `schema_version = 1` 锁在 `1.0`。 -- `0.8` 起 adapter 配置向 Card 迁移,旧 Profile 仍可加载。 -- `0.9` 起宿主投影可重算、可 doctor;过期投影阻断自动 mutation。默认只写当前工作区;`--user` 才写用户级目录。`tools.json` / PATH 发现不是可执行 Card。 -- `1.0` 的对外承诺是:陌生人拿着 Proof Bundle 和自己提供的 git 对象,能得到与源机**相同的完整性结论**(字节仍在、钉死 SHA 可解析)。这不是身份证明,也不是「现在工作区还能 merge」。 +- `0.7.0` 落地 Proof、Capability Card、Host Compiler 与 `verify-bundle`。`trusted_usage` 只解析、默认 `false`,不接入生产 `BudgetUsage`。Console summary 保持 `proof_inspection=not_inspected`,不探 Git / Proof;`dyro objective attention` 走完整快照,可报 `PROOF_DECAYED`。两套入口不得写成同一套 Proof 展示。 +- `1.0` 的对外承诺仍是:陌生人拿着 Proof Bundle 和自己提供的 git 对象,能得到与源机**相同的完整性结论**(字节仍在、钉死 SHA 可解析)。这不是身份证明,也不是「现在工作区还能 merge」。`schema_version = 1` 的可携带合同在 1.0 冻结。 - 实施成本是新的投影层与兼容层,而不是第二套调度器。 ## 兼容 diff --git a/docs/adr/0006-agent-bridge-phase-0.md b/docs/adr/0007-agent-bridge-phase-0.md similarity index 99% rename from docs/adr/0006-agent-bridge-phase-0.md rename to docs/adr/0007-agent-bridge-phase-0.md index 42bb604..141c60a 100644 --- a/docs/adr/0006-agent-bridge-phase-0.md +++ b/docs/adr/0007-agent-bridge-phase-0.md @@ -1,4 +1,4 @@ -# ADR 0006: Agent Bridge Phase 0 +# ADR 0007: Agent Bridge Phase 0 ## Status diff --git a/docs/architecture.md b/docs/architecture.md index 071e5e8..ad5c503 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -241,6 +241,6 @@ Dyro 的交付拓扑与之**实质相近**:TaskGraph(`depends_on` / conflict 未来的 adapter、通知、签名规则、发布平台与审批系统应使用 Python entry point 或独立 Profile 扩展包接入;不要把某个组织的策略加入 core 默认行为。 -`0.7` 起先把已有证据物理学抽成可复验的 Proof(衰减与现有 merge / 下游检查同真值;`proof verify` 看当前工作区,`verify-bundle` 只核完整性,两套结论不得混称)。`0.8` 再把 argv adapter 升级为 Capability Card,并做 Console Proof 只读展示。`0.9` 把定律编译为只收缩权威的宿主投影。`1.0` 的可携带核验是 Proof Bundle 加调用方提供的 git 对象,核验完整性而不是身份,也不承诺与当前 merge 同一套 `live`。这不另造 TaskGraph 或完成状态机;见 [`交付物理学`](designs/delivery-physics.md) 与 [`ADR-0006`](adr/0006-delivery-physics-and-capability-plane.md)。 +`0.7.0` 把已有证据物理学抽成可复验的 Proof,并把 argv adapter 升级为 Capability Card,再把定律编译为只收缩权威的宿主投影。衰减与现有 merge / 下游检查同真值;`proof verify` 看当前工作区,`verify-bundle` 只核完整性,两套结论不得混称。Console summary 与 `dyro objective attention` 不是同一套 Proof 展示。`1.0` 的可携带核验是 Proof Bundle 加调用方提供的 git 对象,核验完整性而不是身份,也不承诺与当前 merge 同一套 `live`。这不另造 TaskGraph 或完成状态机;见 [`交付物理学`](designs/delivery-physics.md) 与 [`ADR-0006`](adr/0006-delivery-physics-and-capability-plane.md)。 开发者侧的可选本地多 Agent 派发(五段式任务契约、注入前机密守卫、locator 核验、隔离 patch)与上述控制面分层并列,随 `dyro` 安装包分发(`dyro dispatch` / `import experiments.local_agent_dispatch`),但**不**替代 gates/合并。同时写多块走 Core Peer Wave(task worktree + `conflict_group`),见 [`peer-wave-execution.md`](designs/peer-wave-execution.md)、[`ADR-0002`](adr/0002-optional-local-agent-dispatch.md)、[`多智能体编排纪律`](agent-orchestration-discipline.md) 与 [`可选本地 Agent 派发设计`](designs/optional-local-agent-dispatch.md)。 diff --git a/docs/designs/agent-bridge-operation-inventory.md b/docs/designs/agent-bridge-operation-inventory.md index 0fb1a2a..cdf3beb 100644 --- a/docs/designs/agent-bridge-operation-inventory.md +++ b/docs/designs/agent-bridge-operation-inventory.md @@ -2,9 +2,9 @@ Status: Linux Ubuntu 24.04 Mandatory Core Surface promoted at S5 -Decision source: [ADR 0006](../adr/0006-agent-bridge-phase-0.md) +Decision source: [ADR 0007](../adr/0007-agent-bridge-phase-0.md) -Review outcomes are incorporated into ADR 0006 and the Phase 0 acceptance +Review outcomes are incorporated into ADR 0007 and the Phase 0 acceptance matrix; point-in-time review exports are not part of the product documentation. ## 1. Purpose @@ -26,7 +26,7 @@ Status vocabulary: - `excluded`: prohibited from Phase 0; - `future-review`: a mutation candidate requiring a separate ADR and review. -Risk vocabulary follows ADR 0006: `R0`, `PLAN`, `R1`, `R2`, and `R3`. +Risk vocabulary follows ADR 0007: `R0`, `PLAN`, `R1`, `R2`, and `R3`. ## 2. Phase 0 declared surface diff --git a/docs/designs/agent-bridge-phase-0-acceptance.md b/docs/designs/agent-bridge-phase-0-acceptance.md index 7d1c3a7..8c4242d 100644 --- a/docs/designs/agent-bridge-phase-0-acceptance.md +++ b/docs/designs/agent-bridge-phase-0-acceptance.md @@ -2,7 +2,7 @@ Status: Enforced Linux source/wheel/sdist release gate -Authority: [ADR 0006](../adr/0006-agent-bridge-phase-0.md) +Authority: [ADR 0007](../adr/0007-agent-bridge-phase-0.md) Protocol: [Agent Bridge Phase 0 Protocol](agent-bridge-protocol.md) @@ -102,7 +102,7 @@ network, or filesystem effects. The Linux gate requires Landlock ABI 3 or newer and a real test whose Git executable reaches the denied write syscall. SHA-1 repositories are the Phase 0 surface; SHA-256 object format and other repository extensions must fail closed -before Git starts. Consistent with ADR 0006, these gates do not claim an +before Git starts. Consistent with ADR 0007, these gates do not claim an immutable snapshot against an actively malicious same-identity process. macOS 15 combines in-process traps, read-only roots, before/after filesystem and diff --git a/docs/designs/agent-bridge-protocol.md b/docs/designs/agent-bridge-protocol.md index 0fa58d5..0ba311b 100644 --- a/docs/designs/agent-bridge-protocol.md +++ b/docs/designs/agent-bridge-protocol.md @@ -2,7 +2,7 @@ Status: Proposed -Authority: [ADR 0006](../adr/0006-agent-bridge-phase-0.md) +Authority: [ADR 0007](../adr/0007-agent-bridge-phase-0.md) Operation allowlist: [Agent Bridge Operation Inventory](agent-bridge-operation-inventory.md) @@ -307,7 +307,7 @@ response schemas. It rejects unavailable, excluded, and mutation operations. `unique`. Phase 0 does not return registry file paths, Profile absolute roots, remote URLs, adapter argv, or environment values. -The opaque ID implements ADR 0006 `WorkspaceIdentityV1`: it is stable only while +The opaque ID implements ADR 0007 `WorkspaceIdentityV1`: it is stable only while the canonical Profile root and validated Profile name are unchanged. Moving or renaming a workspace intentionally changes it. `ConfigRevisionV1` hashes the bounded exact `dyro.toml` bytes with its domain separator. Neither identifier is @@ -348,7 +348,7 @@ directory objects, lazy fetch, replace objects, and more than 100 Git process starts per request fail closed. This is a bounded cooperative-state observation, not a filesystem attestation. -As defined by ADR 0006, an actively malicious process with the same operating- +As defined by ADR 0007, an actively malicious process with the same operating- system identity can still replace a ref or object during the read and restore it afterward; defending that case requires an immutable filesystem snapshot or external broker and is outside Phase 0. Plan digests do not upgrade this trust diff --git a/docs/designs/delivery-physics.md b/docs/designs/delivery-physics.md index 9361b8f..8766ea7 100644 --- a/docs/designs/delivery-physics.md +++ b/docs/designs/delivery-physics.md @@ -258,7 +258,7 @@ read = ["codex", "exec", "--sandbox", "workspace-write", "{prompt}"] write = ["codex", "exec", "--sandbox", "workspace-write", "{prompt}"] attested_isolation = "cwd" # none | cwd | worktree | os_sandbox | external_runner -trusted_usage = false # 不能证明用量则禁止硬限额自动跑 +trusted_usage = false # 0.7 解析该字段;不接入生产 BudgetUsage can_prove = [] # 只能填 Proof kind;空表示输出不能当完成证据 cannot_prove = ["done", "merge", "security", "product_acceptance"] intents = ["observe", "execute"] @@ -274,10 +274,10 @@ hosts = ["cli"] # cli = Dyro 启动的 adapter;不是宿 | `can_prove` | 它的输出里,哪些可以变成 Proof。只填 Proof kind,不填 dispatch 词汇。 | | `cannot_prove` | 即使它写了「已完成」,Core 也不得采信。 | | `intents` | 它可请求的操作格:`observe` `execute` `review` `sign` `integrate` `publish`。 | -| `trusted_usage` | 是否能返回可核验用量。false 时 hard-limit 自动执行 fail-closed。 | +| `trusted_usage` | 是否能返回可核验用量。0.7 解析并默认 `false`,不接入生产 `BudgetUsage` / 硬限额自动跑。 | | `hosts` | 允许被编译到哪些宿主表面。 | -兼容:`0.7` 仍只读 `[adapters.*]`,不解析 `[[capabilities]]`。`0.8` 才运行时升级为 Card,缺省 `cannot_prove = ["done","merge"]`,`attested_isolation = "cwd"`。`dyro agent add` 在 0.8 继续工作,内部写 Card。 +兼容:本 `0.7.0` 已解析 `[[capabilities]]`,并把 `[adapters.*]` 升级为 Card。缺省 `cannot_prove` 至少包含 `done` 与 `merge`,`attested_isolation = "cwd"`。`dyro agent add` 继续工作,内部写 Card。 未审计的本机命令可以出现在 `dyro tool list` 和 Host Compiler 的「已发现未集成」区,**不能**获得 `execute` intent。 @@ -340,7 +340,7 @@ publish → push / 发布;第一版仍显式,且默认关 | TaskGraph / 状态机 | 唯一交付图 | Proof 投影;0.7 衰减与现有 merge / 祖先检查同真值,只多 reason code | | Objective / Continuation | 快照、计划、租约、预算 | `SchedulerSnapshot` 纳入 live/decayed Proof 投影;reason code `PROOF_DECAYED`(attention / 人话,默认不 block 下游)。journal 不存 proofs 当 PASS | | dispatch | 建议、locator、租约 | Card 的 `attested_isolation` 替代口头 strict | -| Console / Home | 只读;summary 零新 git I/O | `0.8` 起展示 Proof 状态与衰减原因,不展示 argv/路径。`0.7` 用 `dyro proof list` 与 `dyro objective attention` | +| Console / Home | 只读;summary 零新 git I/O | Console summary 不探 Git / Proof,`proof_inspection=not_inspected`。`dyro objective attention` 走完整快照,可报 `PROOF_DECAYED`。两套入口不得写成同一套 Proof 展示。`0.8` 起 Console 只读字段可展示已投影的 Proof 状态,不展示 argv/路径。`0.7` 用 `dyro proof list` 与 `dyro objective attention` | | Witness | 追加哈希链 | Proof export 与 ledger 事件对齐;不把 Witness 当完成证据 | | Blueprint / join | SHA 钉死的线 | 新队友得到的投影由本机 Card 编译,不携带源机工具清单 | | Tool catalog | 打开工作区 ≠ 执行权 | 发现结果喂给 Compiler,不喂给 scheduler | diff --git a/plans/delivery-physics-implementation.md b/plans/delivery-physics-implementation.md index 89ebfca..0eb801e 100644 --- a/plans/delivery-physics-implementation.md +++ b/plans/delivery-physics-implementation.md @@ -58,7 +58,7 @@ ADR:[`docs/adr/0006-delivery-physics-and-capability-plane.md`](../docs/adr/000 | 版本 | 主题 | 对用户可见 | 关闭条件未满足时 | | --- | --- | --- | --- | | `0.6.x` | 身份冻结 | 文档 + ADR + 术语扫描 | 不合并 Proof/Card/Compiler 代码 | -| `0.7.0` | Proof 与衰减 | `dyro proof list/show/verify`;可选 experimental `export`;`dyro objective attention` 可含 `PROOF_DECAYED` | 不改 adapter schema;`verify-bundle` 不是 0.7 硬门禁 | +| `0.7.0` | Proof / Card / Compiler / `verify-bundle` | `dyro proof list/show/verify`;`export`;`verify-bundle`;`dyro capability *`;`dyro host compile`;`dyro objective attention` 可含 `PROOF_DECAYED` | 不把本树当 `0.6.x` 或 `1.0.0` 发;`trusted_usage` 不接入 BudgetUsage | | `0.8.0` | Capability Card + Console Proof | `dyro capability *`;`agent add` 写 Card;Console 只读展示 Proof | 不编译宿主文件 | | `0.9.0` | Host Compiler | `dyro host compile/status/doctor` | 不承诺 1.0 对外核验 | | `1.0.0` | 可携带核验 | Proof Bundle `schema_version = 1`;`verify-bundle` 硬门禁;叙事锁死 | 缺一项不得标 1.0 | diff --git a/plans/dyro-agent-bridge-phase-0.md b/plans/dyro-agent-bridge-phase-0.md index 0ddd3c5..e485609 100644 --- a/plans/dyro-agent-bridge-phase-0.md +++ b/plans/dyro-agent-bridge-phase-0.md @@ -7,7 +7,7 @@ inspect-and-plan interface for coding agents without exposing Dyro mutation. Authority: -- [ADR 0006](../docs/adr/0006-agent-bridge-phase-0.md) +- [ADR 0007](../docs/adr/0007-agent-bridge-phase-0.md) - [Operation inventory](../docs/designs/agent-bridge-operation-inventory.md) - [Protocol](../docs/designs/agent-bridge-protocol.md) - [Acceptance matrix](../docs/designs/agent-bridge-phase-0-acceptance.md) diff --git a/pyproject.toml b/pyproject.toml index f8fd8ac..bf4eac4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dyro" -version = "0.6.9" +version = "0.7.0" description = "DyroEngineeringFlow: local-first automation and delivery control for multi-repository teams" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dyro/capability/__init__.py b/src/dyro/capability/__init__.py index 5958382..ced2fb5 100644 --- a/src/dyro/capability/__init__.py +++ b/src/dyro/capability/__init__.py @@ -1,6 +1,17 @@ -"""Capability plane: audited Cards only. PATH discovery is not execute.""" +"""Capability plane: audited Cards only. -from .cards import card_from_adapter, merge_capability_plane, parse_capability_tables +PATH discovery is not a Card. A dispatch-ready provider without a Card is the +second write door (explicitly allowed). A Card without execute is always refused. +""" + +from .cards import ( + assert_capability_allows_write, + card_forbids_execute, + card_from_adapter, + merge_capability_plane, + parse_capability_tables, + write_capability_denied, +) from .models import ( CapabilityCard, CapabilityKind, @@ -23,6 +34,8 @@ "DiscoveredTool", "Isolation", "append_capability", + "assert_capability_allows_write", + "card_forbids_execute", "card_from_adapter", "card_from_command", "card_from_preset", @@ -32,4 +45,5 @@ "parse_capability_tables", "runtime_cards", "test_capability", + "write_capability_denied", ) diff --git a/src/dyro/capability/cards.py b/src/dyro/capability/cards.py index 80d57aa..6f2ca02 100644 --- a/src/dyro/capability/cards.py +++ b/src/dyro/capability/cards.py @@ -5,7 +5,7 @@ from typing import Any, Mapping from ..config import Adapter, validate_id -from ..errors import ValidationError +from ..errors import DyroError, ValidationError from .models import ( DEFAULT_CANNOT_PROVE, CapabilityCard, @@ -14,6 +14,25 @@ ) +def card_forbids_execute(card: object | None) -> bool: + """True only when a Card exists and does not grant execute.""" + return card is not None and "execute" not in getattr(card, "intents", ()) + + +def write_capability_denied( + capabilities: Mapping[str, object] | None, executor: str +) -> bool: + if not capabilities: + return False + return card_forbids_execute(capabilities.get(executor)) + + +def assert_capability_allows_write(config: object, executor: str) -> None: + cards = getattr(config, "capabilities", None) + if write_capability_denied(cards, executor): + raise DyroError(f"Capability {executor} 未授予 execute,不能作为任务执行器") + + def card_from_adapter(adapter: Adapter) -> CapabilityCard: """Runtime upgrade. Missing Card fields stay fail-closed.""" return CapabilityCard( diff --git a/src/dyro/cli.py b/src/dyro/cli.py index dfef1e2..339268c 100644 --- a/src/dyro/cli.py +++ b/src/dyro/cli.py @@ -3436,7 +3436,13 @@ def cmd_objective_tick(args: argparse.Namespace) -> None: record.objective.budget.max_parallel, len(available_write) ), ) - overlay = annotate_objective_tick(snapshot, plan, tick, available_write) + overlay = annotate_objective_tick( + snapshot, + plan, + tick, + available_write, + capabilities=getattr(config, "capabilities", None), + ) if args.format == "json": payload = scheduler_tick_payload(tick) payload.update(overlay) @@ -3712,6 +3718,7 @@ def cmd_task_daemon(args: argparse.Namespace) -> None: bound, decision = apply_harness_bindings( ScheduleWave(tasks=tuple(queued), deferred=()), available_write, + capabilities=getattr(config, "capabilities", None), ) for note in decision.warnings: print(f"warning: {note}") @@ -4028,7 +4035,10 @@ def build_parser() -> argparse.ArgumentParser: "--command", help="作为 launch/read/write 的 argv 命令行;不会经 shell 执行" ) agent_add.set_defaults(func=cmd_agent_add) - capability = sub.add_parser("capability", help="审计后的 Capability Card;PATH 发现不能执行") + capability = sub.add_parser( + "capability", + help="审计后的 Capability Card;PATH 发现不是 Card;无 Card 的 dispatch 就绪是第二扇门;有 Card 无 execute 一律拒绝", + ) capability_sub = capability.add_subparsers(dest="capability_command", required=True) capability_list = capability_sub.add_parser("list", help="列出已审计 Card 与 discovered_unintegrated") capability_list.add_argument("--format", choices=("text", "json"), default="text") diff --git a/src/dyro/continuation/supervision.py b/src/dyro/continuation/supervision.py index 12d99f6..6c889ca 100644 --- a/src/dyro/continuation/supervision.py +++ b/src/dyro/continuation/supervision.py @@ -382,7 +382,11 @@ def _dispatch(config: Config, action: PlannedAction, task: Task, *, expected_con if action.kind is ActionKind.EXECUTE_TASK: from ..peer_wave import bind_wave_executors, discover_available_write_providers - decision = bind_wave_executors((task,), discover_available_write_providers()) + decision = bind_wave_executors( + (task,), + discover_available_write_providers(), + capabilities=getattr(config, "capabilities", None), + ) return run_task( config, task, diff --git a/src/dyro/host/compile.py b/src/dyro/host/compile.py index 8c172a3..0abf50a 100644 --- a/src/dyro/host/compile.py +++ b/src/dyro/host/compile.py @@ -274,7 +274,7 @@ def render_skill( lines.append("这些不是已审计 Card,不能当作执行器。") lines.append("") for item in discovered: - lines.append(f"- {item.id}") + lines.append(f"- {_skill_cell(item.id)}") else: lines.append("无。") lines.extend( diff --git a/src/dyro/peer_wave.py b/src/dyro/peer_wave.py index 1018ded..d8a2a5c 100644 --- a/src/dyro/peer_wave.py +++ b/src/dyro/peer_wave.py @@ -7,6 +7,7 @@ import time from typing import Iterable, Mapping, Sequence +from .capability.cards import card_forbids_execute from .errors import ValidationError from .tasks import ScheduleBlock, ScheduleWave, Task @@ -135,6 +136,7 @@ def bind_wave_executors( ready_write: Sequence[str], *, max_per_backend: int = MAX_PER_BACKEND, + capabilities: Mapping[str, object] | None = None, ) -> HarnessDecision: if type(max_per_backend) is not int or max_per_backend < 1: raise ValidationError("max_per_backend 必须是正整数") @@ -143,7 +145,13 @@ def bind_wave_executors( counts: dict[str, int] = {} bindings: list[ExecutorBinding] = [] deferred: list[ScheduleBlock] = [] - auto_pool = [provider for provider in ready if provider != CURSOR_WRITE_PROVIDER] + cards = capabilities or {} + auto_pool = [ + provider + for provider in ready + if provider != CURSOR_WRITE_PROVIDER + and not card_forbids_execute(cards.get(provider)) + ] for task in tasks: try: @@ -151,6 +159,14 @@ def bind_wave_executors( except ValidationError as exc: deferred.append(ScheduleBlock(task=task, reason=str(exc))) continue + if task.risk == "write" and card_forbids_execute(cards.get(task.executor)): + deferred.append( + ScheduleBlock( + task=task, + reason=f"Capability {task.executor} 未授予 execute,不能作为任务执行器", + ) + ) + continue if task.executor == AUTO_EXECUTOR: chosen = _take_idle(auto_pool, counts, max_per_backend) if chosen is None: @@ -215,9 +231,13 @@ def apply_harness_bindings( ready_write: Sequence[str], *, max_per_backend: int = MAX_PER_BACKEND, + capabilities: Mapping[str, object] | None = None, ) -> tuple[tuple[Task, ...], HarnessDecision]: decision = bind_wave_executors( - wave.tasks, ready_write, max_per_backend=max_per_backend + wave.tasks, + ready_write, + max_per_backend=max_per_backend, + capabilities=capabilities, ) bound_ids = set(decision.bound_tasks) bound_tasks = tuple(task for task in wave.tasks if task.id in bound_ids) @@ -272,6 +292,8 @@ def annotate_objective_tick( plan: object, tick: object, ready_write: Sequence[str], + *, + capabilities: Mapping[str, object] | None = None, ) -> dict[str, object]: from .continuation.models import ActionKind @@ -291,7 +313,9 @@ def annotate_objective_tick( item = tasks_by_id.get(action.subject_id) if item is not None: wave_tasks.append(item.task) - decision = bind_wave_executors(wave_tasks, ready_write) + decision = bind_wave_executors( + wave_tasks, ready_write, capabilities=capabilities + ) return peer_wave_overlay( tasks=execute_tasks or wave_tasks, max_parallel=int(getattr(tick, "max_parallel", 1)), diff --git a/src/dyro/proof/bundle.py b/src/dyro/proof/bundle.py index db3d568..ca416a2 100644 --- a/src/dyro/proof/bundle.py +++ b/src/dyro/proof/bundle.py @@ -22,6 +22,7 @@ _SHA_RE = re.compile(r"^[0-9a-f]{40}(?:[0-9a-f]{24})?$") _HEX_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") MAX_MEMBER_BYTES = 8 * 1024 * 1024 +MAX_PROOF_IDS = 256 MISSING_GIT = "missing_git_objects" MISSING_PROCEDURE = "missing_procedure" @@ -271,10 +272,18 @@ def _valid_manifest(raw: object) -> bool: return False ids = raw.get("proof_ids") digests = raw.get("proof_sha256") - if not isinstance(ids, list) or not all(isinstance(item, str) and item for item in ids): + if not isinstance(ids, list) or not ids or len(ids) > MAX_PROOF_IDS: + return False + if len(set(ids)) != len(ids): + return False + if not all(isinstance(item, str) and item for item in ids): return False if not isinstance(digests, dict): return False + for proof_id in ids: + digest = digests.get(proof_id) + if not isinstance(digest, str) or not _HEX_DIGEST_RE.fullmatch(digest): + return False return True diff --git a/src/dyro/task_dispatch.py b/src/dyro/task_dispatch.py index 459a0a2..b923adc 100644 --- a/src/dyro/task_dispatch.py +++ b/src/dyro/task_dispatch.py @@ -82,7 +82,7 @@ def build_bound_contract( "backend": executor, "mode": "edit" if task.risk == "write" else "read-only", "strict": False, - "allow_unconfined_provider": executor != "echo", + "allow_unconfined_provider": False, "allow_offline_simulation": executor == "echo", "files": list(files), "task": { diff --git a/src/dyro/tasks.py b/src/dyro/tasks.py index a0c50f3..a298d73 100644 --- a/src/dyro/tasks.py +++ b/src/dyro/tasks.py @@ -12,6 +12,7 @@ import uuid from typing import Any, Iterable +from .capability.cards import assert_capability_allows_write from .config import ( Config, expand_argv, @@ -1786,9 +1787,8 @@ def _adapter_argv( raise ValidationError( f"任务 {task.id} 使用的 Agent adapter 未配置:{agent}" ) from exc - card = getattr(config, "capabilities", {}).get(agent) - if card is not None and mode == "write" and "execute" not in getattr(card, "intents", ()): - raise DyroError(f"Capability {agent} 未授予 execute,不能作为任务执行器") + if mode == "write": + assert_capability_allows_write(config, agent) template = adapter.write if mode == "write" else adapter.read return expand_argv( template, @@ -1989,7 +1989,9 @@ def run_gates(config: Config, task: Task, *, dry_run: bool = False) -> bool: return all_passed -def _resolve_run_executor(task: Task, executor_override: str | None) -> str: +def _resolve_run_executor( + config: Config, task: Task, executor_override: str | None +) -> str: from .peer_wave import ( AUTO_EXECUTOR, bind_wave_executors, @@ -2000,7 +2002,11 @@ def _resolve_run_executor(task: Task, executor_override: str | None) -> str: return executor_override if task.executor != AUTO_EXECUTOR: return task.executor - decision = bind_wave_executors((task,), discover_available_write_providers()) + decision = bind_wave_executors( + (task,), + discover_available_write_providers(), + capabilities=getattr(config, "capabilities", None), + ) chosen = decision.executor_for(task.id) if chosen is None: reason = ( @@ -2025,9 +2031,10 @@ def _execute_task_agent( from .peer_wave import assert_write_executor_allowed from .task_dispatch import is_dispatch_write_ready, run_task_bound_dispatch - executor = _resolve_run_executor(task, executor_override) + executor = _resolve_run_executor(config, task, executor_override) if task.risk == "write": assert_write_executor_allowed(executor, risk=task.risk) + assert_capability_allows_write(config, executor) if is_dispatch_write_ready(executor): result = run_task_bound_dispatch( task, @@ -2972,6 +2979,21 @@ def _merge_task_repositories_locked( ) +def _review_evidence_present(task: Task) -> bool: + if not (task.directory / "review.md").is_file(): + return False + try: + resolve_evidence_path(task.directory, "receipt.md") + resolve_evidence_path(task.directory, TASK_HEADS_FILE) + except DyroError: + return False + return True + + +def _signoff_evidence_present(task: Task) -> bool: + return (task.directory / "signoff.json").is_file() + + def merge_task( config: Config, task: Task, *, push: bool = False, dry_run: bool = False ) -> None: @@ -2979,13 +3001,16 @@ def merge_task( if status(config, task) != "done": raise DyroError(f"仅 done 任务可合并:{task.id}") if not _valid_review_acceptance(config, task): + suffix = "" if not _review_evidence_present(task) else "(PROOF_DECAYED)" raise DyroError( - "仅具有有效的独立复核、当前回执与任务 HEAD 绑定的 done 任务可合并(PROOF_DECAYED)" + "仅具有有效的独立复核、当前回执与任务 HEAD 绑定的 done 任务可合并" + + suffix ) if config.policy.require_external_signoff and not _valid_external_signoff( config, task ): - raise DyroError("当前 Profile 要求有效的外部签收后才能合并(PROOF_DECAYED)") + suffix = "" if not _signoff_evidence_present(task) else "(PROOF_DECAYED)" + raise DyroError("当前 Profile 要求有效的外部签收后才能合并" + suffix) _merge_task_repositories(config, task, push=push, dry_run=dry_run) diff --git a/tests/test_capability.py b/tests/test_capability.py index cba19cd..47e23cf 100644 --- a/tests/test_capability.py +++ b/tests/test_capability.py @@ -3,6 +3,7 @@ from contextlib import redirect_stderr, redirect_stdout from io import StringIO from pathlib import Path +from unittest.mock import patch import json import os import stat @@ -13,6 +14,7 @@ from dyro.cli import main from dyro.config import load from dyro.errors import DyroError, ValidationError +from dyro.process import Result from dyro.tasks import load_task, run_task, task_template from dyro.workspace import create_line, doctor @@ -163,8 +165,12 @@ def test_discovered_unintegrated_cannot_execute(self) -> None: task_path.joinpath("task.toml").write_text(spec, encoding="utf-8") task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") - with self.assertRaisesRegex(ValidationError, "未配置"): - run_task(config, load_task(config, "TASK-OPEN")) + with patch( + "dyro.task_dispatch.is_dispatch_write_ready", + return_value=False, + ): + with self.assertRaisesRegex(ValidationError, "未配置"): + run_task(config, load_task(config, "TASK-OPEN")) finally: if previous is None: os.environ.pop("PATH", None) @@ -199,6 +205,62 @@ def test_observe_only_card_cannot_be_task_executor(self) -> None: with self.assertRaisesRegex(DyroError, "未授予 execute"): run_task(config, load_task(config, "TASK-WATCH")) + def test_observe_only_dispatch_ready_same_id_cannot_execute(self) -> None: + path = self.root / "dyro.toml" + path.write_text( + path.read_text(encoding="utf-8") + + """ + +[[capabilities]] +id = "codex" +kind = "agent" +launch = ["/usr/bin/true"] +read = ["/usr/bin/true"] +write = ["/usr/bin/true"] +intents = ["observe"] +""", + encoding="utf-8", + ) + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / "TASK-CODEX" + task_path.mkdir(parents=True) + spec = task_template("TASK-CODEX", "observe only dispatch", "alpha", "api", "services/api") + task_path.joinpath("task.toml").write_text(spec, encoding="utf-8") + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") + with ( + patch("dyro.task_dispatch.is_dispatch_write_ready", return_value=True), + patch("dyro.task_dispatch.run_task_bound_dispatch") as dispatch, + ): + with self.assertRaisesRegex(DyroError, "未授予 execute"): + run_task(config, load_task(config, "TASK-CODEX")) + dispatch.assert_not_called() + + def test_dispatch_ready_without_card_is_explicit_second_door(self) -> None: + config = load(self.root) + self.assertNotIn("codex", getattr(config, "capabilities", {})) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / "TASK-DOOR" + task_path.mkdir(parents=True) + spec = task_template("TASK-DOOR", "second door", "alpha", "api", "services/api") + task_path.joinpath("task.toml").write_text(spec, encoding="utf-8") + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + task_path.joinpath("receipt.md").write_text("result: DONE\n", encoding="utf-8") + result = Result(("dyro", "task-dispatch", "codex", "TASK-DOOR"), 0, "") + with ( + patch("dyro.task_dispatch.is_dispatch_write_ready", return_value=True), + patch( + "dyro.task_dispatch.run_task_bound_dispatch", + return_value=result, + ) as dispatch, + ): + self.assertEqual( + run_task(config, load_task(config, "TASK-DOOR"), dry_run=True), + "dry-run", + ) + dispatch.assert_called_once() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_host.py b/tests/test_host.py index 5a41ed0..8925062 100644 --- a/tests/test_host.py +++ b/tests/test_host.py @@ -23,7 +23,8 @@ inspect_projections, projection_root, ) -from dyro.host.compile import HOOK_NAME, SKILL_NAME +from dyro.host.compile import HOOK_NAME, SKILL_NAME, _skill_cell +from dyro.host.models import HOOK_SIDECAR_NOTE from dyro.tasks import task_template from dyro.workspace import create_line @@ -77,6 +78,22 @@ def _skill(self, host: str = "cli", *, user: bool = False) -> str: root = projection_root(load(self.root), user=user) return (root / host / SKILL_NAME).read_text(encoding="utf-8") + def test_skill_cell_escapes_table_and_rejects_forbidden_commands(self) -> None: + self.assertEqual(_skill_cell("a|b"), "a/b") + self.assertEqual(_skill_cell("x`y"), "x'y") + with self.assertRaisesRegex(DyroError, "未批准命令"): + _skill_cell("dyro task") + + def test_doctor_json_declares_projection_sidecar(self) -> None: + compile_hosts(load(self.root)) + stdout = StringIO() + with redirect_stdout(stdout): + main(["--root", str(self.root), "host", "doctor", "--format", "json"]) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["hook_enforcement"], "projection_sidecar") + self.assertEqual(payload["hook_note"], HOOK_SIDECAR_NOTE) + self.assertIn("未安装到 hook_surface", payload["hook_note"]) + def test_compile_writes_workspace_skill_without_execute_commands(self) -> None: stdout = StringIO() with redirect_stdout(stdout): diff --git a/tests/test_peer_wave.py b/tests/test_peer_wave.py index 948b6ea..786305e 100644 --- a/tests/test_peer_wave.py +++ b/tests/test_peer_wave.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from types import SimpleNamespace import tempfile import unittest @@ -12,7 +13,7 @@ recommended_max_parallel, write_capable_dispatch_ids, ) -from dyro.task_dispatch import run_task_bound_dispatch +from dyro.task_dispatch import build_bound_contract, run_task_bound_dispatch from dyro.tasks import SchedulePlan, Task, select_task_wave @@ -105,6 +106,39 @@ def test_write_capable_ids_exclude_cursor(self) -> None: self.assertIn("codex", write_capable_dispatch_ids()) self.assertNotIn("cursor-agent", write_capable_dispatch_ids()) + def test_observe_only_card_is_not_bound_into_write_wave(self) -> None: + cards = {"codex": SimpleNamespace(intents=("observe",))} + decision = bind_wave_executors( + (self._task("T-OBS", executor="codex"),), + ("codex",), + capabilities=cards, + ) + self.assertEqual(decision.bindings, ()) + self.assertEqual(decision.deferred[0].task.id, "T-OBS") + self.assertIn("未授予 execute", decision.deferred[0].reason) + + def test_auto_pool_skips_observe_only_ready_provider(self) -> None: + cards = {"codex": SimpleNamespace(intents=("observe",))} + decision = bind_wave_executors( + (self._task("T-AUTO", executor=AUTO_EXECUTOR),), + ("codex", "claude"), + capabilities=cards, + ) + self.assertEqual(decision.bindings[0].executor, "claude") + self.assertEqual(decision.deferred, ()) + + def test_bound_contract_does_not_silently_unconfine_real_providers(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + (workspace / "module.py").write_text("value = 1\n", encoding="utf-8") + contract = build_bound_contract( + self._task("T-CONFINE", executor="codex"), + executor="codex", + workspace=workspace, + prompt="do not write", + ) + self.assertFalse(contract.allow_unconfined_provider) + @staticmethod def _task( task_id: str, *, conflict_group: str = "", executor: str = "codex" diff --git a/tests/test_proof_a1_boundary.py b/tests/test_proof_a1_boundary.py index a989ff6..fdfe64a 100644 --- a/tests/test_proof_a1_boundary.py +++ b/tests/test_proof_a1_boundary.py @@ -11,7 +11,13 @@ TASKS = ROOT / "src" / "dyro" / "tasks.py" BANNED_MODULES = {"dyro.proof"} BANNED_NAMES = {"list_proofs", "evaluate_proofs", "evaluate_proof", "verify_bundle"} -PROTECTED = {"merge_task", "check_dispatchable", "_prepare_merge"} +PROTECTED = { + "merge_task", + "check_dispatchable", + "_prepare_merge", + "_merge_task_repositories", + "_merge_task_repositories_locked", +} def _function_nodes(tree: ast.AST) -> dict[str, ast.FunctionDef | ast.AsyncFunctionDef]: diff --git a/tests/test_proof_bundle.py b/tests/test_proof_bundle.py index 14ff334..753aab0 100644 --- a/tests/test_proof_bundle.py +++ b/tests/test_proof_bundle.py @@ -157,7 +157,9 @@ def test_missing_proof_digest_is_not_live(self) -> None: archive.writestr(f"proofs/{'a' * 64}.json", json.dumps(payload) + "\n") proofs = verify_bundle(rewritten, git_dirs=(work,)) self.assertTrue(all(item.status is not ProofStatus.LIVE for item in proofs)) - self.assertEqual(proofs[0].decay_reason, "bundle_bytes_mismatch") + self.assertIn( + proofs[0].decay_reason, {"bundle_bytes_mismatch", "not_proof_bundle"} + ) def test_json_boolean_require_signed_without_keys_is_inconclusive(self) -> None: with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: @@ -255,6 +257,44 @@ def test_short_sha_is_unresolved(self) -> None: self.assertEqual(proofs[0].status, ProofStatus.INCONCLUSIVE) self.assertEqual(proofs[0].decay_reason, "object_unresolved") + def test_invalid_hex_digest_is_not_live(self) -> None: + with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: + root = Path(tmp) + work, sha = _git_head(root) + bundle = root / "hex.zip" + export_bundle((_proof(heads=(("api", sha),)),), bundle) + with zipfile.ZipFile(bundle) as archive: + manifest = json.loads(archive.read("manifest.json")) + body = archive.read(f"proofs/{'a' * 64}.json") + manifest["proof_sha256"]["a" * 64] = "not-a-digest" + rewritten = root / "bad-hex.zip" + with zipfile.ZipFile(rewritten, "w") as archive: + archive.writestr("manifest.json", json.dumps(manifest) + "\n") + archive.writestr(f"proofs/{'a' * 64}.json", body) + proofs = verify_bundle(rewritten, git_dirs=(work,)) + self.assertTrue(all(item.status is not ProofStatus.LIVE for item in proofs)) + self.assertEqual(proofs[0].decay_reason, "not_proof_bundle") + + def test_too_many_proof_ids_is_not_live(self) -> None: + from dyro.proof.bundle import MAX_PROOF_IDS + + with tempfile.TemporaryDirectory(prefix="dyro-bundle-") as tmp: + bundle = Path(tmp) / "many.zip" + digest = "b" * 64 + manifest = { + "kind": "dyro.proof.bundle", + "schema_version": 1, + "proof_ids": [f"{index:064x}" for index in range(MAX_PROOF_IDS + 1)], + "proof_sha256": { + f"{index:064x}": digest for index in range(MAX_PROOF_IDS + 1) + }, + } + with zipfile.ZipFile(bundle, "w") as archive: + archive.writestr("manifest.json", json.dumps(manifest) + "\n") + proofs = verify_bundle(bundle, git_dirs=()) + self.assertTrue(all(item.status is not ProofStatus.LIVE for item in proofs)) + self.assertEqual(proofs[0].decay_reason, "not_proof_bundle") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_proof_cli.py b/tests/test_proof_cli.py index 6e33871..f29f1a2 100644 --- a/tests/test_proof_cli.py +++ b/tests/test_proof_cli.py @@ -146,6 +146,9 @@ def test_export_schema_is_frozen_and_contains_no_git_objects(self) -> None: code, missing_out, _ = self._run(["proof", "verify-bundle", str(bundle), "--format", "json"]) self.assertEqual(code, VERIFY_EXIT_INCONCLUSIVE) missing = json.loads(missing_out) + self.assertEqual(missing["mode"], "integrity") + self.assertEqual(missing["conclusion"], "integrity") + self.assertFalse(missing["merge_equivalent"]) self.assertTrue(any(item["status"] == "inconclusive" for item in missing["proofs"])) self.assertFalse(any(item["status"] == "decayed" for item in missing["proofs"])) @@ -240,6 +243,8 @@ def test_workspace_verify_and_verify_bundle_are_separate_conclusions(self) -> No self.assertEqual(code, VERIFY_EXIT_OK) integrity = json.loads(bundle_out) self.assertEqual(integrity["mode"], "integrity") + self.assertEqual(integrity["conclusion"], "integrity") + self.assertFalse(integrity["merge_equivalent"]) self.assertFalse(any(item["status"] == "decayed" for item in integrity["proofs"])) self.assertTrue(all(item["status"] == "live" for item in integrity["proofs"])) diff --git a/tests/test_proof_decay.py b/tests/test_proof_decay.py index 0bfd285..189a289 100644 --- a/tests/test_proof_decay.py +++ b/tests/test_proof_decay.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone from pathlib import Path +from unittest.mock import patch import hashlib import unittest @@ -9,7 +10,14 @@ from dyro.continuation.budgets import ProgressFacts, progress_fingerprint from dyro.continuation.models import ActionKind, AttentionKind, PlanCompletion, ReasonCode from dyro.continuation.planner import build_continuation_plan, build_task_readiness -from dyro.continuation.snapshot import SchedulerSnapshot, SchedulerTaskSnapshot +from dyro.continuation.snapshot import ( + SchedulerSnapshot, + SchedulerTaskSnapshot, + build_scheduler_snapshot_bounded, + build_scheduler_snapshot_from_facts, +) +from dyro.continuation.store import create_objective +from dyro.read_limits import ObservationLimits, ReadBudget from dyro.errors import DyroError from dyro.graph import explain_task from dyro.proof.decay import ( @@ -297,6 +305,10 @@ def test_missing_review_file_is_inconclusive_after_list(self) -> None: review = next(proof for proof in listed if proof.kind is ProofKind.REVIEW_VERDICT) self.assertEqual(review.status, ProofStatus.INCONCLUSIVE) self.assertIsNot(review.status, ProofStatus.DECAYED) + with self.assertRaises(DyroError) as raised: + merge_task(config, task) + self.assertIn("有效的独立复核", str(raised.exception)) + self.assertNotIn("PROOF_DECAYED", str(raised.exception)) def test_dirty_task_worktree_without_head_change_still_refuses_merge(self) -> None: config, task = self._reviewed_task("TASK-DIRTY-HEAD") @@ -374,6 +386,52 @@ def test_torn_review_does_not_block_downstream_when_ancestor_holds(self) -> None with self.assertRaisesRegex(DyroError, "PROOF_DECAYED"): merge_task(config, load_task(config, "TASK-INT")) + def test_from_facts_does_not_inspect_proofs(self) -> None: + with patch("dyro.proof.evaluate.decayed_merge_subjects") as inspect: + snapshot = build_scheduler_snapshot_from_facts( + tasks=(), + decisions=(), + execution_mode="local", + candidate_ids=(), + observed_at=CLOCK, + ) + inspect.assert_not_called() + self.assertEqual(snapshot.decayed_merge_subjects, ()) + + def test_bounded_snapshot_does_not_inspect_proofs(self) -> None: + config = load(self.root) + create_line(config, line_id="alpha", branch="feat/alpha", base="main") + task_path = config.task_specs_dir / "TASK-BOUND" + task_path.mkdir(parents=True) + task_path.joinpath("task.toml").write_text( + task_template("TASK-BOUND", "bounded", "alpha", "api", "services/api").replace( + 'agent = "codex"', 'agent = "noop"' + ), + encoding="utf-8", + ) + task_path.joinpath("handoff.md").write_text("# handoff\n", encoding="utf-8") + record = create_objective( + config, + '''schema_version = 1 +id = "bounded" +title = "Bounded snapshot" +line = "alpha" +targets = ["TASK-BOUND"] + +[continuation] +requested_mode = "supervised" +operations = ["execute", "review"] +''', + ) + with patch("dyro.proof.evaluate.decayed_merge_subjects") as inspect: + snapshot = build_scheduler_snapshot_bounded( + config, + objective=record, + budget=ReadBudget(ObservationLimits()), + ) + inspect.assert_not_called() + self.assertEqual(snapshot.decayed_merge_subjects, ()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_release_gates.py b/tests/test_release_gates.py index 51aba78..05700e5 100644 --- a/tests/test_release_gates.py +++ b/tests/test_release_gates.py @@ -1,5 +1,7 @@ from __future__ import annotations +from contextlib import redirect_stdout +from io import StringIO from pathlib import Path import sys import unittest @@ -11,10 +13,33 @@ class ReleaseGateTests(unittest.TestCase): - def test_current_tree_has_1_0_evidence(self) -> None: + def test_current_tree_has_1_0_gate_markers_smoke_only(self) -> None: + """Substring markers exist. This is not a 1.0 release pass.""" self.assertEqual(missing_gates(ROOT), []) def test_physics_train_refuses_0_6_publish_tag(self) -> None: with self.assertRaises(SystemExit) as raised: main(["--root", str(ROOT), "--release-tag", "v0.6.0"]) self.assertIn("0.6", str(raised.exception)) + + def test_physics_train_refuses_published_0_6_9_tag(self) -> None: + with self.assertRaises(SystemExit) as raised: + main(["--root", str(ROOT), "--release-tag", "v0.6.9"]) + self.assertIn("0.6", str(raised.exception)) + + def test_0_7_release_runs_gates_without_claiming_1_0(self) -> None: + stdout = StringIO() + with redirect_stdout(stdout): + code = main(["--root", str(ROOT), "--release-tag", "v0.7.0"]) + self.assertEqual(code, 0) + self.assertIn("0.7 gates present", stdout.getvalue()) + self.assertNotIn("1.0 gates present", stdout.getvalue()) + self.assertNotIn("skip 1.0 gates", stdout.getvalue()) + + def test_untagged_0_7_runs_0_7_gates(self) -> None: + stdout = StringIO() + with redirect_stdout(stdout): + code = main(["--root", str(ROOT)]) + self.assertEqual(code, 0) + self.assertIn("0.7 gates present", stdout.getvalue()) + self.assertNotIn("1.0 gates present", stdout.getvalue()) diff --git a/tools/verify_release_gates.py b/tools/verify_release_gates.py index d2ae720..6fa18af 100644 --- a/tools/verify_release_gates.py +++ b/tools/verify_release_gates.py @@ -1,7 +1,7 @@ -"""Refuse a 1.0.0 release tag when P6-export / P12 / verify-bundle evidence is missing. +"""Refuse a physics-train release that is missing Proof / Card / Compiler evidence. -A 0.6.x tag of this physics train is also refused: the tree already contains -Proof / Card / Compiler / verify-bundle. +A 0.6.x tag of this train is refused. A 0.7.0 tag must pass 0.7 gates and must +not be narrated as a 1.0 release. 1.0.0 keeps the stricter stranger contract. """ from __future__ import annotations @@ -29,6 +29,13 @@ ("P0-missing-git", Path("src/dyro/proof/bundle.py"), "if not git_dirs:"), ) +SEVEN_GATES = GATES + ( + ("P0-F5-helper", Path("src/dyro/capability/cards.py"), "def assert_capability_allows_write"), + ("P0-F5-run", Path("src/dyro/tasks.py"), "assert_capability_allows_write(config, executor)"), + ("P0-second-door", Path("src/dyro/capability/__init__.py"), "second write door"), + ("P0-unconfined", Path("src/dyro/task_dispatch.py"), '"allow_unconfined_provider": False'), +) + def _version(root: Path) -> str: metadata = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) @@ -40,9 +47,9 @@ def _is_physics_train(root: Path) -> bool: return bundle.is_file() and "def verify_bundle" in bundle.read_text(encoding="utf-8") -def missing_gates(root: Path) -> list[str]: +def missing_gates(root: Path, gates: tuple[tuple[str, Path, str], ...] = GATES) -> list[str]: missing: list[str] = [] - for name, path, marker in GATES: + for name, path, marker in gates: target = root / path if not target.is_file() or marker not in target.read_text(encoding="utf-8"): missing.append(name) @@ -51,6 +58,10 @@ def missing_gates(root: Path) -> list[str]: return missing +def _tag_name(tag: str) -> str: + return tag[1:] if tag.startswith("v") else tag + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--root", default=".") @@ -59,10 +70,17 @@ def main(argv: list[str] | None = None) -> int: root = Path(args.root).resolve() version = _version(root) tag = args.release_tag.strip() - if version.startswith("0.6") and _is_physics_train(root): - if tag.startswith("v0.6") or tag.startswith("0.6"): - raise SystemExit("拒绝:本树已含 Proof/Card/Compiler,不得作为 0.6.x 发布") - print(f"skip 1.0 gates: version={version} tag={tag or '-'}; do not publish this tree as 0.6.x") + if _is_physics_train(root) and ( + tag.startswith("v0.6") or tag.startswith("0.6") + ): + raise SystemExit("拒绝:本树已含 Proof/Card/Compiler,不得作为 0.6.x 发布") + if tag and _tag_name(tag) != version: + raise SystemExit(f"拒绝:release tag {tag!r} 必须等于 v{version}") + if version == "0.7.0" or tag in {"v0.7.0", "0.7.0"}: + missing = missing_gates(root, SEVEN_GATES) + if missing: + raise SystemExit("拒绝 0.7.0:缺少 " + ", ".join(missing)) + print("0.7 gates present") return 0 if version != "1.0.0" and tag not in {"v1.0.0", "1.0.0"}: print(f"skip 1.0 gates: version={version} tag={tag or '-'}") diff --git a/uv.lock b/uv.lock index 466129e..9a49f40 100644 --- a/uv.lock +++ b/uv.lock @@ -286,7 +286,7 @@ wheels = [ [[package]] name = "dyro" -version = "0.6.9" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "cryptography" },