diff --git a/CHANGELOG.md b/CHANGELOG.md index e7fcfec..aff3b93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +## 0.7.1 - 2026-08-16 + +- Console opens an independent Proof inspect at + `GET /api/v1/workspaces/{alias}/proofs`. Summary stays + `proof_inspection=not_inspected` and does not evaluate Proofs. +- Derive `trigger_observation` from Objective trigger files using + `next_probe_at` only. Expired probes are display-decayed and stay out of + `progress_fingerprint` and merge kinds. +- Every `0.7.x` release gate now requires the inspect and trigger markers and + still refuses to narrate the train as `1.0.0`. + ## 0.7.0 - 2026-08-16 - First delivery-physics release: Proof objects, Capability Cards, the Host diff --git a/docs/adr/0006-delivery-physics-and-capability-plane.md b/docs/adr/0006-delivery-physics-and-capability-plane.md index 55bb065..5defa3a 100644 --- a/docs/adr/0006-delivery-physics-and-capability-plane.md +++ b/docs/adr/0006-delivery-physics-and-capability-plane.md @@ -1,6 +1,6 @@ # ADR-0006:交付物理学、能力卡与宿主编译 -- 状态:提案(2026-08-15);权威投影锁定为 B;衰减语义锁定为 A1;可携带核验锁定为 B1 +- 状态:提案(2026-08-15);权威投影锁定为 B;衰减语义锁定为 A1;可携带核验锁定为 B1;2026-08-16 版本列车收口为 `0.7.x` - 决策者:产品选择 B / A1 / B1 已写入本 ADR;其余条目仍待维护者确认合并 - 关联: - [交付物理学设计](../designs/delivery-physics.md) @@ -33,8 +33,9 @@ 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 相同的衰减结论。 +10. **可携带核验锁定为 B1**:`verify-bundle` 核验完整性,不核验身份,也不承诺与当前工作区 `proof verify` / `task merge` 同一套 `live` / `decayed`。输入是 Proof Bundle + 调用方提供的 git 对象。捆内不塞 git 对象库。缺 procedure、缺 substrate、缺 git 对象、或缺已声明的签名密钥 → `inconclusive`,不得写成 `live`。无 `--current-heads` 时不得报与 merge 相同的衰减结论。该能力在 `0.7.x` 发布,不另开 `1.0.0` 功能号。 11. **写路径两扇门**:有 Card 时,argv adapter、`run_task_bound_dispatch` 与 Peer Wave 写绑定必须同受 `execute` 门。无 Card 的 dispatch 就绪是 0.6.9 已存在的第二扇门(显式允许),不是已审计 Card。PATH / 发现不是 Card。不得同时声称「PATH 发现不能执行」与「dispatch 就绪即可写」。 +12. **版本列车收口为 `0.7.x`**:交付物理功能全部在 `0.7.x` 发布。取消 `0.8.0` / `0.9.0` 功能列车。`1.0.0` 只是身份冻结,未显式要求不得打。 ## 否决项 @@ -63,7 +64,8 @@ - 产品叙事从「启动 agent」转为「核验完成」。 - `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 冻结。 +- 剩余功能(Console 独立 inspect、`trigger_observation`、陌生人核验与叙事锁)继续走 `0.7.x`,不另开 `0.8.0` / `0.9.0` / `1.0.0`。 +- 可携带核验的对外承诺仍是:陌生人拿着 Proof Bundle 和自己提供的 git 对象,能得到与源机**相同的完整性结论**(字节仍在、钉死 SHA 可解析)。这不是身份证明,也不是「现在工作区还能 merge」。`schema_version = 1` 的合同在 `0.7.x` 锁住语义;冻结成 `1.0.0` 身份号须另做产品决定。 - 实施成本是新的投影层与兼容层,而不是第二套调度器。 ## 兼容 diff --git a/docs/architecture.md b/docs/architecture.md index ad5c503..e661c45 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.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)。 +`0.7.0` 把已有证据物理学抽成可复验的 Proof,并把 argv adapter 升级为 Capability Card,再把定律编译为只收缩权威的宿主投影。衰减与现有 merge / 下游检查同真值;`proof verify` 看当前工作区,`verify-bundle` 只核完整性,两套结论不得混称。Console summary 与 `dyro objective attention` 不是同一套 Proof 展示。剩余交付物理功能(Console 独立 inspect、可携带核验门禁)继续在 `0.7.x` 发布,不另开 `0.8.0` / `0.9.0` / `1.0.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/delivery-physics.md b/docs/designs/delivery-physics.md index 8766ea7..aba2ebb 100644 --- a/docs/designs/delivery-physics.md +++ b/docs/designs/delivery-physics.md @@ -1,13 +1,13 @@ # Dyro 交付物理学与能力平面 -状态:提案;2026-08-15 锁定 A1 / B1 -目标版本:`0.7.0` 起分阶段落地,`1.0.0` 收口产品身份 +状态:提案;2026-08-15 锁定 A1 / B1;2026-08-16 版本列车收口为 `0.7.x` +目标版本:`0.7.0` 起在 `0.7.x` 内收口全部交付物理功能;不另开 `0.8.0` / `0.9.0` / `1.0.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`。 +- **B1**:`verify-bundle` 核验完整性:Proof Bundle + **调用方提供的** git 对象。捆内不塞对象库。核验完整性,不核验身份,**不承诺**与当前工作区 `proof verify` / `task merge` 得到同一套 `live` / `decayed`。缺 procedure、缺 substrate、缺 git 对象、或缺已声明的签名密钥 → `inconclusive`。该能力在 `0.7.x` 发布,不另开 `1.0.0` 功能号。 关联: @@ -83,7 +83,7 @@ - 开发线 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`。 +- Trigger 观察有 TTL(`0.7.x` 已派生 `trigger_observation`);过期只唤醒规划,不解除依赖,也不进入 `progress_fingerprint`。 - Objective 在 Task 合约或依赖闭包漂移后必须 reconcile,才能再 mutation。 衰减不是 cron。衰减是证据上的熵。续航引擎的时钟首先用来**宣布死亡**,其次才用来唤醒。展示衰减 ≠ 新的 merge 拒绝。 @@ -180,7 +180,7 @@ Proof | 依赖 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` | +| TriggerObservation | `trigger_observation` | `0.7.x` 已派生;字段跟 `next_probe_at`,不发明 `valid_until` | 源路径、id 公式、substrate 与 `produced_at` 规则见实施计划**附录 A**。 @@ -234,7 +234,7 @@ decay(proof, current_substrate, clock) -> live | decayed | inconclusive 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`。 +4. `trigger_observation`:`0.7.x` 已派生。用现有 `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`,不并排再加一层。 @@ -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 | 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` | +| Console / Home | 只读;summary 零新 git I/O | Console summary 不探 Git / Proof,`proof_inspection=not_inspected`。`dyro objective attention` 走完整快照,可报 `PROOF_DECAYED`。两套入口不得写成同一套 Proof 展示。`0.7.x` 用独立 inspect 让 Console 只读字段展示已投影的 Proof 状态,不展示 argv/路径。`0.7.0` 已用 `dyro proof list` 与 `dyro objective attention` | | Witness | 追加哈希链 | Proof export 与 ledger 事件对齐;不把 Witness 当完成证据 | | Blueprint / join | SHA 钉死的线 | 新队友得到的投影由本机 Card 编译,不携带源机工具清单 | | Tool catalog | 打开工作区 ≠ 执行权 | 发现结果喂给 Compiler,不喂给 scheduler | @@ -431,7 +431,7 @@ Core 仍是唯一 mutation 权。Hook 挡不住的越权,仍由 dirty / HEAD - 不把容器或云沙箱做成 Core 依赖。隔离后端继续走 Card 声明与 entry point。 - 不在仓库内保存「我们学了谁 / 对标谁」的对照附录。反模式用机制描述即可。 - 不把 `git revert` 当成祖先断裂。祖先检查只回答「提交是否仍在历史上」。 -- 不把 Proof Bundle 做成自含 git 对象库。1.0 核验完整性,不核验身份。 +- 不把 Proof Bundle 做成自含 git 对象库。`0.7.x` 核验完整性,不核验身份。 - 不把 `verify-bundle` 的完整性结论说成与当前工作区 `proof verify` / `task merge` 同一套 `live` / `decayed`。 - 不把 `0.7` 写成「不拒绝任务仓 dirty」。那是对 `0.6` 的假描述;保持拒绝即可。 - 不把 `task evidence` ZIP 当作 Proof Bundle。 diff --git a/plans/delivery-physics-implementation.md b/plans/delivery-physics-implementation.md index 0eb801e..2e08c1a 100644 --- a/plans/delivery-physics-implementation.md +++ b/plans/delivery-physics-implementation.md @@ -1,11 +1,11 @@ # Dyro 交付物理学实施计划 -状态:待批准;2026-08-15 锁定 A1 / B1;同日对抗评审仲裁已收口契约 +状态:待批准;2026-08-15 锁定 A1 / B1;同日对抗评审仲裁已收口契约;2026-08-16 版本列车收口为 `0.7.x` 设计:[`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,最后编译宿主;每一阶段未绿之前,下一阶段保持关闭 +基线:`0.6.0` 已发布的 TaskGraph、证据绑定、Objective、只读 Console;`0.7.0` 已发布 Proof / Card / Compiler / `verify-bundle` +默认策略:先抽出投影,再衰减进调度,再换 Card,最后编译宿主;每一阶段未绿之前,下一阶段保持关闭。功能全部在 `0.7.x` 发布,不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号。 已锁定: @@ -19,7 +19,8 @@ ADR:[`docs/adr/0006-delivery-physics-and-capability-plane.md`](../docs/adr/000 | # | 决议 | | --- | --- | | 1 | `proof verify` 默认 decay + rebind,不重跑 gate。`--rerun-procedure` 仅诊断,须 dry-run/隔离。 | -| 2 | Console P7 **滑到 0.8**。`0.7` 出口 = P1–P5 + P3 CLI。P6 `export` 可选、experimental。 | +| 2 | Console P7 留在 `0.7.x`,不另开 `0.8`。`0.7.0` 已发 P1–P6 + P8–P12a。P7 / `trigger_observation` / P13 走后续 `0.7.x`。 | +| 6 | 交付物理功能全部在 `0.7.x` 发布。取消 `0.8.0` / `0.9.0` 列车。`1.0.0` 只是身份冻结,不是本系列功能号;未显式要求不得打 `1.0.0`。 | | 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 可丢弃,不是展示真源。 | @@ -28,7 +29,7 @@ ADR:[`docs/adr/0006-delivery-physics-and-capability-plane.md`](../docs/adr/000 ## 1. 最终交付结果 -完成本计划后,Dyro `1.0.0` 对外可证明: +完成本计划后,Dyro `0.7.x` 对外可证明: 1. 已有 receipt / review / heads / signoff / action receipt 可被列为 Proof,且不复制真源; 2. `dyro proof verify` 对**当前工作区**做衰减与绑定重算(rebind,不是 replay);`verify-bundle` 用 bundle + 调用方 git 对象做**完整性**复验,两套结论不得混称; @@ -36,7 +37,7 @@ ADR:[`docs/adr/0006-delivery-physics-and-capability-plane.md`](../docs/adr/000 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`。 +7. Console / Home 在 `0.7.x` 用独立 inspect 显示已投影的 Proof 状态与衰减原因,仍无写权。summary 保持 `proof_inspection=not_inspected`,与 `dyro objective attention` 不是同一套 Proof 展示。 --- @@ -57,13 +58,12 @@ ADR:[`docs/adr/0006-delivery-physics-and-capability-plane.md`](../docs/adr/000 | 版本 | 主题 | 对用户可见 | 关闭条件未满足时 | | --- | --- | --- | --- | -| `0.6.x` | 身份冻结 | 文档 + ADR + 术语扫描 | 不合并 Proof/Card/Compiler 代码 | -| `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 | +| `0.6.x` | 已发布维护 | 只接受与本计划不冲突的修复 | 不回写 `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.7.x` | 本系列剩余全部功能 | P7:Console 独立 inspect 展示已投影 Proof;`trigger_observation`;P13:陌生人核验与叙事锁。summary 仍 `not_inspected` | 不另开 `0.8.0` / `0.9.0` / `1.0.0` 功能号 | +| `1.0.0` | 身份冻结,不是本系列功能号 | 仅当产品显式要求时才打 | 未显式要求不得标 `1.0.0` | -`0.6.x` 继续只接受维护修复。本计划的实现从独立开发线切入,不回写 `0.6.0` 的已发布语义。 +`0.6.x` 继续只接受维护修复。本计划的剩余实现继续走 `0.7.x`,不回写 `0.6.0` 的已发布语义,也不把同一批功能改标成 `0.8` / `0.9` / `1.0`。 --- @@ -95,7 +95,7 @@ src/dyro/host/ 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 + console/read_model.py 0.7.x 独立 inspect 只读展示;summary 零新 git I/O tooling.py 发现结果供给 Compiler;不得当 Card profile.py / config.py 加载旧 adapters ``` @@ -121,7 +121,7 @@ P5 交付门 decay 投影(A1) │ P6 Proof Bundle export(0.7 experimental) │ -P7 Console/Home 只读展示(默认 0.8) +P7 Console/Home 独立 inspect 只读展示(0.7.x) │ P8 Capability 模型 + adapters 迁移 │ @@ -135,16 +135,16 @@ P12 host doctor + 过期投影阻断自动 mutation │ P12a 可选 deny hook(仅已证明 hook 表面的宿主) │ -P13 1.0 叙事、schema 冻结、verify-bundle 硬门禁 +P13 0.7.x 叙事、schema 冻结、verify-bundle 可作为后续 0.7.x 门禁(仍不打 1.0.0) ``` 并行允许: - P2 可在 P1 模型冻结后与 P3 的 CLI 骨架并行,但 P3 的 verify 必须等 P2。 -- P7 不得早于 P2/P5 同真值落地;默认等 0.8,不得等待 P6。 +- P7 不得早于 P2/P5 同真值落地;走 `0.7.x`,不得等待 P6,也不得另开 `0.8`。 - P8 不得早于 P5:先保证旧 adapter 世界里衰减已经生效。 - P11 不得早于 P9。P12a 不得早于 P12;无 hook 宿主上 P12a 必须仍使 compile 成功。 -- P6 `verify-bundle` 实现可与 P6 export 同文件,但 0.7 tag **不**以其为硬门禁。 +- P6 `verify-bundle` 实现可与 P6 export 同文件。`0.7.0` tag **不**以其为硬门禁;后续 `0.7.x` 才可把它加成发布门,且仍不因此打 `1.0.0`。 --- @@ -162,7 +162,7 @@ P13 1.0 叙事、schema 冻结、verify-bundle 硬门禁 ### P1 · Proof 派生 - 只读扫描现有任务目录,派生 `gate_log` / `review_verdict` / `signoff` / `integration_heads` / `action_receipt`。这是 `0.7` 的 kind 闭集。算法见**附录 A**。 -- 不派生 `trigger_observation`(0.8+)或把 `external_bundle` 当成新 ZIP;后者若出现,只是已有 evidence ZIP 世代的投影。 +- `0.7.0` 未派生 `trigger_observation`。后续 `0.7.x` 已从 `objectives//triggers/.json` 派生该 kind,只用 `next_probe_at`,不另开 `0.8`。不把 `external_bundle` 当成新 ZIP;后者若出现,只是已有 evidence ZIP 世代的投影。 - 不改写 `review.md`、receipt、ledger。**不**把 ledger 当 gate PASS。 - `produced_at` 只取记录内字段;`generation` 用证据世代或 attempt 世代。身份哈希不含「现在」、mtime、`produced_at`。 - 缺绑定字段 → `inconclusive`,不伪造 live。 @@ -226,15 +226,15 @@ dyro proof export --task ID --bundle PATH - 拒绝 `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。 +- **列车:** `export` 已进 `0.7.0`,标 experimental。`verify-bundle` 硬门禁与叙事锁是后续 `0.7.x` / P13,不另开 `1.0.0` 功能号。 - 验收:单任务多 proof 导出有表驱动测试;干净环境带固定 git 夹具得到与源机相同的**完整性**结论(不是「现在能否 merge」);不提供 git 对象时为 `inconclusive`;机密扫描零命中。 -### P7 · 只读展示(默认 0.8) +### P7 · 只读展示(`0.7.x` 独立 inspect) -- `dyro objective attention ` 与 Console read_model 显示 Proof 状态与稳定 reason。**无**顶层 `dyro attention`。 +- `dyro objective attention ` 与 Console **独立 inspect** 显示 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`。 +- `capture_workspace_read_snapshot` / summary **零新 git I/O**,保持 `proof_inspection=not_inspected`。衰减展示只走独立 inspect,不得把 summary 写成与 `objective attention` 同一套 Proof 入口。打破 `test_console_read_model.py` 的「summary 不探 Git」即为回归。 +- 验收:既有 Console 只读攻击夹具仍绿;浏览器无新写入口;独立 inspect 可展示已投影 Proof;`dyro objective attention` JSON 在 merge 相关 decay 时可含 `PROOF_DECAYED`。 ### P8 · Capability 迁移 @@ -259,7 +259,7 @@ dyro capability test - 探测 `opencode`、`cursor-agent` 等,标记 `discovered_unintegrated`。 - Objective / `task run` 不得因探测成功而选中它们。 -- `dyro tool list` / `tool install` / `tool default` / `dyro open` 不得写入可执行 Card,也不得被 Objective 选中。0.8 Card 只包 adapters。 +- `dyro tool list` / `tool install` / `tool default` / `dyro open` 不得写入可执行 Card,也不得被 Objective 选中。`0.7.x` Card 只包 adapters。 - 验收:PATH 里有假 `opencode` 可执行文件时,自动执行仍 fail-closed。 ### P11 · Host Compiler @@ -285,12 +285,12 @@ dyro capability test - 文档与 CLI 帮助不得把 hook 写成沙箱或隔离。 - 验收:假 hook 表面不得触发 hook 文件;无 hook 的 OpenCode 夹具仍能 compile。 -### P13 · 1.0 门禁 +### P13 · `0.7.x` 可携带核验与叙事锁 - Bundle schema 锁 `schema_version = 1`。 - 发布工件含「陌生人核验」CI:从 sdist 安装的干净环境,用夹具 git 对象跑 `verify-bundle`,断言**完整性**结论,不断言与源机当前 HEAD 的 merge 对错相同。 - README 各语言同步身份句,术语扫描覆盖翻译文件。 -- 验收:缺 P5/P6-export/P12 任一证据,发布工作流拒绝打 `1.0.0` 标签。`verify-bundle` 是 1.0 硬门禁。 +- 验收:缺 P5/P6-export/P12 任一证据,后续 `0.7.x` 发布工作流可以拒绝打新 tag。这是 `0.7.x` 门禁,不是改打 `1.0.0` 的理由。未显式要求不得标 `1.0.0`。 --- @@ -302,13 +302,11 @@ dyro capability test | 把 hook 做成所有宿主的强制门槛 | 已否决(选项 C);P12a 保持可选 | | CI / Linear Trigger provider | 已有 Trigger 扩展点;观察不得完成任务 | | HMAC 审计链 | Witness 已有哈希链;重复造链没有产品增量 | -| 自动 push / 发布 | 1.0 仍显式 | +| 自动 push / 发布 | 仍显式;不因 `0.7.x` 收口而自动发布 | | 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 对错;除非产品显式改口 | @@ -331,13 +329,11 @@ dyro capability test ## 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.7.0 已发布:** P1–P6 + P8–P12a 绿。旧工作区不改 toml 即可 `proof list`;merge / 下游对错与 0.6 相同,merge 错误路径只多 `PROOF_DECAYED` 人话。`0.7.0` tag 检查**不含** `verify-bundle` 硬门禁。 -**0.8 可发布:** P7–P10 绿;旧 adapters 仍跑;未审计命令不能进自动执行;Console 只读展示 Proof 且 summary 无新 git probe。 +**0.7.x 剩余实现已落地:** P7 独立 inspect 展示 Proof,summary 无新 git probe;`trigger_observation` 已派生;P13 陌生人核验与 `0.7.x` 叙事锁。旧 adapters 仍跑;未审计命令不能进自动执行;手改投影不能偷偷继续自动跑。全部打 `0.7.x`,不另开 `0.8.0` / `0.9.0` / `1.0.0`。未要求前不发 `0.7.1`。 -**0.9 可发布:** P11–P12a 绿;换机器重编译后 doctor 通过;手改投影不能偷偷继续自动跑;无 hook 宿主仍能 compile,且文案不把 hook 写成隔离。 - -**1.0 可发布:** P13 绿;干净环境用调用方 git 夹具复验 bundle,**完整性**结论与源机导出时一致;缺 git 对象为 `inconclusive`;身份句在所有 README 语言中一致。 +**1.0.0:** 不是本系列功能出口。未显式要求不得标 `1.0.0`。 任一出口的「绿」指:单测、现有 unittest 全量、ruff 基线、术语扫描、以及该阶段新增的 fail-closed 夹具。 @@ -356,6 +352,6 @@ dyro capability test | `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 | +| `action_receipt` | Objective 目录 `action-receipts/`(`action_journal.py`),**不是** task 目录 | `objective_id` | intent / authority / budget 字段 | `created_at` | journal 世代 | 字段或世代被替换 → `decayed`。`proof list --task` **不返回**;`--objective` 或后续 `0.7.x` 再暴露 CLI | 缺文件、缺工具、不可解析 → `inconclusive`,不得 `live`。 diff --git a/pyproject.toml b/pyproject.toml index bf4eac4..d8de198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dyro" -version = "0.7.0" +version = "0.7.1" description = "DyroEngineeringFlow: local-first automation and delivery control for multi-repository teams" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dyro/console/_inspect_worker.py b/src/dyro/console/_inspect_worker.py index 7dfd415..9a70459 100644 --- a/src/dyro/console/_inspect_worker.py +++ b/src/dyro/console/_inspect_worker.py @@ -320,6 +320,11 @@ def main(argv: list[str] | None = None) -> int: if not isinstance(alias, str): raise ConsoleOverviewError("WORKSPACE_ALIAS_INVALID") payload = _isolated_workspace(service, alias) + elif operation == "inspect_proofs": + alias = request.get("alias") + if not isinstance(alias, str): + raise ConsoleOverviewError("WORKSPACE_ALIAS_INVALID") + payload = service.inspect_proofs(alias) else: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") return _response(payload) diff --git a/src/dyro/console/assets.py b/src/dyro/console/assets.py index 1f62944..396db97 100644 --- a/src/dyro/console/assets.py +++ b/src/dyro/console/assets.py @@ -27,13 +27,13 @@ class ConsoleAsset: ), "app.js": ( "text/javascript; charset=utf-8", - "3ce02999da246edc48caceca5a23bc258478e53c333c4bfb151fb06a6bcba7d2", - 15196, + "bfc6909d39325d2b5ab2f59357889cbc42f1c772e754b79b1be82947085b033e", + 16993, ), "styles.css": ( "text/css; charset=utf-8", - "c622a5eda03264a2495f15830a8a061308a3bed6235c6425155b8995eb5069b9", - 10178, + "bd351f50ab998530cc08ab2a0f36ba2d8f9054a17ac8b1269976235db8bca0d8", + 10509, ), } diff --git a/src/dyro/console/assets/app.js b/src/dyro/console/assets/app.js index c3562a4..e32d96c 100644 --- a/src/dyro/console/assets/app.js +++ b/src/dyro/console/assets/app.js @@ -283,6 +283,48 @@ function renderOverview(payload) { for (const summary of data.workspaces) list.append(renderWorkspaceCard(summary)); } +function renderProofInspect(inspect) { + const inspection = text(inspect && inspect.proof_inspection); + const section = element("div"); + section.className = "proof-inspect"; + section.append(element("h3", inspection === "inspected" ? "Proof 已检查" : "Proof 未检查")); + if (inspection !== "inspected") { + section.append(element("p", "摘要保持未检查。独立检查失败时不会把摘要标成已检查。")); + return section; + } + const proofs = Array.isArray(inspect.proofs) ? inspect.proofs : []; + if (!proofs.length) { + section.append(element("p", "没有可展示的 Proof。")); + return section; + } + const list = element("ul"); + for (const proof of proofs) { + const kind = text(proof.kind); + const status = text(proof.status); + const reason = text(proof.decay_reason); + list.append(element("li", reason ? `${kind} · ${status} · ${reason}` : `${kind} · ${status}`)); + } + section.append(list); + const decayed = []; + for (const objective of Array.isArray(inspect.objectives) ? inspect.objectives : []) { + for (const item of objective.attention || []) { + if (text(item.reason) === "PROOF_DECAYED") decayed.push(text(objective.id)); + } + } + if (decayed.length) section.append(element("p", `已投影衰减:${decayed.join("、")}`)); + return section; +} + +async function loadProofInspect(alias) { + try { + const payload = await request(`/api/v1/workspaces/${encodeURIComponent(alias)}/proofs`, `proofs:${alias}`); + if (payload && payload.data) return renderProofInspect(payload.data); + } catch (error) { + if (error && error.message === "SESSION_EXPIRED") throw error; + } + return renderProofInspect({ proof_inspection: "not_inspected", proofs: [], objectives: [] }); +} + function definition(label, value) { const wrapper = element("div"); wrapper.append(element("dt", label), element("dd", value)); @@ -312,6 +354,7 @@ async function loadWorkspace(alias, silent = false) { content.replaceChildren(grid); const command = text(summary.recommendation && summary.recommendation.command); if (command) content.append(commandRow(command)); + content.append(await loadProofInspect(alias)); detail.hidden = false; $("detail-heading").focus(); } catch (error) { diff --git a/src/dyro/console/assets/styles.css b/src/dyro/console/assets/styles.css index b013706..77bae94 100644 --- a/src/dyro/console/assets/styles.css +++ b/src/dyro/console/assets/styles.css @@ -211,6 +211,11 @@ code { .detail-grid div { border-top: 1px solid var(--border); padding-top: .75rem; } .detail-grid dt { color: var(--subtle); font-size: .8rem; } .detail-grid dd { font-weight: 750; margin: .25rem 0 0; } +.proof-inspect { border-top: 1px solid var(--border); margin-top: 1.15rem; padding-top: 1rem; } +.proof-inspect h3 { font-size: .95rem; margin: 0 0 .5rem; } +.proof-inspect p, .proof-inspect ul { color: var(--subtle); font-size: .86rem; margin: 0; } +.proof-inspect ul { padding-left: 1.1rem; } +.proof-inspect li { margin: .2rem 0; } .command { align-items: center; background: var(--surface-muted); border: 1px solid var(--border); border-radius: var(--radius-medium); display: flex; gap: .75rem; margin-top: 1rem; padding: .9rem; } .command code { flex: 1; } .command button { background: var(--accent); color: var(--accent-contrast); flex: 0 0 auto; } diff --git a/src/dyro/console/inspection.py b/src/dyro/console/inspection.py index 5ed8a3b..dbc8915 100644 --- a/src/dyro/console/inspection.py +++ b/src/dyro/console/inspection.py @@ -98,6 +98,9 @@ def page(self, *, cursor: str | None = None, limit: int = 20) -> dict[str, objec def workspace(self, alias: str) -> dict[str, object]: return self._request({"op": "workspace", "alias": alias}) + def inspect_proofs(self, alias: str) -> dict[str, object]: + return self._request({"op": "inspect_proofs", "alias": alias}) + def _request(self, request: Mapping[str, object]) -> dict[str, object]: worker_request = dict(request) if self._target_root is not None: @@ -267,6 +270,9 @@ def _warning_codes(value: list[object]) -> list[str]: def _validate_data( cls, data: dict[str, object], *, expected_operation: str | None ) -> None: + if expected_operation == "inspect_proofs": + cls._validate_inspect(data) + return if set(data) == {"workspace"}: if expected_operation == "overview": raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") @@ -311,6 +317,60 @@ def _validate_data( ): raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + @classmethod + def _validate_inspect(cls, data: dict[str, object]) -> None: + if set(data) != {"proof_inspection", "proofs", "objectives"}: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if data.get("proof_inspection") not in {"not_inspected", "inspected"}: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + proofs = data["proofs"] + if not isinstance(proofs, list) or len(proofs) > 1000: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + for item in proofs: + if not isinstance(item, dict) or set(item) != { + "id", + "kind", + "subject", + "status", + "decay_reason", + }: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if safe_sha256(item.get("id")) != item.get("id"): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if ( + not cls._safe_code(item.get("kind")) + or not cls._safe_code(item.get("subject")) + or not cls._safe_code(item.get("status")) + ): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + reason = item.get("decay_reason") + if reason != "" and not cls._safe_code(reason): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + objectives = data["objectives"] + if not isinstance(objectives, list) or len(objectives) > 1000: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + for objective in objectives: + if not isinstance(objective, dict) or set(objective) != {"id", "attention"}: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if not cls._safe_alias(objective.get("id")): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + attention = objective.get("attention") + if not isinstance(attention, list) or len(attention) > 1000: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + for item in attention: + if not isinstance(item, dict) or set(item) != { + "kind", + "subject_id", + "reason", + }: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if ( + item.get("kind") not in _ATTENTION_KINDS + or not cls._safe_code(item.get("subject_id")) + or not cls._safe_code(item.get("reason")) + ): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + @classmethod def _validate_summary(cls, value: object) -> None: if not isinstance(value, dict) or set(value) != _SUMMARY_KEYS: diff --git a/src/dyro/console/overview.py b/src/dyro/console/overview.py index c252b42..f2fab18 100644 --- a/src/dyro/console/overview.py +++ b/src/dyro/console/overview.py @@ -22,9 +22,13 @@ from ..config import Config, load, validate_id from ..errors import DyroError, ValidationError from ..hub import WorkspaceRegistry, load_registry -from ..observations import WorkspaceReadSnapshot, capture_workspace_read_snapshot +from ..observations import ( + WorkspaceReadSnapshot, + capture_workspace_read_snapshot, + inspect_workspace_read_snapshot, +) from .models import ConsoleEnvelope -from .read_model import workspace_envelope +from .read_model import proof_inspect_data, workspace_envelope from .redaction import REDACTED, safe_id, safe_title @@ -85,6 +89,7 @@ def __init__( registry_loader: Callable[[], WorkspaceRegistry] = load_registry, config_loader: Callable[[Path], Config] = load, snapshot_loader: Callable[[Config], WorkspaceReadSnapshot] = capture_workspace_read_snapshot, + inspect_loader: Callable[[Config], WorkspaceReadSnapshot] = inspect_workspace_read_snapshot, clock: Callable[[], datetime] = _utc_now, cursor_secret: bytes | None = None, summary_loader: Callable[ @@ -99,6 +104,7 @@ def __init__( self._registry_loader = registry_loader self._config_loader = config_loader self._snapshot_loader = snapshot_loader + self._inspect_loader = inspect_loader self._clock = clock self._cursor_secret = cursor_secret or secrets.token_bytes(32) self._summary_loader = summary_loader @@ -163,6 +169,33 @@ def workspace(self, alias: str) -> dict[str, object]: ) return self._envelope({"workspace": summary}, warning_codes) + def inspect_proofs(self, alias: str) -> dict[str, object]: + """Independent Proof inspect. Must not use the summary snapshot_loader.""" + try: + alias = validate_id(alias, "工作区别名") + except ValidationError: + raise ConsoleOverviewError("WORKSPACE_ALIAS_INVALID") from None + registry = self._load_registry() + try: + record = next(item for item in registry.workspaces if item.name == alias) + except StopIteration: + raise ConsoleOverviewError("WORKSPACE_NOT_FOUND") from None + try: + config = self._config_loader(record.root) + snapshot = self._inspect_loader(config) + warning_codes = {_safe_code(failure.code) for failure in snapshot.failures} + warning_codes.discard("REDACTED") + return self._envelope(proof_inspect_data(snapshot), warning_codes) + except (DyroError, ValidationError, OSError, UnicodeError): + return self._envelope( + { + "proof_inspection": "not_inspected", + "proofs": [], + "objectives": [], + }, + {"WORKSPACE_UNAVAILABLE"}, + ) + def _envelope( self, data: dict[str, object], warning_codes: set[str] ) -> dict[str, object]: diff --git a/src/dyro/console/read_model.py b/src/dyro/console/read_model.py index 9b43850..51d60c2 100644 --- a/src/dyro/console/read_model.py +++ b/src/dyro/console/read_model.py @@ -7,7 +7,7 @@ from ..canonical import canonical_json_bytes from ..observations import WorkspaceReadSnapshot from .models import ConsoleEnvelope -from .redaction import safe_branch, safe_id, safe_sha256, safe_title +from .redaction import REDACTED, safe_branch, safe_id, safe_sha256, safe_title def _action_payload(action: object) -> dict[str, str]: @@ -94,6 +94,52 @@ def _data(snapshot: WorkspaceReadSnapshot) -> dict[str, object]: } +def proof_inspect_data(snapshot: WorkspaceReadSnapshot) -> dict[str, object]: + """Whitelisted inspect facts. No argv, paths, logs, or procedure text.""" + return { + "proof_inspection": ( + snapshot.proof_inspection + if snapshot.proof_inspection in {"not_inspected", "inspected"} + else "not_inspected" + ), + "proofs": [ + { + "id": safe_sha256(item.id), + "kind": safe_id(item.kind), + "subject": safe_id(item.subject), + "status": safe_id(item.status), + "decay_reason": safe_id(item.decay_reason) if item.decay_reason else "", + } + for item in snapshot.proofs + if safe_sha256(item.id) != REDACTED + ], + "objectives": [ + { + "id": safe_id(objective.id), + "attention": [_attention_payload(item) for item in objective.attention], + } + for objective in snapshot.objectives + if safe_id(objective.id) != REDACTED + ], + } + + +def proof_inspect_envelope(snapshot: WorkspaceReadSnapshot) -> dict[str, object]: + """Path-free Proof inspect DTO. Separate from summary workspace_envelope.""" + data = proof_inspect_data(snapshot) + digest = hashlib.sha256(canonical_json_bytes(data)).hexdigest() + warnings = tuple(sorted({failure.code for failure in snapshot.failures})) + partial = snapshot.completeness != "complete" + return ConsoleEnvelope( + captured_at=snapshot.observed_at, + snapshot_sha256=digest, + freshness_state="partial" if partial else "fresh", + partial=partial, + warnings=warnings, + data=data, + ).to_payload() + + def workspace_envelope(snapshot: WorkspaceReadSnapshot) -> dict[str, object]: """Return the shared Console response envelope for one Core capture. diff --git a/src/dyro/console/server.py b/src/dyro/console/server.py index bb1985b..c1d5dee 100644 --- a/src/dyro/console/server.py +++ b/src/dyro/console/server.py @@ -331,7 +331,7 @@ def _dispatch(self) -> None: return if self._authorized_session() is None: return - self._workspace_summary(parsed.path) + self._workspace_resource(parsed.path) return if parsed.path.startswith("/api/"): self._error(401, "UNAUTHORIZED") @@ -362,17 +362,19 @@ def _overview(self, query: str) -> None: return self._json(200, payload, etag=str(payload.get("snapshot_sha256", ""))) - def _workspace_summary(self, path: str) -> None: + def _workspace_resource(self, path: str) -> None: service = self.console.overview_service if service is None: self._error(404, "NOT_FOUND") return - alias = path.removeprefix("/api/v1/workspaces/") + remainder = path.removeprefix("/api/v1/workspaces/") + inspect = remainder.endswith("/proofs") + alias = remainder[: -len("/proofs")] if inspect else remainder if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}", alias): self._error(400, "WORKSPACE_ALIAS_INVALID") return try: - payload = service.workspace(alias) + payload = service.inspect_proofs(alias) if inspect else service.workspace(alias) except ConsoleOverviewError as exc: self._error(self._overview_error_status(exc.code), exc.code) return diff --git a/src/dyro/observations.py b/src/dyro/observations.py index c72d398..c35ea56 100644 --- a/src/dyro/observations.py +++ b/src/dyro/observations.py @@ -74,6 +74,15 @@ class ObjectiveAttentionObservation: reason: str +@dataclass(frozen=True) +class WorkspaceProofObservation: + id: str + kind: str + subject: str + status: str + decay_reason: str + + @dataclass(frozen=True) class WorkspaceObjectiveObservation: id: str @@ -114,6 +123,7 @@ class WorkspaceReadSnapshot: lines: tuple[WorkspaceLineObservation, ...] tasks: tuple[WorkspaceTaskObservation, ...] objectives: tuple[WorkspaceObjectiveObservation, ...] + proofs: tuple[WorkspaceProofObservation, ...] = () failures: tuple[ReadFailure, ...] = () @@ -143,12 +153,18 @@ def _task_observation(item: object) -> WorkspaceTaskObservation: ) -def _objective_observation(config: Config, record: object, *, clock: Callable[[], datetime]) -> WorkspaceObjectiveObservation: +def _objective_observation( + config: Config, + record: object, + *, + clock: Callable[[], datetime], + inspect_proofs: bool = False, +) -> WorkspaceObjectiveObservation: snapshot = build_scheduler_snapshot( config, objective=record, clock=clock, - inspect_integration=False, + inspect_integration=inspect_proofs, ) plan = build_continuation_plan(snapshot) scheduler = build_scheduler_projection(snapshot, plan) @@ -201,11 +217,15 @@ def _revision_payload( tasks: tuple[WorkspaceTaskObservation, ...], objectives: tuple[WorkspaceObjectiveObservation, ...], failures: tuple[ReadFailure, ...], + proof_inspection: str = "not_inspected", + proofs: tuple[WorkspaceProofObservation, ...] = (), ) -> dict[str, object]: - return { + payload: dict[str, object] = { "schema_version": READ_SNAPSHOT_SCHEMA_VERSION, "workspace_name": workspace_name, - "proof_inspection": "not_inspected", + "proof_inspection": ( + proof_inspection if proof_inspection in {"not_inspected", "inspected"} else "not_inspected" + ), "lines": [ { "id": item.id, @@ -256,6 +276,18 @@ def _revision_payload( ], "failures": [failure.__dict__ for failure in failures], } + if proof_inspection == "inspected": + payload["proofs"] = [ + { + "id": item.id, + "kind": item.kind, + "subject": item.subject, + "status": item.status, + "decay_reason": item.decay_reason, + } + for item in proofs + ] + return payload def capture_workspace_read_snapshot( @@ -327,7 +359,7 @@ def sample_clock() -> datetime: try: records = list_objectives(config, recover=False) objectives = tuple( - _objective_observation(config, record, clock=sample_clock) + _objective_observation(config, record, clock=sample_clock, inspect_proofs=False) for record in records ) source_digests.extend( @@ -359,5 +391,145 @@ def sample_clock() -> datetime: lines=lines, tasks=tasks, objectives=objectives, + proofs=(), + failures=frozen_failures, + ) + + +def inspect_workspace_read_snapshot( + config: Config, + *, + clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), +) -> WorkspaceReadSnapshot: + """Independent Proof inspect. Must not be used by Console summary.""" + observed_at = _utc(clock()) + + def sample_clock() -> datetime: + return observed_at + + failures: list[ReadFailure] = [] + source_digests: list[tuple[str, str]] = [] + + try: + scheduler_snapshot = build_scheduler_snapshot( + config, + clock=sample_clock, + inspect_integration=True, + ) + tasks = tuple(_task_observation(item) for item in scheduler_snapshot.tasks) + source_digests.append(("tasks", scheduler_snapshot.snapshot_sha256)) + except (DyroError, ValidationError, OSError, UnicodeError): + tasks = () + failures.append(ReadFailure("tasks", "TASKS_UNAVAILABLE")) + + try: + lines = tuple( + WorkspaceLineObservation( + id=line.id, + kind=line.kind, + branch=line.branch, + base=line.base, + repository_count=len(line.repositories), + ) + for line in list_lines(config) + ) + source_digests.append( + ( + "lines", + hashlib.sha256( + canonical_json_bytes( + [ + { + "id": item.id, + "kind": item.kind, + "branch": item.branch, + "base": item.base, + "repository_count": item.repository_count, + } + for item in lines + ] + ) + ).hexdigest(), + ) + ) + except (DyroError, ValidationError, OSError, UnicodeError): + lines = () + failures.append(ReadFailure("lines", "LINES_UNAVAILABLE")) + + objectives: tuple[WorkspaceObjectiveObservation, ...] + try: + records = list_objectives(config, recover=False) + objectives = tuple( + _objective_observation(config, record, clock=sample_clock, inspect_proofs=True) + for record in records + ) + source_digests.extend( + (f"objective:{item.id}", item.event_sha256) for item in objectives + ) + except (DyroError, ValidationError, OSError, UnicodeError): + objectives = () + failures.append(ReadFailure("objectives", "OBJECTIVES_UNAVAILABLE")) + + proofs: tuple[WorkspaceProofObservation, ...] + try: + from .proof import list_proofs + + proofs = tuple( + WorkspaceProofObservation( + id=proof.id, + kind=proof.kind.value, + subject=proof.subject, + status=proof.status.value, + decay_reason=proof.decay_reason, + ) + for proof in list_proofs(config) + ) + source_digests.append( + ( + "proofs", + hashlib.sha256( + canonical_json_bytes( + [ + { + "id": item.id, + "kind": item.kind, + "status": item.status, + "decay_reason": item.decay_reason, + } + for item in proofs + ] + ) + ).hexdigest(), + ) + ) + except (DyroError, ValidationError, OSError, UnicodeError): + proofs = () + failures.append(ReadFailure("proofs", "PROOFS_UNAVAILABLE")) + + frozen_failures = tuple(sorted(failures, key=lambda item: (item.component, item.code))) + payload = _revision_payload( + workspace_name=config.name, + lines=lines, + tasks=tasks, + objectives=objectives, + failures=frozen_failures, + proof_inspection="inspected", + proofs=proofs, + ) + revision = hashlib.sha256(canonical_json_bytes(payload)).hexdigest() + frozen_sources = tuple(sorted(source_digests)) + return WorkspaceReadSnapshot( + schema_version=READ_SNAPSHOT_SCHEMA_VERSION, + workspace_name=config.name, + observed_at=observed_at, + capture_id=f"capture-{revision[:24]}", + workspace_revision=revision, + source_digests=frozen_sources, + completeness="complete" if not frozen_failures else "partial", + proof_inspection="inspected", + lines=lines, + tasks=tasks, + objectives=objectives, + proofs=proofs, failures=frozen_failures, ) diff --git a/src/dyro/proof/__init__.py b/src/dyro/proof/__init__.py index 913dfdb..c4599c6 100644 --- a/src/dyro/proof/__init__.py +++ b/src/dyro/proof/__init__.py @@ -2,7 +2,7 @@ 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 .derive import derive_objective_proofs, derive_task_proofs, derive_trigger_proofs, list_proofs from .evaluate import decayed_merge_subjects, evaluate_proof, evaluate_proofs, live_merge_evidence from .models import ( DecayDecision, @@ -32,6 +32,7 @@ "decayed_merge_subjects", "derive_objective_proofs", "derive_task_proofs", + "derive_trigger_proofs", "evaluate_proof", "evaluate_proofs", "export_bundle", diff --git a/src/dyro/proof/decay.py b/src/dyro/proof/decay.py index 2f7e139..4cf8f51 100644 --- a/src/dyro/proof/decay.py +++ b/src/dyro/proof/decay.py @@ -13,6 +13,7 @@ GATE_BYTES = "gate_bytes" GATE_ARGV = "gate_argv" ACTION_RECEIPT_BYTES = "action_receipt_bytes" +NEXT_PROBE_AT = "next_probe_at" CURRENT_SUBSTRATE_MISSING = "current_substrate_missing" PREDICATE_INCONCLUSIVE = "predicate_inconclusive" STILL_BOUND = "still_bound" @@ -46,6 +47,7 @@ def decay( signoff_ok: bool | None = None, integration_ok: bool | None = None, line_prepare_ok: bool | None = None, + probe_due: bool | None = None, ) -> DecayDecision: """Project live/decayed/inconclusive from injected facts. @@ -92,6 +94,13 @@ def decay( ) if proof.kind is ProofKind.ACTION_RECEIPT: return _bytes_kind(proof, current, clock=clock, mismatch_reason=ACTION_RECEIPT_BYTES) + if proof.kind is ProofKind.TRIGGER_OBSERVATION: + return _from_predicate( + None if probe_due is None else (not probe_due), + live_reason=STILL_BOUND, + decay_reason=NEXT_PROBE_AT, + clock=clock, + ) return _decision(ProofStatus.INCONCLUSIVE, PREDICATE_INCONCLUSIVE, clock) diff --git a/src/dyro/proof/derive.py b/src/dyro/proof/derive.py index 1f80c72..56cf183 100644 --- a/src/dyro/proof/derive.py +++ b/src/dyro/proof/derive.py @@ -9,7 +9,7 @@ from typing import Iterable from ..canonical import canonical_json_bytes -from ..config import Config +from ..config import Config, validate_id from ..errors import DyroError, ValidationError from ..evidence_store import current_evidence_directory, resolve_evidence_path from ..process import run @@ -45,6 +45,8 @@ def list_proofs( proofs: list[Proof] = [] for task in tasks: proofs.extend(derive_task_proofs(config, task)) + if not line_id: + proofs.extend(derive_trigger_proofs(config)) derived = tuple(_dedupe(proofs)) if not evaluate: return derived @@ -126,6 +128,7 @@ def derive_objective_proofs(config: Config, objective_id: str) -> tuple[Proof, . produced_at=produced_at, ) ) + proofs.extend(derive_trigger_proofs(config, objective_id=objective_id)) return tuple(proofs) @@ -305,6 +308,95 @@ def _derive_signoff(config: Config, task: Task) -> Proof | None: ) +def derive_trigger_proofs( + config: Config, + *, + objective_id: str | None = None, +) -> tuple[Proof, ...]: + """Read ``objectives//triggers/.json``. Uses ``next_probe_at`` only.""" + root = getattr(config, "objectives_dir", None) + if root is None or not Path(root).is_dir(): + return () + proofs: list[Proof] = [] + names: Iterable[str] + if objective_id: + try: + validate_id(objective_id, "Objective ID") + except ValidationError: + return () + names = (objective_id,) + else: + names = tuple(path.name for path in Path(root).iterdir() if path.is_dir()) + for name in names: + try: + validate_id(name, "Objective ID") + except ValidationError: + continue + directory = Path(root) / name / "triggers" + if not directory.is_dir(): + continue + for path in sorted(directory.glob("*.json")): + proof = _derive_trigger_observation(config, name, path) + if proof is not None: + proofs.append(proof) + return tuple(_dedupe(proofs)) + + +def _derive_trigger_observation(config: Config, objective_id: str, path: Path) -> Proof | None: + trigger_id = path.stem + try: + validate_id(trigger_id, "Trigger ID") + except ValidationError: + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeError): + return _inconclusive( + config, + kind=ProofKind.TRIGGER_OBSERVATION, + subject=trigger_id, + generation="", + identity={"objective_id": objective_id}, + procedure="objective triggers/.json; next_probe_at", + extra=(("unparseable", "trigger.json"),), + ) + if not isinstance(payload, dict): + return _inconclusive( + config, + kind=ProofKind.TRIGGER_OBSERVATION, + subject=trigger_id, + generation="", + identity={"objective_id": objective_id}, + procedure="objective triggers/.json; next_probe_at", + extra=(("unparseable", "trigger.json"),), + ) + state = str(payload.get("state", "") or "") + summary = str(payload.get("summary", "") or "") + evidence_ref = str(payload.get("evidence_ref", "") or "") + next_probe_at = str(payload.get("next_probe_at", "") or "") + produced_at = str(payload.get("observed_at", "") or "") + extras: list[tuple[str, str]] = [("state", state)] + if next_probe_at: + extras.append(("next_probe_at", next_probe_at)) + if evidence_ref and "/" not in evidence_ref and "\\" not in evidence_ref and not evidence_ref.startswith("~"): + extras.append(("evidence_ref", evidence_ref)) + generation = _sha256_json({"state": state, "summary": summary, "evidence_ref": evidence_ref}) + return _build( + config, + kind=ProofKind.TRIGGER_OBSERVATION, + subject=trigger_id, + generation=generation, + identity={"objective_id": objective_id}, + procedure="objective triggers/.json; next_probe_at", + bytes_sha256=_sha256_bytes(path.read_bytes()), + substrate=ProofSubstrate( + contract_hash="", + extra=tuple(extras), + ), + produced_at=produced_at, + ) + + 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(): diff --git a/src/dyro/proof/evaluate.py b/src/dyro/proof/evaluate.py index 5653b64..e0df6f3 100644 --- a/src/dyro/proof/evaluate.py +++ b/src/dyro/proof/evaluate.py @@ -46,6 +46,7 @@ def evaluate_proof( review_ok: bool | None = None signoff_ok: bool | None = None integration_ok: bool | None = None + probe_due: bool | None = None current: ObservedSubstrate | None = None if proof.kind is ProofKind.REVIEW_VERDICT: @@ -56,6 +57,8 @@ def evaluate_proof( integration_ok = _integration_ok(config, proof) elif proof.kind in {ProofKind.GATE_LOG, ProofKind.ACTION_RECEIPT}: current = _current_bytes(config, proof) + elif proof.kind is ProofKind.TRIGGER_OBSERVATION: + probe_due = _probe_due(proof, observed_at) decision = decay( proof, @@ -64,6 +67,7 @@ def evaluate_proof( review_ok=review_ok, signoff_ok=signoff_ok, integration_ok=integration_ok, + probe_due=probe_due, ) updated = replace(proof, status=decision.status, decay_reason=decision.reason, observed_at=decision.observed_at) return _refresh_integration_state(updated) @@ -179,6 +183,19 @@ def _integration_ok(config: Config, proof: Proof) -> bool | None: return None +def _probe_due(proof: Proof, observed_at: datetime) -> bool | None: + raw = dict(proof.substrate.extra).get("next_probe_at", "") + if not raw: + return None + try: + due = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + if due.tzinfo is None: + return None + return observed_at >= due.astimezone(timezone.utc) + + def _current_bytes(config: Config, proof: Proof) -> ObservedSubstrate | None: try: if proof.kind is ProofKind.ACTION_RECEIPT: diff --git a/src/dyro/proof/models.py b/src/dyro/proof/models.py index 4290d3b..1504145 100644 --- a/src/dyro/proof/models.py +++ b/src/dyro/proof/models.py @@ -17,6 +17,7 @@ class ProofKind(str, Enum): SIGNOFF = "signoff" INTEGRATION_HEADS = "integration_heads" ACTION_RECEIPT = "action_receipt" + TRIGGER_OBSERVATION = "trigger_observation" BUNDLE_FAILURE = "bundle_failure" diff --git a/tests/test_console_inspection.py b/tests/test_console_inspection.py index 5c6ca80..ebb7321 100644 --- a/tests/test_console_inspection.py +++ b/tests/test_console_inspection.py @@ -40,13 +40,17 @@ def test_exec_worker_returns_overview_and_single_workspace_without_root_disclosu overview = service.page(limit=1) workspace = service.workspace("demo") + inspect = service.inspect_proofs("demo") self.assertEqual(overview["data"]["workspaces"][0]["alias"], "demo") self.assertEqual(workspace["data"]["workspace"]["alias"], "demo") self.assertEqual(overview["data"]["workspaces"][0]["availability"], "available") self.assertEqual(workspace["data"]["workspace"]["availability"], "available") + self.assertEqual(inspect["data"]["proof_inspection"], "inspected") + self.assertNotIn("procedure", repr(inspect)) self.assertNotIn(str(self.root), repr(overview)) self.assertNotIn(str(self.root), repr(workspace)) + self.assertNotIn(str(self.root), repr(inspect)) def test_default_workspace_budget_tolerates_process_startup_overhead(self) -> None: clock = [0.0] @@ -177,6 +181,28 @@ def test_parent_rejects_a_digest_consistent_but_unwhitelisted_ipc_payload(self) with self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_UNAVAILABLE"): service._parse_worker_output(raw, expected_operation="overview") + def test_parent_rejects_inspect_payload_with_procedure_or_paths(self) -> None: + service = IsolatedOverviewService( + registry_state_home=self.home, + timeout_seconds=5, + cursor_secret=b"q" * 32, + ) + valid = service.inspect_proofs("demo") + payload = deepcopy(valid) + payload["data"]["procedure"] = "git merge-base --is-ancestor" + payload["snapshot_sha256"] = hashlib.sha256( + canonical_json_bytes( + { + "schema_version": 1, + "freshness": payload["freshness"], + "data": payload["data"], + } + ) + ).hexdigest() + raw = json.dumps({"ok": True, "payload": payload}).encode("utf-8") + with self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_UNAVAILABLE"): + service._parse_worker_output(raw, expected_operation="inspect_proofs") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_console_overview.py b/tests/test_console_overview.py index 32a9d18..b821ab0 100644 --- a/tests/test_console_overview.py +++ b/tests/test_console_overview.py @@ -12,6 +12,7 @@ ObjectiveAttentionObservation, WorkspaceLineObservation, WorkspaceObjectiveObservation, + WorkspaceProofObservation, WorkspaceReadSnapshot, WorkspaceTaskObservation, ) @@ -25,6 +26,8 @@ def _snapshot( reason: str = "TASK_READY", partial: bool = False, failure_code: str = "OBJECTIVES_UNAVAILABLE", + proof_inspection: str = "not_inspected", + proofs: tuple[WorkspaceProofObservation, ...] = (), ) -> WorkspaceReadSnapshot: observed_at = datetime(2026, 8, 4, 12, 0, tzinfo=timezone.utc) failures = () @@ -40,7 +43,8 @@ def _snapshot( workspace_revision="a" * 64, source_digests=(("tasks", "b" * 64),), completeness="partial" if partial else "complete", - proof_inspection="not_inspected", + proof_inspection=proof_inspection, + proofs=proofs, lines=( WorkspaceLineObservation( id="alpha", @@ -229,6 +233,42 @@ def test_single_workspace_reuses_the_same_summary_and_rejects_unsafe_aliases(sel with self.assertRaisesRegex(ConsoleOverviewError, "WORKSPACE_NOT_FOUND"): self.service.workspace("missing") + def test_inspect_proofs_does_not_use_summary_loader_and_can_show_decay(self) -> None: + inspected = _snapshot( + name="Alpha Project", + attention_kind="needs_user", + reason="PROOF_DECAYED", + proof_inspection="inspected", + proofs=( + WorkspaceProofObservation( + id="a" * 64, + kind="review_verdict", + subject="TASK-A", + status="decayed", + decay_reason="review_acceptance", + ), + ), + ) + + def summary_loader(config: object) -> WorkspaceReadSnapshot: + raise AssertionError("summary snapshot_loader must not run during inspect") + + service = ConsoleOverviewService( + registry_loader=lambda: self.registry, + config_loader=self.service._config_loader, + snapshot_loader=summary_loader, + inspect_loader=lambda config: inspected, + clock=lambda: datetime(2026, 8, 4, 12, 5, tzinfo=timezone.utc), + cursor_secret=b"k" * 32, + ) + payload = service.inspect_proofs("alpha") + self.assertEqual(payload["data"]["proof_inspection"], "inspected") + self.assertEqual(payload["data"]["proofs"][0]["status"], "decayed") + self.assertEqual(payload["data"]["objectives"][0]["attention"][0]["reason"], "PROOF_DECAYED") + self.assertNotIn("procedure", repr(payload)) + with self.assertRaisesRegex(ConsoleOverviewError, "WORKSPACE_ALIAS_INVALID"): + service.inspect_proofs("%2fprivate") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_console_read_model.py b/tests/test_console_read_model.py index 17dc568..e583636 100644 --- a/tests/test_console_read_model.py +++ b/tests/test_console_read_model.py @@ -5,11 +5,11 @@ from unittest.mock import patch from dyro.config import load -from dyro.console.read_model import workspace_envelope +from dyro.console.read_model import proof_inspect_envelope, workspace_envelope from dyro.console.models import ConsoleEnvelope from dyro.console.redaction import safe_branch, safe_id, safe_title from dyro.continuation.store import create_objective -from dyro.observations import capture_workspace_read_snapshot +from dyro.observations import capture_workspace_read_snapshot, inspect_workspace_read_snapshot from dyro.tasks import task_template from dyro.workspace import create_line @@ -175,6 +175,27 @@ def test_summary_capture_does_not_evaluate_proofs(self) -> None: self.assertEqual(snapshot.proof_inspection, "not_inspected") self.assertFalse(any(item.reason == "PROOF_DECAYED" for item in snapshot.objectives[0].attention)) + def test_inspect_evaluates_proofs_and_can_project_decayed_attention(self) -> None: + self._objective() + with patch("dyro.proof.evaluate.evaluate_proofs") as evaluate: + evaluate.side_effect = lambda config, proofs, **kwargs: tuple(proofs) + snapshot = inspect_workspace_read_snapshot( + self.config, + clock=lambda: datetime(2026, 8, 4, 12, 0, tzinfo=timezone.utc), + ) + self.assertTrue(evaluate.called) + self.assertEqual(snapshot.proof_inspection, "inspected") + envelope = proof_inspect_envelope(snapshot) + self.assertEqual(envelope["data"]["proof_inspection"], "inspected") + self.assertIn("proofs", envelope["data"]) + self.assertNotIn("procedure", repr(envelope)) + summary = capture_workspace_read_snapshot( + self.config, + clock=lambda: datetime(2026, 8, 4, 12, 0, tzinfo=timezone.utc), + ) + self.assertEqual(summary.proof_inspection, "not_inspected") + self.assertEqual(summary.proofs, ()) + def test_envelope_returns_a_deeply_fresh_json_value(self) -> None: envelope = ConsoleEnvelope( captured_at=datetime(2026, 8, 4, 12, 0, tzinfo=timezone.utc), diff --git a/tests/test_console_server.py b/tests/test_console_server.py index 047c393..6852dbd 100644 --- a/tests/test_console_server.py +++ b/tests/test_console_server.py @@ -244,6 +244,13 @@ def setUp(self) -> None: "freshness": {"state": "partial", "partial": True, "warnings": []}, "data": {"workspace": {"alias": "alpha", "availability": "available"}}, } + self.overview.inspect_proofs.return_value = { + "schema_version": 1, + "captured_at": "2026-08-04T12:00:00+00:00", + "snapshot_sha256": "d" * 64, + "freshness": {"state": "fresh", "partial": False, "warnings": []}, + "data": {"proof_inspection": "inspected", "proofs": [], "objectives": []}, + } self.server = create_console_http_server( port=0, bootstrap_secret="a" * 43, @@ -326,6 +333,27 @@ def test_workspace_summary_is_authenticated_alias_only_and_cacheable(self) -> No json.loads(unsafe_body)["error"]["code"], "WORKSPACE_ALIAS_INVALID" ) + def test_proof_inspect_is_authenticated_get_only(self) -> None: + unauthorized, _, body = self._request("GET", "/api/v1/workspaces/alpha/proofs") + self.assertEqual(unauthorized, 401) + self.assertEqual(json.loads(body)["error"]["code"], "UNAUTHORIZED") + + rejected, _, rejected_body = self._request("POST", "/api/v1/workspaces/alpha/proofs") + self.assertEqual(rejected, 405) + self.assertEqual(json.loads(rejected_body)["error"]["code"], "METHOD_NOT_ALLOWED") + self.overview.inspect_proofs.assert_not_called() + + bearer = self._bearer() + headers = {"Authorization": f"Bearer {bearer}", "Origin": self.origin} + status, response_headers, body = self._request( + "GET", "/api/v1/workspaces/alpha/proofs", headers=headers + ) + self.assertEqual(status, 200) + self.assertEqual(response_headers["ETag"], '"' + "d" * 64 + '"') + self.assertEqual(json.loads(body)["data"]["proof_inspection"], "inspected") + self.overview.inspect_proofs.assert_called_once_with("alpha") + self.overview.workspace.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/tests/test_proof_decay.py b/tests/test_proof_decay.py index 189a289..483d1fd 100644 --- a/tests/test_proof_decay.py +++ b/tests/test_proof_decay.py @@ -28,6 +28,7 @@ GATE_ARGV, GATE_BYTES, LINE_PREPARE_NOT_DECAY, + NEXT_PROBE_AT, PREDICATE_INCONCLUSIVE, REVIEW_ACCEPTANCE, STILL_BOUND, @@ -180,6 +181,37 @@ def test_live_merge_evidence_changes_fingerprint_without_new_field(self) -> None self.assertNotEqual(progress_fingerprint(base), progress_fingerprint(with_live)) self.assertEqual(progress_fingerprint(base), progress_fingerprint(with_decayed)) + def test_trigger_next_probe_at_is_display_decay_only(self) -> None: + proof = _proof(ProofKind.TRIGGER_OBSERVATION) + live = decay(proof, None, clock=CLOCK, probe_due=False) + dead = decay(proof, None, clock=CLOCK, probe_due=True) + unknown = decay(proof, None, clock=CLOCK, probe_due=None) + self.assertEqual(live.status, ProofStatus.LIVE) + self.assertEqual(live.reason, STILL_BOUND) + self.assertEqual(dead.status, ProofStatus.DECAYED) + self.assertEqual(dead.reason, NEXT_PROBE_AT) + self.assertEqual(unknown.status, ProofStatus.INCONCLUSIVE) + trigger = Proof( + id="e" * 64, + kind=ProofKind.TRIGGER_OBSERVATION, + subject="ci-watch", + substrate=ProofSubstrate(extra=(("next_probe_at", "2026-08-15T00:00:00Z"),)), + procedure="objective triggers/ci-watch.json; next_probe_at", + bytes_sha256="aa", + generation="g1", + status=ProofStatus.LIVE, + ) + self.assertEqual(live_merge_evidence((trigger,)), ()) + base = ProgressFacts( + task_states=(("TASK-A", "done"),), + trigger_observations=(("ci-watch", "waiting"),), + ) + churned = ProgressFacts( + task_states=(("TASK-A", "done"),), + trigger_observations=(("ci-watch", "due"),), + ) + self.assertEqual(progress_fingerprint(base), progress_fingerprint(churned)) + def test_planner_emits_proof_decayed_attention_without_blocking_downstream(self) -> None: with self._temp_snapshot() as snapshot: plan = build_continuation_plan(snapshot) diff --git a/tests/test_proof_derive.py b/tests/test_proof_derive.py index a75e7e4..93bcda9 100644 --- a/tests/test_proof_derive.py +++ b/tests/test_proof_derive.py @@ -20,7 +20,14 @@ 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.decay import NEXT_PROBE_AT +from dyro.proof.derive import ( + derive_objective_proofs, + derive_task_proofs, + derive_trigger_proofs, + list_proofs, +) +from dyro.proof.evaluate import evaluate_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 @@ -250,6 +257,48 @@ def test_objective_path_derives_action_receipt(self) -> None: self.assertTrue(receipt.produced_at) self.assertNotIn(receipt, list_proofs(config, task_id="TASK-A")) + def test_trigger_file_derives_stable_identity_and_is_excluded_from_task_filter(self) -> None: + config, _task = self._reviewed_task("TASK-A") + create_objective(config, _objective_contract("release", "TASK-A")) + trigger_dir = config.objectives_dir / "release" / "triggers" + trigger_dir.mkdir(parents=True) + payload = { + "schema_version": 1, + "state": "waiting", + "summary": "probe later", + "evidence_ref": "obs-1", + "next_probe_at": "2026-08-15T00:00:00Z", + "observed_at": "2026-08-14T00:00:00Z", + } + trigger_dir.joinpath("ci-watch.json").write_text(json.dumps(payload), encoding="utf-8") + first = derive_trigger_proofs(config, objective_id="release") + second = derive_trigger_proofs(config, objective_id="release") + self.assertEqual(len(first), 1) + self.assertEqual(first[0].id, second[0].id) + self.assertEqual(first[0].kind, ProofKind.TRIGGER_OBSERVATION) + self.assertEqual(first[0].subject, "ci-watch") + self.assertEqual(dict(first[0].substrate.extra)["next_probe_at"], "2026-08-15T00:00:00Z") + self.assertFalse( + any(proof.kind is ProofKind.TRIGGER_OBSERVATION for proof in list_proofs(config, task_id="TASK-A")) + ) + self.assertTrue( + any(proof.kind is ProofKind.TRIGGER_OBSERVATION for proof in list_proofs(config, objective_id="release")) + ) + self.assertTrue(any(proof.kind is ProofKind.TRIGGER_OBSERVATION for proof in list_proofs(config))) + due = evaluate_proofs( + config, + first, + clock=lambda: datetime(2026, 8, 16, tzinfo=timezone.utc), + ) + early = evaluate_proofs( + config, + first, + clock=lambda: datetime(2026, 8, 14, tzinfo=timezone.utc), + ) + self.assertEqual(due[0].status, ProofStatus.DECAYED) + self.assertEqual(due[0].decay_reason, NEXT_PROBE_AT) + self.assertEqual(early[0].status, ProofStatus.LIVE) + def test_polyrepo_example_lists_empty_without_crash(self) -> None: root = Path(__file__).resolve().parents[1] / "examples" / "polyrepo" config = load(root) diff --git a/tests/test_release_gates.py b/tests/test_release_gates.py index 05700e5..b4d95c9 100644 --- a/tests/test_release_gates.py +++ b/tests/test_release_gates.py @@ -5,6 +5,7 @@ from pathlib import Path import sys import unittest +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] sys.path.append(str(ROOT / "tools")) @@ -30,7 +31,7 @@ def test_physics_train_refuses_published_0_6_9_tag(self) -> None: 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"]) + code = main(["--root", str(ROOT), "--release-tag", "v0.7.1"]) self.assertEqual(code, 0) self.assertIn("0.7 gates present", stdout.getvalue()) self.assertNotIn("1.0 gates present", stdout.getvalue()) @@ -43,3 +44,13 @@ def test_untagged_0_7_runs_0_7_gates(self) -> None: self.assertEqual(code, 0) self.assertIn("0.7 gates present", stdout.getvalue()) self.assertNotIn("1.0 gates present", stdout.getvalue()) + + def test_later_0_7_x_runs_0_7_gates_without_claiming_1_0(self) -> None: + stdout = StringIO() + with patch("verify_release_gates._version", return_value="0.7.1"): + with redirect_stdout(stdout): + code = main(["--root", str(ROOT), "--release-tag", "v0.7.1"]) + 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()) diff --git a/tools/verify_release_gates.py b/tools/verify_release_gates.py index 6fa18af..9450be8 100644 --- a/tools/verify_release_gates.py +++ b/tools/verify_release_gates.py @@ -1,7 +1,9 @@ """Refuse a physics-train release that is missing Proof / Card / Compiler evidence. -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. +A 0.6.x tag of this train is refused. Remaining delivery-physics features ship +as 0.7.x, not 0.8 / 0.9 / 1.0. A 0.7.x tag must pass 0.7 gates and must not be +narrated as a 1.0 release. 1.0.0 is an identity freeze, not this series' feature +number; if that tag is ever cut, it still keeps the stricter stranger contract. """ from __future__ import annotations @@ -36,6 +38,14 @@ ("P0-unconfined", Path("src/dyro/task_dispatch.py"), '"allow_unconfined_provider": False'), ) +SEVEN_X_GATES = ( + ("P7-inspect", Path("src/dyro/observations.py"), "def inspect_workspace_read_snapshot"), + ("P7-console", Path("src/dyro/console/overview.py"), "def inspect_proofs"), + ("P7-route", Path("src/dyro/console/server.py"), "/proofs"), + ("trigger-kind", Path("src/dyro/proof/models.py"), "TRIGGER_OBSERVATION"), + ("trigger-derive", Path("src/dyro/proof/derive.py"), "def derive_trigger_proofs"), +) + def _version(root: Path) -> str: metadata = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) @@ -76,10 +86,10 @@ def main(argv: list[str] | None = None) -> int: 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 version.startswith("0.7."): + missing = missing_gates(root, SEVEN_GATES + SEVEN_X_GATES) if missing: - raise SystemExit("拒绝 0.7.0:缺少 " + ", ".join(missing)) + raise SystemExit(f"拒绝 {version}:缺少 " + ", ".join(missing)) print("0.7 gates present") return 0 if version != "1.0.0" and tag not in {"v1.0.0", "1.0.0"}: diff --git a/uv.lock b/uv.lock index 9a49f40..25e63cb 100644 --- a/uv.lock +++ b/uv.lock @@ -286,7 +286,7 @@ wheels = [ [[package]] name = "dyro" -version = "0.7.0" +version = "0.7.1" source = { editable = "." } dependencies = [ { name = "cryptography" },