diff --git a/.sopify/blueprint/README.md b/.sopify/blueprint/README.md index e8b2bed0..cd0550f5 100644 --- a/.sopify/blueprint/README.md +++ b/.sopify/blueprint/README.md @@ -1,6 +1,6 @@ # 项目蓝图索引 -状态: L2 active +状态: L3 history-ready 维护方式: Sopify 托管自动区块;本页只保留状态、当前目标、当前焦点与阅读入口。 ## 当前目标 @@ -13,9 +13,9 @@ ## 当前焦点 -- 当前无活动 plan;MCP tool plane pilot 已完成 Codex-first 最小注册验证,其他宿主注册保留为证据型待办。 +- 当前无活动 plan;MCP tool plane 已完成 Codex-first 持久注册验证和 Claude 会话级只读调用验证,Qoder / Claude / Copilot 的后续持久注册仍按证据推进。 - P8 Protocol Kernel & Runtime Retirement 已归档至 `../history/2026-06/20260605_p8_protocol_kernel_runtime_retirement`。 -- history 归档:已可用;最近归档:`../history/2026-07/20260626_mcp_tool_plane_pilot`。 +- history 归档:已可用;最近归档:`../history/2026-07/20260717_sopify_entry_preflight`。 ## 深入阅读入口 @@ -28,5 +28,5 @@ - [Sopify 宿主接入规范 (Protocol v0)](./protocol.md) - [Skill 标准对齐蓝图](./skill-standards-refactor.md) - [变更历史](../history/index.md) -- 最近归档:`../history/2026-07/20260626_mcp_tool_plane_pilot` +- 最近归档:`../history/2026-07/20260717_sopify_entry_preflight` diff --git a/.sopify/blueprint/design.md b/.sopify/blueprint/design.md index 7090a0b2..809888fe 100644 --- a/.sopify/blueprint/design.md +++ b/.sopify/blueprint/design.md @@ -356,6 +356,8 @@ ActionProposal 的标量 `side_effect` 字段表达粗粒度权限层级(`none **P8 关键变化**:主链机器真相从 6 个 runtime state 文件收窄为 2 个协议文件(active_plan + current_handoff)。不再存在 runtime gate/router 作为消费者;宿主通过 protocol entry 4 步读顺序消费。 +**会话入口稳定边界**:宿主先识别意图,consult / quick fix 不因陈旧状态进入接续链;`active_plan` 保持纯 `plan_id` 指针,Wave / 任务进度来自方案文件。显式审计非 active plan 时,Verifier 只读目标,宿主校验目标 `plan.md` digest 后仅通过 `sopify_writer` 写方案级 receipt,不切换 active plan / handoff。`session_id` 只作 provenance;只有用户明确并行开发、确认另一任务仍在运行,或写入前发现非本轮状态变化时,才停止当前有副作用的开发。 + **P8 偏好能力退场**:`user/preferences.md` 文件保留(persistence red-line 不可删),但旧 `preferences_preload` installer/doctor capability 在 P8 退场(runtime gate 删除后无自动预加载实现承接)。未来由 protocol entry 重新定义消费方式(TBD)。 ### Legacy Mapping(P8 退场) @@ -532,6 +534,7 @@ output.py 渲染层逐字段分类。只做分类,不做改造决策(改造 - MCP 只承接 active plan / handoff 读取、protocol check 与受 guard 约束的低层 receipt 写入;分析、设计、开发、checkpoint 与 finalize 决策继续由 prompt/skill 和宿主负责。 - repo-local server 为 `scripts/sopify_mcp_server.py`。Codex-first 注册已通过官方 `codex mcp get/add`、真实 stdio tool 调用和 no-op 复验。 +- 2026-07-17 Claude 会话级消费已通过:使用临时 MCP 配置加载同一 repo-local server,成功调用 `sopify.workspace_status_lite`;该证据不等同于持久注册。 - Codex-first 表示验证顺序,不改变 Qoder、Claude、Copilot 的能力梯度或支持声明。其他宿主自动注册须复用同一最小契约并各自补证据。 - 当前不把 Python/MCP 依赖供给、payload 打包、doctor 检查或多宿主配置抽象升级为产品面;只有后续证据证明重复痛点时再设计。 diff --git a/.sopify/blueprint/protocol.md b/.sopify/blueprint/protocol.md index 6b7a5577..eca9c188 100644 --- a/.sopify/blueprint/protocol.md +++ b/.sopify/blueprint/protocol.md @@ -233,6 +233,8 @@ Sopify 接收后由 protocol admission 做结构校验准入,不由生产器 **source**:**MUST** 标识验证器来源,供 Validator 和宿主解释 evidence provenance。是否基于 source 做差异化处理不在当前 normative scope。 +显式审计某个 plan 时,Verifier 仍保持只读并返回 `verdict / evidence / source`。宿主以 `subject_type=plan`、方案目录 `subject_ref` 和目标 `plan.md` 的 SHA-256 `revision_digest` 重新校验主体后,只能通过 `sopify_writer` 写入该方案未占用的 `verify_NNN` receipt;不得切换 active plan、改写 handoff 或修改方案正文。本规则只明确写入责任,不定义新的 Review Wire 或 evidence attachment schema。 + #### Verifier Read-Only Contract(P8 升格) P8 新增 Verifier 写入边界约束: @@ -459,6 +461,8 @@ ExecutionAuthorizationReceipt 是 execute_existing_plan 授权通过后生成的 **顺序设计原则**:active_plan 定位后**先读 plan.md 建立语义真相**,再读 current_handoff 作为恢复提示——避免 handoff 反过来变成第二真相源。 +`workspace_status_lite` 可用时只聚合上述文件事实;不可用时宿主直接按 4 步文件协议继续。MCP 不是 managed action 的入口依赖,也不输出工作流结论。 + ### 8.4 读取预算红线 | 资产 | 默认读取 | 何时扩展 | @@ -490,6 +494,10 @@ host MUST NOT 默认全量扫描 receipts/ 内容。 写 `state/active_plan.json`、`state/current_handoff.json`、`plan//receipts/*.json` 时 MUST 走 `sopify_writer`。Host prompt 负责 request admission 与默认 spec workflow 入口,不负责生成机器真相、不生成计划优先级、不执行验证。 +`sopify_writer` 写 receipt 时 MUST 拒绝覆盖同名文件。调用方选择未占用的既有 `exec_NNN / verify_NNN / final` 标识;本协议不新增编号器、锁或重试机制。 + +session 标识只用于 provenance,不单独构成并发冲突。只有用户明确要求同时开发、宿主确认另一任务仍在运行,或从本轮最近一次已验证快照到首次有副作用开发前出现非本轮已知写入导致的目标 `plan.md` digest / 匹配 handoff 变化,宿主才停止当前写入并询问一次。用户确认其他开发已停止后,宿主重读最新方案状态再继续。 + ### 8.7 链路失败模式(fail-open) | 步 | 文件缺失时 host 行为 | @@ -501,14 +509,23 @@ host MUST NOT 默认全量扫描 receipts/ 内容。 ### 8.8 读后分叉 -| 读到的事实 | Host 行为 | -|---|---| -| 无 active_plan | consult / new-plan | -| 用户请求不指向当前 active_plan | 不自动接续;按 ActionProposal 处理 | -| active_plan 存在且 continue_plan | 按 plan.md + tasks.md 继续 | -| required_host_action = answer_questions | 只展示问题并等待回答 | -| required_host_action = confirm_decision | 只展示选项并等待确认 | -| plan.md 与 handoff 冲突 | 以 plan.md 为准;提示 state conflict | +处理优先序固定为:**本轮用户意图 → active plan 有效性与主体绑定 → 仅消费与 active plan 匹配的 handoff / checkpoint**。同一轮同一冲突只检查、询问一次。 + +| 用户意图与状态 | Host 行为 | 用户交互 | +|---|---|---| +| consult / unmanaged quick_fix,任意方案状态 | 完成当前请求,不进入接续链,不展示无关状态异常 | 无 | +| new_plan,无 active plan | 创建方案并通过 writer 设置新指针 | 无 | +| new_plan,active plan 无效或缺 `plan.md` | 创建方案并由 writer 替换无效指针 | 不追加清理确认 | +| new_plan,active plan 有效 | 确认切换、合并或暂停 | 一次决策 | +| continue_plan,active plan 有效 | 按 4 步协议和方案进度继续;缺 handoff / receipts 属正常降级 | 无 | +| continue_plan,active plan 有效但用户明确指定其他方案 | 确认切换、合并或暂停 | 一次决策 | +| continue_plan,用户明确指定有效方案但 active plan 缺失或无效 | 通过 writer 激活所指方案并继续 | 不追加清理确认 | +| continue_plan / finalize,active plan 无效且未指定目标 | 说明阻断原因和可选目标后停车 | 一次决策 | +| handoff 与 active plan 失配 | 以 `plan.md` 为准,提示一次并继续;下一次正常 handoff 写入自然替换旧提示 | 不等待 | +| 首次进入 continue_plan,匹配 handoff 含 `answer_questions / confirm_decision` | 展示 checkpoint 后停车;失配 handoff 不触发 checkpoint | 一次响应 | +| 用户正在回答匹配 checkpoint | 先绑定当前 plan 与 checkpoint,消费回答并通过 writer 更新一次;不得重显原问题 | 无;无法唯一绑定时提示一次 | +| 显式审计非 active plan | Verifier 只读目标;宿主校验 `plan.md` digest 后通过 writer 写 `verify_NNN` receipt | 无;active plan / handoff 不变 | +| 同一 active plan 出现 §8.6 并行推进信号 | 当前 session 不执行有副作用的开发;可继续只读审计,或等待用户确认后重读最新状态 | 一次决策 | ### 8.9 State 文件索引(P8 post-cutover: 2 文件) @@ -517,6 +534,8 @@ host MUST NOT 默认全量扫描 receipts/ 内容。 | `state/active_plan.json` | 定位:当前 plan_id | ignored | | `state/current_handoff.json` | 恢复:上次停哪 + required_host_action | ignored | +`active_plan.json` 只保存 `plan_id`。Wave 和任务进度属于 `plan.md / tasks.md`,不得写入 active pointer。 + P8 删除的 state 文件:`current_run.json`、`current_plan.json`、`current_clarification.json`、`current_decision.json`、`current_gate_receipt.json`、`current_archive_receipt.json`、`last_route.json`。 `plan/_registry.yaml` [DEPRECATED by P8]:P8 显式退场。不属于协议内核,不作为 host 接续入口,不作为 active plan pointer。协议校验 MUST fail if `_registry.yaml` appears in host entry path。删除理由和未来替代方案见 P8 design.md §4.3。 diff --git a/.sopify/blueprint/tasks.md b/.sopify/blueprint/tasks.md index fb5004cb..1700ac9b 100644 --- a/.sopify/blueprint/tasks.md +++ b/.sopify/blueprint/tasks.md @@ -133,8 +133,7 @@ P0→P4c 主航道已全部完成。后续执行遵循以下原则: 3. 是否新增、删除、替代 action / route / state / checkpoint / receipt 中的任一 machine truth?若是,对照 `design.md` 削减预算表 4. 若涉及 legacy surface,替代 contract 是否已在 `design.md` sunset 表中对应里程碑稳定? 5. 若影响 Core promotion rule / hard max / ownership / validator authority,须补充 ADR impact -- [ ] 补宿主级 first-hop ingress proof / diagnostics -- [ ] MCP 注册扩展证据:沿用 Codex 试点的最小契约验证 Qoder / Claude / Copilot;仅在实测出现重复需求后再决定依赖打包、doctor 和多宿主自动注册 +- [ ] MCP 持久注册扩展证据:沿用 Codex 试点的最小契约验证 Qoder / Claude / Copilot;Claude 会话级只读调用已通过,但不代替持久注册。仅在实测出现重复需求后再决定依赖打包、doctor 和多宿主自动注册 - [ ] `~compare` shortlist facade 收敛进默认主链路 - [ ] blueprint 索引摘要更细粒度自动刷新 - [ ] history feature_key 聚合视图 @@ -144,7 +143,7 @@ P0→P4c 主航道已全部完成。后续执行遵循以下原则: - [ ] 轻量化产品指标与 acceptance gate(首次上手步骤数、必需文件数、默认 workflow 必需 contract 数) - [ ] 产品层 ↔ 实现层 contract matrix 正式化(ownership / admission / lifecycle responsibilities) - [x] GitHub Release pipeline 建立(首次 release 创建 + tag 规范 + install 脚本端到端验证) -- [x] 测试套件健康基线(pass rate ≥ 99%;当前基线 219 tests / 219 passed = 100%,另含 30 passed subtests) +- [x] 测试套件健康基线(pass rate ≥ 99%;当前基线 226 tests / 226 passed = 100%,另含 34 passed subtests) - [ ] Skill packaging / localization governance:skill 打包格式、多语言资产管理、bundle 内 i18n 分层规范 - [ ] Stale stub 检测与错误可观测性:~~诊断信息~~(PR #49 已补 `_stale_stub_diagnostic`);剩余:自动建议 `--target ` 重装或降级到已安装版本(layer 3 auto-fix,用户已明确延后为 opt-in) - [x] Runtime output renderer scope audit:runtime/output.py 已随 P8 W2.10 退场,develop 输出归宿主/skill 所有 @@ -158,6 +157,7 @@ P0→P4c 主航道已全部完成。后续执行遵循以下原则: | Convention 入口兑现差距 | 已并入 | 已转入 P1.5 先行切片 | | ~replay 更多入口 | 终止 | P3b replay 能力已下线 | | 第三方 CLI 宿主能力边界治理(P4a→P4c bridge) | 已消费 | P4c 已消费,Host Capability Governance 已落地 design.md | +| 宿主级 first-hop ingress proof / diagnostics | 已完成 | `20260717_sopify_entry_preflight` 已落地意图优先预检、最小状态事实、静态语义断言与一次 Codex 上下文隔离回放 | ## 明确延后项 diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/assets/entry-preflight-flow.svg b/.sopify/history/2026-07/20260717_sopify_entry_preflight/assets/entry-preflight-flow.svg new file mode 100644 index 00000000..0880b35d --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/assets/entry-preflight-flow.svg @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + Sopify 会话入口状态预检 + 先响应用户意图;只在相关 managed action 中读取状态;授权后才写入 + + + + + + + 1 请求准入 + 2 Managed Preflight + 3 授权写入与单次复核 + + + + + + + + + + + + + + + + + + + 先分类 + 普通请求 + 立即完成 + 进入预检 + 可选 + 客观事实 + 可唯一判断 + 需要用户输入 + 授权动作 + 写入后复核 + + + + + 用户请求 + 问题 / 修复 / 方案操作 + + + + 意图分类 + 先于状态读取 + + + + 直接回答或修复 + consult / quick fix + + + + 完整回复用户 + 不展示无关状态 + + + + + Managed Action + new / continue + finalize / write + + + + + workspace_status_lite + 可选事实聚合 + + + + 4 步文件协议 + active_plan → plan.md + matching handoff → receipts + + + + + 状态判断 + 正常 / 可继续异常 + 需补事实 / 需拍板 + + + + 继续执行 + 静默通过或提示一次 + + + + 只问一次并停车 + 补事实或拍板 + + + + + + canonical writer + 仅执行已授权写入 + + + + 复核一次并回复 + 不重试、不循环 + + + + + 主流程 + + 可选 MCP + + 受权写入 + + diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/background.md b/.sopify/history/2026-07/20260717_sopify_entry_preflight/background.md new file mode 100644 index 00000000..3b0bc1c8 --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/background.md @@ -0,0 +1,61 @@ +# 变更提案: Sopify 会话入口状态预检 + +## 需求背景 + +P8 runtime 退场后,宿主通过 prompt 和文件协议判断是否接续活动方案。现有协议已经规定 consult / quick fix 不自动读取 `active_plan`,但异常状态的处理边界仍不够完整:缺失与冲突容易被一概当成阻断,状态提示也可能抢占用户真正的问题。 + +本方案要补的是一个有限的会话入口状态预检:先识别用户意图,只在 managed plan 操作真正依赖状态链时检查;能够确定的情况直接继续,存在有效方案意图分叉时才向用户询问一次。 + +评分: +- 方案质量: 9/10 +- 落地就绪: 9/10 + +评分理由: +- 优点: 复用 P8 两文件状态模型、现有 `workspace_status_lite` 和宿主 prompt,不恢复 runtime,也不新增诊断平台。 +- 扣分: “先回答用户”属于宿主行为,除自动化契约测试外仍需一次宿主上下文隔离回放验证。 + +## 变更内容 + +1. 在 Host Protocol Entry Contract 中增加意图 × 状态处理矩阵,区分静默放行、非阻断提示和阻断一次。 +2. 扩充 `workspace_status_lite` 的必要客观事实,使宿主能区分 active plan 文件缺失与内容无效,并判断 `plan.md` 是否存在、handoff 是否与活动方案匹配;MCP 不可用时仍按文件协议继续。 +3. 收紧中英文宿主 prompt:用户当前请求优先,异常只阻断它所依赖的 managed plan 操作。 +4. 建立有限状态自动测试,并用一次 Codex 上下文隔离回放验证陈旧状态不会劫持普通问答。 +5. 同步协议、产品说明和 blueprint,完成后按现有 lifecycle 归档。 + +## 影响范围 + +- 模块: Host Protocol Entry Contract、MCP lite status、宿主 prompt、安装资产测试、产品文档与 blueprint。 +- 主要候选文件: + - `.sopify/blueprint/protocol.md` + - `scripts/sopify_mcp_server.py` + - `skills/zh/header.md.template` + - `skills/en/header.md.template` + - `tests/test_sopify_mcp_server.py` + - `tests/test_installer.py` + - `docs/how-sopify-works.md` + - `docs/how-sopify-works.en.md` + - `docs/getting-started.md` + - `.sopify/blueprint/{README,design,tasks}.md` + +## 风险评估 + +- 风险: 入口规则过重,使每轮请求都扫描状态并输出诊断。 +- 缓解: 先做意图分类;consult / quick fix 不进入状态链,健康 managed plan 静默通过。 + +- 风险: 为减少询问而错误覆盖仍有效的活动方案。 +- 缓解: 只有无效旧指针且用户明确发起 new plan 时直接切换;有效方案始终保留切换、合并、暂停的用户决策。 + +- 风险: 只补 prompt 文案,真实宿主仍可能被异常状态劫持。 +- 缓解: 自动化覆盖确定性事实和安装资产,再用一次 Codex 上下文隔离回放验证关键用户路径。 + +- 风险: new plan、旧 checkpoint 和失配 handoff 同时存在时触发多次询问。 +- 缓解: 固定“本轮用户意图 → 活动方案有效性与主体绑定 → 匹配方案的 handoff/checkpoint”优先序,旧方案 checkpoint 不参与 new plan 仲裁。 + +- 风险: 同一 workspace 的两个 session 都基于旧状态准备写入,后者静默覆盖前者。 +- 缓解: session 标识仅写入既有 observability;managed 写入前重读 active plan 与匹配 checkpoint,发现已变化则不覆盖,只对受影响动作提示一次。不在本方案引入锁、租约或 session 注册表。 + +- 风险: 为覆盖异常继续引入状态枚举、诊断记录和修复流程。 +- 缓解: 工具只返回少量客观事实;不新增 machine truth、持久诊断状态或自动修复入口。 + +- 风险: 把 repo-local MCP 试点误写成所有宿主的入口依赖。 +- 缓解: 4 步文件读取始终是规范路径;`workspace_status_lite` 仅在可用时减少重复读取。 diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/design.md b/.sopify/history/2026-07/20260717_sopify_entry_preflight/design.md new file mode 100644 index 00000000..97ac7110 --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/design.md @@ -0,0 +1,119 @@ +# 技术设计: Sopify 会话入口状态预检 + +## 技术方案 + +- 核心技术: P8 Host Protocol Entry Contract、两文件状态读取、可选的 `workspace_status_lite`、中英文宿主 prompt、pytest 与 Codex 上下文隔离回放。 +- 实现要点: + - 先判定用户意图,再决定是否读取状态链;状态预检不能成为全局前置 gate。 + - 4 步文件协议是所有宿主的规范路径;现有 MCP tool 可用时只合并读取客观事实,不新增推理型 tool 或 workflow verdict。 + - 状态异常按“正常、可继续异常、阻断冲突”三类处理,只有第三类等待用户决策。 + - 同一轮同一冲突最多检查一次、询问一次;用户批准写入后通过 canonical writer 执行并验证一次。 + +## 架构设计 + +![Sopify 会话入口状态预检架构](./assets/entry-preflight-flow.svg) + +架构只分三层: + +1. **请求准入**:先判断本轮用户意图。consult / quick fix 直接完成并回复,不进入方案状态链。 +2. **Managed preflight**:new plan、continue、finalize 和协议写入才读取 4 步文件协议;`workspace_status_lite` 只是可选的事实聚合入口。 +3. **授权写入**:可继续的动作只有在获得当前请求授权后才进入 canonical writer;写入前确认状态未被其他 session 推进,写入后只复核一次;需要用户补事实或拍板时只询问一次并停车。 + +SVG 是总链路导读,不承载完整异常枚举。精确行为以下方状态矩阵和 `.sopify/blueprint/protocol.md` 为准,避免形成第二真相源。 + +## 状态处理矩阵 + +处理优先序固定为:**本轮用户意图 → active plan 有效性与主体绑定 → 仅消费与 active plan 匹配的 handoff/checkpoint**。consult / unmanaged quick fix 在第一步结束,不进入后两步;new plan 不响应旧方案 checkpoint,只完成自身的一次切换仲裁。 + +| 用户意图与状态 | 宿主行为 | 用户交互 | +|---|---|---| +| consult / unmanaged quick fix,任意方案状态 | 先完成当前请求,不读取或展示无关接续异常 | 无 | +| new plan,无 active plan | 创建方案并通过 writer 设置新指针 | 无 | +| new plan,active plan 无效或缺 `plan.md` | 创建新方案并由 writer 切到新指针;结果中说明替换了无效指针 | 不追加清理确认 | +| new plan,active plan 有效 | 停车确认切换、合并或暂停 | 一次决策 | +| continue / finalize / 协议写入,active plan 与 `plan.md` 有效 | 按 4 步链继续;缺 handoff / receipts 属正常降级 | 无 | +| continue,用户明确指定有效方案但本地无 active plan | 通过 writer 激活所指方案后继续 | 无 | +| continue,active plan 无效,但用户明确指定的方案有效 | 通过 writer 激活所指方案后继续;旧指针由本次授权动作自然替换 | 不追加清理确认 | +| continue / finalize,active plan 无效且用户未指定目标 | 说明阻断原因和可选目标后停车 | 一次决策 | +| handoff 与 active plan 失配 | 以 `plan.md` 为准,提示一次并继续;后续正常 handoff 写入自然替换 | 不等待 | +| 首次进入 continue,且匹配 active plan 的 handoff 有 `answer_questions` / `confirm_decision` checkpoint | 展示该 checkpoint 后停车;失配 handoff 不得触发 checkpoint | 一次响应 | +| 本轮用户正在回答匹配 active plan 的 checkpoint | 先消费回答并绑定当前 plan/checkpoint,通过 writer 更新一次并复核一次;不得先重显原 checkpoint | 不重问;回答无法唯一绑定时才提示一次 | +| 显式审计非 active plan | 审计器只读目标并返回证据;宿主校验目标主体后通过 writer 写目标方案未占用的 `verify_NNN` receipt;同名 receipt 已存在时拒绝覆盖,不切换 active plan / handoff | 无 | +| 同一 active plan 出现并行推进信号 | 当前 session 不执行有副作用的开发;可继续只读审计,或等待用户确认其他开发已停止后重读最新状态 | 一次决策 | + +## Lite Status 最小事实 + +在保留现有字段的基础上,仅考虑增加: + +- `active_plan_file_exists`: `active_plan.json` 文件是否存在,用于区分正常缺失与内容无效。 +- `active_plan_md_exists`: 活动方案的 `plan.md` 是否存在;无活动方案时为 `null`。 +- `handoff_plan_id`: handoff 指向的方案;无 handoff 时为 `null`。 +- `handoff_matches_active_plan`: 两者是否一致;任一不存在时为 `null`。 + +字段只描述文件事实,不输出 `ready / blocked / repair` 等工作流结论。无效 active plan 定义为:文件存在,但内容不是含合法非空 `plan_id` 的对象,或对应 `plan.md` 缺失。非法 JSON 或读取失败继续走现有结构化 error;是否阻断由用户意图和协议矩阵决定。 + +`plan_id` 用于拼接 plan 路径前必须复用安全子路径校验或等价的 schema 约束;污染值返回结构化 error,不得探测 `plan/` 目录之外。MCP 不可用时,宿主直接按相同文件事实和协议矩阵处理,不得阻断入口。 + +## 防循环约束 + +1. 状态预检只在 managed plan / continuation / finalize / 协议写入入口运行一次。 +2. 同一轮不得因同一个状态冲突重复调用检查或重复询问。 +3. 用户批准状态写入后,只执行一次 canonical writer 写入和一次结果复核。 +4. consult / quick fix 不因陈旧状态转入修复对话。 +5. 不新增持久化“已提示”标记;跨会话再次执行同一受阻 managed action 时,仍以当前事实判断。 + +## 审计旁路写责 + +- 显式审计非 active plan 时,以 `subject_type=plan`、目标方案目录 `subject_ref` 和目标 `plan.md` 的 SHA-256 `revision_digest` 绑定主体;不切换 active plan。 +- 审计器遵守 Verifier Read-Only Contract,只返回 `verdict / evidence / source`,不得写 `state/**`、`plan/**` 或 `blueprint/**`。 +- 宿主重新校验 subject binding 后,仅通过 `sopify_writer` 将结果写入目标方案未占用的 `verify_NNN` receipt;`ProtocolStore` 复用 MCP 入口已有的防覆盖语义,同名文件存在时抛出 `FileExistsError` 并保留原证据。 +- 本方案只补 writer 防覆盖不变量,不新增 receipt 编号器、锁或重试协议,也不升格完整 Review Wire、定义新的 evidence attachment schema 或接入 EvidentLoop。 + +## 多会话最小边界 + +- `active_plan` 仍是 workspace 级纯 `plan_id` 指针,不增加 Wave;Wave 和任务进度以 `plan.md` / `tasks.md` 为准,handoff 只作恢复提示。 +- 宿主能提供 session 标识时,将其写入现有 `current_handoff.observability.provenance`;它只用于定位写入来源。不同 session 标识可能是正常接续,不能单独证明并发。 +- 并行推进信号仅包括:用户明确要求同时开发;宿主确认另一任务仍在运行;从本轮最近一次已验证快照到首次有副作用开发前,目标 `plan.md` digest 或匹配 handoff 出现非本轮已知写入造成的变化。 +- 出现并行推进信号时,当前 session 不执行有副作用的开发,只提示一次:`方案状态出现并行推进信号,本会话暂不写入。你可以继续只读审计,或确认其他开发已停止后在这里继续。` +- 用户确认接续后,宿主必须重读最新 `plan.md`、任务进度和匹配 handoff,再开始开发;无上述信号时不得误拦正常跨 session 接续。 +- 不新增 session state、所有权、注册表、锁文件、心跳或租约;若未来必须并行开发同一方案,另行评估 worktree 和任务拆分,不纳入本方案。 + +## 验证设计 + +### 自动化状态与 writer 守卫 + +- `.sopify/` 不存在或无 active plan。 +- active plan 有效且缺 handoff / receipts。 +- active plan 目录存在但缺 `plan.md`。 +- handoff 与 active plan 一致或失配。 +- active plan 内容为 JSON `null`、数组、缺 `plan_id` 对象或污染路径时返回现有结构化 error,不产生写入。 +- `ProtocolStore` 首次写入 `verify_NNN` 成功;再次写入同一 receipt 时抛出 `FileExistsError`,原文件内容不变。 + +### 宿主资产静态校验 + +- 静态测试断言完整规范句,不以单个关键词出现代替语义检查。 +- 中英文源 prompt 及安装后的 Codex 中文、Claude 英文资产精确保留以下语义: + - 用户正在回答匹配 checkpoint 时先消费回答,不重显同一 checkpoint。 + - 无效旧指针遇到明确 new plan 或明确且有效的 continue 目标时,不追加清理确认。 + - 旧 active plan 仍有效且目标发生切换时,必须等待用户确认。 + - 4 步文件协议是规范路径,MCP 不可用时仍可进入 managed action。 + - machine truth 只通过 `sopify_writer` 写入。 + - `active_plan` 只含 `plan_id`,Wave 和任务进度从方案文件读取。 + - 显式审计非 active plan 时,审计器只读并返回证据;宿主校验目标 `plan.md` digest 后通过 writer 写 `verify_NNN` receipt,active pointer / handoff 不变。 + - 不同 session 标识单独出现不得阻断;只有约定的三类并行推进信号才停止当前有副作用的开发,并只展示一次约定提示。 +- 不引入新的 state、tool、自动修复或强并发承诺表述。 + +### Codex 上下文隔离回放 + +Codex 是本次选取的代表宿主;Claude、Qoder 等仍属于 Sopify 的宿主范围。“上下文隔离”指回放启动不继承当前方案讨论的新 Codex 会话,临时 workspace 只负责隔离文件状态。通过标准是:Codex 完整回答 fixture 问题,不要求先修复状态、不自动写入状态。该回放只执行一次;证据摘要记录新会话标识、fixture 问题、执行命令与宿主版本、回答断言,以及回放前后 `state/` 文件清单与哈希。结果只证明该路径在 Codex 中成立,不代表其他宿主已经实测。 + +## 文档收口 + +- `protocol.md` 作为 normative 处理规则真相源。 +- `docs/how-sopify-works*.md` 与 `docs/getting-started.md` 消除“缺 active plan 自动浏览并接续”的歧义:可以发现候选,但不得脱离用户意图自动恢复。 +- `blueprint/design.md` 只沉淀稳定边界;`blueprint/tasks.md` 在完成后移除对应长期待办;`blueprint/README.md` 仅刷新当前焦点。 + +## 安全与性能 + +- 安全: 不自动清理或迁移 machine truth;有效活动方案的切换始终由用户确认;已有 receipt 不被覆盖。 +- 性能: lite status 只读取固定路径和两个短 JSON 文件,不扫描全量 receipts、history 或工具清单。 diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/plan.md b/.sopify/history/2026-07/20260717_sopify_entry_preflight/plan.md new file mode 100644 index 00000000..6754d846 --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/plan.md @@ -0,0 +1,97 @@ +--- +title: Sopify 会话入口状态预检 +plan_id: 20260717_sopify_entry_preflight +status: completed +lifecycle_state: archived +level: standard +created: 2026-07-17 +updated: 2026-07-17 +archive_ready: true +--- + +# Sopify 会话入口状态预检 + +## Plan Snapshot + +- **Goal**: 让宿主在 managed plan 操作前用有限状态检查判断是否可继续,同时保证普通问答不被陈旧状态劫持。 +- **Status**: 5 个 Wave、双视角独立审计与最小修复均已完成并验证。 +- **Next**: 无活动接续;后续多宿主持久 MCP 注册或 EvidentLoop 审计闭环按 blueprint 独立开包。 +- **Task**: 7/7。 + +## Context / Why + +P8 已规定宿主先识别用户意图,再决定是否读取 `active_plan → plan.md → current_handoff → receipts`。当前规则仍缺少有限状态矩阵和防重复追问约束,`workspace_status_lite` 也只返回目录及文件是否存在,无法稳定区分缺失、失配和可继续状态。 + +真实目标不是建设诊断平台,而是避免两类失败:陈旧状态抢占用户问题;managed plan 遇到状态异常时重复检查、重复询问,却没有推进或收口。 + +## Scope + +- 明确 consult、quick fix、new plan、continue、finalize 和协议写入的预检边界。 +- 给现有 `workspace_status_lite` 补充 plan 与 handoff 的必要客观事实,不新增 MCP tool。 +- 将“正常静默、非阻断提示、阻断一次”落实到中英文宿主 prompt、协议和测试。 +- 用自动化状态矩阵和一次 Codex 上下文隔离回放验证“先回答用户”。 +- 实施完成后同步长期文档并按现有生命周期收口。 + +## Approach + +1. 宿主先分类用户意图;consult 和 unmanaged quick fix 不进入状态接续链。 +2. managed plan 操作只消费现有两文件状态模型及 plan/receipt 事实;处理优先序固定为本轮用户意图、活动方案有效性与主体绑定、匹配该方案的 handoff/checkpoint。 +3. 4 步文件协议是规范路径;`workspace_status_lite` 可用时只合并读取客观事实,不可用时直接按文件协议继续,也不返回工作流 `next_action`。 +4. 可唯一判断的情况直接继续;只有可能改变有效方案意图时才询问,并且同一轮只询问一次。 +5. 用户已明确发起的合法操作可通过 canonical writer 自然替换陈旧提示,不追加“是否清理”的二次确认。 +6. checkpoint 首次出现时展示一次;用户正在回答该 checkpoint 时先消费回答,不重复展示原问题。 +7. `active_plan` 保持 workspace 级纯指针,Wave 留在方案进度中;显式审计其他方案只绑定目标主体并写方案级证据,不切换 active plan。 +8. 多会话只做来源追踪和写前复核;不同 session 标识本身不构成并发冲突,只有约定的三类并行推进信号才停止当前写入。 + +## Waves / Steps + +1. 收紧协议状态矩阵与防循环规则。 +2. 扩充 lite 状态事实,并保持现有工具与两文件状态模型不变。 +3. 同步中英文宿主 prompt 和公开工作流文档。 +4. 补状态矩阵、安装资产静态检查和一次 Codex 上下文隔离回放。 +5. 同步 blueprint、验证后按现有 finalize 流程归档。 + +## Key Decisions + +- 健康状态静默通过;consult 和 quick fix 不展示无关状态异常。 +- 不自动修复 machine truth。只有用户已明确发起新建、接续或写入动作时,canonical writer 才按该授权执行正常状态写入。 +- 无效旧 `active_plan` 遇到明确 new plan 时直接切到新方案;有效旧方案仍需确认切换、合并或暂停。 +- 无效旧 `active_plan` 遇到明确且有效的 continue 目标时,直接激活该方案并继续,不追加清理确认。 +- `plan.md` 是语义真相源;handoff 失配只提示一次并继续,下一次正常 handoff 写入自然替换旧提示。 +- 只有与有效 active plan 匹配的 handoff/checkpoint 才可消费;consult 不消费 checkpoint,new plan 只处理自身的一次方案切换仲裁。 +- checkpoint response 先绑定当前 plan 与匹配 checkpoint,再通过 writer 更新一次;不得先把同一 checkpoint 重新问给用户。 +- MCP 是可选加速层,不是宿主进入 managed plan 的硬依赖。 +- `active_plan` 只保存 `plan_id`;Wave 和任务进度继续以 `plan.md` / `tasks.md` 为准。 +- 显式审计非 active plan 时,审计器只读目标并返回 `verdict / evidence / source`;宿主重新校验目标 `plan.md` 后,仅通过 `sopify_writer` 写目标方案既有的 `verify_NNN` receipt,不切换 active plan 或 handoff;同名 receipt 已存在时 writer 必须拒绝覆盖。 +- session 标识仅作追踪,不单独触发阻断。仅当用户明确要求同时开发、宿主确认另一任务仍在运行,或写入前发现非本轮已知写入导致的目标 digest / 匹配 handoff 变化时,才停止当前有副作用的开发。 +- 发现并行推进信号时只提示一次;用户确认其他开发已停止后,重读最新方案状态再继续。 +- 验收范围为状态矩阵自动测试、宿主 prompt 静态校验和一次 Codex 上下文隔离回放;Codex 是本次代表宿主,不扩展为多宿主实测矩阵。 + +## Constraints / Not-in-scope + +- 不新增 state 文件、持久诊断记录、MCP tool、全量能力注册表或统一诊断平台。 +- 不做自动清理、自动迁移 legacy plan、并发锁、无限重试或异常自愈框架。 +- 不把 persistent MCP、doctor、依赖供给或多宿主自动注册纳入本方案。 +- 不接入 CrossReview / EvidentLoop;其反馈闭环稳定后再作为方案审计环节接入。 +- 只保留一张入口架构 SVG 解释总链路;精确状态规则仍以协议和矩阵为准,不制作第二张图。 + +## Status / Progress + +- [x] 用户确认正常静默、异常不自动修复、无关异常不阻断当前请求。 +- [x] 用户确认无效旧指针的新方案切换边界和最小验收范围。 +- [x] 独立产品评审完成并吸收一轮最小修正。 +- [x] 使用 Fireworks 生成并校验单张入口架构 SVG,嵌入技术设计。 +- [x] 独立复审图文一致性、蓝图符合度和整体闭环;结论为 `approve_with_minor_changes`。 +- [x] 局部收口 checkpoint response、明确 continue 目标、静态语义断言与多会话最小边界。 +- [x] 用户确认 active plan 纯指针、审计旁路和并行推进信号下的简洁交互边界。 +- [x] 独立复审本轮局部补齐;结论为 `approve_with_minor_changes`,无 P1、无新增产品决策。 +- [x] 最小收口审计写责、并发信号与静态语义断言;未扩展 review wire、EvidentLoop 或并发基础设施。 +- [x] 复用现有 MCP 防覆盖语义,将同名 receipt 拒绝写入及单测局部纳入任务 1.1。 +- [x] 用户审计并确认方案。 +- [x] 用户确认方案后进入开发。 +- [x] 5 个 Wave 均完成自验;全量测试、三场景 protocol check 和代表宿主回放通过。 +- [x] 产品交付与架构实现双视角独立审计完成;原子防覆盖、确认后重读和证据元数据三项最小修复已闭环,最终 P1 / P2 / P3 均为 0。 + +## Next + +方案已完成并归档,无活动接续;后续工作按 blueprint 独立开包。 diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipt.md b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipt.md new file mode 100644 index 00000000..801e8f43 --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipt.md @@ -0,0 +1,20 @@ +--- +plan_id: 20260717_sopify_entry_preflight +outcome: completed +--- + +# completed + +## Summary + +Completed the intent-first managed-work entry preflight: objective lite status facts, writer-only and non-overwrite protocol state, checkpoint and stale-pointer interaction rules, bilingual host prompts and public docs, automated matrix coverage, one representative Codex isolation replay, and independent product-delivery plus architecture reviews. + +## Key Decisions + +- Only explicit managed-work intent such as continue or ~go enters resume handling; ordinary questions and small fixes are handled directly. +- The four-file protocol remains canonical and MCP is an optional read accelerator that returns objective facts only. +- active_plan remains a pure plan_id pointer; plan files are semantic truth, handoff is a recovery hint, and machine truth is written only through sopify_writer. +- An invalid stale pointer does not add a cleanup question to an explicitly authorized new or valid continue action; switching away from a valid plan still requires confirmation. +- Non-active-plan audit remains a read-only verifier sidepath, with the host validating the target and the writer creating a non-overwriting plan receipt. +- Session identifiers are provenance only; only the three documented parallel-progress signals stop side-effecting work, followed by a latest-state reread. +- Codex is the representative host replay for this plan, not evidence that every supported host was replayed. diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/exec_001.json b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/exec_001.json new file mode 100644 index 00000000..ce300e8b --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/exec_001.json @@ -0,0 +1,31 @@ +{ + "evidence": { + "commands": [ + "python3.11 -m pytest tests/test_sopify_writer.py -v", + "python3.11 -m pytest tests/test_installer.py -k workspace_preflight_contract -v", + "sopify_protocol_check.py new-plan", + "sopify_protocol_check.py continuation", + "git diff --check" + ], + "result": "passed", + "retry_count": 0, + "review_result": { + "code_quality": "passed: minimal invariant reuse with no new state or tool", + "spec_compliance": "passed: protocol matrix, writer guard, and host prompts match the plan" + }, + "tasks": [ + "1.1", + "1.2" + ], + "verification_source": "project_contract", + "wave": 1 + }, + "provenance": { + "host": "codex", + "plan_id": "20260717_sopify_entry_preflight", + "receipt_id": "exec_001", + "session_id": "019f6df6-83c0-7b73-87ec-5401ce0c843c" + }, + "timestamp": "2026-07-17T07:22:50+00:00", + "verdict": "passed" +} diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/exec_002.json b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/exec_002.json new file mode 100644 index 00000000..8706f603 --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/exec_002.json @@ -0,0 +1,29 @@ +{ + "evidence": { + "commands": [ + "python3.11 -m pytest tests/test_sopify_mcp_server.py -v", + "workspace_status_lite current workspace assertions", + "workspace_status_lite polluted path structured error", + "git diff --check" + ], + "result": "passed", + "retry_count": 0, + "review_result": { + "code_quality": "passed: fixed-file reads only; no tool, state, scan, or workflow verdict added", + "spec_compliance": "passed: four objective facts and safe plan path match the plan" + }, + "tasks": [ + "2.1" + ], + "verification_source": "project_contract", + "wave": 2 + }, + "provenance": { + "host": "codex", + "plan_id": "20260717_sopify_entry_preflight", + "receipt_id": "exec_002", + "session_id": "019f6df6-83c0-7b73-87ec-5401ce0c843c" + }, + "timestamp": "2026-07-17T07:26:40+00:00", + "verdict": "passed" +} diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/final.json b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/final.json new file mode 100644 index 00000000..b09f1476 --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/final.json @@ -0,0 +1,48 @@ +{ + "evidence": { + "completion_receipt": "verify_006", + "final_findings": { + "P1": 0, + "P2": 0, + "P3": 0 + }, + "independent_review_receipt": "verify_005", + "not_added": [ + "state files", + "MCP tools", + "locks or leases", + "automatic repair", + "diagnostic platform", + "doctor integration", + "multi-host replay matrix" + ], + "protocol_scenarios": [ + "new-plan PASS", + "continuation PASS", + "finalize PASS" + ], + "readme_closure": { + "files": [ + "README.md", + "README.zh-CN.md" + ], + "image_changes": 0 + }, + "readme_links": "passed", + "representative_host_replay": { + "answer_assertion": "exact match: ENTRY_PREFLIGHT_FIXTURE_OK_7C9A", + "fresh_session_id": "019f6f09-2803-7dd1-a1d5-fd12f722444e", + "host": "codex-cli 0.144.3", + "multi_host_claim": false + }, + "tests": "226 passed, 34 subtests passed" + }, + "provenance": { + "host": "codex", + "plan_id": "20260717_sopify_entry_preflight", + "receipt_id": "final", + "session_id": "019f6df6-83c0-7b73-87ec-5401ce0c843c" + }, + "timestamp": "2026-07-17T08:26:37+00:00", + "verdict": "finalized" +} diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_001.json b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_001.json new file mode 100644 index 00000000..1354f189 --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_001.json @@ -0,0 +1,30 @@ +{ + "evidence": { + "commands": [ + "python3.11 -m pytest tests/test_sopify_mcp_server.py -v", + "python3.11 -m pytest tests/test_sopify_writer.py -v", + "python3.11 -m pytest tests/test_installer.py -k workspace_preflight_contract -v", + "git diff --check" + ], + "result": "passed", + "retry_count": 0, + "review_result": { + "code_quality": "passed: focused fixtures and shared assertion helper; no policy test framework", + "spec_compliance": "passed: status matrix and exact host semantics are permanently asserted" + }, + "tasks": [ + "3.1", + "3.2" + ], + "verification_source": "project_contract", + "wave": 3 + }, + "provenance": { + "host": "codex", + "plan_id": "20260717_sopify_entry_preflight", + "receipt_id": "verify_001", + "session_id": "019f6df6-83c0-7b73-87ec-5401ce0c843c" + }, + "timestamp": "2026-07-17T07:29:57+00:00", + "verdict": "passed" +} diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_002.json b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_002.json new file mode 100644 index 00000000..c21868ef --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_002.json @@ -0,0 +1,40 @@ +{ + "evidence": { + "answer_assertion": "exact match: ENTRY_PREFLIGHT_FIXTURE_OK_7C9A", + "command": "codex exec --ephemeral --json --sandbox read-only --skip-git-repo-check -C -o ", + "fixture_question": "请读取当前目录的 fixture-answer.txt,并只回复其中的完整内容。", + "fresh_session_id": "019f6f09-2803-7dd1-a1d5-fd12f722444e", + "host_version": "codex-cli 0.144.3", + "result": "passed", + "retry_count": 0, + "review_result": { + "code_quality": "passed: one isolated Codex replay; no multi-host matrix", + "spec_compliance": "passed: stale active_plan did not hijack consult or write state" + }, + "state_after": [ + { + "path": ".sopify/state/active_plan.json", + "sha256": "f65e500e8db1528f9e1196372a9610562dfebfbab0346d62d88c6400eddf4b61" + } + ], + "state_before": [ + { + "path": ".sopify/state/active_plan.json", + "sha256": "f65e500e8db1528f9e1196372a9610562dfebfbab0346d62d88c6400eddf4b61" + } + ], + "tasks": [ + "3.3" + ], + "verification_source": "representative_host_replay", + "wave": 4 + }, + "provenance": { + "host": "codex", + "plan_id": "20260717_sopify_entry_preflight", + "receipt_id": "verify_002", + "session_id": "019f6df6-83c0-7b73-87ec-5401ce0c843c" + }, + "timestamp": "2026-07-17T07:47:36+00:00", + "verdict": "passed" +} diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_003.json b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_003.json new file mode 100644 index 00000000..8d25ecfa --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_003.json @@ -0,0 +1,32 @@ +{ + "evidence": { + "commands": [ + "python3.11 -m pytest tests -v", + "sopify_protocol_check.py new-plan", + "sopify_protocol_check.py continuation", + "sopify_protocol_check.py finalize", + "git diff --check" + ], + "result": "passed", + "retry_count": 1, + "retry_reason": "updated six intentional host prompt golden hashes after the first full run", + "review_result": { + "code_quality": "passed: local documentation edits and existing golden mechanism; no new framework", + "spec_compliance": "passed: public docs and blueprint preserve only the agreed stable boundaries" + }, + "tasks": [ + "4.1" + ], + "test_result": "225 passed, 34 subtests passed", + "verification_source": "project_contract", + "wave": 5 + }, + "provenance": { + "host": "codex", + "plan_id": "20260717_sopify_entry_preflight", + "receipt_id": "verify_003", + "session_id": "019f6df6-83c0-7b73-87ec-5401ce0c843c" + }, + "timestamp": "2026-07-17T07:52:03+00:00", + "verdict": "passed" +} diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_004.json b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_004.json new file mode 100644 index 00000000..d0afeadd --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_004.json @@ -0,0 +1,65 @@ +{ + "evidence": { + "full_test_result": "226 passed, 34 subtests passed", + "protocol_check_commands": [ + "/opt/homebrew/bin/python3.11 scripts/sopify_protocol_check.py check --scenario new-plan --fixture tests/fixtures/minimal_plan", + "/opt/homebrew/bin/python3.11 scripts/sopify_protocol_check.py check --scenario continuation --fixture tests/fixtures/minimal_plan", + "/opt/homebrew/bin/python3.11 scripts/sopify_protocol_check.py check --scenario finalize --fixture tests/fixtures/minimal_plan" + ], + "protocol_check_result": "all PASS", + "purpose": "independent-review corrections and evidence metadata closure", + "replay": { + "answer_assertion": "exact match: ENTRY_PREFLIGHT_FIXTURE_OK_7C9A", + "command": "codex exec --ephemeral --json --sandbox read-only --skip-git-repo-check -C /private/tmp/sopify-entry-preflight-codex-replay.PTz80b/workspace -o /private/tmp/sopify-entry-preflight-codex-replay.PTz80b/last-message-proxy.txt ", + "environment_attempts_before_success": 3, + "environment_attempts_note": "sqlite sandbox denial, proxy TLS failure, and local Ollama startup failure occurred before the single completed replay", + "fixture_answer_sha256": "79b4df245e232fec3ca561317797a88f7332c95d1a912a4e9e13ac4528e5577b", + "fresh_session_id": "019f6f09-2803-7dd1-a1d5-fd12f722444e", + "host_version": "codex-cli 0.144.3", + "last_message_sha256": "4cd93e054df006b5553a8d159dc64f3359f4d2e919c6cf7821de678357951166", + "observed_tool_events": [ + "command_execution: cat fixture-answer.txt; exit_code=0", + "agent_message: ENTRY_PREFLIGHT_FIXTURE_OK_7C9A" + ], + "prompt_asset_sha256": "54cefc1bc568fd943e0e5cd476185dc3dc08db662e6d801525d2b0db6e6159b0", + "sandbox_note": "read-only sandbox was containment; the completed event trace itself contained no state-write command or tool", + "state_after": [ + { + "path": ".sopify/state/active_plan.json", + "sha256": "f65e500e8db1528f9e1196372a9610562dfebfbab0346d62d88c6400eddf4b61" + } + ], + "state_before": [ + { + "path": ".sopify/state/active_plan.json", + "sha256": "f65e500e8db1528f9e1196372a9610562dfebfbab0346d62d88c6400eddf4b61" + } + ], + "write_attempts_observed": false + }, + "result": "passed", + "retry_count": 0, + "review_fixes": [ + "atomic exclusive-create receipt write with a concurrent double-write regression", + "host prompts and static assertions require rereading latest plan state after concurrency stop confirmation", + "blueprint test baseline refreshed" + ], + "review_result": { + "code_quality": "passed: no lock, state, tool, retry framework, or diagnostic platform added", + "spec_compliance": "passed after minimal independent-review fixes" + }, + "supersedes_metadata_in": [ + "exec_001", + "verify_002", + "verify_003" + ] + }, + "provenance": { + "host": "codex", + "plan_id": "20260717_sopify_entry_preflight", + "receipt_id": "verify_004", + "session_id": "019f6df6-83c0-7b73-87ec-5401ce0c843c" + }, + "timestamp": "2026-07-17T08:06:28+00:00", + "verdict": "passed" +} diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_005.json b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_005.json new file mode 100644 index 00000000..ebedd59c --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_005.json @@ -0,0 +1,67 @@ +{ + "evidence": { + "final_findings": { + "P1": 0, + "P2": 0, + "P3": 0 + }, + "purpose": "final independent product and architecture review", + "replay_command_argv": [ + "codex", + "exec", + "--ephemeral", + "--json", + "--sandbox", + "read-only", + "--skip-git-repo-check", + "-C", + "/private/tmp/sopify-entry-preflight-codex-replay.PTz80b/workspace", + "-o", + "/private/tmp/sopify-entry-preflight-codex-replay.PTz80b/last-message-proxy.txt", + "请读取当前目录的 fixture-answer.txt,并只回复其中的完整内容。" + ], + "replay_fixture_question": "请读取当前目录的 fixture-answer.txt,并只回复其中的完整内容。", + "resolved_findings": [ + "receipt writes now use atomic exclusive-create and concurrent regression coverage", + "host prompts and static assertions require rereading current plan state after concurrency stop confirmation", + "protocol-check evidence now records executable commands", + "replay evidence metadata includes prompt, fixture, answer, and state hashes", + "blueprint test baseline updated" + ], + "result": "passed", + "reviewers": [ + { + "final_verdict": "approve_with_minor_changes", + "initial_verdict": "reject", + "perspective": "product_delivery" + }, + { + "final_verdict": "approve_with_minor_changes", + "initial_verdict": "reject", + "perspective": "architecture_implementation" + } + ], + "scope_decisions": [ + "kept user-approved non-active-plan audit sidepath", + "preserved pre-existing Claude MCP evidence", + "did not add state, tool, scan, lock, retry framework, automatic repair, doctor, or multi-host replay matrix" + ], + "verification": { + "git_diff_check": "passed", + "protocol_scenarios": [ + "new-plan PASS", + "continuation PASS", + "finalize PASS" + ], + "tests": "226 passed, 34 subtests passed" + } + }, + "provenance": { + "host": "codex", + "plan_id": "20260717_sopify_entry_preflight", + "receipt_id": "verify_005", + "session_id": "019f6df6-83c0-7b73-87ec-5401ce0c843c" + }, + "timestamp": "2026-07-17T08:10:02+00:00", + "verdict": "passed" +} diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_006.json b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_006.json new file mode 100644 index 00000000..946619ea --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/receipts/verify_006.json @@ -0,0 +1,50 @@ +{ + "evidence": { + "commands": [ + "/opt/homebrew/bin/python3.11 -m pytest tests -v", + "/opt/homebrew/bin/python3.11 scripts/check-readme-links.py", + "/opt/homebrew/bin/python3.11 scripts/sopify_protocol_check.py check --scenario new-plan --fixture tests/fixtures/minimal_plan", + "/opt/homebrew/bin/python3.11 scripts/sopify_protocol_check.py check --scenario continuation --fixture tests/fixtures/minimal_plan", + "/opt/homebrew/bin/python3.11 scripts/sopify_protocol_check.py check --scenario finalize --fixture tests/fixtures/minimal_plan", + "git diff --check" + ], + "plan_md_sha256_before_write": "f70b0f883d010aa191344c28e396b4e5fa52a6475c6b38a827fc3dfec108b024", + "purpose": "README product wording closure and pre-finalize verification", + "readme_closure": { + "behavior": "explicit continue or ~go resumes managed work; ordinary questions and small fixes are handled directly", + "image_changes": 0, + "referenced_images_reviewed": true, + "rewrites_per_language": 3 + }, + "result": "passed", + "retry_count": 0, + "review_result": { + "code_quality": "passed: local bilingual rewrites only; no asset or implementation expansion", + "spec_compliance": "passed: user-facing wording aligned without changing protocol scope" + }, + "scope": [ + "README.md", + "README.zh-CN.md", + "current plan implementation and protocol assets" + ], + "verification": { + "git_diff_check": "passed", + "protocol_scenarios": [ + "new-plan PASS", + "continuation PASS", + "finalize PASS" + ], + "readme_links": "passed", + "tests": "226 passed, 34 subtests passed" + }, + "verification_source": "project_native" + }, + "provenance": { + "host": "codex", + "plan_id": "20260717_sopify_entry_preflight", + "receipt_id": "verify_006", + "session_id": "019f6df6-83c0-7b73-87ec-5401ce0c843c" + }, + "timestamp": "2026-07-17T08:25:50+00:00", + "verdict": "passed" +} diff --git a/.sopify/history/2026-07/20260717_sopify_entry_preflight/tasks.md b/.sopify/history/2026-07/20260717_sopify_entry_preflight/tasks.md new file mode 100644 index 00000000..82bd00a3 --- /dev/null +++ b/.sopify/history/2026-07/20260717_sopify_entry_preflight/tasks.md @@ -0,0 +1,44 @@ +# 任务清单: Sopify 会话入口状态预检 + +原执行目录: `.sopify/plan/20260717_sopify_entry_preflight/`;当前归档目录: `.sopify/history/2026-07/20260717_sopify_entry_preflight/` + +## 1. 协议边界 + +- [x] 1.1 对齐 `.sopify/blueprint/protocol.md` 与 `sopify_writer/store.py`:明确入口状态矩阵、主体绑定和单次询问规则;复用现有 MCP 防覆盖语义,同名 receipt 已存在时抛出 `FileExistsError`。 + - 路由验收:consult / quick fix 不进入接续链,new plan 不消费旧 checkpoint,checkpoint response 不重显原问题;无效旧指针遇到明确且有效的 continue 目标时直接激活。 + - 审计验收:非 active plan 审计器保持只读;宿主校验目标 `plan.md` digest 后仅通过 writer 写未占用的 `verify_NNN` receipt,不切换 active plan / handoff;`tests/test_sopify_writer.py` 证明重复写入被拒绝且原证据不变。 +- [x] 1.2 对齐 `skills/zh/header.md.template` 与 `skills/en/header.md.template`;验收为不同 session 标识不单独阻断正常接续;只有约定的三类并行推进信号才停止同一 active plan 的有副作用开发,并展示一次简洁提示。不出现自动修复、session 所有权、锁或全局 gate。 + +## 2. 最小状态事实 + +- [x] 2.1 在 `scripts/sopify_mcp_server.py` 为现有 `workspace_status_lite` 增加 `active_plan_file_exists`、`active_plan_md_exists`、`handoff_plan_id`、`handoff_matches_active_plan`,并收紧 `plan_id` 子路径校验;验收为污染路径返回结构化 error,不新增 tool、state、workflow verdict 或目录扫描。 + +## 3. 自动化与真实回放 + +- [x] 3.1 扩充 `tests/test_sopify_mcp_server.py`,覆盖无 active plan、JSON `null`、数组、缺 `plan_id`、污染路径、有效 plan 缺 handoff / `plan.md`、handoff 一致/失配;验收为只读检查不产生状态写入。 +- [x] 3.2 扩充 `tests/test_installer.py` 的宿主资产检查;验收为测试完整规范句而非单个关键词,安装后的 Codex 中文和 Claude 英文 prompt 均精确保留: + - checkpoint 回答不重问;无效旧指针的新建 / 明确继续不二问;有效旧方案切换仍需确认。 + - MCP 可选,machine truth 只走 writer;`active_plan` 只含 `plan_id`,Wave / 任务进度来自方案文件。 + - 审计非 active plan 时绑定目标 `plan.md` digest,审计器只读、宿主通过 writer 写 `verify_NNN` receipt,active pointer / handoff 不变。 + - 不同 session 标识单独出现不得阻断;仅用户明确同时开发、宿主确认另一任务运行、或写入前出现非本轮已知状态变化时,才停止当前有副作用的开发并提示一次。 +- [x] 3.3 在临时 fixture 启动不继承当前讨论的新 Codex 会话,执行一次上下文隔离回放;验收为陈旧 `active_plan` 不劫持普通问答、不自动写入状态,证据包含新会话标识、fixture 问题、执行命令与宿主版本、回答断言及前后 `state/` 文件清单与哈希。 + +## 4. 文档收口与验证 + +- [x] 4.1 对齐 `docs/how-sopify-works.md`、`docs/how-sopify-works.en.md`、`docs/getting-started.md` 及 `.sopify/blueprint/{README,design,tasks}.md`,运行定向测试、全量测试、protocol check 和 `git diff --check`;验收为 blueprint 只沉淀 active plan 纯指针、Verifier 只读 / host writer 写 receipt、session 标识仅作 provenance 三条稳定边界,不升格完整 Review Wire,也不扩大到 persistent MCP / doctor / 多宿主注册。 + +## 验收标准 + +- [x] 健康状态静默通过,consult / quick fix 不展示无关方案异常。 +- [x] 缺 handoff / receipts 不被误判为阻断。 +- [x] handoff 失配按 `plan.md` 继续,只提示一次。 +- [x] checkpoint 首次出现时只展示一次;用户正在回答匹配 checkpoint 时先消费回答,不重显原问题。 +- [x] 有效方案切换仍需用户确认;无效旧指针遇到明确 new plan 或明确且有效的 continue 目标时,不二次询问清理。 +- [x] 同一轮同一冲突不重复检查、重复询问或重试。 +- [x] `active_plan` 保持纯 `plan_id` 指针,不增加 Wave;Wave 和任务进度继续以方案文件为准。 +- [x] 显式审计非 active plan 时,审计器只读并返回证据;宿主校验目标主体后通过 writer 写目标方案未占用的 `verify_NNN` receipt,active plan / handoff 不变;同名 receipt 已存在时拒绝覆盖并保留原证据。 +- [x] 不以不同 session 标识误拦正常接续;仅约定的三类并行推进信号触发停止当前有副作用的开发,并展示一次中性提示。 +- [x] 用户确认其他开发已停止后,必须重读最新方案状态再继续;不新增所有权、锁、租约或 session state。 +- [x] 未注册 MCP 的宿主仍能仅按 4 步文件协议完成 managed action 入口。 +- [x] 未新增 state 文件、MCP tool、诊断平台、自动修复或多宿主实测矩阵;单张架构 SVG 仅作总链路导读,不复制状态矩阵。 +- [x] 一次 Codex 上下文隔离回放证明新会话未继承当前讨论,且该路径在代表宿主 Codex 中成立;不代表其他宿主已经实测。 diff --git a/.sopify/history/index.md b/.sopify/history/index.md index ad95841b..d1be12b7 100644 --- a/.sopify/history/index.md +++ b/.sopify/history/index.md @@ -4,6 +4,7 @@ ## 索引 +- `2026-07-17` [`20260717_sopify_entry_preflight`](2026-07/20260717_sopify_entry_preflight/) - standard - 会话入口状态预检:显式 managed 意图才接续;lite 客观状态、writer 原子收据、防重复询问与并行信号边界闭环,Codex 代表回放及产品/架构双审通过 - `2026-07-16` [`20260626_mcp_tool_plane_pilot`](2026-07/20260626_mcp_tool_plane_pilot/) - standard - MCP tool plane pilot:确定性协议读/检查与 guarded plan receipt 写入;Codex-first repo-local 注册、真实配置和 stdio tool smoke 通过;其他宿主扩展保持证据门控 - `2026-06-10` [`20260605_p8_protocol_kernel_runtime_retirement`](2026-06/20260605_p8_protocol_kernel_runtime_retirement/) - architecture - P8 Protocol Kernel & Runtime Retirement: runtime 物理删除(46 files / ~15.6K LOC),canonical root .sopify-skills→.sopify,state model 6→2 files,Qoder PROTOCOL_VERIFIED host proof,产品定位"开发过程协议层",蓝图/README/架构图全量对齐 - `2026-06-05` [`20260529_pre_launch_consolidation`](2026-06/20260529_pre_launch_consolidation/) - standard - 推广前收口整合: D1 README 重写 / D3 命令面收敛 / D5-3B 安全修复 / Wave A 首次触达 / Wave B 文档打磨 / Wave C 输出增强(PR #54)/ Wave D 推广文章草稿(本地就绪 / 发布 deferred / docs/articles 已删除)/ Wave E runtime 线已被 P8 吸收、手工项 deferred diff --git a/CHANGELOG.md b/CHANGELOG.md index 089ec9db..135403fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Format: Summary → Changed → Plan Packages. File-level details live in `git l ## [Unreleased] +### Summary + +Made managed-plan entry intent-first: existing work resumes only when the user explicitly asks to continue or uses `~go`, while ordinary questions and small fixes stay on the current request. + +### Changed + +- **Protocol entry**: Added finite state facts and prompt rules for stale pointers, checkpoint answers, valid plan switches, and three parallel-progress signals without new state, locks, or automatic repair. +- **Receipt safety**: Made plan receipt creation atomic and non-overwriting, with concurrent regression coverage. +- **Docs and evidence**: Aligned bilingual public docs, recorded bounded Codex and Claude host evidence, and archived the independently reviewed plan package. + +### Plan Packages + +- `20260717_sopify_entry_preflight` → archived to `history/2026-07/` + ## [2026-07-16.230506] - 2026-07-16 ### Summary diff --git a/README.md b/README.md index 7f4f266b..fbf9bc75 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ English · [简体中文](./README.zh-CN.md) · [Quick Start](#quick-start) · [ AI coding tools are fast. But when they jump to code before the facts are clear, speed turns into rework. Sopify is a development process protocol layer for AI coding: in managed workflows, the host asks before coding when requirements are incomplete or a decision still needs you. -Sopify stores plans and verification receipts in `.sopify/` as project files tracked by git. Only the local resume pointers stay out of git. Open the same repo on another host, and it reads those files to continue from where the work stopped. +Sopify stores plans and verification receipts in `.sopify/` as project files tracked by git. Only the local resume pointers stay out of git. In the same repo, explicitly ask to continue or use `~go` to resume managed work from those files on any supported host. No new editor, no new CLI. Install into the host you already use — Codex, Claude, Qoder, or Copilot. @@ -58,7 +58,7 @@ curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/instal After install, use `~go` to start a managed workflow. See [Installation](#installation) for other hosts, audit-first install, and Windows. -**Already in a Sopify-managed repo?** Open any AI host and continue — it picks up from where you left off. +**Already in a Sopify-managed repo?** Explicitly ask to continue or use `~go` to resume the current plan. Ordinary questions and small fixes are handled directly without automatically entering the previous plan workflow. ## Why Sopify? @@ -95,7 +95,7 @@ How Sopify achieves stability and quality: - **Same rules on every host** — Claude, Codex, Qoder, and Copilot load the same Sopify instructions, so switching hosts doesn't reset the workflow - **Project assets tracked in git** — plans, decisions, and verification records live in `.sopify/`; only the two local pointer files (`active_plan.json`, `current_handoff.json`) are gitignored -- **Resumes from where you stopped** — the host reads the current plan, picks up the last handoff, and checks what's already been verified before continuing +- **Resumes when you ask** — explicitly ask to continue or use `~go`; the host then reads the current plan, last handoff, and existing verification evidence before proceeding - **Runtime retired; workflow retained** — the analyze → design → develop → finalize workflow is unchanged; what changed is that rules live in files, not a runtime process ## Architecture Details diff --git a/README.zh-CN.md b/README.zh-CN.md index 8d6a957f..7397a24e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,7 +26,7 @@ AI 工具写代码很快。但在事实没搞清、关键决策还没拍板前就直接动手,快就会变成返工。Sopify 是 AI 编程的开发过程协议层:在托管流程里,需求不全或决策未定时,宿主会先追问,再写代码。 -Sopify 把方案和验证收据保存在 `.sopify/` 中,作为可纳入 git 的项目文件;只有恢复用的本地指针不进 git。换一个宿主打开同一个仓库,它会读取这些文件,从上次停下的地方继续。 +Sopify 把方案和验证收据保存在 `.sopify/` 中,作为可纳入 git 的项目文件;只有恢复用的本地指针不进 git。在同一个仓库中,明确说“继续”或使用 `~go`,任一受支持的宿主都可以读取这些文件,从上次停点恢复托管任务。 无需新编辑器、无需新 CLI。安装到你已有的宿主:Codex、Claude、Qoder、Copilot 均支持。 @@ -58,7 +58,7 @@ curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/instal 安装后用 `~go` 启动全托管工作流。审查优先安装、其他宿主和 Windows 请看[安装说明](#安装说明)。 -**已在 Sopify 管理的仓库里?** 打开任意 AI 宿主,让它继续未完成的任务——它会从上次停点恢复。 +**已在 Sopify 管理的仓库里?** 明确说“继续”或使用 `~go`,即可恢复当前方案。普通问答和小范围修改会按当前请求直接处理,不会自动进入之前的方案流程。 ## 为什么选择 Sopify? @@ -95,7 +95,7 @@ Sopify 靠四件事做到稳定可控、质量可靠: - **每个宿主同一套规则** — Claude、Codex、Qoder、Copilot 加载的是同一套 Sopify 指令,切换宿主不会把流程重置 - **项目资产在 git 里** — 方案、决策、验证记录都落在 `.sopify/`;只有两个本地指针文件(`active_plan.json`、`current_handoff.json`)不纳入 git -- **从上次停下的地方继续** — 宿主读取当前方案、上次交接记录和已验证内容,然后接着干 +- **按你的指令继续** — 明确说“继续”或使用 `~go` 后,宿主会读取当前方案、上次交接记录和已有验证证据,再继续执行 - **Runtime 已退场;工作流保留** — analyze → design → develop → finalize 流程不变;变的是规则活在文件里,不再依赖 runtime 进程 ## 架构细节 diff --git a/docs/getting-started.md b/docs/getting-started.md index cd34c30d..dca5c108 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -132,6 +132,8 @@ For managed workflows with planning and review: Most users only need `~go`. +Ordinary questions and small fixes are answered directly, even if local plan state is stale. Sopify reads the managed-plan chain only when you start, continue, or finalize managed work; any state write then goes through `sopify_writer`. + ### What Gets Created As you work, Sopify creates project knowledge in `.sopify/`: @@ -147,7 +149,7 @@ As you work, Sopify creates project knowledge in `.sopify/`: ``` - `blueprint/`, `plan/`, `history/` are tracked by git — they are your project memory -- `state/` is git-ignored — it holds only `active_plan.json` (current plan pointer) and `current_handoff.json` (resume hint). If missing, the host falls back to browsing `plan/` to find active work +- `state/` is git-ignored — it holds only `active_plan.json` (a pure `plan_id` pointer) and `current_handoff.json` (resume hint). If missing during a managed action, the host can browse `plan/` for candidates without auto-resuming one during ordinary questions ## Updating diff --git a/docs/how-sopify-works.en.md b/docs/how-sopify-works.en.md index 63cc3da8..e977ae30 100644 --- a/docs/how-sopify-works.en.md +++ b/docs/how-sopify-works.en.md @@ -31,6 +31,7 @@ Workflow notes: - The host reads protocol entry instructions from its prompt asset (installed via `install.sh --target `) - Before entering managed plan / continuation / finalize, the host follows a 4-step read chain: `state/active_plan.json` → `plan//plan.md` → `state/current_handoff.json` → `plan//receipts/` - Consult and quick-fix requests do **not** auto-continue the active plan +- Stale or invalid state does not interrupt ordinary questions; it is handled only when the user enters a managed-plan action - State writes go through `sopify_writer` (the only write path for protocol assets) ### Checkpoint Pause and Resume @@ -53,9 +54,9 @@ When the host detects `.sopify/` and the user request targets managed plan / con 4. plan//receipts/ → latest 1-3 receipts (what's been verified) ``` -**Design principle**: read `plan.md` first for semantic truth, then `current_handoff` as a resume hint. The handoff is never a second truth source. +**Design principle**: read `plan.md` first for semantic truth, then `current_handoff` as a resume hint. The handoff is never a second truth source; `active_plan.json` stores only `plan_id`, while wave and task progress stay in plan files. MCP may aggregate these facts, but it is not required for protocol entry. -**If state files are missing** (e.g. fresh clone on a new machine): `active_plan.json` and `current_handoff.json` are gitignored by design. The host falls back to browsing `plan/` to find active work packages. Plans and receipts are always in git — only the "where am I right now" pointer is local. +**If state files are missing** (e.g. fresh clone on a new machine): `active_plan.json` and `current_handoff.json` are gitignored by design. The host browses `plan/` for candidates only when the user starts, continues, or finalizes managed work; an ordinary question never auto-resumes an old plan. Plans and receipts are always in git — only the "where am I right now" pointer is local. ## Directory Structure and Layers diff --git a/docs/how-sopify-works.md b/docs/how-sopify-works.md index 949a3b23..20233a6d 100644 --- a/docs/how-sopify-works.md +++ b/docs/how-sopify-works.md @@ -31,6 +31,7 @@ Sopify 把 AI 开发过程中的**方案、决策、交接、执行/验证证据 - 宿主从已安装的 prompt 资产中读取协议入口指令(通过 `install.sh --target ` 安装) - 进入 managed plan / continuation / finalize 前,宿主按 4 步读链恢复上下文:`state/active_plan.json` → `plan//plan.md` → `state/current_handoff.json` → `plan//receipts/` - consult 和 quick-fix 请求**不**自动接续 active plan +- 陈旧或无效状态不会打断普通问答;只有进入方案操作时才按用户意图处理 - 状态写入统一走 `sopify_writer`(协议资产的唯一写路径) ### Checkpoint 暂停与恢复 @@ -53,9 +54,9 @@ Sopify 把 AI 开发过程中的**方案、决策、交接、执行/验证证据 4. plan//receipts/ → 最新 1-3 个 receipt(哪些已验证) ``` -**设计原则**:先读 `plan.md` 建立语义真相,再读 `current_handoff` 作为恢复提示。handoff 永远不是第二真相源。 +**设计原则**:先读 `plan.md` 建立语义真相,再读 `current_handoff` 作为恢复提示。handoff 永远不是第二真相源;`active_plan.json` 只保存 `plan_id`,Wave 和任务进度留在方案文件中。MCP 可帮助读取这些事实,但不是协议入口的依赖。 -**状态文件缺失时**(如新机器 fresh clone):`active_plan.json` 和 `current_handoff.json` 按设计被 gitignore。宿主会回退到浏览 `plan/` 目录来找到活跃方案。方案和收据始终在 git 里——只有"我现在在哪"的指针是本地的。 +**状态文件缺失时**(如新机器 fresh clone):`active_plan.json` 和 `current_handoff.json` 按设计被 gitignore。只有用户要新建、继续或收口方案时,宿主才浏览 `plan/` 发现候选并按用户意图处理;普通问答不会因此自动恢复旧方案。方案和收据始终在 git 里——只有"我现在在哪"的指针是本地的。 ## 目录结构与层级 diff --git a/scripts/sopify_mcp_server.py b/scripts/sopify_mcp_server.py index 9f6c4b58..aec1c87e 100644 --- a/scripts/sopify_mcp_server.py +++ b/scripts/sopify_mcp_server.py @@ -9,6 +9,7 @@ from __future__ import annotations +import re import sys from pathlib import Path from typing import Any, Callable, Mapping @@ -21,6 +22,7 @@ from sopify_writer import ProtocolStore # noqa: E402 MCP_DEPENDENCY = "mcp[cli]>=1.27,<2" +_PLAN_ID_RE = re.compile(r"^[a-zA-Z0-9_]+$") def resolve_workspace_root(workspace_root: str | Path) -> Path: @@ -48,14 +50,42 @@ def read_current_handoff(workspace_root: str | Path) -> dict[str, Any] | None: return handoff.to_dict() if handoff is not None else None +def _validated_plan_id(active_plan: Any) -> str: + """Return a schema-compatible plan_id or reject invalid state.""" + if not isinstance(active_plan, dict): + raise ValueError("active plan must be an object") + plan_id = active_plan.get("plan_id") + if not isinstance(plan_id, str) or not _PLAN_ID_RE.fullmatch(plan_id): + raise ValueError("active plan plan_id is missing or invalid") + return plan_id + + def workspace_status_lite(workspace_root: str | Path) -> dict[str, Any]: """Return a minimal, dependency-light Sopify workspace status.""" workspace = resolve_workspace_root(workspace_root) sopify_root = workspace / ".sopify" state_root = sopify_root / "state" - active_plan = read_active_plan(workspace) if sopify_root.exists() else None - active_plan_id = active_plan.get("plan_id") if isinstance(active_plan, dict) else None - active_plan_dir = sopify_root / "plan" / str(active_plan_id) if active_plan_id else None + active_plan_path = state_root / "active_plan.json" + active_plan_file_exists = active_plan_path.is_file() + active_plan = read_active_plan(workspace) if active_plan_file_exists else None + active_plan_id = _validated_plan_id(active_plan) if active_plan_file_exists else None + active_plan_dir = ( + _safe_child_path(sopify_root / "plan", active_plan_id) + if active_plan_id is not None + else None + ) + handoff = read_current_handoff(workspace) if sopify_root.exists() else None + raw_handoff_plan_id = handoff.get("plan_id") if isinstance(handoff, dict) else None + handoff_plan_id = ( + raw_handoff_plan_id + if isinstance(raw_handoff_plan_id, str) and raw_handoff_plan_id + else None + ) + handoff_matches_active_plan = ( + handoff_plan_id == active_plan_id + if handoff_plan_id and active_plan_id + else None + ) return { "workspace_root": str(workspace), @@ -67,8 +97,14 @@ def workspace_status_lite(workspace_root: str | Path) -> dict[str, Any]: "state": state_root.is_dir(), }, "active_plan": active_plan, + "active_plan_file_exists": active_plan_file_exists, "active_plan_dir_exists": active_plan_dir.is_dir() if active_plan_dir else None, + "active_plan_md_exists": ( + (active_plan_dir / "plan.md").is_file() if active_plan_dir else None + ), "handoff_exists": (state_root / "current_handoff.json").is_file(), + "handoff_plan_id": handoff_plan_id, + "handoff_matches_active_plan": handoff_matches_active_plan, } @@ -88,10 +124,7 @@ def _safe_child_path(root: Path, *parts: str) -> Path: def _active_plan_id(store: ProtocolStore) -> str: - active_plan = store.get_active_plan() - if not isinstance(active_plan, dict) or not active_plan.get("plan_id"): - raise ValueError("active plan is missing") - return str(active_plan["plan_id"]) + return _validated_plan_id(store.get_active_plan()) def _guard_write_plan_receipt( diff --git a/skills/en/header.md.template b/skills/en/header.md.template index 1302937b..fe576fc3 100644 --- a/skills/en/header.md.template +++ b/skills/en/header.md.template @@ -126,11 +126,14 @@ Complex Task (full 3 phases): | `~go plan` | Plan only, no execution | | `~go finalize` | Close out the current metadata-managed plan | -Note: On each Sopify turn, the host first classifies the user request (consult / quick_fix / new_plan / continue_plan / finalize). Only managed plan / continuation / finalize enter the 4-step protocol entry; consult / quick_fix do not auto-resume active_plan by default. See `.sopify/blueprint/protocol.md §8`. - -Note: The 4-step entry order for managed plan / continuation / finalize is `state/active_plan.json` → `plan//plan.md` → `state/current_handoff.json` → `plan//receipts/`. Read plan.md first for semantic truth, then handoff as a resumption hint; handoff is not a second truth source. Respect any pending checkpoint before continuing. - -Note: The host must not bypass checkpoint constraints or write machine truth directly. Protocol state writes (active_plan / current_handoff / receipts) go through `sopify_writer`. See `.sopify/blueprint/protocol.md §8`: Host Protocol Entry Contract. +Notes: +- On each Sopify turn, first classify the user request (consult / quick_fix / new_plan / continue_plan / finalize). Only managed plan / continuation / finalize enter the 4-step protocol entry; consult / quick_fix do not auto-resume the active plan or surface unrelated state errors. +- The 4-step entry is `state/active_plan.json` → `plan//plan.md` → `state/current_handoff.json` → `plan//receipts/`. Read `plan.md` first for semantic truth. MCP may only aggregate objective facts; if unavailable, continue with the file protocol. +- Process the current user intent first, then active-plan validity and subject binding, then only the matching handoff / checkpoint. When a stale invalid pointer meets an explicit new plan or an explicit valid continue target, let the writer switch directly without a cleanup confirmation; switching away from a still-valid plan still requires user confirmation. When the user is answering the matching checkpoint, consume the answer before continuing and do not repeat the same question. +- Write machine truth only through `sopify_writer`. `active_plan` contains only `plan_id`; Wave and task progress come from plan files. For an explicit audit of a non-active plan, the verifier stays read-only; after revalidating the target `plan.md` digest, the host writes a `verify_NNN` receipt through the writer without changing the active pointer or handoff. +- A session ID is provenance only and does not block continuation by itself. Stop side-effecting development only when the user explicitly requests simultaneous development, the host confirms another task is still running, or the target plan digest / matching handoff changes before the first write due to an unknown write. Show this once: `The plan state shows a parallel-advance signal, so this session will not write. You can continue read-only review, or confirm that the other development has stopped and then continue here.` After the user confirms, reread the latest plan state before continuing. + +See `.sopify/blueprint/protocol.md §8`: Host Protocol Entry Contract. --- diff --git a/skills/zh/header.md.template b/skills/zh/header.md.template index d52a0ed2..0a5e0bea 100644 --- a/skills/zh/header.md.template +++ b/skills/zh/header.md.template @@ -126,11 +126,14 @@ Next: {下一步提示} | `~go plan` | 只规划不执行 | | `~go finalize` | 对当前 metadata-managed plan 执行收口归档 | -说明:每次进入 Sopify 回合时,宿主先判断用户请求意图(consult / quick_fix / new_plan / continue_plan / finalize)。仅 managed plan / continuation / finalize 进入 4 步协议入口;consult / quick_fix 默认不自动接续 active_plan。详见 `.sopify/blueprint/protocol.md §8`。 - -说明:managed plan / continuation / finalize 的 4 步入口顺序为 `state/active_plan.json` → `plan//plan.md` → `state/current_handoff.json` → `plan//receipts/`。先读 plan.md 建立语义真相,再读 handoff 作为恢复提示;handoff 不是第二真相源。有未完成 checkpoint 时必须先响应再继续。 - -说明:宿主不得绕过 checkpoint 约束或直接写入 machine truth。协议状态写入(active_plan / current_handoff / receipts)统一走 `sopify_writer`。详见 `.sopify/blueprint/protocol.md §8`:Host Protocol Entry Contract。 +说明: +- 每次进入 Sopify 回合时,先判断用户请求意图(consult / quick_fix / new_plan / continue_plan / finalize)。仅 managed plan / continuation / finalize 进入 4 步协议入口;consult / quick_fix 不自动接续 active plan,也不展示无关状态异常。 +- 4 步入口固定为 `state/active_plan.json` → `plan//plan.md` → `state/current_handoff.json` → `plan//receipts/`。先读 `plan.md` 建立语义真相;MCP 只可选聚合客观事实,不可用时仍按文件协议继续。 +- 入口处理顺序为本轮用户意图、active plan 有效性与主体绑定、匹配该方案的 handoff / checkpoint。无效旧指针遇到明确 new plan 或明确且有效的 continue 目标时,由 writer 直接切换,不追加清理确认;有效旧方案切换仍须用户确认。用户正在回答匹配 checkpoint 时先消费回答,不重显同一问题。 +- machine truth 只通过 `sopify_writer` 写入;`active_plan` 只含 `plan_id`,Wave 和任务进度来自方案文件。显式审计非 active plan 时,审计器只读;宿主校验目标 `plan.md` digest 后通过 writer 写 `verify_NNN` receipt,active pointer / handoff 不变。 +- session 标识只作 provenance,不单独阻断。仅当用户明确要求同时开发、宿主确认另一任务仍在运行,或写入前出现非本轮已知的目标 plan digest / 匹配 handoff 变化时,才停止当前有副作用的开发,并只提示一次:`方案状态出现并行推进信号,本会话暂不写入。你可以继续只读审计,或确认其他开发已停止后在这里继续。` 用户确认后,先重读最新方案状态再继续。 + +详见 `.sopify/blueprint/protocol.md §8`:Host Protocol Entry Contract。 --- diff --git a/sopify_writer/io.py b/sopify_writer/io.py index 45e087ad..e2e8896b 100644 --- a/sopify_writer/io.py +++ b/sopify_writer/io.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os from pathlib import Path from tempfile import NamedTemporaryFile from typing import Any, Optional @@ -27,6 +28,26 @@ def write_json(path: Path, payload: dict[str, Any]) -> None: temp_path.replace(path) +def write_json_exclusive(path: Path, payload: dict[str, Any]) -> None: + """Atomically create a JSON file and fail if the target already exists.""" + path.parent.mkdir(parents=True, exist_ok=True) + temp_path: Path | None = None + try: + with NamedTemporaryFile( + "w", delete=False, dir=path.parent, encoding="utf-8" + ) as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + temp_path = Path(handle.name) + + # Linking a complete same-directory temp file is an atomic exclusive + # create: concurrent writers cannot replace an existing receipt. + os.link(temp_path, path) + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + def read_runtime_handoff(path: Path) -> RuntimeHandoff | None: """Read a handoff file if it exists.""" if not path.exists(): diff --git a/sopify_writer/store.py b/sopify_writer/store.py index aeee6069..4513f67a 100644 --- a/sopify_writer/store.py +++ b/sopify_writer/store.py @@ -30,7 +30,7 @@ from sopify_contracts import RuntimeHandoff from .invariants import InvariantViolationError -from .io import read_json, read_runtime_handoff, write_json +from .io import read_json, read_runtime_handoff, write_json, write_json_exclusive from ._time import iso_now _RECEIPT_ID_RE = re.compile(r"^(exec_\d{3}|verify_\d{3}|final)$") @@ -116,6 +116,7 @@ def write_plan_receipt( - provenance.plan_id matches the plan_id argument if present - provenance.receipt_id matches the receipt_id argument if present - verdict is non-empty + - the target receipt does not already exist Returns the path of the written receipt file. """ @@ -151,8 +152,7 @@ def write_plan_receipt( } receipt_path = self._receipt_path(plan_id, receipt_id) - receipt_path.parent.mkdir(parents=True, exist_ok=True) - write_json(receipt_path, payload) + write_json_exclusive(receipt_path, payload) return receipt_path # -- History receipts (history///receipt.md) -- diff --git a/tests/golden-snapshots.json b/tests/golden-snapshots.json index 980d91cb..5c8a5eb4 100644 --- a/tests/golden-snapshots.json +++ b/tests/golden-snapshots.json @@ -8,13 +8,13 @@ }, "protocol_version": "1.0", "snapshots": { - "codex:zh-CN:header": "918765d5fb9a11207811a1dd0177d6ca78650ee9a40e84d12918193f3bc15a84", - "codex:en-US:header": "b252cce903a78e10fa9250a46c465fc5ae0e50c316a499bbe2435b320007684e", - "claude:zh-CN:header": "22862dddd2da558245732e32742231906b001c5c5e3165a4bf1d73bd52a8cc5c", - "claude:en-US:header": "b9d54771c0a06768400eaafedbf1d2fc239d9bec400e505c7a9855e6aa0aa1e8", - "copilot:zh-CN:managed_block_payload": "ccc44035830d85842b93f8db71edbbf70cc419eb991e5f33006b0aa0c86deb9a", + "codex:zh-CN:header": "1e310a50086042560e38b3c1537bdd1ebfaa1131469c70cdf3293320057aabb3", + "codex:en-US:header": "a692284a23a74bdfa61e398836f6d5dc06df3b9bf96d239b863af4f98b4a2fc9", + "claude:zh-CN:header": "64c19c18824122e99fa9d754d4334ff4bc099d2345f5bf9b1701c316247e717a", + "claude:en-US:header": "a83b77d85d7709ad6820826b11989c36f9013f8296d2007d8f65d8e5ab6b81b9", + "copilot:zh-CN:managed_block_payload": "9e8202318715ef5930a0d378192ffd5a0e97dd52a49560f4e8595ff2998cc5a7", "skills:zh-CN:tree": "41deccef82565834dcc8cfb6d81ec74db275a301356373fefe5b06bc0868009b", - "copilot:en-US:managed_block_payload": "7d96a48c9183b88e6d90c70457fc2046e01497d1c82d76240299f811a8221ecd", + "copilot:en-US:managed_block_payload": "cd864c0f25e3a8747ea50e5fdcc813b77d59d602ac596d33fecb8e724a57f569", "skills:en-US:tree": "7bbce298fb80c319978a0180e3a28a10d5a48bb27a3f309f126de13707865fb6" } } diff --git a/tests/test_installer.py b/tests/test_installer.py index ba81bb78..b04c5a72 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -18,6 +18,32 @@ _FOOTER_TIME_LABELS = ("Generated At:", "生成时间:") +_CN_PREFLIGHT_CONTRACT_SENTENCES = ( + "consult / quick_fix 不自动接续 active plan,也不展示无关状态异常。", + "MCP 只可选聚合客观事实,不可用时仍按文件协议继续。", + "无效旧指针遇到明确 new plan 或明确且有效的 continue 目标时,由 writer 直接切换,不追加清理确认;有效旧方案切换仍须用户确认。", + "用户正在回答匹配 checkpoint 时先消费回答,不重显同一问题。", + "machine truth 只通过 `sopify_writer` 写入;`active_plan` 只含 `plan_id`,Wave 和任务进度来自方案文件。", + "显式审计非 active plan 时,审计器只读;宿主校验目标 `plan.md` digest 后通过 writer 写 `verify_NNN` receipt,active pointer / handoff 不变。", + "session 标识只作 provenance,不单独阻断。", + "仅当用户明确要求同时开发、宿主确认另一任务仍在运行,或写入前出现非本轮已知的目标 plan digest / 匹配 handoff 变化时,才停止当前有副作用的开发", + "并只提示一次:`方案状态出现并行推进信号,本会话暂不写入。你可以继续只读审计,或确认其他开发已停止后在这里继续。`", + "用户确认后,先重读最新方案状态再继续。", +) + +_EN_PREFLIGHT_CONTRACT_SENTENCES = ( + "consult / quick_fix do not auto-resume the active plan or surface unrelated state errors.", + "MCP may only aggregate objective facts; if unavailable, continue with the file protocol.", + "When a stale invalid pointer meets an explicit new plan or an explicit valid continue target, let the writer switch directly without a cleanup confirmation; switching away from a still-valid plan still requires user confirmation.", + "When the user is answering the matching checkpoint, consume the answer before continuing and do not repeat the same question.", + "Write machine truth only through `sopify_writer`. `active_plan` contains only `plan_id`; Wave and task progress come from plan files.", + "For an explicit audit of a non-active plan, the verifier stays read-only; after revalidating the target `plan.md` digest, the host writes a `verify_NNN` receipt through the writer without changing the active pointer or handoff.", + "A session ID is provenance only and does not block continuation by itself.", + "Stop side-effecting development only when the user explicitly requests simultaneous development, the host confirms another task is still running, or the target plan digest / matching handoff changes before the first write due to an unknown write.", + "Show this once: `The plan state shows a parallel-advance signal, so this session will not write. You can continue read-only review, or confirm that the other development has stopped and then continue here.`", + "After the user confirms, reread the latest plan state before continuing.", +) + from installer.bootstrap_workspace import ( _REQUIRED_BUNDLE_FILES, _classify_workspace_bundle, @@ -1087,6 +1113,14 @@ def test_validate_payload_manifests_rejects_escaping_legacy_bundle_manifest(self class HostPromptContractTests(unittest.TestCase): + def _assert_preflight_contract( + self, + content: str, + expected_sentences: tuple[str, ...], + ) -> None: + for sentence in expected_sentences: + self.assertIn(sentence, content) + def _assert_no_footer_time_labels(self, content: str) -> None: for label in _FOOTER_TIME_LABELS: self.assertNotIn(label, content) @@ -1166,13 +1200,19 @@ def test_codex_cn_prompt_install_keeps_workspace_preflight_contract(self) -> Non validate_host_install(CODEX_ADAPTER, home_root=home_root) prompt = (home_root / ".codex" / "AGENTS.md").read_text(encoding="utf-8") - # Protocol entry contract (§8) - self.assertIn("protocol.md §8", prompt) - # 4-step protocol entry order - self.assertIn("active_plan.json", prompt) - self.assertIn("current_handoff.json", prompt) - # Writer boundary - self.assertIn("sopify_writer", prompt) + source = (REPO_ROOT / "skills" / "zh" / "header.md.template").read_text( + encoding="utf-8" + ) + for content in (source, prompt): + self.assertIn("protocol.md §8", content) + self.assertIn( + "`state/active_plan.json` → `plan//plan.md` → `state/current_handoff.json` → `plan//receipts/`", + content, + ) + self._assert_preflight_contract( + content, + _CN_PREFLIGHT_CONTRACT_SENTENCES, + ) def test_codex_cn_installed_prompt_assets_keep_footer_contract(self) -> None: self._assert_installed_footer_contract( @@ -1195,13 +1235,19 @@ def test_claude_en_prompt_install_keeps_workspace_preflight_contract(self) -> No validate_host_install(CLAUDE_ADAPTER, home_root=home_root) prompt = (home_root / ".claude" / "CLAUDE.md").read_text(encoding="utf-8") - # Protocol entry contract (§8) - self.assertIn("protocol.md §8", prompt) - # 4-step protocol entry order - self.assertIn("active_plan.json", prompt) - self.assertIn("current_handoff.json", prompt) - # Writer boundary - self.assertIn("sopify_writer", prompt) + source = (REPO_ROOT / "skills" / "en" / "header.md.template").read_text( + encoding="utf-8" + ) + for content in (source, prompt): + self.assertIn("protocol.md §8", content) + self.assertIn( + "`state/active_plan.json` → `plan//plan.md` → `state/current_handoff.json` → `plan//receipts/`", + content, + ) + self._assert_preflight_contract( + content, + _EN_PREFLIGHT_CONTRACT_SENTENCES, + ) def test_claude_en_installed_prompt_assets_keep_footer_contract(self) -> None: self._assert_installed_footer_contract( diff --git a/tests/test_sopify_mcp_server.py b/tests/test_sopify_mcp_server.py index 110c3bb3..3f30af9d 100644 --- a/tests/test_sopify_mcp_server.py +++ b/tests/test_sopify_mcp_server.py @@ -59,6 +59,24 @@ def _write_plan_md(path: Path) -> None: ) +def _write_active_plan_payload(sopify_root: Path, payload: object) -> Path: + path = sopify_root / "state" / "active_plan.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def _state_snapshot(sopify_root: Path) -> dict[str, bytes]: + state_root = sopify_root / "state" + if not state_root.is_dir(): + return {} + return { + path.name: path.read_bytes() + for path in sorted(state_root.iterdir()) + if path.is_file() + } + + class SopifyMcpServerCoreTests(unittest.TestCase): def test_resolve_workspace_root_rejects_missing_path(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -103,8 +121,12 @@ def test_workspace_status_lite_reports_missing_sopify_root(self) -> None: self.assertFalse(status["paths"]["history"]) self.assertFalse(status["paths"]["state"]) self.assertIsNone(status["active_plan"]) + self.assertFalse(status["active_plan_file_exists"]) self.assertIsNone(status["active_plan_dir_exists"]) + self.assertIsNone(status["active_plan_md_exists"]) self.assertFalse(status["handoff_exists"]) + self.assertIsNone(status["handoff_plan_id"]) + self.assertIsNone(status["handoff_matches_active_plan"]) def test_workspace_status_lite_reports_active_plan_directory(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -124,8 +146,89 @@ def test_workspace_status_lite_reports_active_plan_directory(self) -> None: self.assertTrue(status["paths"]["history"]) self.assertTrue(status["paths"]["state"]) self.assertEqual(status["active_plan"], {"plan_id": plan_id}) + self.assertTrue(status["active_plan_file_exists"]) self.assertTrue(status["active_plan_dir_exists"]) + self.assertFalse(status["active_plan_md_exists"]) self.assertFalse(status["handoff_exists"]) + self.assertIsNone(status["handoff_plan_id"]) + self.assertIsNone(status["handoff_matches_active_plan"]) + + def test_workspace_status_lite_reports_matching_handoff(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + sopify = workspace / ".sopify" + plan_id = "plan_001" + store = ProtocolStore(sopify) + store.set_active_plan(plan_id=plan_id) + store.set_current_handoff( + RuntimeHandoff(schema_version="1", plan_id=plan_id) + ) + _write_plan_md(sopify / "plan" / plan_id / "plan.md") + + status = workspace_status_lite(workspace) + + self.assertTrue(status["active_plan_md_exists"]) + self.assertEqual(status["handoff_plan_id"], plan_id) + self.assertTrue(status["handoff_matches_active_plan"]) + + def test_workspace_status_lite_reports_mismatched_handoff(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + sopify = workspace / ".sopify" + store = ProtocolStore(sopify) + store.set_active_plan(plan_id="plan_001") + store.set_current_handoff( + RuntimeHandoff(schema_version="1", plan_id="plan_002") + ) + _write_plan_md(sopify / "plan" / "plan_001" / "plan.md") + + status = workspace_status_lite(workspace) + + self.assertEqual(status["handoff_plan_id"], "plan_002") + self.assertFalse(status["handoff_matches_active_plan"]) + + def test_workspace_status_lite_rejects_invalid_active_plan_payloads(self) -> None: + for payload in (None, [], {}, {"plan_id": ""}): + with self.subTest(payload=payload), tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + sopify = workspace / ".sopify" + _write_active_plan_payload(sopify, payload) + before = _state_snapshot(sopify) + + result = _safe_tool("status", workspace_status_lite, workspace) + + self.assertIsNone(result["status"]) + self.assertEqual(result["error"]["code"], "ValueError") + self.assertEqual(_state_snapshot(sopify), before) + + def test_workspace_status_lite_rejects_polluted_plan_id(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + sopify = workspace / ".sopify" + _write_active_plan_payload(sopify, {"plan_id": "../outside"}) + before = _state_snapshot(sopify) + + result = _safe_tool("status", workspace_status_lite, workspace) + + self.assertIsNone(result["status"]) + self.assertEqual(result["error"]["code"], "ValueError") + self.assertEqual(_state_snapshot(sopify), before) + + def test_workspace_status_lite_does_not_write_state(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + sopify = workspace / ".sopify" + store = ProtocolStore(sopify) + store.set_active_plan(plan_id="plan_001") + store.set_current_handoff( + RuntimeHandoff(schema_version="1", plan_id="plan_001") + ) + _write_plan_md(sopify / "plan" / "plan_001" / "plan.md") + before = _state_snapshot(sopify) + + workspace_status_lite(workspace) + + self.assertEqual(_state_snapshot(sopify), before) def test_protocol_check_function_matches_cli_result_shape(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: diff --git a/tests/test_sopify_writer.py b/tests/test_sopify_writer.py index d2b85019..853714a6 100644 --- a/tests/test_sopify_writer.py +++ b/tests/test_sopify_writer.py @@ -11,12 +11,16 @@ from __future__ import annotations import json +from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from threading import Barrier import tempfile import unittest +from unittest.mock import patch from sopify_contracts import RuntimeHandoff from sopify_writer import InvariantViolationError, ProtocolStore +from sopify_writer.io import write_json_exclusive # Pre-P8 state files retired by the 2-file model (active_plan + current_handoff). # ProtocolStore must never produce these; if a new state file is added, add it here too. @@ -168,6 +172,67 @@ def test_write_verify_receipt(self) -> None: payload = json.loads(path.read_text(encoding="utf-8")) self.assertEqual(payload["provenance"]["receipt_id"], "verify_002") + def test_existing_receipt_is_not_overwritten(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = ProtocolStore(Path(temp_dir)) + path = store.write_plan_receipt( + plan_id="plan_001", + receipt_id="verify_001", + verdict="pass", + evidence={"source": "first"}, + ) + original = path.read_bytes() + + with self.assertRaises(FileExistsError): + store.write_plan_receipt( + plan_id="plan_001", + receipt_id="verify_001", + verdict="fail", + evidence={"source": "second"}, + ) + + self.assertEqual(path.read_bytes(), original) + + def test_concurrent_receipt_writes_create_exactly_one_file(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = ProtocolStore(Path(temp_dir)) + barrier = Barrier(2) + original_write = write_json_exclusive + + def synchronized_write(path: Path, payload: dict[str, object]) -> None: + barrier.wait() + original_write(path, payload) + + def write(source: str) -> str: + try: + store.write_plan_receipt( + plan_id="plan_001", + receipt_id="verify_001", + verdict="pass", + evidence={"source": source}, + ) + except FileExistsError: + return "exists" + return "created" + + with patch( + "sopify_writer.store.write_json_exclusive", + side_effect=synchronized_write, + ), ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(write, ("first", "second"))) + + self.assertCountEqual(results, ["created", "exists"]) + receipt = json.loads( + ( + Path(temp_dir) + / "plan" + / "plan_001" + / "receipts" + / "verify_001.json" + ).read_text(encoding="utf-8") + ) + self.assertIn(receipt["evidence"]["source"], {"first", "second"}) + def test_receipt_id_pattern_rejects_invalid(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: store = ProtocolStore(Path(temp_dir))