From 7dcd768ec145a5873d2adf4246467884c4234fd7 Mon Sep 17 00:00:00 2001 From: "sanze.li" <2522048902@qq.com> Date: Sun, 19 Jul 2026 14:36:07 +0800 Subject: [PATCH 1/4] feat: align plan contracts and retire CrossReview --- .agents/skills/cross-review/SKILL.md | 197 -------- .agents/skills/cross-review/skill.yaml | 17 - .sopify/blueprint/README.md | 3 +- .../architecture-decision-records/ADR-013.md | 2 +- .../architecture-decision-records/README.md | 4 +- .sopify/blueprint/background.md | 2 +- .sopify/blueprint/design.md | 26 +- .sopify/blueprint/protocol.md | 62 +-- .sopify/blueprint/tasks.md | 15 +- .../background.md | 39 -- .../cross-project-insights.md | 324 ------------- .../20260418_cross_review_engine/design.md | 74 --- .../hermes-insights.md | 427 ------------------ .../product-form-analysis.md | 381 ---------------- .../20260418_cross_review_engine/tasks.md | 54 --- .../design.md | 181 ++++++++ .../plan.md | 134 ++++++ .../tasks.md | 83 ++++ .sopify/project.md | 9 +- docs/how-sopify-works.en.md | 9 +- docs/how-sopify-works.md | 9 +- scripts/sopify_mcp_server.py | 15 +- scripts/sopify_protocol_check.py | 168 +++++-- scripts/w33_qoder_proof.py | 164 ++++--- skills/en/header.md.template | 17 +- skills/en/skills/sopify/design/SKILL.md | 2 +- .../design/assets/background-template.md | 24 - .../design/assets/plan-light-template.md | 24 - .../sopify/design/assets/plan-template.md | 52 +++ .../sopify/design/references/design-rules.md | 20 +- .../design/scripts/select_plan_level.py | 2 +- .../develop/references/develop-rules.md | 28 +- skills/en/skills/sopify/templates/SKILL.md | 104 ++--- skills/zh/header.md.template | 17 +- skills/zh/skills/sopify/design/SKILL.md | 2 +- .../design/assets/background-template.md | 24 - .../design/assets/plan-light-template.md | 24 - .../sopify/design/assets/plan-template.md | 52 +++ .../sopify/design/references/design-rules.md | 20 +- .../design/scripts/select_plan_level.py | 2 +- .../develop/references/develop-rules.md | 28 +- skills/zh/skills/sopify/templates/SKILL.md | 104 ++--- sopify_contracts/__init__.py | 13 +- sopify_contracts/plan_package.py | 115 +++++ sopify_writer/store.py | 235 +++++++--- tests/golden-snapshots.json | 16 +- tests/test_installer.py | 10 +- tests/test_plan_contract_assets.py | 119 +++++ tests/test_plan_package.py | 91 ++++ tests/test_sopify_mcp_server.py | 163 ++++++- tests/test_sopify_writer.py | 220 +++++++-- 51 files changed, 1846 insertions(+), 2081 deletions(-) delete mode 100644 .agents/skills/cross-review/SKILL.md delete mode 100644 .agents/skills/cross-review/skill.yaml delete mode 100644 .sopify/plan/20260418_cross_review_engine/background.md delete mode 100644 .sopify/plan/20260418_cross_review_engine/cross-project-insights.md delete mode 100644 .sopify/plan/20260418_cross_review_engine/design.md delete mode 100644 .sopify/plan/20260418_cross_review_engine/hermes-insights.md delete mode 100644 .sopify/plan/20260418_cross_review_engine/product-form-analysis.md delete mode 100644 .sopify/plan/20260418_cross_review_engine/tasks.md create mode 100644 .sopify/plan/20260718_evidentloop_optional_audit_integration/design.md create mode 100644 .sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md create mode 100644 .sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md delete mode 100644 skills/en/skills/sopify/design/assets/background-template.md delete mode 100644 skills/en/skills/sopify/design/assets/plan-light-template.md create mode 100644 skills/en/skills/sopify/design/assets/plan-template.md delete mode 100644 skills/zh/skills/sopify/design/assets/background-template.md delete mode 100644 skills/zh/skills/sopify/design/assets/plan-light-template.md create mode 100644 skills/zh/skills/sopify/design/assets/plan-template.md create mode 100644 sopify_contracts/plan_package.py create mode 100644 tests/test_plan_contract_assets.py create mode 100644 tests/test_plan_package.py diff --git a/.agents/skills/cross-review/SKILL.md b/.agents/skills/cross-review/SKILL.md deleted file mode 100644 index dfd52e5d..00000000 --- a/.agents/skills/cross-review/SKILL.md +++ /dev/null @@ -1,197 +0,0 @@ ---- -name: cross-review -description: "在开发完成后自动交叉评审代码变更。审查者运行在隔离的 LLM 会话中,不继承开发上下文,发现偏差和盲点。Advisory 模式:结论仅供参考,不自动阻断。" ---- - -## 何时触发 - -在 develop 阶段完成、代码已写入磁盘后触发。不要在规划阶段或未产生代码变更时触发。 - -前置条件: -- 工作区存在未评审的代码变更(已提交的 review range、已暂存的本轮变更,或可确认只包含本轮任务的未暂存变更)。 -- `crossreview` CLI 已安装,且支持 `pack --diff/--staged/--unstaged`、`render-prompt --pack`、`ingest --raw-analysis/--pack/--format human`。 -- 默认 host-integrated 路径可用:宿主能在 fresh / isolated review context 中执行 `render-prompt` 产出的完整 prompt。 - -注释:Sopify Phase 4a 的默认路径不要求 CrossReview 自己配置模型、provider 或 API key;LLM 调用由当前宿主完成。只有使用 standalone fallback 的 `crossreview verify` 时,才需要 `CROSSREVIEW_MODEL` / `CROSSREVIEW_PROVIDER` / API key 或等价 `crossreview.yaml`。 - -## 默认流程(Host-integrated) - -### Step 0 — 选择 diff source 并隔离 review range - -CrossReview `0.1.0a2+` 支持三种确定性差异来源: - -- `--staged`:优先用于本轮未提交变更的 pre-commit review,执行 `git diff --cached`,不要求创建 review commit。 -- `--diff REF`:用于已提交或需要可复现 review range 的场景,执行 `git diff REF HEAD`。 -- `--unstaged`:只在工作区未暂存变更可以确认全是本轮任务时使用,执行 `git diff`。 - -**0A — 选择推荐路径:** - -[IF 本轮变更尚未提交,且可以安全区分本轮目标文件] - 只 stage 本轮 develop 产生的文件(基于任务清单中的目标文件),不使用 `git add -A`。 - 设置 `DIFF_SOURCE="--staged"`。 - -[ELSE IF 本轮变更已提交,或用户要求审查提交范围] - [IF 任务涉及多次提交] - REF = HEAD~{提交数} - [ELSE] - REF = HEAD~1 - - 展示 REF 对应的 commit,让用户确认起点正确: - - ```bash - git log -1 --oneline - ``` - - [ACTION: INFORM_USER] "Review 起点为:`{REF 对应的 commit 摘要}`。本轮 develop 的所有变更应在此之后。" - 若用户认为起点不对,让用户指定正确的 commit SHA 作为 REF。 - 设置 `DIFF_SOURCE="--diff "`。 - -[ELSE IF 工作区存在未暂存变更,且可以确认全部属于本轮任务] - 设置 `DIFF_SOURCE="--unstaged"`。 - -[ELSE] - 跳过 advisory review,记录原因 `diff_source_not_isolated`,继续主流程。 - -重要:不要自动 `git add -A && git commit`。这会把工作区所有变更(包括无关草稿、未跟踪文件)一起提交,违反安全边界。 - -### Step 1 — 生成 ReviewPack - -```bash -CR_TMP="$(mktemp -d)" -printf '%s\n' "$CR_TMP" -crossreview pack $DIFF_SOURCE \ - --intent "任务意图摘要" \ - > "$CR_TMP/pack.json" -``` - -若 exit code 非零,记录 `pack_failed` 并结束本次 advisory review,继续主流程。 - -参数说明: -- `--intent "任务意图摘要"` — 帮助审查者理解变更目标(推荐始终提供)。 -- `--task ./task.md` — 任务描述文件;仅在文件真实存在时添加。 -- `--context ./plan.md` — 额外上下文文件(可重复);仅在文件真实存在时添加。 -- `--focus ` — 聚焦区域(可重复)。 - -注释:`pack` 只做确定性打包,不调用 LLM,不需要 API key。 -注释:如果后续命令不是在同一个 shell session 中执行,必须记录 `mktemp` 打印的实际目录,并把后续命令里的 `$CR_TMP` 显式替换为该目录;不要假设 shell 变量会跨 tool call 保留。 - -### Step 2 — 渲染 canonical reviewer prompt - -```bash -crossreview render-prompt --pack "$CR_TMP/pack.json" > "$CR_TMP/prompt.md" -``` - -若 exit code 非零,记录 `render_prompt_failed` 并结束本次 advisory review,继续主流程。 - -注释:`render-prompt` 只把 ReviewPack 渲染成标准审查 prompt,不调用 LLM,不需要 API key。 - -### Step 3 — 宿主执行隔离审查 - -在 fresh / isolated host review context 中执行 `"$CR_TMP/prompt.md"` 的完整内容,并将原始审查输出保存为: - -```bash -RAW_ANALYSIS="$CR_TMP/raw-analysis.md" -printf '%s\n' "$RAW_ANALYSIS" -``` - -注释:这里是保存路径约定;宿主隔离审查完成后,必须把 raw analysis 写入该文件,之后 Step 4 才能 ingest。 - -硬性约束: -- 不要把 develop 对话、作者推理链、未公开的自我解释放入 reviewer context。 -- 不要在当前 develop 上下文中直接“自己审自己”,这不满足 CrossReview 的上下文隔离目标。 -- 若宿主无法提供 fresh / isolated review context,记录原因 `host_isolation_unavailable` 并结束本次 host-integrated 审查(不得执行 Step 4);若 standalone 前置条件满足可改走备用流程,否则继续主流程。不要伪装成已完成 CrossReview。 - -### Step 4 — Ingest 原始审查输出并读取 verdict - -```bash -crossreview ingest \ - --raw-analysis "$RAW_ANALYSIS" \ - --pack "$CR_TMP/pack.json" \ - --model host_unknown \ - --prompt-source product \ - --prompt-version v0.1 \ - --format human -``` - -若 exit code 非零,记录 `ingest_failed` 并结束本次 advisory review,继续主流程。 - -命令成功时(exit code 0),输出格式为: - -```text -CrossReview 0.1-alpha | artifact: | review_status: - -Intent: -Intent Coverage: covered/partial/unknown -Pack Completeness: 0.XX - -Findings (N): - [HIGH] file.py:42 — 发现摘要 - [MED] other.py — 另一个发现 - -Advisory Verdict: - Rationale: <理由> -``` - -关键字段: -- `review_status` — `complete` / `rejected` / `failed` -- `Advisory Verdict` — 见 Step 5 分支 - -### Step 5 — 根据 verdict 分支处理 - -[IF review_status != "complete"] - [ACTION: LOG_WARNING] 记录非正常状态(rejected / failed),继续主流程,不阻断。 - [SKIP] 不进入以下 verdict 分支。 - -[IF Advisory Verdict == "pass_candidate"] - [ACTION: CONTINUE] - 告知用户:评审未发现问题,代码可以继续推进。 - -[IF Advisory Verdict == "concerns"] - [ACTION: SHOW_FINDINGS] 向用户展示所有 findings(按严重度排列)。 - [ACTION: ASK_USER] "评审发现以下问题:\n{findings}\n(A) 修改代码后重新评审 (B) 接受并继续 (C) 忽略" - - 用户选 A → 修改代码,回到 Step 0 重新执行 - - 用户选 B 或 C → 继续主流程 - -[IF Advisory Verdict == "needs_human_triage"] - [ACTION: SHOW_FINDINGS] 展示所有 findings。 - [ACTION: REQUEST_HUMAN] "评审发现需要人工判断的复杂问题,请审阅后决定。" - 等待用户明确指令后再继续。 - -[IF Advisory Verdict == "inconclusive"] - [ACTION: LOG_WARNING] "评审结果不确定,可能由于上下文不足、隔离审查失败或模型限制。" - 继续主流程,不阻断。 - -## 备用流程(Standalone Verify) - -仅在 host-integrated 路径不可用,且 standalone reviewer 配置已经存在时使用: - -```bash -crossreview verify $DIFF_SOURCE --intent "任务意图摘要" --format human -``` - -可选参数同 Step 1(`--task`、`--context`、`--focus`)。 - -Standalone 前置条件: -- 已设置 `CROSSREVIEW_MODEL` / `CROSSREVIEW_PROVIDER` / `CROSSREVIEW_API_KEY_ENV`,或等价 `crossreview.yaml`。 -- 对应 API key 环境变量存在。 - -注释:standalone fallback 会由 CrossReview CLI 直接调用 LLM API;这不是 Phase 4a 的默认宿主集成路径。 - -## 重要约束 - -- **Exit code 0 = 结果已产出**,不代表评审通过。必须读 `Advisory Verdict` 行判断。 -- **Exit code 非零 = 命令执行失败**,无评审结果。记录错误,继续主流程。 -- **Advisory 模式:verdict 仅供参考,不自动阻断任何流程。** -- 不要在无代码变更时运行(浪费 token)。 -- 默认 host-integrated 路径为 `pack -> render-prompt -> host isolated review -> ingest`;`verify --diff` 只是 standalone fallback。 -- `--format human` 用于终端展示,`--format json` 用于程序解析。 - -## 交付检查 - -- [ ] `crossreview pack` 成功生成 ReviewPack -- [ ] `crossreview render-prompt` 成功生成 canonical reviewer prompt -- [ ] 宿主 fresh / isolated review context 已产出 raw analysis -- [ ] `crossreview ingest --format human` 成功产出 ReviewResult -- [ ] Advisory Verdict 已读取并展示给用户 -- [ ] concerns / needs_human_triage 时用户已被告知并做出决定 -- [ ] 结果不阻断主流程(advisory only) diff --git a/.agents/skills/cross-review/skill.yaml b/.agents/skills/cross-review/skill.yaml deleted file mode 100644 index 8893f67d..00000000 --- a/.agents/skills/cross-review/skill.yaml +++ /dev/null @@ -1,17 +0,0 @@ -schema_version: "1" -id: cross-review -mode: advisory -names: - zh-CN: 交叉评审 - en-US: cross-review -descriptions: - zh-CN: "使用上下文隔离的方式自动审查 AI 生成代码变更。审查者在独立 LLM 会话中运行,不继承开发上下文,发现偏差和盲点。" - en-US: "Automated cross-review for AI-generated code using context isolation. Reviewer runs in a fresh LLM session with no access to the author's reasoning, catching blind spots and drift." -triggers: - - review - - cross-review - - verify - - post_develop -host_support: - - "*" -permission_mode: default diff --git a/.sopify/blueprint/README.md b/.sopify/blueprint/README.md index cd0550f5..23259c30 100644 --- a/.sopify/blueprint/README.md +++ b/.sopify/blueprint/README.md @@ -13,7 +13,8 @@ ## 当前焦点 -- 当前无活动 plan;MCP tool plane 已完成 Codex-first 持久注册验证和 Claude 会话级只读调用验证,Qoder / Claude / Copilot 的后续持久注册仍按证据推进。 +- 当前活动 plan:`../plan/20260718_evidentloop_optional_audit_integration/`;Wave 1–2 已实施并通过全量回归,等待用户审计后再决定是否进入 Wave 3。 +- 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/20260717_sopify_entry_preflight`。 diff --git a/.sopify/blueprint/architecture-decision-records/ADR-013.md b/.sopify/blueprint/architecture-decision-records/ADR-013.md index 2f255566..4cc55b46 100644 --- a/.sopify/blueprint/architecture-decision-records/ADR-013.md +++ b/.sopify/blueprint/architecture-decision-records/ADR-013.md @@ -66,6 +66,6 @@ Core + Default Workflow 之外的生产/验证/知识增强,归入 **Plugins / - Core(durable)只保留证据规范 / 协议准入 / 审计证据 / 合规验证 / 跨宿主接力契约 - Default Workflow 是 Core 之上的官方收敛式工作流,地位高于参考 demo,低于 Core - 具有独立用户价值的生产/验证/知识能力设计为外插 Plugins / Skills -- CrossReview 是已实现的外插参考范本 +- CrossReview 曾是外插参考范本,现已从当前执行面退场;当前边界是通用 Verifier 契约,EvidentLoop 仅作为可选实例 - Runtime 已在 P8 中退场;protocol kernel(sopify_writer + sopify_contracts + protocol.md)是 post-P8 的真相源 - Core promotion rule:只有影响跨宿主互操作、receipt validity、archive admissibility 的契约才进 Core diff --git a/.sopify/blueprint/architecture-decision-records/README.md b/.sopify/blueprint/architecture-decision-records/README.md index 488f682c..f2ce9fa4 100644 --- a/.sopify/blueprint/architecture-decision-records/README.md +++ b/.sopify/blueprint/architecture-decision-records/README.md @@ -19,14 +19,14 @@ Architecture Decision Records for Sopify. | ADR-001 | 不 pipeline 化 engine | historical / runtime-mode constraint | 被 ADR-016 的 Runtime-optional 视角吸收;engine 不再作为 core 边界来源 | - | | ADR-002 | Checkpoint: LLM 判断 + Python/Validator 持久化 | absorbed | 被 ADR-017 与 `blueprint/design.md` Checkpoint 契约吸收;checkpoint 收敛为 `clarification` / `decision` | - | | ADR-003 | `skill.yaml` v2 向后兼容 | scoped / skill package | 保留在 skill package 标准化语境;不作为当前 core baseline 实体 | - | -| ADR-004 | CrossReview 分阶段验证 (advisory → runtime) | scoped / plugin | CrossReview 作为质量验证参考实现;不并入 Sopify core ADR 实体 | - | +| ADR-004 | CrossReview 分阶段验证 (advisory → runtime) | historical / retired plugin | 旧集成事实保留;当前由通用 Verifier 契约与可选 EvidentLoop 方案替代 | - | | ADR-005 | 旧总纲残余吸收策略 | historical | 旧纲吸收已完成;证据留 git history | - | | ADR-006 | 子任务包方向一致性约束 | absorbed | 被 `blueprint/tasks.md` 执行优先级、Plan intake checklist 与 `blueprint/design.md` 硬约束吸收 | - | | ADR-007 | 多宿主原生支持 | absorbed | 被 ADR-016 Protocol-first 与 `blueprint/design.md` Surface-shared / Wire-composable 吸收 | - | | ADR-008 | Plugin 策展集成模型 | deferred / scoped | 后续插件体系或 skill package 分支再实体化;当前不进入 core baseline | - | | ADR-009 | `pipeline_hooks` 默认关闭 | historical / frozen | hooks 不作为当前主线;如恢复需新 ADR 或实现分支重审 | - | | ADR-010 | 不允许外部 plugin 引入新 checkpoint 类型 | superseded | 被 `blueprint/design.md` Checkpoint target: 2 取代,旧 4 类型口径不再合法化 | - | -| ADR-011 | CR verdict → checkpoint 映射 + review-fix 循环限制 | scoped / plugin | CrossReview runtime 模式冻结;后续若恢复 runtime 集成再实体化 | - | +| ADR-011 | CR verdict → checkpoint 映射 + review-fix 循环限制 | historical / retired plugin | 不迁移到 EvidentLoop;默认 hook、checkpoint 映射和 review-fix bridge 均不再采用 | - | | ADR-012 | Minimal Core Boundary — 4 层架构 | superseded | 被 ADR-016 的 Protocol / Validator / Runtime 三层定位取代 | - | | ADR-013 | Product Positioning: Evidence & Authorization Layer | canonical | Sopify 产品定位与生存性测试 | [ADR-013.md](ADR-013.md) | | ADR-014 | Skill / Plugin Permission Tiers | absorbed | 权限语义收敛到 ADR-017 side-effect authorization 与 `blueprint/design.md` hard constraints;skill 权限细节保留在专项 skill 文档 | - | diff --git a/.sopify/blueprint/background.md b/.sopify/blueprint/background.md index d346e709..42cd41d3 100644 --- a/.sopify/blueprint/background.md +++ b/.sopify/blueprint/background.md @@ -14,7 +14,7 @@ Sopify 官方在 core 之上提供一个轻量、可插拔、收敛式的 bluepr |----|------|--------| | **Protocol Kernel**(协议内核) | 协议规范 + sopify_writer + 收据/接力/归档 truth | Durable:不随宿主生态变化而 sunset | | **Default Workflow** | blueprint 驱动的分析 → 计划讨论 → 标准方案包 → 开发验证 → 归档回写 | 用户买到的主体验,建构在 protocol kernel 之上 | -| **Plugins / Skills** | cross-review、graphify、宿主自带分析/开发/验证增强 | Sopify 定义接入点,不做 skill 分发 | +| **Plugins / Skills** | EvidentLoop(可选)、其他 Verifier、graphify、宿主自带增强 | Sopify 定义通用接入点,不绑定单一实现 | 核心价值不在于"能调 skill"或"能编排 workflow",而在于: diff --git a/.sopify/blueprint/design.md b/.sopify/blueprint/design.md index 809888fe..39ed17f4 100644 --- a/.sopify/blueprint/design.md +++ b/.sopify/blueprint/design.md @@ -31,10 +31,16 @@ Sopify 是装在现有 AI 编程宿主上的**开发过程协议层**。它把 | 类型 | 职责 | 典型实例 | 输出流向 | |------|------|---------|---------| -| **Verifier** | 独立验证生产结果,输出 verdict + evidence | cross-review | receipt 风险因子(advisory,不自授权) | +| **Verifier** | 独立验证生产结果,输出 verdict + evidence | EvidentLoop(可选)或其他验证器 | 由宿主通过 writer 写入正式 receipt(不自授权) | | **Deterministic Checker** | 确定性规则检查(lint / test / schema) | CI gate adapter(未实现) | 证据链 / handoff evidence | | **Knowledge Provider** | 提供外部知识输入 | graphify | blueprint / plan context | +### Verifier 使用边界 + +以下形态都合法:只使用 Sopify;显式选择其他 Verifier;使用已独立安装的 EvidentLoop;未来通过 `--with-evidentloop` 一次性配套安装。第四种只是安装便利,将在对应安装 Wave 落地前保持不可用;安装来源不进入运行判断或 receipt。 + +Sopify 不默认提示、安装或执行任何 Verifier。独立审计可不绑定方案;方案主审计使用 `audits/plan/`,附加审计使用 `audits//`。`audits/` 不进入 active plan、handoff 或 4 步默认读取链;正式采用的证据由宿主通过 writer 写 receipt。 + **对外承诺分层(Now / Emerging / Future):** | 层级 | 能力 | 当前状态 | @@ -42,12 +48,12 @@ Sopify 是装在现有 AI 编程宿主上的**开发过程协议层**。它把 | **Now** | 跨宿主可恢复状态(Protocol + sopify_writer) | ✅ Codex / Claude / Qoder protocol_verified | | ~~**Now**~~ | ~~fail-closed 授权收据(ExecutionAuthorizationReceipt)~~ | ~~✅ P1.5 已交付~~ [RETIRED by P8] post-P8 审计主链改为 `plan//receipts/*.json` + `history//receipt.md` | | **Now** | blueprint-driven 知识沉淀 | ✅ 已交付 | -| **Emerging** | 隔离独立审查(cross-review skill) | Advisory only;不自动阻断 | +| **Emerging** | 可选独立审查(EvidentLoop 为首个推荐实例) | 非默认安装、非默认执行;不自动阻断 | | **Emerging** | Convention-first 外部宿主接入 | Protocol spec v0 已落地;缺少面向外部宿主的 quickstart | | **Future** | 硅基↔硅基信任协议(跨 agent 授权) | 概念定义阶段 | | **Future** | Plugin admission + Verifier evidence 标准化 | 设计阶段 | -**协作模式定位:** 当前主战场是碳基↔硅基(人与 AI)。硅基↔硅基仍属战略预留,现阶段仅有 advisory cross-review 作为早期验证形态。碳基↔碳基主要体现为 blueprint / history 共享带来的兼容收益,不是独立产品面。 +**协作模式定位:** 当前主战场是碳基↔硅基(人与 AI)。硅基↔硅基仍属战略预留;当前只允许用户显式选择外部 Verifier 形成审计证据,不把某个验证器变成默认工作流。碳基↔碳基主要体现为 blueprint / history 共享带来的兼容收益,不是独立产品面。 **竞品边界:** @@ -77,7 +83,7 @@ Sopify 是装在现有 AI 编程宿主上的**开发过程协议层**。它把 **微观(单任务)是收敛链**:produce → verify → record evidence → settle。目标是按风险逐步降低不确定性,收敛到"可归档阈值"即停止——不以"更完整/更优雅"为默认继续条件。 - produce: 外部生产器(LLM/宿主)输出候选事实 -- verify: 外部验证器(cross-review 等)提供独立证据 +- verify: 外部验证器(EvidentLoop、其他审计器、测试和 lint 等)提供独立证据 - record evidence: sopify_writer + 协议校验将过程证据写入 receipts/;host 负责语义级 admission - settle: 沉淀为 receipt / handoff / history @@ -153,16 +159,16 @@ Sopify 是装在现有 AI 编程宿主上的**开发过程协议层**。它把 - Host LLM 是执行者,**不是 authorizer** - Protocol admission 做**结构级校验**(sopify_writer:schema 合法、plan_id 有效、receipt 命名规范);host prompt 做**语义级引导** -- sopify_writer **不是 executor**:不做 plan materialization、文件迁移、状态推进 +- sopify_writer **不是 executor**:不生成或修改方案语义;唯一目录迁移例外是 `finalize_plan` 在版本校验后执行标准归档 - 宿主执行层**按协议文件做事**:读 plan.md / handoff / receipts,写回通过 sopify_writer ### Subject Identity(主体身份) ActionProposal 管线中,每个 bound-subject side-effecting action 必须携带明确的 subject identity——"操作的是谁"。Subject identity 是 protocol 层契约,sopify_writer 和宿主都是消费方。Subject-free actions(`consult_readonly`、`propose_plan`)不要求主体。 -- `subject_type`:被操作对象类型(`plan` 为 normative;`code` / `architecture` 保留 draft) +- `subject_type`:被操作对象类型;当前只有 `plan` 为 normative - `subject_ref`:对象定位,workspace-relative 路径(如 `.sopify/plan/20260501_dark_mode`) -- `revision_digest`:版本标识(目标对象的 SHA-256 hex digest),保证操作绑定到确定性快照 +- `plan_version`:完整方案版本,按 level 对应的语义文件集合确定性计算 主体取证优先级:explicit reference → self-reference → new-plan intent → stable handoff evidence → current-plan anchor。 @@ -356,7 +362,7 @@ 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;只有用户明确并行开发、确认另一任务仍在运行,或写入前发现非本轮状态变化时,才停止当前有副作用的开发。 +**会话入口稳定边界**:宿主先识别意图,consult / quick fix 不因陈旧状态进入接续链;`active_plan` 保持纯 `plan_id` 指针,Wave / 任务进度来自方案文件。显式审计非 active plan 时,Verifier 的语义审查对目标只读;宿主可按用户选定 Verifier 的官方流程执行只读取证和独立报告生成,随后校验完整方案的 `plan_version` 并仅通过 `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)。 @@ -420,8 +426,8 @@ P4b 减重和 P4c 宿主消费治理的红线边界。只冻结 artifact / schem |---------|------|----------|-------------|----------|----------------------| | protocol.md §6 Verifier: `verdict`, `evidence`, `source` | doc_contract | host / external_tool | semantics | 跨宿主验证结果的标准格式;宿主消费 verdict 做风险判断 | `scope`(SHOULD,非 MUST);verifier 内部实现方式 | | ~~protocol.md §6 ExecutionAuthorizationReceipt~~ [RETIRED by P8] | ~~doc_contract~~ | ~~host / external_tool~~ | ~~semantics~~ | ~~fail-closed 授权回执~~ P8 后审计主链改为 `plan//receipts/*.json` + `history//receipt.md` | — | -| protocol.md §7 Subject Identity: `subject_type`, `subject_ref`, `revision_digest` | doc_contract | host / external_tool | semantics | 操作主体绑定;admission fail-closed 的前提 | subject resolution 的 runtime 实现方式 | -| protocol.md §7 plan_subject block: `subject_ref`, `revision_digest` | doc_contract | host | semantics | bound-subject 操作的必要条件 | action applicability matrix 的具体枚举值(实现细节) | +| protocol.md §7 plan Subject Identity: `subject_type`, `subject_ref`, `plan_version` | doc_contract | host / external_tool | semantics | 操作主体绑定;admission fail-closed 的前提 | 其他主体的版本字段 | +| protocol.md §7 plan_subject block: `subject_ref`, `plan_version` | doc_contract | host | semantics | bound-subject 操作的必要条件 | action applicability matrix 的具体枚举值(实现细节) | | ~~`current_gate_receipt.json`~~ [RETIRED by P8] | ~~gate_contract~~ | ~~host / external_tool~~ | ~~schema~~ | ~~gate 入口判定~~ P8 退场 pre-execution gate model | — | | `current_handoff.json` top-level: `schema_version`, `route_name`, `run_id`, `plan_id`, `plan_path`, `handoff_kind`, `required_host_action`, `artifacts`, `notes`, `observability`, `resolution_id` | machine_truth | host | schema | 宿主消费 handoff 做执行交接;跨宿主恢复的核心数据 | 内部组装方式(Python to_dict/from_dict);observability 子字段可演进;`recommended_skill_ids` 已在 6.3 裁定中退役(宿主从未消费) | | Archive truth — ArchiveCheckResult: `status`, `subject`, `notes`, `knowledge_sync_result` | machine_truth | host | schema | archive 前检查结果;宿主据此决定是否归档 | Python dataclass 名称和内部方法 | diff --git a/.sopify/blueprint/protocol.md b/.sopify/blueprint/protocol.md index eca9c188..8f93b259 100644 --- a/.sopify/blueprint/protocol.md +++ b/.sopify/blueprint/protocol.md @@ -72,7 +72,7 @@ |---|---|---| | **light** | plan.md | 小任务、单步修复、探索性提案 | | **standard** | plan.md + tasks.md | 多任务、需逐项验收 | -| **architecture** | plan.md + design.md + tasks.md + receipts/ + assets/ | 架构级、协议级、状态模型变更 | +| **architecture** | plan.md + tasks.md + design.md | 架构级、协议级、状态模型变更 | **plan.md 结构**:顶部推荐有 Plan Snapshot 区块(Goal / Status / Next / Task),然后是 8 必备章节(顺序固定): @@ -87,7 +87,7 @@ Plan Snapshot 缺失不阻断审计和接续;host 回退读取完整 plan.md。Plan Snapshot 不是目录索引、不是 state 文件、不是权威审计事实。 -**receipts/ 规则(条件必备)**:managed plan 产生执行/验证事件时必须写到 `receipts/*.json`;finalize 时必须生成 `receipts/final.json`;light plan 如无 managed execution 可无 receipts/。命名规范 `exec_NNN / verify_NNN / final`。 +任何级别都不预建空 receipts、assets、ADR 或 diagram。managed plan 产生执行/验证事件时才写 `receipts/*.json`;finalize 时生成 `receipts/final.json`。命名规范 `exec_NNN / verify_NNN / final`。 **不加**:status.json / plan-level README.md / plan/\/handoff.json(handoff 单态,只在 state/)。 @@ -135,7 +135,7 @@ Plan Snapshot 缺失不阻断审计和接续;host 回退读取完整 plan.md 3. 宿主创建 plan/20260501_dark_mode/plan.md(含 title/scope/approach + 内联 tasks) 4. 宿主逐项完成 tasks,更新 plan.md 中 status → done - 可插拔点:用户可在此阶段引入外部工具(如 graphify 生成架构图、 - cross-review 做独立审查)强化 blueprint 或方案质量,不改变核心方案流 + EvidentLoop 或其他 Verifier 做独立审查)强化方案质量,不改变核心方案流 5. 宿主归档到 history/2026-05/20260501_dark_mode/receipt.md 6. 宿主将稳定设计决策回写 blueprint/design.md(knowledge_sync) ``` @@ -185,7 +185,7 @@ Plan Snapshot 缺失不阻断审计和接续;host 回退读取完整 plan.md - [ ] 能读取 `.sopify/project.md` 并识别项目名 - [ ] 能读取 `blueprint/` 三件套并作为上下文消费 - [ ] 能在 `plan/` 下创建结构化方案包 -- [ ] 方案包至少包含 `plan.md`(title/scope/approach + 内联 tasks/status);standard/full 另需单独 tasks.md 等 +- [ ] 方案包至少包含 `plan.md`;standard 另有 `tasks.md`,architecture 再有 `design.md` - [ ] 能将完成的方案归档到 `history/YYYY-MM/` 并生成 `receipt.md` - [ ] 归档后能将稳定结论回写 blueprint @@ -214,13 +214,13 @@ Sopify 接收后由 protocol admission 做结构校验准入,不由生产器 > **升格状态**:本子段从 informative 升格为 **normative**(P1.5-D 升格)。字段约束使用 RFC 2119 表述。消费路径为 normative 声明。evidence attachment wire format 为 deferred。 -外部验证器(cross-review、测试框架、lint 等)回传给 Sopify 的是 **verdict + evidence**: +外部验证器(EvidentLoop、其他审计器、测试框架、lint 等)回传给 Sopify 的是 **verdict + evidence**: | 字段 | RFC 2119 | 说明 | |------|----------|------| | `verdict` | **MUST** | 可被 Validator 消费的判定标识。具体值域可由 Verifier 实现细化;canonical verdict 值域与完整 mapping 待后续里程碑正式化 | | `evidence` | **MUST** | 可 machine-readably 消费的证据(如文件路径、行号、代码片段等) | -| `source` | **MUST** | 验证器来源标识(如 `cross-review:v1`、`unittest`),供 Validator 和宿主解释 evidence provenance | +| `source` | **MUST** | 验证器来源标识(如 `evidentloop`、`unittest`),供宿主解释 evidence provenance | | `scope` | **SHOULD** | 验证范围(全量 / 增量 / 特定文件)。缺失不阻断 contract 成立,但会降低证据解释力 | **注意**:Verifier 输出的是 **evidence 输入**,不是授权输出;只有 protocol admission 有权准入。(P8 后 Validator 进程已退场,"授权"语义收窄为 protocol admission + receipt validity。) @@ -229,11 +229,11 @@ Sopify 接收后由 protocol admission 做结构校验准入,不由生产器 **verdict**:Validator **MUST** 将 Verifier verdict 视为授权风险因子。Verifier **MAY** 使用实现特定的更细粒度枚举;Validator 与宿主 **SHOULD** 能将其归一化到稳定语义层。canonical verdict 值域与完整 normalization mapping 待后续里程碑正式化;在此之前,verdict **MUST NOT** 被当作自授权信号,而只能作为风险/证据输入。 -**evidence**:Verifier evidence **MUST** 进入 Sopify 的后续证据链。当存在结构化 handoff 承载位点时,**SHOULD** 挂载到 handoff。receipt / history / plan metadata 的具体 attachment 位置与 wire format 继续 **deferred**。 +**evidence**:只有被用户选为正式方案证据的 Verifier 结果才 **MUST** 通过 `sopify_writer` 写入 plan receipt。独立审计可以不绑定方案。审计报告不写入 handoff,也不进入 4 步默认读取链。 **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。 +显式审计某个 plan 时,Verifier 的语义审查过程对审计主体保持只读并返回 `verdict / evidence / source`。宿主以方案目录 `subject_ref` 和完整方案的 `plan_version` 重新校验主体后,只能通过 `sopify_writer` 写入该方案未占用的 `verify_NNN` receipt;不得切换 active plan、改写 handoff 或修改方案正文。 #### Verifier Read-Only Contract(P8 升格) @@ -242,12 +242,12 @@ P8 新增 Verifier 写入边界约束: | 规则 | RFC 2119 | |---|---| | Verifier **MUST** emit verdict + evidence + source | MUST | -| Verifier **MUST** be read-only: true | MUST | -| Verifier **MUST NOT** write `state/**`, `plan/**`, `blueprint/**` | MUST NOT | -| Verifier **MUST NOT** invoke `execute_command` or `modify_files` | MUST NOT | +| Verifier 的语义审查过程 **MUST** 对审计主体只读 | MUST | +| Verifier **MUST NOT** 修改审计主体或写入 `state/**`、`plan/**`、`blueprint/**` | MUST NOT | +| 语义 reviewer **MUST NOT** invoke `execute_command`、`modify_files` 或等价写操作 | MUST NOT | | Verifier verdict **MUST NOT** be treated as self-authorization | MUST NOT | -违反 read-only 约束的 Verifier verdict 降级为 advisory(不自授权)。具体 bridge enforcement 不在 P8 必须范围,后续由 cross-review 独立 slice 实现。 +上述只读约束不禁止宿主按用户选定 Verifier 的官方 Skill 执行受限取证、校验和报告命令;这些机械步骤只能读取审计主体并写 Verifier 自有临时/报告目录。正式报告附着由宿主完成,machine truth 与 receipt 仍只能通过 `sopify_writer` 写入。违反审计主体只读约束的 Verifier verdict 降级为 advisory(不自授权)。Sopify 不为特定 Verifier 建 bridge 或默认执行钩子。 ### Knowledge Provider(外部知识工具) @@ -303,13 +303,13 @@ ExecutionAuthorizationReceipt 是 execute_existing_plan 授权通过后生成的 - 如 `gate_status` 与当前 ExecutionGate.gate_status 不匹配,Validator MUST 返回 DECISION_REJECT - Stale receipt MUST NOT 降级为 consult,MUST NOT 自动 re-authorize -**命名对齐注释**:`plan_revision_digest`(receipt 字段)是通用 Subject Identity 中 `revision_digest` 在 plan subject 场景的特化命名,不是独立概念。两者 MUST NOT 长期并存为不同语义。 +**历史说明**:`plan_revision_digest` 只属于已退场 EAR,不能用于当前 plan receipt;当前统一使用 `plan_version`。 ## 7. Subject Identity & Review Wire Contract -> **升格状态**:本节中 Subject Identity 的通用字段与核心语义(subject_type / subject_ref / revision_digest / 取证优先级)为 **normative**(其中 `subject_type` 仅 `"plan"` 为 normative,其余值域保留 draft)。Bound-subject local actions 的 Subject Binding 为 **normative**(P1 升格 execute_existing_plan;P2 扩展到 modify_files / checkpoint_response / cancel_flow 条件性)。Review Wire Contract 部分仍为 informative/draft,待后续里程碑联动升格。 +> **升格状态**:本节中 plan 主体的 `subject_type / subject_ref / plan_version` 与取证优先级为 **normative**。其他主体类型及其版本字段保留 draft。Bound-subject local actions 的 Subject Binding 为 **normative**;Review Wire Contract 部分仍为 informative/draft。 > > 本节定义跨宿主协作中"操作的是谁"的最小 machine contract(wire level)。适用于 review、execute_existing_plan、modify_files、checkpoint_response、cancel_flow、archive 等所有需要绑定主体的场景。收敛策略性规则(轮数上限、severity 判定、冲突解决)归 `design.md` Default Workflow 策略。 @@ -319,11 +319,11 @@ ExecutionAuthorizationReceipt 是 execute_existing_plan 授权通过后生成的 | 字段 | 说明 | |------|------| -| `subject_type` | 被操作对象类型(`plan` 为 normative;`code` / `architecture` 保留 draft) | +| `subject_type` | 被操作对象类型;当前只有 `plan` 为 normative | | `subject_ref` | 对象定位:workspace-relative 路径(如 `.sopify/plan/20260501_dark_mode`) | -| `revision_digest` | 版本标识:目标对象的确定性快照标识(SHA-256 hex digest),保证操作绑定到确定性快照 | +| `plan_version` | 完整方案的确定性版本,由该级别的 `plan.md / tasks.md / design.md` 语义文件计算 | -> **命名对齐注释**:通用 Subject Identity 使用 `revision_digest`;ExecutionAuthorizationReceipt 使用 `plan_revision_digest`。后者是前者在 plan subject 场景的特化命名,不是独立概念。实现时 MUST NOT 混用或创建不同语义。 +其他主体未来按产品语义定义自己的清晰版本名,不复用模糊的通用 digest 字段。 **主体取证优先级**(当 subject 未显式给出时的解析链路): @@ -354,18 +354,18 @@ ExecutionAuthorizationReceipt 是 execute_existing_plan 授权通过后生成的 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `subject_ref` | string | MUST | 目标 plan 的 workspace-relative 方案目录路径(如 `.sopify/plan/20260501_dark_mode`) | -| `revision_digest` | string | MUST | 该目录下 `plan.md` 文件内容的 SHA-256 hex digest | +| `plan_version` | string | MUST | 当前完整方案版本,格式包含算法前缀 | **可携带规则**: - 跨 session 接力时,新 session MUST 通过 handoff 或 plan 文件重新建立 subject binding,MUST NOT 隐式继承前 session 的绑定 -- plan 内容变更(revision_digest 不匹配)后,已有 ExecutionAuthorizationReceipt 自动失效 +- 方案语义文件变化导致 `plan_version` 不匹配后,既有绑定不再代表当前方案 > **P8 术语说明**:以下 "Validator MUST" 规则描述的是协议准入逻辑。P8 后 Validator 进程已退场,这些规则由 sopify_writer schema 校验 + host prompt 语义引导承担。 -- Validator 在 admission 阶段 MUST 校验 `subject_ref` 存在性 + `revision_digest` 一致性 +- Validator 在 admission 阶段 MUST 校验 `subject_ref` 存在性 + `plan_version` 一致性 - `subject_ref` MUST 是 workspace-relative 路径,MUST 以 `.sopify/plan/` 开头,MUST NOT 包含 `..` 或绝对路径 -- 对 bound-subject actions:缺少 `plan_subject`、`subject_ref` 指向不存在的 plan、或 `revision_digest` 与文件实际内容不匹配时,Validator MUST 返回 DECISION_REJECT(不降级 consult) +- 对 bound-subject actions:缺少 `plan_subject`、`subject_ref` 指向不存在的 plan、方案结构无效或 `plan_version` 与当前版本不匹配时,Validator MUST 返回 DECISION_REJECT(不降级 consult) - 对 `cancel_flow`:上述检查在 `plan_subject` 存在时适用;缺失 `plan_subject` 时不触发 REJECT > **Legacy mapping 注释**(informative): @@ -415,13 +415,13 @@ ExecutionAuthorizationReceipt 是 execute_existing_plan 授权通过后生成的 | 字段 | 说明 | |------|------| | `review_id` | 唯一审查标识 | -| `reviewer_source` | 审查来源标识(`host:cursor` / `host:claude-code` / `skill:cross-review`) | +| `reviewer_source` | 审查来源标识(如 `host:claude-code`、`evidentloop`、其他 Verifier) | | `convergence_action` | `accept` / `revise` / `blocked` / `escalate` | | `revision_suggestions` | 具体修改建议(可选) | ### 存储位置 -审查记录作为 evidence 进入 handoff 或 plan metadata,归档时纳入 receipt 的 verification_evidence。evidence 挂载的 normative 消费规则见 §6 Verifier 消费路径。evidence attachment 的 wire format(字段 schema、路径约定)为 deferred,不属于当前 normative scope。 +独立审计沿用 Verifier 自己的默认目录,不强制绑定 Sopify。方案主审计放在 `audits/plan/`;针对性附加审计放在 `audits//`。只有作为正式方案证据时,宿主才通过 writer 把 `source / audit_path / plan_version` 写入 `verify_NNN` receipt。`audits/` 不进入 active plan、handoff 或 4 步默认读取链。 ## 8. Host Protocol Entry Contract(P8 post-cutover) @@ -461,7 +461,9 @@ ExecutionAuthorizationReceipt 是 execute_existing_plan 授权通过后生成的 **顺序设计原则**:active_plan 定位后**先读 plan.md 建立语义真相**,再读 current_handoff 作为恢复提示——避免 handoff 反过来变成第二真相源。 -`workspace_status_lite` 可用时只聚合上述文件事实;不可用时宿主直接按 4 步文件协议继续。MCP 不是 managed action 的入口依赖,也不输出工作流结论。 +宿主同时按 `plan.md` frontmatter 的 level 校验精确语义文件集合,并计算 `plan_version`:light 为 `plan.md`,standard 再含 `tasks.md`,architecture 再含 `design.md`。路径与原始字节按固定顺序参与计算;audits、receipts、assets 与 state 不参与。 + +`workspace_status_lite` 可用时只聚合上述文件事实,并以 `valid / error / version` 报告方案结构与版本;不可用时宿主直接按文件协议继续。MCP 不是 managed action 的入口依赖,也不输出工作流结论。 ### 8.4 读取预算红线 @@ -494,16 +496,18 @@ 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` 标识;本协议不新增编号器、锁或重试机制。 +`sopify_writer` 写 receipt 时 MUST 先校验方案结构和当前 `plan_version`,将版本写入 `provenance.plan_version`,并拒绝版本冲突或覆盖同名文件。调用方选择未占用的既有 `exec_NNN / verify_NNN / final` 标识;本协议不新增编号器、锁或重试机制。 + +`finalize_plan` 在最终版本校验后写 final/history receipt,将完整方案目录迁入 `history/YYYY-MM/`,归档成功后才清 active plan 和 handoff。版本冲突或归档失败必须保留源方案与 state。 -session 标识只用于 provenance,不单独构成并发冲突。只有用户明确要求同时开发、宿主确认另一任务仍在运行,或从本轮最近一次已验证快照到首次有副作用开发前出现非本轮已知写入导致的目标 `plan.md` digest / 匹配 handoff 变化,宿主才停止当前写入并询问一次。用户确认其他开发已停止后,宿主重读最新方案状态再继续。 +session 标识只用于 provenance,不单独构成并发冲突。只有用户明确要求同时开发、宿主确认另一任务仍在运行,或从本轮最近一次已验证快照到首次有副作用开发前出现非本轮已知写入导致的目标 `plan_version` / 匹配 handoff 变化,宿主才停止当前写入并询问一次。用户确认其他开发已停止后,宿主重读最新方案状态再继续。 ### 8.7 链路失败模式(fail-open) | 步 | 文件缺失时 host 行为 | |---|---| | 1 active_plan 缺失 | 进入 consult 模式或提示 new-plan;不阻断 | -| 2 plan.md 缺失 | 异常 → 提示用户 state 不一致 | +| 2 plan.md 缺失或方案结构无效 | 异常 → 报告 `error`,不把该指针视为有效方案 | | 3 current_handoff 缺失 | 正常 → 仅按 plan.md 进度接续 | | 4 receipts/ 缺失或空 | 正常 → 不假设任何动作已验证 | @@ -515,7 +519,7 @@ session 标识只用于 provenance,不单独构成并发冲突。只有用户 |---|---|---| | consult / unmanaged quick_fix,任意方案状态 | 完成当前请求,不进入接续链,不展示无关状态异常 | 无 | | new_plan,无 active plan | 创建方案并通过 writer 设置新指针 | 无 | -| new_plan,active plan 无效或缺 `plan.md` | 创建方案并由 writer 替换无效指针 | 不追加清理确认 | +| new_plan,active plan 结构无效或缺 `plan.md` | 创建方案并由 writer 替换无效指针 | 不追加清理确认 | | new_plan,active plan 有效 | 确认切换、合并或暂停 | 一次决策 | | continue_plan,active plan 有效 | 按 4 步协议和方案进度继续;缺 handoff / receipts 属正常降级 | 无 | | continue_plan,active plan 有效但用户明确指定其他方案 | 确认切换、合并或暂停 | 一次决策 | @@ -524,7 +528,7 @@ session 标识只用于 provenance,不单独构成并发冲突。只有用户 | 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 | Verifier 语义审查对目标只读;宿主可执行用户选定 Verifier 的受限取证/报告命令,校验 `plan_version` 后通过 writer 写 `verify_NNN` receipt | 无;active plan / handoff 不变 | | 同一 active plan 出现 §8.6 并行推进信号 | 当前 session 不执行有副作用的开发;可继续只读审计,或等待用户确认后重读最新状态 | 一次决策 | ### 8.9 State 文件索引(P8 post-cutover: 2 文件) diff --git a/.sopify/blueprint/tasks.md b/.sopify/blueprint/tasks.md index 1700ac9b..c75f5e88 100644 --- a/.sopify/blueprint/tasks.md +++ b/.sopify/blueprint/tasks.md @@ -112,15 +112,12 @@ P0→P4c 主航道已全部完成。后续执行遵循以下原则: - 服务主线:P5(决定哪些 handoff 生产面是 deep-only vs 可降级为 convention 自驱) - 最小交付证据:Gap 分析报告,覆盖 RuntimeHandoff / RunState / DecisionState 的生产 vs 消费拆分判定 -## 独立产品线 +## 外部验证器接入 -### CrossReview +Sopify 只保留通用 Verifier 证据消费。EvidentLoop 是首个官方推荐的可选实例,不是默认能力或唯一实现。 -独立审查内核,宿主中立。已作为独立产品发布(`evidentloop/CrossReview`,v0.1.0a3)。Sopify 侧方案已瘦身为纯集成任务(Phase 4a advisory / Phase 4b bridge),产品设计已迁移至 CrossReview 自身蓝图。 - -- Registry 状态:active + governance deferred -- 详见:`plan/20260418_cross_review_engine/`(仅 Sopify 集成任务) -- 当前阶段:Phase 4a advisory dogfood 中(首次 E2E 2026-05-31 已完成) +- [ ] 完成 `plan/20260718_evidentloop_optional_audit_integration/` 剩余 Wave:EvidentLoop 通用版本输出、公开 Alpha、可选配套安装、真实 dogfood 与归档 +- [ ] `--with-evidentloop` 落地前不得在公开文档中宣称该参数已可用 ## 流程与工具项 @@ -128,7 +125,7 @@ P0→P4c 主航道已全部完成。后续执行遵循以下原则: - Phase 1 已完成(方案包 `20260522_runtime_slimming_kernel_extraction`):contract 面清理 −6,400 LOC + engine 重构 + 34 退役测试块清理 −1,400 LOC + installer bundle 纯 Python 重写 + legacy workspace marker 退场 - Phase 2 已完成(P8 W2):installer 5 文件解耦 + runtime/ 全删(46 文件 / ~15.6K LOC)+ legacy deep path 退场 + registry 退场 + state 2-file cutover - [ ] Plan intake checklist(后续新 plan 开包时手工回答以下问题): - 1. 主命中哪个蓝图活跃分层?主线里程碑(P4d / P5 / P6)、证据型候选(Adoption Proof / Compliance Phase 2 / Convention 证明)、独立产品线(CrossReview)?若不命中以上任一,须显式标记为"流程工具项"或"延后项",不强行归类 + 1. 主命中哪个蓝图活跃分层?主线里程碑、证据型候选、外部 Verifier 接入、流程工具项或延后项?不得为迁就旧分类强行归类 2. 这次改动定义的是 contract acceptance boundary,还是 execution strategy / implementation wave?(前者进 blueprint,后者留方案包) 3. 是否新增、删除、替代 action / route / state / checkpoint / receipt 中的任一 machine truth?若是,对照 `design.md` 削减预算表 4. 若涉及 legacy surface,替代 contract 是否已在 `design.md` sunset 表中对应里程碑稳定? @@ -143,7 +140,7 @@ P0→P4c 主航道已全部完成。后续执行遵循以下原则: - [ ] 轻量化产品指标与 acceptance gate(首次上手步骤数、必需文件数、默认 workflow 必需 contract 数) - [ ] 产品层 ↔ 实现层 contract matrix 正式化(ownership / admission / lifecycle responsibilities) - [x] GitHub Release pipeline 建立(首次 release 创建 + tag 规范 + install 脚本端到端验证) -- [x] 测试套件健康基线(pass rate ≥ 99%;当前基线 226 tests / 226 passed = 100%,另含 34 passed subtests) +- [x] 测试套件健康基线(pass rate ≥ 99%;当前基线 244 tests / 244 passed = 100%,另含 51 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 所有 diff --git a/.sopify/plan/20260418_cross_review_engine/background.md b/.sopify/plan/20260418_cross_review_engine/background.md deleted file mode 100644 index 982b0b51..00000000 --- a/.sopify/plan/20260418_cross_review_engine/background.md +++ /dev/null @@ -1,39 +0,0 @@ -# 变更提案: CrossReview Sopify 集成 - -## 需求背景 - -Sopify 的稳定性结构覆盖了预防层(runtime gate、checkpoint)和恢复层(handoff、plan/history),但在"发现错误"层缺少正式的一等能力。用户实践表明,生产会话与审查会话分离能显著提高问题发现率,核心机制是 **context isolation**。 - -CrossReview 已作为独立产品完成 v0 CLI(`evidentloop/CrossReview`,v0.1.0a3),提供隔离审查管道:ReviewPack → Reviewer → FindingNormalizer → Adjudicator → ReviewResult。 - -本方案聚焦 **Sopify 如何集成 CrossReview**。CrossReview 产品设计已迁移至其自身蓝图,不再在本方案中维护。 - -## Sopify 集成目标 - -1. develop 阶段完成后,通过 SKILL.md(`.agents/skills/cross-review/`)触发 advisory review -2. 审查结果作为 advisory signal 展示,不阻断主流程 -3. 未来可升级为 checkpoint proposal(Phase 4b bridge) - -## 当前状态 - -- CrossReview v0.1.0a3 已发布(PyPI + CLI) -- SKILL.md 已就位(`.agents/skills/cross-review/SKILL.md`) -- 首次 E2E dogfood 已完成(2026-05-31) - - host-integrated 路径跑通:pack → render-prompt → isolated review → ingest - - 发现 1 个真实 "caught by isolation" case(符号纪律命名不一致) - - 发现 normalizer 解析 bug(已修复) - -## 产品关系 - -| 维度 | 说明 | -|------|------| -| CrossReview | 独立产品,Sopify 是首个深度集成宿主 | -| Sopify 角色 | 消费方 + dogfood 用户 | -| 产品决策 | 归 CrossReview 仓库蓝图 | -| 本方案范围 | 仅 Sopify 侧集成逻辑 | - -## 非目标 - -- 不在本方案中维护 CrossReview 产品设计 -- 不在本方案中定义 ReviewPack/ReviewResult schema -- 不在本方案中管理 CrossReview 路线图 diff --git a/.sopify/plan/20260418_cross_review_engine/cross-project-insights.md b/.sopify/plan/20260418_cross_review_engine/cross-project-insights.md deleted file mode 100644 index d026d48c..00000000 --- a/.sopify/plan/20260418_cross_review_engine/cross-project-insights.md +++ /dev/null @@ -1,324 +0,0 @@ -# CrossReview 方案综合分析:跨项目思想借鉴 - -> **Status**: Archived reference — 外部项目洞察资产 -> **当前事实源**: CrossReview 产品/发布事实以 `cross-review/.sopify/plan/20260425_crossreview_product_master_plan/` 和 `cross-review/docs/v0-scope.md` 为准。 -> **使用方式**: 本文只作为历史分析与 v1+ 设计素材,不参与当前 v0 / PyPI / Sopify Phase 4a 执行判断。 -> -> 基于 Sopify 代码 + superpowers + spec-kit + hermes-agent + helloagents 的深度分析 -> 生成时间:2026-04-20 - ---- - -## 一、总体判断 - -CrossReview 的方案已经覆盖了核心架构(artifact → review pack → reviewer → finding → verdict → policy),但五个项目中有 **12 个高价值思想** 尚未被方案充分吸收。这些思想不是"功能点补充",而是 **设计范式层面的增强**。 - ---- - -## 二、最高价值洞察(应优先考虑纳入设计) - -### 🔴 1. 两层聚合模式(Hermes MoA → CrossReview Reviewer-Aggregator) - -**来源**:hermes-agent `mixture_of_agents_tool.py` - -**现状差距**:CrossReview 当前 adjudicator 是确定性规则(severity 计数 + reviewer 共识),本质上是 **扁平合并**。 - -**Hermes 做法**: -- **Layer 1**:N 个 reference model 并行生成多样化结果(temp=0.6) -- **Layer 2**:Aggregator 以 Layer 1 输出为输入,合成最终结论(temp=0.4) -- 关键:Layer 2 不是简单投票,而是 **看到所有 reviewer 的推理过程后综合判断** - -**对 CrossReview 的启发**: -``` -当前设计: reviewer₁ ─┐ - reviewer₂ ─┤─→ 确定性 adjudicator → verdict - reviewer₃ ─┘ (数 severity、看共识) - -建议增强: reviewer₁ ─┐ - reviewer₂ ─┤─→ synthesizer(Phase 2 LLM adjudicator)→ verdict - reviewer₃ ─┘ (看推理过程、解决矛盾、合成结论) -``` - -**建议**:Phase 1 保持确定性 adjudicator 不变;Phase 2 增加 `synthesizer` 模式作为 adjudicator 的可选升级路径,尤其在 reviewer 冲突严重时自动切换。 - ---- - -### 🔴 2. Spec-as-Acceptance-Criteria 管道(Spec-Kit → CrossReview Review Pack) - -**来源**:spec-kit 的 spec-template.md + analyze.md - -**现状差距**:CrossReview 的 `review_pack.task.acceptance_criteria` 目前是自由文本列表,没有结构化来源。 - -**Spec-Kit 做法**: -- 每个 user story 自带 `Independent Test: [如何独立验证]` -- acceptance scenario 用 Given/When/Then 格式 -- requirements 有唯一 ID(FR-001, SC-001) -- analyze 命令做 **跨文档一致性检查**(spec ↔ plan ↔ tasks) - -**对 CrossReview 的启发**: -- review pack 的 acceptance_criteria 应支持从 spec 自动提取(不只是手写) -- finding 应能 **反向追溯到具体 requirement ID** -- 这让 verdict 从"我觉得有问题"变成"FR-003 没有被覆盖" - -**建议**:在 review_pack schema 中预留 `requirement_refs: list[str]` 字段;Phase 2 与 spec-kit 对接时,自动从 spec.md 提取 acceptance criteria + requirement IDs。 - ---- - -### 🔴 3. 合约驱动的验证路由(HelloAgents contract.json → CrossReview Policy) - -**来源**:helloagents `plan-contract.mjs` + `delivery-gate.mjs` - -**现状差距**:CrossReview 的 policy 是全局配置(`advisory_when / required_when`),没有 **plan 级别的细粒度验证合约**。 - -**HelloAgents 做法**: -```json -{ - "verifyMode": "review-first|metadata-first|verify-only", - "reviewerFocus": ["security", "performance"], - "testerFocus": ["coverage", "edge-cases"], - "advisor": { "required": true, "focus": ["accessibility"] } -} -``` -- 每个 plan 有自己的验证合约 -- 合约决定:哪些验证必须做、以什么顺序、聚焦什么方面 -- delivery gate 是 **确定性的**:缺哪个证据就 block 哪项 - -**对 CrossReview 的启发**: -```yaml -# 当前设计(全局 policy) -cross_review: - policy: - develop: - required_when: [auth, schema] - -# 建议增强(plan 级合约) -# .sopify/plan/20260420_feature/review-contract.yaml -review_contract: - mode: review-first # 先 review 再继续 - required_evidence: - - type: code_review - focus: [security, auth] - - type: spec_compliance - required: true - block_without: [code_review] # 缺这个就 block -``` - -**建议**:Phase 1 不做;Phase 2 在 plan 目录中支持可选的 `review-contract.yaml`,作为全局 policy 的 plan 级覆盖。 - ---- - -### 🔴 4. 工作区指纹与审计链(HelloAgents fingerprint → CrossReview Evidence) - -**来源**:helloagents `.ralph-*.json` 的 fingerprint 机制 - -**现状差距**:CrossReview 的 `review.md` 记录了 finding snapshot,但没有 **review 发生时的工作区快照**。 - -**HelloAgents 做法**: -```json -{ - "fingerprint": { - "filesChanged": ["src/api.ts", "test/api.test.ts"], - "gitCommit": "abc123def", - "timestamp": "2024-04-20T14:30:00Z", - "verifyCommandsUsed": ["npm run lint", "npm run test"] - } -} -``` - -**对 CrossReview 的启发**: -- review.md 的每次 review run 应记录 `artifact_fingerprint`(diff hash / commit ref) -- 这让 history 中的审计可以回答:"这条 finding 是基于哪个版本的代码发现的?" -- 与 Hermes 的 shadow git checkpoint 互补(Hermes 做全量快照,fingerprint 做轻量标识) - -**建议**:在 ReviewResultV1 中增加 `artifact_fingerprint: str` 字段(MVP 可以是 git commit hash 或 diff hash)。 - ---- - -## 三、高价值机制(应纳入设计考虑范围) - -### 🟡 5. 两阶段分离审查(Superpowers → CrossReview Reviewer Pipeline) - -**来源**:superpowers `subagent-driven-development/SKILL.md` - -**现有 hermes-insights.md 已有类似思路**,但 superpowers 的表述更清晰: -1. **Stage 1: Spec Compliance** — 实现是否匹配需求?(不评判质量) -2. **Stage 2: Code Quality** — 实现质量是否达标?(只有 spec 通过才到这步) - -**对 CrossReview 的启发**: -- 当前 develop_quality 已有 `spec_compliance / code_quality` 两段 -- CrossReview 的 reviewer 可以按两阶段编排:先 spec 验证 → 通过后再做质量审查 -- **阶段 1 用确定性 checker 就足够**(对比 spec 条目),阶段 2 才需要 LLM reviewer - -**建议**:在 reviewer orchestration 中支持 `pipeline` 模式(顺序执行、前者阻断后者),与现有 `parallel` 模式并列。 - ---- - -### 🟡 6. 错误分类与自适应恢复(Hermes → CrossReview Failure Handling) - -**来源**:hermes-agent `error_classifier.py` - -**现状差距**:CrossReview 只有 `inconclusive` 作为失败兜底,没有细分失败原因和恢复策略。 - -**Hermes 做法**:12+ 种 FailoverReason,每种有具体恢复建议: -- `rate_limit` → 等待 + 重试 -- `context_overflow` → 压缩 review pack + 重试 -- `auth_permanent` → 跳过此 reviewer -- `billing` → 降级到更便宜模型 - -**建议**:定义 `ReviewerFailureReason` 枚举,至少区分:`timeout / quota_exceeded / context_overflow / model_unavailable / output_malformed`。每种 reason 携带 recovery hint。 - ---- - -### 🟡 7. 熔断器模式(HelloAgents Breaker → CrossReview Escalation) - -**来源**:helloagents `.ralph-breaker.json` - -**现状差距**:CrossReview 没有"连续失败后自动升级"机制。 - -**HelloAgents 做法**: -- 连续 3 次验证失败 → 触发 breaker -- breaker 建议:重新分析根因 / 检查架构 / 回退并重新开始 -- 不是无限重试,而是 **升级为不同类型的干预** - -**对 CrossReview 的启发**: -- 如果同一 task 被 review 3 次仍 block → 不再重试,而是生成 `escalation_finding` -- escalation 可以触发 `human_review`(Q: 这个问题是否需要重新设计?) - -**建议**:在 adjudicator 中增加 `review_attempt_count` 追踪;超过阈值时 verdict 自动升级为 `inconclusive` + `human_review` action。 - ---- - -### 🟡 8. 上下文压缩策略(Hermes → CrossReview Review Pack Budget) - -**来源**:hermes-agent `context_compressor.py` + `trajectory_compressor.py` - -**现状差距**:CrossReview 的 review pack 没有明确的 budget 约束(Sopify 的 compare 有 `max_chars_total: 12000`)。 - -**Hermes 三层压缩**: -1. Per-tool 输出截断 -2. Per-result 持久化(大输出写磁盘,只保留摘要) -3. Per-turn 聚合预算(超限时逐个溢出最大结果) - -**对 CrossReview 的启发**: -- review pack 应有明确 budget(diff 行数、evidence 条目数、总 token 数) -- 超出 budget 时自动截断或摘要化(保留头尾 + 变更密集区) -- compare runtime 的 budget 参数可直接复用 - -**建议**:在 review_pack schema 中增加 `budget` 子对象,复用 compare runtime 的 `max_files / max_lines_per_snippet / max_chars_total` 参数。 - ---- - -## 四、值得借鉴的辅助机制 - -### 🟢 9. 反合理化表(Superpowers → CrossReview Reviewer Discipline) - -**来源**:superpowers 每个 SKILL.md 的 Rationalization Prevention Table - -| 自我合理化 | 真实情况 | -|-----------|---------| -| "应该没问题了" | 运行验证命令 | -| "我很有信心" | 信心 ≠ 证据 | -| "就这一次跳过" | 没有例外 | - -**对 CrossReview 的启发**: -- reviewer prompt 中应包含类似的反合理化约束 -- 例如:"不要因为 diff 看起来简单就跳过检查" -- 这属于 reviewer prompt engineering 层面的增强 - ---- - -### 🟢 10. Metadata-First 验证模式(HelloAgents → CrossReview Pre-Review Gate) - -**来源**:helloagents `verifyMode = "metadata-first"` - -**做法**:要求每个 task 的 acceptance criteria + 验证方式在 review 之前就必须完整,否则 gate 直接 block。 - -**对 CrossReview 的启发**: -- review pack 提交时可以做 **前置完整性检查** -- 如果 acceptance_criteria 为空、artifact 为空、evidence 缺失 → 直接拒绝进入 review 流程 -- 避免浪费 reviewer 资源 - -**建议**:在 review pack normalization 阶段增加 `validate_completeness()` 前置检查。 - ---- - -### 🟢 11. 表达式驱动的 Policy 语言(Spec-Kit → CrossReview Policy Expression) - -**来源**:spec-kit `expressions.py` - -**做法**:Workflow 条件使用 Jinja2 风格表达式: -``` -advisory_if: "{{ severity < 'high' }}" -required_if: "{{ risk_tags | contains('auth') }}" -``` - -**对 CrossReview 的启发**: -- 当前 `advisory_when / required_when` 是硬编码 keyword 列表 -- 更灵活的 policy 表达:`required_when: "{{ changed_files > 5 and 'auth' in risk_tags }}"` -- 不是 Phase 1 需要,但长期可以让 policy 更具表达力 - ---- - -### 🟢 12. Session 持久化与 FTS5 检索(Hermes → CrossReview Audit Search) - -**来源**:hermes-agent `hermes_state.py` + SQLite FTS5 - -**做法**:所有 session 存 SQLite,用 FTS5 做全文检索。 - -**对 CrossReview 的启发**: -- CrossReview 的 review history 如果用 SQLite + FTS5 存储,可以查询:"所有 auth 模块的历史 review" -- 与 `review.md` 资产面互补:`.md` 是人类可读 + 归档证据,SQLite 是机器检索 + 统计 -- Sopify 当前是文件系统为主,但 CrossReview 可以在 core 层选用 SQLite 作为 finding 检索后端 - ---- - -## 五、各项目对 CrossReview 的整体定位参照 - -| 项目 | 核心贡献 | 与 CrossReview 的关系 | -|------|---------|---------------------| -| **Sopify** | 工作流宿主、checkpoint/handoff/gate 基础设施 | CrossReview 的集成宿主 + adapter 层 | -| **Superpowers** | 两阶段审查、反合理化、分块 review、升级机制 | reviewer 编排与 prompt 工程参考 | -| **Spec-Kit** | spec-as-source、acceptance criteria、gate 步骤、workflow 引擎 | review pack 结构化输入的上游来源 | -| **Hermes** | MoA 两层聚合、错误分类、上下文压缩、session 持久化 | 内核执行层参考(aggregation + budget + recovery) | -| **HelloAgents** | contract-based routing、delivery gate、fingerprint、breaker | policy + evidence + 审计链参考 | - ---- - -## 六、建议纳入方案的优先级 - -### Phase 1(MVP 增强,低成本) -1. ✅ `artifact_fingerprint` — 在 ReviewResultV1 增加一个字段 -2. ✅ `validate_completeness()` — review pack 前置检查 -3. ✅ `ReviewerFailureReason` — 区分失败原因(至少 5 种) -4. ✅ `review_pack.budget` — 复用 compare runtime 的 budget 参数 - -### Phase 2(设计增强,需要方案讨论) -5. 🔄 `pipeline` 模式 — spec compliance → code quality 顺序执行 -6. 🔄 `synthesizer` adjudicator — 两层聚合模式 -7. 🔄 `review-contract.yaml` — plan 级验证合约 -8. 🔄 `requirement_refs` — review pack 支持 spec requirement ID 追溯 - -### Phase 3(长期演进) -9. 📋 `breaker` 熔断器 — 连续失败后自动升级 -10. 📋 expression-based policy — 替代硬编码 keyword 列表 -11. 📋 FTS5 检索后端 — 历史 finding 全文检索 -12. 📋 反合理化 prompt 模板 — reviewer prompt 纪律约束 - ---- - -## 七、与已有 hermes-insights.md 的关系 - -上述 12 个思想中: -- **#1 MoA 两层聚合** 和 **#6 错误分类** 是 hermes-insights.md 的 §6 待补充项的直接答案 -- **#8 上下文压缩** 是 hermes-insights.md 的 §6 第四条的直接答案 -- 其余 9 项来自 superpowers / spec-kit / helloagents,是 hermes-insights.md 未覆盖的外部项目 -- 建议拍板后,将对应条目合并进 design.md 的相应章节 - ---- - -## 八、总结一句话 - -> CrossReview 的核心架构(artifact → finding → verdict → policy)是稳的;最值得补充的不是更多功能点,而是 **三个范式升级**: -> 1. 从扁平合并到 **两层聚合**(Hermes MoA) -> 2. 从全局 policy 到 **plan 级验证合约**(HelloAgents contract) -> 3. 从自由文本 criteria 到 **spec-sourced requirement 追溯**(Spec-Kit) diff --git a/.sopify/plan/20260418_cross_review_engine/design.md b/.sopify/plan/20260418_cross_review_engine/design.md deleted file mode 100644 index 46e6e95e..00000000 --- a/.sopify/plan/20260418_cross_review_engine/design.md +++ /dev/null @@ -1,74 +0,0 @@ -# 技术设计: CrossReview Sopify 集成 - -## 集成架构 - -```text -Sopify develop 完成 - ↓ -SKILL.md 触发条件判断(Step 0: diff source 选择) - ↓ -CrossReview CLI pipeline: - pack → render-prompt → host isolated review → ingest - ↓ -Advisory verdict 展示给用户 - ↓ -用户决定(继续 / 修改 / 忽略) -``` - -## Phase 4a: Advisory 集成(当前) - -宿主(LLM)读取 `.agents/skills/cross-review/SKILL.md` 后自主执行 CrossReview CLI 四步流程: - -1. **pack**:从 git diff 构建 ReviewPack(确定性,无 LLM 调用) -2. **render-prompt**:将 ReviewPack 渲染为 canonical reviewer prompt(确定性) -3. **host isolated review**:在 fresh/isolated context 中执行审查(宿主提供隔离环境) -4. **ingest**:将原始审查输出归一化为结构化 ReviewResult(确定性) - -关键约束: -- 不改 Sopify runtime state -- 不写 checkpoint -- verdict 仅供参考(advisory only) -- 执行失败不阻断主流程 - -## Phase 4b: Runtime Bridge(暂缓) - -```text -CrossReview CLI ingest - ↓ -bridge.py(待实现) - ↓ -review_result + checkpoint_proposal - ↓ -Sopify Core validates - ↓ -Core materializes checkpoint(若通过验证) -``` - -设计约束(来自 CrossReview 蓝图 ADR): -- CrossReview bridge 只能 propose checkpoint,不能直接写入 Sopify state -- Pipeline hook 检查由 Sopify Plugin Runtime / Core validation layer 负责 -- CrossReview 不理解 Sopify 内部 action/state/checkpoint 语义 - -## Verdict 映射 - -| CrossReview Verdict | Sopify 行为 (4a advisory) | Sopify 行为 (4b bridge) | -|---|---|---| -| `pass_candidate` | 告知用户,继续 | auto-continue | -| `concerns` | 展示 findings,用户决定 | checkpoint proposal | -| `needs_human_triage` | 展示 findings,等待用户 | blocking checkpoint | -| `inconclusive` | 警告,继续 | log + continue | - -## 与 Sopify 现有能力的关系 - -| Sopify 能力 | 与 CrossReview 的分工 | -|---|---| -| `develop_quality` | spec_compliance / code_quality 两段复审 → Phase 4b 后可映射 CrossReview verdict | -| `~compare` | 多模型候选答案对比 → CrossReview 是验证已有产物,不是生成候选 | -| `execution_gate` | 阶段转换守门 → CrossReview verdict 可作为 gate 输入信号之一 | - -## review.md 资产(Phase 4b 预留) - -未来 plan 目录可增加 `review.md` 作为一等资产: -- 懒加载:首次 review 运行后创建 -- finalize 时包含 finding snapshot(category / severity / description) -- 随 plan 一起进入 history,形成可审计证据 diff --git a/.sopify/plan/20260418_cross_review_engine/hermes-insights.md b/.sopify/plan/20260418_cross_review_engine/hermes-insights.md deleted file mode 100644 index a1cb23ad..00000000 --- a/.sopify/plan/20260418_cross_review_engine/hermes-insights.md +++ /dev/null @@ -1,427 +0,0 @@ -# 参考洞察:Hermes Agent 架构对 CrossReview 的设计启发 - -> **文档状态:持续演进** -> 本文档基于对 `hermes-agent`(NousResearch)源码的代码级分析,目标是提取对 CrossReview 设计有直接参考价值的机制。 -> 不是架构抄袭,而是:相同问题,不同形态,取长补短。 -> -> **如何使用本文档**: -> - 当 `design.md` 中某个设计决策遇到选择分叉时,先来这里查是否有 Hermes 的类比机制 -> - 当发现 design.md 有新的 "Hermes 可以补充" 的空白时,先写到本文档的"待补充"区块,再合并进 design.md - ---- - -## 一、最关键洞察:CrossReview 的核心理念已经在 Hermes 里以 Skill 形式存在 - -`hermes-agent/skills/software-development/requesting-code-review/SKILL.md` 第一行: - -> **"No agent should verify its own work. Fresh context finds what you miss."** - -这就是 CrossReview 的 `verification loop` 概念。差异在于: - -| 维度 | Hermes(Skill 形式) | CrossReview(目标形式) | -|---|---|---| -| 触发方式 | LLM 读 Markdown 后自主决定 | policy 配置 + runtime 确定性触发 | -| artifact 输入 | 自然语言描述 diff | 结构化 review_pack(code_diff / plan_package) | -| reviewer | subagent via `delegate_tool`(LLM 主导) | fresh_session / cross_model reviewer(runtime 编排) | -| 输出 | 自然语言点评 | finding / verdict / adjudication(结构化 contract) | -| 沉淀 | 无(只在当前 turn 产生) | `review.md` → plan asset → history(可审计) | -| 可配置性 | 无(Markdown 写死) | policy(advisory/required/block 三档) | - -**设计启示**:CrossReview 不是在发明新概念,而是把 Hermes 验证直觉**工程化**: -- 从"LLM 读 Markdown 决定要不要验证" → "runtime 按 policy 确定性触发" -- 从"自然语言点评" → "结构化 finding/verdict contract" -- 从"当次消失" → "进入 plan 生命周期,可审计" - ---- - -## 二、Hermes 架构与 Sopify/CrossReview 的根本差异 - -### 2.1 Agent Loop 范式 - -| | Hermes | Sopify / CrossReview | -|---|---|---| -| 工作单元 | `session + conversation turn` | `plan lifecycle + route` | -| 主循环 | `AIAgent.run_conversation()` — while 循环,最多 90 次 iteration | `run_runtime()` — 确定性路由,单 route,无内部循环 | -| 状态载体 | SQLite `state.db`(FTS5 全文检索) | 文件 `current_handoff.json`(结构化 contract) | -| 谁决定做什么 | **LLM 自己决定**(via tool calls) | **runtime router 决定**(确定性分类) | - -**代码证据**: -- `hermes/run_agent.py:8802` — `run_conversation()` while 循环 -- `sopify/runtime/engine.py:603` — `run_runtime()` if/elif route dispatch chain - -### 2.2 工具 vs 技能范式 - -| | Hermes | Sopify | -|---|---|---| -| 工具形式 | OpenAI function call(LLM 主动调用) | Python runtime skill(router 决定调用) | -| 技能形式 | Markdown prompt 注入 system prompt | Python 模块,`run_{skill_id}_runtime` 入口约定 | -| 并发 | 最多 8 个工具并发执行(ThreadPoolExecutor) | 串行,一次 route 一个 skill | - -### 2.3 检查点范式 - -| | Hermes | Sopify | -|---|---|---| -| 检查点类型 | 文件系统快照(shadow git repo,对 LLM 透明) | 语义决策点(clarification/decision/execution_confirm) | -| 触发方式 | write_file/patch 前自动触发 | 需要用户拍板时主动 checkpoint | -| 实现 | `tools/checkpoint_manager.py` — shadow git repo | `develop_checkpoint.py` + `handoff.py`:`required_host_action` | -| 恢复方式 | git rollback | 重新调用 runtime,从 current_run.json 继续 | - -**启示**:Hermes 的检查点是"工件快照"(做错了可以回退);Sopify 的检查点是"决策节点"(需要人确认才能继续)。CrossReview 的 `review.md` 和 `finding/verdict` 更接近 Sopify 的语义,不需要引入 Hermes 的快照机制。 - ---- - -## 三、Hermes Skill 学习机制详解 - -### 3.1 三层机制 - -**层 1:Skills = Markdown 程序性记忆** - -``` -~/.hermes/skills/ -└── software-development/ - ├── requesting-code-review/ - │ └── SKILL.md # 含 front matter + step-by-step 过程 - ├── subagent-driven-development/ - │ └── SKILL.md - └── ... -``` - -Front matter 结构(`SKILL.md` 示例): -```yaml ---- -name: requesting-code-review -version: 2.0.0 -tags: [code-review, security, verification, quality, pre-commit] -related_skills: [subagent-driven-development, writing-plans] ---- -``` - -**层 2:Agent 自主创建/更新 Skill**(`tools/skill_manager_tool.py`) - -```python -# LLM 完成复杂任务后,主动调用: -skill_manager_tool.create(name="pattern-x", content="...") -skill_manager_tool.edit(name="pattern-x", content="...") -skill_manager_tool.patch(name="pattern-x", old="...", new="...") -``` - -关键:不需要用户触发,agent 自己判断"这个经验值得沉淀"。`skills` 是"程序性记忆"(how to do)。 - -**层 3:安全扫描**(`tools/skills_guard.py`) - -Agent 写入的新 skill 过 `scan_skill()` 防注入检查: -```python -_CONTEXT_THREAT_PATTERNS = [ - (r'ignore\s+(previous|all|above|prior)\s+instructions', "prompt_injection"), - (r'system\s+prompt\s+override', "sys_prompt_override"), - ... -] -``` -和社区 Hub 安装的 skill 同等待遇。 - -### 3.2 Hermes 知识分层 - -``` -全局知识(用户级): - ~/.hermes/skills/ # 跨项目,程序性知识(如何做某件事) - ~/.hermes/state.db # 跨项目,历史对话 FTS5 检索 - ~/.hermes/MEMORY.md # 跨项目,陈述性记忆(关于用户/偏好) - -项目知识(目前没有): - Hermes 不区分项目内 vs 全局,所有 skills 都是全局的 -``` - -对比 Sopify: -``` -全局知识: - ~/.claude/sopify/ # 只有安装物和 helpers,没有知识 - ~/.sopify/skills/ # 暂无,但需要(见 §4.2 借鉴建议) - -项目知识: - .sopify/blueprint/ # 项目内结构化上下文(陈述性) - .sopify/history/ # 已完成方案归档 -``` - -**缺口**:Sopify 缺少"全局可复用 AI 操作程序"层。 - ---- - -## 四、对 CrossReview 设计的具体借鉴建议 - -### 4.1 借鉴 1:blueprint 自动更新(类 skill auto-capture) - -**Hermes 机制**:任务完成后,agent 自主调用 `skill_manager_tool.create()` 把经验转成可复用 skill。 - -**CrossReview 可借鉴的方向**: - -在 `review.md` 的 `final_verdict` 确认后,CrossReview 触发一步**经验抽取**: -- 把本次 plan 的高置信度、高频 finding(如"该项目 auth 模块历史上有 expiry bypass 风险")提炼到 `blueprint/patterns.md` -- 粒度是**项目级可复用风险模式**,不是全局过程 - -效果:下次同项目启动 design review 时,adjudicator 可以加载 `blueprint/patterns.md` 里的历史模式作为额外权重。 - -**当前方案包中的挂接点**: -- 触发时机:finalize 时 `final_verdict == "pass"` 后 -- 产出文件:`blueprint/patterns.md`(当前方案包中未规划,建议 Phase 2 补充) -- 任务映射:当前 `3.3 明确 finalize 集成切入点` 中可以加这一步 - -### 4.2 借鉴 2:全局 CrossReview 知识层 - -**Hermes 机制**:`~/.hermes/skills/` 全局共享,跨项目复用。 - -**CrossReview 可借鉴的方向**: - -建立 `~/.crossreview/rubrics/` 目录,存放跨项目通用的 review rubric: -``` -~/.crossreview/rubrics/ -└── code-review/ - ├── auth-patterns.md # 通用 auth 安全检查列表 - ├── api-contracts.md # API 变更风险检查 - └── migration-risks.md # DB migration 审查清单 -``` - -这些 rubric 在 reviewer 编排时自动注入,作为结构化背景知识,类似 Hermes skill 注入 system prompt 的方式。 - -**与项目级 blueprint 的分工**: -- `~/.crossreview/rubrics/`:**如何 review**(通用过程,跨项目) -- `.sopify/blueprint/patterns.md`:**这个项目的已知风险**(项目内知识) - -### 4.3 借鉴 3:review 输出的知识沉淀管道 - -**Hermes 机制**:Skill 有 `version` 字段,`edit/patch` 机制让 skill 持续演进。 - -**CrossReview 可借鉴的方向**: - -`review.md` 的 `finding` 经过多次 plan 积累后,可以形成项目的"review 知识库": -- 已修复的历史 finding → `blueprint/patterns.md` 的"已知问题/已解决"清单 -- 重复出现的 finding → 触发策略升级(advisory → required) -- 高置信度 finding → 沉淀为 deterministic checker 规则 - -这个管道 Hermes 没有对等物(Hermes skill 是全局的,没有项目级积累机制)。CrossReview 可以在这里做出比 Hermes 更深的东西。 - -### 4.4 借鉴 4:skill 安全扫描模式 - -**Hermes 机制**:`skills_guard.py` 对 LLM 写入的 skill 扫描 prompt injection。 - -**CrossReview 的对应场景**: - -当 CrossReview 把 finding 自动写入 `review.md` 和 `blueprint/patterns.md` 时,相同风险存在(恶意代码中的注释可能被 reviewer 误识别为指令并写入文档)。 - -建议在 `review_pack` 的 artifact 处理层加类似的扫描: -- 扫描 `code_diff` 中注释里的 meta-instruction 模式 -- 扫描 `plan_package` 中可能的注入尝试 -- 对写入 `review.md` 的 finding.title/summary 字段做 sanitize - ---- - -## 五、两种知识沉淀策略的对比(核心洞察) - -| | Hermes | Sopify(当前) | CrossReview(目标) | -|---|---|---|---| -| 知识类型 | 程序性(**如何做**某件事) | 陈述性(**这个项目是**什么状态)| **两者都需要** | -| 写入时机 | agent 自主判断 | 生命周期事件触发 | 生命周期事件触发(保持 Sopify 风格) | -| 知识粒度 | 全局可复用过程 | 项目内结构化上下文 | 项目级风险模式 + 全局 review rubric | -| 检索方式 | system prompt 注入 + FTS5 | 文件系统读取 | 文件系统读取(保持 Sopify 风格) | - -**CrossReview 是第一个需要两种知识沉淀都做的 Sopify 能力**: -- 项目级:这个项目历史上的高频风险 → `blueprint/patterns.md` -- 全局级:通用 review rubric → `~/.crossreview/rubrics/` - ---- - -## 六、待补充区块 - -> 以下是后续深入分析时应继续完善的方向: - -- [x] SkillGuidedReviewer 设计(已完成,见 §8) -- [ ] Hermes `subagent-driven-development` skill 的"两阶段 review"与 CrossReview design/develop 分阶段集成的对应分析 -- [ ] Hermes `mixture_of_agents_tool.py` 的并发 fan-out 与 CrossReview reviewer orchestration 的设计对比 -- [ ] Hermes `context_compressor.py` 的 session 压缩机制 vs CrossReview review_pack 的 artifact 截断策略 -- [ ] Hermes FTS5 跨 session 检索 vs CrossReview `blueprint/patterns.md` 项目级历史的检索机制设计 - ---- - -## 七、本文档与 design.md 的关系 - -当本文档中某个借鉴建议被**正式拍板**后,应: - -1. 在 `tasks.md` 中新增对应任务(如 `Phase 2: 补充 blueprint/patterns.md 写入机制`) -2. 在 `design.md` 的对应章节补充设计细节(如"与 Sopify 的集成边界 > finalize 集成") -3. 本文档对应条目标记 `→ 已合并进 design.md`,并留链接 - -未被拍板的内容继续在本文档演进,不提前污染主设计文档。 - ---- - -## 八、SkillGuidedReviewer:CrossReview 复用 Hermes Skill 的接口设计 - -> **状态:已拍板方向,待写入 design.md** - -### 8.1 核心发现:Hermes skill Steps 1-4 = CrossReview review_pack 构建 - -`requesting-code-review/SKILL.md` 步骤分解: - -| Hermes 步骤 | 做的事 | CrossReview 对应 | -|---|---|---| -| Step 1 | `git diff --cached` | `review_pack.artifact.diff` | -| Step 2 | 静态安全扫描(grep 模式)| `deterministic_checker` reviewer | -| Step 3 | 跑测试,记 baseline failures | `review_pack.evidence.tests` | -| Step 4 | Quick code scan | `review_pack.evidence.snippets` | -| **Step 5** | **独立 reviewer subagent(`delegate_task`)** | **`fresh_session_same_model` reviewer** | - -Steps 1-4 = CrossReview 的 review_pack 构建阶段;Step 5 = reviewer 执行阶段。两者不重叠,可以组合。 - -`delegate_tool.py` 的关键约束和 CrossReview reviewer 要求完全吻合: -- 隔离上下文(fresh session,no parent history) -- 只看 artifact + evidence(no producer context) -- 不能递归 delegate(`MAX_DEPTH = 2`) - -### 8.2 Reviewer Transport Protocol - -```python -class ReviewerTransport(Protocol): - reviewer_id: str - reviewer_type: str - - def execute(self, review_pack: ReviewPack) -> RawReviewerOutput: ... - -class RawReviewerOutput: - reviewer_id: str - content: str # 原始输出(自然语言或 JSON 字符串) - output_format: str # "structured_json" | "free_form" | "deterministic" - metadata: dict -``` - -**三种实现**: - -| 类型 | 描述 | 输出格式 | -|---|---|---| -| `NativeStructuredReviewer` | CrossReview 内置,直接 JSON finding | `structured_json` | -| `SkillGuidedReviewer` | 外部 Markdown skill(含 Hermes skill)作为过程指导 | `free_form` | -| `DeterministicChecker` | lint/test/schema rule,无 LLM | `deterministic` | - -### 8.3 SkillGuidedReviewer 实现 - -```python -class SkillGuidedReviewer(ReviewerTransport): - reviewer_type = "skill_guided" - - def __init__(self, skill_path: str, model: str = None): - self.skill_path = Path(skill_path).expanduser() - self.model = model - - def execute(self, review_pack: ReviewPack) -> RawReviewerOutput: - skill_procedure = self._load_skill_procedure() - prompt = self._build_reviewer_prompt(review_pack, skill_procedure) - raw_output = call_llm_isolated_session( - prompt=prompt, - model=self.model, - system="You are an independent code reviewer. Review only what is in the provided context." - ) - return RawReviewerOutput( - reviewer_id=self.reviewer_id, - content=raw_output, - output_format="free_form" - ) - - def _load_skill_procedure(self) -> str: - """提取 SKILL.md 的程序性内容:跳过 front matter 和 When to Use,保留 Step N: 章节。""" - skill_md = (self.skill_path / "SKILL.md").read_text() - return extract_instructional_content(skill_md) - - def _build_reviewer_prompt(self, review_pack: ReviewPack, skill_procedure: str) -> str: - return f"""REVIEW PROCEDURE: -{skill_procedure} - -INPUT TO REVIEW: -Task: {review_pack.task.request} -Acceptance criteria: {review_pack.task.acceptance_criteria} - -Code diff: -{review_pack.artifact.diff} - -Evidence: -- Tests: {review_pack.evidence.tests} -- Snippets: {review_pack.evidence.snippets} - -Risk context: {review_pack.policy_context.risk_tags} - -Follow the review procedure above. Focus on what's in the diff and evidence.""" -``` - -**配置示例**: - -```yaml -cross_review: - reviewers: - - id: hermes_code_review - type: skill_guided - skill_path: ~/.hermes/skills/software-development/requesting-code-review/ - enabled: true - - - id: native_structured - type: fresh_session_same_model - enabled: true - - - id: lint_check - type: deterministic_checker - command: "ruff check {files}" - enabled: true -``` - -### 8.4 FindingNormalizer:统一归一化层 - -```python -class FindingNormalizer: - def normalize(self, raw: RawReviewerOutput, artifact_kind: str) -> list[Finding]: - if raw.output_format == "structured_json": - return parse_json_findings(raw.content) # NativeStructured 输出 - elif raw.output_format == "deterministic": - return parse_tool_output(raw.content) # lint/test 输出 - elif raw.output_format == "free_form": - return extract_findings_via_llm( # Hermes skill 输出 - raw_text=raw.content, - schema=FINDING_JSON_SCHEMA, - prompt="Extract all review findings into the schema." - ) -``` - -这个归一化层让 CrossReview 可以消费任意形式的 reviewer 输出,不要求 reviewer 改变格式。 - -### 8.5 整体数据流 - -``` -CrossReview 负责 Hermes skill 负责 -───────────── ───────────────── -policy 触发 - ↓ -review_pack 构建 ←── 等价于 Hermes Steps 1-4 - (diff + evidence + acceptance criteria) - ↓ -reviewer orchestration ─────────────────→ SkillGuidedReviewer - (加载 SKILL.md 程序) - (构建 reviewer prompt) - (isolated LLM session) ←── 等价于 Hermes Step 5 - ↓ - ←── RawReviewerOutput (自然语言) - ↓ -FindingNormalizer ←── Hermes 没有这层 - ↓ -adjudicator ←── Hermes 没有这层 - ↓ -verdict + findings - ↓ -review.md 写入 ←── Hermes 没有这层 - ↓ -execution_gate 检查 ←── Hermes 没有这层 -``` - -### 8.6 对 design.md 的影响 - -需要在 `design.md` 的以下章节补充: - -1. **核心抽象 > 3. Reviewer** 章节:增加 `skill_guided` 作为第四种 reviewer 类型 -2. **核心抽象 > 4. Finding** 章节:说明 `FindingNormalizer` 处理 free_form 输出的方式 -3. **配置设计**:增加 `skill_path` 字段示例 - -→ **待合并进 design.md**(下一步操作) diff --git a/.sopify/plan/20260418_cross_review_engine/product-form-analysis.md b/.sopify/plan/20260418_cross_review_engine/product-form-analysis.md deleted file mode 100644 index b2fb156f..00000000 --- a/.sopify/plan/20260418_cross_review_engine/product-form-analysis.md +++ /dev/null @@ -1,381 +0,0 @@ -# CrossReview 独立产品形态分析 - -> **文档状态:已拍板方向** -> **Status**: Archived reference — 早期产品形态分析 -> **当前事实源**: CrossReview 产品/发布事实以 `cross-review/.sopify/plan/20260425_crossreview_product_master_plan/` 和 `cross-review/docs/v0-scope.md` 为准。 -> **使用方式**: 本文只保留产品形态与边界分析,不参与当前 v0 / PyPI / Sopify Phase 4a 执行判断。 -> -> 本文档定义 CrossReview 作为独立产品的交付形态、集成协议与边界。 -> Sopify 是消费方之一,不是唯一宿主。 - ---- - -## 一、产品定位 - -CrossReview 是 **独立验证引擎**,解决"AI 不能可靠地审查自己的产出"这个工程问题。 - -核心管道: -``` -Artifact → Review Pack → Reviewer(s) → Finding(s) → Adjudicator → Verdict → Policy Action -``` - -独立价值的来源不是"更多功能",而是: -1. **清晰的输入协议**(ReviewPack) — 任何工具都能构造 -2. **清晰的输出协议**(Finding/Verdict) — 任何工具都能消费 -3. **零依赖的最小闭环** — 不需要 Sopify 也能跑 - ---- - -## 二、产品交付形态(5 个通道) - -### 📦 形态 1: CLI(MVP 必须) - -```bash -# 最小可用 — 在任何 git 仓库里 -crossreview verify -# → 读 git diff, 自动构建 review pack, 运行 reviewer, 输出 findings - -# 带 spec 对齐 -crossreview verify --spec spec.md -# → 检查实现是否覆盖 spec 中的 acceptance criteria - -# 多模型 review -crossreview verify --reviewers claude-sonnet,gpt-4o -# → 并发调用两个模型做独立审查 - -# CI 友好输出 -crossreview verify --format json > review.json -crossreview verify --format sarif > review.sarif # Phase 2 -``` - -**独立价值**:`pip install crossreview && crossreview verify` 就能用。不需要知道 Sopify 是什么。 - -### 📦 形态 2: Python SDK(MVP 必须) - -```python -from crossreview import Engine, ReviewPack, Artifact - -pack = ReviewPack( - artifact=Artifact.from_diff(diff_text), - acceptance_criteria=["用户登录后应跳转到首页"], - evidence={"tests": [{"command": "pytest", "status": "passed"}]}, -) - -engine = Engine(config_path="crossreview.yaml") -result = engine.review(pack) - -print(result.verdict) # "warn" -print(result.findings) # [Finding(...), ...] -print(result.action) # "review_required" -``` - -**独立价值**:任何 Python 工具通过 `import crossreview` 就能调用。这是被集成的核心通道。 - -### 📦 形态 3: MCP Server(Phase 1.5) - -```yaml -tools: - - crossreview_verify: - description: "对工件进行独立验证审查" - input: { artifact_kind, diff, acceptance_criteria, policy } - output: { verdict, findings, action } - - - crossreview_configure: - description: "配置 review 策略" - input: { reviewers, policy } -``` - -**独立价值**:任何支持 MCP 的 AI agent(Claude Code, Cursor, Copilot, Gemini)都能直接调用。最具扩展性的通道。 - -### 📦 形态 4: Agent Skill(Phase 1.5) - -``` -# 以 SKILL.md 形式发布,可被放入任何 agent 的 skills 目录 -skills/crossreview/ -└── SKILL.md # 程序性知识:如何使用 CrossReview 进行验证 -``` - -**独立价值**:与 Hermes / Superpowers / HelloAgents 的 skill 生态兼容。 - -### 📦 形态 5: CI/CD 插件(Phase 2) - -```yaml -# GitHub Action -- uses: crossreview/action@v1 - with: - reviewers: claude-sonnet - policy: strict - spec: spec.md -``` - ---- - -## 三、集成协议:让消费方"零成本"接入 - -### 协议 = 3 个 JSON Schema - -``` -1. ReviewPack (输入) → 消费方构造 -2. ReviewResult (输出) → CrossReview 产出 -3. CrossReviewConfig (配置) → 消费方提供 -``` - -**每个消费方只需要做两件事**: -1. 把自己的上下文打包成 ReviewPack -2. 读取 ReviewResult 并决定如何反应 - -``` -┌─────────────┐ ReviewPack ┌──────────────┐ ReviewResult ┌─────────────┐ -│ Sopify │──────────────────→ │ CrossReview │──────────────────→ │ Sopify │ -│ spec-kit │ (JSON) │ Engine │ (JSON) │ spec-kit │ -│ hermes │ │ │ │ hermes │ -│ superpowers│ │ │ │ superpowers│ -│ helloagents│ │ │ │ helloagents│ -│ CI/CD │ │ │ │ CI/CD │ -└─────────────┘ └──────────────┘ └─────────────┘ - 各自负责 CrossReview 各自负责 - "怎么打包" 只负责 "怎么反应" - "验证 + 发现" -``` - -### 各消费方的 adapter 工作量预估 - -| 消费方 | 构造 ReviewPack | 消费 ReviewResult | 工作量 | -|-------|----------------|-------------------|-------| -| **Sopify** | 从 task + diff + evidence 打包 | verdict → handoff / checkpoint / review.md | 中 | -| **spec-kit** | 从 spec.md + generated code 打包 | verdict → gate step pass/reject | 小 | -| **superpowers** | 从 subagent output + spec 打包 | verdict → dispatch 下一步 | 小 | -| **hermes** | 从 delegate_task output 打包 | verdict → 决定是否 rollback | 小 | -| **helloagents** | 从 plan + code 打包 | verdict → delivery gate | 小 | -| **graphify** | 提供 graph_context 作为 evidence | 不直接消费(作为上游增强) | 小 | -| **CI/CD** | 从 git diff 打包 | verdict → PR comment / block merge | 小 | - ---- - -## 四、独立产品的"Hello World"体验 - -开发者 3 分钟内应能感受到价值: - -```bash -# 1. 安装 -pip install crossreview - -# 2. 配置 API key -export ANTHROPIC_API_KEY=sk-... - -# 3. 在任何 git 仓库里,改完代码后: -crossreview verify - -# 输出: -# ┌ CrossReview ────────────────────────── -# │ Verdict: warn -# │ Findings: 2 -# │ -# │ [HIGH] logic_regression -# │ Token refresh bypasses expiry check -# │ src/auth/token.ts:88 -# │ -# │ [MEDIUM] insufficient_tests -# │ No test covers the new error path -# │ src/api/handler.ts:42 -# │ -# │ Action: review_required -# └──────────────────────────────────────── -``` - -不需要 Sopify。不需要 spec-kit。不需要配方案包。 - ---- - -## 五、产品边界 - -### CrossReview 负责的 -- ✅ 验证协议(ReviewPack → Finding → Verdict) -- ✅ Reviewer 编排(fresh session / cross model / deterministic) -- ✅ 裁决逻辑(adjudicator) -- ✅ Policy 引擎(advisory / required / block) -- ✅ Report 生成(JSON / human-readable / SARIF) -- ✅ CLI + SDK + MCP Server - -### CrossReview 不负责的 -- ❌ 工作流编排(Sopify 的事) -- ❌ Spec 管理(spec-kit 的事) -- ❌ Plan 生命周期(Sopify 的事) -- ❌ Agent 调度(各宿主自己的事) -- ❌ PR/Git 平台集成(CI/CD 层的事) - ---- - -## 六、MVP 仓库结构 - -``` -cross-review/ -├── crossreview/ # Python package -│ ├── engine.py # 核心引擎 -│ ├── artifact.py # Artifact 模型 -│ ├── review_pack.py # ReviewPack 构建 -│ ├── reviewer/ # Reviewer 实现 -│ │ ├── transport.py # ReviewerTransport Protocol -│ │ ├── fresh_session.py # 同模型新 session -│ │ ├── cross_model.py # 跨模型 -│ │ └── deterministic.py # lint/test checker -│ ├── adjudicator.py # 裁决逻辑 -│ ├── finding.py # Finding schema -│ ├── verdict.py # Verdict schema -│ ├── policy.py # Policy 引擎 -│ ├── config.py # 配置加载 -│ └── report/ # 输出格式 -│ ├── json.py -│ ├── human.py -│ └── sarif.py # Phase 2 -├── cli/ # CLI 入口 -│ └── main.py # crossreview verify -├── mcp/ # MCP Server(Phase 1.5) -│ └── server.py -├── profiles/ # Review Profile(垂直能力) -│ ├── code_review.yaml -│ ├── design_review.yaml -│ └── final_audit.yaml -├── schemas/ # JSON Schema 定义(集成协议) -│ ├── review_pack.schema.json -│ ├── review_result.schema.json -│ └── config.schema.json -├── tests/ -├── pyproject.toml -├── crossreview.yaml.example # 示例配置 -└── README.md -``` - ---- - -## 七、外部产品集成分析:Graphify - -### Graphify 是什么 - -Graphify 是一个 **代码知识图谱生成工具**: - -``` -代码/文档/PDF/截图 → tree-sitter AST + LLM 语义提取 → NetworkX 图 → Leiden 社区聚类 - ↓ -输出: graph.json (可查询) + GRAPH_REPORT.md (概览) + graph.html (可视化) -``` - -核心能力: -- **25 种语言** 的确定性 AST 提取(类、函数、导入、调用图) -- **LLM 语义提取**(概念、关系、设计意图) -- **Leiden 社区发现**(基于图拓扑聚类,不用向量数据库) -- **置信度标签**:`EXTRACTED` / `INFERRED` / `AMBIGUOUS` -- **MCP Server**:暴露 `query_graph`, `get_node`, `get_neighbors`, `shortest_path` 给 AI agent - -### 与 CrossReview 的 4 种集成模式 - -#### 🔗 模式 1: Evidence Provider(review pack 增强) - -``` -开发者改了 src/auth/token.ts - ↓ -CrossReview 构建 review pack 时,查询 graphify: - graphify query "token auth" → 子图 - ↓ -review_pack.evidence.graph_context = { - "affected_nodes": ["TokenManager", "AuthFlow", "SessionStore"], - "god_nodes_touched": ["AuthFlow"], # 高连接度节点 = 高风险 - "communities_spanned": [2, 5], # 跨社区 = 跨切面修改 - "ambiguous_relationships": ["TokenManager --?--> CacheLayer"] -} -``` - -**价值**:reviewer 不需要自己理解项目结构。graph 告诉它"这个改动涉及核心节点,跨了两个模块边界"。 - -#### 🔗 模式 2: Risk Signal(policy 触发增强) - -```yaml -# crossreview.yaml 中的 graphify-aware policy -cross_review: - policy: - develop: - required_when: - - graphify_god_node_touched # 改了核心节点 → 必须 review - - graphify_cross_community # 跨社区修改 → 必须 review - advisory_when: - - graphify_ambiguous_edge # 涉及模糊关系 → 建议 review -``` - -**价值**:policy 不再只依赖静态 keyword(`auth`, `schema`),而是基于 **项目结构的动态分析** 做决策。 - -#### 🔗 模式 3: Reviewer Knowledge Base(reviewer 上下文注入) - -```python -# CrossReview reviewer 执行前,注入 graphify 上下文 -prompt = f""" -PROJECT STRUCTURE CONTEXT (from graphify): -{graph_report_summary} - -God nodes (most connected entities): {god_nodes} -This change touches community {community_id}: {community_description} - -REVIEW THE FOLLOWING DIFF: -{diff} -""" -``` - -**价值**:reviewer 在隔离上下文中仍然"懂项目"——不是靠 session 历史,而是靠结构化图谱。 - -#### 🔗 模式 4: Impact Analysis(变更影响分析) - -```bash -# 变更影响分析作为 deterministic_checker 类型的 reviewer -crossreview verify --with-graphify -# → graphify 查询:从 changed files 出发,BFS 3 层 -# → 输出:影响范围 + 是否触及核心路径 + 是否跨社区 -# → 作为 deterministic finding 进入 adjudicator -``` - -**价值**:不需要 LLM 就能给出"这个改动的影响面有多大"的结构化评估。 - -### 集成接口设计 - -Graphify 已有 MCP Server(`serve.py`),CrossReview 通过 MCP 协议调用: - -``` -CrossReview Graphify MCP Server - │ │ - ├── query_graph("auth flow") ─────────→│ → 子图文本 - ├── get_node("TokenManager") ─────────→│ → 节点详情 - ├── get_neighbors("AuthFlow") ────────→│ → 邻居 + 边 - └── shortest_path("A", "B") ──────────→│ → 路径 -``` - -CrossReview 不需要直接依赖 graphify 的 Python API,通过 MCP 实现松耦合集成。 - -### 实现优先级 - -| 优先级 | 集成项 | Phase | 工作量 | -|-------|--------|-------|-------| -| 高 | `graphify_context` 作为 review_pack.evidence 可选字段 | 1.5 | 小 | -| 高 | `graphify_god_node_touched` 作为 policy 触发条件 | 2 | 中 | -| 中 | reviewer prompt 注入 GRAPH_REPORT.md 摘要 | 2 | 小 | -| 低 | graphify BFS 作为 deterministic_checker reviewer | 3 | 中 | - -### 关键约束 - -1. **graphify 是可选依赖**:没有 graphify 的项目,CrossReview 正常工作 -2. **通过 MCP 而非 import 集成**:保持两个产品的独立性 -3. **graph.json 存在时自动增强**:检测到 `graphify-out/graph.json` → 自动注入 graph context - -### 一句话总结 - -> Graphify 给 CrossReview 提供的是 **项目结构感知能力**——让 reviewer 不只是看 diff 本身,而是知道"这个 diff 在项目结构中意味着什么"。集成方式是通过 MCP Server 松耦合查询,不增加硬依赖。 - ---- - -## 八、本文档与方案包的关系 - -本文档定义了 CrossReview 的独立产品形态。后续 `design.md` 中的"独立使用形态"和"仓库形态"章节应以本文档为准更新。 - -与其他文档的关系: -- `background.md`:问题定义(为什么需要 CrossReview) -- `design.md`:核心架构(ReviewPack / Finding / Verdict / Policy 的技术细节) -- **本文档**:产品形态(如何交付、如何被集成) -- `cross-project-insights.md`:外部项目参考思想 -- `hermes-insights.md`:Hermes 架构对比洞察 diff --git a/.sopify/plan/20260418_cross_review_engine/tasks.md b/.sopify/plan/20260418_cross_review_engine/tasks.md deleted file mode 100644 index a417b061..00000000 --- a/.sopify/plan/20260418_cross_review_engine/tasks.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -plan_id: 20260418_cross_review_engine -feature_key: cross_review_engine -level: standard -lifecycle_state: deferred -knowledge_sync: - project: skip - background: skip - design: skip - tasks: skip -archive_ready: false ---- - -# 任务清单: CrossReview Sopify 集成 - -> 本方案聚焦 Sopify 侧集成任务。CrossReview 产品任务见 `evidentloop/CrossReview` 仓库蓝图。 - -## 已完成 - -- [x] 0.1 产品命名与定位决策(Q1-Q9)→ 已固化到 CrossReview 蓝图 -- [x] 0.2 产品边界与仓库形态 → CrossReview 已独立(`evidentloop/CrossReview`) -- [x] 0.3 SKILL.md 创建(`.agents/skills/cross-review/SKILL.md`) -- [x] 0.4 首次 E2E dogfood(2026-05-31,host-integrated 路径) - -## Phase 4a — Advisory 集成(当前) - -- [x] SKILL.md host-integrated 流程就位 -- [ ] 日常 dogfood 2 周+,收集可用性反馈 -- [ ] 根据 dogfood 反馈优化 SKILL.md 流程 -- [ ] 确认 f-002(符号纪律命名不一致)修复方案 - -## Phase 4b — Runtime Bridge(暂缓) - -- [ ] bridge.py: CrossReview verdict → Sopify checkpoint proposal -- [ ] develop_quality 映射: CrossReview finding → spec_compliance / code_quality -- [ ] review.md 作为 plan asset: finalize 时写入 finding snapshot -- [ ] handoff 集成: `handoff.artifacts.cross_review_verdict` 字段 - -## 启动条件 - -Phase 4b 启动需满足: -1. Phase 4a dogfood 稳定 2 周+ -2. CrossReview normalizer / adjudicator 无 silent drop bug -3. 用户确认 advisory → checkpoint 升级有价值 - -## 历史参考文档 - -以下文档完成了 CrossReview 产品孵化使命,核心洞察已吸收至 CrossReview 自身蓝图: - -- `cross-project-insights.md` — 5 个外部项目(Hermes、HelloAgents、Spec-Kit、Superpowers、Graphify)的架构洞察,12 项高价值思想已分类归档 -- `hermes-insights.md` — Hermes Agent 知识沉淀机制分析,SkillGuidedReviewer / FindingNormalizer / 全局 rubric 等概念已写入 CrossReview 蓝图 v1+ 设计预留 -- `product-form-analysis.md` — CrossReview 独立产品形态分析(CLI / SDK / MCP / Skill / CI 五通道),产品边界已固化到 CrossReview 蓝图 - -这些文件保留为历史参考,不参与当前执行判断。 diff --git a/.sopify/plan/20260718_evidentloop_optional_audit_integration/design.md b/.sopify/plan/20260718_evidentloop_optional_audit_integration/design.md new file mode 100644 index 00000000..3bbdb13b --- /dev/null +++ b/.sopify/plan/20260718_evidentloop_optional_audit_integration/design.md @@ -0,0 +1,181 @@ +# 技术设计: EvidentLoop 可选方案审计接入与 CrossReview 收口 + +## 设计目标 + +本设计只解决四个已确认问题:Sopify 方案入口与生成器不一致、CrossReview 当前执行面仍未退场、EvidentLoop 正式产物缺少可供通用消费者直接使用的版本值、用户选择配套安装时仍需手工执行两套安装。它不建设跨产品审计平台、通用依赖管理器,也不把 Sopify 工作流写进 EvidentLoop。 + +## 产品边界 + +| 层 | 负责 | 不负责 | +|---|---|---| +| EvidentLoop | 审计本地 Git diff,生成和修订正式报告,提供 `diff_version / report_version` | Sopify plan、blueprint、receipt、目录和默认触发 | +| Sopify | 生成和接续方案,计算 `plan_version`,决定审计是否成为方案证据 | 审查模型、diff 解析、finding 锚定和报告渲染 | +| Receipt | 关联方案版本、代码版本、报告版本及产物路径 | 自动授权、默认阻断或替代用户裁定 | + +EvidentLoop 是独立产品,Sopify 是其通用能力的一个消费者。双方只通过正式报告和简明版本值连接,不增加专用 adapter 或共享运行状态。 + +Sopify 的默认能力是接收产品无关的 `verdict / evidence / source`,不是内置某个验证器。EvidentLoop 是官方推荐、完成真实 dogfood 的首个验证器,但不默认安装、不默认执行,也不排斥其他实现。 + +## 合法使用形态 + +| 形态 | Sopify 行为 | 安装所有权 | +|---|---|---| +| 仅 Sopify | plan、develop、handoff、receipt 正常工作;不运行外部审计 | 无 | +| 其他验证器 | 用户显式指定后按其 Skill/契约运行;正式证据仍可写通用 receipt | 该验证器自身 | +| 独立安装 EvidentLoop | 用户显式请求时直接做兼容探针并使用 | EvidentLoop 自身 | +| `--with-evidentloop` | 同一次 Sopify 安装中补齐缺失的官方 CLI 与 Skill;运行时与独立安装完全相同 | 安装便利由 Sopify 编排,产物仍归 EvidentLoop | + +`--with-evidentloop` 不是 capability flag。Sopify 不记录“由谁安装”,也不以安装参数决定运行资格;运行时只认用户意图、当前可发现的 Skill/CLI 和兼容性证据。 + +## 可选配套安装 + +`--with-evidentloop` 面向 Sopify 的多宿主安装入口。直接在现有安装器增加一个布尔分支和一组 EvidentLoop 常量/辅助函数,并在现有 `HostAdapter` 增加标准 Skills CLI agent id;不抽象 installer component framework,也不另建宿主注册表。 + +当前 Sopify 宿主复用现有 `HostAdapter`,只增加下列 Skills CLI 官方映射和用户级全局发现路径: + +| Sopify target | Skills CLI agent | 全局发现路径 | +|---|---|---| +| `codex` | `codex` | `~/.codex/skills/` | +| `claude` | `claude-code` | `~/.claude/skills/` | +| `qoder` | `qoder` | `~/.qoder/skills/` | +| `copilot` | `github-copilot` | `~/.copilot/skills/` | + +Skill 安装固定使用 `-g`,不跟随 Sopify 核心资产的 workspace/global scope;兼容与结构探针也必须检查上表对应的 agent 全局发现路径,不从 Sopify `destination_dirname` 推导。这一点尤其适用于 Copilot:Sopify 核心资产可以是 workspace scope,EvidentLoop Skill 仍安装到 `~/.copilot/skills/`。后续 Sopify 新增宿主时,只有明确提供该映射才开放配套安装;缺映射只影响可选项,不影响 Sopify 核心安装。 + +安装分支固定为: + +1. 未传参数:走现有 Sopify 安装路径;不查找 `uv / npx / evidentloop`,不访问 EvidentLoop 网络来源,不改变核心 payload、`stdlib_only` 依赖声明或安装成功语义;只在 help/文档中披露可选参数。 +2. 已传参数:在核心写入前检查 target 是否有标准 Skill 安装映射,以及 `uv` 和 `npx`;缺映射或前置条件缺失时明确失败,不把可选依赖变成所有用户的基础要求。固定公开来源只用于随后安装缺失项,不建设额外远程解析层。 +3. CLI 与 Skill 都缺失:使用本次 Sopify 发布中固定的 EvidentLoop package 版本、Git tag 和已验证的 Skills CLI 版本安装;Skill 命令固定带 `-g -a -y`,随后运行 doctor、兼容探针和目标 agent 全局发现路径的 Skill 文件校验;不调用浮动的 `skills@latest`。 +4. 任一项已存在:先完成只读兼容检查;CLI 核对 package/schema/prompt 与必要命令,Skill 只核对目标 agent 全局发现路径的官方必备文件结构。兼容项复用,只安装缺失项。已有项不兼容时停止,既不替换已有安装,也不先安装另一半形成可预见的混合状态;Skill 是否被宿主真实发现仍由后续新会话 dogfood 证明。 +5. 安装中途失败:报告已完成和未完成项;不删除刚安装的外部产物。相同命令可根据只读探针复用成功项并补齐缺失项,不增加 rollback manager。 + +所有外部安装调用使用固定版本/tag 组成的 argv,不拼接用户输入,不读取或输出凭据。Skills CLI 子进程显式关闭其匿名遥测,但不修改用户持久环境。用户显式传入 `--with-evidentloop` 即授权本次缺失项安装,但不授权替换已有不兼容安装。 + +安装成功只证明目标宿主具备 CLI 和 Skill 结构,不代表 Skill discovery 或审计已经运行。真实 E2E 由本方案在 Codex 上单独 dogfood;其他宿主沿用各自证据状态,后续可独立补证,不阻塞本次多宿主安装契约。 + +## 升级边界 + +本方案不自动升级、降级或卸载 EvidentLoop,也不新增版本范围求解和持久 component state。 + +- 用户按 EvidentLoop 官方方式独立升级后,Sopify 在下一次显式审计时重新执行兼容探针;通过即可使用,与最初安装来源无关。 +- 再次运行 `--with-evidentloop` 时,兼容安装只复用;不兼容安装只报告当前值和本次 Sopify 已验证值,不自动改写。 +- 后续 Sopify 发布可以更新自己验证过的 package/schema/prompt/Skill tag 常量,但“由 Sopify 自动升级已有 EvidentLoop”需等真实用户阻塞后另立任务。 + +这样保留未来升级入口,但当前不把可选安装便利扩张成包管理器。 + +## 方案包契约 + +| 级别 | 必备语义文件 | 适用场景 | +|---|---|---| +| light | `plan.md` | 单一目标、少量步骤 | +| standard | `plan.md + tasks.md` | 多任务、需逐项验收 | +| architecture | `plan.md + tasks.md + design.md` | 协议、状态模型、跨产品边界或架构取舍变化 | + +`plan.md` 始终是唯一语义入口。宿主从 Plan Snapshot 取得目标、状态和下一步,需要执行时再读取 tasks,需要架构判断时再读取 design。缺少 `plan.md` 的 managed plan 无效;handoff 只提供恢复提示,不能覆盖方案文件。 + +`level` 必须写在 `plan.md` frontmatter,且文件集合精确对应上表:light 出现 `tasks.md` 或 `design.md`、standard 缺少 `tasks.md` 或多出 `design.md`、architecture 缺少 `tasks.md` 或 `design.md` 都是无效结构;方案根目录遗留的 `background.md` 同样视为结构不匹配。`audits/`、`receipts/`、`assets/` 和其他非语义附件不影响级别判断;history 中的旧方案不按新结构迁移或判错。 + +本方案调整方案版本、writer、入口预检和跨产品边界,因此使用 architecture 级别;不因级别名称自动创建 ADR、图、assets 或空 receipts。 + +## 方案版本 + +对外只使用 `plan_version`。它表示当前可执行方案的完整版本,不等同于单个 `plan.md` 文件版本。 + +计算规则固定为: + +1. 先按 `level` 校验结构,再按固定顺序读取 `plan.md`、该级别必备的 `tasks.md`、`design.md`。 +2. 每个文件按固定顺序写入“相对路径长度、相对路径、原始字节长度、原始字节”,避免路径与内容边界歧义。 +3. 对完整输入计算 SHA-256,值使用 `sha256:` 格式。 +4. `audits/`、`receipts/`、`assets/`、state 和其他文件不参与计算。 +5. `level` 缺失、未知或语义文件集合不匹配时先判为结构无效,不计算部分版本。 + +底层算法写在规范和代码中,用户面只称“方案版本”。既有 history 和 receipt 中的旧名称、旧值不迁移。 + +实现只增加一个确定性计算函数,并由现有消费面复用: + +- `workspace_status_lite` 增加一个 `active_plan_package` 客观事实,内部只含 `level / valid / error / version`。无 active plan 时为 `null`;结构无效时返回错误事实而不把整个 lite tool 判错。既有非法 active-plan state 仍沿用原错误行为。 +- `sopify_writer.write_plan_receipt` 及现有同名 MCP wrapper 增加可选 `expected_plan_version`:写入前始终按共享函数重算,结构无效则拒绝;调用方提供的预期值不一致也拒绝。writer 自己把当前值固定写入 `provenance.plan_version`,调用方不得用 provenance 另造第二个位置。 +- 现有 `ProtocolStore.finalize_plan` 收口既有非原子顺序,不新增第二个 finalize 入口:先按公共规则校验 `plan_id` 与 `YYYY-MM` 月份,再校验最终 `expected_plan_version`;writer 在源方案目录写 `receipts/final.json` 和待归档 `receipt.md`,确认语义文件未再变化后把整个目录迁到未占用的 `history///`,仅在迁移成功后清理 active plan 与 handoff。写 receipt 或迁移失败时保留源目录和 state,拒绝覆盖既有历史目标。finalize checker 拒绝残留 state,并只对带 `plan_version` 的新归档复算版本;旧历史保持原样。history index 是归档后可重建的派生索引,由宿主随后更新;索引失败必须告警和单独重试,但不回滚已完成归档或恢复 state。 +- protocol check 对缺失/未知 level、文件集合不匹配和版本不稳定给出失败项。 +- entry-preflight 的并行推进信号比较 `plan_version` 或匹配 handoff,不再只观察单个文件。 + +host 仍按现有意图和 fail-open 表处理:consult/quick fix 不受无关 active plan 影响,managed continuation/finalize 遇到无效活动方案时停车。三个消费面复用同一计算函数和错误码,不各自解析 frontmatter 或实现哈希。 + +不增加版本 manifest、缓存、锁、state 文件或新 MCP tool。每次需要时对最多三个短文本文件重新计算,成本可忽略。 + +## 审计方式 + +本节是 Sopify 消费 EvidentLoop 时的目录约定,不是所有验证器必须实现的协议。其他验证器只需遵守已有通用 Verifier evidence 边界,并按自身产物格式提供证据。 + +### 独立审计 + +用户直接使用 EvidentLoop 审计 Git diff,沿用 EvidentLoop 默认输出目录。它不绑定 Sopify 方案、不写 Sopify receipt,也不影响 active plan。 + +### 方案主审计 + +用户显式要求把审计作为方案整体证据时,最终报告固定附着到: + +```text +audits/plan/audit.json +audits/plan/audit.html +``` + +主审计必须写 plan receipt。报告本身不包含 Sopify plan 字段;receipt 负责关联 `plan_version / diff_version / report_version / audit_path`。 + +### 附加审计 + +用户可以按目的生成 `audits//`,例如 `audits/security/`。目录名只表达用途,不创造 EvidentLoop 尚未提供的 `--focus` 能力。附加报告只有被明确采纳为正式方案证据时才写 receipt;否则不会出现在 4 步入口的默认读取链。 + +不使用 `overall`、自动编号、manifest、latest 指针或报告索引。Git 历史负责保存同一逻辑槽位的旧版本。 + +## EvidentLoop 通用版本能力 + +EvidentLoop 对外只新增两个语义名称: + +- `diff_version`:本次实际 Git diff 的确定版本。 +- `report_version`:正式 `audit.json` 原始字节的确定版本。 + +底层继续复用现有 SHA-256 计算,但字段名不暴露算法。新报告在 `finalize` 时把 `diff_version` 写入 `audit.json` 的 `extensions.evidentloop.diff_version`,并返回非空的 `diff_version / report_version`;`report_version` 不能写进自身,只在结构化结果中返回并允许消费者按正式 JSON 原始字节复核。 + +`revise` 不猜测已丢失的 diff 身份:带 `diff_version` 的报告原样继承该值并返回新的 `report_version`;既有 schema `0.4` 报告缺少该扩展时仍允许修订,返回 `diff_version: null` 和非空 `report_version`。Skill 对新报告要求两个版本非空,对 legacy revise 接受这一明确降级。需要覆盖 legacy `0.4`、连续两轮 revise、copy、in-place 和 recovery 路径,避免反馈修订兼容被新字段破坏。 + +新增字段保持 additive:不修改 code-diff schema 核心字段、不升级 schema 版本,也不迁移反馈 schema 已发布的 `source_audit_sha256`。EvidentLoop 不新增 `plan_id`、`plan_version`、Sopify 输出目录、receipt、plan/design profile、宿主 adapter、模型 SDK 或 provider 配置。未来出现第二种真实 artifact profile 时,再按 EvidentLoop 自身门禁扩展,不在本次预埋通用 target 平台。 + +## Sopify 消费契约 + +Sopify 只在用户显式请求 EvidentLoop,或用户在多个可用验证器中选择 EvidentLoop 时调用其官方 Skill。是否由 `--with-evidentloop` 安装不参与判断。正式主审计流程为: + +1. 核对公开 CLI 和 Skill discovery,取得 package/schema/prompt 版本。 +2. 先完成所有可能改变产品的测试、修复、残留清理、blueprint/protocol 同步和方案语义更新,再冻结“实现与契约 staged diff”及当前 `plan_version`。 +3. 审计主体包含产品实现、测试、Skill/模板、当前 blueprint/protocol 和方案语义;排除 `audits/**`、`receipts/**`、忽略的报告目录及 finalize 的纯状态/归档差异,避免报告审计自身。 +4. 在 Git 忽略的 `reports/` 中完成 `prepare → host review → finalize`,然后显式停车,把初次报告交给用户裁定;宿主不得自行认定“人工裁定完成”。 +5. 用户反馈要求改变审计主体时,废弃旧结论并回到步骤 2;用户提交会改变报告裁定的有效反馈块时,宿主只按反馈确定性执行 `revise`;用户明确接受初始报告且没有有效反馈变化时,不调用 `revise`,直接使用 `finalize` 生成的正式报告对。 +6. 将最终 `audit.json + audit.html` 附着到 `audits/plan/`,验证可搬移性;writer 以冻结时的版本作为 `expected_plan_version` 写未占用的 `verify_NNN` receipt。 +7. 冻结后只允许报告附着、receipt 和 finalize 的机械状态/归档动作;任何非机械的实现或契约变化都使旧审计失效。finalize 前先完成任务/生命周期状态更新并冻结最终 `plan_version`;归档后复算语义文件,必须与 `final.json` 的 `provenance.plan_version` 一致。 + +`plan_version` 在所有 plan receipt 中只放 `provenance.plan_version`。本次 EvidentLoop 主审计的 `evidence` 使用现有对象,不建立通用 Review Wire,仅固定九个扁平键:`source`、`package_version`、`schema_version`、`prompt_version`、`run_id`、`review_status`、`diff_version`、`report_version`、`audit_path`;最终结论继续使用 receipt 顶层 `verdict`,不在 evidence 重复。`pass_candidate` 仍是 advisory 证据,不代表用户授权或自动放行。 + +其他验证器不需要生成上述 EvidentLoop 字段或 `audits/plan/` 报告对;若用户要求把其结论作为正式方案证据,宿主只按现有通用 `verdict / evidence / source` 与 `provenance.plan_version` 写 receipt。用户泛指“验证”且宿主明确知道有多个可用实现时,只询问一次使用哪个,不建立默认优先级或验证器注册表。 + +## 跨仓与发布边界 + +EvidentLoop 通用增强在其独立仓库建立最小方案、测试和发布证据;Sopify 方案只记录依赖和最终消费证据。跨仓执行前必须保护 EvidentLoop 现有用户改动,不在 Sopify 工作区直接修改或提交它们。 + +下一 EvidentLoop Alpha 必须在 tag、PyPI 和 GitHub Release 前再次取得用户明确授权。Sopify dogfood 只使用已发布安装物;doctor 成功不等于 Skill discovery 成功,源码 checkout 或 editable install 不构成公开版本证据。 + +## 失败与停车规则 + +- 方案结构无效、`plan_version` 不一致、目标 diff 变化或报告版本无法复核时,不写正式 verification receipt。 +- 新报告缺少任一版本时不写正式 receipt;legacy 报告的 `diff_version: null` 只保证独立修订兼容,不能作为本方案主审计证据。 +- EvidentLoop 缺少通用版本输出时,在跨仓 checkpoint 停止,不在 Sopify 内复制一套临时字段实现。 +- 审计发现需要修改 EvidentLoop 产品语义、正式 artifact profile 或 Sopify 默认工作流时,列出证据并等待用户新决策。 +- 发布未获明确授权时,只完成候选验证,不创建 tag、不上传 PyPI、不创建 GitHub Release。 +- 未选择 `--with-evidentloop` 时,任何 EvidentLoop 缺失或不兼容都不得影响 Sopify 安装、doctor 或核心工作流。 +- 选择配套安装但 target/前置依赖不受支持时,在安装写入前停止;已有不兼容 EvidentLoop 时不自动替换。 +- 历史目录出现非预期修改时立即停止,不用批量迁移或重渲染掩盖差异。 +- finalize 的 history 目标已存在、最终版本冲突、final/history receipt 写入失败或目录迁移失败时,不清理 active plan/handoff;history index 更新失败不属于上述阻断条件,不改变已经完成的归档事实。 + +## 图与 ADR 判断 + +现有 P8 入口关系不变,可选安装也只是 installer 到两个官方安装物的单向编排;上述表格与流程已经足够表达,不生成新图。当前决策由本方案 `design.md` 和后续 blueprint/protocol 承载,不新建 ADR;若实施中出现新的状态、调用方向或跨产品所有权,再重新判断。 diff --git a/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md b/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md new file mode 100644 index 00000000..9d2e3339 --- /dev/null +++ b/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md @@ -0,0 +1,134 @@ +--- +title: EvidentLoop 可选方案审计接入与 CrossReview 收口 +plan_id: 20260718_evidentloop_optional_audit_integration +status: in_progress +lifecycle_state: active +level: architecture +created: 2026-07-18 +updated: 2026-07-19 +archive_ready: false +knowledge_sync: + project: review + background: required + design: required + tasks: required +--- + +# EvidentLoop 可选方案审计接入与 CrossReview 收口 + +## Plan Snapshot + +- **Goal**: 保留 Sopify 产品无关的验证证据能力,以 EvidentLoop 替代当前 CrossReview 集成,并提供不绑定运行时的一次性可选配套安装与公开版本 dogfood。 +- **Status**: Wave 1–2 主实现及审计修复已获两路独立审批和用户接受;247 tests、58 subtests 通过。 +- **Next**: 将 Wave 1–2 作为单个本地 checkpoint commit 提交后进入 Wave 3;不推送 Sopify 分支。 +- **Task**: 7/20。 + +评分: +- 方案质量: 9/10 +- 落地就绪: 8/10 + +评分理由: +- 优点: 统一复用现有 plan 入口、writer、Verifier 和 EvidentLoop 通用产物,不增加第二套审计协议或兼容层。 +- 扣分: EvidentLoop 新公开版本、多宿主配套安装和 Codex 完整 diff dogfood 尚需执行期证据确认;自动升级明确延后。 + +## Context / Why + +P8 已删除 runtime registry 和多层状态,以 `active_plan.json → plan.md → handoff → receipts` 作为 managed plan 接续链。上一期 entry-preflight 又把 `plan.md` 是否存在落实为活动方案有效性判据,并用方案版本变化识别审计主体和并行推进。Wave 1 实施前,中英文 header、design Skill、templates 和分级脚本仍可能生成不含 `plan.md` 的 standard/full 方案,形成生产者与消费者契约断裂。 + +CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopify 当前层仍保留 CrossReview Skill、develop 完成后的默认 advisory 钩子、废弃方案包,以及把 CrossReview 当作当前参考实现的蓝图和协议文案。EvidentLoop 当前公开 code-diff 能力可以被 Sopify 消费,但正式报告尚未用简明、通用的版本字段同时表达“审查了哪份 diff”和“生成了哪版报告”。 + +本方案不建设审计平台。目标是修复方案入口契约、删除 CrossReview 当前执行面、让 EvidentLoop 只补通用版本能力,再由 Sopify 完成可选安装便利、目录、方案关联、receipt 和 dogfood 适配。Sopify 没有安装任何验证器、使用其他验证器或使用独立安装的 EvidentLoop 时,核心工作流都应成立。 + +## Scope + +- 统一 plan 级别为 `light / standard / architecture`,所有级别以 `plan.md` 为唯一语义入口。 +- 用 `plan_version` 表达完整方案版本,并使入口预检、并行推进检查和 receipt 使用同一语义。 +- 删除 Sopify 当前 CrossReview Skill、默认钩子、废弃方案和当前文档承诺;历史归档保持不变。 +- 在 EvidentLoop 独立仓库补充通用 `diff_version / report_version` 输出,并通过下一公开 Alpha 提供给所有消费者。 +- 保持 Sopify 默认只提供产品无关的 Verifier evidence 消费;不安装验证器也能完整使用 plan、develop、handoff 和 receipt。 +- 为 Sopify 可安装宿主增加显式 `--with-evidentloop` 配套安装:只在用户选择时为目标宿主安装缺失的官方固定版本 CLI 与 Skill;不把该参数作为运行能力开关。 +- 允许用户直接使用已独立安装的 EvidentLoop 或其他验证组件,不要求经 Sopify 安装,不增加验证器注册表。 +- 在 Sopify 定义独立审计、方案主审计和附加审计三种消费方式,不新增默认触发或专用命令。 +- 使用公开 EvidentLoop CLI 与官方 Skill 审计本方案冻结后的“实现与契约 diff”,完成人工裁定、报告附着和 receipt 记录。 +- 完成定向验证、全量回归、知识同步和标准归档。 + +## Approach + +- `plan.md` 只承担统一入口;`plan_version` 由方案级别对应的语义文件共同计算,派生证据不参与计算。 +- 在现有 `sopify_writer`、`workspace_status_lite` 和 protocol check 上复用一个确定性版本计算,不新增 state、registry、manifest 或 MCP tool。 +- Verifier 契约保持产品无关。EvidentLoop 只输出通用 diff/report 版本与审计产物,不认识 Sopify 的 plan、receipt 或目录约定。 +- `--with-evidentloop` 只是现有安装器的一条显式分支:不带参数时不探测、不访问 EvidentLoop 来源、不安装、不提示 EvidentLoop;带参数时才预检并安装缺失项。 +- 安装来源不决定运行资格。用户显式请求 EvidentLoop 时,以当下 CLI、Skill discovery 和兼容探针为准;已独立安装的兼容版本与配套安装版本走同一消费链。 +- Sopify 负责把 `plan_version / diff_version / report_version / audit_path` 写入正式方案 receipt;独立审计不强制进入 Sopify 证据链。 +- CrossReview 当前执行面直接删除,不提供命令别名、配置兼容、bridge、迁移器或本地 EvidentLoop Skill 副本。 +- EvidentLoop 通用增强按其独立仓库生命周期实施和发布;Sopify dogfood 只消费公开安装物,不使用源码 checkout 或 editable install。 + +## Waves / Steps + +1. 对齐方案包结构、`plan_version`、入口预检和中英文分发资产。 +2. 删除 CrossReview 当前执行面,并收口 Sopify blueprint、protocol 与 ADR 当前真相。 +3. 在 EvidentLoop 独立仓库补齐通用 `diff_version / report_version`,经确认后发布下一 Alpha。 +4. 在现有 Sopify 多宿主安装器中加入 `--with-evidentloop` 最小分支,复用目标宿主映射并验证未选择、缺失安装、兼容复用和不兼容停车。 +5. 先完成全量验证、残留审计和知识同步,再以 Codex 作为首轮真实宿主,用该公开版本与配套安装路径审计最终“实现与契约 diff”;初次报告生成后显式停车等待用户裁定。 +6. 用户裁定闭环后附着报告、写 receipt,完成只读残留复核和标准 finalize 归档。 + +## Key Decisions + +- `20260418_cross_review_engine` 直接删除,不迁入 history;原文由 Git 历史保存,替代关系进入本方案和最终 receipt。 +- `.sopify/history/**` 不做名称迁移、内容重写或样例重生成;旧字段和旧版本值保持历史事实。 +- 方案级别统一为:light 只有 `plan.md`;standard 为 `plan.md + tasks.md`;architecture 为 `plan.md + tasks.md + design.md`。不预建空 receipts、assets、ADR 或 diagrams。 +- `plan.md` 是统一入口,`plan_version` 是完整方案版本。底层使用 SHA-256,但字段名和用户文案不暴露算法术语。 +- `plan_version` 先按 level 校验精确文件集合,再按固定顺序读取该级别的 `plan.md / tasks.md / design.md`;排除 `audits/`、`receipts/`、`assets/`、state 和其他派生产物。 +- EvidentLoop 通用提供 `diff_version` 与 `report_version`;Sopify 自己计算 `plan_version` 并建立 receipt 关联。EvidentLoop 不增加 `plan_id`、Sopify adapter、receipt 或 plan/design profile。 +- Sopify 默认能力是消费通用 `verdict / evidence / source`;EvidentLoop 是官方推荐、可选配套安装的首个验证器,不是默认安装、默认执行或唯一实现。 +- `--with-evidentloop` 默认关闭且只提供安装便利,不写 `evidentloop_enabled`、component registry、state 或持久安装来源。未选择时保持现有安装路径和 `stdlib_only` 核心依赖模型。 +- 配套安装面向 Sopify 已有可安装宿主,通过现有 host adapter 映射到标准 Skills CLI agent target:`codex → codex`、`claude → claude-code`、`qoder → qoder`、`copilot → github-copilot`。Skill 固定使用 `-g` 安装到目标宿主的用户级全局发现路径;新增宿主缺少映射时才明确返回未支持,不另建宿主框架。 +- Codex 是本方案首轮真实 E2E dogfood 宿主,不是配套安装的唯一宿主。其他宿主必须区分“安装链/结构已验证”和“Skill discovery/审计 E2E 已验证”,不把安装成功宣传为完整可用证据。 +- 带参数时,缺失项按 Sopify 发布时固定的 EvidentLoop 公开版本与 tag 安装;已存在且通过兼容探针时直接复用;已存在但不兼容时停止并说明,不静默覆盖、降级或升级。 +- 本方案不自动管理 EvidentLoop 后续升级。用户可按 EvidentLoop 官方方式独立升级;升级后只要兼容探针通过,Sopify 即可使用。自动升级待真实需求出现后另行设计。 +- 用户使用其他验证器时沿用通用 Verifier evidence 与 receipt,不要求 `diff_version / report_version`、EvidentLoop 报告目录或安装参数。 +- 独立 EvidentLoop 审计沿用产品默认目录,不绑定 Sopify 方案;方案主审计使用 `audits/plan/` 并必须写 receipt;附加审计使用 `audits//`,仅在作为正式方案证据时写 receipt。 +- 不使用 `overall`,不新增自动编号器、报告索引、manifest、latest 指针或聚合层;`audits/` 不进入 4 步协议默认读取链。 +- EvidentLoop 不进入默认 plan、develop 或 finalize 流程;没有用户显式请求时,Sopify 不提示、安装或执行审计。 +- 用户泛指验证但未指定组件且存在多个可用验证器时,宿主只询问一次选择;不新增默认优先级、自动路由或验证器发现注册中心。 +- 最终审计主体只包含冻结时的产品实现、测试、Skill/模板、当前 blueprint/protocol 和方案语义,不包含后生成的 `audits/**`、`receipts/**`、报告临时目录或 finalize 的纯状态/归档差异。 +- 报告先在 Git 忽略的 `reports/` 中生成;初次报告完成后宿主必须停车,由用户查看并提交裁定。若裁定要求修改审计主体,先改动、重跑验证并重新审计,不能只 revise 旧报告。 +- 冻结后只允许报告附着、receipt 写入和 finalize 的机械状态/归档动作;若实现、测试、Skill、blueprint/protocol 或方案的目标、范围、方法、任务要求发生变化,旧审计失效并回到冻结前。 +- 下一 EvidentLoop Alpha 的 tag、PyPI 和 GitHub Release 属外部发布动作,执行到发布点时再次请求用户明确授权;未发布前不得把源码验证写成公开版本 dogfood。 +- 当前架构关系可用文字完整说明,因此不新增架构图或 ADR;若执行中出现新的跨产品调用关系,再单独评估是否需要一张图。 + +## Constraints / Not-in-scope + +- 不保留 `crossreview` 命令别名、兼容层、迁移器、`bridge.py`、`review.md`、checkpoint 映射或 handoff 专用字段。 +- 不新增 `~audit` 命令、MCP tool、state 文件、运行注册表、报告平台、自动质量门禁或默认 post-develop 审计。 +- 不建设通用 installer component framework、依赖解析器、版本范围求解、自动升级、自动降级、卸载或跨安装器迁移。 +- 不因本轮只在 Codex 完成真实 dogfood 就把配套安装硬编码为 Codex-only;也不为其他宿主虚构 Skill discovery 或审计 E2E 结论。 +- 不在本方案实现 EvidentLoop `--path`、`--focus`、plan/design artifact profile、多审查者或确定性 checker。 +- 不把 Sopify 的方案结构、目录命名、receipt 或 blueprint 语义写入 EvidentLoop 公共契约。 +- 不修改或删除 CrossReview 独立仓库。 +- 不读取、覆盖或提交 EvidentLoop 仓库中与本方案无关的用户改动;跨仓实施前必须重新核对工作区并隔离范围。 +- Claude 持久 MCP 注册验证保持独立,已验证内容不重复纳入本方案。 +- 不用新 renderer 覆盖旧 EvidentLoop audit 样例,不为命名整洁迁移已发布 schema 的历史字段。 + +## Status / Progress + +- [x] 用户确认直接删除旧 CrossReview deferred 方案,不做 superseded 归档。 +- [x] 用户确认所有方案级别统一以 `plan.md` 为入口,并按复杂度增加 tasks/design。 +- [x] 用户确认对外统一使用 `plan_version / diff_version / report_version`,底层算法不进入字段名。 +- [x] 用户确认独立审计、`audits/plan/` 主审计和 `audits//` 附加审计三种方式。 +- [x] 用户确认 EvidentLoop 只补通用能力,Sopify 自己完成 blueprint、目录和 receipt 适配。 +- [x] 用户确认先补 EvidentLoop 通用版本输出并发布下一 Alpha,再完成 Sopify 公开版本 dogfood。 +- [x] 用户确认方案包重写和两路独立审批。 +- [x] 用户确认 Sopify 默认只保留产品无关的验证证据能力;EvidentLoop 是官方推荐但非默认的验证器。 +- [x] 用户确认 `--with-evidentloop` 只是一次性可选配套安装;未选择、已独立安装或使用其他验证器都属于合法状态。 +- [x] 用户确认配套安装是 Sopify 多宿主能力,Codex 只作为首轮真实 dogfood 宿主。 +- [x] 上一版产品设计与技术架构审批已因安装边界重开,不再作为最终批准。 +- [x] 产品设计与技术架构对最终重写版本重新审批通过;两路均为 P0=0、P1=0,无新用户决策。 +- [x] 用户确认连续实施 Wave 1–2。 +- [x] Wave 1–2 实施、定向验证、全量回归和当前知识收口完成。 +- [x] Wave 1–2 审计问题最小修复与回归完成;两路独立审批 P0=0、P1=0,用户已接受。 +- [ ] Wave 3–6、公开版本 dogfood 和归档完成。 + +## Next + +Wave 1–2 形成本地 checkpoint commit 后进入 Wave 3。Wave 3 只实施 EvidentLoop 通用版本能力;发布前仍需用户单独授权。 diff --git a/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md b/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md new file mode 100644 index 00000000..b249736d --- /dev/null +++ b/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md @@ -0,0 +1,83 @@ +# 任务清单: EvidentLoop 可选方案审计接入与 CrossReview 收口 + +目录: `.sopify/plan/20260718_evidentloop_optional_audit_integration/` + +## Wave 1 | 方案入口与版本契约 + +- [x] 1.1 对齐 `skills/{zh,en}/header.md.template`、design Skill、templates Skill 和分级脚本:方案级别统一为 `light / standard / architecture`,所有级别都有 `plan.md`;standard 增加 `tasks.md`,architecture 再增加 `design.md`。 + - 验收:不再生成缺少 `plan.md` 的 managed plan;不再把 plan 级 `background.md`、空 receipts/assets/ADR/diagram 当作必备文件。 +- [x] 1.2 在现有 Sopify 代码中实现一个确定性的 `plan_version` 计算并复用到 writer、`workspace_status_lite`、protocol check 和入口预检语义。 + - 验收:先按 level 精确校验语义文件集合,再按固定顺序包含 `plan.md`、该级别的 `tasks.md`、`design.md`;路径与原始字节共同参与计算,输出值携带算法前缀。 + - 验收:writer 写 plan receipt 时把版本固定写到 `provenance.plan_version`,可选 `expected_plan_version` 冲突或结构无效时拒绝;lite status 以 `valid/error/version` 客观事实表达无效结构,不输出工作流结论或整体报错。 + - 验收:`tasks.md` 或 `design.md` 的真实变化会改变 `plan_version`;audits、receipts、assets 和 state 变化不会改变它。 + - 验收:现有 `finalize_plan` 在最终版本校验后写 final/history receipt 并迁移方案目录,只在归档成功后清 active plan/handoff;冲突或失败时保留源方案与 state,不新增 finalize 入口。 +- [x] 1.3 增加最小防漂移测试,覆盖中英文 header、design/templates 资产、分级脚本、安装产物、plan 结构和版本计算;历史归档不参与迁移或重算。 + +## Wave 2 | CrossReview 退场与当前真相 + +- [x] 2.1 删除 `.agents/skills/cross-review/` 和中英文 develop rules 中默认 `post_develop` CrossReview advisory 钩子;不创建仓库内 EvidentLoop Skill 副本,也不增加新的默认钩子。 +- [x] 2.2 删除 `.sopify/plan/20260418_cross_review_engine/` 全部文件;不移动到 history,不移植 bridge、checkpoint mapping、`review.md`、handoff 字段或通用 rubric 设想。 +- [x] 2.3 更新 `.sopify/blueprint/{background,design,tasks,protocol}.md`、相关当前 ADR/registry、中英文公开说明与宿主资产:当前能力收口为通用 Verifier + EvidentLoop 可选实例。 + - 验收:entry-preflight 保持“先判意图再接续”;`plan.md` 仍是统一入口,方案并行推进和正式审计统一比较 `plan_version`。 + - 验收:Verifier 只读并返回证据;宿主通过 writer 写 receipt。`audits/` 不进入 active plan/handoff 或 4 步默认读取链。 + - 验收:仅 Sopify、其他验证器、独立安装 EvidentLoop、`--with-evidentloop` 四种形态均有明确说明;安装来源不进入 runtime 判断或 receipt。 + - 验收:CrossReview 只允许保留在 history、历史 ADR 说明和 Git 历史中,不把旧编号改写成 EvidentLoop 新契约。 +- [x] 2.4 收口 Wave 1–2 审计问题:统一校验公共 writer 的 `plan_id` 与归档月份,finalize checker 拒绝残留 state 并复核新格式归档的 `plan_version`;同时澄清语义 reviewer 只读与宿主执行官方受限审计命令的边界。 + - 验收:只覆盖路径逃逸、残留 state、归档后语义文件变化三个主负路径;旧历史不迁移、不复算,不增加通用路径框架或新的权限系统。 + +## Wave 3 | EvidentLoop 通用版本能力 + +- [ ] 3.1 在 EvidentLoop 独立仓库重新核对 main、用户改动和公开版本状态,按其独立生命周期建立最小方案;范围仅为通用 `diff_version / report_version`,不包含任何 Sopify 名称或 plan 语义。 +- [ ] 3.2 复用 EvidentLoop 已有确定性哈希,在正式 `audit.json` 的 namespaced extension 和 `finalize / revise` 结构化结果中提供通用版本值;同步 API、CLI、Skill、数据模型和定向测试。 + - 验收:`diff_version` 唯一对应本次实际 Git diff;`report_version` 唯一对应正式 `audit.json` 字节;正式 HTML 继续校验与 JSON 的 graph/run identity 一致,不单设版本字段。 + - 验收:新报告 finalize 返回非空两版本;带版本报告 revise 原样继承 `diff_version`;legacy schema `0.4` 报告缺失时仍可 revise,并明确返回 `diff_version: null`,不得猜测。 + - 验收:覆盖 legacy `0.4`、连续两轮 revise、copy、in-place、recovery 和 Skill 校验路径。 + - 验收:不升级 code-diff schema 版本,不迁移已发布字段,不增加 plan profile、宿主 adapter、模型 SDK、provider 配置或集成注册表。 +- [ ] 3.3 完成 EvidentLoop 定向与全量验证后,在 tag/PyPI/GitHub Release 前停车请求用户发布授权;获授权后发布下一 Alpha,并核对 package、schema、prompt、Release metadata 和 evidence asset 状态。 + +## Wave 4 | 多宿主可选配套安装 + +- [ ] 4.1 在现有 Python installer、远程 shell/PowerShell wrapper 和用户文档中增加默认关闭的 `--with-evidentloop`;通过现有 HostAdapter 固定映射 `codex → codex`、`claude → claude-code`、`qoder → qoder`、`copilot → github-copilot`,并在核心写入前校验映射、`uv` 和 `npx`。 + - 验收:不带参数时不执行任何 EvidentLoop lookup、网络访问或提示,现有安装成功语义、payload 和 `stdlib_only` 依赖声明保持不变;只有 help/文档新增可选参数说明。缺少 Skill target 映射的新宿主明确停止可选分支。 +- [ ] 4.2 用一个 EvidentLoop 专用安装 helper 实现最小分支,不建立通用 component framework:兼容安装直接复用,缺失的 CLI/Skill 按固定 package 版本和 Git tag 补齐,已有不兼容项时不修改。 + - 验收:CLI 与 Skill 分别探测后再写入;中途失败报告部分结果,同一命令可复用成功项后补齐,不删除外部产物。 + - 验收:固定 Skills CLI 版本,使用 `-g -a -y` 非交互安装到目标 agent 的用户级全局 scope,不使用 `@latest`;仅在子进程环境关闭匿名遥测。 + - 验收:兼容与结构探针分别检查 `~/.codex/skills/`、`~/.claude/skills/`、`~/.qoder/skills/`、`~/.copilot/skills/`,不从 Sopify `destination_dirname` 推导 Skill 路径。 + - 验收:不写 `evidentloop_enabled`、安装来源、component registry 或 state;运行时不读取 installer flag。 + - 验收:本轮不自动升级、降级或卸载。用户独立升级后只要兼容探针通过即可使用;不兼容时给出事实和官方处理入口,不静默修复。 +- [ ] 4.3 增加安装器定向测试和结构检查,覆盖各现有宿主的 Skills CLI target 映射、`-g` 全局 scope 和 agent 发现路径(包括 Copilot 不跟随 Sopify workspace destination),以及未选择、缺映射、缺少前置依赖、全新安装、单项缺失、兼容复用、不兼容停车和失败后重跑;另证明非 EvidentLoop verifier receipt 不需要 ELoop 专用字段。 + +## Wave 5 | 验证冻结与公开版本真实 Dogfood + +- [ ] 5.1 在隔离 HOME 中用冻结的 Sopify release candidate 安装入口和 `--with-evidentloop` 完成 Codex 配套安装;在不读取或输出凭据的前提下核对 `evidentloop doctor --json`、固定版本、package resources 和 Skill 文件,再用不继承当前讨论的新宿主会话证明真实 Skill discovery。 +- [ ] 5.2 完成 plan 契约、版本计算、installer/skill 分发、writer/lite status、protocol check、EvidentLoop 消费路径的定向测试,以及全量 `python3 -m pytest tests -v`、`git diff --check`、版本/链接一致性、CrossReview 当前层残留和 history 未改写检查;修复本方案问题,并完成 `project.md`、blueprint/protocol 和方案语义同步。 + - 验收:所有可能改变实现或契约的动作在审计前结束;区分本方案失败与无关基线问题。 +- [ ] 5.3 冻结只属于本方案的“实现与契约 staged diff”和当前 `plan_version`,在 Git 忽略的 `reports/` 下完成 `prepare → host review → finalize`,随后停车并把初次报告交给用户裁定。 + - 审计主体包含实现、测试、Skill/模板、当前 blueprint/protocol 和方案语义;排除 `audits/**`、`receipts/**`、报告临时目录及 finalize 的纯状态/归档差异。 + - Checkpoint U1(用户动作,不由宿主勾选):用户查看初次报告并提交裁定/反馈;宿主不得自动代替。 +- [ ] 5.4 收到用户裁定后继续:若要求修改审计主体,回到 5.2 并生成新报告;若提交会改变报告裁定的有效反馈块,只执行确定性 `revise`;若明确接受初始报告且没有有效反馈变化,不调用 `revise`。随后将对应的最终报告附着到 `audits/plan/`、验证可搬移性,并通过 writer 写未占用的 `verify_NNN` receipt。 + - receipt 顶层 `verdict` 保存结论,`provenance.plan_version` 绑定冻结方案;evidence 保存 `source`、package/schema/prompt 版本、run ID、review status、`diff_version`、`report_version` 和 `audit_path`。 + - 验收:报告附着和 receipt 写入不改变 `plan_version`;active pointer 与 handoff 保持符合预期;`pass_candidate` 不解释为用户授权。 + +## Wave 6 | 残留复核与归档 + +- [ ] 6.1 只读比较冻结快照与当前工作区:确认新增差异仅为最终报告、receipt 和 finalize 所需的机械状态/归档变化;若实现、测试、Skill、blueprint/protocol 或方案目标、范围、方法、任务要求变化,废弃旧审计并回到 5.2。 +- [ ] 6.2 再次确认没有 CrossReview 当前入口、旧方案、默认触发、兼容别名、bridge、新 state/tool/schema、验证器注册表、安装 component framework、自动升级或报告索引,且 `.sopify/history/**` 旧正文未被改写;该步骤只读,不再引入修复。 +- [ ] 6.3 完成所有任务和方案生命周期的 `ready_to_archive` 状态更新,确认语义文件不再变化并计算最终 `plan_version`。 + - Finalize action(writer 机械动作,不是第二个任务):以最终版本作为 `expected_plan_version` 写 `final.json` 和 `receipt.md`,将完整方案迁入未占用的 `history/2026-07/`,成功后才清 active plan/handoff;宿主随后更新可重建的 history index。 + - 验收:归档包复算版本等于 `final.json.provenance.plan_version`;final/history receipt 写入、目标冲突或迁移失败均保留源方案和 state。history index 失败需告警和单独重试,但不回滚已完成归档。 + +## 验收标准 + +- [ ] light、standard、architecture 三种方案都能从 `plan.md` 进入,生成器与 entry-preflight 不再互相矛盾。 +- [ ] `plan_version` 是唯一方案版本名称,并覆盖当前级别的全部语义文件;用户面不再混用 digest、fingerprint、package hash 等术语。 +- [ ] EvidentLoop 对所有消费者提供通用 `diff_version / report_version`,公共契约不包含 Sopify 专用字段。 +- [ ] Sopify 当前执行面不再包含 CrossReview,也不提供兼容层或默认 EvidentLoop 钩子。 +- [ ] 未选择配套安装时 Sopify 核心安装与工作流完全不依赖 EvidentLoop;选择后可一次补齐目标宿主所需公开 CLI 与 Skill。 +- [ ] Codex 完成真实安装、Skill discovery 和审计 E2E dogfood;其他宿主只声明实际取得的安装/结构证据,不被错误标记为 Codex-only 或 E2E 已验证。 +- [ ] 已独立安装的兼容 EvidentLoop 可直接使用;其他验证器可写通用 evidence/receipt,不被 ELoop 字段或目录约束。 +- [ ] 配套安装不保存运行开关、不管理已有安装升级;缺失安装、兼容复用和不兼容停车边界可验证。 +- [ ] 独立审计、`audits/plan/` 主审计和 `audits//` 附加审计边界清楚,只有正式方案证据写 receipt。 +- [ ] 本方案产出一份由公开 EvidentLoop 版本生成、经过显式用户裁定、可搬移且由 receipt 关联三个版本值的主审计报告;审计主体不包含报告自身、receipt 或机械归档差异。 +- [ ] 未新增命令、MCP tool、state、schema、handoff 字段、报告索引、聚合或自动门禁。 +- [ ] 历史事实保持不变;旧 CrossReview deferred 方案的删除和替代由本方案及最终 receipt 可追溯。 diff --git a/.sopify/project.md b/.sopify/project.md index 81df6dbf..e4ece9e7 100644 --- a/.sopify/project.md +++ b/.sopify/project.md @@ -14,9 +14,10 @@ - 当约定发生变化时,应以代码现状为准并同步更新。 ## Plan 归档约定 -- Plan 归档到 `history/` 后,`plan/` 下的同名原件**必须删除**,避免双驻留。 -- `lifecycle_state` 在 history 副本中须改为 `archived`(或 `completed`,视收口结论)。 -- 唯一例外:`lifecycle_state: deferred` 的 plan 尚未归档,保留在 `plan/` 下。 +- 方案级别固定为 `light / standard / architecture`;分别包含 `plan.md`、`plan.md + tasks.md`、`plan.md + tasks.md + design.md`。 +- 所有方案都以 `plan.md` 为语义入口;`plan_version` 绑定该级别全部语义文件,audits、receipts、assets 与 state 不参与版本。 +- finalize 前由宿主完成最终语义状态更新,再由 `sopify_writer.finalize_plan` 校验版本、写 final/history receipt、迁移完整目录;归档成功后才清 runtime state。 +- Plan 归档到 `history/` 后,`plan/` 下不得保留同名原件,避免双驻留。 ## 文档边界 - `project.md`:只放跨任务可复用的技术约定。 @@ -29,7 +30,7 @@ - `sopify_writer/` 是 protocol state 与 receipts 的唯一写路径;`ProtocolStore` 通过 `sopify_writer.store` 访问。 - `sopify_contracts/` 定义 schema 与共享数据结构(`RuntimeHandoff` 等),是所有写回操作的契约基线。 - `installer/` 负责 payload 分发、workspace bootstrap、doctor/inspection;不再打包 `runtime/` 目录。 -- repo-local 测试统一使用 `python3 -m pytest tests -v`;测试文件按 `test_sopify_writer` / `test_installer` / `test_distribution` / `protocol/` 分组。 +- repo-local 测试统一使用 Python 3.11+ 执行 `python -m pytest tests -v`;测试文件按 `test_sopify_writer` / `test_installer` / `test_distribution` / `protocol/` 分组。 - `runtime/` 目录已在 P8 W2.10 物理删除(46 文件 / ~15.6K LOC);不再存在 runtime facade、runtime engine、runtime gate。 - `scripts/sopify_protocol_check.py` 是 CI/preflight 协议合规 smoke(3 场景:new-plan / continuation / finalize);不得 import runtime。 diff --git a/docs/how-sopify-works.en.md b/docs/how-sopify-works.en.md index e977ae30..b734e360 100644 --- a/docs/how-sopify-works.en.md +++ b/docs/how-sopify-works.en.md @@ -54,10 +54,16 @@ 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; `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. +**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. A complete plan is identified by `plan_version`; standard tasks and architecture design both participate in that version. 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 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. +### Optional Verifiers + +Sopify does not install or run a verifier by default. Sopify alone, an explicitly selected alternative verifier, and an independently installed EvidentLoop are all valid paths. `--with-evidentloop` is a planned one-command installation convenience and remains unavailable until its installer wave lands; it will not be a runtime switch. + +Independent audits do not need a plan binding. A formal plan-wide audit may live in `audits/plan/`, while focused audits may use `audits//`; the host writes a receipt only when the user adopts the result as formal evidence. `audits/` is not part of the default 4-step read chain. + ## Directory Structure and Layers ```text @@ -73,6 +79,7 @@ When the host detects `.sopify/` and the user request targets managed plan / con │ ├── plan.md # sole semantic entry │ ├── tasks.md # optional (standard+) │ ├── design.md # optional (architecture level) +│ ├── audits/ # optional audit reports; not read by default │ └── receipts/ # execution/verification evidence ├── history/ # L3 archived plans (git tracked) │ ├── index.md diff --git a/docs/how-sopify-works.md b/docs/how-sopify-works.md index 20233a6d..e68817ac 100644 --- a/docs/how-sopify-works.md +++ b/docs/how-sopify-works.md @@ -54,10 +54,16 @@ Sopify 把 AI 开发过程中的**方案、决策、交接、执行/验证证据 4. plan//receipts/ → 最新 1-3 个 receipt(哪些已验证) ``` -**设计原则**:先读 `plan.md` 建立语义真相,再读 `current_handoff` 作为恢复提示。handoff 永远不是第二真相源;`active_plan.json` 只保存 `plan_id`,Wave 和任务进度留在方案文件中。MCP 可帮助读取这些事实,但不是协议入口的依赖。 +**设计原则**:先读 `plan.md` 建立语义真相,再读 `current_handoff` 作为恢复提示。handoff 永远不是第二真相源;`active_plan.json` 只保存 `plan_id`,Wave 和任务进度留在方案文件中。完整方案用 `plan_version` 识别,standard 的 tasks 和 architecture 的 design 都参与版本。MCP 可帮助读取这些事实,但不是协议入口的依赖。 **状态文件缺失时**(如新机器 fresh clone):`active_plan.json` 和 `current_handoff.json` 按设计被 gitignore。只有用户要新建、继续或收口方案时,宿主才浏览 `plan/` 发现候选并按用户意图处理;普通问答不会因此自动恢复旧方案。方案和收据始终在 git 里——只有"我现在在哪"的指针是本地的。 +### 可选验证器 + +Sopify 不默认安装或运行验证器。只用 Sopify、显式使用其他 Verifier、使用已独立安装的 EvidentLoop 都是完整合法的路径。`--with-evidentloop` 是规划中的一次性配套安装便利,安装器落地前不可用;它不会成为运行开关。 + +独立审计不必绑定方案。正式方案主审计可放在 `audits/plan/`,针对性审计可放在 `audits//`;只有用户采用为正式证据时,宿主才通过 writer 写 receipt。`audits/` 不进入 4 步默认读取链。 + ## 目录结构与层级 ```text @@ -73,6 +79,7 @@ Sopify 把 AI 开发过程中的**方案、决策、交接、执行/验证证据 │ ├── plan.md # 唯一语义入口 │ ├── tasks.md # 可选(standard+) │ ├── design.md # 可选(architecture 级) +│ ├── audits/ # 可选审计报告,不进入默认读取链 │ └── receipts/ # 执行/验证证据 ├── history/ # L3 已归档方案(git tracked) │ ├── index.md diff --git a/scripts/sopify_mcp_server.py b/scripts/sopify_mcp_server.py index aec1c87e..53c50f49 100644 --- a/scripts/sopify_mcp_server.py +++ b/scripts/sopify_mcp_server.py @@ -19,6 +19,7 @@ sys.path.insert(0, str(REPO_ROOT)) from scripts.sopify_protocol_check import run_protocol_check # noqa: E402 +from sopify_contracts import inspect_plan_package # noqa: E402 from sopify_writer import ProtocolStore # noqa: E402 MCP_DEPENDENCY = "mcp[cli]>=1.27,<2" @@ -68,7 +69,9 @@ def workspace_status_lite(workspace_root: str | Path) -> dict[str, Any]: 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_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 @@ -86,6 +89,11 @@ def workspace_status_lite(workspace_root: str | Path) -> dict[str, Any]: if handoff_plan_id and active_plan_id else None ) + active_plan_package = ( + inspect_plan_package(active_plan_dir).to_dict() + if active_plan_dir is not None + else None + ) return { "workspace_root": str(workspace), @@ -102,6 +110,7 @@ def workspace_status_lite(workspace_root: str | Path) -> dict[str, Any]: "active_plan_md_exists": ( (active_plan_dir / "plan.md").is_file() if active_plan_dir else None ), + "active_plan_package": active_plan_package, "handoff_exists": (state_root / "current_handoff.json").is_file(), "handoff_plan_id": handoff_plan_id, "handoff_matches_active_plan": handoff_matches_active_plan, @@ -156,6 +165,7 @@ def write_plan_receipt( verdict: str, evidence: Mapping[str, Any] | None = None, provenance: Mapping[str, Any] | None = None, + expected_plan_version: str | None = None, ) -> dict[str, str]: """Write a receipt for the current active plan after S2A MCP guards pass.""" workspace = resolve_workspace_root(workspace_root) @@ -173,6 +183,7 @@ def write_plan_receipt( verdict=verdict, evidence=evidence, provenance=provenance, + expected_plan_version=expected_plan_version, ) return {"path": str(receipt_path)} @@ -232,6 +243,7 @@ def tool_write_plan_receipt( verdict: str, evidence: dict[str, Any] | None = None, provenance: dict[str, Any] | None = None, + expected_plan_version: str | None = None, ) -> dict[str, Any]: """Write a plan receipt only after the host has authorized the protocol write.""" return _safe_tool( @@ -243,6 +255,7 @@ def tool_write_plan_receipt( verdict, evidence, provenance, + expected_plan_version, ) return server diff --git a/scripts/sopify_protocol_check.py b/scripts/sopify_protocol_check.py index 25e29235..335c33aa 100644 --- a/scripts/sopify_protocol_check.py +++ b/scripts/sopify_protocol_check.py @@ -19,6 +19,12 @@ import sys from pathlib import Path +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from sopify_contracts import inspect_plan_package # noqa: E402 + REQUIRED_SECTIONS = [ "Context / Why", "Scope", @@ -51,10 +57,17 @@ ] ALLOWANCE_MARKERS = [ - "[retired", "[deprecated", "[superseded", - "must not", "must_not", - "禁止", "不得", "退场", - "~~", "pre-p8", "legacy", + "[retired", + "[deprecated", + "[superseded", + "must not", + "must_not", + "禁止", + "不得", + "退场", + "~~", + "pre-p8", + "legacy", ] @@ -85,32 +98,63 @@ def check_required_sections(plan_md: Path) -> list[str]: for i, sec in enumerate(REQUIRED_SECTIONS): if sec not in found: failures.append(f"Missing required section: '{sec}'") - elif i > 0 and found.index(sec) < found.index(REQUIRED_SECTIONS[i - 1]) if REQUIRED_SECTIONS[i - 1] in found else False: - failures.append(f"Section '{sec}' out of order (must come after '{REQUIRED_SECTIONS[i - 1]}')") + elif ( + i > 0 and found.index(sec) < found.index(REQUIRED_SECTIONS[i - 1]) + if REQUIRED_SECTIONS[i - 1] in found + else False + ): + failures.append( + f"Section '{sec}' out of order (must come after '{REQUIRED_SECTIONS[i - 1]}')" + ) return failures +def check_plan_package(plan_dir: Path) -> list[str]: + """Validate the same semantic package used by writer and entry preflight.""" + snapshot = inspect_plan_package(plan_dir) + if snapshot.valid: + return [] + return [f"Invalid plan package: {snapshot.error or 'unknown error'}"] + + def check_forbidden_patterns(target_file: Path) -> list[str]: failures = [] try: - for line_num, line in enumerate(target_file.read_text(encoding="utf-8").splitlines(), 1): + for line_num, line in enumerate( + target_file.read_text(encoding="utf-8").splitlines(), 1 + ): if is_allowance_line(line): continue for pattern, desc in FORBIDDEN_PATTERNS: if re.search(pattern, line): - failures.append(f"{target_file.name}:{line_num}: active reference to {desc}") + failures.append( + f"{target_file.name}:{line_num}: active reference to {desc}" + ) for sf in FORBIDDEN_STATE_FILES: # Only flag if it looks like an active read reference, not a retirement note - if sf in line and not any(kw in line for kw in [ - "删除", "P8 删除", "RETIRED", "折叠", "替代", "退场", "legacy" - ]): - failures.append(f"{target_file.name}:{line_num}: active reference to retired state file '{sf}'") + if sf in line and not any( + kw in line + for kw in [ + "删除", + "P8 删除", + "RETIRED", + "折叠", + "替代", + "退场", + "legacy", + ] + ): + failures.append( + f"{target_file.name}:{line_num}: active reference to retired state file '{sf}'" + ) except FileNotFoundError: pass return failures -def check_active_plan(state_dir: Path, expected_plan_id: str | None = None) -> tuple[dict | None, list[str]]: +def check_active_plan( + state_dir: Path, expected_plan_id: str | None = None +) -> tuple[dict | None, list[str]]: failures = [] ap_file = state_dir / "active_plan.json" if not ap_file.exists(): @@ -129,14 +173,18 @@ def check_active_plan(state_dir: Path, expected_plan_id: str | None = None) -> t if not isinstance(data.get("plan_id"), str) or not data["plan_id"]: failures.append("active_plan.json 'plan_id' must be a non-empty string") if expected_plan_id and data.get("plan_id") != expected_plan_id: - failures.append(f"active_plan.json plan_id '{data.get('plan_id')}' != expected '{expected_plan_id}'") + failures.append( + f"active_plan.json plan_id '{data.get('plan_id')}' != expected '{expected_plan_id}'" + ) extra = set(data.keys()) - {"plan_id"} if extra: failures.append(f"active_plan.json has unexpected fields: {extra}") return data, failures -def check_current_handoff(state_dir: Path, expected_plan_id: str | None = None) -> tuple[dict | None, list[str]]: +def check_current_handoff( + state_dir: Path, expected_plan_id: str | None = None +) -> tuple[dict | None, list[str]]: failures = [] ch_file = state_dir / "current_handoff.json" if not ch_file.exists(): @@ -154,18 +202,27 @@ def check_current_handoff(state_dir: Path, expected_plan_id: str | None = None) if field not in data: failures.append(f"current_handoff.json missing required field '{field}'") valid_actions = [ - "continue_host_develop", "answer_questions", "confirm_decision", - "continue_host_consult", "resolve_state_conflict", + "continue_host_develop", + "answer_questions", + "confirm_decision", + "continue_host_consult", + "resolve_state_conflict", ] action = data.get("required_host_action") if action and action not in valid_actions: - failures.append(f"current_handoff.json invalid required_host_action: '{action}'") + failures.append( + f"current_handoff.json invalid required_host_action: '{action}'" + ) retired = ["route_name", "run_id", "handoff_kind", "resolution_id"] for f in retired: if f in data: - failures.append(f"current_handoff.json has retired field '{f}' (move to observability.provenance)") + failures.append( + f"current_handoff.json has retired field '{f}' (move to observability.provenance)" + ) if expected_plan_id and data.get("plan_id") != expected_plan_id: - failures.append(f"current_handoff.json plan_id mismatch: '{data.get('plan_id')}' != '{expected_plan_id}'") + failures.append( + f"current_handoff.json plan_id mismatch: '{data.get('plan_id')}' != '{expected_plan_id}'" + ) # W2.5: artifact conventions for folded clarification/decision if action == "answer_questions": artifacts = data.get("artifacts") @@ -206,7 +263,32 @@ def check_receipts(plan_dir: Path, require_final: bool = False) -> list[str]: return failures for field in ["verdict", "evidence", "provenance", "timestamp"]: if field not in data: - failures.append(f"receipts/final.json missing required field '{field}'") + failures.append( + f"receipts/final.json missing required field '{field}'" + ) + provenance = data.get("provenance") + if not isinstance(provenance, dict): + failures.append( + "receipts/final.json field 'provenance' must be an object" + ) + elif "plan_version" in provenance: + expected_version = provenance.get("plan_version") + if not isinstance(expected_version, str) or not expected_version: + failures.append( + "receipts/final.json provenance.plan_version must be a non-empty string" + ) + else: + snapshot = inspect_plan_package(plan_dir) + if not snapshot.valid or snapshot.version is None: + failures.append( + "Archived plan package is invalid: " + f"{snapshot.error or 'unknown error'}" + ) + elif snapshot.version != expected_version: + failures.append( + "Archived plan_version does not match " + "receipts/final.json provenance.plan_version" + ) except json.JSONDecodeError as e: failures.append(f"Invalid JSON in receipts/final.json: {e}") elif receipts_dir.exists(): @@ -214,7 +296,9 @@ def check_receipts(plan_dir: Path, require_final: bool = False) -> list[str]: if f.name == "final.json": continue if not re.match(r"^(exec|verify)_\d{3}\.json$", f.name): - failures.append(f"receipts/{f.name} doesn't match naming convention (exec_NNN/verify_NNN.json)") + failures.append( + f"receipts/{f.name} doesn't match naming convention (exec_NNN/verify_NNN.json)" + ) return failures @@ -222,9 +306,9 @@ def check_state_empty(state_dir: Path) -> list[str]: failures = [] if state_dir.exists(): for f in state_dir.iterdir(): - if f.name in ("active_plan.json", "current_handoff.json"): - continue - failures.append(f"state/ should be empty after finalize, but found: {f.name}") + failures.append( + f"state/ should be empty after finalize, but found: {f.name}" + ) return failures @@ -257,6 +341,7 @@ def run_new_plan(fixture: Path) -> dict: if not plan_md.exists(): failures.append(f"plan/{plan_id}/plan.md not found") else: + failures.extend(check_plan_package(plan_dir)) failures.extend(check_required_sections(plan_md)) failures.extend(check_forbidden_patterns(plan_md)) @@ -282,11 +367,14 @@ def run_continuation(fixture: Path) -> dict: if not plan_md.exists(): failures.append(f"plan/{plan_id}/plan.md not found (state inconsistency)") else: + failures.extend(check_plan_package(plan_dir)) failures.extend(check_required_sections(plan_md)) failures.extend(check_forbidden_patterns(plan_md)) # Step 3: current_handoff - ch_data, ch_failures = check_current_handoff(state, expected_plan_id=plan_id or None) + ch_data, ch_failures = check_current_handoff( + state, expected_plan_id=plan_id or None + ) failures.extend(ch_failures) # Step 4: receipts (latest-only) @@ -299,11 +387,23 @@ def run_continuation(fixture: Path) -> dict: failures.extend(check_forbidden_patterns(protocol_md)) else: # Fallback: scan the repo's own protocol.md if fixture doesn't have one - repo_protocol = Path(__file__).resolve().parent.parent / ".sopify" / "blueprint" / "protocol.md" + repo_protocol = ( + Path(__file__).resolve().parent.parent + / ".sopify" + / "blueprint" + / "protocol.md" + ) if repo_protocol.exists(): failures.extend(check_forbidden_patterns(repo_protocol)) # Also scan host prompt entry spec - repo_prompt_spec = Path(__file__).resolve().parent.parent / ".sopify" / "plan" / "20260605_p8_protocol_kernel_runtime_retirement" / "assets" / "host-prompt-protocol-entry.md" + repo_prompt_spec = ( + Path(__file__).resolve().parent.parent + / ".sopify" + / "plan" + / "20260605_p8_protocol_kernel_runtime_retirement" + / "assets" + / "host-prompt-protocol-entry.md" + ) if repo_prompt_spec.exists(): failures.extend(check_forbidden_patterns(repo_prompt_spec)) @@ -355,13 +455,17 @@ def run_protocol_check(workspace_root: Path | str, scenario: str) -> dict: if scenario not in SCENARIOS: return make_result( scenario, - [f"Unsupported scenario: {scenario!r}; expected one of {', '.join(SCENARIOS)}"], + [ + f"Unsupported scenario: {scenario!r}; expected one of {', '.join(SCENARIOS)}" + ], fixture, ) if not fixture.exists(): return make_result(scenario, [f"fixture not found: {fixture}"], fixture) if not fixture.is_dir(): - return make_result(scenario, [f"fixture is not a directory: {fixture}"], fixture) + return make_result( + scenario, [f"fixture is not a directory: {fixture}"], fixture + ) runners = { "new-plan": run_new_plan, @@ -392,7 +496,9 @@ def main(): sys.exit(1) if not args.fixture.exists(): - print(json.dumps({"error": f"fixture not found: {args.fixture}"}), file=sys.stderr) + print( + json.dumps({"error": f"fixture not found: {args.fixture}"}), file=sys.stderr + ) sys.exit(2) result = run_protocol_check(args.fixture, args.scenario) diff --git a/scripts/w33_qoder_proof.py b/scripts/w33_qoder_proof.py index a52265a8..e9b52e8b 100644 --- a/scripts/w33_qoder_proof.py +++ b/scripts/w33_qoder_proof.py @@ -14,6 +14,7 @@ Output: structured proof transcript to stdout. """ + from __future__ import annotations import json @@ -66,21 +67,35 @@ def main() -> None: print() print(f"- Date: {datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}") print(f"- Payload: `{bundle_dir}`") - print(f"- sys.path: repo-local paths filtered out; installed payload inserted at front; stdlib + site-packages retained") + print( + "- sys.path: repo-local paths filtered out; installed payload inserted at front; stdlib + site-packages retained" + ) print() - print("> **Scope**: This transcript is a writer-level durable proof. Session A/B are") - print("> simulated ProtocolStore instances, not real Qoder LLM sessions. Receipt evidence") - print("> values are examples, not live command outputs. Protocol entry instructions are") - print("> installed to ~/.qoder/AGENTS.md (L131-135), but this transcript does not verify") - print("> that the LLM autonomously follows those instructions (host behavioral proof is") - print("> out of scope). Note: .qoder/rules/ overrides AGENTS.md if user/project rules exist.") + print( + "> **Scope**: This transcript is a writer-level durable proof. Session A/B are" + ) + print( + "> simulated ProtocolStore instances, not real Qoder LLM sessions. Receipt evidence" + ) + print( + "> values are examples, not live command outputs. Protocol entry instructions are" + ) + print( + "> installed to ~/.qoder/AGENTS.md (L131-135), but this transcript does not verify" + ) + print( + "> that the LLM autonomously follows those instructions (host behavioral proof is" + ) + print( + "> out of scope). Note: .qoder/rules/ overrides AGENTS.md if user/project rules exist." + ) print() # ── Step 1: Import from installed payload ── print("## Step 1: Import from Installed Payload") print() try: - from sopify_writer import ProtocolStore, InvariantViolationError + from sopify_writer import ProtocolStore from sopify_contracts import RuntimeHandoff except ImportError as exc: print(f"FAIL: import error: {exc}") @@ -90,7 +105,7 @@ def main() -> None: handoff_origin = RuntimeHandoff.__module__ print(f"- `sopify_writer.ProtocolStore` from: `{writer_origin}`") print(f"- `sopify_contracts.RuntimeHandoff` from: `{handoff_origin}`") - print(f"- **PASS**: imports resolved from installed payload") + print("- **PASS**: imports resolved from installed payload") print() # ── Step 2: Session A — create plan, write state + receipts ── @@ -104,16 +119,51 @@ def main() -> None: plan_id = "20260610_w33_e2e_proof" store_a = ProtocolStore(sopify_root) + plan_dir = sopify_root / "plan" / plan_id + plan_dir.mkdir(parents=True) + (plan_dir / "plan.md").write_text( + """--- +level: light +--- + +# W3.3 E2E Proof + +## Context / Why +Prove installed payload behavior. + +## Scope +Writer-level protocol proof. + +## Approach +Simulate two sessions. + +## Waves / Steps +- [ ] Complete proof. + +## Key Decisions +- Use installed payload only. + +## Constraints / Not-in-scope +- No live host behavior claim. + +## Status / Progress +In progress. + +## Next +Finalize the proof. +""", + encoding="utf-8", + ) # 2a: Write active_plan store_a.set_active_plan(plan_id=plan_id) active_plan = json.loads( (sopify_root / "state" / "active_plan.json").read_text() ) - print(f"### 2a: active_plan.json") - print(f"```json") + print("### 2a: active_plan.json") + print("```json") print(json.dumps(active_plan, indent=2)) - print(f"```") + print("```") print(f"- **PASS**: plan_id = `{active_plan['plan_id']}`") print() @@ -129,10 +179,10 @@ def main() -> None: handoff_data = json.loads( (sopify_root / "state" / "current_handoff.json").read_text() ) - print(f"### 2b: current_handoff.json") - print(f"```json") + print("### 2b: current_handoff.json") + print("```json") print(json.dumps(handoff_data, indent=2)) - print(f"```") + print("```") print( f"- **PASS**: plan_id=`{handoff_data['plan_id']}`, " f"action=`{handoff_data['required_host_action']}`" @@ -151,14 +201,12 @@ def main() -> None: }, provenance={"session_id": "w33-session-a", "host": "qoder"}, ) - exec_path = ( - sopify_root / "plan" / plan_id / "receipts" / "exec_001.json" - ) + exec_path = sopify_root / "plan" / plan_id / "receipts" / "exec_001.json" exec_data = json.loads(exec_path.read_text()) - print(f"### 2c: receipts/exec_001.json") - print(f"```json") + print("### 2c: receipts/exec_001.json") + print("```json") print(json.dumps(exec_data, indent=2)) - print(f"```") + print("```") print(f"- **PASS**: verdict=`{exec_data['verdict']}`") print() @@ -173,18 +221,16 @@ def main() -> None: }, provenance={"session_id": "w33-session-a", "host": "qoder"}, ) - print(f"### 2d: receipts/verify_001.json") - print(f"- **PASS**: written successfully") + print("### 2d: receipts/verify_001.json") + print("- **PASS**: written successfully") print() # 2e: State file check - state_files = sorted( - f.name for f in (sopify_root / "state").iterdir() - ) - print(f"### 2e: State File Check") + state_files = sorted(f.name for f in (sopify_root / "state").iterdir()) + print("### 2e: State File Check") print(f"- Files in `state/`: `{state_files}`") assert state_files == ["active_plan.json", "current_handoff.json"] - print(f"- **PASS**: exactly 2 files (2-file model)") + print("- **PASS**: exactly 2 files (2-file model)") print() # ── Step 3: Session B — read via 4-step chain + write ── @@ -196,25 +242,25 @@ def main() -> None: # 3a: Step 1 of read chain — active_plan active_b = store_b.get_active_plan() - print(f"### 3a: Read Chain Step 1 — active_plan.json") - print(f"```json") + print("### 3a: Read Chain Step 1 — active_plan.json") + print("```json") print(json.dumps(active_b, indent=2)) - print(f"```") + print("```") assert active_b is not None assert active_b["plan_id"] == plan_id print(f"- **PASS**: located plan_id = `{plan_id}`") print() - # 3b: Step 2 of read chain — plan.md (simulated check) - print(f"### 3b: Read Chain Step 2 — plan.md") - print(f"- plan.md would be read at: `.sopify/plan/{plan_id}/plan.md`") - print(f"- (Not created in this proof — protocol allows fallback to handoff)") - print(f"- **PASS**: read chain handles missing plan.md gracefully") + # 3b: Step 2 of read chain — plan.md + print("### 3b: Read Chain Step 2 — plan.md") + print(f"- plan.md read at: `.sopify/plan/{plan_id}/plan.md`") + assert (plan_dir / "plan.md").is_file() + print("- **PASS**: semantic entry exists") print() # 3c: Step 3 of read chain — current_handoff handoff_b = store_b.get_current_handoff() - print(f"### 3c: Read Chain Step 3 — current_handoff.json") + print("### 3c: Read Chain Step 3 — current_handoff.json") if handoff_b: print(f"- plan_id: `{handoff_b.plan_id}`") print(f"- required_host_action: `{handoff_b.required_host_action}`") @@ -222,17 +268,17 @@ def main() -> None: assert handoff_b is not None assert handoff_b.plan_id == plan_id assert handoff_b.required_host_action == "continue_host_develop" - print(f"- **PASS**: session B recovered context from handoff") + print("- **PASS**: session B recovered context from handoff") print() # 3d: Step 4 of read chain — receipts receipts_dir = sopify_root / "plan" / plan_id / "receipts" receipt_files = sorted(f.name for f in receipts_dir.iterdir()) - print(f"### 3d: Read Chain Step 4 — receipts/") + print("### 3d: Read Chain Step 4 — receipts/") print(f"- Receipt files: `{receipt_files}`") assert "exec_001.json" in receipt_files assert "verify_001.json" in receipt_files - print(f"- **PASS**: session B can see what was verified") + print("- **PASS**: session B can see what was verified") print() # 3e: Session B writes new receipt @@ -247,17 +293,17 @@ def main() -> None: provenance={"session_id": "w33-session-b", "host": "qoder"}, ) updated_receipts = sorted(f.name for f in receipts_dir.iterdir()) - print(f"### 3e: Session B Writes exec_002.json") + print("### 3e: Session B Writes exec_002.json") print(f"- Receipts after write: `{updated_receipts}`") assert "exec_002.json" in updated_receipts - print(f"- **PASS**: cross-session continuation verified") + print("- **PASS**: cross-session continuation verified") print() # ── Step 4: Finalize ── print("## Step 4: Finalize — Clear State + Final Receipt + History") print() - store_b.finalize_plan( + finalize_result = store_b.finalize_plan( plan_id=plan_id, outcome="completed", summary="W3.3 end-to-end proof: Session A created plan and wrote receipts; Session B resumed via 4-step read chain and continued; finalize cleared state.", @@ -270,34 +316,32 @@ def main() -> None: # 4a: State cleared remaining = list((sopify_root / "state").iterdir()) - print(f"### 4a: State Cleared") + print("### 4a: State Cleared") print(f"- Files in `state/`: `{[f.name for f in remaining]}`") assert len(remaining) == 0 - print(f"- **PASS**: state/ empty after finalize") + print("- **PASS**: state/ empty after finalize") print() # 4b: final.json - final_path = sopify_root / "plan" / plan_id / "receipts" / "final.json" + final_path = finalize_result["final_receipt"] final_data = json.loads(final_path.read_text()) - print(f"### 4b: receipts/final.json") - print(f"```json") + print("### 4b: receipts/final.json") + print("```json") print(json.dumps(final_data, indent=2)) - print(f"```") + print("```") print(f"- **PASS**: verdict=`{final_data['verdict']}`") print() # 4c: history receipt month = datetime.now().strftime("%Y-%m") - history_receipt = ( - sopify_root / "history" / month / plan_id / "receipt.md" - ) + history_receipt = sopify_root / "history" / month / plan_id / "receipt.md" assert history_receipt.exists() hr_preview = history_receipt.read_text()[:300] print(f"### 4c: history/{month}/{plan_id}/receipt.md") - print(f"```markdown") + print("```markdown") print(hr_preview) - print(f"```") - print(f"- **PASS**: history receipt generated") + print("```") + print("- **PASS**: history receipt generated") print() # ── Step 5: Negative Checks ── @@ -319,14 +363,18 @@ def main() -> None: print(f"- **PASS**: No retired state files produced ({len(retired)} checked)") # 5b: No runtime import - print(f"- **PASS**: No `runtime` module imported (repo paths filtered; stdlib + site-packages retained)") + print( + "- **PASS**: No `runtime` module imported (repo paths filtered; stdlib + site-packages retained)" + ) # 5c: No _registry.yaml assert not (sopify_root / "plan" / "_registry.yaml").exists() - print(f"- **PASS**: No `_registry.yaml` dependency") + print("- **PASS**: No `_registry.yaml` dependency") # 5d: sopify_writer does not route or execute - print(f"- **PASS**: sopify_writer only writes protocol files (no routing, no execution)") + print( + "- **PASS**: sopify_writer only writes protocol files (no routing, no execution)" + ) print() # ── Summary ── diff --git a/skills/en/header.md.template b/skills/en/header.md.template index fa516494..6110fb93 100644 --- a/skills/en/header.md.template +++ b/skills/en/header.md.template @@ -129,9 +129,10 @@ Complex Task (full 3 phases): 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. +- An active plan is valid only when its level has the exact semantic files: light has `plan.md`, standard adds `tasks.md`, and architecture adds `design.md`. Bind the complete package with `plan_version`; audits, receipts, assets, and state do not participate in that version. - 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. +- 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's semantic review stays read-only on the target; the host may follow the user-selected verifier's official workflow for bounded evidence collection and independent report generation, then revalidates the target `plan_version` and 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_version` / 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. @@ -179,8 +180,10 @@ Complex: Files > 5, architectural changes, new features | Level | Structure | Trigger | |-------|-----------|---------| | light | Single `plan.md` | Medium tasks | -| standard | `background.md` + `design.md` + `tasks.md` | Complex tasks | -| full | Standard + `adr/` + `diagrams/` | Architectural changes | +| standard | `plan.md` + `tasks.md` | Complex tasks | +| architecture | `plan.md` + `tasks.md` + `design.md` | Architectural changes | + +Every level uses `plan.md` as its semantic entry. Create ADRs, diagrams, assets, and receipts only when needed. **Directory Structure:** ``` @@ -278,7 +281,7 @@ Next: Continue to solution design? (Y/n) **Execution Flow:** ``` 1. Read design Skill -2. Determine plan package level (light/standard/full) +2. Determine plan package level (light/standard/architecture) 3. Generate plan files 4. Output summary ``` @@ -296,9 +299,9 @@ Scoring rationale: {1 line} --- Changes: 3 files - - .sopify/plan/20260115_feature/background.md - - .sopify/plan/20260115_feature/design.md + - .sopify/plan/20260115_feature/plan.md - .sopify/plan/20260115_feature/tasks.md + - .sopify/plan/20260115_feature/design.md Next: Continue plan review or execution in the host session, or reply with feedback ``` diff --git a/skills/en/skills/sopify/design/SKILL.md b/skills/en/skills/sopify/design/SKILL.md index a4f072c2..45d97daa 100644 --- a/skills/en/skills/sopify/design/SKILL.md +++ b/skills/en/skills/sopify/design/SKILL.md @@ -15,7 +15,7 @@ description: Design phase entry. Aggregates plan-level selection, task breakdown ## Execution skeleton 1. Load `references/design-rules.md`. -2. Decide `light/standard/full` from explicit change signals. +2. Decide `light/standard/architecture` from explicit change signals. 3. Generate the plan files from the matching templates in `assets/`. 4. Produce the task list and validate task granularity. 5. Add the scoring block to the formal plan package and render the scored summary with `assets/output-summary.md`. diff --git a/skills/en/skills/sopify/design/assets/background-template.md b/skills/en/skills/sopify/design/assets/background-template.md deleted file mode 100644 index 27546de5..00000000 --- a/skills/en/skills/sopify/design/assets/background-template.md +++ /dev/null @@ -1,24 +0,0 @@ -# Change Proposal: {Feature Name} - -## Requirement Background -{Describe the current state, pain points, and change drivers} - -Scoring: -- Solution quality: {X}/10 -- Implementation readiness: {Y}/10 - -Scoring rationale: -- Strengths: {1 line} -- Deductions: {1 line} - -## Change Content -1. {change point 1} -2. {change point 2} - -## Impact Scope -- Modules: {list} -- Files: {list} - -## Risk Assessment -- Risk: {description} -- Mitigation: {measures} diff --git a/skills/en/skills/sopify/design/assets/plan-light-template.md b/skills/en/skills/sopify/design/assets/plan-light-template.md deleted file mode 100644 index fa810a39..00000000 --- a/skills/en/skills/sopify/design/assets/plan-light-template.md +++ /dev/null @@ -1,24 +0,0 @@ -# {Feature Name} - -## Background -{1-2 sentences describing the requirement background} - -Scoring: -- Solution quality: {X}/10 -- Implementation readiness: {Y}/10 - -Scoring rationale: -- Strengths: {1 line} -- Deductions: {1 line} - -## Solution -{Technical solution points as a list} - -## Tasks -- [ ] {task1} -- [ ] {task2} -- [ ] {task3} - -## Changed Files -- {file1} -- {file2} diff --git a/skills/en/skills/sopify/design/assets/plan-template.md b/skills/en/skills/sopify/design/assets/plan-template.md new file mode 100644 index 00000000..e6913a35 --- /dev/null +++ b/skills/en/skills/sopify/design/assets/plan-template.md @@ -0,0 +1,52 @@ +--- +title: {Feature Name} +plan_id: {YYYYMMDD_feature} +status: planned +lifecycle_state: planned +level: {light|standard|architecture} +created: {YYYY-MM-DD} +updated: {YYYY-MM-DD} +archive_ready: false +--- + +# {Feature Name} + +Scoring: +- Solution quality: {X}/10 +- Implementation readiness: {Y}/10 + +Scoring rationale: +- Strengths: {1 line} +- Deductions: {1 line} + +## Context / Why + +{Why this change is needed} + +## Scope + +{What this plan includes} + +## Approach + +{Smallest viable approach} + +## Waves / Steps + +- [ ] {Step 1} + +## Key Decisions + +- {Confirmed decision} + +## Constraints / Not-in-scope + +- {Explicit exclusion} + +## Status / Progress + +- [ ] {Current status} + +## Next + +{Next action or decision checkpoint} diff --git a/skills/en/skills/sopify/design/references/design-rules.md b/skills/en/skills/sopify/design/references/design-rules.md index 89910e23..3278f650 100644 --- a/skills/en/skills/sopify/design/references/design-rules.md +++ b/skills/en/skills/sopify/design/references/design-rules.md @@ -6,7 +6,7 @@ Design the technical solution, break work into executable tasks, and generate a ## Overall flow -1. Decide the plan level (`light/standard/full`). +1. Decide the plan level (`light/standard/architecture`). 2. Generate the plan file scaffold. 3. Break down tasks and mark verification criteria. 4. Render the summary and wait for the next host action. @@ -17,16 +17,15 @@ Auto-detection rules (`plan.level=auto`): - `light`: 3-5 files, no architectural change, scope is explicit. - `standard`: more than 5 files, or a new feature, or a cross-module change. -- `full`: architectural change, major refactor, or new system design. +- `architecture`: architectural change, major refactor, or new system design. ## Step 2: Generate plan files - `light`: generate `plan.md`. -- `standard`: generate `background.md + design.md + tasks.md`. -- `full`: extend standard with `adr/` and `diagrams/`. -- formal plan packages include the scoring block by default: - - `light`: write it into `plan.md` - - `standard/full`: write it into `background.md` +- `standard`: generate `plan.md + tasks.md`. +- `architecture`: generate `plan.md + tasks.md + design.md`. +- Every formal package puts its `level` frontmatter, semantic entry, and scoring block in `plan.md`. +- Create ADRs, diagrams, assets, and receipts only when evidence requires them; no level requires empty supporting directories. - the plan summary must also surface: - `Solution quality` - `Implementation readiness` @@ -34,11 +33,10 @@ Auto-detection rules (`plan.level=auto`): Template sources live in `assets/`: -1. `assets/plan-light-template.md` -2. `assets/background-template.md` +1. `assets/plan-template.md` +2. `assets/tasks-template.md` 3. `assets/design-template.md` -4. `assets/tasks-template.md` -5. `assets/adr-template.md` +4. `assets/adr-template.md` (only for an actual architecture decision) ## Step 3: Break down tasks diff --git a/skills/en/skills/sopify/design/scripts/select_plan_level.py b/skills/en/skills/sopify/design/scripts/select_plan_level.py index 0c4f6634..dafba8af 100644 --- a/skills/en/skills/sopify/design/scripts/select_plan_level.py +++ b/skills/en/skills/sopify/design/scripts/select_plan_level.py @@ -26,7 +26,7 @@ def main() -> int: reasons: List[str] = [] if args.architecture_change or args.major_refactor or args.new_system: - level = "full" + level = "architecture" if args.architecture_change: reasons.append("architecture_change") if args.major_refactor: diff --git a/skills/en/skills/sopify/develop/references/develop-rules.md b/skills/en/skills/sopify/develop/references/develop-rules.md index a4f8d3fc..25bd4161 100644 --- a/skills/en/skills/sopify/develop/references/develop-rules.md +++ b/skills/en/skills/sopify/develop/references/develop-rules.md @@ -8,10 +8,9 @@ Implement the task list, maintain task state, sync V2 long-lived knowledge throu 1. Read the task list. 2. Execute each task through the fixed quality loop: implement change -> discover verification -> run verification -> retry once when needed -> perform two-stage review. Update task markers only after the minimum quality contract is satisfied. -3. If the workspace contains a `post_develop` advisory skill and its prerequisites are met, execute a single post-develop advisory review. -4. Sync KB and preference data through `knowledge_sync`. -5. Move the completed plan into `history/`. -6. Render the execution summary. +3. Sync KB and preference data through `knowledge_sync`. +4. Move the completed plan into `history/`. +5. Render the execution summary. ## Step 1: Read the task list @@ -159,24 +158,7 @@ After completing the two-stage review, before outputting the final summary: 3. Review conclusion line present: success must include `spec_compliance` + `code_quality` each with one sentence of evidence. 4. Footer complete: `Changes:` + `Next:` must be present. -## Step 3: Post-develop advisory review - -After all tasks pass the quality loop and two-stage review, if the workspace `.agents/skills/` contains an advisory skill whose triggers include `post_develop`, execute a single post-develop advisory review following its SKILL.md. Currently only CrossReview Phase 4a is included in this path. - -Trigger conditions: - -1. All tasks have passed the quality loop (Step 2 complete). -2. The workspace has unreviewed code changes (committed review range `git diff ..HEAD` is non-empty, or uncommitted changes exist and must be confirmed/handled per advisory skill Step 0). -3. The advisory skill's prerequisites are met (e.g. CLI installed, and the isolated execution context required by host-integrated review is available). - -Execution constraints: - -- Execution failure or `inconclusive` verdict does not block the main flow. -- `concerns` / `needs_human_triage` are shown to the user and await their decision; do not auto-write checkpoints or auto-modify code. -- If prerequisites are not met (e.g. CLI not installed), skip and log the reason without blocking. -- Note: Phase 4a stays in Convention mode; this does not introduce `bridge.py` or `pipeline_hooks`. - -## Step 4: Sync the knowledge base +## Step 3: Sync the knowledge base Sync timing: @@ -210,7 +192,7 @@ Disallowed: - Guesses from incomplete context. - Generalized conclusions unrelated to the task. -## Step 5: Plan migration +## Step 4: Plan migration Migration path: diff --git a/skills/en/skills/sopify/templates/SKILL.md b/skills/en/skills/sopify/templates/SKILL.md index ddfbd46f..f2bfe460 100644 --- a/skills/en/skills/sopify/templates/SKILL.md +++ b/skills/en/skills/sopify/templates/SKILL.md @@ -138,84 +138,49 @@ No confirmed long-term preferences yet. ## A2 | Plan Package Templates -### Light Level - plan.md +Every level starts with a `plan.md` carrying `level` frontmatter. Standard adds `tasks.md`; architecture adds `design.md`. Create supporting ADRs, diagrams, and evidence only when needed, without empty directories. -```markdown -# {Feature Name} - -## Background -{1-2 sentences describing the requirement background} - -Scoring: -- Solution quality: {X}/10 -- Implementation readiness: {Y}/10 - -Scoring rationale: -- Strengths: {1 line} -- Deductions: {1 line} - -## Solution -- {technical point 1} -- {technical point 2} - -## Tasks -- [ ] {task1} -- [ ] {task2} - -## Changed Files -- {file1} -- {file2} -``` - -### Standard Level - background.md +### All Levels - plan.md ```markdown -# Change Proposal: {Feature Name} +--- +title: {Feature Name} +plan_id: {YYYYMMDD_feature} +level: {light|standard|architecture} +--- -## Requirement Background -{Describe the current state, pain points, and change drivers} +# {Feature Name} Scoring: - Solution quality: {X}/10 - Implementation readiness: {Y}/10 -Scoring rationale: -- Strengths: {1 line} -- Deductions: {1 line} +## Context / Why +{Why this change is needed} -## Change Content -1. {change point 1} -2. {change point 2} - -## Impact Scope -- Modules: {list} -- Files: {list} +## Scope +{What this plan includes} -## Risk Assessment -- Risk: {description} -- Mitigation: {measures} -``` +## Approach +{Smallest viable approach} -### Standard Level - design.md +## Waves / Steps +- [ ] {Step 1} -```markdown -# Technical Design: {Feature Name} +## Key Decisions +- {Confirmed decision} -## Technical Solution -- Core technology: {language/framework/library} -- Implementation points: - - {point1} - - {point2} +## Constraints / Not-in-scope +- {Explicit exclusion} -## Architecture Design -{Include a mermaid diagram when the architecture changes} +## Status / Progress +- [ ] {Current status} -## Security and Performance -- Security: {measures} -- Performance: {optimizations} +## Next +{Next action or decision checkpoint} ``` -### Standard Level - tasks.md +### Standard / Architecture - tasks.md ```markdown # Task List: {Feature Name} @@ -233,22 +198,19 @@ Directory: `.sopify/plan/{YYYYMMDD}_{feature}/` - [ ] 3.1 Update `project.md / blueprint/background.md / blueprint/design.md / blueprint/tasks.md` ``` -### Full Level - adr/{NNN}-{title}.md +### Architecture - design.md ```markdown -# ADR-{NNN}: {Decision Title} - -## Status -Adopted | Pending | Deprecated +# Technical Design: {Feature Name} -## Date -{YYYY-MM-DD} +## Technical Solution +- {Implementation point} -## Context -{Background and problem statement} +## Architecture Boundaries +{Module relationships and responsibilities} -## Decision -{Core decision content} +## Verification Strategy +- {Verification method} ``` ## A3 | Task Markers diff --git a/skills/zh/header.md.template b/skills/zh/header.md.template index 0cb82b49..a2c0bef4 100644 --- a/skills/zh/header.md.template +++ b/skills/zh/header.md.template @@ -129,9 +129,10 @@ Next: {下一步提示} 说明: - 每次进入 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 只有在 level 对应语义文件精确成立时才有效:light 为 `plan.md`,standard 再含 `tasks.md`,architecture 再含 `design.md`。完整方案以 `plan_version` 绑定;audits、receipts、assets 与 state 不参与版本。 - 入口处理顺序为本轮用户意图、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 变化时,才停止当前有副作用的开发,并只提示一次:`方案状态出现并行推进信号,本会话暂不写入。你可以继续只读审计,或确认其他开发已停止后在这里继续。` 用户确认后,先重读最新方案状态再继续。 +- machine truth 只通过 `sopify_writer` 写入;`active_plan` 只含 `plan_id`,Wave 和任务进度来自方案文件。显式审计非 active plan 时,审计器的语义审查对目标只读;宿主可按用户选定审计器的官方流程执行受限取证和独立报告生成,校验目标 `plan_version` 后通过 writer 写 `verify_NNN` receipt,active pointer / handoff 不变。 +- session 标识只作 provenance,不单独阻断。仅当用户明确要求同时开发、宿主确认另一任务仍在运行,或写入前出现非本轮已知的目标 `plan_version` / 匹配 handoff 变化时,才停止当前有副作用的开发,并只提示一次:`方案状态出现并行推进信号,本会话暂不写入。你可以继续只读审计,或确认其他开发已停止后在这里继续。` 用户确认后,先重读最新方案状态再继续。 详见 `.sopify/blueprint/protocol.md §8`:Host Protocol Entry Contract。 @@ -179,8 +180,10 @@ Next: {下一步提示} | 级别 | 结构 | 触发条件 | |-----|------|---------| | light | `plan.md` 单文件 | 中等任务 | -| standard | `background.md` + `design.md` + `tasks.md` | 复杂任务 | -| full | 标准 + `adr/` + `diagrams/` | 架构级变更 | +| standard | `plan.md` + `tasks.md` | 复杂任务 | +| architecture | `plan.md` + `tasks.md` + `design.md` | 架构级变更 | + +所有级别都以 `plan.md` 为语义入口;ADR、diagram、assets 与 receipts 仅按实际需要创建。 **目录结构:** ``` @@ -278,7 +281,7 @@ Next: 继续方案设计?(Y/n) **执行流程:** ``` 1. 读取 design Skill -2. 确定方案包级别 (light/standard/full) +2. 确定方案包级别 (light/standard/architecture) 3. 生成方案文件 4. 输出摘要 ``` @@ -296,9 +299,9 @@ Next: 继续方案设计?(Y/n) --- Changes: 3 files - - .sopify/plan/20260115_feature/background.md - - .sopify/plan/20260115_feature/design.md + - .sopify/plan/20260115_feature/plan.md - .sopify/plan/20260115_feature/tasks.md + - .sopify/plan/20260115_feature/design.md Next: 在宿主会话中继续评审或执行方案,或直接回复修改意见 ``` diff --git a/skills/zh/skills/sopify/design/SKILL.md b/skills/zh/skills/sopify/design/SKILL.md index 14aa2452..08945f43 100644 --- a/skills/zh/skills/sopify/design/SKILL.md +++ b/skills/zh/skills/sopify/design/SKILL.md @@ -15,7 +15,7 @@ description: 方案设计阶段入口;聚合方案分级、任务拆分与方 ## 执行骨架 1. 加载 `references/design-rules.md`。 -2. 基于变更信号判定 `light/standard/full`。 +2. 基于变更信号判定 `light/standard/architecture`。 3. 按级别选取 `assets/` 中对应模板生成方案文件。 4. 产出任务清单并做粒度检查(每项可验证、依赖明确)。 5. 在正式 plan 包中补齐评分区块,并输出带评分的方案摘要,格式参考 `assets/output-summary.md`。 diff --git a/skills/zh/skills/sopify/design/assets/background-template.md b/skills/zh/skills/sopify/design/assets/background-template.md deleted file mode 100644 index 1df45ee4..00000000 --- a/skills/zh/skills/sopify/design/assets/background-template.md +++ /dev/null @@ -1,24 +0,0 @@ -# 变更提案: {功能名称} - -## 需求背景 -{描述现状、痛点及变更驱动因素} - -评分: -- 方案质量: {X}/10 -- 落地就绪: {Y}/10 - -评分理由: -- 优点: {1 行} -- 扣分: {1 行} - -## 变更内容 -1. {变更点1} -2. {变更点2} - -## 影响范围 -- 模块: {列表} -- 文件: {列表} - -## 风险评估 -- 风险: {描述} -- 缓解: {措施} diff --git a/skills/zh/skills/sopify/design/assets/plan-light-template.md b/skills/zh/skills/sopify/design/assets/plan-light-template.md deleted file mode 100644 index 285640ff..00000000 --- a/skills/zh/skills/sopify/design/assets/plan-light-template.md +++ /dev/null @@ -1,24 +0,0 @@ -# {功能名称} - -## 背景 -{1-2 句话描述需求背景} - -评分: -- 方案质量: {X}/10 -- 落地就绪: {Y}/10 - -评分理由: -- 优点: {1 行} -- 扣分: {1 行} - -## 方案 -{技术方案要点,列表形式} - -## 任务 -- [ ] {任务1} -- [ ] {任务2} -- [ ] {任务3} - -## 变更文件 -- {file1} -- {file2} diff --git a/skills/zh/skills/sopify/design/assets/plan-template.md b/skills/zh/skills/sopify/design/assets/plan-template.md new file mode 100644 index 00000000..1276933f --- /dev/null +++ b/skills/zh/skills/sopify/design/assets/plan-template.md @@ -0,0 +1,52 @@ +--- +title: {功能名称} +plan_id: {YYYYMMDD_feature} +status: planned +lifecycle_state: planned +level: {light|standard|architecture} +created: {YYYY-MM-DD} +updated: {YYYY-MM-DD} +archive_ready: false +--- + +# {功能名称} + +评分: +- 方案质量: {X}/10 +- 落地就绪: {Y}/10 + +评分理由: +- 优点: {1 行} +- 扣分: {1 行} + +## Context / Why + +{为什么要做} + +## Scope + +{本次包含什么} + +## Approach + +{最小可行方法} + +## Waves / Steps + +- [ ] {步骤 1} + +## Key Decisions + +- {已确认决策} + +## Constraints / Not-in-scope + +- {明确不做什么} + +## Status / Progress + +- [ ] {当前状态} + +## Next + +{下一步或等待确认事项} diff --git a/skills/zh/skills/sopify/design/references/design-rules.md b/skills/zh/skills/sopify/design/references/design-rules.md index 9c4b04d8..8d32456b 100644 --- a/skills/zh/skills/sopify/design/references/design-rules.md +++ b/skills/zh/skills/sopify/design/references/design-rules.md @@ -6,7 +6,7 @@ ## 总流程 -1. 判定方案包级别(`light/standard/full`)。 +1. 判定方案包级别(`light/standard/architecture`)。 2. 生成方案文件骨架。 3. 拆分任务并标注验证标准。 4. 输出摘要并等待宿主后续动作。 @@ -17,16 +17,15 @@ - `light`:文件数 3-5,且无架构级变更,修改范围明确。 - `standard`:文件数 >5,或新功能开发,或跨模块改动。 -- `full`:架构级变更、重大重构、新系统设计。 +- `architecture`:架构级变更、重大重构、新系统设计。 ## 步骤 2:生成方案文件 - `light`:生成 `plan.md`。 -- `standard`:生成 `background.md + design.md + tasks.md`。 -- `full`:在 standard 基础上补 `adr/` 与 `diagrams/`。 -- 正式 plan 包默认带评分区块: - - `light`:写入 `plan.md` - - `standard/full`:写入 `background.md` +- `standard`:生成 `plan.md + tasks.md`。 +- `architecture`:生成 `plan.md + tasks.md + design.md`。 +- 所有正式 plan 包都在 `plan.md` 中写入 `level` frontmatter、统一语义入口和评分区块。 +- ADR、diagram、assets、receipts 仅在真实需要时创建,不是任何级别的空目录或必备文件。 - 方案摘要也必须显式输出: - `方案质量` - `落地就绪` @@ -34,11 +33,10 @@ 模板来源统一使用 `assets/` 目录: -1. `assets/plan-light-template.md` -2. `assets/background-template.md` +1. `assets/plan-template.md` +2. `assets/tasks-template.md` 3. `assets/design-template.md` -4. `assets/tasks-template.md` -5. `assets/adr-template.md` +4. `assets/adr-template.md`(仅在确有架构决策时使用) ## 步骤 3:任务拆分 diff --git a/skills/zh/skills/sopify/design/scripts/select_plan_level.py b/skills/zh/skills/sopify/design/scripts/select_plan_level.py index 0c4f6634..dafba8af 100644 --- a/skills/zh/skills/sopify/design/scripts/select_plan_level.py +++ b/skills/zh/skills/sopify/design/scripts/select_plan_level.py @@ -26,7 +26,7 @@ def main() -> int: reasons: List[str] = [] if args.architecture_change or args.major_refactor or args.new_system: - level = "full" + level = "architecture" if args.architecture_change: reasons.append("architecture_change") if args.major_refactor: diff --git a/skills/zh/skills/sopify/develop/references/develop-rules.md b/skills/zh/skills/sopify/develop/references/develop-rules.md index b626c320..bab0cb66 100644 --- a/skills/zh/skills/sopify/develop/references/develop-rules.md +++ b/skills/zh/skills/sopify/develop/references/develop-rules.md @@ -8,10 +8,9 @@ 1. 读取任务清单。 2. 对每个任务按固定质量循环执行:实现修改 -> 发现验证 -> 执行验证 -> 必要时一次重试 -> 两阶段复审。仅在质量结果满足最小 contract 后更新任务状态。 -3. 若工作区存在 `post_develop` advisory skill 且前置条件满足,执行一次 post-develop advisory review。 -4. 按 `knowledge_sync` 同步知识库与偏好信息。 -5. 迁移方案包到 `history/`。 -6. 输出执行结果摘要。 +3. 按 `knowledge_sync` 同步知识库与偏好信息。 +4. 迁移方案包到 `history/`。 +5. 输出执行结果摘要。 ## 步骤 1:读取任务清单 @@ -159,24 +158,7 @@ Stage B `code_quality` 至少检查: 3. 复审结论行是否存在:success 必须有 `spec_compliance` + `code_quality` 各一句依据。 4. footer 是否完整:`Changes:` + `Next:` 必须存在。 -## 步骤 3:Post-develop advisory review - -开发任务完成、验证与两阶段复审通过后,若工作区 `.agents/skills/` 下存在 triggers 包含 `post_develop` 且 mode 为 `advisory` 的技能,可按其 SKILL.md 执行一次 post-develop advisory review。当前仅 CrossReview Phase 4a 纳入此路径。 - -触发条件: - -1. 所有任务已通过质量循环(Step 2 完成)。 -2. 工作区存在未评审的代码变更(已提交的 review range `git diff ..HEAD` 非空,或存在未提交变更须按 advisory skill Step 0 确认处理)。 -3. 对应 advisory skill 的前置条件满足(如 CLI 已安装,且 host-integrated 审查所需的隔离执行上下文可用)。 - -执行约束: - -- 执行失败或结果 `inconclusive` 不阻断主流程。 -- `concerns` / `needs_human_triage` 只展示并等待用户决定,不自动写 checkpoint、不自动改代码。 -- 若前置条件不满足(如 CLI 未安装),跳过并记录原因,不阻断。 -- 注释:Phase 4a 仅采用 Convention 模式;这里不引入 `bridge.py` 或 `pipeline_hooks`。 - -## 步骤 4:知识库同步 +## 步骤 3:知识库同步 同步时机: @@ -210,7 +192,7 @@ Stage B `code_quality` 至少检查: - 上下文不完整的猜测。 - 与任务无关的泛化结论。 -## 步骤 5:方案迁移 +## 步骤 4:方案迁移 迁移路径: diff --git a/skills/zh/skills/sopify/templates/SKILL.md b/skills/zh/skills/sopify/templates/SKILL.md index 655f57b3..9005971a 100644 --- a/skills/zh/skills/sopify/templates/SKILL.md +++ b/skills/zh/skills/sopify/templates/SKILL.md @@ -138,84 +138,49 @@ description: 文档模板集合;创建文档时读取;包含所有知识库 ## A2 | 方案文件模板 -### Light 级别 - plan.md +所有级别都先生成带 `level` frontmatter 的 `plan.md`;standard 再增加 `tasks.md`,architecture 再增加 `design.md`。支持性 ADR、图和证据按需创建,不预建空目录。 -```markdown -# {功能名称} - -## 背景 -{1-2 句话描述需求背景} - -评分: -- 方案质量: {X}/10 -- 落地就绪: {Y}/10 - -评分理由: -- 优点: {1 行} -- 扣分: {1 行} - -## 方案 -- {技术方案要点1} -- {技术方案要点2} - -## 任务 -- [ ] {任务1} -- [ ] {任务2} - -## 变更文件 -- {file1} -- {file2} -``` - -### Standard 级别 - background.md +### 所有级别 - plan.md ```markdown -# 变更提案: {功能名称} +--- +title: {功能名称} +plan_id: {YYYYMMDD_feature} +level: {light|standard|architecture} +--- -## 需求背景 -{描述现状、痛点及变更驱动因素} +# {功能名称} 评分: - 方案质量: {X}/10 - 落地就绪: {Y}/10 -评分理由: -- 优点: {1 行} -- 扣分: {1 行} - -## 变更内容 -1. {变更点1} -2. {变更点2} +## Context / Why +{为什么要做} -## 影响范围 -- 模块: {列表} -- 文件: {列表} +## Scope +{本次包含什么} -## 风险评估 -- 风险: {描述} -- 缓解: {措施} -``` +## Approach +{最小可行方法} -### Standard 级别 - design.md +## Waves / Steps +- [ ] {步骤 1} -```markdown -# 技术设计: {功能名称} +## Key Decisions +- {已确认决策} -## 技术方案 -- 核心技术: {语言/框架/库} -- 实现要点: - - {要点1} - - {要点2} +## Constraints / Not-in-scope +- {明确不做什么} -## 架构设计 -{如有架构变更} +## Status / Progress +- [ ] {当前状态} -## 安全与性能 -- 安全: {措施} -- 性能: {优化} +## Next +{下一步或等待确认事项} ``` -### Standard 级别 - tasks.md +### Standard / Architecture - tasks.md ```markdown # 任务清单: {功能名称} @@ -233,22 +198,19 @@ description: 文档模板集合;创建文档时读取;包含所有知识库 - [ ] 3.1 更新 `project.md / blueprint/background.md / blueprint/design.md / blueprint/tasks.md` ``` -### Full 级别 - adr/{NNN}-{title}.md +### Architecture - design.md ```markdown -# ADR-{NNN}: {决策标题} - -## 状态 -已采纳 | 待定 | 已废弃 +# 技术设计: {功能名称} -## 日期 -{YYYY-MM-DD} +## 技术方案 +- {实现要点} -## 上下文 -{背景和问题描述} +## 架构边界 +{模块关系与责任边界} -## 决策 -{核心决策内容} +## 验证策略 +- {验证方式} ``` ## A3 | 任务状态符号 diff --git a/sopify_contracts/__init__.py b/sopify_contracts/__init__.py index aa4c5d81..c6076c16 100644 --- a/sopify_contracts/__init__.py +++ b/sopify_contracts/__init__.py @@ -5,7 +5,14 @@ """ from .artifacts import KbArtifact, PlanArtifact -from .core import ExecutionGate, ExecutionSummary, RouteDecision, RunState, RuntimeConfig, SkillMeta +from .core import ( + ExecutionGate, + ExecutionSummary, + RouteDecision, + RunState, + RuntimeConfig, + SkillMeta, +) from .decision import ( ClarificationState, DecisionCheckpoint, @@ -20,6 +27,7 @@ ) from .handoff import RecoveredContext, RuntimeHandoff, RuntimeResult, SkillActivation from .proposal import PlanProposalState +from .plan_package import PLAN_FILES_BY_LEVEL, PlanPackageSnapshot, inspect_plan_package __all__ = [ "ClarificationState", @@ -37,6 +45,8 @@ "KbArtifact", "PlanArtifact", "PlanProposalState", + "PLAN_FILES_BY_LEVEL", + "PlanPackageSnapshot", "RecoveredContext", "RouteDecision", "RunState", @@ -45,4 +55,5 @@ "RuntimeResult", "SkillActivation", "SkillMeta", + "inspect_plan_package", ] diff --git a/sopify_contracts/plan_package.py b/sopify_contracts/plan_package.py new file mode 100644 index 00000000..3f8e26d8 --- /dev/null +++ b/sopify_contracts/plan_package.py @@ -0,0 +1,115 @@ +"""Deterministic contract for Sopify managed-plan packages.""" + +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +from pathlib import Path +import re + + +PLAN_FILES_BY_LEVEL: dict[str, tuple[str, ...]] = { + "light": ("plan.md",), + "standard": ("plan.md", "tasks.md"), + "architecture": ("plan.md", "tasks.md", "design.md"), +} +_SEMANTIC_FILENAMES = frozenset({"plan.md", "tasks.md", "design.md", "background.md"}) +_FRONTMATTER_LEVEL_RE = re.compile(r"^level\s*:\s*(.+?)\s*$") + + +@dataclass(frozen=True) +class PlanPackageSnapshot: + """Objective structure and version facts for one plan directory.""" + + valid: bool + level: str | None + version: str | None + files: tuple[str, ...] + error: str | None + + def to_dict(self) -> dict[str, object]: + return { + "valid": self.valid, + "level": self.level, + "version": self.version, + "error": self.error, + } + + +def _read_level(plan_md: Path) -> tuple[str | None, str | None]: + try: + lines = plan_md.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError) as exc: + return None, f"cannot read plan.md: {exc}" + + if not lines or lines[0].strip() != "---": + return None, "plan.md must start with YAML frontmatter" + + levels: list[str] = [] + for line in lines[1:]: + if line.strip() == "---": + break + match = _FRONTMATTER_LEVEL_RE.match(line) + if match: + levels.append(match.group(1).strip().strip("'\"")) + else: + return None, "plan.md frontmatter is not closed" + + if len(levels) != 1: + return None, "plan.md frontmatter must contain exactly one level" + level = levels[0] + if level not in PLAN_FILES_BY_LEVEL: + expected = ", ".join(PLAN_FILES_BY_LEVEL) + return None, f"unsupported plan level {level!r}; expected {expected}" + return level, None + + +def _calculate_version(plan_dir: Path, filenames: tuple[str, ...]) -> str: + digest = sha256() + for filename in filenames: + raw = (plan_dir / filename).read_bytes() + path_bytes = filename.encode("utf-8") + # Length framing keeps path/content boundaries deterministic and unambiguous. + digest.update(len(path_bytes).to_bytes(8, "big")) + digest.update(path_bytes) + digest.update(len(raw).to_bytes(8, "big")) + digest.update(raw) + return f"sha256:{digest.hexdigest()}" + + +def inspect_plan_package(plan_dir: Path) -> PlanPackageSnapshot: + """Validate a plan package and compute its semantic version when valid.""" + if not plan_dir.is_dir(): + return PlanPackageSnapshot(False, None, None, (), "plan directory not found") + + plan_md = plan_dir / "plan.md" + if not plan_md.is_file(): + return PlanPackageSnapshot(False, None, None, (), "missing plan.md") + + level, level_error = _read_level(plan_md) + if level_error is not None or level is None: + return PlanPackageSnapshot(False, level, None, (), level_error) + + expected = PLAN_FILES_BY_LEVEL[level] + present_semantic = tuple( + name for name in sorted(_SEMANTIC_FILENAMES) if (plan_dir / name).is_file() + ) + missing = [name for name in expected if name not in present_semantic] + extra = [name for name in present_semantic if name not in expected] + if missing or extra: + details: list[str] = [] + if missing: + details.append(f"missing semantic files: {', '.join(missing)}") + if extra: + details.append(f"unexpected semantic files: {', '.join(extra)}") + return PlanPackageSnapshot( + False, level, None, present_semantic, "; ".join(details) + ) + + try: + version = _calculate_version(plan_dir, expected) + except OSError as exc: + return PlanPackageSnapshot( + False, level, None, expected, f"cannot read plan files: {exc}" + ) + return PlanPackageSnapshot(True, level, version, expected, None) diff --git a/sopify_writer/store.py b/sopify_writer/store.py index 4513f67a..74f28bf7 100644 --- a/sopify_writer/store.py +++ b/sopify_writer/store.py @@ -16,9 +16,7 @@ history/// receipt.md Auditable Markdown receipt at finalize time. -Boundary: ProtocolStore writes files into existing directories. It does not -move, delete, or archive plan directories. Archive lifecycle is outside -scope (deferred to W3/W3.6 blueprint sync). +Finalize moves a validated plan package into history before clearing state. """ from __future__ import annotations @@ -28,12 +26,14 @@ from pathlib import Path from typing import Any, Mapping, Optional, Sequence -from sopify_contracts import RuntimeHandoff +from sopify_contracts import RuntimeHandoff, inspect_plan_package from .invariants import InvariantViolationError 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)$") +_PLAN_ID_RE = re.compile(r"^[a-zA-Z0-9_]+$") +_HISTORY_MONTH_RE = re.compile(r"^\d{4}-(?:0[1-9]|1[0-2])$") class ProtocolStore: @@ -55,6 +55,14 @@ def _receipt_path(self, plan_id: str, receipt_id: str) -> Path: def _history_receipt_path(self, plan_id: str, month: str) -> Path: return self.root / "history" / month / plan_id / "receipt.md" + @staticmethod + def _validated_plan_id(plan_id: str) -> str: + if not isinstance(plan_id, str) or not _PLAN_ID_RE.fullmatch(plan_id): + raise InvariantViolationError( + "plan_id must contain only letters, numbers, and underscores" + ) + return plan_id + def _ensure_state_dir(self) -> None: self.state_dir.mkdir(parents=True, exist_ok=True) @@ -66,8 +74,7 @@ def get_active_plan(self) -> Optional[dict]: def set_active_plan(self, *, plan_id: str) -> None: """Write active_plan.json with a minimal plan_id pointer.""" - if not plan_id or not plan_id.strip(): - raise InvariantViolationError("plan_id must be non-empty") + plan_id = self._validated_plan_id(plan_id) self._ensure_state_dir() write_json(self.active_plan_path, {"plan_id": plan_id}) @@ -83,14 +90,17 @@ def get_current_handoff(self) -> Optional[RuntimeHandoff]: def set_current_handoff(self, handoff: RuntimeHandoff) -> None: """Write current_handoff.json with observability metadata injection.""" + self._validated_plan_id(handoff.plan_id) self._ensure_state_dir() payload = handoff.to_dict() observability = dict(payload.get("observability") or {}) - observability.update({ - "state_kind": "current_handoff", - "writer": "sopify_writer", - "written_at": iso_now(), - }) + observability.update( + { + "state_kind": "current_handoff", + "writer": "sopify_writer", + "written_at": iso_now(), + } + ) payload["observability"] = observability write_json(self.current_handoff_path, payload) @@ -108,11 +118,14 @@ def write_plan_receipt( verdict: str, evidence: Optional[Mapping[str, Any]] = None, provenance: Optional[Mapping[str, Any]] = None, + expected_plan_version: Optional[str] = None, ) -> Path: """Write a plan receipt to plan//receipts/.json. Validates: - receipt_id matches ^(exec_\\d{3}|verify_\\d{3}|final)$ + - the plan package is structurally valid + - expected/provenance plan versions match the current package - provenance.plan_id matches the plan_id argument if present - provenance.receipt_id matches the receipt_id argument if present - verdict is non-empty @@ -120,8 +133,7 @@ def write_plan_receipt( Returns the path of the written receipt file. """ - if not plan_id or not plan_id.strip(): - raise InvariantViolationError("plan_id must be non-empty") + plan_id = self._validated_plan_id(plan_id) if not _RECEIPT_ID_RE.match(receipt_id): raise InvariantViolationError( f"receipt_id {receipt_id!r} does not match " @@ -130,6 +142,21 @@ def write_plan_receipt( if not verdict or not verdict.strip(): raise InvariantViolationError("verdict must be non-empty") + plan_dir = self.root / "plan" / plan_id + snapshot = inspect_plan_package(plan_dir) + if not snapshot.valid or snapshot.version is None: + raise InvariantViolationError( + f"invalid plan package {plan_id!r}: {snapshot.error or 'unknown error'}" + ) + if ( + expected_plan_version is not None + and expected_plan_version != snapshot.version + ): + raise InvariantViolationError( + f"expected_plan_version {expected_plan_version!r} does not match " + f"current plan_version {snapshot.version!r}" + ) + prov: dict[str, Any] = dict(provenance) if provenance else {} if "plan_id" in prov and prov["plan_id"] != plan_id: raise InvariantViolationError( @@ -141,8 +168,14 @@ def write_plan_receipt( f"provenance.receipt_id {prov['receipt_id']!r} conflicts " f"with receipt_id {receipt_id!r}" ) + if "plan_version" in prov and prov["plan_version"] != snapshot.version: + raise InvariantViolationError( + f"provenance.plan_version {prov['plan_version']!r} conflicts " + f"with current plan_version {snapshot.version!r}" + ) prov["plan_id"] = plan_id prov["receipt_id"] = receipt_id + prov["plan_version"] = snapshot.version payload = { "verdict": verdict, @@ -151,7 +184,7 @@ def write_plan_receipt( "timestamp": iso_now(), } - receipt_path = self._receipt_path(plan_id, receipt_id) + receipt_path = plan_dir / "receipts" / f"{receipt_id}.json" write_json_exclusive(receipt_path, payload) return receipt_path @@ -165,6 +198,7 @@ def write_history_receipt( summary: str, key_decisions: Sequence[str], month: Optional[str] = None, + plan_version: Optional[str] = None, ) -> Path: """Write a history receipt Markdown to history///receipt.md. @@ -175,25 +209,69 @@ def write_history_receipt( Returns the path of the written receipt file. """ - if not plan_id or not plan_id.strip(): - raise InvariantViolationError("plan_id must be non-empty") + month = self._validated_history_month( + plan_id=plan_id, + outcome=outcome, + summary=summary, + key_decisions=key_decisions, + month=month, + ) + content = self._render_history_receipt( + plan_id=plan_id, + outcome=outcome, + summary=summary, + key_decisions=key_decisions, + plan_version=plan_version, + ) + + receipt_path = self._history_receipt_path(plan_id, month) + receipt_path.parent.mkdir(parents=True, exist_ok=True) + receipt_path.write_text(content, encoding="utf-8") + return receipt_path + + @staticmethod + def _validated_history_month( + *, + plan_id: str, + outcome: str, + summary: str, + key_decisions: Sequence[str], + month: Optional[str], + ) -> str: + ProtocolStore._validated_plan_id(plan_id) if not outcome or not outcome.strip(): raise InvariantViolationError("outcome must be non-empty") if not summary or not summary.strip(): raise InvariantViolationError("summary must be non-empty") if not key_decisions or not all(d.strip() for d in key_decisions): - raise InvariantViolationError("key_decisions must have at least one non-empty item") - - if month is None: - month = datetime.now(timezone.utc).strftime("%Y-%m") - if not month.strip(): - raise InvariantViolationError("month must be non-empty") - - decisions_text = "\n".join(f"- {d}" for d in key_decisions) - content = ( + raise InvariantViolationError( + "key_decisions must have at least one non-empty item" + ) + resolved_month = ( + datetime.now(timezone.utc).strftime("%Y-%m") if month is None else month + ) + if not isinstance(resolved_month, str) or not _HISTORY_MONTH_RE.fullmatch( + resolved_month + ): + raise InvariantViolationError("month must match YYYY-MM") + return resolved_month + + @staticmethod + def _render_history_receipt( + *, + plan_id: str, + outcome: str, + summary: str, + key_decisions: Sequence[str], + plan_version: Optional[str], + ) -> str: + decisions_text = "\n".join(f"- {decision}" for decision in key_decisions) + version_line = f"plan_version: {plan_version}\n" if plan_version else "" + return ( f"---\n" f"plan_id: {plan_id}\n" f"outcome: {outcome}\n" + f"{version_line}" f"---\n\n" f"# {outcome}\n\n" f"## Summary\n\n" @@ -202,11 +280,6 @@ def write_history_receipt( f"{decisions_text}\n" ) - receipt_path = self._history_receipt_path(plan_id, month) - receipt_path.parent.mkdir(parents=True, exist_ok=True) - receipt_path.write_text(content, encoding="utf-8") - return receipt_path - # -- Finalize (write final receipt + history receipt + clear state) -- def finalize_plan( @@ -219,39 +292,99 @@ def finalize_plan( evidence: Optional[Mapping[str, Any]] = None, provenance: Optional[Mapping[str, Any]] = None, month: Optional[str] = None, + expected_plan_version: Optional[str] = None, ) -> dict[str, Path]: - """Finalize a plan: write final receipt, history receipt, clear state. - - Performs three operations in order: - 1. Write plan//receipts/final.json - 2. Write history///receipt.md - 3. Clear active_plan.json and current_handoff.json - - Does not move or delete the plan directory. - - Returns a dict with keys 'final_receipt' and 'history_receipt' - pointing to the written file paths. - """ - final_receipt_path = self.write_plan_receipt( - plan_id=plan_id, - receipt_id="final", - verdict="finalized", - evidence=evidence, - provenance=provenance, - ) - history_receipt_path = self.write_history_receipt( + """Finalize and archive one valid plan, then clear its runtime state.""" + resolved_month = self._validated_history_month( plan_id=plan_id, outcome=outcome, summary=summary, key_decisions=key_decisions, month=month, ) + source_dir = self.root / "plan" / plan_id + snapshot = inspect_plan_package(source_dir) + if not snapshot.valid or snapshot.version is None: + raise InvariantViolationError( + f"invalid plan package {plan_id!r}: {snapshot.error or 'unknown error'}" + ) + if ( + expected_plan_version is not None + and expected_plan_version != snapshot.version + ): + raise InvariantViolationError( + f"expected_plan_version {expected_plan_version!r} does not match " + f"current plan_version {snapshot.version!r}" + ) - # Clear state after both receipts are written successfully. + active_plan = self.get_active_plan() + if active_plan is not None and active_plan.get("plan_id") != plan_id: + raise InvariantViolationError( + f"active plan {active_plan.get('plan_id')!r} conflicts with " + f"finalize plan {plan_id!r}" + ) + handoff = self.get_current_handoff() + if handoff is not None and handoff.plan_id != plan_id: + raise InvariantViolationError( + f"handoff plan {handoff.plan_id!r} conflicts with finalize plan {plan_id!r}" + ) + + archive_dir = self.root / "history" / resolved_month / plan_id + if archive_dir.exists(): + raise FileExistsError(f"history plan already exists: {archive_dir}") + history_source = source_dir / "receipt.md" + if history_source.exists(): + raise FileExistsError( + f"history receipt already exists in plan: {history_source}" + ) + + final_source = self._receipt_path(plan_id, "final") + created_final = False + created_history = False + try: + self.write_plan_receipt( + plan_id=plan_id, + receipt_id="final", + verdict="finalized", + evidence=evidence, + provenance=provenance, + expected_plan_version=snapshot.version, + ) + created_final = True + history_content = self._render_history_receipt( + plan_id=plan_id, + outcome=outcome, + summary=summary, + key_decisions=key_decisions, + plan_version=snapshot.version, + ) + history_source.write_text( + history_content, encoding="utf-8", errors="strict" + ) + created_history = True + final_snapshot = inspect_plan_package(source_dir) + if not final_snapshot.valid or final_snapshot.version != snapshot.version: + raise InvariantViolationError( + "plan package changed while finalize evidence was being written" + ) + archive_dir.parent.mkdir(parents=True, exist_ok=True) + source_dir.rename(archive_dir) + except Exception: + # Generated finalize evidence is safe to remove; semantic plan files remain intact. + if created_history: + history_source.unlink(missing_ok=True) + if created_final: + final_source.unlink(missing_ok=True) + raise + + final_receipt_path = archive_dir / "receipts" / "final.json" + history_receipt_path = archive_dir / "receipt.md" + # Runtime pointers are cleared only after the full directory is archived. self.clear_active_plan() self.clear_current_handoff() return { "final_receipt": final_receipt_path, "history_receipt": history_receipt_path, + "archive_dir": archive_dir, } diff --git a/tests/golden-snapshots.json b/tests/golden-snapshots.json index 6cd28c5d..a67d3c53 100644 --- a/tests/golden-snapshots.json +++ b/tests/golden-snapshots.json @@ -8,13 +8,13 @@ }, "protocol_version": "1.0", "snapshots": { - "codex:zh-CN:header": "33ca71635a75c7d0d5e3c8fb9d04e29801d5dc11db33e692d35f08abab591c83", - "codex:en-US:header": "7eea0751d5d963df829f971a05f428362962cd3394e3cc770e7b32b11d29207c", - "claude:zh-CN:header": "05367ec7fa2aaa7e48f43527df2036c5677d0f9e2a0458310f0f1f726757d48a", - "claude:en-US:header": "eadba1e63dcc8deac52b2be99570aefe76894450ee1efc112fcd2930a7a93017", - "copilot:zh-CN:managed_block_payload": "70331f91da6153037762d2ce1bf06c1f2a20889259c6ec4b27b380e664c66f53", - "skills:zh-CN:tree": "41deccef82565834dcc8cfb6d81ec74db275a301356373fefe5b06bc0868009b", - "copilot:en-US:managed_block_payload": "606fd1d3392a076acf991d6b762d571668c698eeccff7f1f1b9b024843fe082a", - "skills:en-US:tree": "7bbce298fb80c319978a0180e3a28a10d5a48bb27a3f309f126de13707865fb6" + "codex:zh-CN:header": "0ec1f813b0796eb3cd8eff5ac1d4ba9d1a5a8d7594d8ea1b85f316926320b787", + "codex:en-US:header": "e687af9bbca32a5cdcf204bd5a8819d9535a37a999342a12c0ead46ebfeef2bc", + "claude:zh-CN:header": "fb9a6b664a3bd16192e7b75515874fdd06148a466fb31d475802ac2fc4246ba5", + "claude:en-US:header": "ad2e8ae091bfe2a0615d2f03a4347ed0bb324e9a26c87cc076b428d4dcb7db16", + "copilot:zh-CN:managed_block_payload": "fae89a10b6d8a7efe568ae9c520cc8d2477a49c3c1ac1b1650993a97494199ef", + "skills:zh-CN:tree": "fd0221eb7bd60e381c440381dbfdaf547a5c06d8de518be51ce55b7a4d8514f1", + "copilot:en-US:managed_block_payload": "b95b3c0e7e2610c67b5aa4672c524d8cc7d8c844a90bb166385b5afaf326ef00", + "skills:en-US:tree": "feef68ee74cd1d1e851d368bef0a741569d3063a6b08aec3837e213d6b311253" } } diff --git a/tests/test_installer.py b/tests/test_installer.py index b04c5a72..22cf62eb 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -21,12 +21,13 @@ _CN_PREFLIGHT_CONTRACT_SENTENCES = ( "consult / quick_fix 不自动接续 active plan,也不展示无关状态异常。", "MCP 只可选聚合客观事实,不可用时仍按文件协议继续。", + "active plan 只有在 level 对应语义文件精确成立时才有效:light 为 `plan.md`,standard 再含 `tasks.md`,architecture 再含 `design.md`。", "无效旧指针遇到明确 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 不变。", + "显式审计非 active plan 时,审计器的语义审查对目标只读;宿主可按用户选定审计器的官方流程执行受限取证和独立报告生成,校验目标 `plan_version` 后通过 writer 写 `verify_NNN` receipt,active pointer / handoff 不变。", "session 标识只作 provenance,不单独阻断。", - "仅当用户明确要求同时开发、宿主确认另一任务仍在运行,或写入前出现非本轮已知的目标 plan digest / 匹配 handoff 变化时,才停止当前有副作用的开发", + "仅当用户明确要求同时开发、宿主确认另一任务仍在运行,或写入前出现非本轮已知的目标 `plan_version` / 匹配 handoff 变化时,才停止当前有副作用的开发", "并只提示一次:`方案状态出现并行推进信号,本会话暂不写入。你可以继续只读审计,或确认其他开发已停止后在这里继续。`", "用户确认后,先重读最新方案状态再继续。", ) @@ -34,12 +35,13 @@ _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.", + "An active plan is valid only when its level has the exact semantic files: light has `plan.md`, standard adds `tasks.md`, and architecture adds `design.md`.", "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.", + "For an explicit audit of a non-active plan, the verifier's semantic review stays read-only on the target; the host may follow the user-selected verifier's official workflow for bounded evidence collection and independent report generation, then revalidates the target `plan_version` and 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.", + "Stop side-effecting development only when the user explicitly requests simultaneous development, the host confirms another task is still running, or the target `plan_version` / 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.", ) diff --git a/tests/test_plan_contract_assets.py b/tests/test_plan_contract_assets.py new file mode 100644 index 00000000..5b3dae2f --- /dev/null +++ b/tests/test_plan_contract_assets.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from pathlib import Path +import json +import subprocess +import sys +import tempfile +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class PlanContractAssetTests(unittest.TestCase): + def test_bilingual_sources_share_current_plan_levels(self) -> None: + for language in ("zh", "en"): + with self.subTest(language=language): + language_root = REPO_ROOT / "skills" / language + header = (language_root / "header.md.template").read_text( + encoding="utf-8" + ) + design_rules = ( + language_root + / "skills" + / "sopify" + / "design" + / "references" + / "design-rules.md" + ).read_text(encoding="utf-8") + templates = ( + language_root / "skills" / "sopify" / "templates" / "SKILL.md" + ).read_text(encoding="utf-8") + + for content in (header, design_rules, templates): + self.assertIn("light", content) + self.assertIn("standard", content) + self.assertIn("architecture", content) + self.assertNotIn("light/standard/full", header) + self.assertNotIn("background.md + design.md + tasks.md", design_rules) + self.assertNotIn("Full Level", templates) + self.assertNotIn("Full 级别", templates) + + def test_design_assets_match_the_three_level_file_contract(self) -> None: + for language in ("zh", "en"): + with self.subTest(language=language): + assets = ( + REPO_ROOT + / "skills" + / language + / "skills" + / "sopify" + / "design" + / "assets" + ) + self.assertTrue((assets / "plan-template.md").is_file()) + self.assertTrue((assets / "tasks-template.md").is_file()) + self.assertTrue((assets / "design-template.md").is_file()) + self.assertFalse((assets / "plan-light-template.md").exists()) + self.assertFalse((assets / "background-template.md").exists()) + + def test_bilingual_selectors_return_architecture(self) -> None: + for language in ("zh", "en"): + with self.subTest(language=language): + script = ( + REPO_ROOT + / "skills" + / language + / "skills" + / "sopify" + / "design" + / "scripts" + / "select_plan_level.py" + ) + result = subprocess.run( + [sys.executable, str(script), "--file-count", "2", "--new-system"], + check=True, + capture_output=True, + text=True, + ) + self.assertEqual( + json.loads(result.stdout)["plan_level"], "architecture" + ) + + def test_rendered_host_headers_keep_the_same_contract(self) -> None: + render_script = REPO_ROOT / "scripts" / "render-host-skills.py" + for language in ("zh", "en"): + for host in ("codex", "claude", "qoder", "copilot"): + with ( + self.subTest(language=language, host=host), + tempfile.TemporaryDirectory() as temp_dir, + ): + output = Path(temp_dir) / "host.md" + subprocess.run( + [ + sys.executable, + str(render_script), + "--hosts-file", + str(REPO_ROOT / "skills" / "hosts.yaml"), + "--skills-root", + str(REPO_ROOT / "skills"), + "--lang", + language, + "--host", + host, + "--output", + str(output), + ], + check=True, + capture_output=True, + text=True, + ) + rendered = output.read_text(encoding="utf-8") + self.assertIn("plan_version", rendered) + self.assertIn("architecture", rendered) + self.assertNotIn("light/standard/full", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plan_package.py b/tests/test_plan_package.py new file mode 100644 index 00000000..706a3062 --- /dev/null +++ b/tests/test_plan_package.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from sopify_contracts import PLAN_FILES_BY_LEVEL, inspect_plan_package + + +def _write_plan(plan_dir: Path, level: str) -> None: + plan_dir.mkdir(parents=True, exist_ok=True) + (plan_dir / "plan.md").write_text( + f"---\nlevel: {level}\n---\n\n# Test plan\n", + encoding="utf-8", + ) + for filename in PLAN_FILES_BY_LEVEL.get(level, ())[1:]: + (plan_dir / filename).write_text(f"# {filename}\n", encoding="utf-8") + + +class PlanPackageTests(unittest.TestCase): + def test_all_supported_levels_have_exact_semantic_files(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + for level, files in PLAN_FILES_BY_LEVEL.items(): + with self.subTest(level=level): + plan_dir = Path(temp_dir) / level + _write_plan(plan_dir, level) + + snapshot = inspect_plan_package(plan_dir) + + self.assertTrue(snapshot.valid) + self.assertEqual(snapshot.level, level) + self.assertEqual(snapshot.files, files) + self.assertRegex(snapshot.version or "", r"^sha256:[0-9a-f]{64}$") + + def test_missing_or_extra_semantic_file_is_invalid(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + missing_dir = Path(temp_dir) / "missing" + _write_plan(missing_dir, "standard") + (missing_dir / "tasks.md").unlink() + self.assertFalse(inspect_plan_package(missing_dir).valid) + + extra_dir = Path(temp_dir) / "extra" + _write_plan(extra_dir, "light") + (extra_dir / "background.md").write_text("legacy", encoding="utf-8") + self.assertFalse(inspect_plan_package(extra_dir).valid) + + def test_semantic_content_changes_version(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + plan_dir = Path(temp_dir) / "plan" + _write_plan(plan_dir, "architecture") + initial = inspect_plan_package(plan_dir).version + + (plan_dir / "tasks.md").write_text("# changed tasks\n", encoding="utf-8") + tasks_changed = inspect_plan_package(plan_dir).version + (plan_dir / "design.md").write_text("# changed design\n", encoding="utf-8") + design_changed = inspect_plan_package(plan_dir).version + + self.assertNotEqual(initial, tasks_changed) + self.assertNotEqual(tasks_changed, design_changed) + + def test_derived_artifacts_do_not_change_version(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + plan_dir = Path(temp_dir) / "plan" + _write_plan(plan_dir, "architecture") + initial = inspect_plan_package(plan_dir).version + + for relative_path in ( + "audits/plan/audit.json", + "receipts/verify_001.json", + "assets/note.txt", + ): + path = plan_dir / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(relative_path, encoding="utf-8") + + self.assertEqual(inspect_plan_package(plan_dir).version, initial) + + def test_level_is_required_in_frontmatter(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + plan_dir = Path(temp_dir) / "plan" + plan_dir.mkdir() + (plan_dir / "plan.md").write_text("# no frontmatter\n", encoding="utf-8") + + snapshot = inspect_plan_package(plan_dir) + + self.assertFalse(snapshot.valid) + self.assertIn("frontmatter", snapshot.error or "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sopify_mcp_server.py b/tests/test_sopify_mcp_server.py index 3f30af9d..19b13a56 100644 --- a/tests/test_sopify_mcp_server.py +++ b/tests/test_sopify_mcp_server.py @@ -7,7 +7,7 @@ import unittest from sopify_contracts import RuntimeHandoff -from sopify_writer import ProtocolStore +from sopify_writer import InvariantViolationError, ProtocolStore from scripts.sopify_mcp_server import ( _safe_tool, @@ -27,6 +27,10 @@ def _write_plan_md(path: Path) -> None: path.write_text( "\n".join( [ + "---", + "level: light", + "---", + "", "# Test Plan", "", "## Context / Why", @@ -124,6 +128,7 @@ def test_workspace_status_lite_reports_missing_sopify_root(self) -> None: self.assertFalse(status["active_plan_file_exists"]) self.assertIsNone(status["active_plan_dir_exists"]) self.assertIsNone(status["active_plan_md_exists"]) + self.assertIsNone(status["active_plan_package"]) self.assertFalse(status["handoff_exists"]) self.assertIsNone(status["handoff_plan_id"]) self.assertIsNone(status["handoff_matches_active_plan"]) @@ -149,6 +154,8 @@ def test_workspace_status_lite_reports_active_plan_directory(self) -> None: self.assertTrue(status["active_plan_file_exists"]) self.assertTrue(status["active_plan_dir_exists"]) self.assertFalse(status["active_plan_md_exists"]) + self.assertFalse(status["active_plan_package"]["valid"]) + self.assertEqual(status["active_plan_package"]["error"], "missing plan.md") self.assertFalse(status["handoff_exists"]) self.assertIsNone(status["handoff_plan_id"]) self.assertIsNone(status["handoff_matches_active_plan"]) @@ -168,6 +175,12 @@ def test_workspace_status_lite_reports_matching_handoff(self) -> None: status = workspace_status_lite(workspace) self.assertTrue(status["active_plan_md_exists"]) + self.assertTrue(status["active_plan_package"]["valid"]) + self.assertEqual(status["active_plan_package"]["level"], "light") + self.assertRegex( + status["active_plan_package"]["version"], + r"^sha256:[0-9a-f]{64}$", + ) self.assertEqual(status["handoff_plan_id"], plan_id) self.assertTrue(status["handoff_matches_active_plan"]) @@ -189,7 +202,10 @@ def test_workspace_status_lite_reports_mismatched_handoff(self) -> None: 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: + with ( + self.subTest(payload=payload), + tempfile.TemporaryDirectory() as temp_dir, + ): workspace = Path(temp_dir) sopify = workspace / ".sopify" _write_active_plan_payload(sopify, payload) @@ -254,6 +270,129 @@ def test_protocol_check_reports_invalid_scenario_without_throwing(self) -> None: self.assertEqual(result["verdict"], "FAIL") self.assertIn("Unsupported scenario", result["failures"][0]) + def test_protocol_check_rejects_incomplete_standard_package(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + plan_id = "plan_001" + sopify = workspace / ".sopify" + ProtocolStore(sopify).set_active_plan(plan_id=plan_id) + _write_plan_md(sopify / "plan" / plan_id / "plan.md") + plan_md = sopify / "plan" / plan_id / "plan.md" + plan_md.write_text( + plan_md.read_text(encoding="utf-8").replace( + "level: light", "level: standard" + ), + encoding="utf-8", + ) + + result = protocol_check(workspace, "new-plan") + + self.assertEqual(result["verdict"], "FAIL") + self.assertIn("missing semantic files: tasks.md", result["failures"][0]) + + def test_protocol_check_accepts_writer_finalize_archive(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + plan_id = "plan_001" + sopify = workspace / ".sopify" + store = ProtocolStore(sopify) + _write_plan_md(sopify / "plan" / plan_id / "plan.md") + store.set_active_plan(plan_id=plan_id) + store.finalize_plan( + plan_id=plan_id, + outcome="completed", + summary="Done.", + key_decisions=["Keep the plan contract small"], + month="2026-07", + ) + + result = protocol_check(workspace, "finalize") + + self.assertEqual(result["verdict"], "PASS") + self.assertEqual(result["failures"], []) + + def test_finalize_check_rejects_remaining_runtime_state(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + plan_id = "plan_001" + sopify = workspace / ".sopify" + store = ProtocolStore(sopify) + _write_plan_md(sopify / "plan" / plan_id / "plan.md") + store.finalize_plan( + plan_id=plan_id, + outcome="completed", + summary="Done.", + key_decisions=["Keep the plan contract small"], + month="2026-07", + ) + store.set_active_plan(plan_id="stale_001") + + result = protocol_check(workspace, "finalize") + + self.assertEqual(result["verdict"], "FAIL") + self.assertTrue( + any("active_plan.json" in failure for failure in result["failures"]) + ) + + def test_finalize_check_rejects_tampered_archived_plan(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + plan_id = "plan_001" + sopify = workspace / ".sopify" + store = ProtocolStore(sopify) + _write_plan_md(sopify / "plan" / plan_id / "plan.md") + result = store.finalize_plan( + plan_id=plan_id, + outcome="completed", + summary="Done.", + key_decisions=["Keep the plan contract small"], + month="2026-07", + ) + plan_md = result["archive_dir"] / "plan.md" + plan_md.write_text( + plan_md.read_text(encoding="utf-8") + "\nTampered.\n", + encoding="utf-8", + ) + + check = protocol_check(workspace, "finalize") + + self.assertEqual(check["verdict"], "FAIL") + self.assertTrue( + any( + "plan_version does not match" in failure + for failure in check["failures"] + ) + ) + + def test_finalize_check_does_not_recompute_legacy_history_plan(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + archive = workspace / ".sopify" / "history" / "2026-06" / "legacy_plan" + (archive / "receipts").mkdir(parents=True) + (archive / "plan.md").write_text( + "# Legacy plan without level frontmatter\n", encoding="utf-8" + ) + (archive / "receipt.md").write_text( + "# completed\n\n## Outcome\nDone.\n\n## Summary\nDone.\n\n" + "## Key Decisions\n- Preserve history.\n", + encoding="utf-8", + ) + (archive / "receipts" / "final.json").write_text( + json.dumps( + { + "verdict": "finalized", + "evidence": {}, + "provenance": {}, + "timestamp": "2026-06-01T00:00:00Z", + } + ), + encoding="utf-8", + ) + + result = protocol_check(workspace, "finalize") + + self.assertEqual(result["verdict"], "PASS") + def test_mcp_dependency_hint_pins_stable_sdk_line(self) -> None: hint = get_mcp_dependency_hint() self.assertIn("mcp[cli]>=1.27,<2", hint) @@ -285,6 +424,26 @@ def test_write_plan_receipt_writes_current_active_plan(self) -> None: self.assertEqual(payload["provenance"]["actor"], "test") self.assertEqual(payload["provenance"]["plan_id"], plan_id) self.assertEqual(payload["provenance"]["receipt_id"], "exec_001") + self.assertRegex( + payload["provenance"]["plan_version"], r"^sha256:[0-9a-f]{64}$" + ) + + def test_write_plan_receipt_rejects_stale_expected_version(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = Path(temp_dir) + plan_id = "plan_001" + sopify = workspace / ".sopify" + ProtocolStore(sopify).set_active_plan(plan_id=plan_id) + _write_plan_md(sopify / "plan" / plan_id / "plan.md") + + with self.assertRaises(InvariantViolationError): + write_plan_receipt( + workspace, + plan_id, + "exec_001", + "PASS", + expected_plan_version="sha256:stale", + ) def test_write_plan_receipt_rejects_missing_active_plan(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: diff --git a/tests/test_sopify_writer.py b/tests/test_sopify_writer.py index 853714a6..721d9d68 100644 --- a/tests/test_sopify_writer.py +++ b/tests/test_sopify_writer.py @@ -8,6 +8,7 @@ - Finalize: write final receipt + history receipt + clear state - No retired state files produced """ + from __future__ import annotations import json @@ -34,6 +35,20 @@ ) +def _write_plan_package(sopify_root: Path, plan_id: str, level: str = "light") -> Path: + plan_dir = sopify_root / "plan" / plan_id + plan_dir.mkdir(parents=True, exist_ok=True) + (plan_dir / "plan.md").write_text( + f"---\nlevel: {level}\n---\n\n# Test plan\n", + encoding="utf-8", + ) + if level in {"standard", "architecture"}: + (plan_dir / "tasks.md").write_text("# Tasks\n", encoding="utf-8") + if level == "architecture": + (plan_dir / "design.md").write_text("# Design\n", encoding="utf-8") + return plan_dir + + class ProtocolStoreActivePlanTests(unittest.TestCase): def test_set_active_plan_writes_plan_id_only(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -73,7 +88,7 @@ def test_clear_active_plan_is_idempotent(self) -> None: def test_set_active_plan_empty_plan_id_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: store = ProtocolStore(Path(temp_dir)) - for bad_id in ("", " "): + for bad_id in ("", " ", "../escape", "bad-id", "bad/name"): with self.subTest(plan_id=bad_id): with self.assertRaises(InvariantViolationError): store.set_active_plan(plan_id=bad_id) @@ -142,9 +157,14 @@ def test_clear_current_handoff_removes_file(self) -> None: class ProtocolStorePlanReceiptTests(unittest.TestCase): + def _store(self, temp_dir: str, plan_id: str = "plan_001") -> ProtocolStore: + store = ProtocolStore(Path(temp_dir)) + _write_plan_package(store.root, plan_id) + return store + def test_write_exec_receipt(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - store = ProtocolStore(Path(temp_dir)) + store = self._store(temp_dir) path = store.write_plan_receipt( plan_id="plan_001", receipt_id="exec_001", @@ -158,11 +178,14 @@ def test_write_exec_receipt(self) -> None: self.assertEqual(payload["evidence"], {"files_changed": 3}) self.assertEqual(payload["provenance"]["plan_id"], "plan_001") self.assertEqual(payload["provenance"]["receipt_id"], "exec_001") + self.assertRegex( + payload["provenance"]["plan_version"], r"^sha256:[0-9a-f]{64}$" + ) self.assertIn("timestamp", payload) def test_write_verify_receipt(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - store = ProtocolStore(Path(temp_dir)) + store = self._store(temp_dir) path = store.write_plan_receipt( plan_id="plan_001", receipt_id="verify_002", @@ -174,7 +197,7 @@ def test_write_verify_receipt(self) -> None: def test_existing_receipt_is_not_overwritten(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - store = ProtocolStore(Path(temp_dir)) + store = self._store(temp_dir) path = store.write_plan_receipt( plan_id="plan_001", receipt_id="verify_001", @@ -195,7 +218,7 @@ def test_existing_receipt_is_not_overwritten(self) -> None: def test_concurrent_receipt_writes_create_exactly_one_file(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - store = ProtocolStore(Path(temp_dir)) + store = self._store(temp_dir) barrier = Barrier(2) original_write = write_json_exclusive @@ -215,10 +238,13 @@ def write(source: str) -> str: return "exists" return "created" - with patch( - "sopify_writer.store.write_json_exclusive", - side_effect=synchronized_write, - ), ThreadPoolExecutor(max_workers=2) as pool: + 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"]) @@ -235,7 +261,7 @@ def write(source: str) -> str: def test_receipt_id_pattern_rejects_invalid(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - store = ProtocolStore(Path(temp_dir)) + store = self._store(temp_dir) for bad_id in ("exec_1", "exec_0001", "final_001", "EXEC_001", "", "foo"): with self.subTest(receipt_id=bad_id): with self.assertRaises(InvariantViolationError): @@ -247,8 +273,14 @@ def test_receipt_id_pattern_rejects_invalid(self) -> None: def test_receipt_id_pattern_accepts_valid(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - store = ProtocolStore(Path(temp_dir)) - for valid_id in ("exec_001", "exec_999", "verify_001", "verify_100", "final"): + store = self._store(temp_dir) + for valid_id in ( + "exec_001", + "exec_999", + "verify_001", + "verify_100", + "final", + ): with self.subTest(receipt_id=valid_id): path = store.write_plan_receipt( plan_id="plan_001", @@ -259,7 +291,7 @@ def test_receipt_id_pattern_accepts_valid(self) -> None: def test_empty_verdict_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - store = ProtocolStore(Path(temp_dir)) + store = self._store(temp_dir) with self.assertRaises(InvariantViolationError): store.write_plan_receipt( plan_id="plan_001", @@ -269,7 +301,7 @@ def test_empty_verdict_rejected(self) -> None: def test_empty_plan_id_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - store = ProtocolStore(Path(temp_dir)) + store = self._store(temp_dir) with self.assertRaises(InvariantViolationError): store.write_plan_receipt( plan_id="", @@ -279,7 +311,7 @@ def test_empty_plan_id_rejected(self) -> None: def test_provenance_plan_id_conflict_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - store = ProtocolStore(Path(temp_dir)) + store = self._store(temp_dir) with self.assertRaises(InvariantViolationError): store.write_plan_receipt( plan_id="plan_001", @@ -290,7 +322,7 @@ def test_provenance_plan_id_conflict_rejected(self) -> None: def test_provenance_receipt_id_conflict_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - store = ProtocolStore(Path(temp_dir)) + store = self._store(temp_dir) with self.assertRaises(InvariantViolationError): store.write_plan_receipt( plan_id="plan_001", @@ -301,7 +333,7 @@ def test_provenance_receipt_id_conflict_rejected(self) -> None: def test_provenance_passthrough_extra_fields(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: - store = ProtocolStore(Path(temp_dir)) + store = self._store(temp_dir) path = store.write_plan_receipt( plan_id="plan_001", receipt_id="exec_001", @@ -314,6 +346,32 @@ def test_provenance_passthrough_extra_fields(self) -> None: self.assertEqual(payload["provenance"]["host"], "codex") self.assertEqual(payload["provenance"]["plan_id"], "plan_001") + def test_expected_plan_version_conflict_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = self._store(temp_dir) + + with self.assertRaises(InvariantViolationError): + store.write_plan_receipt( + plan_id="plan_001", + receipt_id="exec_001", + verdict="pass", + expected_plan_version="sha256:stale", + ) + + def test_invalid_plan_package_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = self._store(temp_dir) + (store.root / "plan" / "plan_001" / "background.md").write_text( + "legacy", encoding="utf-8" + ) + + with self.assertRaises(InvariantViolationError): + store.write_plan_receipt( + plan_id="plan_001", + receipt_id="exec_001", + verdict="pass", + ) + class ProtocolStoreHistoryReceiptTests(unittest.TestCase): def test_write_history_receipt_default_month(self) -> None: @@ -345,7 +403,9 @@ def test_write_history_receipt_explicit_month(self) -> None: month="2026-06", ) - expected = Path(temp_dir) / "history" / "2026-06" / "plan_001" / "receipt.md" + expected = ( + Path(temp_dir) / "history" / "2026-06" / "plan_001" / "receipt.md" + ) self.assertEqual(path, expected) def test_empty_outcome_rejected(self) -> None: @@ -408,14 +468,16 @@ def test_empty_plan_id_rejected(self) -> None: def test_empty_month_rejected(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: store = ProtocolStore(Path(temp_dir)) - with self.assertRaises(InvariantViolationError): - store.write_history_receipt( - plan_id="plan_001", - outcome="completed", - summary="Done.", - key_decisions=["A"], - month="", - ) + for bad_month in ("", "2026-7", "2026-13", "../../escaped"): + with self.subTest(month=bad_month): + with self.assertRaises(InvariantViolationError): + store.write_history_receipt( + plan_id="plan_001", + outcome="completed", + summary="Done.", + key_decisions=["A"], + month=bad_month, + ) def test_multiple_key_decisions_rendered(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -436,6 +498,7 @@ def test_multiple_key_decisions_rendered(self) -> None: class ProtocolStoreFinalizeTests(unittest.TestCase): def _setup_active_plan(self, store: ProtocolStore, plan_id: str) -> None: + _write_plan_package(store.root, plan_id) store.set_active_plan(plan_id=plan_id) store.set_current_handoff( RuntimeHandoff( @@ -461,7 +524,9 @@ def test_finalize_writes_both_receipts_and_clears_state(self) -> None: # Final receipt written self.assertTrue(result["final_receipt"].exists()) - final_payload = json.loads(result["final_receipt"].read_text(encoding="utf-8")) + final_payload = json.loads( + result["final_receipt"].read_text(encoding="utf-8") + ) self.assertEqual(final_payload["verdict"], "finalized") self.assertEqual(final_payload["evidence"], {"waves_completed": 3}) self.assertEqual(final_payload["provenance"]["plan_id"], "finalize_001") @@ -479,6 +544,7 @@ def test_finalize_writes_both_receipts_and_clears_state(self) -> None: def test_finalize_clears_state_even_when_no_prior_state(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: store = ProtocolStore(Path(temp_dir)) + _write_plan_package(store.root, "no_state_001") result = store.finalize_plan( plan_id="no_state_001", @@ -496,6 +562,7 @@ def test_finalize_clears_state_even_when_no_prior_state(self) -> None: def test_finalize_receipt_paths(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: store = ProtocolStore(Path(temp_dir)) + _write_plan_package(store.root, "paths_001") result = store.finalize_plan( plan_id="paths_001", @@ -505,10 +572,103 @@ def test_finalize_receipt_paths(self) -> None: month="2026-06", ) - expected_final = Path(temp_dir) / "plan" / "paths_001" / "receipts" / "final.json" - expected_history = Path(temp_dir) / "history" / "2026-06" / "paths_001" / "receipt.md" + expected_final = ( + Path(temp_dir) + / "history" + / "2026-06" + / "paths_001" + / "receipts" + / "final.json" + ) + expected_history = ( + Path(temp_dir) / "history" / "2026-06" / "paths_001" / "receipt.md" + ) self.assertEqual(result["final_receipt"], expected_final) self.assertEqual(result["history_receipt"], expected_history) + self.assertEqual( + result["archive_dir"], + Path(temp_dir) / "history" / "2026-06" / "paths_001", + ) + + def test_finalize_version_conflict_preserves_plan_and_state(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = ProtocolStore(Path(temp_dir)) + self._setup_active_plan(store, "conflict_001") + + with self.assertRaises(InvariantViolationError): + store.finalize_plan( + plan_id="conflict_001", + outcome="completed", + summary="Done.", + key_decisions=["A"], + month="2026-06", + expected_plan_version="sha256:stale", + ) + + self.assertTrue((store.root / "plan" / "conflict_001").is_dir()) + self.assertIsNotNone(store.get_active_plan()) + self.assertIsNotNone(store.get_current_handoff()) + self.assertFalse( + (store.root / "history" / "2026-06" / "conflict_001").exists() + ) + + def test_finalize_rejects_history_path_escape(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = ProtocolStore(Path(temp_dir)) + self._setup_active_plan(store, "escape_001") + + with self.assertRaises(InvariantViolationError): + store.finalize_plan( + plan_id="escape_001", + outcome="completed", + summary="Done.", + key_decisions=["A"], + month="../../escaped", + ) + + self.assertTrue((store.root / "plan" / "escape_001").is_dir()) + self.assertIsNotNone(store.get_active_plan()) + self.assertFalse((store.root.parent / "escaped" / "escape_001").exists()) + + def test_finalize_archive_failure_preserves_plan_and_state(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = ProtocolStore(Path(temp_dir)) + self._setup_active_plan(store, "failure_001") + + with patch("pathlib.Path.rename", side_effect=OSError("rename failed")): + with self.assertRaises(OSError): + store.finalize_plan( + plan_id="failure_001", + outcome="completed", + summary="Done.", + key_decisions=["A"], + month="2026-06", + ) + + plan_dir = store.root / "plan" / "failure_001" + self.assertTrue(plan_dir.is_dir()) + self.assertFalse((plan_dir / "receipts" / "final.json").exists()) + self.assertFalse((plan_dir / "receipt.md").exists()) + self.assertIsNotNone(store.get_active_plan()) + self.assertIsNotNone(store.get_current_handoff()) + + def test_finalize_rejects_mismatched_active_plan(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + store = ProtocolStore(Path(temp_dir)) + _write_plan_package(store.root, "target_001") + store.set_active_plan(plan_id="other_001") + + with self.assertRaises(InvariantViolationError): + store.finalize_plan( + plan_id="target_001", + outcome="completed", + summary="Done.", + key_decisions=["A"], + month="2026-06", + ) + + self.assertTrue((store.root / "plan" / "target_001").is_dir()) + self.assertEqual(store.get_active_plan(), {"plan_id": "other_001"}) class ProtocolStoreNoRetiredFilesTests(unittest.TestCase): @@ -516,6 +676,7 @@ def test_writer_does_not_produce_retired_state_files(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: sopify_root = Path(temp_dir) store = ProtocolStore(sopify_root) + _write_plan_package(sopify_root, "no_retired_001") store.set_active_plan(plan_id="no_retired_001") store.set_current_handoff( @@ -566,6 +727,7 @@ def test_state_dir_empty_after_finalize(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: sopify_root = Path(temp_dir) store = ProtocolStore(sopify_root) + _write_plan_package(sopify_root, "clear_after_001") store.set_active_plan(plan_id="clear_after_001") store.set_current_handoff( From 00db38bcce35664ea06117f107377733464588bf Mon Sep 17 00:00:00 2001 From: "sanze.li" <2522048902@qq.com> Date: Sun, 19 Jul 2026 16:29:51 +0800 Subject: [PATCH 2/4] fix: align skill distribution contracts --- .sopify/blueprint/design.md | 6 +- .../plan.md | 21 ++++-- .../tasks.md | 11 +++- scripts/render-host-skills.py | 2 +- skills/catalog/analyze/skill.yaml | 2 + skills/catalog/builtin_catalog.generated.json | 22 +++++-- skills/catalog/design/skill.yaml | 2 + skills/catalog/develop/skill.yaml | 2 + skills/catalog/kb/skill.yaml | 2 + skills/catalog/templates/skill.yaml | 2 + skills/en/header.md.template | 9 +-- skills/en/skills/sopify/develop/SKILL.md | 3 +- .../develop/references/develop-rules.md | 15 ++--- skills/en/skills/sopify/kb/SKILL.md | 3 +- skills/en/skills/sopify/templates/SKILL.md | 6 +- skills/zh/header.md.template | 9 +-- skills/zh/skills/sopify/develop/SKILL.md | 5 +- .../develop/references/develop-rules.md | 15 ++--- skills/zh/skills/sopify/kb/SKILL.md | 3 +- skills/zh/skills/sopify/templates/SKILL.md | 6 +- tests/golden-snapshots.json | 16 ++--- tests/test_plan_contract_assets.py | 66 +++++++++++++++++++ 22 files changed, 166 insertions(+), 62 deletions(-) diff --git a/.sopify/blueprint/design.md b/.sopify/blueprint/design.md index 39ed17f4..b300fb6d 100644 --- a/.sopify/blueprint/design.md +++ b/.sopify/blueprint/design.md @@ -441,6 +441,8 @@ P4b 减重和 P4c 宿主消费治理的红线边界。只冻结 artifact / schem > **未列入面默认可删**:`state/sessions/*`、`state/last_route.json`、runtime 内部模块边界、route name 全集、output 渲染文案措辞均为 runtime 内部实现,不在 keep-list 内。P4b 减重时可自由处置。 +`host_support` 只声明官方适配器能把 Skill 语义交付到对应宿主的支持界面并由宿主消费。它不等同于原生 Skill discovery,也不证明该宿主已经完成 E2E;支持等级、已验证能力和入口形态继续由 `HostCapability` 表达。当前五个内置 Skill 面向 `codex / claude / qoder / copilot`,其中 Copilot 通过单文件展开消费。 + ## Output Rendering Audit — *[pre-P8 legacy reference]* @@ -589,7 +591,7 @@ Sopify 的设计目标不仅是工程轻量(削减 runtime),更是产品 ## 硬约束 1. **能删则删**:新概念必须替换旧概念或证明不增加概念预算 -2. **sopify_writer 只做协议准入和写入**:不做 plan materialization、文件迁移、自动修复、状态推进 +2. **sopify_writer 只做确定性协议操作**:负责结构/版本校验、receipt 写入、标准目录迁移与成功后清理 state;不做 plan materialization、自动修复或语义状态推断 3. **确定性执行层只按结构化事实执行**:不理解人话、不做语义推断 4. **Host prompt 不定义机器真相**:prompt 只渲染 machine truth,不作为 truth source 5. **develop_mode 是 hint**:不参与权限裁决;权限裁决看 ActionProposal side_effect + state + risk policy @@ -604,7 +606,7 @@ Sopify 的设计目标不仅是工程轻量(削减 runtime),更是产品 - `ActionProposal(action_type="archive_plan")` 是协议入口;`~go finalize` 只是 alias - 主体是结构化 `archive_subject`,不通过正则或词表猜 -- 两层分离:sopify_writer 负责 protocol admission + write receipts;宿主负责 check + apply +- 两层分离:宿主负责确认显式 finalize 意图并准备语义输入;sopify_writer 负责结构/版本校验、receipt 写入、标准目录迁移和成功后清理 state - Legacy/metadata 不完整主体返回 `migration_required`,不自动修复 - 归档只在主体等于当前 `active_plan.json` 指向的 plan 时清理执行状态 diff --git a/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md b/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md index 9d2e3339..96209954 100644 --- a/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md +++ b/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md @@ -19,9 +19,9 @@ knowledge_sync: ## Plan Snapshot - **Goal**: 保留 Sopify 产品无关的验证证据能力,以 EvidentLoop 替代当前 CrossReview 集成,并提供不绑定运行时的一次性可选配套安装与公开版本 dogfood。 -- **Status**: Wave 1–2 主实现及审计修复已获两路独立审批和用户接受;247 tests、58 subtests 通过。 -- **Next**: 将 Wave 1–2 作为单个本地 checkpoint commit 提交后进入 Wave 3;不推送 Sopify 分支。 -- **Task**: 7/20。 +- **Status**: Wave 1–3 与 Task 4.0 已完成;EvidentLoop `v0.1.0a2` 已公开发布,当前分发契约漂移已收口。 +- **Next**: 等待用户审计 Task 4.0;确认前不进入 Wave 4.1 的可选安装实现。 +- **Task**: 11/21。 评分: - 方案质量: 9/10 @@ -29,7 +29,7 @@ knowledge_sync: 评分理由: - 优点: 统一复用现有 plan 入口、writer、Verifier 和 EvidentLoop 通用产物,不增加第二套审计协议或兼容层。 -- 扣分: EvidentLoop 新公开版本、多宿主配套安装和 Codex 完整 diff dogfood 尚需执行期证据确认;自动升级明确延后。 +- 扣分: 多宿主配套安装和 Codex 完整 diff dogfood 尚需执行期证据确认;自动升级明确延后。 ## Context / Why @@ -45,6 +45,7 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif - 用 `plan_version` 表达完整方案版本,并使入口预检、并行推进检查和 receipt 使用同一语义。 - 删除 Sopify 当前 CrossReview Skill、默认钩子、废弃方案和当前文档承诺;历史归档保持不变。 - 在 EvidentLoop 独立仓库补充通用 `diff_version / report_version` 输出,并通过下一公开 Alpha 提供给所有消费者。 +- 在进入可选安装前收口当前分发契约:develop 完成态、active plan 读取入口和内置 Skill 的宿主支持声明保持一致。 - 保持 Sopify 默认只提供产品无关的 Verifier evidence 消费;不安装验证器也能完整使用 plan、develop、handoff 和 receipt。 - 为 Sopify 可安装宿主增加显式 `--with-evidentloop` 配套安装:只在用户选择时为目标宿主安装缺失的官方固定版本 CLI 与 Skill;不把该参数作为运行能力开关。 - 允许用户直接使用已独立安装的 EvidentLoop 或其他验证组件,不要求经 Sopify 安装,不增加验证器注册表。 @@ -68,7 +69,7 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif 1. 对齐方案包结构、`plan_version`、入口预检和中英文分发资产。 2. 删除 CrossReview 当前执行面,并收口 Sopify blueprint、protocol 与 ADR 当前真相。 3. 在 EvidentLoop 独立仓库补齐通用 `diff_version / report_version`,经确认后发布下一 Alpha。 -4. 在现有 Sopify 多宿主安装器中加入 `--with-evidentloop` 最小分支,复用目标宿主映射并验证未选择、缺失安装、兼容复用和不兼容停车。 +4. 先收口当前分发契约漂移,再在现有 Sopify 多宿主安装器中加入 `--with-evidentloop` 最小分支,复用目标宿主映射并验证未选择、缺失安装、兼容复用和不兼容停车。 5. 先完成全量验证、残留审计和知识同步,再以 Codex 作为首轮真实宿主,用该公开版本与配套安装路径审计最终“实现与契约 diff”;初次报告生成后显式停车等待用户裁定。 6. 用户裁定闭环后附着报告、写 receipt,完成只读残留复核和标准 finalize 归档。 @@ -78,12 +79,14 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif - `.sopify/history/**` 不做名称迁移、内容重写或样例重生成;旧字段和旧版本值保持历史事实。 - 方案级别统一为:light 只有 `plan.md`;standard 为 `plan.md + tasks.md`;architecture 为 `plan.md + tasks.md + design.md`。不预建空 receipts、assets、ADR 或 diagrams。 - `plan.md` 是统一入口,`plan_version` 是完整方案版本。底层使用 SHA-256,但字段名和用户文案不暴露算法术语。 +- develop 完成后只把 `plan.md` 生命周期元数据更新为 `ready_to_archive` 并保留在 `plan/`;只有显式 `~go finalize` 才通过 writer 归档。该状态不是新的 state 文件或生命周期引擎。 - `plan_version` 先按 level 校验精确文件集合,再按固定顺序读取该级别的 `plan.md / tasks.md / design.md`;排除 `audits/`、`receipts/`、`assets/`、state 和其他派生产物。 - EvidentLoop 通用提供 `diff_version` 与 `report_version`;Sopify 自己计算 `plan_version` 并建立 receipt 关联。EvidentLoop 不增加 `plan_id`、Sopify adapter、receipt 或 plan/design profile。 - Sopify 默认能力是消费通用 `verdict / evidence / source`;EvidentLoop 是官方推荐、可选配套安装的首个验证器,不是默认安装、默认执行或唯一实现。 - `--with-evidentloop` 默认关闭且只提供安装便利,不写 `evidentloop_enabled`、component registry、state 或持久安装来源。未选择时保持现有安装路径和 `stdlib_only` 核心依赖模型。 - 配套安装面向 Sopify 已有可安装宿主,通过现有 host adapter 映射到标准 Skills CLI agent target:`codex → codex`、`claude → claude-code`、`qoder → qoder`、`copilot → github-copilot`。Skill 固定使用 `-g` 安装到目标宿主的用户级全局发现路径;新增宿主缺少映射时才明确返回未支持,不另建宿主框架。 - Codex 是本方案首轮真实 E2E dogfood 宿主,不是配套安装的唯一宿主。其他宿主必须区分“安装链/结构已验证”和“Skill discovery/审计 E2E 已验证”,不把安装成功宣传为完整可用证据。 +- 内置 Skill 的 `host_support` 只表示其语义可由官方适配器交付到对应宿主的支持界面并被消费;它不代表原生 Skill discovery 或 E2E 已验证,能力等级继续由 `HostCapability` 表达。 - 带参数时,缺失项按 Sopify 发布时固定的 EvidentLoop 公开版本与 tag 安装;已存在且通过兼容探针时直接复用;已存在但不兼容时停止并说明,不静默覆盖、降级或升级。 - 本方案不自动管理 EvidentLoop 后续升级。用户可按 EvidentLoop 官方方式独立升级;升级后只要兼容探针通过,Sopify 即可使用。自动升级待真实需求出现后另行设计。 - 用户使用其他验证器时沿用通用 Verifier evidence 与 receipt,不要求 `diff_version / report_version`、EvidentLoop 报告目录或安装参数。 @@ -127,8 +130,12 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif - [x] 用户确认连续实施 Wave 1–2。 - [x] Wave 1–2 实施、定向验证、全量回归和当前知识收口完成。 - [x] Wave 1–2 审计问题最小修复与回归完成;两路独立审批 P0=0、P1=0,用户已接受。 -- [ ] Wave 3–6、公开版本 dogfood 和归档完成。 +- [x] Wave 1–2 已提交为本地 checkpoint `7dcd768`,Sopify 分支未推送。 +- [x] Wave 3 的 EvidentLoop 通用版本实现、定向验证与 370 项全量回归完成。 +- [x] EvidentLoop `v0.1.0a2` 已发布:[Publish](https://github.com/evidentloop/evidentloop/actions/runs/29678876261) 成功,[PyPI](https://pypi.org/project/evidentloop/0.1.0a2/) 包含未撤回的 wheel 与 sdist,[GitHub prerelease](https://github.com/evidentloop/evidentloop/releases/tag/v0.1.0a2) 为非草稿;schema `0.4`、prompt `v0.5` 保持不变,Release 无额外 assets 不阻断。 +- [x] Task 4.0 已完成:develop/finalize 生命周期、active-plan 读取入口和四宿主 `host_support` 已双语收口;96 tests、36 subtests 与 Ruff 通过。 +- [ ] Wave 4–6、公开版本 dogfood 和归档完成。 ## Next -Wave 1–2 形成本地 checkpoint commit 后进入 Wave 3。Wave 3 只实施 EvidentLoop 通用版本能力;发布前仍需用户单独授权。 +等待用户审计 Task 4.0;确认前不进入 Wave 4.1 的 `--with-evidentloop` 实现。 diff --git a/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md b/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md index b249736d..99f1f849 100644 --- a/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md +++ b/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md @@ -27,16 +27,21 @@ ## Wave 3 | EvidentLoop 通用版本能力 -- [ ] 3.1 在 EvidentLoop 独立仓库重新核对 main、用户改动和公开版本状态,按其独立生命周期建立最小方案;范围仅为通用 `diff_version / report_version`,不包含任何 Sopify 名称或 plan 语义。 -- [ ] 3.2 复用 EvidentLoop 已有确定性哈希,在正式 `audit.json` 的 namespaced extension 和 `finalize / revise` 结构化结果中提供通用版本值;同步 API、CLI、Skill、数据模型和定向测试。 +- [x] 3.1 在 EvidentLoop 独立仓库重新核对 main、用户改动和公开版本状态,按其独立生命周期建立最小方案;范围仅为通用 `diff_version / report_version`,不包含任何 Sopify 名称或 plan 语义。 +- [x] 3.2 复用 EvidentLoop 已有确定性哈希,在正式 `audit.json` 的 namespaced extension 和 `finalize / revise` 结构化结果中提供通用版本值;同步 API、CLI、Skill、数据模型和定向测试。 - 验收:`diff_version` 唯一对应本次实际 Git diff;`report_version` 唯一对应正式 `audit.json` 字节;正式 HTML 继续校验与 JSON 的 graph/run identity 一致,不单设版本字段。 - 验收:新报告 finalize 返回非空两版本;带版本报告 revise 原样继承 `diff_version`;legacy schema `0.4` 报告缺失时仍可 revise,并明确返回 `diff_version: null`,不得猜测。 - 验收:覆盖 legacy `0.4`、连续两轮 revise、copy、in-place、recovery 和 Skill 校验路径。 - 验收:不升级 code-diff schema 版本,不迁移已发布字段,不增加 plan profile、宿主 adapter、模型 SDK、provider 配置或集成注册表。 -- [ ] 3.3 完成 EvidentLoop 定向与全量验证后,在 tag/PyPI/GitHub Release 前停车请求用户发布授权;获授权后发布下一 Alpha,并核对 package、schema、prompt、Release metadata 和 evidence asset 状态。 +- [x] 3.3 完成 EvidentLoop 定向与全量验证后,在 tag/PyPI/GitHub Release 前停车请求用户发布授权;获授权后发布 `v0.1.0a2`,并核对 package、schema、prompt、Release metadata 和 evidence asset 状态。 + - 证据:[Publish run](https://github.com/evidentloop/evidentloop/actions/runs/29678876261) 成功;[PyPI](https://pypi.org/project/evidentloop/0.1.0a2/) 包含未撤回的 wheel 与 sdist;[GitHub prerelease](https://github.com/evidentloop/evidentloop/releases/tag/v0.1.0a2) 为非草稿,schema `0.4`、prompt `v0.5` 未变,Release 无额外 assets 不阻断。 ## Wave 4 | 多宿主可选配套安装 +- [x] 4.0 收口当前分发契约的三处既有漂移,不重开 Wave 1–2,也不引入新状态或宿主框架。 + - 验收:中英文 header 与 develop Skill/rules 统一为“develop 完成后更新 `plan.md` 的 `ready_to_archive` 生命周期元数据并保留在 `plan/`;只有显式 `~go finalize` 才通过 writer 归档”。 + - 验收:中英文 `$kb/$templates` 使用 `state/active_plan.json → plan//plan.md`,删除退役的 `current_plan.path + current_plan.files` 口径。 + - 验收:五个内置 Skill 都声明 `codex / claude / qoder / copilot`,同步生成 catalog 和最小防漂移测试;`host_support` 只表示官方适配器可交付并消费 Skill 语义,不替代 `HostCapability` 的能力等级与 E2E 证据。 - [ ] 4.1 在现有 Python installer、远程 shell/PowerShell wrapper 和用户文档中增加默认关闭的 `--with-evidentloop`;通过现有 HostAdapter 固定映射 `codex → codex`、`claude → claude-code`、`qoder → qoder`、`copilot → github-copilot`,并在核心写入前校验映射、`uv` 和 `npx`。 - 验收:不带参数时不执行任何 EvidentLoop lookup、网络访问或提示,现有安装成功语义、payload 和 `stdlib_only` 依赖声明保持不变;只有 help/文档新增可选参数说明。缺少 Skill target 映射的新宿主明确停止可选分支。 - [ ] 4.2 用一个 EvidentLoop 专用安装 helper 实现最小分支,不建立通用 component framework:兼容安装直接复用,缺失的 CLI/Skill 按固定 package 版本和 Git tag 补齐,已有不兼容项时不修改。 diff --git a/scripts/render-host-skills.py b/scripts/render-host-skills.py index 56bb459a..26baf8ce 100644 --- a/scripts/render-host-skills.py +++ b/scripts/render-host-skills.py @@ -125,7 +125,7 @@ def main() -> None: help="Path to hosts.yaml", ) parser.add_argument("--lang", help="Language code (en or zh)") - parser.add_argument("--host", help="Host id (claude, codex, copilot)") + parser.add_argument("--host", help="Host id (claude, codex, qoder, copilot)") parser.add_argument("--output", type=Path, help="Output file path (default: stdout)") parser.add_argument( "--verify-all", diff --git a/skills/catalog/analyze/skill.yaml b/skills/catalog/analyze/skill.yaml index b8c6e49d..8a224b37 100644 --- a/skills/catalog/analyze/skill.yaml +++ b/skills/catalog/analyze/skill.yaml @@ -16,4 +16,6 @@ allowed_paths: host_support: - codex - claude + - qoder + - copilot permission_mode: default diff --git a/skills/catalog/builtin_catalog.generated.json b/skills/catalog/builtin_catalog.generated.json index b395f8eb..f36253c5 100644 --- a/skills/catalog/builtin_catalog.generated.json +++ b/skills/catalog/builtin_catalog.generated.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-06-07T06:13:51.963293+00:00", + "generated_at": "2026-07-19T08:14:29.246893+00:00", "schema_version": "1", "skills": [ { @@ -15,7 +15,9 @@ "handoff_kind": "analysis", "host_support": [ "codex", - "claude" + "claude", + "qoder", + "copilot" ], "id": "analyze", "metadata": {}, @@ -44,7 +46,9 @@ "handoff_kind": "plan", "host_support": [ "codex", - "claude" + "claude", + "qoder", + "copilot" ], "id": "design", "metadata": {}, @@ -73,7 +77,9 @@ "handoff_kind": "develop", "host_support": [ "codex", - "claude" + "claude", + "qoder", + "copilot" ], "id": "develop", "metadata": {}, @@ -103,7 +109,9 @@ "handoff_kind": "kb", "host_support": [ "codex", - "claude" + "claude", + "qoder", + "copilot" ], "id": "kb", "metadata": {}, @@ -133,7 +141,9 @@ "handoff_kind": "template", "host_support": [ "codex", - "claude" + "claude", + "qoder", + "copilot" ], "id": "templates", "metadata": {}, diff --git a/skills/catalog/design/skill.yaml b/skills/catalog/design/skill.yaml index 83d14421..7904369c 100644 --- a/skills/catalog/design/skill.yaml +++ b/skills/catalog/design/skill.yaml @@ -16,4 +16,6 @@ allowed_paths: host_support: - codex - claude + - qoder + - copilot permission_mode: default diff --git a/skills/catalog/develop/skill.yaml b/skills/catalog/develop/skill.yaml index d0ce09a3..c9d0c2db 100644 --- a/skills/catalog/develop/skill.yaml +++ b/skills/catalog/develop/skill.yaml @@ -17,4 +17,6 @@ allowed_paths: host_support: - codex - claude + - qoder + - copilot permission_mode: default diff --git a/skills/catalog/kb/skill.yaml b/skills/catalog/kb/skill.yaml index e8a78413..a4d931c0 100644 --- a/skills/catalog/kb/skill.yaml +++ b/skills/catalog/kb/skill.yaml @@ -17,4 +17,6 @@ allowed_paths: host_support: - codex - claude + - qoder + - copilot permission_mode: default diff --git a/skills/catalog/templates/skill.yaml b/skills/catalog/templates/skill.yaml index 90cd5b9c..aa1b7db6 100644 --- a/skills/catalog/templates/skill.yaml +++ b/skills/catalog/templates/skill.yaml @@ -16,4 +16,6 @@ allowed_paths: host_support: - codex - claude + - qoder + - copilot permission_mode: default diff --git a/skills/en/header.md.template b/skills/en/header.md.template index 6110fb93..1e7839a8 100644 --- a/skills/en/header.md.template +++ b/skills/en/header.md.template @@ -210,7 +210,8 @@ First Trigger: real project repositories should at least create .sopify/blueprin First Plan Lifecycle: populate .sopify/blueprint/background.md / design.md / tasks.md Plan Creation: .sopify/plan/YYYYMMDD_feature_name/ Task Close-Out: refresh blueprint README managed sections and update deeper blueprint docs when required -Ready for Verification: migrate to .sopify/history/YYYY-MM/ and update index.md +Development Complete: set plan.md lifecycle metadata to ready_to_archive and keep the package in .sopify/plan/ +Explicit Archive: only ~go finalize may use sopify_writer to move the package into .sopify/history/YYYY-MM/ and update index.md ``` --- @@ -315,7 +316,7 @@ Next: Continue plan review or execution in the host session, or reply with feedb 1. Read develop Skill 2. Execute in tasks.md order 3. Update knowledge base -4. Migrate plan to history/ +4. Mark the plan ready_to_archive and keep it in plan/ 5. Output results ``` @@ -332,9 +333,9 @@ Changes: 5 files - src/types/index.ts - src/hooks/useXxx.ts - .sopify/blueprint/design.md - - .sopify/history/2026-01/... + - .sopify/plan/20260115_feature/plan.md -Next: Please verify the functionality +Next: Please verify the functionality; run ~go finalize explicitly when the plan should be archived ``` --- diff --git a/skills/en/skills/sopify/develop/SKILL.md b/skills/en/skills/sopify/develop/SKILL.md index cda88348..a51f343f 100644 --- a/skills/en/skills/sopify/develop/SKILL.md +++ b/skills/en/skills/sopify/develop/SKILL.md @@ -18,7 +18,7 @@ description: Develop phase entry; routes task execution, state updates, KB sync, 2. Extract pending tasks and execute them in numbered order. 3. Update task markers after each step (`[ ] -> [x] / [-] / [!]`). 4. Sync KB files and conservative preference / feedback records. -5. Move completed plans into `history/` and update the index. +5. Set completed plans' `plan.md` lifecycle metadata to `ready_to_archive` and keep them in `plan/`. 6. Render the matching result template. ## Resource navigation @@ -43,4 +43,5 @@ The script returns JSON with pending tasks, status counts, and execution order. ## Boundaries - This skill executes and closes out work; it does not redefine the plan structure. +- `ready_to_archive` is `plan.md` metadata only; archival happens only when explicit `~go finalize` delegates to `sopify_writer`. - Rollback remains an explicit user action and must keep a traceable record. diff --git a/skills/en/skills/sopify/develop/references/develop-rules.md b/skills/en/skills/sopify/develop/references/develop-rules.md index 25bd4161..0a335c92 100644 --- a/skills/en/skills/sopify/develop/references/develop-rules.md +++ b/skills/en/skills/sopify/develop/references/develop-rules.md @@ -9,7 +9,7 @@ Implement the task list, maintain task state, sync V2 long-lived knowledge throu 1. Read the task list. 2. Execute each task through the fixed quality loop: implement change -> discover verification -> run verification -> retry once when needed -> perform two-stage review. Update task markers only after the minimum quality contract is satisfied. 3. Sync KB and preference data through `knowledge_sync`. -4. Move the completed plan into `history/`. +4. Mark the completed plan `ready_to_archive` and keep it in `plan/`. 5. Render the execution summary. ## Step 1: Read the task list @@ -192,16 +192,15 @@ Disallowed: - Guesses from incomplete context. - Generalized conclusions unrelated to the task. -## Step 4: Plan migration +## Step 4: Plan completion state -Migration path: +After tasks, verification, and `knowledge_sync` are complete: -```text -.sopify/plan/YYYYMMDD_feature/ - -> .sopify/history/YYYY-MM/YYYYMMDD_feature/ -``` +1. Set `lifecycle_state: ready_to_archive` and `archive_ready: true` in `plan.md`. +2. Keep the package at `.sopify/plan/YYYYMMDD_feature/`. +3. Develop does not move the directory, update the history index, or simulate finalize directly. -Create and update `.sopify/history/index.md` on demand during the first explicit finalize. +`ready_to_archive` is plan semantic metadata, not a new state file or lifecycle engine. Only explicit `~go finalize` lets the host use `sopify_writer` to validate the final `plan_version`, move the package, and update the history index. ## Output templates diff --git a/skills/en/skills/sopify/kb/SKILL.md b/skills/en/skills/sopify/kb/SKILL.md index 6d615957..e8b2ac9d 100644 --- a/skills/en/skills/sopify/kb/SKILL.md +++ b/skills/en/skills/sopify/kb/SKILL.md @@ -82,12 +82,13 @@ First explicit long-term preference: 4. `blueprint/background.md` 5. `blueprint/design.md` 6. `blueprint/tasks.md` -7. `active_plan = current_plan.path + current_plan.files` +7. `state/active_plan.json` → `plan//plan.md` Rules: - consult / clarification routes prefer `L0/L1` and must not require deep blueprint files - planning / develop may enter `L2 active plan` +- `active_plan.json` provides only `plan_id`; `plan.md` is the active plan's semantic entry. Read handoff and receipts only when continuation needs them, and never fall back to the retired current-plan projection. - `history/` is not the default long-lived context source; read it only for finalize lookups or human traceability ## Update Rules diff --git a/skills/en/skills/sopify/templates/SKILL.md b/skills/en/skills/sopify/templates/SKILL.md index f2bfe460..1fa1bcc4 100644 --- a/skills/en/skills/sopify/templates/SKILL.md +++ b/skills/en/skills/sopify/templates/SKILL.md @@ -84,15 +84,15 @@ Maintenance: keep only status, current goal, current focus, and read-next links ## Formal Contracts - `knowledge_sync` is the only formal sync contract. -- `active_plan = current_plan.path + current_plan.files`. +- `state/active_plan.json` provides only `plan_id`; the active plan semantic entry is always `plan//plan.md`. ## Consumption Contract | Context Profile | Reads | Fail-open Rule | Notes | |-----|------|------|------| | `consult` | `project.md`, `preferences.md`, `blueprint/README.md` | missing deep blueprint does not fail | do not force plan materialization | -| `plan` | `L1` + `active_plan` | materialize deep blueprint by lifecycle when missing | history is not default context | -| `finalize` | `active_plan`, `knowledge_sync`, `blueprint/*`, `history/index.md` | create `history/index.md` on demand when missing | block when `required` sync is not satisfied | +| `plan` | `L1` + `active_plan.json → plan.md` | materialize deep blueprint by lifecycle when missing | history is not default context | +| `finalize` | `active_plan.json → plan.md`, `knowledge_sync`, `blueprint/*`, `history/index.md` | create `history/index.md` on demand when missing | block when `required` sync is not satisfied | ``` ### blueprint/tasks.md diff --git a/skills/zh/header.md.template b/skills/zh/header.md.template index a2c0bef4..73db5226 100644 --- a/skills/zh/header.md.template +++ b/skills/zh/header.md.template @@ -210,7 +210,8 @@ Next: {下一步提示} 首次进入方案流: 补齐 .sopify/blueprint/background.md / design.md / tasks.md 方案创建: .sopify/plan/YYYYMMDD_feature_name/ 任务收口: 刷新 blueprint README 托管区块,并在需要时更新深层 blueprint -准备交付验证: 迁移至 .sopify/history/YYYY-MM/ 并更新 index.md +开发完成: 将 plan.md 生命周期元数据更新为 ready_to_archive,方案继续保留在 .sopify/plan/ +显式归档: 仅由 ~go finalize 通过 sopify_writer 迁移至 .sopify/history/YYYY-MM/ 并更新 index.md ``` --- @@ -315,7 +316,7 @@ Next: 在宿主会话中继续评审或执行方案,或直接回复修改意 1. 读取 develop Skill 2. 按 tasks.md 顺序执行 3. 更新知识库 -4. 迁移方案至 history/ +4. 将方案更新为 ready_to_archive,保留在 plan/ 5. 输出结果 ``` @@ -332,9 +333,9 @@ Changes: 5 files - src/types/index.ts - src/hooks/useXxx.ts - .sopify/blueprint/design.md - - .sopify/history/2026-01/... + - .sopify/plan/20260115_feature/plan.md -Next: 请验证功能 +Next: 请验证功能;需要归档时显式运行 ~go finalize ``` --- diff --git a/skills/zh/skills/sopify/develop/SKILL.md b/skills/zh/skills/sopify/develop/SKILL.md index 6872bc30..f9d25dc9 100644 --- a/skills/zh/skills/sopify/develop/SKILL.md +++ b/skills/zh/skills/sopify/develop/SKILL.md @@ -1,6 +1,6 @@ --- name: develop -description: 开发实施阶段入口;聚合任务执行、状态更新、知识库同步与收尾迁移规则,按需加载 references/assets/scripts。 +description: 开发实施阶段入口;聚合任务执行、状态更新、知识库同步与收尾状态规则,按需加载 references/assets/scripts。 --- # Develop(入口文档) @@ -18,7 +18,7 @@ description: 开发实施阶段入口;聚合任务执行、状态更新、知 2. 提取待执行任务并按编号顺序执行。 3. 每完成一步更新任务状态(`[ ] -> [x] / [-] / [!]`)。 4. 同步知识库与偏好/反馈(保守写入)。 -5. 方案完成后迁移到 `history/` 并更新索引。 +5. 方案完成后将 `plan.md` 生命周期元数据更新为 `ready_to_archive`,继续保留在 `plan/`。 6. 按结果输出成功/部分成功/快速修复摘要。 ## 资源导航 @@ -43,4 +43,5 @@ python3 skills/zh/skills/sopify/develop/scripts/extract_pending_tasks.py \ ## 边界 - 本技能负责执行与收尾,不负责重新定义方案结构(交给 `design`)。 +- `ready_to_archive` 只是 `plan.md` 元数据;只有显式 `~go finalize` 才通过 `sopify_writer` 归档。 - 回滚属于显式用户动作,必须保留可追溯记录。 diff --git a/skills/zh/skills/sopify/develop/references/develop-rules.md b/skills/zh/skills/sopify/develop/references/develop-rules.md index bab0cb66..bfd05afc 100644 --- a/skills/zh/skills/sopify/develop/references/develop-rules.md +++ b/skills/zh/skills/sopify/develop/references/develop-rules.md @@ -9,7 +9,7 @@ 1. 读取任务清单。 2. 对每个任务按固定质量循环执行:实现修改 -> 发现验证 -> 执行验证 -> 必要时一次重试 -> 两阶段复审。仅在质量结果满足最小 contract 后更新任务状态。 3. 按 `knowledge_sync` 同步知识库与偏好信息。 -4. 迁移方案包到 `history/`。 +4. 将完成方案更新为 `ready_to_archive`,保留在 `plan/`。 5. 输出执行结果摘要。 ## 步骤 1:读取任务清单 @@ -192,16 +192,15 @@ Stage B `code_quality` 至少检查: - 上下文不完整的猜测。 - 与任务无关的泛化结论。 -## 步骤 4:方案迁移 +## 步骤 4:方案完成态 -迁移路径: +任务、验证和 `knowledge_sync` 均完成后: -```text -.sopify/plan/YYYYMMDD_feature/ - -> .sopify/history/YYYY-MM/YYYYMMDD_feature/ -``` +1. 在 `plan.md` 中把 `lifecycle_state` 更新为 `ready_to_archive`,并设置 `archive_ready: true`。 +2. 方案目录继续保留在 `.sopify/plan/YYYYMMDD_feature/`。 +3. develop 不迁移目录、不更新 history index,也不直接模拟 finalize。 -索引更新:在首次显式 finalize 时按需创建并更新 `.sopify/history/index.md`。 +`ready_to_archive` 只是方案语义元数据,不是新的 state 文件或生命周期引擎。只有用户显式运行 `~go finalize` 时,宿主才通过 `sopify_writer` 校验最终 `plan_version`、迁移目录并更新 history index。 ## 输出模板 diff --git a/skills/zh/skills/sopify/kb/SKILL.md b/skills/zh/skills/sopify/kb/SKILL.md index bffd0dc4..d94f805f 100644 --- a/skills/zh/skills/sopify/kb/SKILL.md +++ b/skills/zh/skills/sopify/kb/SKILL.md @@ -82,12 +82,13 @@ description: 知识库管理技能;知识库操作时读取;包含初始化 4. `blueprint/background.md` 5. `blueprint/design.md` 6. `blueprint/tasks.md` -7. `active_plan = current_plan.path + current_plan.files` +7. `state/active_plan.json` → `plan//plan.md` 读取规则: - 咨询/澄清优先消费 `L0/L1`,不强制要求 deep blueprint。 - planning / develop 才进入 `L2 active plan`。 +- `active_plan.json` 只提供 `plan_id`;`plan.md` 是活动方案语义入口。接续时再按协议读取 handoff 与 receipts,不能回退到已退役的 current-plan 投影。 - `history/` 不是默认长期上下文,只在 finalize 后查询或人工追溯时读取。 ## 更新规则 diff --git a/skills/zh/skills/sopify/templates/SKILL.md b/skills/zh/skills/sopify/templates/SKILL.md index 9005971a..b8322e0b 100644 --- a/skills/zh/skills/sopify/templates/SKILL.md +++ b/skills/zh/skills/sopify/templates/SKILL.md @@ -84,15 +84,15 @@ description: 文档模板集合;创建文档时读取;包含所有知识库 ## 正式契约 - `knowledge_sync` 是唯一正式同步契约。 -- `active_plan = current_plan.path + current_plan.files`。 +- `state/active_plan.json` 只提供 `plan_id`,活动方案语义入口固定为 `plan//plan.md`。 ## 消费契约 | Context Profile | Reads | Fail-open Rule | Notes | |-----|------|------|------| | `consult` | `project.md`, `preferences.md`, `blueprint/README.md` | 缺 deep blueprint 不报错 | 不强制物化 plan | -| `plan` | `L1` + `active_plan` | 缺 deep blueprint 时按生命周期补齐 | history 不是默认上下文 | -| `finalize` | `active_plan`, `knowledge_sync`, `blueprint/*`, `history/index.md` | 缺 `history/index.md` 时现场创建 | `required` 未满足则阻断 | +| `plan` | `L1` + `active_plan.json → plan.md` | 缺 deep blueprint 时按生命周期补齐 | history 不是默认上下文 | +| `finalize` | `active_plan.json → plan.md`, `knowledge_sync`, `blueprint/*`, `history/index.md` | 缺 `history/index.md` 时现场创建 | `required` 未满足则阻断 | ``` ### blueprint/tasks.md diff --git a/tests/golden-snapshots.json b/tests/golden-snapshots.json index a67d3c53..7e542ae1 100644 --- a/tests/golden-snapshots.json +++ b/tests/golden-snapshots.json @@ -8,13 +8,13 @@ }, "protocol_version": "1.0", "snapshots": { - "codex:zh-CN:header": "0ec1f813b0796eb3cd8eff5ac1d4ba9d1a5a8d7594d8ea1b85f316926320b787", - "codex:en-US:header": "e687af9bbca32a5cdcf204bd5a8819d9535a37a999342a12c0ead46ebfeef2bc", - "claude:zh-CN:header": "fb9a6b664a3bd16192e7b75515874fdd06148a466fb31d475802ac2fc4246ba5", - "claude:en-US:header": "ad2e8ae091bfe2a0615d2f03a4347ed0bb324e9a26c87cc076b428d4dcb7db16", - "copilot:zh-CN:managed_block_payload": "fae89a10b6d8a7efe568ae9c520cc8d2477a49c3c1ac1b1650993a97494199ef", - "skills:zh-CN:tree": "fd0221eb7bd60e381c440381dbfdaf547a5c06d8de518be51ce55b7a4d8514f1", - "copilot:en-US:managed_block_payload": "b95b3c0e7e2610c67b5aa4672c524d8cc7d8c844a90bb166385b5afaf326ef00", - "skills:en-US:tree": "feef68ee74cd1d1e851d368bef0a741569d3063a6b08aec3837e213d6b311253" + "codex:zh-CN:header": "896aeabaa8fd9cad5d5ba6012625e8079763d9280bad635bb30f94cf8d84596e", + "codex:en-US:header": "b14eb827816af17b13ddce7d0f799e21b57c12607ff2a6311f6ddfc4b2d639cc", + "claude:zh-CN:header": "0456c105cc51dcdf2ac2ee94324d32e04ded54b0b5ca8f47a630b9e4ffe98f12", + "claude:en-US:header": "212a04e1d15ec1417ede205aeb74d71790fa5e1d4678b51fc7ab9f60a6e3afc5", + "copilot:zh-CN:managed_block_payload": "6ba7e87904473dc3138d49e7f681aeb4aae37e32bb81e04002879a36c5713017", + "skills:zh-CN:tree": "4869836b785233879fa62a6c101a4634ce7f3ac6ec6e18cef6f96f6e65629447", + "copilot:en-US:managed_block_payload": "6b16ed4893d90ae1bbb8793def8db136d4bb098c366032701500cde51e1b1408", + "skills:en-US:tree": "76da619fd73029fe30c842515c82a13252fd497a7f8dd3550c56e7656454a999" } } diff --git a/tests/test_plan_contract_assets.py b/tests/test_plan_contract_assets.py index 5b3dae2f..def2b33b 100644 --- a/tests/test_plan_contract_assets.py +++ b/tests/test_plan_contract_assets.py @@ -7,8 +7,12 @@ import tempfile import unittest +from scripts._yaml_subset import load_yaml + REPO_ROOT = Path(__file__).resolve().parents[1] +BUILTIN_SKILL_IDS = ("analyze", "design", "develop", "kb", "templates") +SUPPORTED_HOSTS = ["codex", "claude", "qoder", "copilot"] class PlanContractAssetTests(unittest.TestCase): @@ -114,6 +118,68 @@ def test_rendered_host_headers_keep_the_same_contract(self) -> None: self.assertIn("architecture", rendered) self.assertNotIn("light/standard/full", rendered) + def test_develop_completion_requires_explicit_finalize(self) -> None: + retired_phrases = { + "zh": ("迁移方案至 history/", "方案完成后迁移到 `history/`", "## 步骤 4:方案迁移"), + "en": ("Migrate plan to history/", "Move completed plans into `history/`", "## Step 4: Plan migration"), + } + for language in ("zh", "en"): + with self.subTest(language=language): + language_root = REPO_ROOT / "skills" / language + sources = [ + language_root / "header.md.template", + language_root / "skills" / "sopify" / "develop" / "SKILL.md", + language_root + / "skills" + / "sopify" + / "develop" + / "references" + / "develop-rules.md", + ] + contents: list[str] = [] + for source in sources: + content = source.read_text(encoding="utf-8") + contents.append(content) + self.assertIn("ready_to_archive", content) + self.assertIn("~go finalize", content) + combined = "\n".join(contents) + for phrase in retired_phrases[language]: + self.assertNotIn(phrase, combined) + + def test_kb_and_templates_use_plan_md_as_active_plan_entry(self) -> None: + for language in ("zh", "en"): + with self.subTest(language=language): + skill_root = REPO_ROOT / "skills" / language / "skills" / "sopify" + for relative_path in ("kb/SKILL.md", "templates/SKILL.md"): + content = (skill_root / relative_path).read_text(encoding="utf-8") + self.assertIn("state/active_plan.json", content) + self.assertIn("plan//plan.md", content) + self.assertNotIn("current_plan.path + current_plan.files", content) + + def test_builtin_host_support_matches_catalog_and_product_contract(self) -> None: + catalog = json.loads( + (REPO_ROOT / "skills" / "catalog" / "builtin_catalog.generated.json").read_text( + encoding="utf-8" + ) + ) + catalog_by_id = {item["id"]: item for item in catalog["skills"]} + + for skill_id in BUILTIN_SKILL_IDS: + with self.subTest(skill_id=skill_id): + manifest = load_yaml( + ( + REPO_ROOT / "skills" / "catalog" / skill_id / "skill.yaml" + ).read_text(encoding="utf-8") + ) + self.assertEqual(manifest["host_support"], SUPPORTED_HOSTS) + self.assertEqual(catalog_by_id[skill_id]["host_support"], SUPPORTED_HOSTS) + + blueprint = (REPO_ROOT / ".sopify" / "blueprint" / "design.md").read_text( + encoding="utf-8" + ) + self.assertIn("`host_support` 只声明官方适配器", blueprint) + self.assertIn("`HostCapability`", blueprint) + if __name__ == "__main__": unittest.main() From d41f4b6c4fdf51d1333820a4ec4c9aacf8bbb83e Mon Sep 17 00:00:00 2001 From: Li-WeiXin <2522048902@qq.com> Date: Sun, 19 Jul 2026 23:14:25 +0800 Subject: [PATCH 3/4] feat: Multiple optional installations --- .github/workflows/ci.yml | 3 +- .sopify/blueprint/README.md | 2 +- .sopify/blueprint/background.md | 2 + .sopify/blueprint/design.md | 6 +- .sopify/blueprint/tasks.md | 3 +- .../design.md | 39 +- .../plan.md | 21 +- .../tasks.md | 20 +- .sopify/project.md | 3 +- README.md | 23 + README.zh-CN.md | 21 + docs/getting-started.md | 52 ++ docs/how-sopify-works.en.md | 2 +- docs/how-sopify-works.md | 2 +- install.ps1 | 61 +- install.sh | 48 +- installer/distribution.py | 192 ++++- installer/evidentloop.py | 454 +++++++++++ installer/hosts/base.py | 24 + installer/hosts/claude.py | 2 + installer/hosts/codex.py | 2 + installer/hosts/copilot.py | 2 + installer/hosts/qoder.py | 2 + installer/models.py | 11 + scripts/install_sopify.py | 63 +- scripts/release-preflight.sh | 3 +- .../plan/test_clarification_001/plan.md | 4 + .../.sopify/plan/test_decision_001/plan.md | 4 + .../2026-06/test_finalize_001/receipt.md | 0 .../test_finalize_001/receipts/final.json | 0 .../.sopify/plan/test_minimal_001/plan.md | 4 + tests/test_distribution.py | 94 ++- tests/test_evidentloop_installer.py | 754 ++++++++++++++++++ 33 files changed, 1809 insertions(+), 114 deletions(-) create mode 100644 installer/evidentloop.py rename tests/fixtures/{minimal_plan => finalized_plan}/.sopify/history/2026-06/test_finalize_001/receipt.md (100%) rename tests/fixtures/{minimal_plan => finalized_plan}/.sopify/history/2026-06/test_finalize_001/receipts/final.json (100%) create mode 100644 tests/test_evidentloop_installer.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36e8d410..6f0ab7d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,7 @@ jobs: tests/protocol/test_convention_compliance.py \ tests/test_check_readme_links.py \ tests/test_distribution.py \ + tests/test_evidentloop_installer.py \ tests/test_golden_snapshots.py \ tests/test_release_hooks.py \ tests/test_sopify_init_smoke.py \ @@ -78,7 +79,7 @@ jobs: run: python3 scripts/sopify_protocol_check.py check --scenario continuation --fixture tests/fixtures/decision_pending - name: Run protocol smoke — finalize - run: python3 scripts/sopify_protocol_check.py check --scenario finalize --fixture tests/fixtures/minimal_plan + run: python3 scripts/sopify_protocol_check.py check --scenario finalize --fixture tests/fixtures/finalized_plan - name: Run install/payload bootstrap smoke run: python3 scripts/check-install-payload-bundle-smoke.py diff --git a/.sopify/blueprint/README.md b/.sopify/blueprint/README.md index 23259c30..57e862a4 100644 --- a/.sopify/blueprint/README.md +++ b/.sopify/blueprint/README.md @@ -13,7 +13,7 @@ ## 当前焦点 -- 当前活动 plan:`../plan/20260718_evidentloop_optional_audit_integration/`;Wave 1–2 已实施并通过全量回归,等待用户审计后再决定是否进入 Wave 3。 +- 当前活动 plan:`../plan/20260718_evidentloop_optional_audit_integration/`;Wave 1–4 已完成,四宿主可选配套安装及独立复审已收口;正在等待用户确认 Wave 5。 - 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/20260717_sopify_entry_preflight`。 diff --git a/.sopify/blueprint/background.md b/.sopify/blueprint/background.md index 42cd41d3..3e3d7968 100644 --- a/.sopify/blueprint/background.md +++ b/.sopify/blueprint/background.md @@ -27,6 +27,8 @@ Sopify 官方在 core 之上提供一个轻量、可插拔、收敛式的 bluepr **产品形态锚点**:Protocol 是宿主的唯一硬依赖;宿主执行、Sopify 保存、任意宿主恢复——这是 P8 runtime 退场后的三层分工。一切外部验证与生产能力通过 integration contract 外插,不进 kernel。 +**可选验证安装边界**:Sopify 不默认安装或运行 Verifier。用户显式传入 `--with-evidentloop` 时,先完成 Sopify,再独立尝试 EvidentLoop;后者失败不回滚或否定 Sopify。新装组件使用当前 Sopify 发布验证过的版本和来源,已有组件只做兼容检查;Sopify 不声明来源相同,不负责组件升级,也不自动提交 Copilot 项目 Skill。该参数不形成持久能力开关,也不改变通用 receipt 契约。 + ## 核心架构模式 P8 后 Sopify 不再运行进程。一切交互通过纯文件协议: diff --git a/.sopify/blueprint/design.md b/.sopify/blueprint/design.md index b300fb6d..b553afa0 100644 --- a/.sopify/blueprint/design.md +++ b/.sopify/blueprint/design.md @@ -37,7 +37,7 @@ Sopify 是装在现有 AI 编程宿主上的**开发过程协议层**。它把 ### Verifier 使用边界 -以下形态都合法:只使用 Sopify;显式选择其他 Verifier;使用已独立安装的 EvidentLoop;未来通过 `--with-evidentloop` 一次性配套安装。第四种只是安装便利,将在对应安装 Wave 落地前保持不可用;安装来源不进入运行判断或 receipt。 +以下形态都合法:只使用 Sopify;显式选择其他 Verifier;使用已独立安装的 EvidentLoop;通过 `--with-evidentloop` 一次性配套安装。第四种只是显式安装便利:Sopify 先完成,EvidentLoop 再独立尝试,失败不回滚 Sopify。新装组件使用当前发布验证过的版本和来源,已有组件只做兼容检查。Sopify 不声明复用 Skill 来源相同,不负责组件升级,也不自动提交 Copilot 项目 Skill;安装来源不进入运行判断或 receipt。 Sopify 不默认提示、安装或执行任何 Verifier。独立审计可不绑定方案;方案主审计使用 `audits/plan/`,附加审计使用 `audits//`。`audits/` 不进入 active plan、handoff 或 4 步默认读取链;正式采用的证据由宿主通过 writer 写 receipt。 @@ -48,7 +48,7 @@ Sopify 不默认提示、安装或执行任何 Verifier。独立审计可不绑 | **Now** | 跨宿主可恢复状态(Protocol + sopify_writer) | ✅ Codex / Claude / Qoder protocol_verified | | ~~**Now**~~ | ~~fail-closed 授权收据(ExecutionAuthorizationReceipt)~~ | ~~✅ P1.5 已交付~~ [RETIRED by P8] post-P8 审计主链改为 `plan//receipts/*.json` + `history//receipt.md` | | **Now** | blueprint-driven 知识沉淀 | ✅ 已交付 | -| **Emerging** | 可选独立审查(EvidentLoop 为首个推荐实例) | 非默认安装、非默认执行;不自动阻断 | +| **Emerging** | 可选独立审查(EvidentLoop 为首个推荐实例) | 四宿主配套安装契约已验证;非默认执行,Skill discovery 与审计 E2E 仍只按真实宿主证据声明 | | **Emerging** | Convention-first 外部宿主接入 | Protocol spec v0 已落地;缺少面向外部宿主的 quickstart | | **Future** | 硅基↔硅基信任协议(跨 agent 授权) | 概念定义阶段 | | **Future** | Plugin admission + Verifier evidence 标准化 | 设计阶段 | @@ -432,7 +432,7 @@ P4b 减重和 P4c 宿主消费治理的红线边界。只冻结 artifact / schem | `current_handoff.json` top-level: `schema_version`, `route_name`, `run_id`, `plan_id`, `plan_path`, `handoff_kind`, `required_host_action`, `artifacts`, `notes`, `observability`, `resolution_id` | machine_truth | host | schema | 宿主消费 handoff 做执行交接;跨宿主恢复的核心数据 | 内部组装方式(Python to_dict/from_dict);observability 子字段可演进;`recommended_skill_ids` 已在 6.3 裁定中退役(宿主从未消费) | | Archive truth — ArchiveCheckResult: `status`, `subject`, `notes`, `knowledge_sync_result` | machine_truth | host | schema | archive 前检查结果;宿主据此决定是否归档 | Python dataclass 名称和内部方法 | | Archive truth — ArchiveApplyResult: `status`, `subject`, `archived_plan`, `kb_artifact`, `notes`, `registry_updated`, `state_cleared`, `knowledge_sync_result` | machine_truth | host | schema | archive 执行结果的完整凭证 | Python dataclass 名称和内部方法 | -| `install.sh` / `install.ps1` user params: `--target`, `--ref`, `--workspace`, `-h` | install_contract | user | existence | 用户安装入口的稳定参数 | 内部转发参数(`--source-channel`, `--source-resolved-ref`, `--source-asset-name`);`SOURCE_CHANNEL`/`SOURCE_REF` 为 distribution metadata,非用户面稳定接口,默认值可变;freeze 口径以用户入口 contract 为主 | +| `install.sh` / `install.ps1` user params: `--target`, `--ref`, `--workspace`, `--language`, `--no-copilot`, `--with-evidentloop`, `--verbose`, `-h` | install_contract | user | existence | 用户安装入口的稳定参数;源码下载前选择 Python 3.11+,没有兼容解释器时不下载、不写入 | 内部转发参数(`--source-channel`, `--source-resolved-ref`, `--source-asset-name`);不自动安装 Python 或修改用户环境;`SOURCE_CHANNEL`/`SOURCE_REF` 为 distribution metadata,非用户面稳定接口,默认值可变 | | `builtin_catalog.generated.json` file-level: `schema_version`, `generated_at`, `source`, `skills`; per-skill: `id`, `names`, `descriptions`, `mode`, `entry_kind`, `handoff_kind`, `contract_version`, `supports_routes`, `triggers`, `metadata`, `tools`, `disallowed_tools`, `allowed_paths`, `requires_network`, `host_support`, `permission_mode`, `runtime_entry` | machine_truth | host | schema | 宿主消费 skill 清单做能力发现和 prompt 注入 | 具体 skill 枚举(能力上下线属内容变更,不违反 freeze);Python API 签名(`load_builtin_skills()`) | | Persistence: `blueprint/` `plan/` `history/` `project.md` | persistence_red_line | user / host | existence | 长期知识;人 + 宿主 + runtime 共同消费 | 目录内部文件结构(可增删文件) | | Persistence: `user/preferences.md` · `user/feedback.jsonl` | persistence_red_line | user / host | existence | 偏好审计;tracked 不可删 | 文件内部格式可演进 | diff --git a/.sopify/blueprint/tasks.md b/.sopify/blueprint/tasks.md index c75f5e88..606c1973 100644 --- a/.sopify/blueprint/tasks.md +++ b/.sopify/blueprint/tasks.md @@ -116,8 +116,7 @@ P0→P4c 主航道已全部完成。后续执行遵循以下原则: Sopify 只保留通用 Verifier 证据消费。EvidentLoop 是首个官方推荐的可选实例,不是默认能力或唯一实现。 -- [ ] 完成 `plan/20260718_evidentloop_optional_audit_integration/` 剩余 Wave:EvidentLoop 通用版本输出、公开 Alpha、可选配套安装、真实 dogfood 与归档 -- [ ] `--with-evidentloop` 落地前不得在公开文档中宣称该参数已可用 +- [ ] 完成 `plan/20260718_evidentloop_optional_audit_integration/` 剩余 Wave:Codex 真实 dogfood、用户裁定、正式 receipt 与归档 ## 流程与工具项 diff --git a/.sopify/plan/20260718_evidentloop_optional_audit_integration/design.md b/.sopify/plan/20260718_evidentloop_optional_audit_integration/design.md index 3bbdb13b..cda824fa 100644 --- a/.sopify/plan/20260718_evidentloop_optional_audit_integration/design.md +++ b/.sopify/plan/20260718_evidentloop_optional_audit_integration/design.md @@ -14,7 +14,7 @@ EvidentLoop 是独立产品,Sopify 是其通用能力的一个消费者。双方只通过正式报告和简明版本值连接,不增加专用 adapter 或共享运行状态。 -Sopify 的默认能力是接收产品无关的 `verdict / evidence / source`,不是内置某个验证器。EvidentLoop 是官方推荐、完成真实 dogfood 的首个验证器,但不默认安装、不默认执行,也不排斥其他实现。 +Sopify 的默认能力是接收产品无关的 `verdict / evidence / source`,不是内置某个验证器。EvidentLoop 是官方推荐并计划完成首轮真实 dogfood 的验证器,但不默认安装、不默认执行,也不排斥其他实现。 ## 合法使用形态 @@ -23,36 +23,37 @@ Sopify 的默认能力是接收产品无关的 `verdict / evidence / source`, | 仅 Sopify | plan、develop、handoff、receipt 正常工作;不运行外部审计 | 无 | | 其他验证器 | 用户显式指定后按其 Skill/契约运行;正式证据仍可写通用 receipt | 该验证器自身 | | 独立安装 EvidentLoop | 用户显式请求时直接做兼容探针并使用 | EvidentLoop 自身 | -| `--with-evidentloop` | 同一次 Sopify 安装中补齐缺失的官方 CLI 与 Skill;运行时与独立安装完全相同 | 安装便利由 Sopify 编排,产物仍归 EvidentLoop | +| `--with-evidentloop` | 同一次 Sopify 安装中补齐缺失的测试版本,或复用兼容组件;运行时与独立安装完全相同 | 安装便利由 Sopify 编排,产物仍归 EvidentLoop | `--with-evidentloop` 不是 capability flag。Sopify 不记录“由谁安装”,也不以安装参数决定运行资格;运行时只认用户意图、当前可发现的 Skill/CLI 和兼容性证据。 ## 可选配套安装 -`--with-evidentloop` 面向 Sopify 的多宿主安装入口。直接在现有安装器增加一个布尔分支和一组 EvidentLoop 常量/辅助函数,并在现有 `HostAdapter` 增加标准 Skills CLI agent id;不抽象 installer component framework,也不另建宿主注册表。 +`--with-evidentloop` 直接复用现有安装入口和 `HostAdapter`。适配器只增加 Skills CLI agent id 和明确的 Skill 目录;不建设通用组件框架或第二套宿主注册表。 -当前 Sopify 宿主复用现有 `HostAdapter`,只增加下列 Skills CLI 官方映射和用户级全局发现路径: +| Sopify target | Skills CLI agent | Skill 目录 | 范围 | +|---|---|---|---| +| `codex` | `codex` | `$HOME/.agents/skills/evidentloop/` | 当前用户 | +| `claude` | `claude-code` | `$HOME/.claude/skills/evidentloop/` | 当前用户 | +| `qoder` | `qoder` | `$HOME/.qoder/skills/evidentloop/` | 当前用户 | +| `copilot` | `github-copilot` | `/.github/skills/evidentloop/` | 当前项目 | -| Sopify target | Skills CLI agent | 全局发现路径 | -|---|---|---| -| `codex` | `codex` | `~/.codex/skills/` | -| `claude` | `claude-code` | `~/.claude/skills/` | -| `qoder` | `qoder` | `~/.qoder/skills/` | -| `copilot` | `github-copilot` | `~/.copilot/skills/` | +EvidentLoop CLI 始终作为当前用户的 `uv tool` 安装。新装 CLI 与 Skill 使用当前 Sopify 发布验证过的版本和来源;已有组件只做兼容检查,Sopify 不声明其来源相同。Skill 遵守上表的宿主范围,不能从 Sopify prompt 资产目录推导,也不能把 Copilot 改成用户级私有目录。新增宿主没有明确映射时,只有可选分支停止,Sopify 核心安装不受影响。 -Skill 安装固定使用 `-g`,不跟随 Sopify 核心资产的 workspace/global scope;兼容与结构探针也必须检查上表对应的 agent 全局发现路径,不从 Sopify `destination_dirname` 推导。这一点尤其适用于 Copilot:Sopify 核心资产可以是 workspace scope,EvidentLoop Skill 仍安装到 `~/.copilot/skills/`。后续 Sopify 新增宿主时,只有明确提供该映射才开放配套安装;缺映射只影响可选项,不影响 Sopify 核心安装。 +Sopify 本身要求 Python 3.11+。公开 shell/PowerShell 包装器在下载源码前按 `python3 → python → py -3` 选择第一个兼容解释器;没有兼容版本时显示检测结果并停止,不把它误报为 EvidentLoop 问题,也不自动安装 Python 或修改用户环境。 -安装分支固定为: +安装流程固定为: -1. 未传参数:走现有 Sopify 安装路径;不查找 `uv / npx / evidentloop`,不访问 EvidentLoop 网络来源,不改变核心 payload、`stdlib_only` 依赖声明或安装成功语义;只在 help/文档中披露可选参数。 -2. 已传参数:在核心写入前检查 target 是否有标准 Skill 安装映射,以及 `uv` 和 `npx`;缺映射或前置条件缺失时明确失败,不把可选依赖变成所有用户的基础要求。固定公开来源只用于随后安装缺失项,不建设额外远程解析层。 -3. CLI 与 Skill 都缺失:使用本次 Sopify 发布中固定的 EvidentLoop package 版本、Git tag 和已验证的 Skills CLI 版本安装;Skill 命令固定带 `-g -a -y`,随后运行 doctor、兼容探针和目标 agent 全局发现路径的 Skill 文件校验;不调用浮动的 `skills@latest`。 -4. 任一项已存在:先完成只读兼容检查;CLI 核对 package/schema/prompt 与必要命令,Skill 只核对目标 agent 全局发现路径的官方必备文件结构。兼容项复用,只安装缺失项。已有项不兼容时停止,既不替换已有安装,也不先安装另一半形成可预见的混合状态;Skill 是否被宿主真实发现仍由后续新会话 dogfood 证明。 -5. 安装中途失败:报告已完成和未完成项;不删除刚安装的外部产物。相同命令可根据只读探针复用成功项并补齐缺失项,不增加 rollback manager。 +1. 未传参数时,走原有 Sopify 安装路径;不查找 `evidentloop / uv / git / npx`,不访问 EvidentLoop 来源,不提示用户安装。 +2. 传入参数时也先完成并校验 Sopify,再只读检查已有 EvidentLoop CLI 与目标 Skill 目录。CLI 缺失才要求 `uv`;Skill 缺失才要求 Git 和 `npx`。已有组件不完整或不兼容时保持原样。 +3. CLI 缺失时安装固定的 `evidentloop==0.1.0a2`,随后通过 doctor、package/schema/prompt 版本和必要子命令探针,并确认未来宿主可从 `PATH` 解析同一可执行文件。若 uv 工具目录尚未进入 `PATH`,不自动修改 shell,只提示执行 `uv tool update-shell`、重启宿主后重跑。 +4. Skill 缺失时,在临时目录浅克隆 tag `v0.1.0a2`,要求 HEAD 等于 `fcefb77083d32b034e56b04dcd085dcf5a835550`,并先校验 Skill 结构。然后用固定的 Skills CLI `1.5.9` 从该本地目录执行 `--copy` 安装,不使用可变 tag URL 或 `skills@latest`。 +5. Codex、Claude、Qoder 使用 `-g -a -y --copy` 写入各自用户目录。Copilot 在临时项目生成 `.agents/skills/evidentloop/` 后校验,再只把 Skill 原子复制到目标项目 `.github/skills/evidentloop/`;临时 `.agents/` 和 `skills-lock.json` 不进入用户项目。目标 Skill 属于用户项目,用户按需审查和提交,Sopify 不自动提交或更新。 +6. 安装完成后再次校验最终 CLI 与 Skill。任一步未完成时不回滚 Sopify 或已有 EvidentLoop 产物,只告诉用户 Sopify 可正常使用,并给出独立安装或重跑入口;不解释外部命令内部错误。 -所有外部安装调用使用固定版本/tag 组成的 argv,不拼接用户输入,不读取或输出凭据。Skills CLI 子进程显式关闭其匿名遥测,但不修改用户持久环境。用户显式传入 `--with-evidentloop` 即授权本次缺失项安装,但不授权替换已有不兼容安装。 +所有外部命令只使用固定常量和已解析路径,不读取或输出凭据。遥测开关只进入 Skills CLI 子进程环境,不修改用户环境。安装结果显示 CLI 动作、Skill 动作、版本和最终 Skill 路径。 -安装成功只证明目标宿主具备 CLI 和 Skill 结构,不代表 Skill discovery 或审计已经运行。真实 E2E 由本方案在 Codex 上单独 dogfood;其他宿主沿用各自证据状态,后续可独立补证,不阻塞本次多宿主安装契约。 +这些证据只证明下载、目录结构、兼容性和安装链。Skill discovery 与实际审计仍需真实宿主会话证明。Copilot Skill 放在 GitHub 文档支持的 `.github/skills/`,但本机 `uv tool` 不会自动进入云端环境;本轮只承诺本地组件放置、兼容校验和 PATH 可见性。 ## 升级边界 diff --git a/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md b/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md index 96209954..42dad442 100644 --- a/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md +++ b/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md @@ -19,9 +19,9 @@ knowledge_sync: ## Plan Snapshot - **Goal**: 保留 Sopify 产品无关的验证证据能力,以 EvidentLoop 替代当前 CrossReview 集成,并提供不绑定运行时的一次性可选配套安装与公开版本 dogfood。 -- **Status**: Wave 1–3 与 Task 4.0 已完成;EvidentLoop `v0.1.0a2` 已公开发布,当前分发契约漂移已收口。 -- **Next**: 等待用户审计 Task 4.0;确认前不进入 Wave 4.1 的可选安装实现。 -- **Task**: 11/21。 +- **Status**: Wave 1–4 已完成;四宿主安装路径、固定来源、Python/PATH 预检、失败边界和安装承诺已收口。 +- **Next**: 停车等待用户确认是否进入 Wave 5。 +- **Task**: 14/21。 评分: - 方案质量: 9/10 @@ -47,7 +47,7 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif - 在 EvidentLoop 独立仓库补充通用 `diff_version / report_version` 输出,并通过下一公开 Alpha 提供给所有消费者。 - 在进入可选安装前收口当前分发契约:develop 完成态、active plan 读取入口和内置 Skill 的宿主支持声明保持一致。 - 保持 Sopify 默认只提供产品无关的 Verifier evidence 消费;不安装验证器也能完整使用 plan、develop、handoff 和 receipt。 -- 为 Sopify 可安装宿主增加显式 `--with-evidentloop` 配套安装:只在用户选择时为目标宿主安装缺失的官方固定版本 CLI 与 Skill;不把该参数作为运行能力开关。 +- 为 Sopify 可安装宿主增加显式 `--with-evidentloop` 配套安装:只在用户选择时安装缺失的当前发布测试版本,已有组件只做兼容检查;不把该参数作为运行能力开关。 - 允许用户直接使用已独立安装的 EvidentLoop 或其他验证组件,不要求经 Sopify 安装,不增加验证器注册表。 - 在 Sopify 定义独立审计、方案主审计和附加审计三种消费方式,不新增默认触发或专用命令。 - 使用公开 EvidentLoop CLI 与官方 Skill 审计本方案冻结后的“实现与契约 diff”,完成人工裁定、报告附着和 receipt 记录。 @@ -84,10 +84,13 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif - EvidentLoop 通用提供 `diff_version` 与 `report_version`;Sopify 自己计算 `plan_version` 并建立 receipt 关联。EvidentLoop 不增加 `plan_id`、Sopify adapter、receipt 或 plan/design profile。 - Sopify 默认能力是消费通用 `verdict / evidence / source`;EvidentLoop 是官方推荐、可选配套安装的首个验证器,不是默认安装、默认执行或唯一实现。 - `--with-evidentloop` 默认关闭且只提供安装便利,不写 `evidentloop_enabled`、component registry、state 或持久安装来源。未选择时保持现有安装路径和 `stdlib_only` 核心依赖模型。 -- 配套安装面向 Sopify 已有可安装宿主,通过现有 host adapter 映射到标准 Skills CLI agent target:`codex → codex`、`claude → claude-code`、`qoder → qoder`、`copilot → github-copilot`。Skill 固定使用 `-g` 安装到目标宿主的用户级全局发现路径;新增宿主缺少映射时才明确返回未支持,不另建宿主框架。 +- Sopify 公开安装入口要求 Python 3.11+;包装器在源码下载前选择第一个兼容解释器,没有时明确停止。它不静默跳过显式配套安装,不自动安装 Python,也不修改用户环境。 +- 配套安装复用现有 host adapter,但每个宿主使用自己的接入范围:Codex 为 `$HOME/.agents/skills/evidentloop/`,Claude 为 `$HOME/.claude/skills/evidentloop/`,Qoder 为 `$HOME/.qoder/skills/evidentloop/`,Copilot 为当前项目的 `.github/skills/evidentloop/`。新增宿主没有明确映射时停止,不猜路径。 +- CLI 统一作为当前用户的 `uv tool` 安装;新装 Skill 通过固定 Skills CLI 版本和固定源码 commit 获取。已有组件通过兼容探针即可复用,Sopify 不声明其来源与固定 commit 相同。Codex、Claude、Qoder 使用对应 agent target 的全局安装;Copilot 先在临时目录下载并校验,再只复制 Skill 到 `.github/skills/`,不在项目写 `.agents/skills/` 或 `skills-lock.json`。 +- Sopify 核心安装和校验先完成,再按缺失组件检查 EvidentLoop 前置依赖:CLI 已兼容时不要求 `uv`,Skill 已兼容时不要求 Git 和 `npx`。EvidentLoop 未完成时不回滚或否定 Sopify,只说明可稍后独立安装或重跑;已有组件不完整或不兼容时保持原样,不自动升级、降级或覆盖。 +- Copilot Skill 放在 GitHub 文档支持的项目目录,属于用户项目内容;用户按需审查和提交,Sopify 不自动提交或更新。本机 `uv tool` 不会自动进入云端环境。本方案只承诺本地组件放置、兼容校验和 PATH 可见性;Skill discovery 与审计 E2E 仍需宿主证据,云端 CLI 需另行提供。 - Codex 是本方案首轮真实 E2E dogfood 宿主,不是配套安装的唯一宿主。其他宿主必须区分“安装链/结构已验证”和“Skill discovery/审计 E2E 已验证”,不把安装成功宣传为完整可用证据。 - 内置 Skill 的 `host_support` 只表示其语义可由官方适配器交付到对应宿主的支持界面并被消费;它不代表原生 Skill discovery 或 E2E 已验证,能力等级继续由 `HostCapability` 表达。 -- 带参数时,缺失项按 Sopify 发布时固定的 EvidentLoop 公开版本与 tag 安装;已存在且通过兼容探针时直接复用;已存在但不兼容时停止并说明,不静默覆盖、降级或升级。 - 本方案不自动管理 EvidentLoop 后续升级。用户可按 EvidentLoop 官方方式独立升级;升级后只要兼容探针通过,Sopify 即可使用。自动升级待真实需求出现后另行设计。 - 用户使用其他验证器时沿用通用 Verifier evidence 与 receipt,不要求 `diff_version / report_version`、EvidentLoop 报告目录或安装参数。 - 独立 EvidentLoop 审计沿用产品默认目录,不绑定 Sopify 方案;方案主审计使用 `audits/plan/` 并必须写 receipt;附加审计使用 `audits//`,仅在作为正式方案证据时写 receipt。 @@ -134,8 +137,10 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif - [x] Wave 3 的 EvidentLoop 通用版本实现、定向验证与 370 项全量回归完成。 - [x] EvidentLoop `v0.1.0a2` 已发布:[Publish](https://github.com/evidentloop/evidentloop/actions/runs/29678876261) 成功,[PyPI](https://pypi.org/project/evidentloop/0.1.0a2/) 包含未撤回的 wheel 与 sdist,[GitHub prerelease](https://github.com/evidentloop/evidentloop/releases/tag/v0.1.0a2) 为非草稿;schema `0.4`、prompt `v0.5` 保持不变,Release 无额外 assets 不阻断。 - [x] Task 4.0 已完成:develop/finalize 生命周期、active-plan 读取入口和四宿主 `host_support` 已双语收口;96 tests、36 subtests 与 Ruff 通过。 -- [ ] Wave 4–6、公开版本 dogfood 和归档完成。 +- [x] Wave 4 已按宿主原生路径重写:固定 tag 解析到预期 commit;Skills CLI 在隔离目录真实写入 Codex `.agents`、Claude `.claude`、Qoder `.qoder`,安装 helper 真实把 Copilot 临时产物只复制到项目 `.github/skills/`。 +- [x] Wave 4 回归已收口:268 tests、74 subtests 与 release preflight 的 99 项硬门禁、全部协议 smoke、增量 Ruff、README 校验、shell 语法和 diff check 均通过;公开包装器已补齐下载前 Python 3.11+ 预检。后续按用户决策进一步收窄为“Sopify 先完成,EvidentLoop 失败只说明独立安装下一步”,不展示外部命令诊断。 +- [ ] Wave 5–6、公开版本真实 dogfood、用户裁定和归档完成。 ## Next -等待用户审计 Task 4.0;确认前不进入 Wave 4.1 的 `--with-evidentloop` 实现。 +停车等待用户确认是否进入 Wave 5。 diff --git a/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md b/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md index 99f1f849..3f4fd287 100644 --- a/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md +++ b/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md @@ -42,15 +42,17 @@ - 验收:中英文 header 与 develop Skill/rules 统一为“develop 完成后更新 `plan.md` 的 `ready_to_archive` 生命周期元数据并保留在 `plan/`;只有显式 `~go finalize` 才通过 writer 归档”。 - 验收:中英文 `$kb/$templates` 使用 `state/active_plan.json → plan//plan.md`,删除退役的 `current_plan.path + current_plan.files` 口径。 - 验收:五个内置 Skill 都声明 `codex / claude / qoder / copilot`,同步生成 catalog 和最小防漂移测试;`host_support` 只表示官方适配器可交付并消费 Skill 语义,不替代 `HostCapability` 的能力等级与 E2E 证据。 -- [ ] 4.1 在现有 Python installer、远程 shell/PowerShell wrapper 和用户文档中增加默认关闭的 `--with-evidentloop`;通过现有 HostAdapter 固定映射 `codex → codex`、`claude → claude-code`、`qoder → qoder`、`copilot → github-copilot`,并在核心写入前校验映射、`uv` 和 `npx`。 - - 验收:不带参数时不执行任何 EvidentLoop lookup、网络访问或提示,现有安装成功语义、payload 和 `stdlib_only` 依赖声明保持不变;只有 help/文档新增可选参数说明。缺少 Skill target 映射的新宿主明确停止可选分支。 -- [ ] 4.2 用一个 EvidentLoop 专用安装 helper 实现最小分支,不建立通用 component framework:兼容安装直接复用,缺失的 CLI/Skill 按固定 package 版本和 Git tag 补齐,已有不兼容项时不修改。 - - 验收:CLI 与 Skill 分别探测后再写入;中途失败报告部分结果,同一命令可复用成功项后补齐,不删除外部产物。 - - 验收:固定 Skills CLI 版本,使用 `-g -a -y` 非交互安装到目标 agent 的用户级全局 scope,不使用 `@latest`;仅在子进程环境关闭匿名遥测。 - - 验收:兼容与结构探针分别检查 `~/.codex/skills/`、`~/.claude/skills/`、`~/.qoder/skills/`、`~/.copilot/skills/`,不从 Sopify `destination_dirname` 推导 Skill 路径。 - - 验收:不写 `evidentloop_enabled`、安装来源、component registry 或 state;运行时不读取 installer flag。 - - 验收:本轮不自动升级、降级或卸载。用户独立升级后只要兼容探针通过即可使用;不兼容时给出事实和官方处理入口,不静默修复。 -- [ ] 4.3 增加安装器定向测试和结构检查,覆盖各现有宿主的 Skills CLI target 映射、`-g` 全局 scope 和 agent 发现路径(包括 Copilot 不跟随 Sopify workspace destination),以及未选择、缺映射、缺少前置依赖、全新安装、单项缺失、兼容复用、不兼容停车和失败后重跑;另证明非 EvidentLoop verifier receipt 不需要 ELoop 专用字段。 +- [x] 4.1 在现有 Python installer、shell/PowerShell wrapper 和用户文档中增加默认关闭的 `--with-evidentloop`,并在 HostAdapter 明确四个宿主的 Skill 目标:Codex `$HOME/.agents/skills/`、Claude `$HOME/.claude/skills/`、Qoder `$HOME/.qoder/skills/`、Copilot `/.github/skills/`。 + - 验收:不带参数时不查找命令、不联网、不安装、不提示;现有核心安装、payload 和 `stdlib_only` 依赖声明不变。带参数时也先完成 Sopify;新宿主缺少映射或 EvidentLoop 未完成时只停止可选分支,不回滚或否定 Sopify。 + - 验收:公开包装器在下载前选择 Python 3.11+;没有兼容解释器时说明检测结果和未发生下载/安装,不静默跳过、不自动安装 Python、不修改用户环境。 +- [x] 4.2 用一个 EvidentLoop 专用 helper 安装或复用 CLI 与 Skill,不建立通用组件框架。 + - 验收:新装 CLI 固定为当前用户的 `evidentloop==0.1.0a2`;新装 Skill 固定到源码 commit `fcefb77083d32b034e56b04dcd085dcf5a835550`,Skills CLI 固定为 `1.5.9`,不使用可变 tag 或 `@latest`。 + - 验收:只为缺失组件检查前置命令;CLI 缺失才要求 `uv`,Skill 缺失才要求 Git 和 `npx`。新装 CLI 必须能从 `PATH` 解析到同一文件;不可见时不改 shell,保留已安装 CLI 并给出 `uv tool update-shell`、重启和重跑提示。已有兼容组件直接复用,但只声明通过兼容检查,不声明来源相同;不完整或不兼容组件保持原样并停止。中途失败说明已完成部分,同一命令可继续补齐。 + - 验收:Codex、Claude、Qoder 使用对应 agent target 的用户级安装;Copilot 在临时目录下载并校验后,只复制 Skill 到项目 `.github/skills/evidentloop/`,不写项目 `.agents/skills/` 或 `skills-lock.json`,也不自动提交或更新用户项目内容。仅子进程关闭匿名遥测。 + - 验收:不写 `evidentloop_enabled`、安装来源、component registry 或 state;不自动升级、降级、覆盖或卸载。 +- [x] 4.3 用精简测试和隔离安装证据验证四宿主路径、固定命令、真实 Skill 目录结构、Copilot 定向复制、默认关闭、缺映射、按组件前置检查、兼容复用、不兼容停车、部分完成提示和输出错误码。 + - 验收:安装/结构证据与 Skill discovery/E2E 结论分开;没有真实宿主证据时不宣称 discovery 已验证。Copilot 文档明确项目 Skill 与本机 CLI 的云端边界。 + - 证据:268 tests、74 subtests、release preflight 99 项硬门禁与全部协议 smoke 通过;来源、Copilot 项目归属和“Sopify 先完成、EvidentLoop 失败可独立安装”的承诺已最小收窄,无外部错误诊断框架。 ## Wave 5 | 验证冻结与公开版本真实 Dogfood diff --git a/.sopify/project.md b/.sopify/project.md index e4ece9e7..83e96d6e 100644 --- a/.sopify/project.md +++ b/.sopify/project.md @@ -30,6 +30,7 @@ - `sopify_writer/` 是 protocol state 与 receipts 的唯一写路径;`ProtocolStore` 通过 `sopify_writer.store` 访问。 - `sopify_contracts/` 定义 schema 与共享数据结构(`RuntimeHandoff` 等),是所有写回操作的契约基线。 - `installer/` 负责 payload 分发、workspace bootstrap、doctor/inspection;不再打包 `runtime/` 目录。 +- 公开 `install.sh / install.ps1` 要求 Python 3.11+;下载源码前按 `python3 → python → py -3` 选择第一个兼容解释器,没有兼容版本时明确停止,不自动安装 Python 或修改用户环境。 - repo-local 测试统一使用 Python 3.11+ 执行 `python -m pytest tests -v`;测试文件按 `test_sopify_writer` / `test_installer` / `test_distribution` / `protocol/` 分组。 - `runtime/` 目录已在 P8 W2.10 物理删除(46 文件 / ~15.6K LOC);不再存在 runtime facade、runtime engine、runtime gate。 - `scripts/sopify_protocol_check.py` 是 CI/preflight 协议合规 smoke(3 场景:new-plan / continuation / finalize);不得 import runtime。 @@ -47,4 +48,4 @@ - develop 质量结果的正式字段固定为:`verification_source / command / scope / result / reason_code / retry_count / root_cause / review_result`。 - `result` 的稳定值域固定为:`passed / retried / failed / skipped / replan_required`;`root_cause` 的稳定值域固定为:`logic_regression / environment_or_dependency / missing_test_infra / scope_or_design_mismatch`。 - 当 `result == replan_required` 或 `root_cause == scope_or_design_mismatch` 时,宿主不得继续盲修;必须停下来向用户报告根因并等待方向指示。 -- 当前仓库暂不在 `project.md` 固定单一默认 verify 命令;在解释器基线统一到 Python 3.11+ 之前,未识别到稳定命令时应走 `not_configured` 可见降级,而不是假定默认测试入口存在。 +- 当前仓库不因 Python 3.11+ 基线而假定单一默认 verify 命令;未识别到稳定命令时应走 `not_configured` 可见降级。 diff --git a/README.md b/README.md index 90370153..8323b7f9 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,14 @@ No new editor, no new CLI. Install into the host you already use — Codex, Clau curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/install.sh | bash -s -- --target codex:en-US ``` +Sopify requires Python 3.11 or newer; the installer checks before downloading. This +installs Sopify only. [EvidentLoop](https://github.com/evidentloop/evidentloop) is an +optional code-change audit tool: it turns a local Git diff into an interactive HTML report +with findings linked to changed lines and user feedback. Sopify can keep that report as +plan audit evidence. To install the versions tested with this Sopify release—or reuse +compatible existing components—append `--with-evidentloop`. Without that flag, the +installer does not inspect or install EvidentLoop. + 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?** 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. @@ -135,6 +143,21 @@ Host support: Pass `--workspace ` to target another repo, `--language ` to control output language. +The Quick Start flag also works with the audit-first script: + +```bash +bash sopify-install.sh --target codex:en-US --with-evidentloop +``` + +The CLI is installed for the current user. The Skill uses the host's supported path: +Codex `$HOME/.agents/skills/evidentloop/`, Claude `$HOME/.claude/skills/evidentloop/`, +Qoder `$HOME/.qoder/skills/evidentloop/`, or Copilot +`/.github/skills/evidentloop/`. Existing components are compatibility-checked +before reuse; Sopify does not claim they match the pinned source. Incompatible components +are left unchanged. The Copilot path belongs to your project; Sopify does not commit or +update it. See [Getting Started](./docs/getting-started.md#optional-evidentloop-companion) +for prerequisites and the local/cloud boundary. + For the full setup guide, see [Getting Started](./docs/getting-started.md). For a step-by-step demo, see [External Repo Quickstart](./examples/external-repo-quickstart/README.md). ## Command Reference diff --git a/README.zh-CN.md b/README.zh-CN.md index 0273e27d..5b36f170 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -56,6 +56,13 @@ Sopify 把方案和验证收据保存在 `.sopify/` 中,作为可纳入 git curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/install.sh | bash -s -- --target codex:zh-CN ``` +Sopify 需要 Python 3.11 或更高版本;安装器会在下载前检查。这条命令只安装 +Sopify。[EvidentLoop](https://github.com/evidentloop/evidentloop) 是一个可选的代码变更 +审计工具:它把本地 Git diff 变成可交互、可反馈的 HTML 报告,审计问题可回链到 +具体修改行;Sopify 可将这份报告作为方案审计证据。若要同时安装本次 Sopify 发布 +验证过的版本,或复用已有兼容组件,在命令末尾加 `--with-evidentloop`;不加时, +安装器不会探测或安装它。 + 安装后用 `~go` 启动全托管工作流。审查优先安装、其他宿主和 Windows 请看[安装说明](#安装说明)。 **已在 Sopify 管理的仓库里?** 明确说“继续”或使用 `~go`,即可恢复当前方案。普通问答和小范围修改会按当前请求直接处理,不会自动进入之前的方案流程。 @@ -135,6 +142,20 @@ Get-Content sopify-install.ps1 | more 可用 `--workspace ` 指定目标仓库,`--language ` 控制输出语言。 +快速开始里的可选参数也适用于审查后执行: + +```bash +bash sopify-install.sh --target codex:zh-CN --with-evidentloop +``` + +CLI 安装给当前用户;Skill 使用宿主支持的目录:Codex 为 +`$HOME/.agents/skills/evidentloop/`,Claude 为 `$HOME/.claude/skills/evidentloop/`, +Qoder 为 `$HOME/.qoder/skills/evidentloop/`,Copilot 为当前项目的 +`/.github/skills/evidentloop/`。已有组件通过兼容检查后复用;Sopify 不声明 +它们与固定来源完全相同。不兼容组件保持原样。Copilot 目录属于你的项目,Sopify +不会自动提交或更新。前置条件和本地/云端边界见 +[Getting Started](./docs/getting-started.md#optional-evidentloop-companion)。 + 完整设置指南见 [Getting Started](./docs/getting-started.md)。分步 demo 见 [External Repo Quickstart](./examples/external-repo-quickstart/README.md)。 ## 命令参考 diff --git a/docs/getting-started.md b/docs/getting-started.md index dca5c108..485e08b6 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,6 +52,58 @@ git clone https://github.com/evidentloop/sopify.git /tmp/sopify python3 /tmp/sopify/scripts/sopify_init.py init --workspace . ``` +## Optional EvidentLoop Companion + +Sopify works without EvidentLoop. [EvidentLoop](https://github.com/evidentloop/evidentloop) +turns a local Git diff into an interactive, feedback-ready HTML audit report. To install +the versions tested with this Sopify release, or reuse compatible existing components, +opt in explicitly: + +```bash +curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/install.sh | bash -s -- --target codex:en-US --with-evidentloop +``` + +If you downloaded the installer for inspection, pass the same flag to +`bash sopify-install.sh`. + +The option is off by default and is not saved as a runtime setting. The CLI is a +current-user `uv tool`; the Skill path depends on the target host: + +| Target | EvidentLoop Skill path | Scope | +|--------|-------------------------|-------| +| Codex | `$HOME/.agents/skills/evidentloop/` | Current user | +| Claude | `$HOME/.claude/skills/evidentloop/` | Current user | +| Qoder | `$HOME/.qoder/skills/evidentloop/` | Current user | +| Copilot | `/.github/skills/evidentloop/` | Current project | + +These locations follow the host documentation for +[Codex](https://learn.chatgpt.com/docs/build-skills), +[Claude Code](https://code.claude.com/docs/en/slash-commands), +[Qoder](https://docs.qoder.com/en/cli/Skills), and +[GitHub Copilot](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/add-skills). + +For new installs, Sopify uses EvidentLoop `0.1.0a2`, Skills CLI `1.5.9`, and the tested +Skill source commit. Existing components are reused only after compatibility checks; +Sopify does not claim they are identical to that source. `uv` is required only when the +CLI is missing; Git and `npx` only when the Skill is missing. Incomplete or incompatible +components are reported and left unchanged. Sopify is installed first and remains usable +if the optional EvidentLoop install does not finish. You can install EvidentLoop separately +later from its official repository, or rerun the same command. + +After a fresh CLI install, the installer also checks that a future host can resolve +`evidentloop` from `PATH`. If the uv tool directory is not visible yet, it leaves the +validated CLI in place and asks you to run `uv tool update-shell`, restart the shell and +AI host, then rerun the same command. It never edits your shell profile automatically. + +For Copilot, pass `--workspace ` or run the installer from the target project. +The installer writes only `.github/skills/evidentloop/`; it does not add a project +`.agents/skills/` copy or `skills-lock.json`. That is GitHub's documented project Skill +location and becomes part of your project: review it, and commit it yourself if a cloud +workflow needs it. Sopify does not commit or update it. A cloud-hosted agent also does not +automatically receive the EvidentLoop CLI installed on your local machine. This option +proves local component placement and compatibility only; Skill discovery and audit E2E +still require host evidence, and cloud CLI availability must be provisioned separately. + ## Host-Specific Setup ### Codex / Claude diff --git a/docs/how-sopify-works.en.md b/docs/how-sopify-works.en.md index b734e360..e007ad25 100644 --- a/docs/how-sopify-works.en.md +++ b/docs/how-sopify-works.en.md @@ -60,7 +60,7 @@ When the host detects `.sopify/` and the user request targets managed plan / con ### Optional Verifiers -Sopify does not install or run a verifier by default. Sopify alone, an explicitly selected alternative verifier, and an independently installed EvidentLoop are all valid paths. `--with-evidentloop` is a planned one-command installation convenience and remains unavailable until its installer wave lands; it will not be a runtime switch. +Sopify does not install or run a verifier by default. Sopify alone, another verifier, and an independently installed EvidentLoop are all valid paths. `--with-evidentloop` is an explicit install convenience, not a runtime switch: new components use versions and sources tested with the current Sopify release, while existing components are reused only after compatibility checks. Sopify does not claim a reused Skill is identical to the tested source, and it does not upgrade, remove, or track components. The Copilot Skill is project-owned content in `.github/skills/evidentloop/`; users review and commit it when needed, and Sopify does not commit or update it. Installation proves local placement and compatibility only; Skill discovery and audit E2E still require host evidence, and cloud agents do not inherit the locally installed CLI. Independent audits do not need a plan binding. A formal plan-wide audit may live in `audits/plan/`, while focused audits may use `audits//`; the host writes a receipt only when the user adopts the result as formal evidence. `audits/` is not part of the default 4-step read chain. diff --git a/docs/how-sopify-works.md b/docs/how-sopify-works.md index e68817ac..0daf1c8e 100644 --- a/docs/how-sopify-works.md +++ b/docs/how-sopify-works.md @@ -60,7 +60,7 @@ Sopify 把 AI 开发过程中的**方案、决策、交接、执行/验证证据 ### 可选验证器 -Sopify 不默认安装或运行验证器。只用 Sopify、显式使用其他 Verifier、使用已独立安装的 EvidentLoop 都是完整合法的路径。`--with-evidentloop` 是规划中的一次性配套安装便利,安装器落地前不可用;它不会成为运行开关。 +Sopify 不默认安装或运行验证器。只用 Sopify、使用其他 Verifier、使用已独立安装的 EvidentLoop 都是完整路径。`--with-evidentloop` 只是显式安装便利,不是运行开关:新装组件使用本次 Sopify 发布验证过的版本和来源,已有组件只在兼容检查通过后复用。Sopify 不声明复用的 Skill 与固定来源完全相同,也不升级、卸载或记录组件状态。Copilot Skill 是项目中的 `.github/skills/evidentloop/` 内容;用户按需审查和提交,Sopify 不会自动提交或更新。安装成功只证明本地放置和兼容性;Skill discovery 与审计 E2E 仍需宿主证据,云端也不会自动获得本机安装的 CLI。 独立审计不必绑定方案。正式方案主审计可放在 `audits/plan/`,针对性审计可放在 `audits//`;只有用户采用为正式证据时,宿主才通过 writer 写 receipt。`audits/` 不进入 4 步默认读取链。 diff --git a/install.ps1 b/install.ps1 index d29a1f9c..c9ab08f8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -9,13 +9,13 @@ $SourceRef = "main" function Show-Usage { @" -Usage: install.ps1 [--target ] [--ref ] +Usage: install.ps1 [--target ] [--with-evidentloop] [--ref ] Install Sopify for a supported AI host. Use `--target copilot` to bootstrap the current workspace and write Copilot instruction files. For Codex / Claude, this installs the host prompt and -Sopify runtime only. Project files are initialized later when you run `~go` +Sopify protocol kernel only. Project files are initialized later when you run `~go` inside a workspace. Options: @@ -26,6 +26,9 @@ Options: --language Copilot only: bootstrap output language (en-US/zh-CN). --no-copilot Copilot only: skip Copilot instruction file distribution. + --with-evidentloop Install the EvidentLoop CLI and Skill versions tested + with this Sopify release, or reuse compatible existing + components. Disabled by default. --verbose Show full diagnostic install details. --ref Advanced: override the source ref. -h, --help Show this help. @@ -57,31 +60,45 @@ function Write-InstallStep { } function Resolve-PythonCommand { - $python3 = Get-Command python3 -ErrorAction SilentlyContinue - if ($null -ne $python3) { - return @{ - executable = $python3.Path - prefixArgs = @() - } - } + $foundPythonCommand = $false + $detectedPython = $null + $pythonProbe = 'import sys; print(".".join(map(str, sys.version_info[:3]))); raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' - $python = Get-Command python -ErrorAction SilentlyContinue - if ($null -ne $python) { - return @{ - executable = $python.Path - prefixArgs = @() + foreach ($candidateName in @("python3", "python", "py")) { + $command = Get-Command $candidateName -ErrorAction SilentlyContinue + if ($null -eq $command) { + continue } - } - - $py = Get-Command py -ErrorAction SilentlyContinue - if ($null -ne $py) { - return @{ - executable = $py.Path - prefixArgs = @("-3") + $foundPythonCommand = $true + $prefixArgs = if ($candidateName -eq "py") { @("-3") } else { @() } + $probeArgs = @() + $probeArgs += $prefixArgs + $probeArgs += @("-c", $pythonProbe) + $probeOutput = & $command.Path @probeArgs 2>$null + $probeExitCode = $LASTEXITCODE + $versionText = (($probeOutput | ForEach-Object { "$_" }) -join "").Trim() + if ($null -eq $detectedPython -and -not [string]::IsNullOrWhiteSpace($versionText)) { + $detectedPython = "$candidateName $versionText" + } + if ($probeExitCode -eq 0) { + return @{ + executable = $command.Path + prefixArgs = $prefixArgs + } } } - Fail-Install -Phase "preflight" -ReasonCode "MISSING_PYTHON" -Detail "None of `python3`, `python`, or `py -3` is available." -NextStep "Install Python 3, then rerun the installer." + if (-not $foundPythonCommand) { + $reasonCode = "MISSING_PYTHON" + $detail = "Sopify needs Python 3.11 or newer, but no Python command was found. Nothing was downloaded or installed." + } elseif (-not [string]::IsNullOrWhiteSpace($detectedPython)) { + $reasonCode = "UNSUPPORTED_PYTHON" + $detail = "Sopify needs Python 3.11 or newer. Found: $detectedPython. Nothing was downloaded or installed." + } else { + $reasonCode = "UNSUPPORTED_PYTHON" + $detail = "A Python command was found, but it could not run Python 3.11 or newer. Nothing was downloaded or installed." + } + Fail-Install -Phase "preflight" -ReasonCode $reasonCode -Detail $detail -NextStep "Install Python 3.11 or newer, then rerun the same command." } $forwardedArgs = New-Object System.Collections.Generic.List[string] diff --git a/install.sh b/install.sh index 38e584c0..03d8576f 100755 --- a/install.sh +++ b/install.sh @@ -9,7 +9,7 @@ SOURCE_REF="main" usage() { cat <<'EOF' -Usage: install.sh [--target ] [--ref ] +Usage: install.sh [--target ] [--with-evidentloop] [--ref ] Install Sopify for a supported AI host. @@ -26,6 +26,9 @@ Options: --language Copilot only: bootstrap output language (en-US/zh-CN). --no-copilot Copilot only: skip Copilot instruction file distribution and only write workspace markers. + --with-evidentloop Install the EvidentLoop CLI and Skill versions tested + with this Sopify release, or reuse compatible existing + components. Disabled by default. --verbose Show full diagnostic install details. --ref Advanced: override the source ref. -h, --help Show this help. @@ -154,20 +157,47 @@ spin_start "Checking requirements..." require_command "curl" "MISSING_CURL" "Install curl, or use the inspect-first flow to download the release asset manually." require_command "tar" "MISSING_TAR" "Install tar, or use a machine with basic archive support." -# Python fallback chain: python3 → python → py -3 (aligned with install.ps1) +# Select the first available Python 3.11+ before downloading any source. PYTHON_CMD="" +PYTHON_ARGS=() +FOUND_PYTHON_COMMAND=0 +DETECTED_PYTHON="" +PYTHON_PROBE='import sys; print(".".join(map(str, sys.version_info[:3]))); raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' for _candidate in python3 python py; do if command -v "$_candidate" >/dev/null 2>&1; then - PYTHON_CMD="$_candidate" - break + FOUND_PYTHON_COMMAND=1 + _probe_succeeded=0 + if [[ "$_candidate" == "py" ]]; then + if _version="$("$_candidate" -3 -c "$PYTHON_PROBE" 2>/dev/null)"; then + _probe_succeeded=1 + fi + elif _version="$("$_candidate" -c "$PYTHON_PROBE" 2>/dev/null)"; then + _probe_succeeded=1 + fi + if (( _probe_succeeded == 1 )); then + PYTHON_CMD="$_candidate" + if [[ "$_candidate" == "py" ]]; then + PYTHON_ARGS=("-3") + fi + break + fi + if [[ -z "$DETECTED_PYTHON" && -n "$_version" ]]; then + DETECTED_PYTHON="$_candidate $_version" + fi fi done if [[ -z "$PYTHON_CMD" ]]; then - fail "preflight" "MISSING_PYTHON" "None of python3, python, or py is available." "Install Python 3, then rerun the installer." -fi -PYTHON_ARGS=() -if [[ "$PYTHON_CMD" == "py" ]]; then - PYTHON_ARGS+=("-3") + if (( FOUND_PYTHON_COMMAND == 0 )); then + _python_reason="MISSING_PYTHON" + _python_detail="Sopify needs Python 3.11 or newer, but no Python command was found. Nothing was downloaded or installed." + elif [[ -n "$DETECTED_PYTHON" ]]; then + _python_reason="UNSUPPORTED_PYTHON" + _python_detail="Sopify needs Python 3.11 or newer. Found: $DETECTED_PYTHON. Nothing was downloaded or installed." + else + _python_reason="UNSUPPORTED_PYTHON" + _python_detail="A Python command was found, but it could not run Python 3.11 or newer. Nothing was downloaded or installed." + fi + fail "preflight" "$_python_reason" "$_python_detail" "Install Python 3.11 or newer, then rerun the same command." fi spin_stop "Requirements OK" diff --git a/installer/distribution.py b/installer/distribution.py index b6bc2fc9..7520d626 100644 --- a/installer/distribution.py +++ b/installer/distribution.py @@ -9,7 +9,13 @@ from installer.hosts import get_host_adapter, iter_host_registrations, iter_installable_hosts from installer.inspection import build_doctor_payload, build_status_payload -from installer.models import InstallError, InstallResult, InstallTarget, LANGUAGE_DIRECTORY_MAP +from installer.models import ( + EvidentLoopInstallResult, + InstallError, + InstallResult, + InstallTarget, + LANGUAGE_DIRECTORY_MAP, +) from installer.outcome_contract import render_outcome_summary SOURCE_CHANNEL_REPO_LOCAL = "repo-local" @@ -46,6 +52,7 @@ class DistributionRequest: interactive: bool source_channel: str source_metadata: DistributionSourceMetadata + with_evidentloop: bool = False @dataclass(frozen=True) @@ -119,12 +126,15 @@ def run_distribution_install( resolved_home = (home_root or Path.home()).expanduser().resolve() try: - install_result = install_executor( - target_value=target_value, - workspace_value=request.workspace, - repo_root=repo_root, - home_root=resolved_home, - ) + install_kwargs = { + "target_value": target_value, + "workspace_value": request.workspace, + "repo_root": repo_root, + "home_root": resolved_home, + } + if request.with_evidentloop: + install_kwargs["with_evidentloop"] = True + install_result = install_executor(**install_kwargs) except InstallError as exc: raise _map_install_error(exc) from exc @@ -207,6 +217,18 @@ def render_distribution_result(report: DistributionInstallReport) -> str: payload_outcome = render_outcome_summary(payload_bundle) workspace_outcome = render_outcome_summary(workspace_bundle) workspace_line = _render_workspace_line(install_result) + action_lines = [ + f" host prompt: {install_result.host_install.action}", + f" payload: {install_result.payload_install.action}", + f" workspace bootstrap: {_workspace_bootstrap_action(install_result)}", + ] + if install_result.evidentloop_install is not None: + action_lines.extend( + _companion_action_lines( + install_result.evidentloop_install, + language="en-US", + ) + ) lines = [ "Sopify already current:" if _is_noop_install(install_result) else "Installed Sopify successfully:", f" target: {install_result.target.value}", @@ -219,9 +241,7 @@ def render_distribution_result(report: DistributionInstallReport) -> str: f" bundle root: {install_result.bundle_root if install_result.bundle_root is not None else '(not requested)'}", "", "Install actions:", - f" host prompt: {install_result.host_install.action}", - f" payload: {install_result.payload_install.action}", - f" workspace bootstrap: {_workspace_bootstrap_action(install_result)}", + *action_lines, "", "Verification:", ( @@ -283,9 +303,14 @@ def render_distribution_user_result(report: DistributionInstallReport) -> str: def render_distribution_error(exc: DistributionError) -> str: """Render a stable error surface for shell, PowerShell, and repo-local installs.""" + title = ( + "Sopify installed; EvidentLoop was not installed:" + if exc.reason_code == "EVIDENTLOOP_COMPANION_INCOMPLETE" + else "Sopify install failed:" + ) return "\n".join( [ - "Sopify install failed:", + title, f" phase: {exc.phase}", f" reason_code: {exc.reason_code}", f" detail: {exc.detail}", @@ -296,6 +321,27 @@ def render_distribution_error(exc: DistributionError) -> str: def render_distribution_user_error(exc: DistributionError, *, language: str = "en-US") -> str: """Render a human-first install error while preserving diagnostic codes.""" + if exc.reason_code == "EVIDENTLOOP_COMPANION_INCOMPLETE": + if language == "zh-CN": + return "\n".join( + [ + "Sopify 已安装,可以正常使用。", + "EvidentLoop 未安装完成。", + "", + "下一步:", + " 稍后单独安装 EvidentLoop,或重新运行同一命令:", + " https://github.com/evidentloop/evidentloop", + ] + ) + return "\n".join( + [ + "Sopify is installed and ready to use.", + "EvidentLoop was not installed.", + "", + "Next:", + f" {exc.next_step}", + ] + ) if language == "zh-CN": lines = [ f"Sopify 安装失败:{exc.detail}", @@ -418,6 +464,38 @@ def _map_install_error(exc: InstallError) -> DistributionError: detail=detail, next_step="Omit `--workspace` and trigger Sopify inside that project instead. On first activation, choose whether to enable the current directory or the repository root.", ) + if detail.startswith("EvidentLoop companion install is not supported"): + return DistributionError( + phase="input", + reason_code="EVIDENTLOOP_HOST_UNSUPPORTED", + detail=detail, + next_step="Install Sopify without `--with-evidentloop`, or choose a host with a declared EvidentLoop Skill path.", + ) + if detail.startswith("EvidentLoop companion preflight failed"): + return DistributionError( + phase="input", + reason_code="EVIDENTLOOP_PREREQUISITE_MISSING", + detail=detail, + next_step="Install the missing command named above, then rerun the same installer command.", + ) + if detail.startswith("Existing EvidentLoop") or detail.startswith( + "EvidentLoop Skill directory is incomplete" + ): + return DistributionError( + phase="input", + reason_code="EVIDENTLOOP_INCOMPATIBLE", + detail=detail, + next_step="Keep the existing installation unchanged and resolve its version or directory contents using https://github.com/evidentloop/evidentloop#quick-start.", + ) + if detail.startswith( + "Sopify core installation completed, but EvidentLoop was not installed" + ): + return DistributionError( + phase="install", + reason_code="EVIDENTLOOP_COMPANION_INCOMPLETE", + detail=detail, + next_step="Install EvidentLoop separately later, or rerun the same command: https://github.com/evidentloop/evidentloop", + ) if detail.startswith("Missing source"): return DistributionError( phase="install", @@ -486,16 +564,23 @@ def _render_distribution_user_result_en(report: DistributionInstallReport) -> st install_result = report.install_result title = "Sopify is already current." if _is_noop_install(install_result) else "Sopify installed successfully." host_name = _host_display_name(install_result.target.host) - lines = [ - title, - "", - "Installed:", + installed_lines = [ f" Host: {host_name} ({install_result.target.value})", f" Language: {_language_display_name(install_result.target.language, 'en-US')}", f" Host prompt: {_action_label(install_result.host_install.action, 'en-US')}", f" Runtime: {install_result.payload_root}", f" Version: {install_result.payload_install.version or 'unknown'}", f" Runtime action: {_action_label(install_result.payload_install.action, 'en-US')}", + ] + if install_result.evidentloop_install is not None: + installed_lines.extend( + _companion_action_lines(install_result.evidentloop_install, language="en-US") + ) + lines = [ + title, + "", + "Installed:", + *installed_lines, "", "Project:", ] @@ -536,16 +621,23 @@ def _render_distribution_user_result_zh(report: DistributionInstallReport) -> st install_result = report.install_result title = "Sopify 已是最新。" if _is_noop_install(install_result) else "Sopify 安装完成。" host_name = _host_display_name(install_result.target.host) - lines = [ - title, - "", - "已安装:", + installed_lines = [ f" 宿主:{host_name}({install_result.target.value})", f" 语言:{_language_display_name(install_result.target.language, 'zh-CN')}", f" 宿主提示:{_action_label(install_result.host_install.action, 'zh-CN')}", f" 运行时:{install_result.payload_root}", f" 版本:{install_result.payload_install.version or 'unknown'}", f" 运行时操作:{_action_label(install_result.payload_install.action, 'zh-CN')}", + ] + if install_result.evidentloop_install is not None: + installed_lines.extend( + _companion_action_lines(install_result.evidentloop_install, language="zh-CN") + ) + lines = [ + title, + "", + "已安装:", + *installed_lines, "", "项目:", ] @@ -596,6 +688,9 @@ def _render_workspace_scope_result_zh(report: DistributionInstallReport) -> str: f" 宿主提示:{_action_label(install_result.host_install.action, 'zh-CN')}", f" 版本:{install_result.host_install.version or 'unknown'}", ] + if install_result.evidentloop_install is not None: + companion = install_result.evidentloop_install + lines.extend(_companion_action_lines(companion, language="zh-CN")) for p in install_result.host_install.paths: lines.append(f" 文件:{p}") lines.extend( @@ -623,6 +718,9 @@ def _render_workspace_scope_result_en(report: DistributionInstallReport) -> str: f" Host prompt: {_action_label(install_result.host_install.action, 'en-US')}", f" Version: {install_result.host_install.version or 'unknown'}", ] + if install_result.evidentloop_install is not None: + companion = install_result.evidentloop_install + lines.extend(_companion_action_lines(companion, language="en-US")) for p in install_result.host_install.paths: lines.append(f" File: {p}") lines.extend( @@ -707,10 +805,18 @@ def _workspace_bootstrap_action(install_result: InstallResult) -> str: def _is_noop_install(install_result: InstallResult) -> bool: + companion_noop = ( + install_result.evidentloop_install is None + or ( + install_result.evidentloop_install.cli_action == "reused" + and install_result.evidentloop_install.skill_action == "reused" + ) + ) return ( install_result.host_install.action == "skipped" and install_result.payload_install.action == "skipped" and install_result.workspace_root is None + and companion_noop ) @@ -726,6 +832,52 @@ def _friendly_smoke_summary(smoke_output: str, language: str) -> str: return first_line +def _companion_action_lines( + result: EvidentLoopInstallResult, + *, + language: str, +) -> list[str]: + if language == "zh-CN": + lines = [ + f" EvidentLoop CLI({result.package_version}):{_companion_action_label(result.cli_action, language)}", + f" EvidentLoop Skill:{_companion_action_label(result.skill_action, language)}", + f" EvidentLoop Skill 路径:{result.skill_path}", + ] + if _is_copilot_project_skill(result.skill_path): + lines.append( + " Copilot 项目 Skill:如需云端使用,请审查后自行提交;" + "Sopify 不会自动提交或更新。" + ) + return lines + lines = [ + f" EvidentLoop CLI ({result.package_version}): {_companion_action_label(result.cli_action, language)}", + f" EvidentLoop Skill: {_companion_action_label(result.skill_action, language)}", + f" EvidentLoop Skill path: {result.skill_path}", + ] + if _is_copilot_project_skill(result.skill_path): + lines.append( + " Copilot project Skill: review and commit it if your cloud workflow needs it; " + "Sopify will not commit or update it." + ) + return lines + + +def _is_copilot_project_skill(skill_path: Path) -> bool: + return skill_path.parts[-4:-1] == (".github", "skills", "evidentloop") + + +def _companion_action_label(action: str, language: str) -> str: + if action == "installed": + return ( + "已安装(本次 Sopify 发布验证版本)" + if language == "zh-CN" + else "installed (tested with this Sopify release)" + ) + if action == "reused": + return "已复用(已通过兼容检查)" if language == "zh-CN" else "reused (compatibility checked)" + return _action_label(action, language) + + def _host_display_name(host_id: str) -> str: return { "codex": "Codex", @@ -753,11 +905,13 @@ def _action_label(action: str, language: str) -> str: "installed": "已安装", "updated": "已更新", "skipped": "已是最新", + "reused": "已复用", "bootstrapped": "已初始化", }.get(action, action) return { "installed": "installed", "updated": "updated", "skipped": "already current", + "reused": "reused", "bootstrapped": "initialized", }.get(action, action) diff --git a/installer/evidentloop.py b/installer/evidentloop.py new file mode 100644 index 00000000..bc972cab --- /dev/null +++ b/installer/evidentloop.py @@ -0,0 +1,454 @@ +"""Explicit, optional EvidentLoop companion installation.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +from typing import Mapping + +from installer.hosts.base import HostAdapter +from installer.models import EvidentLoopInstallResult, InstallError + +EVIDENTLOOP_PACKAGE_VERSION = "0.1.0a2" +EVIDENTLOOP_SCHEMA_VERSION = "0.4" +EVIDENTLOOP_PROMPT_VERSION = "v0.5" +EVIDENTLOOP_SKILL_TAG = "v0.1.0a2" +EVIDENTLOOP_SKILL_COMMIT = "fcefb77083d32b034e56b04dcd085dcf5a835550" +EVIDENTLOOP_SKILL_REPOSITORY = "https://github.com/evidentloop/evidentloop.git" +SKILLS_CLI_VERSION = "1.5.9" + +_SKILL_NAME = "evidentloop" +_SKILL_ENTRYPOINT = "SKILL.md" +_SKILL_REQUIRED_REFERENCE = Path("references") / "codex-cli-isolation.md" +_SKILLS_CLI_PROJECT_DIR = Path(".agents") / "skills" +_REQUIRED_SUBCOMMANDS = ("prepare", "finalize", "render", "revise") +_OFFICIAL_INSTALL_GUIDE = "https://github.com/evidentloop/evidentloop#quick-start" + +_COMPATIBILITY_CODE = ( + "import json; import evidentloop; " + "from evidentloop.api import finalize_review, prepare_local_diff, " + "recover_interrupted_revision, render_audit_file, revise_audit; " + "from evidentloop.review.core.prompt import PRODUCT_REVIEWER_PROMPT_VERSION; " + "from evidentloop.validation import SCHEMA_VERSION; " + "print(json.dumps({'package_version': evidentloop.__version__, " + "'schema_version': SCHEMA_VERSION, " + "'prompt_version': PRODUCT_REVIEWER_PROMPT_VERSION}))" +) + + +@dataclass(frozen=True) +class EvidentLoopInstallPlan: + adapter: HostAdapter + home_root: Path + skill_dir: Path + uv_path: str | None + npx_path: str | None + git_path: str | None + install_cli: bool + install_skill: bool + + +def prepare_evidentloop_install( + adapter: HostAdapter, + *, + home_root: Path, + workspace_root: Path | None = None, +) -> EvidentLoopInstallPlan: + """Validate existing components and discover only the installers still needed.""" + if adapter.skills_cli_agent is None or adapter.skill_install_dirname is None: + raise InstallError( + f"EvidentLoop companion install is not supported for host '{adapter.host_name}': " + "missing Skill install mapping" + ) + + skill_dir = adapter.skill_install_path( + home_root=home_root, + workspace_root=workspace_root, + skill_name=_SKILL_NAME, + ) + skill_path_occupied = _path_is_occupied(skill_dir) + if skill_path_occupied: + _validate_skill(skill_dir) + + cli_executable: Path | None = None + discovered_cli = shutil.which("evidentloop") + if discovered_cli is not None: + cli_executable = Path(discovered_cli).expanduser().resolve() + _validate_cli(cli_executable, home_root=home_root) + + uv_path = shutil.which("uv") if cli_executable is None else None + npx_path = shutil.which("npx") if not skill_path_occupied else None + git_path = shutil.which("git") if not skill_path_occupied else None + missing: list[str] = [] + if cli_executable is None and uv_path is None: + missing.append("uv") + if not skill_path_occupied and npx_path is None: + missing.append("npx") + if not skill_path_occupied and git_path is None: + missing.append("git") + if missing: + raise InstallError( + "EvidentLoop companion preflight failed: " + f"missing required command(s): {', '.join(missing)}" + ) + + return EvidentLoopInstallPlan( + adapter=adapter, + home_root=home_root, + skill_dir=skill_dir, + uv_path=uv_path, + npx_path=npx_path, + git_path=git_path, + install_cli=cli_executable is None, + install_skill=not skill_path_occupied, + ) + + +def install_evidentloop_companion( + plan: EvidentLoopInstallPlan, +) -> EvidentLoopInstallResult: + """Install missing components and re-run the compatibility probes.""" + cli_action = "reused" + skill_action = "reused" + completed: list[str] = [] + + try: + if plan.install_cli: + if plan.uv_path is None: # Defensive: prepare() owns prerequisite checks. + raise InstallError("EvidentLoop CLI install requires uv") + _run_command( + [ + plan.uv_path, + "tool", + "install", + f"evidentloop=={EVIDENTLOOP_PACKAGE_VERSION}", + ], + env=_base_child_env(plan.home_root), + label="EvidentLoop CLI install", + ) + completed.append("CLI installed") + cli_executable = _uv_tool_executable(plan) + _validate_cli(cli_executable, home_root=plan.home_root) + _validate_cli_on_path(cli_executable) + cli_action = "installed" + else: + completed.append("CLI reused") + + if plan.install_skill: + try: + _install_skill(plan) + except InstallError: + if _path_is_occupied(plan.skill_dir): + completed.append("Skill path created but not validated") + raise + skill_action = "installed" + completed.append("Skill installed") + else: + completed.append("Skill reused") + except InstallError as exc: + progress = ", ".join(completed) or "no companion changes completed" + raise InstallError( + "EvidentLoop companion installation is incomplete " + f"({progress}); no external files were rolled back. " + f"Resolve the reported failure, then rerun the same command. {exc}" + ) from exc + + return EvidentLoopInstallResult( + cli_action=cli_action, + skill_action=skill_action, + package_version=EVIDENTLOOP_PACKAGE_VERSION, + skill_path=plan.skill_dir / _SKILL_ENTRYPOINT, + ) + + +def _install_skill(plan: EvidentLoopInstallPlan) -> None: + if plan.npx_path is None: # Defensive: prepare() owns prerequisite checks. + raise InstallError("EvidentLoop Skill install requires npx") + if plan.git_path is None: + raise InstallError("EvidentLoop Skill install requires git") + if _path_is_occupied(plan.skill_dir): + raise InstallError( + f"EvidentLoop Skill path changed after preflight: {plan.skill_dir}" + ) + + source_env = _base_child_env(plan.home_root) + skills_cli_env = dict(source_env) + skills_cli_env["DISABLE_TELEMETRY"] = "1" + skills_cli_env["DO_NOT_TRACK"] = "1" + with tempfile.TemporaryDirectory(prefix="sopify-evidentloop-") as temp_dir: + staging_root = Path(temp_dir) + source_root = staging_root / "source" + _run_command( + [ + plan.git_path, + "clone", + "--quiet", + "--depth", + "1", + "--branch", + EVIDENTLOOP_SKILL_TAG, + EVIDENTLOOP_SKILL_REPOSITORY, + str(source_root), + ], + env=source_env, + label="EvidentLoop Skill source download", + ) + resolved_commit = _run_command( + [plan.git_path, "-C", str(source_root), "rev-parse", "HEAD"], + env=source_env, + label="EvidentLoop Skill source verification", + ).stdout.strip() + if resolved_commit != EVIDENTLOOP_SKILL_COMMIT: + raise InstallError( + "EvidentLoop Skill source commit changed: " + f"expected {EVIDENTLOOP_SKILL_COMMIT}, got {resolved_commit or '(empty)'}" + ) + + source_skill = source_root / "skills" / _SKILL_NAME + _validate_skill(source_skill) + argv = [ + plan.npx_path, + "--yes", + f"skills@{SKILLS_CLI_VERSION}", + "add", + str(source_skill), + ] + + if plan.adapter.is_workspace_scope: + # Skills CLI targets Copilot through .agents/skills and a lockfile. + # Keep those staging files out of the project and copy only the Skill. + _run_command( + [ + *argv, + "-a", + str(plan.adapter.skills_cli_agent), + "-y", + "--copy", + ], + env=skills_cli_env, + label="EvidentLoop Skill download", + cwd=staging_root, + ) + staged_skill = staging_root / _SKILLS_CLI_PROJECT_DIR / _SKILL_NAME + _validate_skill(staged_skill) + _copy_skill_directory(staged_skill, plan.skill_dir) + else: + _run_command( + [ + *argv, + "-g", + "-a", + str(plan.adapter.skills_cli_agent), + "-y", + "--copy", + ], + env=skills_cli_env, + label="EvidentLoop Skill install", + cwd=staging_root, + ) + + _validate_skill(plan.skill_dir) + + +def _copy_skill_directory(source: Path, destination: Path) -> None: + if _path_is_occupied(destination): + raise InstallError( + f"EvidentLoop Skill path changed during installation: {destination}" + ) + try: + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix=".evidentloop-copy-", + dir=destination.parent, + ) as temp_dir: + staged_copy = Path(temp_dir) / destination.name + shutil.copytree(source, staged_copy) + staged_copy.replace(destination) + except OSError as exc: + raise InstallError( + f"EvidentLoop Skill could not be copied to {destination}" + ) from exc + + +def _validate_cli(executable: Path, *, home_root: Path) -> None: + if not executable.is_absolute(): + raise InstallError("Existing EvidentLoop CLI path is not absolute") + env = _isolated_python_env(home_root) + doctor = _run_command( + [str(executable), "doctor", "--json"], + env=env, + label="Existing EvidentLoop CLI doctor", + ) + payload = _json_object(doctor.stdout, label="Existing EvidentLoop CLI doctor") + if payload.get("status") != "ok": + raise InstallError( + "Existing EvidentLoop CLI is incompatible: doctor did not report status=ok. " + f"Manage the existing install via {_OFFICIAL_INSTALL_GUIDE}" + ) + python_value = payload.get("python_executable") + python_executable = Path(str(python_value or "")) + if not python_executable.is_absolute(): + raise InstallError( + "Existing EvidentLoop CLI doctor did not return an absolute python_executable" + ) + + probe = _run_command( + [str(python_executable), "-I", "-c", _COMPATIBILITY_CODE], + env=env, + label="Existing EvidentLoop CLI compatibility probe", + ) + versions = _json_object( + probe.stdout, + label="Existing EvidentLoop CLI compatibility probe", + ) + expected = { + "package_version": EVIDENTLOOP_PACKAGE_VERSION, + "schema_version": EVIDENTLOOP_SCHEMA_VERSION, + "prompt_version": EVIDENTLOOP_PROMPT_VERSION, + } + if versions != expected: + observed = ", ".join(f"{key}={versions.get(key)!r}" for key in expected) + required = ", ".join(f"{key}={value!r}" for key, value in expected.items()) + raise InstallError( + f"Existing EvidentLoop CLI is incompatible ({observed}); required {required}. " + "Sopify will not replace it automatically. Manage the existing install via " + f"{_OFFICIAL_INSTALL_GUIDE}" + ) + + help_result = _run_command( + [str(python_executable), "-I", "-m", "evidentloop", "--help"], + env=env, + label="Existing EvidentLoop CLI module probe", + ) + missing = [name for name in _REQUIRED_SUBCOMMANDS if name not in help_result.stdout] + if missing: + raise InstallError( + "Existing EvidentLoop CLI is incompatible: missing subcommand(s) " + + ", ".join(missing) + ) + + +def _validate_skill(skill_dir: Path) -> None: + entrypoint = skill_dir / _SKILL_ENTRYPOINT + if not skill_dir.is_dir() or not entrypoint.is_file(): + raise InstallError( + f"EvidentLoop Skill directory is incomplete: expected {entrypoint}" + ) + required_reference = skill_dir / _SKILL_REQUIRED_REFERENCE + if not required_reference.is_file(): + raise InstallError( + "EvidentLoop Skill directory is incomplete: missing referenced file " + f"{required_reference}" + ) + try: + content = entrypoint.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise InstallError(f"EvidentLoop Skill is unreadable: {entrypoint}") from exc + required_markers = ( + "name: evidentloop", + f"`package_version` equal to `{EVIDENTLOOP_PACKAGE_VERSION}`", + f"`schema_version` equal to `{EVIDENTLOOP_SCHEMA_VERSION}`", + f"`prompt_version` equal to `{EVIDENTLOOP_PROMPT_VERSION}`", + "references/codex-cli-isolation.md", + ) + if any(marker not in content for marker in required_markers): + raise InstallError( + f"Existing EvidentLoop Skill is incompatible at {entrypoint}; " + "Sopify will not replace it automatically. Manage the existing install via " + f"{_OFFICIAL_INSTALL_GUIDE}" + ) + + +def _path_is_occupied(path: Path) -> bool: + """Treat broken symlinks as existing so installation never overwrites them.""" + return path.exists() or path.is_symlink() + + +def _uv_tool_executable(plan: EvidentLoopInstallPlan) -> Path: + if plan.uv_path is None: + raise InstallError("uv executable is unavailable") + completed = _run_command( + [plan.uv_path, "tool", "dir", "--bin"], + env=_base_child_env(plan.home_root), + label="uv tool binary directory probe", + ) + bin_dir = Path(completed.stdout.strip()).expanduser() + if not bin_dir.is_absolute(): + raise InstallError("uv did not return an absolute tool binary directory") + executable = bin_dir / ("evidentloop.exe" if os.name == "nt" else "evidentloop") + if not executable.is_file(): + raise InstallError(f"EvidentLoop CLI is missing after install: {executable}") + return executable.resolve() + + +def _validate_cli_on_path(executable: Path) -> None: + discovered = shutil.which("evidentloop") + if discovered is None: + raise InstallError( + "EvidentLoop CLI was installed and validated at " + f"{executable}, but it is not discoverable on PATH. " + f"Add {executable.parent} to PATH (for uv, run `uv tool update-shell`), " + "restart the shell and AI host, then rerun the same install command" + ) + discovered_path = Path(discovered).expanduser().resolve() + if discovered_path != executable: + raise InstallError( + "EvidentLoop CLI was installed and validated at " + f"{executable}, but PATH resolves `evidentloop` to {discovered_path}. " + "Fix PATH precedence, restart the shell and AI host, then rerun the same " + "install command" + ) + + +def _run_command( + argv: list[str], + *, + env: Mapping[str, str], + label: str, + cwd: Path | None = None, +) -> subprocess.CompletedProcess[str]: + try: + completed = subprocess.run( + argv, + capture_output=True, + text=True, + check=False, + env=dict(env), + cwd=str(cwd) if cwd is not None else None, + ) + except OSError as exc: + raise InstallError(f"{label} could not start") from exc + if completed.returncode != 0: + raise InstallError(f"{label} failed with exit code {completed.returncode}") + return completed + + +def _json_object(raw_value: str, *, label: str) -> dict[str, object]: + try: + payload = json.loads(raw_value) + except (TypeError, json.JSONDecodeError) as exc: + raise InstallError(f"{label} did not return valid JSON") from exc + if not isinstance(payload, dict): + raise InstallError(f"{label} did not return a JSON object") + return payload + + +def _base_child_env(home_root: Path) -> dict[str, str]: + env = os.environ.copy() + env["HOME"] = str(home_root) + if os.name == "nt": + env["USERPROFILE"] = str(home_root) + return env + + +def _isolated_python_env(home_root: Path) -> dict[str, str]: + env = _base_child_env(home_root) + env.pop("PYTHONPATH", None) + env.pop("PYTHONHOME", None) + env["PYTHONNOUSERSITE"] = "1" + return env diff --git a/installer/hosts/base.py b/installer/hosts/base.py index 1727e0b8..eea23dc1 100644 --- a/installer/hosts/base.py +++ b/installer/hosts/base.py @@ -31,6 +31,8 @@ class HostAdapter: instruction_surface: str = field(default=INSTRUCTION_SURFACE_HEADER_EMBEDDED) instruction_file_relpath: str | None = field(default=None) default_language: str | None = field(default=None) + skills_cli_agent: str | None = field(default=None) + skill_install_dirname: str | None = field(default=None) @property def is_workspace_scope(self) -> bool: @@ -73,6 +75,28 @@ def expected_payload_paths(self, home_root: Path) -> tuple[Path, ...]: payload_root / "helpers" / "bootstrap_workspace.py", ) + def skill_install_path( + self, + *, + home_root: Path, + workspace_root: Path | None, + skill_name: str, + ) -> Path: + """Return the host-native install directory for an optional Skill.""" + if self.skill_install_dirname is None: + raise InstallError( + f"Host '{self.host_name}' does not declare a Skill install path" + ) + if self.is_workspace_scope: + if workspace_root is None: + raise InstallError( + f"Host '{self.host_name}' requires a workspace for Skill installation" + ) + root = workspace_root + else: + root = home_root + return root / self.skill_install_dirname / skill_name + @dataclass(frozen=True) class HostRegistration: diff --git a/installer/hosts/claude.py b/installer/hosts/claude.py index 7b8ea385..d4ff9ea8 100644 --- a/installer/hosts/claude.py +++ b/installer/hosts/claude.py @@ -11,6 +11,8 @@ destination_dirname=".claude", header_filename="CLAUDE.md", config_dir="~/.claude", + skills_cli_agent="claude-code", + skill_install_dirname=".claude/skills", ) CLAUDE_CAPABILITY = HostCapability( diff --git a/installer/hosts/codex.py b/installer/hosts/codex.py index ab1ccf19..f94348d0 100644 --- a/installer/hosts/codex.py +++ b/installer/hosts/codex.py @@ -11,6 +11,8 @@ destination_dirname=".codex", header_filename="AGENTS.md", config_dir="~/.codex", + skills_cli_agent="codex", + skill_install_dirname=".agents/skills", ) CODEX_CAPABILITY = HostCapability( diff --git a/installer/hosts/copilot.py b/installer/hosts/copilot.py index ce130298..f96a95c0 100644 --- a/installer/hosts/copilot.py +++ b/installer/hosts/copilot.py @@ -14,6 +14,8 @@ instruction_surface=INSTRUCTION_SURFACE_SINGLE_FILE, instruction_file_relpath=None, default_language="en-US", + skills_cli_agent="github-copilot", + skill_install_dirname=".github/skills", ) COPILOT_CAPABILITY = HostCapability( diff --git a/installer/hosts/qoder.py b/installer/hosts/qoder.py index 599f35ba..b56ba038 100644 --- a/installer/hosts/qoder.py +++ b/installer/hosts/qoder.py @@ -12,6 +12,8 @@ header_filename="AGENTS.md", config_dir="~/.qoder", default_language="zh-CN", + skills_cli_agent="qoder", + skill_install_dirname=".qoder/skills", ) QODER_CAPABILITY = HostCapability( diff --git a/installer/models.py b/installer/models.py index 5b0f8098..6b0dc730 100644 --- a/installer/models.py +++ b/installer/models.py @@ -133,6 +133,17 @@ class InstallResult: payload_install: "InstallPhaseResult" workspace_bootstrap: "BootstrapResult | None" smoke_output: str + evidentloop_install: "EvidentLoopInstallResult | None" = None + + +@dataclass(frozen=True) +class EvidentLoopInstallResult: + """Result of the explicitly requested EvidentLoop companion install.""" + + cli_action: str + skill_action: str + package_version: str + skill_path: Path @dataclass(frozen=True) diff --git a/scripts/install_sopify.py b/scripts/install_sopify.py index 5db823d7..5df0ba6f 100755 --- a/scripts/install_sopify.py +++ b/scripts/install_sopify.py @@ -23,9 +23,13 @@ render_distribution_user_result, run_distribution_install, ) +from installer.evidentloop import ( + install_evidentloop_companion, + prepare_evidentloop_install, +) from installer.hosts import get_host_adapter, iter_host_registrations, iter_installable_hosts -from installer.hosts.base import install_host_assets -from installer.models import BootstrapResult, InstallError, InstallPhaseResult, InstallResult, LANGUAGE_DIRECTORY_MAP, parse_install_target +from installer.hosts.base import HostAdapter, install_host_assets +from installer.models import BootstrapResult, EvidentLoopInstallResult, InstallError, InstallPhaseResult, InstallResult, LANGUAGE_DIRECTORY_MAP, parse_install_target from installer.payload import install_global_payload, run_workspace_bootstrap from installer.validate import ( validate_bundle_install, @@ -79,6 +83,15 @@ def build_parser() -> argparse.ArgumentParser: help="Advanced: override the remote source ref. Not supported for repo-local installs.", ) parser.add_argument("--verbose", action="store_true", help="Show full diagnostic install details.") + parser.add_argument( + "--with-evidentloop", + action="store_true", + help=( + "Install the EvidentLoop CLI and Skill versions tested with this Sopify release, " + "or reuse compatible existing components. This option is disabled by default " + "and is not a runtime switch." + ), + ) parser.add_argument("--source-channel", default="repo-local", help=argparse.SUPPRESS) parser.add_argument("--source-resolved-ref", default="working-tree", help=argparse.SUPPRESS) parser.add_argument("--source-asset-name", default="scripts/install_sopify.py", help=argparse.SUPPRESS) @@ -91,22 +104,22 @@ def run_install( workspace_value: str | None, repo_root: Path, home_root: Path | None = None, + with_evidentloop: bool = False, ) -> InstallResult | BootstrapOnlyResult: target = parse_install_target(target_value) workspace_root = Path(workspace_value).expanduser().resolve() if workspace_value is not None else None + adapter = get_host_adapter(target.host) + if adapter.is_workspace_scope and workspace_root is None: + workspace_root = Path.cwd().resolve() if workspace_root is not None and not workspace_root.exists(): raise InstallError(f"Workspace does not exist: {workspace_root}") if workspace_root is not None and not workspace_root.is_dir(): raise InstallError(f"Workspace is not a directory: {workspace_root}") resolved_home = (home_root or Path.home()).expanduser().resolve() - adapter = get_host_adapter(target.host) - if adapter.is_workspace_scope: # Workspace-scope hosts (e.g. Copilot): render single file to workspace, # skip payload install and workspace bootstrap. - if workspace_root is None: - workspace_root = Path(workspace_value or ".").expanduser().resolve() host_install = install_host_assets( adapter, repo_root=repo_root, @@ -114,6 +127,12 @@ def run_install( language_directory=target.language_directory, workspace_root=workspace_root, ) + evidentloop_install = _install_evidentloop_if_requested( + enabled=with_evidentloop, + adapter=adapter, + home_root=resolved_home, + workspace_root=workspace_root, + ) return InstallResult( target=target, workspace_root=workspace_root, @@ -129,6 +148,7 @@ def run_install( ), workspace_bootstrap=None, smoke_output="", + evidentloop_install=evidentloop_install, ) host_install = install_host_assets( @@ -148,6 +168,13 @@ def run_install( bundle_root = workspace_bootstrap.bundle_root validate_workspace_stub_manifest(workspace_root / ".sopify") + evidentloop_install = _install_evidentloop_if_requested( + enabled=with_evidentloop, + adapter=adapter, + home_root=resolved_home, + workspace_root=workspace_root, + ) + return InstallResult( target=target, workspace_root=workspace_root, @@ -168,9 +195,32 @@ def run_install( ), workspace_bootstrap=workspace_bootstrap, smoke_output="", + evidentloop_install=evidentloop_install, ) +def _install_evidentloop_if_requested( + *, + enabled: bool, + adapter: HostAdapter, + home_root: Path, + workspace_root: Path | None, +) -> EvidentLoopInstallResult | None: + if not enabled: + return None + try: + plan = prepare_evidentloop_install( + adapter, + home_root=home_root, + workspace_root=workspace_root, + ) + return install_evidentloop_companion(plan) + except InstallError as exc: + raise InstallError( + "Sopify core installation completed, but EvidentLoop was not installed." + ) from exc + + def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) @@ -197,6 +247,7 @@ def main(argv: list[str] | None = None) -> int: interactive=sys.stdin.isatty() and sys.stdout.isatty(), source_channel=args.source_channel, source_metadata=source_metadata, + with_evidentloop=args.with_evidentloop, ) try: diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index a8298a69..505ea8cc 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -72,6 +72,7 @@ run_step "Run hard gate tests (protocol + smoke + distribution)" python3 -m pyte "$ROOT_DIR/tests/protocol/test_convention_compliance.py" \ "$ROOT_DIR/tests/test_check_readme_links.py" \ "$ROOT_DIR/tests/test_distribution.py" \ + "$ROOT_DIR/tests/test_evidentloop_installer.py" \ "$ROOT_DIR/tests/test_golden_snapshots.py" \ "$ROOT_DIR/tests/test_release_hooks.py" \ "$ROOT_DIR/tests/test_sopify_init_smoke.py" \ @@ -80,7 +81,7 @@ run_step "Run protocol smoke — new-plan" python3 "$ROOT_DIR/scripts/sopify_pro run_step "Run protocol smoke — continuation" python3 "$ROOT_DIR/scripts/sopify_protocol_check.py" check --scenario continuation --fixture "$ROOT_DIR/tests/fixtures/minimal_plan" run_step "Run protocol smoke — continuation (clarification pending)" python3 "$ROOT_DIR/scripts/sopify_protocol_check.py" check --scenario continuation --fixture "$ROOT_DIR/tests/fixtures/clarification_pending" run_step "Run protocol smoke — continuation (decision pending)" python3 "$ROOT_DIR/scripts/sopify_protocol_check.py" check --scenario continuation --fixture "$ROOT_DIR/tests/fixtures/decision_pending" -run_step "Run protocol smoke — finalize" python3 "$ROOT_DIR/scripts/sopify_protocol_check.py" check --scenario finalize --fixture "$ROOT_DIR/tests/fixtures/minimal_plan" +run_step "Run protocol smoke — finalize" python3 "$ROOT_DIR/scripts/sopify_protocol_check.py" check --scenario finalize --fixture "$ROOT_DIR/tests/fixtures/finalized_plan" run_step "Run install/payload bootstrap smoke" python3 "$ROOT_DIR/scripts/check-install-payload-bundle-smoke.py" echo "[release-preflight] All checks passed." diff --git a/tests/fixtures/clarification_pending/.sopify/plan/test_clarification_001/plan.md b/tests/fixtures/clarification_pending/.sopify/plan/test_clarification_001/plan.md index 9ae309f1..e090707c 100644 --- a/tests/fixtures/clarification_pending/.sopify/plan/test_clarification_001/plan.md +++ b/tests/fixtures/clarification_pending/.sopify/plan/test_clarification_001/plan.md @@ -1,3 +1,7 @@ +--- +level: light +--- + # Clarification Pending Fixture ## Context / Why diff --git a/tests/fixtures/decision_pending/.sopify/plan/test_decision_001/plan.md b/tests/fixtures/decision_pending/.sopify/plan/test_decision_001/plan.md index e3c04d39..97f6d380 100644 --- a/tests/fixtures/decision_pending/.sopify/plan/test_decision_001/plan.md +++ b/tests/fixtures/decision_pending/.sopify/plan/test_decision_001/plan.md @@ -1,3 +1,7 @@ +--- +level: light +--- + # Decision Pending Fixture ## Context / Why diff --git a/tests/fixtures/minimal_plan/.sopify/history/2026-06/test_finalize_001/receipt.md b/tests/fixtures/finalized_plan/.sopify/history/2026-06/test_finalize_001/receipt.md similarity index 100% rename from tests/fixtures/minimal_plan/.sopify/history/2026-06/test_finalize_001/receipt.md rename to tests/fixtures/finalized_plan/.sopify/history/2026-06/test_finalize_001/receipt.md diff --git a/tests/fixtures/minimal_plan/.sopify/history/2026-06/test_finalize_001/receipts/final.json b/tests/fixtures/finalized_plan/.sopify/history/2026-06/test_finalize_001/receipts/final.json similarity index 100% rename from tests/fixtures/minimal_plan/.sopify/history/2026-06/test_finalize_001/receipts/final.json rename to tests/fixtures/finalized_plan/.sopify/history/2026-06/test_finalize_001/receipts/final.json diff --git a/tests/fixtures/minimal_plan/.sopify/plan/test_minimal_001/plan.md b/tests/fixtures/minimal_plan/.sopify/plan/test_minimal_001/plan.md index 0b29304a..65e60928 100644 --- a/tests/fixtures/minimal_plan/.sopify/plan/test_minimal_001/plan.md +++ b/tests/fixtures/minimal_plan/.sopify/plan/test_minimal_001/plan.md @@ -1,3 +1,7 @@ +--- +level: light +--- + # Test Minimal Plan ## Plan Snapshot diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 4638351e..507e3675 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -446,7 +446,7 @@ def test_root_install_scripts_keep_internal_workspace_flag_out_of_primary_usage( install_sh = (REPO_ROOT / "install.sh").read_text(encoding="utf-8") install_ps1 = (REPO_ROOT / "install.ps1").read_text(encoding="utf-8") - for flag in ("--target", "--ref", "--verbose"): + for flag in ("--target", "--ref", "--verbose", "--with-evidentloop"): self.assertIn(flag, install_sh) self.assertIn(flag, install_ps1) self.assertIn("--workspace", install_sh) @@ -455,8 +455,8 @@ def test_root_install_scripts_keep_internal_workspace_flag_out_of_primary_usage( self.assertIn("scripts/install_sopify.py", install_ps1) self.assertIn("--source-channel", install_sh) self.assertIn("--source-channel", install_ps1) - self.assertIn("Usage: install.sh [--target ] [--ref ]", install_sh) - self.assertIn("Usage: install.ps1 [--target ] [--ref ]", install_ps1) + self.assertIn("Usage: install.sh [--target ] [--with-evidentloop] [--ref ]", install_sh) + self.assertIn("Usage: install.ps1 [--target ] [--with-evidentloop] [--ref ]", install_ps1) self.assertIn("Use `--target copilot` to bootstrap the current workspace", install_sh) self.assertIn("Use `--target copilot` to bootstrap the current workspace", install_ps1) self.assertIn("--language ", install_sh) @@ -464,17 +464,89 @@ def test_root_install_scripts_keep_internal_workspace_flag_out_of_primary_usage( self.assertIn("--no-copilot", install_sh) self.assertIn("--no-copilot", install_ps1) - def test_powershell_installer_prefers_python3_probe(self) -> None: + def test_root_installers_probe_python_311_before_source_download(self) -> None: + install_sh = (REPO_ROOT / "install.sh").read_text(encoding="utf-8") install_ps1 = (REPO_ROOT / "install.ps1").read_text(encoding="utf-8") - lines = install_ps1.splitlines() + self.assertIn('@("python3", "python", "py")', install_ps1) + for content in (install_sh, install_ps1): + self.assertIn("sys.version_info >= (3, 11)", content) + self.assertIn("UNSUPPORTED_PYTHON", content) + self.assertIn("Sopify needs Python 3.11 or newer", content) + self.assertLess( + install_sh.index("sys.version_info >= (3, 11)"), + install_sh.index("ARCHIVE_URL"), + ) + self.assertLess( + install_ps1.index("sys.version_info >= (3, 11)"), + install_ps1.index("$archiveUrl"), + ) + + def test_shell_installer_rejects_old_python_before_download(self) -> None: + bash = shutil.which("bash") + if bash is None: + self.skipTest("bash is required") + + with tempfile.TemporaryDirectory() as temp_dir: + tools_dir = Path(temp_dir) + self._write_shell_stub(tools_dir / "curl", "exit 99") + self._write_shell_stub(tools_dir / "tar", "exit 99") + self._write_shell_stub( + tools_dir / "python3", + "printf '3.9.6\\n'\nexit 1", + ) + env = os.environ.copy() + env["PATH"] = str(tools_dir) + completed = subprocess.run( + [bash, str(REPO_ROOT / "install.sh"), "--target", "codex:en-US"], + capture_output=True, + text=True, + check=False, + env=env, + ) + + self.assertEqual(completed.returncode, 1) + self.assertIn("Sopify needs Python 3.11 or newer", completed.stderr) + self.assertIn("Found: python3 3.9.6", completed.stderr) + self.assertIn("Nothing was downloaded or installed", completed.stderr) + self.assertIn("reason_code: UNSUPPORTED_PYTHON", completed.stderr) + self.assertNotIn("Downloading Sopify source", completed.stderr) + + def test_shell_installer_uses_later_compatible_python(self) -> None: + bash = shutil.which("bash") + if bash is None: + self.skipTest("bash is required") + + with tempfile.TemporaryDirectory() as temp_dir: + tools_dir = Path(temp_dir) + self._write_shell_stub(tools_dir / "curl", "exit 1") + self._write_shell_stub(tools_dir / "tar", "exit 99") + self._write_shell_stub( + tools_dir / "python3", + "printf '3.9.6\\n'\nexit 1", + ) + self._write_shell_stub( + tools_dir / "python", + "printf '3.11.9\\n'\nexit 0", + ) + env = os.environ.copy() + env["PATH"] = os.pathsep.join((str(tools_dir), os.defpath)) + completed = subprocess.run( + [bash, str(REPO_ROOT / "install.sh"), "--target", "codex:en-US"], + capture_output=True, + text=True, + check=False, + env=env, + ) - python3_index = next(index for index, line in enumerate(lines) if "Get-Command python3 " in line) - python_index = next(index for index, line in enumerate(lines) if "Get-Command python " in line) - py_index = next(index for index, line in enumerate(lines) if "Get-Command py " in line) + self.assertEqual(completed.returncode, 1) + self.assertIn("Downloading Sopify source", completed.stderr) + self.assertIn("reason_code: SOURCE_FETCH_FAILED", completed.stderr) + self.assertNotIn("UNSUPPORTED_PYTHON", completed.stderr) - self.assertLess(python3_index, python_index) - self.assertLess(python_index, py_index) - self.assertIn("None of `python3`, `python`, or `py -3` is available.", install_ps1) + @staticmethod + def _write_shell_stub(path: Path, body: str) -> None: + path.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") + path.chmod(0o755) def test_render_release_installers_renders_stable_assets(self) -> None: with tempfile.TemporaryDirectory() as output_dir: diff --git a/tests/test_evidentloop_installer.py b/tests/test_evidentloop_installer.py new file mode 100644 index 00000000..40b9c9b3 --- /dev/null +++ b/tests/test_evidentloop_installer.py @@ -0,0 +1,754 @@ +"""Tests for the opt-in EvidentLoop companion installer.""" + +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +from installer.distribution import ( + _companion_action_lines, + DistributionError, + DistributionRequest, + DistributionSourceMetadata, + render_distribution_result, + render_distribution_user_result, + run_distribution_install, +) +from installer.evidentloop import ( + EVIDENTLOOP_PACKAGE_VERSION, + EVIDENTLOOP_SKILL_COMMIT, + EVIDENTLOOP_SKILL_REPOSITORY, + EVIDENTLOOP_SKILL_TAG, + SKILLS_CLI_VERSION, + install_evidentloop_companion, + prepare_evidentloop_install, +) +from installer.hosts.base import HostAdapter +from installer.hosts.claude import CLAUDE_ADAPTER +from installer.hosts.codex import CODEX_ADAPTER +from installer.hosts.copilot import COPILOT_ADAPTER +from installer.hosts.qoder import QODER_ADAPTER +from installer.models import EvidentLoopInstallResult, InstallError +from scripts.install_sopify import build_parser, run_install + + +REPO_ROOT = Path(__file__).resolve().parents[1] +COMPATIBLE_SKILL = """\ +--- +name: evidentloop +--- +Require `package_version` equal to `0.1.0a2`, `schema_version` equal to `0.4`, and `prompt_version` equal to `v0.5`. +Read references/codex-cli-isolation.md before running the CLI. +""" + + +def _completed( + argv: list[str], + *, + stdout: str = "", + returncode: int = 0, +) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(argv, returncode, stdout=stdout, stderr="") + + +def _write_compatible_skill(skill_dir: Path) -> None: + (skill_dir / "references").mkdir(parents=True) + (skill_dir / "SKILL.md").write_text(COMPATIBLE_SKILL, encoding="utf-8") + (skill_dir / "references" / "codex-cli-isolation.md").write_text( + "# CLI isolation\n", + encoding="utf-8", + ) + + +class EvidentLoopInstallerTests(unittest.TestCase): + def test_versions_source_and_host_native_paths_are_fixed(self) -> None: + self.assertEqual(EVIDENTLOOP_PACKAGE_VERSION, "0.1.0a2") + self.assertEqual( + EVIDENTLOOP_SKILL_COMMIT, + "fcefb77083d32b034e56b04dcd085dcf5a835550", + ) + self.assertEqual(EVIDENTLOOP_SKILL_TAG, "v0.1.0a2") + self.assertEqual( + EVIDENTLOOP_SKILL_REPOSITORY, + "https://github.com/evidentloop/evidentloop.git", + ) + self.assertEqual(SKILLS_CLI_VERSION, "1.5.9") + + home = Path("/home/test") + workspace = Path("/workspace/project") + expected = { + CODEX_ADAPTER: ("codex", home / ".agents/skills/evidentloop"), + CLAUDE_ADAPTER: ("claude-code", home / ".claude/skills/evidentloop"), + QODER_ADAPTER: ("qoder", home / ".qoder/skills/evidentloop"), + COPILOT_ADAPTER: ( + "github-copilot", + workspace / ".github/skills/evidentloop", + ), + } + for adapter, (agent, path) in expected.items(): + with self.subTest(host=adapter.host_name): + self.assertEqual(adapter.skills_cli_agent, agent) + self.assertEqual( + adapter.skill_install_path( + home_root=home, + workspace_root=workspace, + skill_name="evidentloop", + ), + path, + ) + + with self.assertRaisesRegex(InstallError, "requires a workspace"): + COPILOT_ADAPTER.skill_install_path( + home_root=home, + workspace_root=None, + skill_name="evidentloop", + ) + + def test_flag_is_disabled_by_default_and_does_no_companion_work(self) -> None: + parser = build_parser() + self.assertFalse(parser.parse_args(["--target", "codex:en-US"]).with_evidentloop) + self.assertTrue( + parser.parse_args( + ["--target", "codex:en-US", "--with-evidentloop"] + ).with_evidentloop + ) + + with tempfile.TemporaryDirectory() as temp_dir: + with ( + patch("scripts.install_sopify.prepare_evidentloop_install") as prepare, + patch("scripts.install_sopify.install_evidentloop_companion") as install, + ): + result = run_install( + target_value="codex:en-US", + workspace_value=None, + repo_root=REPO_ROOT, + home_root=Path(temp_dir), + ) + + prepare.assert_not_called() + install.assert_not_called() + self.assertIsNone(result.evidentloop_install) + + def test_missing_mapping_stops_before_command_lookup(self) -> None: + adapter = HostAdapter( + host_name="future-host", + destination_dirname=".future", + header_filename="AGENTS.md", + ) + with patch("installer.evidentloop.shutil.which") as which: + with self.assertRaisesRegex(InstallError, "missing Skill install mapping"): + prepare_evidentloop_install(adapter, home_root=Path("/home/test")) + which.assert_not_called() + + def test_compatible_components_are_reused_without_uv_or_npx(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + skill_dir = CODEX_ADAPTER.skill_install_path( + home_root=home, + workspace_root=None, + skill_name="evidentloop", + ) + _write_compatible_skill(skill_dir) + + def fake_which(name: str) -> str | None: + return { + "evidentloop": "/tools/evidentloop", + "git": "/tools/git", + }.get(name) + + with ( + patch("installer.evidentloop.shutil.which", side_effect=fake_which), + patch( + "installer.evidentloop.subprocess.run", + side_effect=self._compatible_runner, + ), + ): + plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + result = install_evidentloop_companion(plan) + + self.assertFalse(plan.install_cli) + self.assertFalse(plan.install_skill) + self.assertEqual((result.cli_action, result.skill_action), ("reused", "reused")) + self.assertEqual(result.skill_path, skill_dir / "SKILL.md") + + def test_prerequisites_are_required_only_for_missing_components(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + skill_dir = CODEX_ADAPTER.skill_install_path( + home_root=home, + workspace_root=None, + skill_name="evidentloop", + ) + _write_compatible_skill(skill_dir) + with patch("installer.evidentloop.shutil.which", return_value=None): + with self.assertRaisesRegex(InstallError, r"missing required command\(s\): uv$"): + prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + + def fake_which(name: str) -> str | None: + return { + "evidentloop": "/tools/evidentloop", + "git": "/tools/git", + }.get(name) + + with ( + patch("installer.evidentloop.shutil.which", side_effect=fake_which), + patch( + "installer.evidentloop.subprocess.run", + side_effect=self._compatible_runner, + ), + ): + with self.assertRaisesRegex(InstallError, r"missing required command\(s\): npx$"): + prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + + def test_missing_prerequisite_does_not_undo_sopify_install(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + with patch("installer.evidentloop.shutil.which", return_value=None): + with self.assertRaisesRegex( + InstallError, + "Sopify core installation completed.*EvidentLoop was not installed", + ): + run_install( + target_value="codex:en-US", + workspace_value=None, + repo_root=REPO_ROOT, + home_root=home, + with_evidentloop=True, + ) + self.assertTrue((home / ".codex/AGENTS.md").is_file()) + + def test_existing_skill_directory_is_validated_without_replacement(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + skill_dir = CODEX_ADAPTER.skill_install_path( + home_root=home, + workspace_root=None, + skill_name="evidentloop", + ) + skill_dir.mkdir(parents=True) + entrypoint = skill_dir / "SKILL.md" + entrypoint.write_text(COMPATIBLE_SKILL, encoding="utf-8") + + with patch("installer.evidentloop.shutil.which") as which: + with self.assertRaisesRegex(InstallError, "missing referenced file"): + prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + which.assert_not_called() + self.assertEqual(entrypoint.read_text(encoding="utf-8"), COMPATIBLE_SKILL) + + def test_incompatible_cli_is_not_replaced(self) -> None: + def fake_which(name: str) -> str | None: + return { + "evidentloop": "/tools/evidentloop", + "uv": "/tools/uv", + "npx": "/tools/npx", + }.get(name) + + def incompatible_runner( + argv: list[str], + **_kwargs: object, + ) -> subprocess.CompletedProcess[str]: + if argv[1:] == ["doctor", "--json"]: + return _completed( + argv, + stdout=json.dumps( + {"status": "ok", "python_executable": "/tools/python"} + ), + ) + if "-c" in argv: + return _completed( + argv, + stdout=json.dumps( + { + "package_version": "0.1.0a1", + "schema_version": "0.4", + "prompt_version": "v0.5", + } + ), + ) + raise AssertionError(f"unexpected argv: {argv}") + + with tempfile.TemporaryDirectory() as temp_dir: + with ( + patch("installer.evidentloop.shutil.which", side_effect=fake_which), + patch( + "installer.evidentloop.subprocess.run", + side_effect=incompatible_runner, + ) as run, + ): + with self.assertRaisesRegex(InstallError, "CLI is incompatible"): + prepare_evidentloop_install( + CODEX_ADAPTER, + home_root=Path(temp_dir), + ) + + executed = [call.args[0][0] for call in run.call_args_list] + self.assertNotIn("/tools/uv", executed) + self.assertNotIn("/tools/npx", executed) + + def test_home_skill_install_uses_fixed_global_command_and_real_directory(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + skill_dir = home / ".agents/skills/evidentloop" + observed: list[tuple[list[str], dict[str, object]]] = [] + + def fake_which(name: str) -> str | None: + return { + "evidentloop": "/tools/evidentloop", + "npx": "/tools/npx", + "git": "/tools/git", + }.get(name) + + def fake_run( + argv: list[str], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + observed.append((list(argv), kwargs)) + source_result = self._skill_source_result(argv) + if source_result is not None: + return source_result + if argv[0] == "/tools/npx": + _write_compatible_skill(skill_dir) + return _completed(argv) + return self._compatible_runner(argv, **kwargs) + + with ( + patch("installer.evidentloop.shutil.which", side_effect=fake_which), + patch("installer.evidentloop.subprocess.run", side_effect=fake_run), + ): + plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + result = install_evidentloop_companion(plan) + self.assertTrue((skill_dir / "SKILL.md").is_file()) + + npx_argv, npx_kwargs = next(item for item in observed if item[0][0] == "/tools/npx") + clone_argv = next( + argv for argv, _kwargs in observed if argv[:2] == ["/tools/git", "clone"] + ) + self.assertEqual( + clone_argv[:-1], + [ + "/tools/git", + "clone", + "--quiet", + "--depth", + "1", + "--branch", + "v0.1.0a2", + "https://github.com/evidentloop/evidentloop.git", + ], + ) + self.assertEqual(Path(clone_argv[-1]).name, "source") + self.assertEqual(npx_argv[:4], ["/tools/npx", "--yes", "skills@1.5.9", "add"]) + self.assertEqual(Path(npx_argv[4]).parts[-3:], ("source", "skills", "evidentloop")) + self.assertEqual(npx_argv[5:], ["-g", "-a", "codex", "-y", "--copy"]) + self.assertTrue(Path(str(npx_kwargs["cwd"])).name.startswith("sopify-evidentloop-")) + self.assertEqual(npx_kwargs["env"]["HOME"], str(home)) + self.assertEqual(npx_kwargs["env"]["DISABLE_TELEMETRY"], "1") + self.assertEqual(npx_kwargs["env"]["DO_NOT_TRACK"], "1") + self.assertEqual(result.skill_path, skill_dir / "SKILL.md") + + def test_copilot_install_copies_only_skill_to_project_github_path(self) -> None: + with ( + tempfile.TemporaryDirectory() as home_dir, + tempfile.TemporaryDirectory() as workspace_dir, + ): + home = Path(home_dir) + workspace = Path(workspace_dir) + target = workspace / ".github/skills/evidentloop" + observed_npx: list[tuple[list[str], Path]] = [] + + def fake_which(name: str) -> str | None: + return { + "evidentloop": "/tools/evidentloop", + "npx": "/tools/npx", + "git": "/tools/git", + }.get(name) + + def fake_run( + argv: list[str], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + source_result = self._skill_source_result(argv) + if source_result is not None: + return source_result + if argv[0] == "/tools/npx": + staging_root = Path(str(kwargs["cwd"])) + observed_npx.append((list(argv), staging_root)) + _write_compatible_skill( + staging_root / ".agents/skills/evidentloop" + ) + (staging_root / "skills-lock.json").write_text( + "{}\n", + encoding="utf-8", + ) + return _completed(argv) + return self._compatible_runner(argv, **kwargs) + + with ( + patch("installer.evidentloop.shutil.which", side_effect=fake_which), + patch("installer.evidentloop.subprocess.run", side_effect=fake_run), + ): + plan = prepare_evidentloop_install( + COPILOT_ADAPTER, + home_root=home, + workspace_root=workspace, + ) + result = install_evidentloop_companion(plan) + + npx_argv, staging_root = observed_npx[0] + self.assertEqual( + npx_argv[:4], + ["/tools/npx", "--yes", "skills@1.5.9", "add"], + ) + self.assertEqual( + Path(npx_argv[4]).parts[-3:], + ("source", "skills", "evidentloop"), + ) + self.assertEqual( + npx_argv[5:], + ["-a", "github-copilot", "-y", "--copy"], + ) + self.assertNotEqual(staging_root, workspace) + self.assertTrue((target / "SKILL.md").is_file()) + self.assertTrue( + (target / "references/codex-cli-isolation.md").is_file() + ) + self.assertFalse((workspace / ".agents/skills/evidentloop").exists()) + self.assertFalse((workspace / "skills-lock.json").exists()) + self.assertEqual(result.skill_path, target / "SKILL.md") + + def test_missing_cli_installs_without_touching_compatible_skill(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + skill_dir = home / ".agents/skills/evidentloop" + _write_compatible_skill(skill_dir) + bin_dir = home / "uv-bin" + bin_dir.mkdir() + (bin_dir / "evidentloop").write_text("", encoding="utf-8") + observed: list[list[str]] = [] + cli_installed = False + + def fake_which(name: str) -> str | None: + if name == "evidentloop" and cli_installed: + return str(bin_dir / "evidentloop") + return "/tools/uv" if name == "uv" else None + + def fake_run( + argv: list[str], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + nonlocal cli_installed + observed.append(list(argv)) + if argv[1:] == ["tool", "install", "evidentloop==0.1.0a2"]: + cli_installed = True + return _completed(argv) + if argv[1:] == ["tool", "dir", "--bin"]: + return _completed(argv, stdout=f"{bin_dir}\n") + return self._compatible_runner(argv, **kwargs) + + with ( + patch("installer.evidentloop.shutil.which", side_effect=fake_which), + patch("installer.evidentloop.subprocess.run", side_effect=fake_run), + ): + plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + result = install_evidentloop_companion(plan) + + self.assertEqual((result.cli_action, result.skill_action), ("installed", "reused")) + self.assertIn( + ["/tools/uv", "tool", "install", "evidentloop==0.1.0a2"], + observed, + ) + self.assertFalse(any(argv[0].endswith("npx") for argv in observed)) + + def test_fresh_cli_stops_when_command_is_not_on_path(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + skill_dir = home / ".agents/skills/evidentloop" + _write_compatible_skill(skill_dir) + bin_dir = home / "uv-bin" + bin_dir.mkdir() + (bin_dir / "evidentloop").write_text("", encoding="utf-8") + + def fake_which(name: str) -> str | None: + return "/tools/uv" if name == "uv" else None + + def fake_run( + argv: list[str], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + if argv[1:] == ["tool", "install", "evidentloop==0.1.0a2"]: + return _completed(argv) + if argv[1:] == ["tool", "dir", "--bin"]: + return _completed(argv, stdout=f"{bin_dir}\n") + return self._compatible_runner(argv, **kwargs) + + with ( + patch("installer.evidentloop.shutil.which", side_effect=fake_which), + patch("installer.evidentloop.subprocess.run", side_effect=fake_run), + ): + plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + with self.assertRaisesRegex( + InstallError, + r"CLI installed.*not discoverable on PATH.*uv tool update-shell", + ): + install_evidentloop_companion(plan) + + def test_changed_skill_tag_stops_before_skills_cli(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + observed: list[list[str]] = [] + + def fake_which(name: str) -> str | None: + return { + "evidentloop": "/tools/evidentloop", + "npx": "/tools/npx", + "git": "/tools/git", + }.get(name) + + def fake_run( + argv: list[str], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + observed.append(list(argv)) + if argv[:2] == ["/tools/git", "clone"]: + _write_compatible_skill(Path(argv[-1]) / "skills/evidentloop") + return _completed(argv) + if argv[0:2] == ["/tools/git", "-C"]: + return _completed(argv, stdout="changed-commit\n") + return self._compatible_runner(argv, **kwargs) + + with ( + patch("installer.evidentloop.shutil.which", side_effect=fake_which), + patch("installer.evidentloop.subprocess.run", side_effect=fake_run), + ): + plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + with self.assertRaisesRegex(InstallError, "source commit changed"): + install_evidentloop_companion(plan) + + self.assertFalse(any(argv[0] == "/tools/npx" for argv in observed)) + + def test_partial_failure_reports_cli_that_was_already_installed(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + bin_dir = home / "uv-bin" + bin_dir.mkdir() + (bin_dir / "evidentloop").write_text("", encoding="utf-8") + cli_installed = False + + def fake_which(name: str) -> str | None: + if name == "evidentloop" and cli_installed: + return str(bin_dir / "evidentloop") + return { + "uv": "/tools/uv", + "npx": "/tools/npx", + "git": "/tools/git", + }.get(name) + + def fake_run( + argv: list[str], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + nonlocal cli_installed + if argv[1:] == ["tool", "install", "evidentloop==0.1.0a2"]: + cli_installed = True + return _completed(argv) + if argv[1:] == ["tool", "dir", "--bin"]: + return _completed(argv, stdout=f"{bin_dir}\n") + if argv[:2] == ["/tools/git", "clone"]: + return _completed(argv, returncode=1) + return self._compatible_runner(argv, **kwargs) + + with ( + patch("installer.evidentloop.shutil.which", side_effect=fake_which), + patch("installer.evidentloop.subprocess.run", side_effect=fake_run), + ): + plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + with self.assertRaisesRegex( + InstallError, + "CLI installed.*no external files were rolled back", + ): + install_evidentloop_companion(plan) + + def test_core_completion_is_visible_when_companion_fails(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + with ( + patch( + "scripts.install_sopify.prepare_evidentloop_install", + return_value=object(), + ), + patch( + "scripts.install_sopify.install_evidentloop_companion", + side_effect=InstallError("Skill install failed"), + ), + ): + with self.assertRaisesRegex( + InstallError, + "Sopify core installation completed.*EvidentLoop was not installed", + ): + run_install( + target_value="codex:en-US", + workspace_value=None, + repo_root=REPO_ROOT, + home_root=home, + with_evidentloop=True, + ) + self.assertTrue((home / ".codex/AGENTS.md").is_file()) + + def test_distribution_reports_actions_version_path_and_stable_errors(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + skill_path = home / ".agents/skills/evidentloop/SKILL.md" + companion = EvidentLoopInstallResult( + cli_action="installed", + skill_action="reused", + package_version="0.1.0a2", + skill_path=skill_path, + ) + request = self._request() + with ( + patch( + "scripts.install_sopify.prepare_evidentloop_install", + return_value=object(), + ), + patch( + "scripts.install_sopify.install_evidentloop_companion", + return_value=companion, + ), + ): + report = run_distribution_install( + request=request, + repo_root=REPO_ROOT, + home_root=home, + install_executor=run_install, + ) + + detailed = render_distribution_result(report) + user_facing = render_distribution_user_result(report) + self.assertIn( + "EvidentLoop CLI (0.1.0a2): installed (tested with this Sopify release)", + detailed, + ) + self.assertIn( + "EvidentLoop Skill: reused (compatibility checked)", detailed + ) + self.assertIn(f"EvidentLoop Skill path: {skill_path}", detailed) + self.assertIn( + "EvidentLoop CLI(0.1.0a2):已安装(本次 Sopify 发布验证版本)", + user_facing, + ) + self.assertIn(f"EvidentLoop Skill 路径:{skill_path}", user_facing) + + copilot_result = EvidentLoopInstallResult( + cli_action="reused", + skill_action="installed", + package_version="0.1.0a2", + skill_path=home / ".github/skills/evidentloop/SKILL.md", + ) + copilot_en = "\n".join( + _companion_action_lines(copilot_result, language="en-US") + ) + copilot_zh = "\n".join( + _companion_action_lines(copilot_result, language="zh-CN") + ) + self.assertIn("review and commit it if your cloud workflow needs it", copilot_en) + self.assertIn("Sopify will not commit or update it", copilot_en) + self.assertIn("如需云端使用,请审查后自行提交", copilot_zh) + self.assertIn("Sopify 不会自动提交或更新", copilot_zh) + + errors = ( + ( + "EvidentLoop companion preflight failed: " + "missing required command(s): uv", + "EVIDENTLOOP_PREREQUISITE_MISSING", + ), + ( + "Existing EvidentLoop CLI is incompatible", + "EVIDENTLOOP_INCOMPATIBLE", + ), + ( + "Sopify core installation completed, but EvidentLoop was not installed.", + "EVIDENTLOOP_COMPANION_INCOMPLETE", + ), + ) + for message, reason_code in errors: + with self.subTest(reason_code=reason_code): + def fail_install(**_kwargs: object) -> object: + raise InstallError(message) + + with self.assertRaises(DistributionError) as raised: + run_distribution_install( + request=self._request(), + repo_root=REPO_ROOT, + home_root=Path("/tmp/sopify-test-home"), + install_executor=fail_install, + ) + self.assertEqual(raised.exception.reason_code, reason_code) + + @staticmethod + def _request() -> DistributionRequest: + return DistributionRequest( + target="codex:zh-CN", + workspace=None, + ref_override=None, + interactive=False, + source_channel="repo-local", + source_metadata=DistributionSourceMetadata( + resolved_ref="working-tree", + asset_name="scripts/install_sopify.py", + ), + with_evidentloop=True, + ) + + @staticmethod + def _versions_json() -> str: + return json.dumps( + { + "package_version": "0.1.0a2", + "schema_version": "0.4", + "prompt_version": "v0.5", + } + ) + + def _compatible_runner( + self, + argv: list[str], + **_kwargs: object, + ) -> subprocess.CompletedProcess[str]: + if argv[1:] == ["doctor", "--json"]: + return _completed( + argv, + stdout=json.dumps( + {"status": "ok", "python_executable": "/tools/python"} + ), + ) + if "-c" in argv: + return _completed(argv, stdout=self._versions_json()) + if argv[-1] == "--help": + return _completed(argv, stdout="prepare finalize render revise") + raise AssertionError(f"unexpected argv: {argv}") + + @staticmethod + def _skill_source_result( + argv: list[str], + ) -> subprocess.CompletedProcess[str] | None: + if argv[:2] == ["/tools/git", "clone"]: + source_root = Path(argv[-1]) + _write_compatible_skill(source_root / "skills/evidentloop") + return _completed(argv) + if argv[0:2] == ["/tools/git", "-C"] and argv[-2:] == ["rev-parse", "HEAD"]: + return _completed( + argv, + stdout="fcefb77083d32b034e56b04dcd085dcf5a835550\n", + ) + return None + + +if __name__ == "__main__": + unittest.main() From ea92482cec1e93f41cbe63b68e1b067805f5dfd0 Mon Sep 17 00:00:00 2001 From: "sanze.li" <2522048902@qq.com> Date: Mon, 20 Jul 2026 19:03:09 +0800 Subject: [PATCH 4/4] feat: complete optional EvidentLoop installation - install missing CLI and Skill from current official sources - reuse healthy components and keep failed Skill installs retryable - align user documentation and archive the verified integration plan --- .sopify/blueprint/README.md | 2 +- .sopify/blueprint/background.md | 2 +- .sopify/blueprint/design.md | 2 +- .sopify/blueprint/tasks.md | 6 - .../audits/plan/audit.html | 1234 +++++++++++++++++ .../audits/plan/audit.json | 500 +++++++ .../design.md | 30 +- .../plan.md | 45 +- .../receipt.md | 18 + .../receipts/final.json | 15 + .../receipts/verify_001.json | 20 + .../tasks.md | 52 +- .sopify/history/index.md | 1 + README.md | 36 +- README.zh-CN.md | 29 +- docs/getting-started.md | 24 +- docs/how-sopify-works.en.md | 2 +- docs/how-sopify-works.md | 2 +- install.ps1 | 6 +- install.sh | 6 +- installer/distribution.py | 43 +- installer/evidentloop.py | 291 ++-- scripts/install_sopify.py | 6 +- tests/test_evidentloop_installer.py | 549 +++----- 24 files changed, 2211 insertions(+), 710 deletions(-) create mode 100644 .sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/audits/plan/audit.html create mode 100644 .sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/audits/plan/audit.json rename .sopify/{plan => history/2026-07}/20260718_evidentloop_optional_audit_integration/design.md (81%) rename .sopify/{plan => history/2026-07}/20260718_evidentloop_optional_audit_integration/plan.md (77%) create mode 100644 .sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipt.md create mode 100644 .sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipts/final.json create mode 100644 .sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipts/verify_001.json rename .sopify/{plan => history/2026-07}/20260718_evidentloop_optional_audit_integration/tasks.md (74%) diff --git a/.sopify/blueprint/README.md b/.sopify/blueprint/README.md index 57e862a4..911b1d33 100644 --- a/.sopify/blueprint/README.md +++ b/.sopify/blueprint/README.md @@ -13,7 +13,7 @@ ## 当前焦点 -- 当前活动 plan:`../plan/20260718_evidentloop_optional_audit_integration/`;Wave 1–4 已完成,四宿主可选配套安装及独立复审已收口;正在等待用户确认 Wave 5。 +- 当前无活动 plan;EvidentLoop 可选配套安装、Codex 公开版本 dogfood 与正式 receipt 已收口,归档见 `../history/2026-07/20260718_evidentloop_optional_audit_integration/`。 - 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/20260717_sopify_entry_preflight`。 diff --git a/.sopify/blueprint/background.md b/.sopify/blueprint/background.md index 3e3d7968..8b50df80 100644 --- a/.sopify/blueprint/background.md +++ b/.sopify/blueprint/background.md @@ -27,7 +27,7 @@ Sopify 官方在 core 之上提供一个轻量、可插拔、收敛式的 bluepr **产品形态锚点**:Protocol 是宿主的唯一硬依赖;宿主执行、Sopify 保存、任意宿主恢复——这是 P8 runtime 退场后的三层分工。一切外部验证与生产能力通过 integration contract 外插,不进 kernel。 -**可选验证安装边界**:Sopify 不默认安装或运行 Verifier。用户显式传入 `--with-evidentloop` 时,先完成 Sopify,再独立尝试 EvidentLoop;后者失败不回滚或否定 Sopify。新装组件使用当前 Sopify 发布验证过的版本和来源,已有组件只做兼容检查;Sopify 不声明来源相同,不负责组件升级,也不自动提交 Copilot 项目 Skill。该参数不形成持久能力开关,也不改变通用 receipt 契约。 +**可选验证安装边界**:Sopify 不默认安装或运行 Verifier。用户显式传入 `--with-evidentloop` 时,先完成 Sopify,再按 EvidentLoop 当前官方方式尝试安装;后者失败不回滚或否定 Sopify,且不在最终 Skill 目录留下未验证内容。已有健康组件直接复用且不自动升级;兼容性由 EvidentLoop 自身负责,Sopify 不维护版本矩阵,也不自动提交 Copilot 项目 Skill。该参数不形成持久能力开关,也不改变通用 receipt 契约。 ## 核心架构模式 diff --git a/.sopify/blueprint/design.md b/.sopify/blueprint/design.md index b553afa0..e4c60e8a 100644 --- a/.sopify/blueprint/design.md +++ b/.sopify/blueprint/design.md @@ -37,7 +37,7 @@ Sopify 是装在现有 AI 编程宿主上的**开发过程协议层**。它把 ### Verifier 使用边界 -以下形态都合法:只使用 Sopify;显式选择其他 Verifier;使用已独立安装的 EvidentLoop;通过 `--with-evidentloop` 一次性配套安装。第四种只是显式安装便利:Sopify 先完成,EvidentLoop 再独立尝试,失败不回滚 Sopify。新装组件使用当前发布验证过的版本和来源,已有组件只做兼容检查。Sopify 不声明复用 Skill 来源相同,不负责组件升级,也不自动提交 Copilot 项目 Skill;安装来源不进入运行判断或 receipt。 +以下形态都合法:只使用 Sopify;显式选择其他 Verifier;使用已独立安装的 EvidentLoop;通过 `--with-evidentloop` 一次性配套安装。第四种只是显式安装便利:Sopify 先完成,EvidentLoop 再按当前官方方式独立尝试,失败不回滚 Sopify,也不在最终 Skill 目录留下未验证内容。已有健康组件直接复用且不自动升级;兼容性由 EvidentLoop 自身负责,Sopify 不维护版本矩阵,也不自动提交 Copilot 项目 Skill;安装来源不进入运行判断或 receipt。 Sopify 不默认提示、安装或执行任何 Verifier。独立审计可不绑定方案;方案主审计使用 `audits/plan/`,附加审计使用 `audits//`。`audits/` 不进入 active plan、handoff 或 4 步默认读取链;正式采用的证据由宿主通过 writer 写 receipt。 diff --git a/.sopify/blueprint/tasks.md b/.sopify/blueprint/tasks.md index 606c1973..944edfc6 100644 --- a/.sopify/blueprint/tasks.md +++ b/.sopify/blueprint/tasks.md @@ -112,12 +112,6 @@ P0→P4c 主航道已全部完成。后续执行遵循以下原则: - 服务主线:P5(决定哪些 handoff 生产面是 deep-only vs 可降级为 convention 自驱) - 最小交付证据:Gap 分析报告,覆盖 RuntimeHandoff / RunState / DecisionState 的生产 vs 消费拆分判定 -## 外部验证器接入 - -Sopify 只保留通用 Verifier 证据消费。EvidentLoop 是首个官方推荐的可选实例,不是默认能力或唯一实现。 - -- [ ] 完成 `plan/20260718_evidentloop_optional_audit_integration/` 剩余 Wave:Codex 真实 dogfood、用户裁定、正式 receipt 与归档 - ## 流程与工具项 - [x] Runtime retirement cutover(`target-state-first` 已于 2026-05-22 拍板):先解耦 installer 5 文件,再同步删除 `runtime/` 与 legacy deep path diff --git a/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/audits/plan/audit.html b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/audits/plan/audit.html new file mode 100644 index 00000000..42f77fea --- /dev/null +++ b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/audits/plan/audit.html @@ -0,0 +1,1234 @@ + + + + + + + 反馈修订 2 · EvidentLoop + + + +
+
+
+
+
EvidentLoop · 代码变更
+

反馈修订 2

+
+运行:run-feedback-00fbe17d21db032d · 来源:staged · schema:0.4 · 审查状态:输出完整 +
+
+
+ 当前结论 + 候选通过 +
基于人工裁定,未重新审查代码
+
+
+ +
+
+ 模型原判断 + 存在待处理问题 +
原风险分:20
+
+
+ 我的裁定 + 1 项结论裁定 +
1 项严重度调整 · 1 条评论
+
+
+ 当前剩余问题 + 0 项 +
当前结论:候选通过 · 风险分:0
+
+
+ +
+
+ 已计分风险分 + 0 + +
+
+ 待处理问题 + 0 + 已清空 +
+
+ 未计分问题 + 0 + +
+
+ 影响文件 + 16 + 已采集 +
+
+ +
+ 审查输出已完整接收 +
这只表示审查者输出满足结构契约,不代表上下文覆盖充分;请结合结论、问题与未计分项人工判断。
+
+
+ +
+
+

1. 变更摘要与审计结论

+ 变更目标:未审计 +
+

基于人工裁定,未重新审查代码

+
+
+

Sopify 变更

+

共 16 个文件发生变更(+390/-667)。

+
+
+ 影响文件(16)· 查看全部 +
+
+ .sopify/blueprint/background.md + + +1 + -1 + · 修改 +
+
+ .sopify/blueprint/design.md + + +1 + -1 + · 修改 +
+
+ .sopify/plan/20260718_evidentloop_optional_audit_integration/design.md + + +15 + -15 + · 修改 +
+
+ .sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md + + +17 + -14 + · 修改 +
+
+ .sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md + + +12 + -9 + · 修改 +
+
+ README.md + + +13 + -11 + · 修改 +
+
+ README.zh-CN.md + + +9 + -9 + · 修改 +
+
+ docs/getting-started.md + + +14 + -10 + · 修改 +
+
+ docs/how-sopify-works.en.md + + +1 + -1 + · 修改 +
+
+ docs/how-sopify-works.md + + +1 + -1 + · 修改 +
+
+ install.ps1 + + +3 + -3 + · 修改 +
+
+ install.sh + + +3 + -3 + · 修改 +
+
+ installer/distribution.py + + +8 + -35 + · 修改 +
+
+ installer/evidentloop.py + + +87 + -204 + · 修改 +
+
+ scripts/install_sopify.py + + +3 + -3 + · 修改 +
+
+ tests/test_evidentloop_installer.py + + +202 + -347 + · 修改 +
+
+
+
+
+ 暂无摘要声明审计 + 未生成结构化 claim +
+
+
+
+ +
+
+

2. 风险

+ 1 条 +
+
+
+
+ + 风险 +
+ CLI 健康检查把 `doctor --json` 返回的 `warning` 状态也视为可复用的健康安装。 + +installer/evidentloop.py:204 +
+ 已忽略 +
+
+
+
+
+为什么是问题: 条件 `payload.get("status") not in {"ok", "warning"}` 明确接受 `warning`,但同一 diff 的设计、文档及测试均将复用条件描述为 doctor 健康或 `status=ok`;这会让已报告警告的 CLI 被直接复用,同时跳过安装或修复路径。 +
+ +
+
+ @@ -169,89 +129,48 @@ def install_evidentloop_companion( + 可信节选 · 展示 17/113 行 · 橙色标记:旧文件第 204 行 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
旧行新行代码
… 省略 27 行可信 diff 内容 …
196- str(source_root),
197- ],
198- env=source_env,
199- label="EvidentLoop Skill source download",
200- )
201- resolved_commit = _run_command(
202- [plan.git_path, "-C", str(source_root), "rev-parse", "HEAD"],
203- env=source_env,
204- label="EvidentLoop Skill source verification",
205- ).stdout.strip()
206- if resolved_commit != EVIDENTLOOP_SKILL_COMMIT:
207- raise InstallError(
208- "EvidentLoop Skill source commit changed: "
209- f"expected {EVIDENTLOOP_SKILL_COMMIT}, got {resolved_commit or '(empty)'}"
210- )
211-
212- source_skill = source_root / "skills" / _SKILL_NAME
… 省略 69 行可信 diff 内容 …
+
+
+
+ + +
+ + +
+
+ 模型原判断 + 待处理 · 中 +
+
+ 我的裁定 + 误报 +
+严重度改为中 · 评论:批判看看要不要优化 不要过度设计
+
+
+ 当前状态 + 已忽略 · 中 +
+
+ + +
+
+
+
+ + + + +
+
+

3. 多轮对比

+ 3 轮 +
+
+ + + + + + + + + + + + + + + + + + + +
运行状态摘要
Sopify 代码变更审计
模型审查
存在待处理问题 + 实现整体围绕临时目录安装、最终路径校验和简化错误界面进行了较一致的调整,但 CLI 健康判定与文档承诺不一致:`warning` 被当作健康状态复用。建议仅接受明确的 `status=ok`,或同步修改契约并补充针对 `warning` 的预期行为测试。 +
反馈修订 1
人工反馈修订
存在待处理问题 + 基于人工裁定,未重新审查代码 +
+ 修订详情 · 本轮采用 3 条反馈 +
source run:run-8d72fe3ae6f34c2d8021eadd1adf5d12
+
source audit:sha256:c7e985fd4b3157eb56933caa3335a535da0327fbe8dc3f43e9bad416cd078d98
+
feedback:sha256:c750132cdddb17bb98368c2636256087f18cc58d493d72a3b72eb2adceeb2c20
+
+
反馈修订 2
人工反馈修订
候选通过 + 基于人工裁定,未重新审查代码 +
+ 修订详情 · 本轮采用 1 条反馈 +
source run:run-feedback-fc634e01b8909cf7
+
source audit:sha256:f51aa6f85a0061db64f047d4ba2dafc951709ef0dd923454de3246879d429cef
+
feedback:sha256:ace7aa88ad7eabfe037e3bb098c768ab933c8cd8738aab97525388963609f616
+
+
+
+
+ +
+
+

4. 审查依据详情

+ 1 条 +
+
+ 查看全部 1 条审查依据 +
+ + + + + + + + + + +
证据来源状态关联
宿主语义审查结论:CLI 健康检查把 `doctor --json` 返回的 `warning` 状态也视为可复用的健康安装。
条件 `payload.get("status") not in {"ok", "warning"}` 明确接受 `warning`,但同一 diff 的设计、文档及测试均将复用条件描述为 doctor 健康或 `status=ok`;这会让已报告警告的 CLI 被直接复用,同时跳过安装或修复路径。
宿主语义审查发现问题finding-001
+
+
+
+
+ + + diff --git a/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/audits/plan/audit.json b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/audits/plan/audit.json new file mode 100644 index 00000000..b2e9d72c --- /dev/null +++ b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/audits/plan/audit.json @@ -0,0 +1,500 @@ +{ + "schema_version": "0.4", + "graph_id": "audit:run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "source": { + "type": "git_diff", + "ref": "staged", + "description": "Sopify 本地 Git diff 审计" + }, + "runs": [ + { + "id": "run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "label": "Sopify 代码变更审计", + "status": "concerns", + "summary": "实现整体围绕临时目录安装、最终路径校验和简化错误界面进行了较一致的调整,但 CLI 健康判定与文档承诺不一致:`warning` 被当作健康状态复用。建议仅接受明确的 `status=ok`,或同步修改契约并补充针对 `warning` 的预期行为测试。", + "kind": "model_review" + }, + { + "id": "run-feedback-fc634e01b8909cf7", + "label": "反馈修订 1", + "status": "concerns", + "summary": "基于人工裁定,未重新审查代码", + "kind": "feedback_revision", + "revision": { + "source_run_id": "run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "source_audit_sha256": "sha256:c7e985fd4b3157eb56933caa3335a535da0327fbe8dc3f43e9bad416cd078d98", + "feedback_sha256": "sha256:c750132cdddb17bb98368c2636256087f18cc58d493d72a3b72eb2adceeb2c20", + "source_summary": { + "review_status": "complete", + "verdict": "concerns", + "risk_score": 20, + "finding_count": 1, + "unscored_finding_count": 0, + "open_finding_count": 1, + "fix_count": 0, + "fix_done_count": 0, + "summary_audit_status": "not_audited", + "basis": "model_review", + "extensions": { + "evidentloop": { + "review_diagnostics": { + "intent_coverage": "covered", + "files_reviewed": 16, + "files_total": 16, + "pack_completeness": 0.65, + "raw_findings_count": 1, + "emitted_findings_count": 1, + "reviewer_failure_reason": null, + "advisory_verdict": "concerns", + "advisory_rationale": "review found medium/high-severity issues" + } + } + } + }, + "events": [ + { + "graph_id": "audit:run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "created_at": "2026-07-20T03:26:44.369Z", + "fingerprint": "sha256:a13749027576eceaeed7a10a769854ffcb16887accffc52788d670e48d09e3e6", + "action": "accept", + "run_id": "run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "target_id": "finding-001", + "source_audit_sha256": "sha256:c7e985fd4b3157eb56933caa3335a535da0327fbe8dc3f43e9bad416cd078d98", + "target_type": "finding" + }, + { + "graph_id": "audit:run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "created_at": "2026-07-20T03:26:57.808Z", + "fingerprint": "sha256:a13749027576eceaeed7a10a769854ffcb16887accffc52788d670e48d09e3e6", + "action": "comment", + "run_id": "run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "target_id": "finding-001", + "source_audit_sha256": "sha256:c7e985fd4b3157eb56933caa3335a535da0327fbe8dc3f43e9bad416cd078d98", + "target_type": "finding", + "comment": "批判看看要不要优化 不要过度设计" + }, + { + "graph_id": "audit:run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "created_at": "2026-07-20T03:26:46.032Z", + "fingerprint": "sha256:a13749027576eceaeed7a10a769854ffcb16887accffc52788d670e48d09e3e6", + "action": "severity_override", + "run_id": "run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "target_id": "finding-001", + "source_audit_sha256": "sha256:c7e985fd4b3157eb56933caa3335a535da0327fbe8dc3f43e9bad416cd078d98", + "target_type": "finding", + "severity": "medium" + } + ], + "notice": "基于人工裁定,未重新审查代码" + } + }, + { + "id": "run-feedback-00fbe17d21db032d", + "label": "反馈修订 2", + "status": "pass_candidate", + "summary": "基于人工裁定,未重新审查代码", + "kind": "feedback_revision", + "revision": { + "source_run_id": "run-feedback-fc634e01b8909cf7", + "source_audit_sha256": "sha256:f51aa6f85a0061db64f047d4ba2dafc951709ef0dd923454de3246879d429cef", + "feedback_sha256": "sha256:ace7aa88ad7eabfe037e3bb098c768ab933c8cd8738aab97525388963609f616", + "source_summary": { + "review_status": "complete", + "verdict": "concerns", + "risk_score": 20, + "finding_count": 1, + "unscored_finding_count": 0, + "open_finding_count": 1, + "fix_count": 0, + "fix_done_count": 0, + "summary_audit_status": "not_audited", + "risk_delta": 0, + "basis": "human_adjudication", + "model_verdict": "concerns", + "model_risk_score": 20, + "notice": "基于人工裁定,未重新审查代码", + "extensions": { + "evidentloop": { + "review_diagnostics": { + "intent_coverage": "covered", + "files_reviewed": 16, + "files_total": 16, + "pack_completeness": 0.65, + "raw_findings_count": 1, + "emitted_findings_count": 1, + "reviewer_failure_reason": null, + "advisory_verdict": "concerns", + "advisory_rationale": "review found medium/high-severity issues" + } + } + } + }, + "events": [ + { + "run_id": "run-feedback-fc634e01b8909cf7", + "created_at": "2026-07-20T03:36:53.533Z", + "target_id": "finding-001", + "graph_id": "audit:run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "action": "false_positive", + "source_audit_sha256": "sha256:f51aa6f85a0061db64f047d4ba2dafc951709ef0dd923454de3246879d429cef", + "target_type": "finding", + "fingerprint": "sha256:a13749027576eceaeed7a10a769854ffcb16887accffc52788d670e48d09e3e6" + } + ], + "notice": "基于人工裁定,未重新审查代码" + } + } + ], + "nodes": [ + { + "id": "change-001", + "type": "change", + "title": "Sopify 变更", + "summary": "共 16 个文件发生变更(+390/-667)。" + }, + { + "id": "file-001", + "type": "file", + "path": ".sopify/blueprint/background.md", + "change_type": "modified", + "additions": 1, + "deletions": 1 + }, + { + "id": "file-002", + "type": "file", + "path": ".sopify/blueprint/design.md", + "change_type": "modified", + "additions": 1, + "deletions": 1 + }, + { + "id": "file-003", + "type": "file", + "path": ".sopify/plan/20260718_evidentloop_optional_audit_integration/design.md", + "change_type": "modified", + "additions": 15, + "deletions": 15 + }, + { + "id": "file-004", + "type": "file", + "path": ".sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md", + "change_type": "modified", + "additions": 17, + "deletions": 14 + }, + { + "id": "file-005", + "type": "file", + "path": ".sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md", + "change_type": "modified", + "additions": 12, + "deletions": 9 + }, + { + "id": "file-006", + "type": "file", + "path": "README.md", + "change_type": "modified", + "additions": 13, + "deletions": 11 + }, + { + "id": "file-007", + "type": "file", + "path": "README.zh-CN.md", + "change_type": "modified", + "additions": 9, + "deletions": 9 + }, + { + "id": "file-008", + "type": "file", + "path": "docs/getting-started.md", + "change_type": "modified", + "additions": 14, + "deletions": 10 + }, + { + "id": "file-009", + "type": "file", + "path": "docs/how-sopify-works.en.md", + "change_type": "modified", + "additions": 1, + "deletions": 1 + }, + { + "id": "file-010", + "type": "file", + "path": "docs/how-sopify-works.md", + "change_type": "modified", + "additions": 1, + "deletions": 1 + }, + { + "id": "file-011", + "type": "file", + "path": "install.ps1", + "change_type": "modified", + "additions": 3, + "deletions": 3 + }, + { + "id": "file-012", + "type": "file", + "path": "install.sh", + "change_type": "modified", + "additions": 3, + "deletions": 3 + }, + { + "id": "file-013", + "type": "file", + "path": "installer/distribution.py", + "change_type": "modified", + "additions": 8, + "deletions": 35 + }, + { + "id": "file-014", + "type": "file", + "path": "installer/evidentloop.py", + "change_type": "modified", + "additions": 87, + "deletions": 204 + }, + { + "id": "file-015", + "type": "file", + "path": "scripts/install_sopify.py", + "change_type": "modified", + "additions": 3, + "deletions": 3 + }, + { + "id": "file-016", + "type": "file", + "path": "tests/test_evidentloop_installer.py", + "change_type": "modified", + "additions": 202, + "deletions": 347 + }, + { + "id": "finding-001", + "type": "finding", + "category": "risk", + "severity": "medium", + "status": "dismissed", + "title": "CLI 健康检查把 `doctor --json` 返回的 `warning` 状态也视为可复用的健康安装。", + "detail": "条件 `payload.get(\"status\") not in {\"ok\", \"warning\"}` 明确接受 `warning`,但同一 diff 的设计、文档及测试均将复用条件描述为 doctor 健康或 `status=ok`;这会让已报告警告的 CLI 被直接复用,同时跳过安装或修复路径。", + "fingerprint": "sha256:a13749027576eceaeed7a10a769854ffcb16887accffc52788d670e48d09e3e6", + "model_judgment": { + "status": "open", + "severity": "medium" + }, + "extensions": { + "evidentloop": { + "review_finding_id": "f-001", + "original_category": "missing_validation", + "confidence": "plausible" + } + }, + "file_path": "installer/evidentloop.py", + "hunk_id": "hunk:18f1882bd959e1ad:129:5", + "start_line": 204, + "end_line": 204, + "line_side": "old", + "highlight_lines": [ + 204 + ], + "hunk": "@@ -169,89 +129,48 @@ def install_evidentloop_companion(\n def _install_skill(plan: EvidentLoopInstallPlan) -> None:\n if plan.npx_path is None: # Defensive: prepare() owns prerequisite checks.\n raise InstallError(\"EvidentLoop Skill install requires npx\")\n- if plan.git_path is None:\n- raise InstallError(\"EvidentLoop Skill install requires git\")\n if _path_is_occupied(plan.skill_dir):\n raise InstallError(\n f\"EvidentLoop Skill path changed after preflight: {plan.skill_dir}\"\n )\n \n- source_env = _base_child_env(plan.home_root)\n- skills_cli_env = dict(source_env)\n- skills_cli_env[\"DISABLE_TELEMETRY\"] = \"1\"\n- skills_cli_env[\"DO_NOT_TRACK\"] = \"1\"\n with tempfile.TemporaryDirectory(prefix=\"sopify-evidentloop-\") as temp_dir:\n staging_root = Path(temp_dir)\n- source_root = staging_root / \"source\"\n- _run_command(\n- [\n- plan.git_path,\n- \"clone\",\n- \"--quiet\",\n- \"--depth\",\n- \"1\",\n- \"--branch\",\n- EVIDENTLOOP_SKILL_TAG,\n- EVIDENTLOOP_SKILL_REPOSITORY,\n- str(source_root),\n- ],\n- env=source_env,\n- label=\"EvidentLoop Skill source download\",\n- )\n- resolved_commit = _run_command(\n- [plan.git_path, \"-C\", str(source_root), \"rev-parse\", \"HEAD\"],\n- env=source_env,\n- label=\"EvidentLoop Skill source verification\",\n- ).stdout.strip()\n- if resolved_commit != EVIDENTLOOP_SKILL_COMMIT:\n- raise InstallError(\n- \"EvidentLoop Skill source commit changed: \"\n- f\"expected {EVIDENTLOOP_SKILL_COMMIT}, got {resolved_commit or '(empty)'}\"\n- )\n-\n- source_skill = source_root / \"skills\" / _SKILL_NAME\n- _validate_skill(source_skill)\n+ staging_home = staging_root / \"home\"\n+ staging_home.mkdir()\n+ env = _base_child_env(staging_home)\n+ env[\"DISABLE_TELEMETRY\"] = \"1\"\n+ env[\"DO_NOT_TRACK\"] = \"1\"\n argv = [\n plan.npx_path,\n \"--yes\",\n- f\"skills@{SKILLS_CLI_VERSION}\",\n+ SKILLS_CLI_PACKAGE,\n \"add\",\n- str(source_skill),\n+ EVIDENTLOOP_SKILL_SOURCE,\n+ \"--skill\",\n+ _SKILL_NAME,\n ]\n \n if plan.adapter.is_workspace_scope:\n- # Skills CLI targets Copilot through .agents/skills and a lockfile.\n- # Keep those staging files out of the project and copy only the Skill.\n- _run_command(\n- [\n- *argv,\n- \"-a\",\n- str(plan.adapter.skills_cli_agent),\n- \"-y\",\n- \"--copy\",\n- ],\n- env=skills_cli_env,\n- label=\"EvidentLoop Skill download\",\n- cwd=staging_root,\n- )\n+ argv.extend([\"-a\", str(plan.adapter.skills_cli_agent), \"-y\", \"--copy\"])\n staged_skill = staging_root / _SKILLS_CLI_PROJECT_DIR / _SKILL_NAME\n- _validate_skill(staged_skill)\n- _copy_skill_directory(staged_skill, plan.skill_dir)\n else:\n- _run_command(\n- [\n- *argv,\n- \"-g\",\n- \"-a\",\n- str(plan.adapter.skills_cli_agent),\n- \"-y\",\n- \"--copy\",\n- ],\n- env=skills_cli_env,\n- label=\"EvidentLoop Skill install\",\n- cwd=staging_root,\n+ argv.extend(\n+ [\"-g\", \"-a\", str(plan.adapter.skills_cli_agent), \"-y\", \"--copy\"]\n+ )\n+ staged_skill = (\n+ staging_home / str(plan.adapter.skill_install_dirname) / _SKILL_NAME\n )\n \n+ _run_command(\n+ argv,\n+ env=env,\n+ label=\"EvidentLoop Skill install\",\n+ cwd=staging_root,\n+ )\n+ _validate_skill(staged_skill)\n+ _copy_skill_directory(staged_skill, plan.skill_dir)\n+\n _validate_skill(plan.skill_dir)\n \n ", + "human_adjudication": { + "disposition": "false_positive", + "comment": "批判看看要不要优化 不要过度设计", + "severity_override": "medium", + "applied_run_id": "run-feedback-00fbe17d21db032d" + } + }, + { + "id": "evidence-001", + "type": "evidence", + "source": "host_llm", + "status": "fail", + "summary": "宿主语义审查结论:CLI 健康检查把 `doctor --json` 返回的 `warning` 状态也视为可复用的健康安装。", + "detail": "条件 `payload.get(\"status\") not in {\"ok\", \"warning\"}` 明确接受 `warning`,但同一 diff 的设计、文档及测试均将复用条件描述为 doctor 健康或 `status=ok`;这会让已报告警告的 CLI 被直接复用,同时跳过安装或修复路径。" + } + ], + "edges": [ + { + "id": "edge-001", + "type": "contains_change", + "from": "run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "to": "change-001" + }, + { + "id": "edge-002", + "type": "changes_file", + "from": "change-001", + "to": "file-001" + }, + { + "id": "edge-003", + "type": "changes_file", + "from": "change-001", + "to": "file-002" + }, + { + "id": "edge-004", + "type": "changes_file", + "from": "change-001", + "to": "file-003" + }, + { + "id": "edge-005", + "type": "changes_file", + "from": "change-001", + "to": "file-004" + }, + { + "id": "edge-006", + "type": "changes_file", + "from": "change-001", + "to": "file-005" + }, + { + "id": "edge-007", + "type": "changes_file", + "from": "change-001", + "to": "file-006" + }, + { + "id": "edge-008", + "type": "changes_file", + "from": "change-001", + "to": "file-007" + }, + { + "id": "edge-009", + "type": "changes_file", + "from": "change-001", + "to": "file-008" + }, + { + "id": "edge-010", + "type": "changes_file", + "from": "change-001", + "to": "file-009" + }, + { + "id": "edge-011", + "type": "changes_file", + "from": "change-001", + "to": "file-010" + }, + { + "id": "edge-012", + "type": "changes_file", + "from": "change-001", + "to": "file-011" + }, + { + "id": "edge-013", + "type": "changes_file", + "from": "change-001", + "to": "file-012" + }, + { + "id": "edge-014", + "type": "changes_file", + "from": "change-001", + "to": "file-013" + }, + { + "id": "edge-015", + "type": "changes_file", + "from": "change-001", + "to": "file-014" + }, + { + "id": "edge-016", + "type": "changes_file", + "from": "change-001", + "to": "file-015" + }, + { + "id": "edge-017", + "type": "changes_file", + "from": "change-001", + "to": "file-016" + }, + { + "id": "edge-018", + "type": "finding_in_file", + "from": "finding-001", + "to": "file-014" + }, + { + "id": "edge-019", + "type": "supported_by_evidence", + "from": "finding-001", + "to": "evidence-001" + }, + { + "id": "edge-run-feedback-fc634e01b8909cf7-supersedes", + "type": "supersedes_run", + "from": "run-feedback-fc634e01b8909cf7", + "to": "run-8d72fe3ae6f34c2d8021eadd1adf5d12" + }, + { + "id": "edge-run-feedback-00fbe17d21db032d-supersedes", + "type": "supersedes_run", + "from": "run-feedback-00fbe17d21db032d", + "to": "run-feedback-fc634e01b8909cf7" + } + ], + "summary": { + "review_status": "complete", + "verdict": "pass_candidate", + "risk_score": 0, + "finding_count": 1, + "unscored_finding_count": 0, + "open_finding_count": 0, + "fix_count": 0, + "fix_done_count": 0, + "summary_audit_status": "not_audited", + "risk_delta": -20, + "basis": "human_adjudication", + "model_verdict": "concerns", + "model_risk_score": 20, + "notice": "基于人工裁定,未重新审查代码", + "extensions": { + "evidentloop": { + "review_diagnostics": { + "intent_coverage": "covered", + "files_reviewed": 16, + "files_total": 16, + "pack_completeness": 0.65, + "raw_findings_count": 1, + "emitted_findings_count": 1, + "reviewer_failure_reason": null, + "advisory_verdict": "concerns", + "advisory_rationale": "review found medium/high-severity issues" + } + } + } + }, + "extensions": { + "evidentloop": { + "profile": "code_diff", + "run_id": "run-8d72fe3ae6f34c2d8021eadd1adf5d12", + "adapter": "gitdiff/v0", + "diff_version": "sha256:4146c123c0a2e4aac9392e5e3917b1f7c6f252614a171fc07a01ddd60bbbd8f3", + "reviewer_prompt": { + "source": "product", + "version": "v0.5", + "sha256": "sha256:b3f437c0ea776dc994cca90b049d45b8b54d48f657be1841cf7f8ac87af78907" + } + } + } +} diff --git a/.sopify/plan/20260718_evidentloop_optional_audit_integration/design.md b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/design.md similarity index 81% rename from .sopify/plan/20260718_evidentloop_optional_audit_integration/design.md rename to .sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/design.md index cda824fa..3fb9469b 100644 --- a/.sopify/plan/20260718_evidentloop_optional_audit_integration/design.md +++ b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/design.md @@ -23,7 +23,7 @@ Sopify 的默认能力是接收产品无关的 `verdict / evidence / source`, | 仅 Sopify | plan、develop、handoff、receipt 正常工作;不运行外部审计 | 无 | | 其他验证器 | 用户显式指定后按其 Skill/契约运行;正式证据仍可写通用 receipt | 该验证器自身 | | 独立安装 EvidentLoop | 用户显式请求时直接做兼容探针并使用 | EvidentLoop 自身 | -| `--with-evidentloop` | 同一次 Sopify 安装中补齐缺失的测试版本,或复用兼容组件;运行时与独立安装完全相同 | 安装便利由 Sopify 编排,产物仍归 EvidentLoop | +| `--with-evidentloop` | 同一次 Sopify 安装中按官方当前方式补齐缺失组件,或复用健康组件;运行时与独立安装完全相同 | 安装便利由 Sopify 编排,兼容性和产物仍归 EvidentLoop | `--with-evidentloop` 不是 capability flag。Sopify 不记录“由谁安装”,也不以安装参数决定运行资格;运行时只认用户意图、当前可发现的 Skill/CLI 和兼容性证据。 @@ -38,32 +38,32 @@ Sopify 的默认能力是接收产品无关的 `verdict / evidence / source`, | `qoder` | `qoder` | `$HOME/.qoder/skills/evidentloop/` | 当前用户 | | `copilot` | `github-copilot` | `/.github/skills/evidentloop/` | 当前项目 | -EvidentLoop CLI 始终作为当前用户的 `uv tool` 安装。新装 CLI 与 Skill 使用当前 Sopify 发布验证过的版本和来源;已有组件只做兼容检查,Sopify 不声明其来源相同。Skill 遵守上表的宿主范围,不能从 Sopify prompt 资产目录推导,也不能把 Copilot 改成用户级私有目录。新增宿主没有明确映射时,只有可选分支停止,Sopify 核心安装不受影响。 +EvidentLoop CLI 始终作为当前用户的 `uv tool` 安装。新装 CLI 与 Skill 使用 EvidentLoop 当前官方来源;已有 CLI 只检查 doctor 健康状态,已有 Skill 只检查顶部 front matter 的 `name: evidentloop` 身份,均不自动升级。兼容性由 EvidentLoop 自身负责,Sopify 不维护版本矩阵。Skill 遵守上表的宿主范围,不能从 Sopify prompt 资产目录推导,也不能把 Copilot 改成用户级私有目录。新增宿主没有明确映射时,只有可选分支停止,Sopify 核心安装不受影响。 Sopify 本身要求 Python 3.11+。公开 shell/PowerShell 包装器在下载源码前按 `python3 → python → py -3` 选择第一个兼容解释器;没有兼容版本时显示检测结果并停止,不把它误报为 EvidentLoop 问题,也不自动安装 Python 或修改用户环境。 安装流程固定为: 1. 未传参数时,走原有 Sopify 安装路径;不查找 `evidentloop / uv / git / npx`,不访问 EvidentLoop 来源,不提示用户安装。 -2. 传入参数时也先完成并校验 Sopify,再只读检查已有 EvidentLoop CLI 与目标 Skill 目录。CLI 缺失才要求 `uv`;Skill 缺失才要求 Git 和 `npx`。已有组件不完整或不兼容时保持原样。 -3. CLI 缺失时安装固定的 `evidentloop==0.1.0a2`,随后通过 doctor、package/schema/prompt 版本和必要子命令探针,并确认未来宿主可从 `PATH` 解析同一可执行文件。若 uv 工具目录尚未进入 `PATH`,不自动修改 shell,只提示执行 `uv tool update-shell`、重启宿主后重跑。 -4. Skill 缺失时,在临时目录浅克隆 tag `v0.1.0a2`,要求 HEAD 等于 `fcefb77083d32b034e56b04dcd085dcf5a835550`,并先校验 Skill 结构。然后用固定的 Skills CLI `1.5.9` 从该本地目录执行 `--copy` 安装,不使用可变 tag URL 或 `skills@latest`。 -5. Codex、Claude、Qoder 使用 `-g -a -y --copy` 写入各自用户目录。Copilot 在临时项目生成 `.agents/skills/evidentloop/` 后校验,再只把 Skill 原子复制到目标项目 `.github/skills/evidentloop/`;临时 `.agents/` 和 `skills-lock.json` 不进入用户项目。目标 Skill 属于用户项目,用户按需审查和提交,Sopify 不自动提交或更新。 -6. 安装完成后再次校验最终 CLI 与 Skill。任一步未完成时不回滚 Sopify 或已有 EvidentLoop 产物,只告诉用户 Sopify 可正常使用,并给出独立安装或重跑入口;不解释外部命令内部错误。 +2. 传入参数时也先完成并校验 Sopify,再只读检查已有 EvidentLoop CLI 与目标 Skill 目录。CLI 缺失才要求 `uv`;Skill 缺失才要求 Git 和 `npx`。CLI 健康性只认 EvidentLoop 的 `doctor --json`,Skill 只检查非空入口结构;已有异常组件保持原样。 +3. CLI 缺失时执行官方当前安装命令 `uv tool install evidentloop`,再以 doctor 返回的版本和状态确认健康,并确认未来宿主可从 `PATH` 解析同一可执行文件。若 uv 工具目录尚未进入 `PATH`,不自动修改 shell,只提示执行 `uv tool update-shell`、重启宿主后重跑。 +4. Skill 缺失时执行官方当前安装命令 `npx skills@latest add evidentloop/evidentloop --skill evidentloop`。四个宿主都先安装到临时 HOME 或临时项目,校验 `SKILL.md` 后再原子复制到最终目录;失败产生的未验证内容随临时目录删除,不占用用户目标路径。 +5. Codex、Claude、Qoder 使用 `-g -a -y --copy` 定向生成各自用户级 Skill;Copilot 使用项目级 target,在临时项目生成 `.agents/skills/evidentloop/` 后只复制 Skill 到目标项目 `.github/skills/evidentloop/`。临时 `.agents/` 和 `skills-lock.json` 不进入用户项目。目标 Skill 属于用户项目,用户按需审查和提交,Sopify 不自动提交或更新。 +6. 任一步未完成时不回滚 Sopify,也不把可选安装包装成复杂事务。用户面只说明 Sopify 已可用、EvidentLoop 安装未完成,并给出重跑同一命令或独立安装入口;不解释外部命令内部错误。 -所有外部命令只使用固定常量和已解析路径,不读取或输出凭据。遥测开关只进入 Skills CLI 子进程环境,不修改用户环境。安装结果显示 CLI 动作、Skill 动作、版本和最终 Skill 路径。 +所有外部命令只使用代码内确定的官方来源和已解析路径,不读取或输出凭据。遥测开关只进入 Skills CLI 子进程环境,不修改用户环境。安装结果显示 CLI 动作、Skill 动作、doctor 返回版本和最终 Skill 路径。 -这些证据只证明下载、目录结构、兼容性和安装链。Skill discovery 与实际审计仍需真实宿主会话证明。Copilot Skill 放在 GitHub 文档支持的 `.github/skills/`,但本机 `uv tool` 不会自动进入云端环境;本轮只承诺本地组件放置、兼容校验和 PATH 可见性。 +这些证据只证明下载、目录结构、健康检查和安装链。Skill discovery 与实际审计仍需真实宿主会话证明。Copilot Skill 放在 GitHub 文档支持的 `.github/skills/`,但本机 `uv tool` 不会自动进入云端环境;本轮只承诺本地组件放置、健康检查和 PATH 可见性。 ## 升级边界 本方案不自动升级、降级或卸载 EvidentLoop,也不新增版本范围求解和持久 component state。 -- 用户按 EvidentLoop 官方方式独立升级后,Sopify 在下一次显式审计时重新执行兼容探针;通过即可使用,与最初安装来源无关。 -- 再次运行 `--with-evidentloop` 时,兼容安装只复用;不兼容安装只报告当前值和本次 Sopify 已验证值,不自动改写。 -- 后续 Sopify 发布可以更新自己验证过的 package/schema/prompt/Skill tag 常量,但“由 Sopify 自动升级已有 EvidentLoop”需等真实用户阻塞后另立任务。 +- EvidentLoop 自己负责 CLI、Skill、schema 和 prompt 的版本兼容;`doctor` 和 Skill 内容随产品发布演进。 +- 再次运行 `--with-evidentloop` 时,健康组件直接复用;异常组件保持原样并提示用户重跑或按 EvidentLoop 官方方式独立处理。 +- Sopify 只负责“缺失时按当前官方方式安装”的便利,不维护兼容矩阵、版本选择器或升级管理器。 -这样保留未来升级入口,但当前不把可选安装便利扩张成包管理器。 +这样保留独立产品边界,也避免把可选安装便利扩张成包管理器。 ## 方案包契约 @@ -172,8 +172,8 @@ EvidentLoop 通用增强在其独立仓库建立最小方案、测试和发布 - EvidentLoop 缺少通用版本输出时,在跨仓 checkpoint 停止,不在 Sopify 内复制一套临时字段实现。 - 审计发现需要修改 EvidentLoop 产品语义、正式 artifact profile 或 Sopify 默认工作流时,列出证据并等待用户新决策。 - 发布未获明确授权时,只完成候选验证,不创建 tag、不上传 PyPI、不创建 GitHub Release。 -- 未选择 `--with-evidentloop` 时,任何 EvidentLoop 缺失或不兼容都不得影响 Sopify 安装、doctor 或核心工作流。 -- 选择配套安装但 target/前置依赖不受支持时,在安装写入前停止;已有不兼容 EvidentLoop 时不自动替换。 +- 未选择 `--with-evidentloop` 时,任何 EvidentLoop 缺失或异常都不得影响 Sopify 安装、doctor 或核心工作流。 +- 选择配套安装但 target/前置依赖不受支持时,只停止可选分支;已有异常 EvidentLoop 时不自动替换。 - 历史目录出现非预期修改时立即停止,不用批量迁移或重渲染掩盖差异。 - finalize 的 history 目标已存在、最终版本冲突、final/history receipt 写入失败或目录迁移失败时,不清理 active plan/handoff;history index 更新失败不属于上述阻断条件,不改变已经完成的归档事实。 diff --git a/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/plan.md similarity index 77% rename from .sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md rename to .sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/plan.md index 42dad442..139f3255 100644 --- a/.sopify/plan/20260718_evidentloop_optional_audit_integration/plan.md +++ b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/plan.md @@ -1,12 +1,12 @@ --- title: EvidentLoop 可选方案审计接入与 CrossReview 收口 plan_id: 20260718_evidentloop_optional_audit_integration -status: in_progress -lifecycle_state: active +status: completed +lifecycle_state: ready_to_archive level: architecture created: 2026-07-18 -updated: 2026-07-19 -archive_ready: false +updated: 2026-07-20 +archive_ready: true knowledge_sync: project: review background: required @@ -19,17 +19,17 @@ knowledge_sync: ## Plan Snapshot - **Goal**: 保留 Sopify 产品无关的验证证据能力,以 EvidentLoop 替代当前 CrossReview 集成,并提供不绑定运行时的一次性可选配套安装与公开版本 dogfood。 -- **Status**: Wave 1–4 已完成;四宿主安装路径、固定来源、Python/PATH 预检、失败边界和安装承诺已收口。 -- **Next**: 停车等待用户确认是否进入 Wave 5。 -- **Task**: 14/21。 +- **Status**: Wave 1–6 已完成;公开版本安装、Codex Skill discovery、最终审计、用户裁定、正式 receipt 与只读残留复核均已闭环。 +- **Next**: 无;方案已进入 `ready_to_archive`,由 `sopify_writer` 执行标准归档。 +- **Task**: 21/21。 评分: - 方案质量: 9/10 -- 落地就绪: 8/10 +- 落地就绪: 10/10 评分理由: - 优点: 统一复用现有 plan 入口、writer、Verifier 和 EvidentLoop 通用产物,不增加第二套审计协议或兼容层。 -- 扣分: 多宿主配套安装和 Codex 完整 diff dogfood 尚需执行期证据确认;自动升级明确延后。 +- 扣分: EvidentLoop 对误报后历史严重度和评论的展示仍可在其后续版本最小优化,不阻断 Sopify 本方案。 ## Context / Why @@ -47,7 +47,7 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif - 在 EvidentLoop 独立仓库补充通用 `diff_version / report_version` 输出,并通过下一公开 Alpha 提供给所有消费者。 - 在进入可选安装前收口当前分发契约:develop 完成态、active plan 读取入口和内置 Skill 的宿主支持声明保持一致。 - 保持 Sopify 默认只提供产品无关的 Verifier evidence 消费;不安装验证器也能完整使用 plan、develop、handoff 和 receipt。 -- 为 Sopify 可安装宿主增加显式 `--with-evidentloop` 配套安装:只在用户选择时安装缺失的当前发布测试版本,已有组件只做兼容检查;不把该参数作为运行能力开关。 +- 为 Sopify 可安装宿主增加显式 `--with-evidentloop` 配套安装:只在用户选择时按 EvidentLoop 官方当前方式安装缺失组件,已有健康组件直接复用且不自动升级;兼容性归 EvidentLoop,自身参数不作为运行能力开关。 - 允许用户直接使用已独立安装的 EvidentLoop 或其他验证组件,不要求经 Sopify 安装,不增加验证器注册表。 - 在 Sopify 定义独立审计、方案主审计和附加审计三种消费方式,不新增默认触发或专用命令。 - 使用公开 EvidentLoop CLI 与官方 Skill 审计本方案冻结后的“实现与契约 diff”,完成人工裁定、报告附着和 receipt 记录。 @@ -59,7 +59,7 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif - 在现有 `sopify_writer`、`workspace_status_lite` 和 protocol check 上复用一个确定性版本计算,不新增 state、registry、manifest 或 MCP tool。 - Verifier 契约保持产品无关。EvidentLoop 只输出通用 diff/report 版本与审计产物,不认识 Sopify 的 plan、receipt 或目录约定。 - `--with-evidentloop` 只是现有安装器的一条显式分支:不带参数时不探测、不访问 EvidentLoop 来源、不安装、不提示 EvidentLoop;带参数时才预检并安装缺失项。 -- 安装来源不决定运行资格。用户显式请求 EvidentLoop 时,以当下 CLI、Skill discovery 和兼容探针为准;已独立安装的兼容版本与配套安装版本走同一消费链。 +- 安装来源不决定运行资格。用户显式请求 EvidentLoop 时,以当下 CLI doctor 和 Skill discovery 为准;已独立安装的健康版本与配套安装版本走同一消费链,兼容性由 EvidentLoop 自身负责。 - Sopify 负责把 `plan_version / diff_version / report_version / audit_path` 写入正式方案 receipt;独立审计不强制进入 Sopify 证据链。 - CrossReview 当前执行面直接删除,不提供命令别名、配置兼容、bridge、迁移器或本地 EvidentLoop Skill 副本。 - EvidentLoop 通用增强按其独立仓库生命周期实施和发布;Sopify dogfood 只消费公开安装物,不使用源码 checkout 或 editable install。 @@ -69,7 +69,7 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif 1. 对齐方案包结构、`plan_version`、入口预检和中英文分发资产。 2. 删除 CrossReview 当前执行面,并收口 Sopify blueprint、protocol 与 ADR 当前真相。 3. 在 EvidentLoop 独立仓库补齐通用 `diff_version / report_version`,经确认后发布下一 Alpha。 -4. 先收口当前分发契约漂移,再在现有 Sopify 多宿主安装器中加入 `--with-evidentloop` 最小分支,复用目标宿主映射并验证未选择、缺失安装、兼容复用和不兼容停车。 +4. 先收口当前分发契约漂移,再在现有 Sopify 多宿主安装器中加入 `--with-evidentloop` 最小分支,复用目标宿主映射并验证未选择、官方当前安装、健康复用、异常停车和失败可重试。 5. 先完成全量验证、残留审计和知识同步,再以 Codex 作为首轮真实宿主,用该公开版本与配套安装路径审计最终“实现与契约 diff”;初次报告生成后显式停车等待用户裁定。 6. 用户裁定闭环后附着报告、写 receipt,完成只读残留复核和标准 finalize 归档。 @@ -86,12 +86,12 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif - `--with-evidentloop` 默认关闭且只提供安装便利,不写 `evidentloop_enabled`、component registry、state 或持久安装来源。未选择时保持现有安装路径和 `stdlib_only` 核心依赖模型。 - Sopify 公开安装入口要求 Python 3.11+;包装器在源码下载前选择第一个兼容解释器,没有时明确停止。它不静默跳过显式配套安装,不自动安装 Python,也不修改用户环境。 - 配套安装复用现有 host adapter,但每个宿主使用自己的接入范围:Codex 为 `$HOME/.agents/skills/evidentloop/`,Claude 为 `$HOME/.claude/skills/evidentloop/`,Qoder 为 `$HOME/.qoder/skills/evidentloop/`,Copilot 为当前项目的 `.github/skills/evidentloop/`。新增宿主没有明确映射时停止,不猜路径。 -- CLI 统一作为当前用户的 `uv tool` 安装;新装 Skill 通过固定 Skills CLI 版本和固定源码 commit 获取。已有组件通过兼容探针即可复用,Sopify 不声明其来源与固定 commit 相同。Codex、Claude、Qoder 使用对应 agent target 的全局安装;Copilot 先在临时目录下载并校验,再只复制 Skill 到 `.github/skills/`,不在项目写 `.agents/skills/` 或 `skills-lock.json`。 -- Sopify 核心安装和校验先完成,再按缺失组件检查 EvidentLoop 前置依赖:CLI 已兼容时不要求 `uv`,Skill 已兼容时不要求 Git 和 `npx`。EvidentLoop 未完成时不回滚或否定 Sopify,只说明可稍后独立安装或重跑;已有组件不完整或不兼容时保持原样,不自动升级、降级或覆盖。 -- Copilot Skill 放在 GitHub 文档支持的项目目录,属于用户项目内容;用户按需审查和提交,Sopify 不自动提交或更新。本机 `uv tool` 不会自动进入云端环境。本方案只承诺本地组件放置、兼容校验和 PATH 可见性;Skill discovery 与审计 E2E 仍需宿主证据,云端 CLI 需另行提供。 +- CLI 统一作为当前用户的 `uv tool` 安装;新装 CLI 与 Skill 使用 EvidentLoop 当前官方命令。已有 CLI 通过 doctor 健康检查、已有 Skill 通过顶部 front matter 的 `name: evidentloop` 身份检查后直接复用,均不自动升级;Sopify 不维护版本矩阵。Codex、Claude、Qoder 和 Copilot 都先在临时 HOME 或临时项目生成并校验 Skill,再原子复制到最终目录;Copilot 不在项目写 `.agents/skills/` 或 `skills-lock.json`。 +- Sopify 核心安装和校验先完成,再按缺失组件检查 EvidentLoop 前置依赖:CLI 已健康时不要求 `uv`,Skill 已存在且结构健康时不要求 Git 和 `npx`。EvidentLoop 安装未完成时不回滚或否定 Sopify,最终 Skill 目录不留未验证内容;用户可重跑或独立安装。已有异常组件保持原样,不自动升级、降级或覆盖。 +- Copilot Skill 放在 GitHub 文档支持的项目目录,属于用户项目内容;用户按需审查和提交,Sopify 不自动提交或更新。本机 `uv tool` 不会自动进入云端环境。本方案只承诺本地组件放置、健康检查和 PATH 可见性;Skill discovery 与审计 E2E 仍需宿主证据,云端 CLI 需另行提供。 - Codex 是本方案首轮真实 E2E dogfood 宿主,不是配套安装的唯一宿主。其他宿主必须区分“安装链/结构已验证”和“Skill discovery/审计 E2E 已验证”,不把安装成功宣传为完整可用证据。 - 内置 Skill 的 `host_support` 只表示其语义可由官方适配器交付到对应宿主的支持界面并被消费;它不代表原生 Skill discovery 或 E2E 已验证,能力等级继续由 `HostCapability` 表达。 -- 本方案不自动管理 EvidentLoop 后续升级。用户可按 EvidentLoop 官方方式独立升级;升级后只要兼容探针通过,Sopify 即可使用。自动升级待真实需求出现后另行设计。 +- 本方案不自动管理 EvidentLoop 后续升级。用户可按 EvidentLoop 官方方式独立升级;升级后的兼容性由 EvidentLoop doctor 与 Skill 自身负责,Sopify 只复用健康组件。 - 用户使用其他验证器时沿用通用 Verifier evidence 与 receipt,不要求 `diff_version / report_version`、EvidentLoop 报告目录或安装参数。 - 独立 EvidentLoop 审计沿用产品默认目录,不绑定 Sopify 方案;方案主审计使用 `audits/plan/` 并必须写 receipt;附加审计使用 `audits//`,仅在作为正式方案证据时写 receipt。 - 不使用 `overall`,不新增自动编号器、报告索引、manifest、latest 指针或聚合层;`audits/` 不进入 4 步协议默认读取链。 @@ -137,10 +137,15 @@ CrossReview 已被独立产品 EvidentLoop 替代,但 Wave 2 实施前,Sopif - [x] Wave 3 的 EvidentLoop 通用版本实现、定向验证与 370 项全量回归完成。 - [x] EvidentLoop `v0.1.0a2` 已发布:[Publish](https://github.com/evidentloop/evidentloop/actions/runs/29678876261) 成功,[PyPI](https://pypi.org/project/evidentloop/0.1.0a2/) 包含未撤回的 wheel 与 sdist,[GitHub prerelease](https://github.com/evidentloop/evidentloop/releases/tag/v0.1.0a2) 为非草稿;schema `0.4`、prompt `v0.5` 保持不变,Release 无额外 assets 不阻断。 - [x] Task 4.0 已完成:develop/finalize 生命周期、active-plan 读取入口和四宿主 `host_support` 已双语收口;96 tests、36 subtests 与 Ruff 通过。 -- [x] Wave 4 已按宿主原生路径重写:固定 tag 解析到预期 commit;Skills CLI 在隔离目录真实写入 Codex `.agents`、Claude `.claude`、Qoder `.qoder`,安装 helper 真实把 Copilot 临时产物只复制到项目 `.github/skills/`。 -- [x] Wave 4 回归已收口:268 tests、74 subtests 与 release preflight 的 99 项硬门禁、全部协议 smoke、增量 Ruff、README 校验、shell 语法和 diff check 均通过;公开包装器已补齐下载前 Python 3.11+ 预检。后续按用户决策进一步收窄为“Sopify 先完成,EvidentLoop 失败只说明独立安装下一步”,不展示外部命令诊断。 -- [ ] Wave 5–6、公开版本真实 dogfood、用户裁定和归档完成。 +- [x] Wave 4 已按宿主原生路径收口:缺失组件使用 EvidentLoop 当前官方命令;四宿主 Skill 都先在隔离目录完成安装和校验,再原子复制到最终目录,失败不留下未验证目标目录。 +- [x] Wave 4 回归已收口:266 tests、71 subtests,Python 3.11 硬门禁 97 tests、4 subtests,5 个协议 smoke、payload smoke、增量 Ruff、README 校验、shell 语法和 diff check 均通过;用户面只说明 Sopify 已可用、EvidentLoop 安装未完成,并给出重跑或独立安装入口。 +- [x] Wave 5.1 已完成:隔离 HOME 的同一安装入口一次完成 Sopify、EvidentLoop CLI 与 Codex Skill;doctor 返回 `status=ok`、实际版本 `0.1.0a2` 和 4 个可读 package resources,Codex 本地 model-visible prompt 明确发现隔离路径中的 `evidentloop` Skill。 +- [x] Wave 5.2 已完成:172 tests、46 subtests 定向验证与 266 tests、71 subtests 全量回归通过;Python 3.11 release preflight 全部通过,当前方案结构/version 有效,CrossReview 当前执行面与退役兼容面无残留,history 和 tracked state 无差异。 +- [x] Wave 5 初次报告为 `complete / concerns`,唯一 medium finding 指出 Skill 身份校验不足;用户接受 finding 后,只增加顶部 front matter 的 `name: evidentloop` 检查并去除重复测试。定向、全量与 release preflight 仍全部通过,隔离 HOME 中的官方 Skill 已真实复用成功;旧报告因 diff 改变失效。 +- [x] 替代报告覆盖冻结 staged diff 的 16/16 文件;用户正式裁定唯一 finding 为误报,确定性修订后结论为 `complete / pass_candidate`、风险 0、开放问题 0。 +- [x] Wave 5.4 已附着最终 `audit.json + audit.html` 并由 writer 写入 `verify_001`;报告、diff 与方案三个版本值已绑定,附着未改变 `plan_version`。 +- [x] Wave 6 只读残留复核通过;新增差异仅为最终报告、receipt 和收口状态,方案已进入 `ready_to_archive`。 ## Next -停车等待用户确认是否进入 Wave 5。 +无。方案通过 `sopify_writer` 标准归档后,以 history 包内的 final receipt 为最终机器证据。 diff --git a/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipt.md b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipt.md new file mode 100644 index 00000000..f63db413 --- /dev/null +++ b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipt.md @@ -0,0 +1,18 @@ +--- +plan_id: 20260718_evidentloop_optional_audit_integration +outcome: completed +plan_version: sha256:f6f1d87168a690b0bcde68ae9a942ef1a8a23aca816efeb0cb6ca0d347c05fea +--- + +# completed + +## Summary + +完成产品无关 Verifier 契约、EvidentLoop 可选配套安装、公开版本 Codex dogfood、用户裁定与正式审计证据闭环。 + +## Key Decisions + +- EvidentLoop 是官方推荐的可选 Verifier,不进入 Sopify 默认安装、执行或唯一实现。 +- 新装组件使用 EvidentLoop 当前官方来源;已有健康组件直接复用且不自动升级,兼容性由 EvidentLoop 自身负责。 +- 可选安装失败不回滚 Sopify,也不留下未验证 Skill 目录;用户可重跑或独立安装。 +- 公开版本 dogfood 的最终报告经用户裁定为 pass_candidate,并由 verify_001 绑定 plan、diff 与 report 版本。 diff --git a/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipts/final.json b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipts/final.json new file mode 100644 index 00000000..f63012b1 --- /dev/null +++ b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipts/final.json @@ -0,0 +1,15 @@ +{ + "evidence": { + "audit_path": "audits/plan/audit.json", + "protocol_smokes": "5 passed", + "targeted_tests": "55 passed, 48 subtests passed", + "verification_receipt": "receipts/verify_001.json" + }, + "provenance": { + "plan_id": "20260718_evidentloop_optional_audit_integration", + "plan_version": "sha256:f6f1d87168a690b0bcde68ae9a942ef1a8a23aca816efeb0cb6ca0d347c05fea", + "receipt_id": "final" + }, + "timestamp": "2026-07-20T04:47:10+00:00", + "verdict": "finalized" +} diff --git a/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipts/verify_001.json b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipts/verify_001.json new file mode 100644 index 00000000..8d5ddaaf --- /dev/null +++ b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/receipts/verify_001.json @@ -0,0 +1,20 @@ +{ + "evidence": { + "audit_path": "audits/plan/audit.json", + "diff_version": "sha256:4146c123c0a2e4aac9392e5e3917b1f7c6f252614a171fc07a01ddd60bbbd8f3", + "package_version": "0.1.0a2", + "prompt_version": "v0.5", + "report_version": "sha256:98b12be84e5a57f7d4f3f848ef6e4212d895eaf7b93f9f4762c9efd92661c1ef", + "review_status": "complete", + "run_id": "run-feedback-00fbe17d21db032d", + "schema_version": "0.4", + "source": "evidentloop" + }, + "provenance": { + "plan_id": "20260718_evidentloop_optional_audit_integration", + "plan_version": "sha256:dc699fb7fba4b99afe4264903a0940647fe74ca526ee66878f931da813f13c90", + "receipt_id": "verify_001" + }, + "timestamp": "2026-07-20T04:41:49+00:00", + "verdict": "pass_candidate" +} diff --git a/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/tasks.md similarity index 74% rename from .sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md rename to .sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/tasks.md index 3f4fd287..501a80b5 100644 --- a/.sopify/plan/20260718_evidentloop_optional_audit_integration/tasks.md +++ b/.sopify/history/2026-07/20260718_evidentloop_optional_audit_integration/tasks.md @@ -46,45 +46,49 @@ - 验收:不带参数时不查找命令、不联网、不安装、不提示;现有核心安装、payload 和 `stdlib_only` 依赖声明不变。带参数时也先完成 Sopify;新宿主缺少映射或 EvidentLoop 未完成时只停止可选分支,不回滚或否定 Sopify。 - 验收:公开包装器在下载前选择 Python 3.11+;没有兼容解释器时说明检测结果和未发生下载/安装,不静默跳过、不自动安装 Python、不修改用户环境。 - [x] 4.2 用一个 EvidentLoop 专用 helper 安装或复用 CLI 与 Skill,不建立通用组件框架。 - - 验收:新装 CLI 固定为当前用户的 `evidentloop==0.1.0a2`;新装 Skill 固定到源码 commit `fcefb77083d32b034e56b04dcd085dcf5a835550`,Skills CLI 固定为 `1.5.9`,不使用可变 tag 或 `@latest`。 - - 验收:只为缺失组件检查前置命令;CLI 缺失才要求 `uv`,Skill 缺失才要求 Git 和 `npx`。新装 CLI 必须能从 `PATH` 解析到同一文件;不可见时不改 shell,保留已安装 CLI 并给出 `uv tool update-shell`、重启和重跑提示。已有兼容组件直接复用,但只声明通过兼容检查,不声明来源相同;不完整或不兼容组件保持原样并停止。中途失败说明已完成部分,同一命令可继续补齐。 - - 验收:Codex、Claude、Qoder 使用对应 agent target 的用户级安装;Copilot 在临时目录下载并校验后,只复制 Skill 到项目 `.github/skills/evidentloop/`,不写项目 `.agents/skills/` 或 `skills-lock.json`,也不自动提交或更新用户项目内容。仅子进程关闭匿名遥测。 + - 验收:新装 CLI 执行官方当前命令 `uv tool install evidentloop`;新装 Skill 执行 `npx skills@latest add evidentloop/evidentloop --skill evidentloop`,不提供版本选择或维护兼容矩阵。 + - 验收:只为缺失组件检查前置命令;CLI 缺失才要求 `uv`,Skill 缺失才要求 Git 和 `npx`。新装 CLI 必须能从 `PATH` 解析到同一文件;不可见时不改 shell,保留已安装 CLI 并给出 `uv tool update-shell`、重启和重跑提示。已有 CLI 通过 doctor 健康检查、已有 Skill 通过顶部 front matter 的 `name: evidentloop` 身份检查后直接复用,均不自动升级;兼容性归 EvidentLoop 自身负责。 + - 验收:四宿主都先在临时 HOME 或临时项目完成 Skill 安装与校验,再原子复制到最终目录;失败不留下未验证目标目录,可直接重跑或独立安装。Copilot 不写项目 `.agents/skills/` 或 `skills-lock.json`,也不自动提交或更新用户项目内容。仅子进程关闭匿名遥测。 - 验收:不写 `evidentloop_enabled`、安装来源、component registry 或 state;不自动升级、降级、覆盖或卸载。 -- [x] 4.3 用精简测试和隔离安装证据验证四宿主路径、固定命令、真实 Skill 目录结构、Copilot 定向复制、默认关闭、缺映射、按组件前置检查、兼容复用、不兼容停车、部分完成提示和输出错误码。 +- [x] 4.3 用精简测试和隔离安装证据验证四宿主路径、官方当前命令、Skill 临时安装与原子复制、Copilot 定向复制、默认关闭、缺映射、按组件前置检查、健康复用、异常停车、失败可重试和单一用户错误提示。 - 验收:安装/结构证据与 Skill discovery/E2E 结论分开;没有真实宿主证据时不宣称 discovery 已验证。Copilot 文档明确项目 Skill 与本机 CLI 的云端边界。 - - 证据:268 tests、74 subtests、release preflight 99 项硬门禁与全部协议 smoke 通过;来源、Copilot 项目归属和“Sopify 先完成、EvidentLoop 失败可独立安装”的承诺已最小收窄,无外部错误诊断框架。 + - 证据:266 tests、71 subtests,Python 3.11 硬门禁 97 tests、4 subtests,5 个协议 smoke、payload smoke 与增量 Ruff 通过;来源、Copilot 项目归属和“Sopify 先完成、EvidentLoop 失败可重试或独立安装”的承诺已最小收窄,无外部错误诊断框架。 ## Wave 5 | 验证冻结与公开版本真实 Dogfood -- [ ] 5.1 在隔离 HOME 中用冻结的 Sopify release candidate 安装入口和 `--with-evidentloop` 完成 Codex 配套安装;在不读取或输出凭据的前提下核对 `evidentloop doctor --json`、固定版本、package resources 和 Skill 文件,再用不继承当前讨论的新宿主会话证明真实 Skill discovery。 -- [ ] 5.2 完成 plan 契约、版本计算、installer/skill 分发、writer/lite status、protocol check、EvidentLoop 消费路径的定向测试,以及全量 `python3 -m pytest tests -v`、`git diff --check`、版本/链接一致性、CrossReview 当前层残留和 history 未改写检查;修复本方案问题,并完成 `project.md`、blueprint/protocol 和方案语义同步。 +- [x] 5.1 在隔离 HOME 中用冻结的 Sopify release candidate 安装入口和 `--with-evidentloop` 完成 Codex 配套安装;在不读取或输出凭据的前提下核对 `evidentloop doctor --json` 返回的实际版本、package resources 和 Skill 文件,再用不继承当前讨论的新宿主会话证明真实 Skill discovery。 + - 证据:全新隔离 HOME 下同一入口返回成功,CLI 与 Skill 均为 `installed`;doctor 返回 `status=ok`、实际版本 `0.1.0a2`、4 个 package resources 可读;Codex `debug prompt-input` 在不调用模型的 fresh 上下文中列出隔离路径 `.agents/skills/evidentloop/SKILL.md`。 +- [x] 5.2 完成 plan 契约、版本计算、installer/skill 分发、writer/lite status、protocol check、EvidentLoop 消费路径的定向测试,以及全量 `python3 -m pytest tests -v`、`git diff --check`、版本/链接一致性、CrossReview 当前层残留和 history 未改写检查;修复本方案问题,并完成 `project.md`、blueprint/protocol 和方案语义同步。 - 验收:所有可能改变实现或契约的动作在审计前结束;区分本方案失败与无关基线问题。 -- [ ] 5.3 冻结只属于本方案的“实现与契约 staged diff”和当前 `plan_version`,在 Git 忽略的 `reports/` 下完成 `prepare → host review → finalize`,随后停车并把初次报告交给用户裁定。 + - 证据:初次审计 finding 最小修复后,172 tests、46 subtests 定向验证与 266 tests、71 subtests 全量回归通过;Python 3.11 release preflight 的 97 tests、4 subtests、5 个协议 smoke、payload smoke、golden snapshot、catalog drift、链接和版本一致性检查全部通过;隔离 HOME 中的当前官方 Skill 通过新身份检查并真实复用,当前层无 CrossReview/退役兼容面残留,history 与 tracked state 无差异。 +- [x] 5.3 冻结只属于本方案的“实现与契约 staged diff”和当前 `plan_version`,在 Git 忽略的 `reports/` 下完成 `prepare → host review → finalize`,随后停车并把初次报告交给用户裁定。 - 审计主体包含实现、测试、Skill/模板、当前 blueprint/protocol 和方案语义;排除 `audits/**`、`receipts/**`、报告临时目录及 finalize 的纯状态/归档差异。 - Checkpoint U1(用户动作,不由宿主勾选):用户查看初次报告并提交裁定/反馈;宿主不得自动代替。 -- [ ] 5.4 收到用户裁定后继续:若要求修改审计主体,回到 5.2 并生成新报告;若提交会改变报告裁定的有效反馈块,只执行确定性 `revise`;若明确接受初始报告且没有有效反馈变化,不调用 `revise`。随后将对应的最终报告附着到 `audits/plan/`、验证可搬移性,并通过 writer 写未占用的 `verify_NNN` receipt。 + - 证据:替代报告覆盖冻结 staged diff 的 16/16 文件;用户正式裁定唯一 finding 为误报,确定性修订后结论为 `complete / pass_candidate`、风险 0、开放问题 0。 +- [x] 5.4 收到用户裁定后继续:若要求修改审计主体,回到 5.2 并生成新报告;若提交会改变报告裁定的有效反馈块,只执行确定性 `revise`;若明确接受初始报告且没有有效反馈变化,不调用 `revise`。随后将对应的最终报告附着到 `audits/plan/`、验证可搬移性,并通过 writer 写未占用的 `verify_NNN` receipt。 - receipt 顶层 `verdict` 保存结论,`provenance.plan_version` 绑定冻结方案;evidence 保存 `source`、package/schema/prompt 版本、run ID、review status、`diff_version`、`report_version` 和 `audit_path`。 - 验收:报告附着和 receipt 写入不改变 `plan_version`;active pointer 与 handoff 保持符合预期;`pass_candidate` 不解释为用户授权。 + - 证据:最终报告已原字节附着到 `audits/plan/`,无工作区绝对路径;writer 已写入 `receipts/verify_001.json`,附着前后 `plan_version` 一致。 ## Wave 6 | 残留复核与归档 -- [ ] 6.1 只读比较冻结快照与当前工作区:确认新增差异仅为最终报告、receipt 和 finalize 所需的机械状态/归档变化;若实现、测试、Skill、blueprint/protocol 或方案目标、范围、方法、任务要求变化,废弃旧审计并回到 5.2。 -- [ ] 6.2 再次确认没有 CrossReview 当前入口、旧方案、默认触发、兼容别名、bridge、新 state/tool/schema、验证器注册表、安装 component framework、自动升级或报告索引,且 `.sopify/history/**` 旧正文未被改写;该步骤只读,不再引入修复。 -- [ ] 6.3 完成所有任务和方案生命周期的 `ready_to_archive` 状态更新,确认语义文件不再变化并计算最终 `plan_version`。 +- [x] 6.1 只读比较冻结快照与当前工作区:确认新增差异仅为最终报告、receipt 和 finalize 所需的机械状态/归档变化;若实现、测试、Skill、blueprint/protocol 或方案目标、范围、方法、任务要求变化,废弃旧审计并回到 5.2。 +- [x] 6.2 再次确认没有 CrossReview 当前入口、旧方案、默认触发、兼容别名、bridge、新 state/tool/schema、验证器注册表、安装 component framework、自动升级或报告索引,且 `.sopify/history/**` 旧正文未被改写;该步骤只读,不再引入修复。 +- [x] 6.3 完成所有任务和方案生命周期的 `ready_to_archive` 状态更新,确认语义文件不再变化并计算最终 `plan_version`。 - Finalize action(writer 机械动作,不是第二个任务):以最终版本作为 `expected_plan_version` 写 `final.json` 和 `receipt.md`,将完整方案迁入未占用的 `history/2026-07/`,成功后才清 active plan/handoff;宿主随后更新可重建的 history index。 - 验收:归档包复算版本等于 `final.json.provenance.plan_version`;final/history receipt 写入、目标冲突或迁移失败均保留源方案和 state。history index 失败需告警和单独重试,但不回滚已完成归档。 ## 验收标准 -- [ ] light、standard、architecture 三种方案都能从 `plan.md` 进入,生成器与 entry-preflight 不再互相矛盾。 -- [ ] `plan_version` 是唯一方案版本名称,并覆盖当前级别的全部语义文件;用户面不再混用 digest、fingerprint、package hash 等术语。 -- [ ] EvidentLoop 对所有消费者提供通用 `diff_version / report_version`,公共契约不包含 Sopify 专用字段。 -- [ ] Sopify 当前执行面不再包含 CrossReview,也不提供兼容层或默认 EvidentLoop 钩子。 -- [ ] 未选择配套安装时 Sopify 核心安装与工作流完全不依赖 EvidentLoop;选择后可一次补齐目标宿主所需公开 CLI 与 Skill。 -- [ ] Codex 完成真实安装、Skill discovery 和审计 E2E dogfood;其他宿主只声明实际取得的安装/结构证据,不被错误标记为 Codex-only 或 E2E 已验证。 -- [ ] 已独立安装的兼容 EvidentLoop 可直接使用;其他验证器可写通用 evidence/receipt,不被 ELoop 字段或目录约束。 -- [ ] 配套安装不保存运行开关、不管理已有安装升级;缺失安装、兼容复用和不兼容停车边界可验证。 -- [ ] 独立审计、`audits/plan/` 主审计和 `audits//` 附加审计边界清楚,只有正式方案证据写 receipt。 -- [ ] 本方案产出一份由公开 EvidentLoop 版本生成、经过显式用户裁定、可搬移且由 receipt 关联三个版本值的主审计报告;审计主体不包含报告自身、receipt 或机械归档差异。 -- [ ] 未新增命令、MCP tool、state、schema、handoff 字段、报告索引、聚合或自动门禁。 -- [ ] 历史事实保持不变;旧 CrossReview deferred 方案的删除和替代由本方案及最终 receipt 可追溯。 +- [x] light、standard、architecture 三种方案都能从 `plan.md` 进入,生成器与 entry-preflight 不再互相矛盾。 +- [x] `plan_version` 是唯一方案版本名称,并覆盖当前级别的全部语义文件;用户面不再混用 digest、fingerprint、package hash 等术语。 +- [x] EvidentLoop 对所有消费者提供通用 `diff_version / report_version`,公共契约不包含 Sopify 专用字段。 +- [x] Sopify 当前执行面不再包含 CrossReview,也不提供兼容层或默认 EvidentLoop 钩子。 +- [x] 未选择配套安装时 Sopify 核心安装与工作流完全不依赖 EvidentLoop;选择后可一次补齐目标宿主所需公开 CLI 与 Skill。 +- [x] Codex 完成真实安装、Skill discovery 和审计 E2E dogfood;其他宿主只声明实际取得的安装/结构证据,不被错误标记为 Codex-only 或 E2E 已验证。 +- [x] 已独立安装的健康 EvidentLoop 可直接使用;兼容性由其产品自身负责;其他验证器可写通用 evidence/receipt,不被 ELoop 字段或目录约束。 +- [x] 配套安装不保存运行开关、不管理已有安装升级;官方当前安装、健康复用、异常停车和失败可重试边界可验证。 +- [x] 独立审计、`audits/plan/` 主审计和 `audits//` 附加审计边界清楚,只有正式方案证据写 receipt。 +- [x] 本方案产出一份由公开 EvidentLoop 版本生成、经过显式用户裁定、可搬移且由 receipt 关联三个版本值的主审计报告;审计主体不包含报告自身、receipt 或机械归档差异。 +- [x] 未新增命令、MCP tool、state、schema、handoff 字段、报告索引、聚合或自动门禁。 +- [x] 历史事实保持不变;旧 CrossReview deferred 方案的删除和替代由本方案及最终 receipt 可追溯。 diff --git a/.sopify/history/index.md b/.sopify/history/index.md index d1be12b7..7b8b797d 100644 --- a/.sopify/history/index.md +++ b/.sopify/history/index.md @@ -4,6 +4,7 @@ ## 索引 +- `2026-07-20` [`20260718_evidentloop_optional_audit_integration`](2026-07/20260718_evidentloop_optional_audit_integration/) - architecture - 产品无关 Verifier 契约、EvidentLoop 可选配套安装与公开版本 Codex dogfood 收口;正式报告经用户裁定并由 receipt 绑定方案、diff 与报告版本 - `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/架构图全量对齐 diff --git a/README.md b/README.md index 8323b7f9..b261e03b 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,13 @@ No new editor, no new CLI. Install into the host you already use — Codex, Clau curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/install.sh | bash -s -- --target codex:en-US ``` -Sopify requires Python 3.11 or newer; the installer checks before downloading. This -installs Sopify only. [EvidentLoop](https://github.com/evidentloop/evidentloop) is an -optional code-change audit tool: it turns a local Git diff into an interactive HTML report -with findings linked to changed lines and user feedback. Sopify can keep that report as -plan audit evidence. To install the versions tested with this Sopify release—or reuse -compatible existing components—append `--with-evidentloop`. Without that flag, the -installer does not inspect or install EvidentLoop. +Sopify requires Python 3.11 or newer; the installer checks before downloading. This command +installs Sopify only. -After install, use `~go` to start a managed workflow. See [Installation](#installation) for other hosts, audit-first install, and Windows. +Optional: append `--with-evidentloop` to the same command to install EvidentLoop; see +[Installation](#installation). + +After install, use `~go` to start a managed workflow. See [Installation](#installation) for other hosts, Windows, and how to review the installer before running it. **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. @@ -97,7 +95,7 @@ A month later, someone asks why the cache key includes the user ID. The answer i Sopify Product Form — host executes, skill guides, assets preserved, any host resumes -The host LLM executes. Sopify preserves auditable development assets — plans, decisions, handoffs, and verification evidence — in `.sopify/`, accessible across sessions, hosts, and teammates. +The host LLM executes. Sopify preserves traceable development assets — plans, decisions, handoffs, and verification evidence — in `.sopify/`, accessible across sessions, hosts, and teammates. How Sopify achieves stability and quality: @@ -116,7 +114,7 @@ For readers who want the internal layering behind the product form, the technica ## Installation -Audit-first install: +Review before running: ```bash curl -fsSL -o sopify-install.sh https://github.com/evidentloop/sopify/releases/latest/download/install.sh @@ -143,20 +141,20 @@ Host support: Pass `--workspace ` to target another repo, `--language ` to control output language. -The Quick Start flag also works with the audit-first script: +[EvidentLoop](https://github.com/evidentloop/evidentloop) is an optional code-change audit +tool. It turns a local Git diff into an interactive report, ties audit findings to changed +lines, and lets users update the report through feedback. To install the current official +CLI and Skill with Sopify, or reuse healthy existing components, pass +`--with-evidentloop` explicitly: ```bash bash sopify-install.sh --target codex:en-US --with-evidentloop ``` -The CLI is installed for the current user. The Skill uses the host's supported path: -Codex `$HOME/.agents/skills/evidentloop/`, Claude `$HOME/.claude/skills/evidentloop/`, -Qoder `$HOME/.qoder/skills/evidentloop/`, or Copilot -`/.github/skills/evidentloop/`. Existing components are compatibility-checked -before reuse; Sopify does not claim they match the pinned source. Incompatible components -are left unchanged. The Copilot path belongs to your project; Sopify does not commit or -update it. See [Getting Started](./docs/getting-started.md#optional-evidentloop-companion) -for prerequisites and the local/cloud boundary. +Healthy existing components are reused without automatic upgrades. The Copilot Skill is +project-scoped; Sopify does not commit or update it. See +[Getting Started](./docs/getting-started.md#optional-evidentloop-companion) for +prerequisites, host-specific paths, and the local/cloud boundary. For the full setup guide, see [Getting Started](./docs/getting-started.md). For a step-by-step demo, see [External Repo Quickstart](./examples/external-repo-quickstart/README.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 5b36f170..75c5d416 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -56,14 +56,12 @@ Sopify 把方案和验证收据保存在 `.sopify/` 中,作为可纳入 git curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/install.sh | bash -s -- --target codex:zh-CN ``` -Sopify 需要 Python 3.11 或更高版本;安装器会在下载前检查。这条命令只安装 -Sopify。[EvidentLoop](https://github.com/evidentloop/evidentloop) 是一个可选的代码变更 -审计工具:它把本地 Git diff 变成可交互、可反馈的 HTML 报告,审计问题可回链到 -具体修改行;Sopify 可将这份报告作为方案审计证据。若要同时安装本次 Sopify 发布 -验证过的版本,或复用已有兼容组件,在命令末尾加 `--with-evidentloop`;不加时, -安装器不会探测或安装它。 +Sopify 需要 Python 3.11 或更高版本;安装器会在下载前检查。这条命令只安装 Sopify。 -安装后用 `~go` 启动全托管工作流。审查优先安装、其他宿主和 Windows 请看[安装说明](#安装说明)。 +可选:在同一命令末尾追加 `--with-evidentloop` 可同时安装 EvidentLoop,详情见 +[安装说明](#安装说明)。 + +安装后用 `~go` 启动全托管工作流。其他宿主、Windows,以及如何先查看脚本再安装,请看[安装说明](#安装说明)。 **已在 Sopify 管理的仓库里?** 明确说“继续”或使用 `~go`,即可恢复当前方案。普通问答和小范围修改会按当前请求直接处理,不会自动进入之前的方案流程。 @@ -96,7 +94,7 @@ Sopify。[EvidentLoop](https://github.com/evidentloop/evidentloop) 是一个可 Sopify 产品形态 — 宿主执行、技能引导、资产留存、任意宿主接续 -宿主 LLM 负责执行。Sopify 把 AI 开发过程中的审计资产——方案、决策、交接、验证证据——持久保留在 `.sopify/` 中,跨 session、宿主和团队成员均可访问。 +宿主 LLM 负责执行。Sopify 把 AI 开发过程中可追溯的资产——方案、决策、交接、验证证据——持久保留在 `.sopify/` 中,跨 session、宿主和团队成员均可访问。 Sopify 靠四件事做到稳定可控、质量可靠: @@ -115,7 +113,7 @@ Sopify 靠四件事做到稳定可控、质量可靠: ## 安装说明 -审查优先安装: +先查看脚本再安装: ```bash curl -fsSL -o sopify-install.sh https://github.com/evidentloop/sopify/releases/latest/download/install.sh @@ -142,18 +140,17 @@ Get-Content sopify-install.ps1 | more 可用 `--workspace ` 指定目标仓库,`--language ` 控制输出语言。 -快速开始里的可选参数也适用于审查后执行: +[EvidentLoop](https://github.com/evidentloop/evidentloop) 是一个可选的代码变更审计工具, +可基于本地 Git diff 生成交互式报告,将审计发现定位到具体修改行,并支持用户通过反馈 +更新报告。如需随 Sopify 安装当前官方 CLI 与 Skill,或复用已有健康组件,请显式传入 +`--with-evidentloop`: ```bash bash sopify-install.sh --target codex:zh-CN --with-evidentloop ``` -CLI 安装给当前用户;Skill 使用宿主支持的目录:Codex 为 -`$HOME/.agents/skills/evidentloop/`,Claude 为 `$HOME/.claude/skills/evidentloop/`, -Qoder 为 `$HOME/.qoder/skills/evidentloop/`,Copilot 为当前项目的 -`/.github/skills/evidentloop/`。已有组件通过兼容检查后复用;Sopify 不声明 -它们与固定来源完全相同。不兼容组件保持原样。Copilot 目录属于你的项目,Sopify -不会自动提交或更新。前置条件和本地/云端边界见 +已有健康组件会直接复用,不自动升级。Copilot Skill 仅写入当前项目,Sopify 不会自动 +提交或更新。前置条件、各宿主安装路径及本地/云端边界见 [Getting Started](./docs/getting-started.md#optional-evidentloop-companion)。 完整设置指南见 [Getting Started](./docs/getting-started.md)。分步 demo 见 [External Repo Quickstart](./examples/external-repo-quickstart/README.md)。 diff --git a/docs/getting-started.md b/docs/getting-started.md index 485e08b6..c11efb07 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -56,8 +56,8 @@ python3 /tmp/sopify/scripts/sopify_init.py init --workspace . Sopify works without EvidentLoop. [EvidentLoop](https://github.com/evidentloop/evidentloop) turns a local Git diff into an interactive, feedback-ready HTML audit report. To install -the versions tested with this Sopify release, or reuse compatible existing components, -opt in explicitly: +the current EvidentLoop CLI and Skill from their official sources, or reuse healthy +existing components, opt in explicitly: ```bash curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/install.sh | bash -s -- --target codex:en-US --with-evidentloop @@ -82,13 +82,17 @@ These locations follow the host documentation for [Qoder](https://docs.qoder.com/en/cli/Skills), and [GitHub Copilot](https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/add-skills). -For new installs, Sopify uses EvidentLoop `0.1.0a2`, Skills CLI `1.5.9`, and the tested -Skill source commit. Existing components are reused only after compatibility checks; -Sopify does not claim they are identical to that source. `uv` is required only when the -CLI is missing; Git and `npx` only when the Skill is missing. Incomplete or incompatible -components are reported and left unchanged. Sopify is installed first and remains usable -if the optional EvidentLoop install does not finish. You can install EvidentLoop separately -later from its official repository, or rerun the same command. +For new installs, Sopify follows EvidentLoop's current official commands: `uv tool install +evidentloop` for the CLI and `npx skills@latest add evidentloop/evidentloop --skill +evidentloop` for the Skill. Existing components are reused when the CLI doctor is healthy +and the Skill front matter declares `name: evidentloop`; Sopify does not upgrade them or +maintain an EvidentLoop compatibility matrix. `uv` is required only when the CLI is +missing; Git and `npx` only when the Skill is missing. + +Sopify is installed first and remains usable if the optional setup does not finish. Skill +downloads happen in a temporary directory and are copied to the final host path only after +validation, so a failed attempt does not leave a partial Skill there. Rerun the same +command, or install EvidentLoop independently from its official repository. After a fresh CLI install, the installer also checks that a future host can resolve `evidentloop` from `PATH`. If the uv tool directory is not visible yet, it leaves the @@ -101,7 +105,7 @@ The installer writes only `.github/skills/evidentloop/`; it does not add a proje location and becomes part of your project: review it, and commit it yourself if a cloud workflow needs it. Sopify does not commit or update it. A cloud-hosted agent also does not automatically receive the EvidentLoop CLI installed on your local machine. This option -proves local component placement and compatibility only; Skill discovery and audit E2E +proves local component placement and health only; Skill discovery and audit E2E still require host evidence, and cloud CLI availability must be provisioned separately. ## Host-Specific Setup diff --git a/docs/how-sopify-works.en.md b/docs/how-sopify-works.en.md index e007ad25..b308443e 100644 --- a/docs/how-sopify-works.en.md +++ b/docs/how-sopify-works.en.md @@ -60,7 +60,7 @@ When the host detects `.sopify/` and the user request targets managed plan / con ### Optional Verifiers -Sopify does not install or run a verifier by default. Sopify alone, another verifier, and an independently installed EvidentLoop are all valid paths. `--with-evidentloop` is an explicit install convenience, not a runtime switch: new components use versions and sources tested with the current Sopify release, while existing components are reused only after compatibility checks. Sopify does not claim a reused Skill is identical to the tested source, and it does not upgrade, remove, or track components. The Copilot Skill is project-owned content in `.github/skills/evidentloop/`; users review and commit it when needed, and Sopify does not commit or update it. Installation proves local placement and compatibility only; Skill discovery and audit E2E still require host evidence, and cloud agents do not inherit the locally installed CLI. +Sopify does not install or run a verifier by default. Sopify alone, another verifier, and an independently installed EvidentLoop are all valid paths. `--with-evidentloop` is an explicit install convenience, not a runtime switch: new components use EvidentLoop's current official sources, while healthy existing components are reused without automatic upgrades. EvidentLoop owns compatibility; Sopify does not maintain a version matrix, remove components, or track installation state. The Copilot Skill is project-owned content in `.github/skills/evidentloop/`; users review and commit it when needed, and Sopify does not commit or update it. Installation proves local placement and health only; Skill discovery and audit E2E still require host evidence, and cloud agents do not inherit the locally installed CLI. Independent audits do not need a plan binding. A formal plan-wide audit may live in `audits/plan/`, while focused audits may use `audits//`; the host writes a receipt only when the user adopts the result as formal evidence. `audits/` is not part of the default 4-step read chain. diff --git a/docs/how-sopify-works.md b/docs/how-sopify-works.md index 0daf1c8e..20d1435e 100644 --- a/docs/how-sopify-works.md +++ b/docs/how-sopify-works.md @@ -60,7 +60,7 @@ Sopify 把 AI 开发过程中的**方案、决策、交接、执行/验证证据 ### 可选验证器 -Sopify 不默认安装或运行验证器。只用 Sopify、使用其他 Verifier、使用已独立安装的 EvidentLoop 都是完整路径。`--with-evidentloop` 只是显式安装便利,不是运行开关:新装组件使用本次 Sopify 发布验证过的版本和来源,已有组件只在兼容检查通过后复用。Sopify 不声明复用的 Skill 与固定来源完全相同,也不升级、卸载或记录组件状态。Copilot Skill 是项目中的 `.github/skills/evidentloop/` 内容;用户按需审查和提交,Sopify 不会自动提交或更新。安装成功只证明本地放置和兼容性;Skill discovery 与审计 E2E 仍需宿主证据,云端也不会自动获得本机安装的 CLI。 +Sopify 不默认安装或运行验证器。只用 Sopify、使用其他 Verifier、使用已独立安装的 EvidentLoop 都是完整路径。`--with-evidentloop` 只是显式安装便利,不是运行开关:新装组件使用 EvidentLoop 当前官方来源,已有组件通过健康检查后复用且不自动升级。兼容性由 EvidentLoop 自身负责,Sopify 不维护版本矩阵,也不卸载或记录组件状态。Copilot Skill 是项目中的 `.github/skills/evidentloop/` 内容;用户按需审查和提交,Sopify 不会自动提交或更新。安装成功只证明本地放置和健康检查;Skill discovery 与审计 E2E 仍需宿主证据,云端也不会自动获得本机安装的 CLI。 独立审计不必绑定方案。正式方案主审计可放在 `audits/plan/`,针对性审计可放在 `audits//`;只有用户采用为正式证据时,宿主才通过 writer 写 receipt。`audits/` 不进入 4 步默认读取链。 diff --git a/install.ps1 b/install.ps1 index c9ab08f8..d1fa67a6 100644 --- a/install.ps1 +++ b/install.ps1 @@ -26,9 +26,9 @@ Options: --language Copilot only: bootstrap output language (en-US/zh-CN). --no-copilot Copilot only: skip Copilot instruction file distribution. - --with-evidentloop Install the EvidentLoop CLI and Skill versions tested - with this Sopify release, or reuse compatible existing - components. Disabled by default. + --with-evidentloop Install the current EvidentLoop CLI and Skill from + official sources, or reuse healthy existing components. + Disabled by default. --verbose Show full diagnostic install details. --ref Advanced: override the source ref. -h, --help Show this help. diff --git a/install.sh b/install.sh index 03d8576f..80cf33b2 100755 --- a/install.sh +++ b/install.sh @@ -26,9 +26,9 @@ Options: --language Copilot only: bootstrap output language (en-US/zh-CN). --no-copilot Copilot only: skip Copilot instruction file distribution and only write workspace markers. - --with-evidentloop Install the EvidentLoop CLI and Skill versions tested - with this Sopify release, or reuse compatible existing - components. Disabled by default. + --with-evidentloop Install the current EvidentLoop CLI and Skill from + official sources, or reuse healthy existing components. + Disabled by default. --verbose Show full diagnostic install details. --ref Advanced: override the source ref. -h, --help Show this help. diff --git a/installer/distribution.py b/installer/distribution.py index 7520d626..b6da1c0f 100644 --- a/installer/distribution.py +++ b/installer/distribution.py @@ -304,7 +304,7 @@ def render_distribution_user_result(report: DistributionInstallReport) -> str: def render_distribution_error(exc: DistributionError) -> str: """Render a stable error surface for shell, PowerShell, and repo-local installs.""" title = ( - "Sopify installed; EvidentLoop was not installed:" + "Sopify installed; EvidentLoop setup did not complete:" if exc.reason_code == "EVIDENTLOOP_COMPANION_INCOMPLETE" else "Sopify install failed:" ) @@ -326,17 +326,17 @@ def render_distribution_user_error(exc: DistributionError, *, language: str = "e return "\n".join( [ "Sopify 已安装,可以正常使用。", - "EvidentLoop 未安装完成。", + "EvidentLoop 安装未完成。", "", "下一步:", - " 稍后单独安装 EvidentLoop,或重新运行同一命令:", + " 可重新运行同一命令,或单独安装 EvidentLoop:", " https://github.com/evidentloop/evidentloop", ] ) return "\n".join( [ "Sopify is installed and ready to use.", - "EvidentLoop was not installed.", + "EvidentLoop setup did not complete.", "", "Next:", f" {exc.next_step}", @@ -464,37 +464,14 @@ def _map_install_error(exc: InstallError) -> DistributionError: detail=detail, next_step="Omit `--workspace` and trigger Sopify inside that project instead. On first activation, choose whether to enable the current directory or the repository root.", ) - if detail.startswith("EvidentLoop companion install is not supported"): - return DistributionError( - phase="input", - reason_code="EVIDENTLOOP_HOST_UNSUPPORTED", - detail=detail, - next_step="Install Sopify without `--with-evidentloop`, or choose a host with a declared EvidentLoop Skill path.", - ) - if detail.startswith("EvidentLoop companion preflight failed"): - return DistributionError( - phase="input", - reason_code="EVIDENTLOOP_PREREQUISITE_MISSING", - detail=detail, - next_step="Install the missing command named above, then rerun the same installer command.", - ) - if detail.startswith("Existing EvidentLoop") or detail.startswith( - "EvidentLoop Skill directory is incomplete" - ): - return DistributionError( - phase="input", - reason_code="EVIDENTLOOP_INCOMPATIBLE", - detail=detail, - next_step="Keep the existing installation unchanged and resolve its version or directory contents using https://github.com/evidentloop/evidentloop#quick-start.", - ) if detail.startswith( - "Sopify core installation completed, but EvidentLoop was not installed" + "Sopify core installation completed, but EvidentLoop setup did not complete" ): return DistributionError( phase="install", reason_code="EVIDENTLOOP_COMPANION_INCOMPLETE", detail=detail, - next_step="Install EvidentLoop separately later, or rerun the same command: https://github.com/evidentloop/evidentloop", + next_step="Rerun the same command, or install EvidentLoop separately: https://github.com/evidentloop/evidentloop", ) if detail.startswith("Missing source"): return DistributionError( @@ -868,13 +845,9 @@ def _is_copilot_project_skill(skill_path: Path) -> bool: def _companion_action_label(action: str, language: str) -> str: if action == "installed": - return ( - "已安装(本次 Sopify 发布验证版本)" - if language == "zh-CN" - else "installed (tested with this Sopify release)" - ) + return "已安装" if language == "zh-CN" else "installed" if action == "reused": - return "已复用(已通过兼容检查)" if language == "zh-CN" else "reused (compatibility checked)" + return "已复用(健康检查通过)" if language == "zh-CN" else "reused (health check passed)" return _action_label(action, language) diff --git a/installer/evidentloop.py b/installer/evidentloop.py index bc972cab..15092ca3 100644 --- a/installer/evidentloop.py +++ b/installer/evidentloop.py @@ -14,31 +14,13 @@ from installer.hosts.base import HostAdapter from installer.models import EvidentLoopInstallResult, InstallError -EVIDENTLOOP_PACKAGE_VERSION = "0.1.0a2" -EVIDENTLOOP_SCHEMA_VERSION = "0.4" -EVIDENTLOOP_PROMPT_VERSION = "v0.5" -EVIDENTLOOP_SKILL_TAG = "v0.1.0a2" -EVIDENTLOOP_SKILL_COMMIT = "fcefb77083d32b034e56b04dcd085dcf5a835550" -EVIDENTLOOP_SKILL_REPOSITORY = "https://github.com/evidentloop/evidentloop.git" -SKILLS_CLI_VERSION = "1.5.9" +EVIDENTLOOP_PACKAGE = "evidentloop" +EVIDENTLOOP_SKILL_SOURCE = "evidentloop/evidentloop" +SKILLS_CLI_PACKAGE = "skills@latest" _SKILL_NAME = "evidentloop" _SKILL_ENTRYPOINT = "SKILL.md" -_SKILL_REQUIRED_REFERENCE = Path("references") / "codex-cli-isolation.md" _SKILLS_CLI_PROJECT_DIR = Path(".agents") / "skills" -_REQUIRED_SUBCOMMANDS = ("prepare", "finalize", "render", "revise") -_OFFICIAL_INSTALL_GUIDE = "https://github.com/evidentloop/evidentloop#quick-start" - -_COMPATIBILITY_CODE = ( - "import json; import evidentloop; " - "from evidentloop.api import finalize_review, prepare_local_diff, " - "recover_interrupted_revision, render_audit_file, revise_audit; " - "from evidentloop.review.core.prompt import PRODUCT_REVIEWER_PROMPT_VERSION; " - "from evidentloop.validation import SCHEMA_VERSION; " - "print(json.dumps({'package_version': evidentloop.__version__, " - "'schema_version': SCHEMA_VERSION, " - "'prompt_version': PRODUCT_REVIEWER_PROMPT_VERSION}))" -) @dataclass(frozen=True) @@ -48,9 +30,9 @@ class EvidentLoopInstallPlan: skill_dir: Path uv_path: str | None npx_path: str | None - git_path: str | None install_cli: bool install_skill: bool + package_version: str | None def prepare_evidentloop_install( @@ -76,20 +58,20 @@ def prepare_evidentloop_install( _validate_skill(skill_dir) cli_executable: Path | None = None - discovered_cli = shutil.which("evidentloop") + package_version: str | None = None + discovered_cli = shutil.which(EVIDENTLOOP_PACKAGE) if discovered_cli is not None: cli_executable = Path(discovered_cli).expanduser().resolve() - _validate_cli(cli_executable, home_root=home_root) + package_version = _validate_cli(cli_executable, home_root=home_root) uv_path = shutil.which("uv") if cli_executable is None else None npx_path = shutil.which("npx") if not skill_path_occupied else None - git_path = shutil.which("git") if not skill_path_occupied else None missing: list[str] = [] if cli_executable is None and uv_path is None: missing.append("uv") if not skill_path_occupied and npx_path is None: missing.append("npx") - if not skill_path_occupied and git_path is None: + if not skill_path_occupied and shutil.which("git") is None: missing.append("git") if missing: raise InstallError( @@ -103,65 +85,43 @@ def prepare_evidentloop_install( skill_dir=skill_dir, uv_path=uv_path, npx_path=npx_path, - git_path=git_path, install_cli=cli_executable is None, install_skill=not skill_path_occupied, + package_version=package_version, ) def install_evidentloop_companion( plan: EvidentLoopInstallPlan, ) -> EvidentLoopInstallResult: - """Install missing components and re-run the compatibility probes.""" + """Install missing components using EvidentLoop's current official sources.""" cli_action = "reused" skill_action = "reused" - completed: list[str] = [] + package_version = plan.package_version - try: - if plan.install_cli: - if plan.uv_path is None: # Defensive: prepare() owns prerequisite checks. - raise InstallError("EvidentLoop CLI install requires uv") - _run_command( - [ - plan.uv_path, - "tool", - "install", - f"evidentloop=={EVIDENTLOOP_PACKAGE_VERSION}", - ], - env=_base_child_env(plan.home_root), - label="EvidentLoop CLI install", - ) - completed.append("CLI installed") - cli_executable = _uv_tool_executable(plan) - _validate_cli(cli_executable, home_root=plan.home_root) - _validate_cli_on_path(cli_executable) - cli_action = "installed" - else: - completed.append("CLI reused") - - if plan.install_skill: - try: - _install_skill(plan) - except InstallError: - if _path_is_occupied(plan.skill_dir): - completed.append("Skill path created but not validated") - raise - skill_action = "installed" - completed.append("Skill installed") - else: - completed.append("Skill reused") - except InstallError as exc: - progress = ", ".join(completed) or "no companion changes completed" - raise InstallError( - "EvidentLoop companion installation is incomplete " - f"({progress}); no external files were rolled back. " - f"Resolve the reported failure, then rerun the same command. {exc}" - ) from exc + if plan.install_cli: + if plan.uv_path is None: # Defensive: prepare() owns prerequisite checks. + raise InstallError("EvidentLoop CLI install requires uv") + _run_command( + [plan.uv_path, "tool", "install", EVIDENTLOOP_PACKAGE], + env=_base_child_env(plan.home_root), + label="EvidentLoop CLI install", + ) + cli_executable = _uv_tool_executable(plan) + package_version = _validate_cli(cli_executable, home_root=plan.home_root) + _validate_cli_on_path(cli_executable) + cli_action = "installed" + + if plan.install_skill: + _install_skill(plan) + skill_action = "installed" + if package_version is None: # Defensive: installed or reused CLI must be validated. + raise InstallError("EvidentLoop CLI version could not be determined") return EvidentLoopInstallResult( cli_action=cli_action, skill_action=skill_action, - package_version=EVIDENTLOOP_PACKAGE_VERSION, + package_version=package_version, skill_path=plan.skill_dir / _SKILL_ENTRYPOINT, ) @@ -169,89 +129,48 @@ def install_evidentloop_companion( def _install_skill(plan: EvidentLoopInstallPlan) -> None: if plan.npx_path is None: # Defensive: prepare() owns prerequisite checks. raise InstallError("EvidentLoop Skill install requires npx") - if plan.git_path is None: - raise InstallError("EvidentLoop Skill install requires git") if _path_is_occupied(plan.skill_dir): raise InstallError( f"EvidentLoop Skill path changed after preflight: {plan.skill_dir}" ) - source_env = _base_child_env(plan.home_root) - skills_cli_env = dict(source_env) - skills_cli_env["DISABLE_TELEMETRY"] = "1" - skills_cli_env["DO_NOT_TRACK"] = "1" with tempfile.TemporaryDirectory(prefix="sopify-evidentloop-") as temp_dir: staging_root = Path(temp_dir) - source_root = staging_root / "source" - _run_command( - [ - plan.git_path, - "clone", - "--quiet", - "--depth", - "1", - "--branch", - EVIDENTLOOP_SKILL_TAG, - EVIDENTLOOP_SKILL_REPOSITORY, - str(source_root), - ], - env=source_env, - label="EvidentLoop Skill source download", - ) - resolved_commit = _run_command( - [plan.git_path, "-C", str(source_root), "rev-parse", "HEAD"], - env=source_env, - label="EvidentLoop Skill source verification", - ).stdout.strip() - if resolved_commit != EVIDENTLOOP_SKILL_COMMIT: - raise InstallError( - "EvidentLoop Skill source commit changed: " - f"expected {EVIDENTLOOP_SKILL_COMMIT}, got {resolved_commit or '(empty)'}" - ) - - source_skill = source_root / "skills" / _SKILL_NAME - _validate_skill(source_skill) + staging_home = staging_root / "home" + staging_home.mkdir() + env = _base_child_env(staging_home) + env["DISABLE_TELEMETRY"] = "1" + env["DO_NOT_TRACK"] = "1" argv = [ plan.npx_path, "--yes", - f"skills@{SKILLS_CLI_VERSION}", + SKILLS_CLI_PACKAGE, "add", - str(source_skill), + EVIDENTLOOP_SKILL_SOURCE, + "--skill", + _SKILL_NAME, ] if plan.adapter.is_workspace_scope: - # Skills CLI targets Copilot through .agents/skills and a lockfile. - # Keep those staging files out of the project and copy only the Skill. - _run_command( - [ - *argv, - "-a", - str(plan.adapter.skills_cli_agent), - "-y", - "--copy", - ], - env=skills_cli_env, - label="EvidentLoop Skill download", - cwd=staging_root, - ) + argv.extend(["-a", str(plan.adapter.skills_cli_agent), "-y", "--copy"]) staged_skill = staging_root / _SKILLS_CLI_PROJECT_DIR / _SKILL_NAME - _validate_skill(staged_skill) - _copy_skill_directory(staged_skill, plan.skill_dir) else: - _run_command( - [ - *argv, - "-g", - "-a", - str(plan.adapter.skills_cli_agent), - "-y", - "--copy", - ], - env=skills_cli_env, - label="EvidentLoop Skill install", - cwd=staging_root, + argv.extend( + ["-g", "-a", str(plan.adapter.skills_cli_agent), "-y", "--copy"] + ) + staged_skill = ( + staging_home / str(plan.adapter.skill_install_dirname) / _SKILL_NAME ) + _run_command( + argv, + env=env, + label="EvidentLoop Skill install", + cwd=staging_root, + ) + _validate_skill(staged_skill) + _copy_skill_directory(staged_skill, plan.skill_dir) + _validate_skill(plan.skill_dir) @@ -275,62 +194,21 @@ def _copy_skill_directory(source: Path, destination: Path) -> None: ) from exc -def _validate_cli(executable: Path, *, home_root: Path) -> None: +def _validate_cli(executable: Path, *, home_root: Path) -> str: if not executable.is_absolute(): raise InstallError("Existing EvidentLoop CLI path is not absolute") - env = _isolated_python_env(home_root) doctor = _run_command( [str(executable), "doctor", "--json"], - env=env, - label="Existing EvidentLoop CLI doctor", - ) - payload = _json_object(doctor.stdout, label="Existing EvidentLoop CLI doctor") - if payload.get("status") != "ok": - raise InstallError( - "Existing EvidentLoop CLI is incompatible: doctor did not report status=ok. " - f"Manage the existing install via {_OFFICIAL_INSTALL_GUIDE}" - ) - python_value = payload.get("python_executable") - python_executable = Path(str(python_value or "")) - if not python_executable.is_absolute(): - raise InstallError( - "Existing EvidentLoop CLI doctor did not return an absolute python_executable" - ) - - probe = _run_command( - [str(python_executable), "-I", "-c", _COMPATIBILITY_CODE], - env=env, - label="Existing EvidentLoop CLI compatibility probe", - ) - versions = _json_object( - probe.stdout, - label="Existing EvidentLoop CLI compatibility probe", - ) - expected = { - "package_version": EVIDENTLOOP_PACKAGE_VERSION, - "schema_version": EVIDENTLOOP_SCHEMA_VERSION, - "prompt_version": EVIDENTLOOP_PROMPT_VERSION, - } - if versions != expected: - observed = ", ".join(f"{key}={versions.get(key)!r}" for key in expected) - required = ", ".join(f"{key}={value!r}" for key, value in expected.items()) - raise InstallError( - f"Existing EvidentLoop CLI is incompatible ({observed}); required {required}. " - "Sopify will not replace it automatically. Manage the existing install via " - f"{_OFFICIAL_INSTALL_GUIDE}" - ) - - help_result = _run_command( - [str(python_executable), "-I", "-m", "evidentloop", "--help"], - env=env, - label="Existing EvidentLoop CLI module probe", + env=_isolated_python_env(home_root), + label="EvidentLoop CLI doctor", ) - missing = [name for name in _REQUIRED_SUBCOMMANDS if name not in help_result.stdout] - if missing: - raise InstallError( - "Existing EvidentLoop CLI is incompatible: missing subcommand(s) " - + ", ".join(missing) - ) + payload = _json_object(doctor.stdout, label="EvidentLoop CLI doctor") + if payload.get("status") not in {"ok", "warning"}: + raise InstallError("EvidentLoop CLI doctor reported an unhealthy installation") + package_version = payload.get("version") + if not isinstance(package_version, str) or not package_version.strip(): + raise InstallError("EvidentLoop CLI doctor did not return a version") + return package_version.strip() def _validate_skill(skill_dir: Path) -> None: @@ -339,28 +217,33 @@ def _validate_skill(skill_dir: Path) -> None: raise InstallError( f"EvidentLoop Skill directory is incomplete: expected {entrypoint}" ) - required_reference = skill_dir / _SKILL_REQUIRED_REFERENCE - if not required_reference.is_file(): - raise InstallError( - "EvidentLoop Skill directory is incomplete: missing referenced file " - f"{required_reference}" - ) try: content = entrypoint.read_text(encoding="utf-8") except (OSError, UnicodeError) as exc: raise InstallError(f"EvidentLoop Skill is unreadable: {entrypoint}") from exc - required_markers = ( - "name: evidentloop", - f"`package_version` equal to `{EVIDENTLOOP_PACKAGE_VERSION}`", - f"`schema_version` equal to `{EVIDENTLOOP_SCHEMA_VERSION}`", - f"`prompt_version` equal to `{EVIDENTLOOP_PROMPT_VERSION}`", - "references/codex-cli-isolation.md", - ) - if any(marker not in content for marker in required_markers): + if not content.strip(): + raise InstallError(f"EvidentLoop Skill entrypoint is empty: {entrypoint}") + + lines = content.splitlines() + names: list[str] = [] + frontmatter_closed = False + if lines and lines[0].strip() == "---": + for line in lines[1:]: + if line.strip() == "---": + frontmatter_closed = True + break + if line[:1].isspace(): + continue + key, separator, value = line.partition(":") + if separator and key.strip() == "name": + name = value.strip() + if len(name) >= 2 and name[0] == name[-1] and name[0] in "'\"": + name = name[1:-1] + names.append(name) + if not frontmatter_closed or names != [_SKILL_NAME]: raise InstallError( - f"Existing EvidentLoop Skill is incompatible at {entrypoint}; " - "Sopify will not replace it automatically. Manage the existing install via " - f"{_OFFICIAL_INSTALL_GUIDE}" + "EvidentLoop Skill identity is invalid: expected front matter " + f"`name: {_SKILL_NAME}` in {entrypoint}" ) @@ -387,7 +270,7 @@ def _uv_tool_executable(plan: EvidentLoopInstallPlan) -> Path: def _validate_cli_on_path(executable: Path) -> None: - discovered = shutil.which("evidentloop") + discovered = shutil.which(EVIDENTLOOP_PACKAGE) if discovered is None: raise InstallError( "EvidentLoop CLI was installed and validated at " diff --git a/scripts/install_sopify.py b/scripts/install_sopify.py index 5df0ba6f..8abc6d1a 100755 --- a/scripts/install_sopify.py +++ b/scripts/install_sopify.py @@ -87,8 +87,8 @@ def build_parser() -> argparse.ArgumentParser: "--with-evidentloop", action="store_true", help=( - "Install the EvidentLoop CLI and Skill versions tested with this Sopify release, " - "or reuse compatible existing components. This option is disabled by default " + "Install the current EvidentLoop CLI and Skill from their official sources, " + "or reuse healthy existing components. This option is disabled by default " "and is not a runtime switch." ), ) @@ -217,7 +217,7 @@ def _install_evidentloop_if_requested( return install_evidentloop_companion(plan) except InstallError as exc: raise InstallError( - "Sopify core installation completed, but EvidentLoop was not installed." + "Sopify core installation completed, but EvidentLoop setup did not complete." ) from exc diff --git a/tests/test_evidentloop_installer.py b/tests/test_evidentloop_installer.py index 40b9c9b3..42838ab0 100644 --- a/tests/test_evidentloop_installer.py +++ b/tests/test_evidentloop_installer.py @@ -14,16 +14,16 @@ DistributionError, DistributionRequest, DistributionSourceMetadata, + render_distribution_error, render_distribution_result, + render_distribution_user_error, render_distribution_user_result, run_distribution_install, ) from installer.evidentloop import ( - EVIDENTLOOP_PACKAGE_VERSION, - EVIDENTLOOP_SKILL_COMMIT, - EVIDENTLOOP_SKILL_REPOSITORY, - EVIDENTLOOP_SKILL_TAG, - SKILLS_CLI_VERSION, + EVIDENTLOOP_PACKAGE, + EVIDENTLOOP_SKILL_SOURCE, + SKILLS_CLI_PACKAGE, install_evidentloop_companion, prepare_evidentloop_install, ) @@ -37,12 +37,12 @@ REPO_ROOT = Path(__file__).resolve().parents[1] -COMPATIBLE_SKILL = """\ +HEALTHY_VERSION = "0.2.0" +COMPLETE_SKILL = """\ --- name: evidentloop --- -Require `package_version` equal to `0.1.0a2`, `schema_version` equal to `0.4`, and `prompt_version` equal to `v0.5`. -Read references/codex-cli-isolation.md before running the CLI. +Use EvidentLoop to audit a local Git diff. """ @@ -55,28 +55,16 @@ def _completed( return subprocess.CompletedProcess(argv, returncode, stdout=stdout, stderr="") -def _write_compatible_skill(skill_dir: Path) -> None: - (skill_dir / "references").mkdir(parents=True) - (skill_dir / "SKILL.md").write_text(COMPATIBLE_SKILL, encoding="utf-8") - (skill_dir / "references" / "codex-cli-isolation.md").write_text( - "# CLI isolation\n", - encoding="utf-8", - ) +def _write_complete_skill(skill_dir: Path) -> None: + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text(COMPLETE_SKILL, encoding="utf-8") class EvidentLoopInstallerTests(unittest.TestCase): - def test_versions_source_and_host_native_paths_are_fixed(self) -> None: - self.assertEqual(EVIDENTLOOP_PACKAGE_VERSION, "0.1.0a2") - self.assertEqual( - EVIDENTLOOP_SKILL_COMMIT, - "fcefb77083d32b034e56b04dcd085dcf5a835550", - ) - self.assertEqual(EVIDENTLOOP_SKILL_TAG, "v0.1.0a2") - self.assertEqual( - EVIDENTLOOP_SKILL_REPOSITORY, - "https://github.com/evidentloop/evidentloop.git", - ) - self.assertEqual(SKILLS_CLI_VERSION, "1.5.9") + def test_official_sources_and_host_native_paths(self) -> None: + self.assertEqual(EVIDENTLOOP_PACKAGE, "evidentloop") + self.assertEqual(EVIDENTLOOP_SKILL_SOURCE, "evidentloop/evidentloop") + self.assertEqual(SKILLS_CLI_PACKAGE, "skills@latest") home = Path("/home/test") workspace = Path("/workspace/project") @@ -101,16 +89,11 @@ def test_versions_source_and_host_native_paths_are_fixed(self) -> None: path, ) - with self.assertRaisesRegex(InstallError, "requires a workspace"): - COPILOT_ADAPTER.skill_install_path( - home_root=home, - workspace_root=None, - skill_name="evidentloop", - ) - def test_flag_is_disabled_by_default_and_does_no_companion_work(self) -> None: parser = build_parser() - self.assertFalse(parser.parse_args(["--target", "codex:en-US"]).with_evidentloop) + self.assertFalse( + parser.parse_args(["--target", "codex:en-US"]).with_evidentloop + ) self.assertTrue( parser.parse_args( ["--target", "codex:en-US", "--with-evidentloop"] @@ -120,7 +103,9 @@ def test_flag_is_disabled_by_default_and_does_no_companion_work(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: with ( patch("scripts.install_sopify.prepare_evidentloop_install") as prepare, - patch("scripts.install_sopify.install_evidentloop_companion") as install, + patch( + "scripts.install_sopify.install_evidentloop_companion" + ) as install, ): result = run_install( target_value="codex:en-US", @@ -144,28 +129,23 @@ def test_missing_mapping_stops_before_command_lookup(self) -> None: prepare_evidentloop_install(adapter, home_root=Path("/home/test")) which.assert_not_called() - def test_compatible_components_are_reused_without_uv_or_npx(self) -> None: + def test_healthy_existing_components_are_reused_without_installers(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: home = Path(temp_dir) - skill_dir = CODEX_ADAPTER.skill_install_path( - home_root=home, - workspace_root=None, - skill_name="evidentloop", - ) - _write_compatible_skill(skill_dir) - - def fake_which(name: str) -> str | None: - return { - "evidentloop": "/tools/evidentloop", - "git": "/tools/git", - }.get(name) + skill_dir = home / ".agents/skills/evidentloop" + _write_complete_skill(skill_dir) with ( - patch("installer.evidentloop.shutil.which", side_effect=fake_which), patch( - "installer.evidentloop.subprocess.run", - side_effect=self._compatible_runner, + "installer.evidentloop.shutil.which", + side_effect=lambda name: ( + "/tools/evidentloop" if name == "evidentloop" else None + ), ), + patch( + "installer.evidentloop.subprocess.run", + side_effect=self._healthy_runner, + ) as run, ): plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) result = install_evidentloop_companion(plan) @@ -173,23 +153,22 @@ def fake_which(name: str) -> str | None: self.assertFalse(plan.install_cli) self.assertFalse(plan.install_skill) self.assertEqual((result.cli_action, result.skill_action), ("reused", "reused")) - self.assertEqual(result.skill_path, skill_dir / "SKILL.md") + self.assertEqual(result.package_version, HEALTHY_VERSION) + self.assertEqual(len(run.call_args_list), 1) + self.assertEqual(run.call_args.args[0][1:], ["doctor", "--json"]) def test_prerequisites_are_required_only_for_missing_components(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: home = Path(temp_dir) - skill_dir = CODEX_ADAPTER.skill_install_path( - home_root=home, - workspace_root=None, - skill_name="evidentloop", - ) - _write_compatible_skill(skill_dir) + _write_complete_skill(home / ".agents/skills/evidentloop") with patch("installer.evidentloop.shutil.which", return_value=None): - with self.assertRaisesRegex(InstallError, r"missing required command\(s\): uv$"): + with self.assertRaisesRegex( + InstallError, + r"missing required command\(s\): uv$", + ): prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) with tempfile.TemporaryDirectory() as temp_dir: - home = Path(temp_dir) def fake_which(name: str) -> str | None: return { @@ -201,101 +180,76 @@ def fake_which(name: str) -> str | None: patch("installer.evidentloop.shutil.which", side_effect=fake_which), patch( "installer.evidentloop.subprocess.run", - side_effect=self._compatible_runner, + side_effect=self._healthy_runner, ), ): - with self.assertRaisesRegex(InstallError, r"missing required command\(s\): npx$"): - prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) - - def test_missing_prerequisite_does_not_undo_sopify_install(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - home = Path(temp_dir) - with patch("installer.evidentloop.shutil.which", return_value=None): with self.assertRaisesRegex( InstallError, - "Sopify core installation completed.*EvidentLoop was not installed", + r"missing required command\(s\): npx$", ): - run_install( - target_value="codex:en-US", - workspace_value=None, - repo_root=REPO_ROOT, - home_root=home, - with_evidentloop=True, + prepare_evidentloop_install( + CODEX_ADAPTER, + home_root=Path(temp_dir), ) - self.assertTrue((home / ".codex/AGENTS.md").is_file()) - def test_existing_skill_directory_is_validated_without_replacement(self) -> None: + def test_existing_skill_rejects_empty_or_wrong_identity(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: home = Path(temp_dir) - skill_dir = CODEX_ADAPTER.skill_install_path( - home_root=home, - workspace_root=None, - skill_name="evidentloop", - ) - skill_dir.mkdir(parents=True) + skill_dir = home / ".agents/skills/evidentloop" + _write_complete_skill(skill_dir) entrypoint = skill_dir / "SKILL.md" - entrypoint.write_text(COMPATIBLE_SKILL, encoding="utf-8") + entrypoint.write_text("", encoding="utf-8") + with self.assertRaisesRegex(InstallError, "entrypoint is empty"): + prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) - with patch("installer.evidentloop.shutil.which") as which: - with self.assertRaisesRegex(InstallError, "missing referenced file"): - prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) - which.assert_not_called() - self.assertEqual(entrypoint.read_text(encoding="utf-8"), COMPATIBLE_SKILL) - - def test_incompatible_cli_is_not_replaced(self) -> None: - def fake_which(name: str) -> str | None: - return { - "evidentloop": "/tools/evidentloop", - "uv": "/tools/uv", - "npx": "/tools/npx", - }.get(name) - - def incompatible_runner( + entrypoint.write_text( + "---\nname: another-skill\n---\nNot EvidentLoop.\n", + encoding="utf-8", + ) + with self.assertRaisesRegex( + InstallError, + r"expected front matter `name: evidentloop`", + ): + prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + + def test_unhealthy_cli_is_not_replaced(self) -> None: + def unhealthy_runner( argv: list[str], **_kwargs: object, ) -> subprocess.CompletedProcess[str]: - if argv[1:] == ["doctor", "--json"]: - return _completed( - argv, - stdout=json.dumps( - {"status": "ok", "python_executable": "/tools/python"} - ), - ) - if "-c" in argv: - return _completed( - argv, - stdout=json.dumps( - { - "package_version": "0.1.0a1", - "schema_version": "0.4", - "prompt_version": "v0.5", - } - ), - ) - raise AssertionError(f"unexpected argv: {argv}") + return _completed( + argv, + stdout=json.dumps( + { + "status": "error", + "version": HEALTHY_VERSION, + "python_executable": "/tools/python", + } + ), + ) with tempfile.TemporaryDirectory() as temp_dir: + home = Path(temp_dir) + _write_complete_skill(home / ".agents/skills/evidentloop") with ( - patch("installer.evidentloop.shutil.which", side_effect=fake_which), + patch( + "installer.evidentloop.shutil.which", + return_value="/tools/evidentloop", + ), patch( "installer.evidentloop.subprocess.run", - side_effect=incompatible_runner, + side_effect=unhealthy_runner, ) as run, ): - with self.assertRaisesRegex(InstallError, "CLI is incompatible"): - prepare_evidentloop_install( - CODEX_ADAPTER, - home_root=Path(temp_dir), - ) + with self.assertRaisesRegex(InstallError, "unhealthy installation"): + prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) - executed = [call.args[0][0] for call in run.call_args_list] - self.assertNotIn("/tools/uv", executed) - self.assertNotIn("/tools/npx", executed) + self.assertEqual(len(run.call_args_list), 1) - def test_home_skill_install_uses_fixed_global_command_and_real_directory(self) -> None: + def test_home_skill_install_stages_then_copies_to_final_path(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: home = Path(temp_dir) - skill_dir = home / ".agents/skills/evidentloop" + target = home / ".agents/skills/evidentloop" observed: list[tuple[list[str], dict[str, object]]] = [] def fake_which(name: str) -> str | None: @@ -310,13 +264,11 @@ def fake_run( **kwargs: object, ) -> subprocess.CompletedProcess[str]: observed.append((list(argv), kwargs)) - source_result = self._skill_source_result(argv) - if source_result is not None: - return source_result if argv[0] == "/tools/npx": - _write_compatible_skill(skill_dir) + staging_home = Path(str(kwargs["env"]["HOME"])) + _write_complete_skill(staging_home / ".agents/skills/evidentloop") return _completed(argv) - return self._compatible_runner(argv, **kwargs) + return self._healthy_runner(argv, **kwargs) with ( patch("installer.evidentloop.shutil.which", side_effect=fake_which), @@ -324,36 +276,33 @@ def fake_run( ): plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) result = install_evidentloop_companion(plan) - self.assertTrue((skill_dir / "SKILL.md").is_file()) - npx_argv, npx_kwargs = next(item for item in observed if item[0][0] == "/tools/npx") - clone_argv = next( - argv for argv, _kwargs in observed if argv[:2] == ["/tools/git", "clone"] - ) - self.assertEqual( - clone_argv[:-1], - [ - "/tools/git", - "clone", - "--quiet", - "--depth", - "1", - "--branch", - "v0.1.0a2", - "https://github.com/evidentloop/evidentloop.git", - ], - ) - self.assertEqual(Path(clone_argv[-1]).name, "source") - self.assertEqual(npx_argv[:4], ["/tools/npx", "--yes", "skills@1.5.9", "add"]) - self.assertEqual(Path(npx_argv[4]).parts[-3:], ("source", "skills", "evidentloop")) - self.assertEqual(npx_argv[5:], ["-g", "-a", "codex", "-y", "--copy"]) - self.assertTrue(Path(str(npx_kwargs["cwd"])).name.startswith("sopify-evidentloop-")) - self.assertEqual(npx_kwargs["env"]["HOME"], str(home)) - self.assertEqual(npx_kwargs["env"]["DISABLE_TELEMETRY"], "1") - self.assertEqual(npx_kwargs["env"]["DO_NOT_TRACK"], "1") - self.assertEqual(result.skill_path, skill_dir / "SKILL.md") - - def test_copilot_install_copies_only_skill_to_project_github_path(self) -> None: + npx_argv, npx_kwargs = next( + item for item in observed if item[0][0] == "/tools/npx" + ) + self.assertEqual( + npx_argv, + [ + "/tools/npx", + "--yes", + "skills@latest", + "add", + "evidentloop/evidentloop", + "--skill", + "evidentloop", + "-g", + "-a", + "codex", + "-y", + "--copy", + ], + ) + self.assertNotEqual(npx_kwargs["env"]["HOME"], str(home)) + self.assertEqual(npx_kwargs["env"]["DISABLE_TELEMETRY"], "1") + self.assertTrue((target / "SKILL.md").is_file()) + self.assertEqual(result.skill_path, target / "SKILL.md") + + def test_copilot_install_copies_only_skill_to_project_path(self) -> None: with ( tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as workspace_dir, @@ -361,7 +310,7 @@ def test_copilot_install_copies_only_skill_to_project_github_path(self) -> None: home = Path(home_dir) workspace = Path(workspace_dir) target = workspace / ".github/skills/evidentloop" - observed_npx: list[tuple[list[str], Path]] = [] + observed_npx: list[list[str]] = [] def fake_which(name: str) -> str | None: return { @@ -374,21 +323,15 @@ def fake_run( argv: list[str], **kwargs: object, ) -> subprocess.CompletedProcess[str]: - source_result = self._skill_source_result(argv) - if source_result is not None: - return source_result if argv[0] == "/tools/npx": + observed_npx.append(list(argv)) staging_root = Path(str(kwargs["cwd"])) - observed_npx.append((list(argv), staging_root)) - _write_compatible_skill( - staging_root / ".agents/skills/evidentloop" - ) + _write_complete_skill(staging_root / ".agents/skills/evidentloop") (staging_root / "skills-lock.json").write_text( - "{}\n", - encoding="utf-8", + "{}\n", encoding="utf-8" ) return _completed(argv) - return self._compatible_runner(argv, **kwargs) + return self._healthy_runner(argv, **kwargs) with ( patch("installer.evidentloop.shutil.which", side_effect=fake_which), @@ -401,42 +344,31 @@ def fake_run( ) result = install_evidentloop_companion(plan) - npx_argv, staging_root = observed_npx[0] - self.assertEqual( - npx_argv[:4], - ["/tools/npx", "--yes", "skills@1.5.9", "add"], - ) - self.assertEqual( - Path(npx_argv[4]).parts[-3:], - ("source", "skills", "evidentloop"), - ) self.assertEqual( - npx_argv[5:], + observed_npx[0][-4:], ["-a", "github-copilot", "-y", "--copy"], ) - self.assertNotEqual(staging_root, workspace) self.assertTrue((target / "SKILL.md").is_file()) - self.assertTrue( - (target / "references/codex-cli-isolation.md").is_file() - ) self.assertFalse((workspace / ".agents/skills/evidentloop").exists()) self.assertFalse((workspace / "skills-lock.json").exists()) self.assertEqual(result.skill_path, target / "SKILL.md") - def test_missing_cli_installs_without_touching_compatible_skill(self) -> None: + def test_missing_cli_installs_current_package_and_reports_doctor_version( + self, + ) -> None: with tempfile.TemporaryDirectory() as temp_dir: home = Path(temp_dir) - skill_dir = home / ".agents/skills/evidentloop" - _write_compatible_skill(skill_dir) + _write_complete_skill(home / ".agents/skills/evidentloop") bin_dir = home / "uv-bin" bin_dir.mkdir() - (bin_dir / "evidentloop").write_text("", encoding="utf-8") - observed: list[list[str]] = [] + executable = bin_dir / "evidentloop" + executable.write_text("", encoding="utf-8") cli_installed = False + observed: list[list[str]] = [] def fake_which(name: str) -> str | None: if name == "evidentloop" and cli_installed: - return str(bin_dir / "evidentloop") + return str(executable) return "/tools/uv" if name == "uv" else None def fake_run( @@ -445,12 +377,12 @@ def fake_run( ) -> subprocess.CompletedProcess[str]: nonlocal cli_installed observed.append(list(argv)) - if argv[1:] == ["tool", "install", "evidentloop==0.1.0a2"]: + if argv[1:] == ["tool", "install", "evidentloop"]: cli_installed = True return _completed(argv) if argv[1:] == ["tool", "dir", "--bin"]: return _completed(argv, stdout=f"{bin_dir}\n") - return self._compatible_runner(argv, **kwargs) + return self._healthy_runner(argv, **kwargs) with ( patch("installer.evidentloop.shutil.which", side_effect=fake_which), @@ -459,18 +391,16 @@ def fake_run( plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) result = install_evidentloop_companion(plan) - self.assertEqual((result.cli_action, result.skill_action), ("installed", "reused")) - self.assertIn( - ["/tools/uv", "tool", "install", "evidentloop==0.1.0a2"], - observed, + self.assertEqual( + (result.cli_action, result.skill_action), ("installed", "reused") ) - self.assertFalse(any(argv[0].endswith("npx") for argv in observed)) + self.assertEqual(result.package_version, HEALTHY_VERSION) + self.assertIn(["/tools/uv", "tool", "install", "evidentloop"], observed) def test_fresh_cli_stops_when_command_is_not_on_path(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: home = Path(temp_dir) - skill_dir = home / ".agents/skills/evidentloop" - _write_compatible_skill(skill_dir) + _write_complete_skill(home / ".agents/skills/evidentloop") bin_dir = home / "uv-bin" bin_dir.mkdir() (bin_dir / "evidentloop").write_text("", encoding="utf-8") @@ -482,11 +412,11 @@ def fake_run( argv: list[str], **kwargs: object, ) -> subprocess.CompletedProcess[str]: - if argv[1:] == ["tool", "install", "evidentloop==0.1.0a2"]: + if argv[1:] == ["tool", "install", "evidentloop"]: return _completed(argv) if argv[1:] == ["tool", "dir", "--bin"]: return _completed(argv, stdout=f"{bin_dir}\n") - return self._compatible_runner(argv, **kwargs) + return self._healthy_runner(argv, **kwargs) with ( patch("installer.evidentloop.shutil.which", side_effect=fake_which), @@ -495,14 +425,15 @@ def fake_run( plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) with self.assertRaisesRegex( InstallError, - r"CLI installed.*not discoverable on PATH.*uv tool update-shell", + r"not discoverable on PATH.*uv tool update-shell", ): install_evidentloop_companion(plan) - def test_changed_skill_tag_stops_before_skills_cli(self) -> None: + def test_failed_skill_install_leaves_no_target_and_can_be_retried(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: home = Path(temp_dir) - observed: list[list[str]] = [] + target = home / ".agents/skills/evidentloop" + attempt = 0 def fake_which(name: str) -> str | None: return { @@ -515,65 +446,36 @@ def fake_run( argv: list[str], **kwargs: object, ) -> subprocess.CompletedProcess[str]: - observed.append(list(argv)) - if argv[:2] == ["/tools/git", "clone"]: - _write_compatible_skill(Path(argv[-1]) / "skills/evidentloop") + nonlocal attempt + if argv[0] == "/tools/npx": + attempt += 1 + staging_home = Path(str(kwargs["env"]["HOME"])) + staged_skill = staging_home / ".agents/skills/evidentloop" + if attempt == 1: + staged_skill.mkdir(parents=True) + (staged_skill / "partial.tmp").write_text( + "partial", encoding="utf-8" + ) + return _completed(argv, returncode=1) + _write_complete_skill(staged_skill) return _completed(argv) - if argv[0:2] == ["/tools/git", "-C"]: - return _completed(argv, stdout="changed-commit\n") - return self._compatible_runner(argv, **kwargs) + return self._healthy_runner(argv, **kwargs) with ( patch("installer.evidentloop.shutil.which", side_effect=fake_which), patch("installer.evidentloop.subprocess.run", side_effect=fake_run), ): - plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) - with self.assertRaisesRegex(InstallError, "source commit changed"): - install_evidentloop_companion(plan) + first_plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + with self.assertRaisesRegex(InstallError, "Skill install failed"): + install_evidentloop_companion(first_plan) + self.assertFalse(target.exists()) - self.assertFalse(any(argv[0] == "/tools/npx" for argv in observed)) + retry_plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) + result = install_evidentloop_companion(retry_plan) - def test_partial_failure_reports_cli_that_was_already_installed(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - home = Path(temp_dir) - bin_dir = home / "uv-bin" - bin_dir.mkdir() - (bin_dir / "evidentloop").write_text("", encoding="utf-8") - cli_installed = False - - def fake_which(name: str) -> str | None: - if name == "evidentloop" and cli_installed: - return str(bin_dir / "evidentloop") - return { - "uv": "/tools/uv", - "npx": "/tools/npx", - "git": "/tools/git", - }.get(name) - - def fake_run( - argv: list[str], - **kwargs: object, - ) -> subprocess.CompletedProcess[str]: - nonlocal cli_installed - if argv[1:] == ["tool", "install", "evidentloop==0.1.0a2"]: - cli_installed = True - return _completed(argv) - if argv[1:] == ["tool", "dir", "--bin"]: - return _completed(argv, stdout=f"{bin_dir}\n") - if argv[:2] == ["/tools/git", "clone"]: - return _completed(argv, returncode=1) - return self._compatible_runner(argv, **kwargs) - - with ( - patch("installer.evidentloop.shutil.which", side_effect=fake_which), - patch("installer.evidentloop.subprocess.run", side_effect=fake_run), - ): - plan = prepare_evidentloop_install(CODEX_ADAPTER, home_root=home) - with self.assertRaisesRegex( - InstallError, - "CLI installed.*no external files were rolled back", - ): - install_evidentloop_companion(plan) + self.assertTrue((target / "SKILL.md").is_file()) + self.assertEqual(result.skill_action, "installed") + self.assertEqual(attempt, 2) def test_core_completion_is_visible_when_companion_fails(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: @@ -590,7 +492,7 @@ def test_core_completion_is_visible_when_companion_fails(self) -> None: ): with self.assertRaisesRegex( InstallError, - "Sopify core installation completed.*EvidentLoop was not installed", + "Sopify core installation completed.*setup did not complete", ): run_install( target_value="codex:en-US", @@ -601,17 +503,16 @@ def test_core_completion_is_visible_when_companion_fails(self) -> None: ) self.assertTrue((home / ".codex/AGENTS.md").is_file()) - def test_distribution_reports_actions_version_path_and_stable_errors(self) -> None: + def test_distribution_reports_simple_actions_and_one_companion_error(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: home = Path(temp_dir) skill_path = home / ".agents/skills/evidentloop/SKILL.md" companion = EvidentLoopInstallResult( cli_action="installed", skill_action="reused", - package_version="0.1.0a2", + package_version=HEALTHY_VERSION, skill_path=skill_path, ) - request = self._request() with ( patch( "scripts.install_sopify.prepare_evidentloop_install", @@ -623,7 +524,7 @@ def test_distribution_reports_actions_version_path_and_stable_errors(self) -> No ), ): report = run_distribution_install( - request=request, + request=self._request(), repo_root=REPO_ROOT, home_root=home, install_executor=run_install, @@ -631,65 +532,44 @@ def test_distribution_reports_actions_version_path_and_stable_errors(self) -> No detailed = render_distribution_result(report) user_facing = render_distribution_user_result(report) + self.assertIn("EvidentLoop CLI (0.2.0): installed", detailed) + self.assertIn("EvidentLoop Skill: reused (health check passed)", detailed) + self.assertIn("EvidentLoop CLI(0.2.0):已安装", user_facing) + self.assertIn("EvidentLoop Skill:已复用(健康检查通过)", user_facing) + + def fail_install(**_kwargs: object) -> object: + raise InstallError( + "Sopify core installation completed, but EvidentLoop setup did not complete." + ) + + with self.assertRaises(DistributionError) as raised: + run_distribution_install( + request=self._request(), + repo_root=REPO_ROOT, + home_root=Path("/tmp/sopify-test-home"), + install_executor=fail_install, + ) + error = raised.exception + self.assertEqual(error.reason_code, "EVIDENTLOOP_COMPANION_INCOMPLETE") + self.assertIn("setup did not complete", render_distribution_error(error)) self.assertIn( - "EvidentLoop CLI (0.1.0a2): installed (tested with this Sopify release)", - detailed, - ) - self.assertIn( - "EvidentLoop Skill: reused (compatibility checked)", detailed + "EvidentLoop 安装未完成", + render_distribution_user_error(error, language="zh-CN"), ) - self.assertIn(f"EvidentLoop Skill path: {skill_path}", detailed) self.assertIn( - "EvidentLoop CLI(0.1.0a2):已安装(本次 Sopify 发布验证版本)", - user_facing, + "重新运行同一命令,或单独安装", + render_distribution_user_error(error, language="zh-CN"), ) - self.assertIn(f"EvidentLoop Skill 路径:{skill_path}", user_facing) - copilot_result = EvidentLoopInstallResult( + copilot = EvidentLoopInstallResult( cli_action="reused", skill_action="installed", - package_version="0.1.0a2", + package_version=HEALTHY_VERSION, skill_path=home / ".github/skills/evidentloop/SKILL.md", ) - copilot_en = "\n".join( - _companion_action_lines(copilot_result, language="en-US") - ) - copilot_zh = "\n".join( - _companion_action_lines(copilot_result, language="zh-CN") - ) - self.assertIn("review and commit it if your cloud workflow needs it", copilot_en) - self.assertIn("Sopify will not commit or update it", copilot_en) - self.assertIn("如需云端使用,请审查后自行提交", copilot_zh) - self.assertIn("Sopify 不会自动提交或更新", copilot_zh) - - errors = ( - ( - "EvidentLoop companion preflight failed: " - "missing required command(s): uv", - "EVIDENTLOOP_PREREQUISITE_MISSING", - ), - ( - "Existing EvidentLoop CLI is incompatible", - "EVIDENTLOOP_INCOMPATIBLE", - ), - ( - "Sopify core installation completed, but EvidentLoop was not installed.", - "EVIDENTLOOP_COMPANION_INCOMPLETE", - ), - ) - for message, reason_code in errors: - with self.subTest(reason_code=reason_code): - def fail_install(**_kwargs: object) -> object: - raise InstallError(message) - - with self.assertRaises(DistributionError) as raised: - run_distribution_install( - request=self._request(), - repo_root=REPO_ROOT, - home_root=Path("/tmp/sopify-test-home"), - install_executor=fail_install, - ) - self.assertEqual(raised.exception.reason_code, reason_code) + copilot_lines = "\n".join(_companion_action_lines(copilot, language="zh-CN")) + self.assertIn("如需云端使用,请审查后自行提交", copilot_lines) + self.assertIn("Sopify 不会自动提交或更新", copilot_lines) @staticmethod def _request() -> DistributionRequest: @@ -707,17 +587,7 @@ def _request() -> DistributionRequest: ) @staticmethod - def _versions_json() -> str: - return json.dumps( - { - "package_version": "0.1.0a2", - "schema_version": "0.4", - "prompt_version": "v0.5", - } - ) - - def _compatible_runner( - self, + def _healthy_runner( argv: list[str], **_kwargs: object, ) -> subprocess.CompletedProcess[str]: @@ -725,30 +595,15 @@ def _compatible_runner( return _completed( argv, stdout=json.dumps( - {"status": "ok", "python_executable": "/tools/python"} + { + "status": "ok", + "version": HEALTHY_VERSION, + "python_executable": "/tools/python", + } ), ) - if "-c" in argv: - return _completed(argv, stdout=self._versions_json()) - if argv[-1] == "--help": - return _completed(argv, stdout="prepare finalize render revise") raise AssertionError(f"unexpected argv: {argv}") - @staticmethod - def _skill_source_result( - argv: list[str], - ) -> subprocess.CompletedProcess[str] | None: - if argv[:2] == ["/tools/git", "clone"]: - source_root = Path(argv[-1]) - _write_compatible_skill(source_root / "skills/evidentloop") - return _completed(argv) - if argv[0:2] == ["/tools/git", "-C"] and argv[-2:] == ["rev-parse", "HEAD"]: - return _completed( - argv, - stdout="fcefb77083d32b034e56b04dcd085dcf5a835550\n", - ) - return None - if __name__ == "__main__": unittest.main()