diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 16e33629..b3d5bf3d 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -22,6 +22,8 @@ snapshot_managed_release_paths() {
"CHANGELOG.md"
"skills/zh/header.md.template"
"skills/en/header.md.template"
+ "skills/zh/cursor-plugin-rule.mdc.template"
+ "skills/en/cursor-plugin-rule.mdc.template"
)
local path
local src
@@ -152,6 +154,8 @@ git -C "$ROOT_DIR" add \
CHANGELOG.md \
skills/zh/header.md.template \
skills/en/header.md.template \
+ skills/zh/cursor-plugin-rule.mdc.template \
+ skills/en/cursor-plugin-rule.mdc.template \
tests/golden-snapshots.json
cat >"$STATE_FILE" < **未列入面默认可删**:`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 通过单文件展开消费。
+`host_support` 只声明官方适配器能把 Skill 语义交付到对应宿主的支持界面并由宿主消费。它不等同于原生 Skill discovery,也不证明该宿主已经完成 E2E;支持等级、已验证能力和入口形态继续由 `HostCapability` 表达。当前五个内置 Skill 面向 `codex / claude / qoder / copilot / cursor`,其中 Copilot 通过单文件展开消费,Cursor IDE 通过用户 Plugin Rule 与全局 Skill 树拆分消费;Agent CLI 只保留手工 Skill 与用户 Hook 兼容面。
## Output Rendering Audit — *[pre-P8 legacy reference]*
@@ -504,6 +504,7 @@ output.py 渲染层逐字段分类。只做分类,不做改造决策(改造
| Claude | `protocol_verified` | `install.sh --target claude:zh-CN` | CONTINUATION + INTERACTION + AUDIT | 已验证 |
| Qoder | `protocol_verified` | `install.sh --target qoder` | CONTINUATION + INTERACTION + AUDIT | W3.1-W3.3 已验证 |
| Copilot | `baseline_supported` | `install.sh --target copilot` | PROMPT_ONLY | Prompt-only |
+| Cursor | `baseline_supported` | `install.sh --target cursor` | PROMPT_ONLY + HOOKS | IDE user Plugin Rule + global Skills/payload + user-level `~/.cursor/hooks.json`; observable IDE baseline evidence, CLI manual Skills only |
### 契约消费矩阵(P8 2-file state model)
@@ -543,7 +544,7 @@ output.py 渲染层逐字段分类。只做分类,不做改造决策(改造
- MCP 只承接 active plan / handoff 读取、protocol check 与受 guard 约束的低层 receipt 写入;分析、设计、开发、checkpoint 与 finalize 决策继续由 prompt/skill 和宿主负责。
- repo-local server 为 `scripts/sopify_mcp_server.py`。Codex-first 注册已通过官方 `codex mcp get/add`、真实 stdio tool 调用和 no-op 复验。
- 2026-07-17 Claude 会话级消费已通过:使用临时 MCP 配置加载同一 repo-local server,成功调用 `sopify.workspace_status_lite`;该证据不等同于持久注册。
-- Codex-first 表示验证顺序,不改变 Qoder、Claude、Copilot 的能力梯度或支持声明。其他宿主自动注册须复用同一最小契约并各自补证据。
+- Codex-first 表示验证顺序,不改变 Qoder、Claude、Copilot、Cursor 的能力梯度或支持声明。其他宿主自动注册须复用同一最小契约并各自补证据。
- 当前不把 Python/MCP 依赖供给、payload 打包、doctor 检查或多宿主配置抽象升级为产品面;只有后续证据证明重复痛点时再设计。
### 禁止消费面
diff --git a/.sopify/plan/20260819_cursor_support/audits/cursor_ide_final_review.md b/.sopify/plan/20260819_cursor_support/audits/cursor_ide_final_review.md
new file mode 100644
index 00000000..50bf5725
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/audits/cursor_ide_final_review.md
@@ -0,0 +1,121 @@
+# Sopify Cursor IDE Plugin 最终独立复审任务
+
+## 复审模式
+
+- 只读。不要修改代码、方案、`.sopify/state`、receipts、Cursor 配置或 Git index。
+- 仓库:`/Users/weixin.li/code/github/sopify`
+- 分支:`feat/cursor-support`
+- 基线:`d0e81f168be629cfa7f1e6b3cce5ed01f712fc3e`
+- 复审对象:相对基线的全部未提交 diff,包括删除文件与 untracked 文件。
+- 不沿用实现者结论;只按源码、测试输出、IDE 工具轨迹和文件结果独立裁决。
+
+## 已定产品边界
+
+1. Cursor IDE 是自动语义入口:用户级 Plugin 中一个薄 `alwaysApply: true` Rule 负责意图分类和精确 Skill 路由。
+2. Agent CLI 是低频兼容面:不承诺自动加载 Plugin Rule,只保留手工 Skill 与 Cursor 实际加载范围内的用户 Hooks。
+3. 不安装项目 `.cursor/rules/sopify.mdc`,不新增 launcher、prompt injector、Command、Custom Agent、MCP、orchestrator 或 runtime。
+4. 全局五项 Skills、shared references、payload 与用户 Hooks 由同一个 Cursor host installer 管理。
+5. 支持档位保持 `BASELINE_SUPPORTED`;独立 sessionStart、显式 finalize、AskQuestion 跨模型稳定性不是本轮阻塞项,也不得写成已验证。
+6. 不修改 Cursor settings、代理、模型、账号、API Key、钥匙串或 MCP;不支持 Cloud Agent。
+
+## 代码与结构审查
+
+- Cursor 仍是多宿主内核中的第五个 adapter,不是 fork。
+- `installer/cursor_plugin.py` 只负责本地 Plugin manifest、README 和一个薄 Rule;不得维护 Plugin 私有 Skills 副本。
+- Cursor 安装不要求 workspace、不预热项目 `.sopify`,首次产品写入前预检用户 hooks。
+- Hooks 只保护明显直接写入 state/handoff/receipt,放行方案文档、业务代码和正常 `sopify_writer` 库 API;helper 异常 fail-open,并由 Doctor 报告。
+- Doctor 分开报告 Plugin、Skill、payload、hooks 与 IDE/CLI behavior;临时本机 transcript 不得让静态 behavior 项冒充产品认证。
+- 检查 Codex、Claude、Qoder、Copilot 行为没有被 Cursor-specific 分支意外改变。
+
+实现者在 2026-08-22 的最终候选上报告:308 passed / 78 subtests,Python compileall、`bash -n install.sh` 与 `git diff --check` 均通过。复审者应按下面命令自行重跑,不把这段记录当成独立通过。
+
+同日真实 home Doctor 中,Cursor 的 Plugin、payload、bundle resolution、Skill tree 与 Hooks 五项均为 pass;IDE/CLI behavior 仍按静态设计为 skip / `BLACK_BOX_NOT_VERIFIED`。Doctor 的 overall fail 来自本仓库旧 `.sopify/sopify.json` 对 Codex、Claude、Qoder bundle 的历史 pin,不是 Cursor 检查失败,本任务不顺手改写该 workspace 配置。
+
+## 可独立核验的 IDE 证据
+
+### Plugin、consult 与 Analyze
+
+- 用户范围的 Sopify Plugin Rule 已在 Cursor Customize > Rules 中显示为 Always Apply,Plugin 服务日志报告 `ruleCount: 1`。
+- consult transcript:`/Users/weixin.li/.cursor/projects/Users-weixin-li-code-github-sopify/agent-transcripts/067ce461-c667-46fb-88ce-bffe4c4362a1/067ce461-c667-46fb-88ce-bffe4c4362a1.jsonl`
+- Analyze transcript:`/Users/weixin.li/.cursor/projects/Users-weixin-li-code-nio-data-sprite/agent-transcripts/7101b90b-ce5d-4acb-8fc3-930056f95e68/7101b90b-ce5d-4acb-8fc3-930056f95e68.jsonl`
+- 核验重点:consult 不加载阶段 Skill、不续旧方案、不写 machine truth;Analyze 实际读取 Cursor Skill 并执行评分脚本,低分时不进入 Design 或写文件。
+
+### AskQuestion
+
+- transcript:`/Users/weixin.li/.cursor/projects/Users-weixin-li-code-nio-data-sprite/agent-transcripts/84e9fa01-c2dc-4db5-9611-0f15c120bcb8/84e9fa01-c2dc-4db5-9611-0f15c120bcb8.jsonl`
+- 同一会话包含 AskQuestion tool use 与 `Tool not found` 回退;结构化问卷 UI 属人工观察,transcript 不含 tool result、选项回传或 `Other...`。
+- 这只证明 AskQuestion 调用尝试与文本回退都发生过;结构化问卷 UI 仍是人工观察。不要据此宣称每个模型或每次会话都会稳定提供问卷。
+
+### Managed 主链与 Hook
+
+- 临时仓库:`/private/tmp/sopify-cursor-managed-blackbox.WRxVtd`
+- transcript:`/Users/weixin.li/.cursor/projects/private-tmp-sopify-cursor-managed-blackbox-WRxVtd/agent-transcripts/2448476f-57b8-4e1d-a522-c19e8202d658/2448476f-57b8-4e1d-a522-c19e8202d658.jsonl`
+- 初始业务文件为 `hello\n`。第一轮实际读取 Analyze/Design Skills,执行评分 `10/10` 和 light 分级,创建方案并经 writer 写 active plan/handoff,然后在 `confirm_decision` 停车,业务文件未改。
+- 用户确认后,第二轮读取四步链与 Develop Skill,将 `src/greeting.txt` 精确改成 `hello from Cursor\n`,更新方案为 `ready_to_archive`,并经 writer 追加 `exec_001`、`verify_001` 与 handoff;未 finalize、未 commit。
+- 第三轮实际执行紧贴重定向的 Shell 直写 `active_plan.json`,Cursor Hook 返回拒绝;Agent 未尝试绕过。
+
+现场哈希:
+
+```text
+f9e1d6f57a5d7a795513cfb8ef1ce5c8c7c3deb3319a0ab38df5a73a735ad0b2 .sopify/state/active_plan.json
+f24d2621d2fbc470b9febdcd8e700679358469f025c25f668e74dd1e661ce950 .sopify/state/current_handoff.json
+7335746bb72af92df43cb5b1435934d8bed6fbe282307f02630d62836dfe4ecd .sopify/plan/20260822_greeting_from_cursor/receipts/exec_001.json
+70c06cac2adeecc10e91443a04149cd6d5c627befc8cc6fbdbba8943c4ff3e47 .sopify/plan/20260822_greeting_from_cursor/receipts/verify_001.json
+bd601aecf0dae231623085daa65a69e8304327aaa829e629eb8362f34cb0496b src/greeting.txt
+```
+
+不要只看最终回复。以 transcript 中的 `tool_use`、方案/receipt 内容、Git diff 和文件哈希为证据。
+
+## CLI 已知边界
+
+- 自动用户 Plugin:`4c24eab4-dd82-46f3-8fd7-67888baa1a53`
+- 显式 `--plugin-dir`:`7626c137-9668-450f-ab00-a44619f90f9c`
+- 唯一标记探针:`109d2991-78e6-46d8-a19a-5bd86f3198f0`
+
+三次都没有证明 Agent CLI 自动加载用户 Plugin Rule。该能力不在产品承诺内,不是 IDE baseline 阻塞项,也不得改写成 CLI 自动支持。
+
+## 建议命令
+
+```bash
+cd /Users/weixin.li/code/github/sopify
+git status --short --branch
+git diff --check
+git diff -- . ':(exclude).sopify/plan/20260819_cursor_support/audits/cursor_ide_final_review.md'
+PYTHONDONTWRITEBYTECODE=1 /opt/homebrew/opt/python@3.11/libexec/bin/python3 -m pytest -p no:cacheprovider tests -q
+PYTHONDONTWRITEBYTECODE=1 /opt/homebrew/opt/python@3.11/libexec/bin/python3 scripts/sopify_doctor.py --format json --home-root /Users/weixin.li
+```
+
+普通 `git diff` 不展示 untracked 文件,请单独读取 `git status --short` 列出的全部新资产,尤其是:
+
+- `installer/cursor_plugin.py`
+- 双语 Cursor Plugin Rule 与 README 模板
+- 本审计文件
+
+## 输出要求
+
+请直接在 chat 返回,不写文件:
+
+1. 一句话裁决。
+2. P0 / P1 / P2 findings;没有则明确“无”,每条附文件或可观察证据。
+3. 是否存在过度设计、冗余入口或口径过强。
+4. 自动化、IDE、CLI 三类证据分别证明什么,哪些仍未验证。
+5. 是否认可保持 `BASELINE_SUPPORTED`。
+6. 精确状态:`accept`、`accept_with_fixes` 或 `reject`。
+
+不要 commit、push、安装新组件、删除兼容 Skill,或生成 verify receipt。
+
+## 独立复审结果(2026-08-22)
+
+- 精确裁决:`accept`;P0 无,P1 无。
+- 认可:Cursor 仍是第五个 adapter,不是 fork;用户 Plugin 薄 Rule、全局 Skills/payload 与用户 Hooks 的分层没有引入 launcher、runtime、MCP、Custom Agent 或第二套项目 Rule。
+- P2-1:复审时真实安装 Rule/README 与候选模板有文字差异。已用最终 `cursor:zh-CN` 候选原位重装并逐字核对;Hooks、CLI/User settings、MCP 文件存在状态与系统代理前后不变。
+- P2-2:AskQuestion transcript 证明调用尝试与文本回退,但不独立证明结构化问卷结果。公开文档与方案已删除“原生问卷/Other 已验证”等过强口径,不承诺跨模型稳定性或选项回传。
+- P2-3:本轮独立复审没有重看 Customize UI 或 `ruleCount: 1` 日志。公开证据口径以已安装 Rule 的 `alwaysApply: true`、consult/Analyze 工具轨迹和既有人工 UI 观察分层记录,不把 UI 观察写成可移植认证。
+- 自动化:最终候选 `308 passed, 78 subtests passed`;Python compileall、`bash -n install.sh`、SVG XML 与 `git diff --check` 通过。
+- 支持边界:保持 `BASELINE_SUPPORTED`。独立 sessionStart、显式 finalize、AskQuestion 跨模型稳定性、CLI 自动 Plugin Rule 与 Cloud Agent 不在本轮闭环内。
+
+## 独立视觉与文档复审(2026-08-22)
+
+- 最终裁决:`accept`;P0/P1/P2 均无,未发现重复入口或过度设计。
+- 三张全量宿主矩阵 SVG 均通过视觉检查;Cursor 与 Copilot 均按 baseline 表达。封面、demo 与跨宿主场景图保持具体示例,不为凑齐宿主重绘。
+- 首轮发现的 Cursor Verify Setup 断点、badge 错误导航与 README 顶部跨宿主过强口径,已分别以用户级 Doctor/Always 验证、专项文档直链和支持入口/档位分层关闭。
diff --git a/.sopify/plan/20260819_cursor_support/audits/implementation_review.md b/.sopify/plan/20260819_cursor_support/audits/implementation_review.md
new file mode 100644
index 00000000..df078c6c
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/audits/implementation_review.md
@@ -0,0 +1,82 @@
+# Sopify Cursor 宿主实施 · 独立复审包
+
+## 审计任务
+
+只读审查当前本地提交,不修改代码、方案、state、receipt 或 Git index。不要复述实现者结论;按源码、基线 diff、测试与真实 Cursor CLI transcript 独立裁决。
+
+- Repository: `/Users/weixin.li/code/github/sopify`
+- Branch: `feat/cursor-support`
+- Baseline: `2c2c13f3f8fd94f05fb3e3789bd2e20755e27863`
+- Diff: `git diff 2c2c13f3f8fd94f05fb3e3789bd2e20755e27863..HEAD`,并检查 `git status --short`
+- Delivery: 本地已提交,未 push
+- 范围: 本地 Cursor IDE + Cursor Agent CLI;不含 Cloud Agent
+- 档位: `BASELINE_SUPPORTED`;不得从文件存在或单次模型自述推导 `PROTOCOL_VERIFIED`
+
+## 已定边界
+
+- 保留多宿主内核,Cursor 是新 host,不建 fork。
+- 项目规则:`/.cursor/rules/sopify.mdc`;全局 Skills:`~/.cursor/skills/sopify/`;payload:`~/.cursor/sopify/`;用户 Hooks:`~/.cursor/hooks.json`。
+- 项目规则负责语义路由;Hook 只注入状态事实、拒绝明显直接改写 machine truth。它是 fail-open 防误写 guard,不是安全沙箱。
+- 没有项目规则时 Hook no-op;不写仓库级 hooks;不修改 Cursor settings、代理、模型、账号、API Key、钥匙串或 MCP。
+- managed writer 全流程与 finalize 不在本轮声明范围;不扩建 MCP、runtime、plugin、orchestrator 或 writer CLI。
+
+## 本轮修复过的审计 finding
+
+1. Doctor 缺席只看 Sopify 自有落点,不再把整个 `~/.cursor` 当成已安装。
+2. Cursor 禁用未验证的 EvidentLoop agent 映射;`--workspace` 只安装项目规则,不 bootstrap `.sopify`。
+3. Hook doctor 核对 helper 实际路径、可执行 Python 和 `failClosed: false`。
+4. `sessionStart` 只注入与 active plan 匹配的 handoff,并声明状态事实不是恢复命令。
+5. latest receipt 遵循 `final.json` 优先、timestamp 降序、缺 timestamp 时 receipt 数字兜底。
+6. 文件 Hook 按目标路径选择多根 workspace;Shell 在 writer marker 前拒绝明显直接改写。
+7. 非法 `hooks.json` 在任何 Sopify 产品写入前失败;合法配置原子合并并保留用户条目。
+8. Cursor Hook/helper 绑定具体 `cursor` host,不与通用 `project_rules` surface 隐式耦合。
+9. Helper 严格按 `hook_event_name` 分发,不猜事件。
+10. 安装结果不再把项目规则落点误报成“已预热”。
+11. 安装结果不再声称项目规则“会自动加载”,只说明已按 `alwaysApply: true` 安装并要求在 Cursor Rules 中确认。
+12. 多根 `sessionStart` 只在 `cwd` 唯一归属一个已启用 workspace 时注入方案事实;否则只报告歧义。
+13. Shell guard 可识别 state 与 receipt 的 `echo x>.sopify/...`、`echo x>>.sopify/...` 等紧贴输出重定向;仍明确不是完整 Shell 解析器或安全沙箱。
+
+第一次独立 Cursor 复审对当时实现给出的裁决是:无 P0/P1,上述 11–13 为应修 P2。第二次复审确认 11、12 关闭,但重现了 receipt 紧贴 `>` 时 13 未关闭。当前实现只给 receipt 命令边界增加 `>`,并补对应测试和真实 CLI 复测;没有引入 Shell parser。`verify_002` 是修复前的过强历史 receipt,未覆盖;本轮用 `verify_003` 纠正。请重新按源码与可观察证据裁决,不沿用实现者结论。
+
+## 自动化证据
+
+- `python3 -m pytest tests -q`:307 passed / 76 subtests。
+- `python3 -m compileall -q installer scripts sopify_writer`:passed。
+- `git diff --check`:passed。
+- 临时 home 测试覆盖 settings 保持不变、非法 hooks 无半安装、stale/fail-closed hooks doctor 失败、project_rules 不隐式装 Cursor Hook、多根路径与多根 sessionStart 歧义、receipt 排序、普通 Shell 绕过、state/receipt 紧贴重定向与命令路径空格。
+
+## 真实 Cursor Agent CLI 证据
+
+临时项目:`/private/tmp/sopify-cursor-cli-blackbox-v2.vDHYE4`。真实安装使用本机已登录的 `agent`,进程环境保留现有 `127.0.0.1:7898` 代理;未改代理或钥匙串。
+
+- Consult:session `c647872d-20e2-4be3-8888-6b4cd0476b44` 实际读取项目 Rule 与 `~/.cursor/skills/sopify/references/shared-writing-dna.md`,未读 Claude/Codex 同名 Skill,未写 state/receipt;但仍读取 Develop Skill/payload,因此只证明副作用边界,不证明 consult 语义路由完全符合规则。
+- sessionStart:session `162b4762-f5e1-4a87-970c-4b5dc3bf711f` 在不读取 `.sopify` 文件的情况下收到 `blackbox_plan / continue_host_develop / exec_001` Hook context。
+- Shell deny:session `d9670b90-0173-4744-8f6f-c20976a77646` 的 `echo hacked > .sopify/state/active_plan.json # sopify_writer` 被 `beforeShellExecution` 拒绝。
+- 无空格 Shell deny:session `40d6cbc5-5486-4df8-80bf-bbdb50dc8c19` 的实际命令 `echo hacked>.sopify/state/active_plan.json` 被 Hook 拒绝。
+- Receipt 紧贴重定向 deny:session `3a13cbf2-d184-488e-9331-25e7a7510365` 的实际命令 `echo hacked>.sopify/plan/blackbox_plan/receipts/exec_001.json` 在 `agent -p --output-format stream-json` 标准输出中返回 `tool_call completed` 与 `result.rejected`。持久 transcript 仍只保存 `tool_use` 和模型回复,因此工具拒绝以本次 stream-json 输出为证,未用 transcript 中的模型自述替代。
+- 文件工具 deny:session `4ac55fd4-aa38-4f9e-a39f-dc5601ffca99` 对 `current_handoff.json` 的 edit tool 调用被 `preToolUse` 拒绝。
+- Analyze 部分失败:session `d2b1121e-7a5e-4c74-8e48-12050e18a32e` 实际读取项目 Rule、Cursor `analyze/SKILL.md`、长规则与评分脚本,但没有调用 `score_requirement.py`,而是继续寻找仓库中不存在的评分细则;会话被停止。不得判定评分门通过。
+- 三份 machine truth 最终哈希与黑盒前一致:active `7bc36890...`、handoff `95438c62...`、receipt `00c09422...`;receipt 紧贴重定向复测后再次核对未变。
+- Cursor IDE settings 前后哈希均为 `93128937...`;`~/.cursor/settings.json` 仍不存在。
+- 多根 `sessionStart` 的歧义处理目前只有自动化证据,没有真实 IDE/CLI 多根黑盒;不得外推为宿主行为已验证。
+
+Transcript 根目录:`/Users/weixin.li/.cursor/projects/private-tmp-sopify-cursor-cli-blackbox-v2-vDHYE4/agent-transcripts/`。请检查实际 tool call / hook result,不以 thinking 或最终自述代替证据。
+
+## 明确未通过或未验证
+
+- CLI Analyze:Skill 路由通过,评分脚本执行未通过。
+- CLI consult:machine-truth 零写入通过,语义路由不干净。
+- Cursor IDE:未验证 Rules 面板 `Always Apply`、IDE 内 consult/analyze/Hook 行为。
+- managed develop/finalize writer-only 完整闭环:不在本轮发布声明范围。
+- Doctor 的 IDE/CLI behavior 仍固定报告 `BLACK_BOX_NOT_VERIFIED`,没有把本机临时证据写成产品级持久认证。
+
+## 请独立裁决
+
+1. 是否存在 P0/P1 正确性或回归风险;给出文件、行号、复现。
+2. `project_rules` 新 surface 是否保持最小,Cursor-specific Hook 是否避免污染其他 host。
+3. Hook 的 fail-open、用户级落点、多根路径、shell 明显改写策略是否与声明一致;不要把它当安全沙箱审计。
+4. Doctor、README、专项文档与支持档位是否存在过强口径。
+5. 当前准确状态应否是:安装与 Hook baseline 已实现,CLI 黑盒部分通过,Analyze 与 IDE 未闭环,保持 `BASELINE_SUPPORTED`。
+6. 是否有可删除的重复层、无消费者抽象或超出本期范围的实现。
+
+输出建议按 `P0 / P1 / P2 / 非阻塞 / 证据缺口 / 过度设计 / 最终裁决`,只报告可复现问题。
diff --git a/.sopify/plan/20260819_cursor_support/plan.md b/.sopify/plan/20260819_cursor_support/plan.md
new file mode 100644
index 00000000..ec9b1365
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/plan.md
@@ -0,0 +1,111 @@
+---
+title: Sopify Cursor 宿主支持
+plan_id: 20260819_cursor_support
+status: completed
+lifecycle_state: ready_to_archive
+level: standard
+created: 2026-08-19
+updated: 2026-08-22
+archive_ready: true
+knowledge_sync:
+ project: review
+ background: review
+ design: required
+ tasks: review
+---
+
+# Sopify Cursor 宿主支持
+
+就绪状态: Ready to archive
+依据: 用户范围的 Sopify Plugin Rule 已在 Customize > Rules 中显示为 Always Apply;consult、Analyze 评分门、AskQuestion 调用尝试与文本回退、Analyze → Design → Develop、writer 写回及 Hook direct-write deny 均有可观察 IDE 证据。最终独立复审返回 `accept`;未单独验证的 sessionStart、显式 finalize 与 AskQuestion 跨模型稳定性只作为后续边界,不阻塞本次 baseline 收口。
+
+## Context / Why
+
+当前 `feat/cursor-support` 已将第五个 host 从每仓项目 Rule 修正为 IDE 用户 Plugin、全局五项 Skills/payload 与用户级 Hooks。安装面、Rule 路由、consult、Analyze 评分、AskQuestion 调用与回退路径、managed develop、writer 写回与 Hook direct-write 证据已闭环;最终独立复审已接受候选,支持档位保持 `BASELINE_SUPPORTED`。Doctor 的行为项继续静态报告 `BLACK_BOX_NOT_VERIFIED`,不把本机临时证据写成可移植产品认证。
+
+用户主要在包含多个独立 Git 仓库的工作区中使用 Cursor,希望 Sopify 成为安装一次即可跨仓库工作的默认语义入口,自动区分 consult、quick fix 与 managed workflow,并按当前仓库的 `.sopify/`、项目规则和知识文档进入现有 Skills。每个仓库单独安装项目 `.mdc` 会增加迁移成本,也无法证明模型实际执行协议。
+
+Cursor 官方允许用户范围安装 Cursor Plugin,Plugin 可包含 `rules/*.mdc`,`alwaysApply: true` 的 Rule 可作为 IDE 默认上下文;官方 CLI 文档仍只明确列出项目 `.cursor/rules`、`AGENTS.md` 与 `CLAUDE.md`。真实 CLI 也未自动加载用户 Plugin Rule,因此本期将 IDE 作为自动入口,CLI 作为手工 Skill 兼容面。
+
+## Scope
+
+- 保留 Sopify 多宿主内核与 `cursor` host,不创建 Cursor fork;现有 Codex、Claude、Qoder、Copilot 行为保持不变。
+- Cursor IDE 改为用户级 Cursor Plugin:安装到 `~/.cursor/plugins/local/sopify/`,包含 `.cursor-plugin/plugin.json` 与一个极薄的 `rules/sopify.mdc`,作为 IDE 唯一 Sopify 语义入口。
+- Agent CLI 不承诺自动加载 Plugin Rule;低频使用时手工调用已安装的 `/analyze`、`/design`、`/develop` 等 Skill,用户 Hooks 继续提供其实际加载范围内的写保护。
+- IDE Plugin Rule 负责意图分类与 Skill 路由;Analyze 需要有限选项澄清时,当前会话提供内建 AskQuestion 就优先调用并使用问卷自带自由输入,否则回退文本。问卷不成为 Sopify machine truth,也不授权提前进入 Design/Develop。
+- Cursor 不再要求 `--workspace`,不再安装或检查项目 `.cursor/rules/sopify.mdc`;当前双语项目 Rule 的有效内容迁入全局薄 Rule 后删除项目 Rule 模板与本仓库临时规则。
+- 同一次 Sopify 安装继续管理 `~/.cursor/skills/sopify/`、`~/.cursor/sopify/` 与 `~/.cursor/hooks.json`。Plugin 不维护 Cursor 私有 Skills 副本,也不复制 Hook 配置或 helper。
+- 复用现有 `sessionStart`、`preToolUse`、`beforeShellExecution` Hooks。Hook 不再以项目 `.mdc` 为开关,改为按本次事件的目标路径、`cwd` 与 `workspace_roots` 识别由 `.sopify/` 管理的仓库。
+- 文件工具按目标路径选择所属 managed root;Shell 按 `cwd` 选择;`sessionStart` 只有在唯一 managed root 且 active plan 语义有效时才注入事实,多根不唯一或状态无效时 no-op。
+- `preToolUse` 与 `beforeShellExecution` 继续只拒绝对 `active_plan.json`、`current_handoff.json`、`plan/*/receipts/*.json` 的明显直接写入;`plan.md`、`tasks.md`、`design.md` 与业务代码不在保护范围,`sopify_writer` 库 API 继续放行。
+- Doctor 分开报告 Plugin/Rule、全局 Skill 树、payload、用户级 Hooks 的安装事实,以及 IDE/CLI 的行为证据;安装事实不得冒充宿主遵守协议。
+- 同步 installer、registry/capability、CLI 安装文案、README、Cursor 专项文档、长期 blueprint 与相关自动化测试。
+
+## Approach
+
+- 只增加 Cursor-specific 的用户 Plugin 安装能力,不抽象通用 Plugin 框架。Plugin 资产由仓库内单一模板/manifest 生成,五项 Skills 与共享 references 继续来自现有权威源码。
+- IDE 薄 Rule 只保留路由与边界:先分类本轮意图;consult 不续旧 plan;quick fix 不写 protocol machine truth;Analyze 必须实际评分,有限选项澄清按当前会话能力优先调用非 MCP 的内建 AskQuestion;machine truth 只经 `sopify_writer`。不新增问卷状态、tool wrapper 或手工“其他”选项。
+- 不给阶段 Skills 增加 `disable-model-invocation: true`。该字段会把 Skills 变成手工调用,与自然语言默认路由目标冲突;阶段语义继续由薄 Rule 的精确路径读取约束和黑盒证据验证。
+- `.sopify/` 只作为 Hook 写保护的 managed-root 信号,不作为可恢复方案事实。`sessionStart` 仍须验证 active plan 指针、对应 `plan.md` 与匹配 handoff;不能仅凭目录存在注入恢复上下文。
+- 保持 Hook fail-open:helper 缺失或异常不阻塞 Cursor,由 Doctor 报错。Hook 是应用层防误写 guard,不是安全沙箱,不新增完整 Shell parser。
+- 先在隔离 home 验证 Plugin Rule、Skills、payload、Hooks 与 settings 不变,再安装到真实 `~/.cursor`,以 IDE 作为本期唯一自动入口黑盒。
+- CLI 自动 Plugin Rule 与显式 `--plugin-dir` 的失败证据作为已知边界保留,不再作为发布否决门;不长期保留双入口,也不把手工 Skill 使用写成自动路由。
+
+## Waves / Steps
+
+1. 把 Cursor adapter 从 `project_rules` 调整为用户 Plugin 安装面,增加最小 Plugin manifest/Rule 资产并移除 workspace 依赖。
+2. 调整 installer、distribution、Doctor/status 与缺席判定,保证一次安装完成 Plugin、Skills、payload、Hooks,且不修改 Cursor settings、代理或账号相关文件。
+3. 复用现有 Hook helper,替换项目 Rule 启用条件,补目标路径、Shell cwd、唯一 session workspace 与有效事实注入测试。
+4. 删除项目 Rule 产品路径,更新 capability、双语文案、README、Cursor 文档、blueprint 与自动化。
+5. 运行定向和全量回归;保留 CLI 自动入口失败证据,随后执行真实 IDE 黑盒和独立 Cursor 复审。所有行为结论以工具轨迹和文件结果为证据。
+
+## Key Decisions
+
+- 继续使用当前分支 `feat/cursor-support` 与当前 active plan `20260819_cursor_support`;这是同一宿主功能的架构修正,不另建分支或方案包。
+- Cursor IDE 的唯一 Sopify 语义入口是用户级 Plugin 中的薄 Always Rule;项目 `.cursor/rules/sopify.mdc` 不保留为默认或可选第二入口。
+- Plugin 只承载 manifest 与薄 Rule。现有全局 Skills、shared references、payload 与用户级 Hooks 由同一个安装器管理,只有一份维护源和一个安装生命周期。
+- 项目 `AGENTS.md`、`CLAUDE.md`、已有 `.cursor/rules` 与 `.sopify/` 是当前仓库事实,不是第二套 Sopify 入口。真实冲突按 Cursor 规则优先级处理,writer/Hook 边界不依赖模型服从。
+- Hook 使用 `.sopify/` managed-root 信号,但按事件选择目标:文件工具看目标路径、Shell 看 `cwd`、sessionStart 只接受唯一有效 workspace;不新增通用 Git-root resolver。
+- 不设置 `disable-model-invocation: true`,不把 Plugin 的正常构建/安装产物误判为第二套协议源码。
+- 支持档位本波继续为 `BASELINE_SUPPORTED`。Plugin 已安装、Rule 显示 Always、Skill 可发现或模型自述均不能自动升级;`PROTOCOL_VERIFIED` 不在本波承诺范围。
+- CLI 自动入口明确不支持;手工 Skill 是低频兼容路径。当前分支不增加 launcher、prompt injector 或第二套 Rule。
+
+## Constraints / Not-in-scope
+
+- 不做 Marketplace 发布、Team Plugin、Cloud Agent、workspaceOpen 动态加载或 data-sprite 专用 manifest。
+- 不新增 `/sopify` dispatcher、Custom Agent、Command、`/go`、MCP、orchestrator、runtime 或 writer CLI。
+- 不新增 Plugin 自有 Hooks,不把五项 Skills 手工复制成 Cursor 私有维护树,不建立项目 `.mdc` 兼容探测层。
+- 不修改 Cursor `settings.json`、代理、模型、账号、API Key、钥匙串或 MCP 配置;现有 `127.0.0.1:7898` 环境不受影响。
+- 不把文件存在、Customize 列表、Rule 为 Always、Skill discovery 或模型自述当成行为通过。
+- 不覆盖已有 `exec_001`、`exec_002`、`verify_001`、`verify_002`、`verify_003` 或独立审计;新实施和验证追加新 receipt。
+- 显式 finalize 不在本轮范围。合并与 release 只在用户明确授权后执行。
+
+## Status / Progress
+
+- [x] 原 Cursor adapter、用户级 Hooks 与 baseline 修正已提交为 `d0e81f1`;既有自动化为 307 passed / 76 subtests。
+- [x] 用户确认采用“全局 Plugin 唯一薄入口 + 现有 Skills/payload/Hooks”的产品方向,并确认不新增 Hooks 框架。
+- [x] 完成独立 Cursor 审计的二次裁决:接受证据门与分层,驳回 `sopify.json` 唯一标记、`disable-model-invocation: true`、禁止 Plugin 正常复用权威资产三项过度修正。
+- [x] 2026-08-20 复核 Cursor 官方 Plugin、Rules、Hooks 与 CLI 文档,并把 CLI 未自动加载 Plugin Rule 的事实固化为支持边界。
+- [x] 当前 standard 方案与任务清单已按新决策收口,Ready to implement。
+- [x] 实施用户级 Plugin 安装面、Hook managed-root 启用、Doctor 与文档调整。
+- [x] 完成定向与全量自动化,保持其他 host 行为不变:308 passed / 78 subtests,compileall 与 `git diff --check` 通过。
+- [x] 唯一一轮 Cursor 独立复审已返回;批判裁决接受安装结构与 Hook D,关闭其沙箱 pytest 与代理快照误判,保留 IDE A/B/C 行为缺口。
+- [x] 按官方本地 Plugin 流程补充 Reload/Restart 安装提示与测试,重新本地安装;settings、CLI config、hooks 与系统代理哈希均未变化。
+- [x] 根据真实 Customize 空白详情页补充最薄双语 Plugin README;只介绍用途、工作方式与支持边界,不复制 Rule 或协议正文。真实 Reload 证明 Local 详情页不渲染 manifest description 或 README,用户已接受该宿主限制;README 保留为包内/发布文档,但不作为 Doctor 或 installed 健康硬门槛。
+- [-] CLI 自动入口验收已按产品决策关闭:2026-08-20 session `4c24eab4-dd82-46f3-8fd7-67888baa1a53` 反证自动 Plugin 入口未加载;显式 `--plugin-dir` session `7626c137-9668-450f-ab00-a44619f90f9c` 与唯一标记探针 `109d2991-78e6-46d8-a19a-5bd86f3198f0` 同样未加载 Rule。三份 machine truth 哈希均未变化。用户接受 CLI 低频手工 Skill 路径,因此不实施 launcher。
+- [x] 批判核验 Plugin 自动入口失败:包加载成功但 Plugin `ruleCount: 0`;“会遵循 Plugin”的回答前主动 Glob/Read 本地 Rule,不计注入证据。完成单变量 manifest 自动发现修复、22 tests / 2 subtests 定向验证、308 tests / 78 subtests 全量回归、compileall、diff check 与真实重装,受保护配置哈希未变。
+- [x] Reload 后用户范围的 Sopify Plugin Rule 在 Customize > Rules 中显示为 `sopify`、模式为 Always Apply;真实文件名为 `sopify.mdc`,Cursor Plugin 服务在 sopify、data-sprite 等窗口均报告 `ruleCount: 1`,目录自动发现修复成立。
+- [x] IDE consult 会话 `067ce461-c667-46fb-88ce-bffe4c4362a1` 只读取 Cursor shared-writing-dna 并执行只读 Git 查询;分支和 23 个 tracked / 6 个 untracked 变更与现场一致,未加载阶段 Skill、未续跑 active plan、未写 machine truth。
+- [x] IDE Analyze 会话 `7101b90b-ce5d-4acb-8fc3-930056f95e68` 读取 Cursor Analyze Skill、必要规则与输出契约,并实际运行 `score_requirement.py` 得到 `3/10, pass=false`;随后只追问,不进入 Design、不创建 plan/state/receipt。
+- [x] 真实 Cursor 安装原位切换为 `cursor:zh-CN`;Plugin 与 Skill 根各只有一份,英文 `Requirements Analysis` 模板无残留,hooks / CLI config 哈希及系统代理不变,settings 仍不存在。全量回归为 308 passed / 78 subtests,compileall 与 `git diff --check` 通过。
+- [x] 最薄 AskQuestion 扩展仅修改双语 Cursor Plugin Rule 与既有静态契约测试:有限选项按当前会话能力优先使用内建 AskQuestion,不经 MCP 探测,使用问卷自带自由输入并保留文本回退。会话 `84e9fa01-c2dc-4db5-9611-0f15c120bcb8` 保留了 AskQuestion tool use 与 `Tool not found` 回退;结构化问卷 UI 属人工观察,transcript 不含 tool result 或选项回传,因此不承诺跨模型稳定性。
+- [x] 临时仓库 `/private/tmp/sopify-cursor-managed-blackbox.WRxVtd` 完成真实 IDE managed 主链:首轮评分 `10/10`、选择 light、创建 plan 并经 writer 写 active plan/handoff 后停车;用户确认后修改唯一业务文件,经 writer 追加 `exec_001`、`verify_001` 与 handoff,方案保持 `ready_to_archive` 且未 finalize/commit。随后 Shell 直写 `active_plan.json` 被 Hook 拒绝,前后哈希一致。完整轨迹为 session `2448476f-57b8-4e1d-a522-c19e8202d658`。
+- [x] 2026-08-22 最终 Cursor 只读独立复审返回 `accept`:P0/P1 无;三个 P2 分别以真实安装对齐、AskQuestion 证据降级和 UI/文件证据分层关闭。
+- [x] README 宿主 badge、架构图与中英文产品形态 SVG 已同步 Cursor;仅修改全量宿主矩阵,封面、demo 与跨宿主场景图保持具体示例,不为凑齐宿主重绘。
+- [x] 独立视觉/文档复审最终返回 `accept`,P0/P1/P2 均无;其发现的 Cursor 验证断点、badge 导航和顶部跨宿主口径已做最小修正,未新增入口或机制。
+- [x] 最终候选回归通过:308 passed / 78 subtests,Python compileall、`bash -n install.sh`、SVG XML、视觉渲染与 `git diff --check` 均通过;真实 `cursor:zh-CN` 安装与候选逐字对齐,受保护配置及系统代理前后不变。
+- [x] Release 收口检查通过:Cursor 双语 Rule 已纳入版本同步、版本一致性检查和 pre-commit 回滚;README、宿主文档与 GitHub Pages 的双语数据已对齐。定向测试 33 passed,全量测试 311 passed / 78 subtests,隔离 Cursor 安装通过 Doctor。安装结果不替代 IDE/CLI 行为认证。
+
+## Next
+
+本方案保持 `ready_to_archive`;按用户授权合并 PR、发布 latest release 并验证稳定安装包。显式归档留给后续 `~go finalize`。
diff --git a/.sopify/plan/20260819_cursor_support/receipts/exec_001.json b/.sopify/plan/20260819_cursor_support/receipts/exec_001.json
new file mode 100644
index 00000000..72b0bc87
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/receipts/exec_001.json
@@ -0,0 +1,25 @@
+{
+ "evidence": {
+ "decision": "accept_with_modifications",
+ "modifications": [
+ "user_level_hooks_json",
+ "enable_only_when_project_rule_present",
+ "helper_fail_open",
+ "session_start_non_resume_clause",
+ "pretooluse_no_narrow_matcher",
+ "before_shell_execution",
+ "fix_baseline_audit_findings",
+ "keep_baseline_supported"
+ ],
+ "required_host_action_was": "confirm_decision"
+ },
+ "provenance": {
+ "host": "cursor",
+ "plan_id": "20260819_cursor_support",
+ "plan_version": "sha256:52de3f2cb9a976b28201b1fe978c05c6dae0268fb8a64724b27623a398e21518",
+ "receipt_id": "exec_001",
+ "source": "user_decision"
+ },
+ "timestamp": "2026-08-19T06:08:17+00:00",
+ "verdict": "decision_accepted_with_modifications"
+}
diff --git a/.sopify/plan/20260819_cursor_support/receipts/exec_002.json b/.sopify/plan/20260819_cursor_support/receipts/exec_002.json
new file mode 100644
index 00000000..8ecd23ba
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/receipts/exec_002.json
@@ -0,0 +1,21 @@
+{
+ "evidence": {
+ "decision": "authorized",
+ "finalize_authorized": false,
+ "operations": [
+ "commit feat/cursor-support locally",
+ "switch /Users/weixin.li/code/github/sopify to feat/cursor-support",
+ "remove /Users/weixin.li/.codex/worktrees/f74d/sopify"
+ ],
+ "push_authorized": false
+ },
+ "provenance": {
+ "host": "codex",
+ "plan_id": "20260819_cursor_support",
+ "plan_version": "sha256:d45f120c339224588ce6562b9d96f183ac6f4f7bf0969d3507b65a0f5e9b7ffe",
+ "receipt_id": "exec_002",
+ "source": "user_decision"
+ },
+ "timestamp": "2026-08-19T08:30:57+00:00",
+ "verdict": "local_commit_switch_and_worktree_removal_authorized_no_push"
+}
diff --git a/.sopify/plan/20260819_cursor_support/receipts/verify_001.json b/.sopify/plan/20260819_cursor_support/receipts/verify_001.json
new file mode 100644
index 00000000..70e8505b
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/receipts/verify_001.json
@@ -0,0 +1,38 @@
+{
+ "evidence": {
+ "black_box": {
+ "cursor_cli_behavior": "BLACK_BOX_NOT_VERIFIED",
+ "cursor_ide_behavior": "BLACK_BOX_NOT_VERIFIED",
+ "open_workspace_has_project_rule": false,
+ "real_home_has_sopify_cursor_assets": false,
+ "reason_code": "human_action_required"
+ },
+ "command": "python3 -m pytest tests",
+ "committed": false,
+ "pushed": false,
+ "pytest": {
+ "passed": 293,
+ "subtests": 73
+ },
+ "reason_code": null,
+ "result": "passed",
+ "retry_count": 0,
+ "review_result": {
+ "code_quality": "pass_with_known_fail_open_limits",
+ "spec_compliance": "pass_with_residual_black_box"
+ },
+ "root_cause": null,
+ "scope": "tasks 3.1-3.4, 4.2; installer/cursor_hook.py, installer/cursor_hooks.py, installer/inspection.py, installer/payload.py, installer/hosts/cursor.py, tests/test_cursor_hooks.py",
+ "support_tier": "baseline_supported",
+ "verification_source": "project_native"
+ },
+ "provenance": {
+ "host": "cursor",
+ "plan_id": "20260819_cursor_support",
+ "plan_version": "sha256:5f1d69c2e7a3887d62947f95f308d0f8db961c159dacfa09cd5fc0620f71f1da",
+ "receipt_id": "verify_001",
+ "source": "implementation_verify"
+ },
+ "timestamp": "2026-08-19T06:37:45+00:00",
+ "verdict": "implementation_complete_black_box_pending"
+}
diff --git a/.sopify/plan/20260819_cursor_support/receipts/verify_002.json b/.sopify/plan/20260819_cursor_support/receipts/verify_002.json
new file mode 100644
index 00000000..6682dcb6
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/receipts/verify_002.json
@@ -0,0 +1,50 @@
+{
+ "evidence": {
+ "command": "python3 -m pytest tests",
+ "committed": false,
+ "compileall": "passed",
+ "cursor_cli": {
+ "analyze_score_gate": "not_executed",
+ "consult_machine_truth_writes": 0,
+ "consult_semantic_routing": "not_clean",
+ "no_space_redirect_denied": true,
+ "no_space_redirect_session": "40d6cbc5-5486-4df8-80bf-bbdb50dc8c19",
+ "status": "partial"
+ },
+ "cursor_ide": {
+ "status": "BLACK_BOX_NOT_VERIFIED"
+ },
+ "diff_check": "passed",
+ "machine_truth_hashes_unchanged": true,
+ "multi_root_session_start": {
+ "automated": "passed",
+ "real_host_black_box": "not_run"
+ },
+ "pushed": false,
+ "pytest": {
+ "passed": 307
+ },
+ "reason_code": null,
+ "result": "passed",
+ "retry_count": 0,
+ "review_result": {
+ "code_quality": "pass_minimal_heuristic_guard",
+ "independent_review_before_fix": "no_p0_p1_three_p2",
+ "spec_compliance": "pass_with_declared_behavior_gaps"
+ },
+ "root_cause": null,
+ "scope": "independent-review P2 fixes: installation wording, multi-root sessionStart selection, no-space shell redirection",
+ "settings_sha256_before_after": "9312893735cb53aa1a8acece3f9adde34dcc8128e3d03c1a72f7ec423344f1e5",
+ "support_tier": "baseline_supported",
+ "verification_source": "project_native_and_cursor_cli"
+ },
+ "provenance": {
+ "host": "codex",
+ "plan_id": "20260819_cursor_support",
+ "plan_version": "sha256:3386c563b18ae7a154a9c980bd10370e27ef30820999c04d1972af645c7accfb",
+ "receipt_id": "verify_002",
+ "source": "implementation_verify"
+ },
+ "timestamp": "2026-08-19T08:02:11+00:00",
+ "verdict": "cursor_baseline_p2_fixed_cli_partial_ide_pending"
+}
diff --git a/.sopify/plan/20260819_cursor_support/receipts/verify_003.json b/.sopify/plan/20260819_cursor_support/receipts/verify_003.json
new file mode 100644
index 00000000..f05d9af4
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/receipts/verify_003.json
@@ -0,0 +1,50 @@
+{
+ "evidence": {
+ "command": "python3 -m pytest tests -q",
+ "committed": false,
+ "compileall": "passed",
+ "cursor_cli": {
+ "analyze_score_gate": "not_executed",
+ "command": "echo hacked>.sopify/plan/blackbox_plan/receipts/exec_001.json",
+ "consult_semantic_routing": "not_clean",
+ "persistent_transcript_contains_tool_result": false,
+ "receipt_redirect_session": "3a13cbf2-d184-488e-9331-25e7a7510365",
+ "receipt_sha256_unchanged": "00c09422e88cc03dbeee39c2a267884a2bf9f46f9594a5a6d63352debe5a7970",
+ "status": "partial",
+ "stream_json_tool_result": "completed_rejected_by_hook"
+ },
+ "cursor_ide": {
+ "status": "BLACK_BOX_NOT_VERIFIED"
+ },
+ "diff_check": "passed",
+ "fix": "add > to the existing receipt command prefix boundary; no shell parser or new abstraction",
+ "machine_truth_hashes_unchanged": true,
+ "pushed": false,
+ "pytest": {
+ "passed": 307,
+ "subtests_passed": 76
+ },
+ "reason_code": null,
+ "result": "passed",
+ "retry_count": 0,
+ "review_result": {
+ "code_quality": "minimal_boundary_change",
+ "independent_review_before_fix": "no_p0_p1_one_open_p2",
+ "spec_compliance": "receipt_p2_fixed_with_declared_behavior_gaps"
+ },
+ "root_cause": "RECEIPT_COMMAND_RE did not accept output redirection as a path boundary",
+ "scope": "receipt path immediately following shell output redirection",
+ "settings_sha256_unchanged": "9312893735cb53aa1a8acece3f9adde34dcc8128e3d03c1a72f7ec423344f1e5",
+ "support_tier": "baseline_supported",
+ "verification_source": "project_native_and_cursor_cli_stream_json"
+ },
+ "provenance": {
+ "host": "cursor",
+ "plan_id": "20260819_cursor_support",
+ "plan_version": "sha256:5980ff839636ac70ef10d0fe01ffc565b709ac6f6de315ef3c0c327363b1bfa1",
+ "receipt_id": "verify_003",
+ "source": "implementation_verify_after_independent_review"
+ },
+ "timestamp": "2026-08-19T08:26:34+00:00",
+ "verdict": "receipt_redirect_fixed_cli_rejected_remaining_black_box_gaps"
+}
diff --git a/.sopify/plan/20260819_cursor_support/receipts/verify_004.json b/.sopify/plan/20260819_cursor_support/receipts/verify_004.json
new file mode 100644
index 00000000..4f6b48de
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/receipts/verify_004.json
@@ -0,0 +1,81 @@
+{
+ "evidence": {
+ "automated_verification": {
+ "bash_n_install_sh": "passed",
+ "git_diff_check": "passed",
+ "pytest": {
+ "passed": 308,
+ "subtests_passed": 78
+ },
+ "python_compileall": "passed",
+ "svg_visual_render": "passed",
+ "svg_xml": "passed"
+ },
+ "committed": false,
+ "cursor_cli": {
+ "automatic_plugin_rule": "unsupported",
+ "manual_skill_path": "documented_compatibility_surface"
+ },
+ "cursor_ide": {
+ "analyze_score_gate": "score_script_executed",
+ "askquestion": "capability_path_observed_with_text_fallback",
+ "consult_readonly": "observable_read_only_tool_trace",
+ "hook_direct_write": "denied_with_hash_unchanged",
+ "managed_chain": "analyze_design_stop_then_develop_observed",
+ "rule_registration": "always_apply_observed",
+ "writer_boundary": "state_handoff_receipts_written_via_library_api"
+ },
+ "doctor": {
+ "cli_behavior": "BLACK_BOX_NOT_VERIFIED",
+ "ide_behavior": "BLACK_BOX_NOT_VERIFIED",
+ "install_surface": "passed"
+ },
+ "finalized": false,
+ "implementation": {
+ "extra_runtime_or_launcher": false,
+ "hooks": "user_level_fail_open_guard",
+ "ide_entry": "user_plugin_always_rule",
+ "payload": "user_level_versioned_bundle",
+ "project_rule": "removed",
+ "skills": "global_five_skill_tree"
+ },
+ "independent_reviews": {
+ "cursor_implementation": {
+ "p0": 0,
+ "p1": 0,
+ "verdict": "accept"
+ },
+ "visual_and_docs": {
+ "p0": 0,
+ "p1": 0,
+ "p2": 0,
+ "verdict": "accept"
+ }
+ },
+ "pushed": false,
+ "real_install": {
+ "candidate_assets_exact_match": true,
+ "protected_cursor_configuration_unchanged": true,
+ "system_proxy_unchanged": true
+ },
+ "released": false,
+ "result": "passed",
+ "support_tier": "BASELINE_SUPPORTED",
+ "unverified_boundaries": [
+ "independent_sessionStart_black_box",
+ "explicit_finalize_black_box",
+ "AskQuestion_cross_model_stability",
+ "Cloud_Agent"
+ ]
+ },
+ "provenance": {
+ "host": "codex",
+ "plan_id": "20260819_cursor_support",
+ "plan_version": "sha256:09e5ddd1ef473a0d410f65c882bdc56ccf0aebc3f85c038479c402293699e4dd",
+ "receipt_id": "verify_004",
+ "reviewer": "cursor",
+ "source": "final_closeout_after_cursor_and_visual_independent_reviews"
+ },
+ "timestamp": "2026-08-22T02:46:58+00:00",
+ "verdict": "cursor_baseline_supported_release_candidate_verified"
+}
diff --git a/.sopify/plan/20260819_cursor_support/receipts/verify_005.json b/.sopify/plan/20260819_cursor_support/receipts/verify_005.json
new file mode 100644
index 00000000..c951af91
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/receipts/verify_005.json
@@ -0,0 +1,45 @@
+{
+ "evidence": {
+ "command": "python3 -m pytest -p no:cacheprovider tests -q",
+ "committed": false,
+ "finalized": false,
+ "git_diff_check": "passed",
+ "implementation_changed": false,
+ "independent_audit": {
+ "p0": 0,
+ "p1": 0,
+ "p2_resolved": [
+ "AskQuestion persisted evidence separated from manual UI observation",
+ "Plugin Rule separated from native User Rule terminology"
+ ],
+ "sessionStart": "declared_unverified_boundary_no_code_change",
+ "verdict": "accept"
+ },
+ "pushed": false,
+ "pytest": {
+ "passed": 308,
+ "subtests_passed": 78
+ },
+ "reason_code": null,
+ "released": false,
+ "result": "passed",
+ "retry_count": 0,
+ "review_result": {
+ "code_quality": "documentation_only_no_new_entry_or_runtime",
+ "spec_compliance": "evidence claims now match transcript and Cursor Plugin terminology"
+ },
+ "scope": "AskQuestion evidence wording and user-scope Plugin Rule terminology",
+ "support_tier": "BASELINE_SUPPORTED",
+ "verification_source": "project_native"
+ },
+ "provenance": {
+ "host": "codex",
+ "plan_id": "20260819_cursor_support",
+ "plan_version": "sha256:482b990ac2ca05fd7a37092e7d2f5f83957fe09e2bfb670b05fa80966d840671",
+ "receipt_id": "verify_005",
+ "reviewer": "external_cursor",
+ "source": "post_commit_independent_audit_documentation_cleanup"
+ },
+ "timestamp": "2026-08-22T03:41:09+00:00",
+ "verdict": "cursor_release_docs_evidence_terminology_aligned"
+}
diff --git a/.sopify/plan/20260819_cursor_support/receipts/verify_006.json b/.sopify/plan/20260819_cursor_support/receipts/verify_006.json
new file mode 100644
index 00000000..6284e956
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/receipts/verify_006.json
@@ -0,0 +1,30 @@
+{
+ "evidence": {
+ "behavior_boundary": "IDE and CLI behavior checks remain separate from file installation checks",
+ "committed": false,
+ "full_tests": "309 passed, 78 subtests passed",
+ "git_diff_check": "passed",
+ "isolated_cursor_install": "Plugin, Rule, Skills, payload and Hooks passed Doctor",
+ "merged": false,
+ "pushed": false,
+ "python_syntax": "54 files parsed",
+ "release_sync_replay": "four source templates updated to one version",
+ "released": false,
+ "scope": "Cursor release version sync and public installation text",
+ "shell_syntax": "passed",
+ "support_tier": "BASELINE_SUPPORTED",
+ "svg_xml": "passed",
+ "targeted_tests": "33 passed",
+ "version_consistency": "passed"
+ },
+ "provenance": {
+ "host": "codex",
+ "plan_id": "20260819_cursor_support",
+ "plan_version": "sha256:1e7865f1abbe2479326ccbd15de8741058050dd4e232ea700ad2c39939e93701",
+ "receipt_id": "verify_006",
+ "reviewer": "codex_release_preflight",
+ "source": "final_worktree_commands_and_file_review"
+ },
+ "timestamp": "2026-08-22T05:37:02+00:00",
+ "verdict": "release_preflight_passed"
+}
diff --git a/.sopify/plan/20260819_cursor_support/receipts/verify_007.json b/.sopify/plan/20260819_cursor_support/receipts/verify_007.json
new file mode 100644
index 00000000..0536eff8
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/receipts/verify_007.json
@@ -0,0 +1,33 @@
+{
+ "evidence": {
+ "behavior_boundary": "IDE and CLI behavior checks remain separate from file installation checks",
+ "committed": false,
+ "full_tests": "311 passed, 78 subtests passed",
+ "git_diff_check": "passed",
+ "isolated_cursor_install": "Plugin, Rule, Skills, payload and Hooks passed Doctor",
+ "merged": false,
+ "pages_content": "Cursor appears in both language host lists, install support text and FAQ",
+ "pages_visual_check": "desktop home and Chinese mobile layouts rendered with existing styles",
+ "pushed": false,
+ "python_syntax": "54 files parsed",
+ "release_sync_replay": "four source templates updated to one version",
+ "released": false,
+ "scope": "Cursor release candidate including GitHub Pages",
+ "shell_syntax": "passed",
+ "static_page_tests": "20 passed",
+ "support_tier": "BASELINE_SUPPORTED",
+ "svg_xml": "passed",
+ "targeted_release_tests": "33 passed",
+ "version_consistency": "passed"
+ },
+ "provenance": {
+ "host": "codex",
+ "plan_id": "20260819_cursor_support",
+ "plan_version": "sha256:2df4073325e3cf657226230f58b37479028d74375becc9a4d625ec5d34c4fb53",
+ "receipt_id": "verify_007",
+ "reviewer": "codex_release_candidate",
+ "source": "final_worktree_commands_file_review_and_local_browser_render"
+ },
+ "timestamp": "2026-08-22T05:46:06+00:00",
+ "verdict": "release_candidate_passed"
+}
diff --git a/.sopify/plan/20260819_cursor_support/tasks.md b/.sopify/plan/20260819_cursor_support/tasks.md
new file mode 100644
index 00000000..90352a13
--- /dev/null
+++ b/.sopify/plan/20260819_cursor_support/tasks.md
@@ -0,0 +1,77 @@
+# 任务清单: Sopify Cursor 宿主支持
+
+目录: `.sopify/plan/20260819_cursor_support/`
+
+## 1. 用户级 Plugin 入口
+
+- [x] 1.1 将 Cursor adapter 从项目 Rule 安装面调整为 Cursor-specific 用户 Plugin 安装面;移除 `--workspace` 必需条件,不影响其他 host 的 instruction surface。
+ - 产物:Cursor registration/capability 与最小安装路径。
+ - 验收:`--target cursor` 在未传 workspace 时可安装;Codex、Claude、Qoder、Copilot adapter 测试不变。
+- [x] 1.2 增加 `.cursor-plugin/plugin.json`、渐进式双语 `README.md` 与单一 `rules/sopify.mdc` 资产,由现有双语权威内容生成/安装到 `~/.cursor/plugins/local/sopify/`。
+ - 产物:用户级 Plugin manifest、用户可见简介与薄 Always Rule。
+ - 验收:Rule 有合法 frontmatter、意图分类、精确 Skill 路由、Analyze 评分门、按当前会话能力使用 AskQuestion 并安全回退、四步入口和 writer-only 边界;Plugin 不含 Commands、Agents、MCP、Hooks 或私有 Skills 树。
+- [x] 1.3 删除项目 `.cursor/rules/sopify.mdc` 的产品安装路径、双语项目 Rule 模板及本仓库临时规则;不建立 fallback 或兼容探测层。
+ - 依赖:1.2 的隔离安装测试完成;最终删除受 5.2 CLI 发布门约束。
+ - 验收:新安装不会写目标仓库 `.cursor/`;仓库不需要逐个安装 Sopify Rule。
+
+## 2. 安装、状态与 Doctor
+
+- [x] 2.1 调整 Cursor 安装流程:先预检已有 `~/.cursor/hooks.json`,再以同一次安装写 Plugin、全局五项 Skills/shared references、payload/helper 与用户级 Hooks。
+ - 验收:非法 hooks JSON 时在首次产品写入前停止且原文件不变;重复安装幂等;不修改 `settings.json`、代理、模型、账号、API Key、钥匙串或 MCP。
+- [x] 2.2 将 Cursor 安装结果、CLI help、distribution 文案从“项目规则落点”改为“用户级 Plugin + 全局资产”;移除 Cursor 的 workspace/bootstrap 暗示。
+ - 验收:中英文输出只声明文件安装事实,先提示 Reload/Restart,再提示在 Cursor Customize 中确认 Rule 为 Always;不声称 IDE/CLI 已执行。
+- [x] 2.3 将 Doctor/status 的 `project_rule_present` 改为 Cursor Plugin/Rule 安装检查,并继续独立检查 Skill 树、payload、Hooks、IDE/CLI behavior。
+ - 验收:仅有 Cursor IDE settings 时 host 为 absent/skip;缺 Plugin Rule、Skill、payload 或 Hooks 时准确失败;行为项仍为 `BLACK_BOX_NOT_VERIFIED`,不因安装完整自动通过。
+
+## 3. 复用用户级 Hooks
+
+- [x] 3.1 移除 Hook helper 对项目 `.cursor/rules/sopify.mdc` 的依赖;按事件选择 `.sopify/` managed root:文件工具看目标路径,Shell 看 `cwd`,sessionStart 看唯一候选 workspace。
+ - 验收:无 `.sopify/` 的普通仓库 no-op;父目录包含多个 Git 仓时不默认取第一个;目标明确落在某个子仓时只保护该仓。
+- [x] 3.2 保持 sessionStart 非恢复语义,只有 active plan 指针、对应语义文件和匹配 handoff 有效时才注入事实;目录存在、残留 state 或多根歧义均不注入方案动作。
+ - 验收:consult 不因快照自动 continue;handoff plan_id 不匹配时 action 为 none;latest receipt 仍按协议 timestamp/fallback 选择。
+- [x] 3.3 保持现有 `preToolUse` 与 `beforeShellExecution` 最小写保护、`sopify_writer` 放行和 fail-open 行为,不新增 Hook 类型或完整 Shell parser。
+ - 验收:Write/StrReplace 与明显 Shell 重定向不能直写 state/handoff/receipt;`plan.md` 与正常 writer API 调用放行;helper 异常由 Doctor 报告但不阻塞 Cursor。
+
+## 4. 契约、文档与回归
+
+- [x] 4.1 更新 Cursor capability、registry/catalog、README、`docs/cursor-host.md`、install.sh/install.ps1 帮助和 distribution 文案,统一描述“用户级 Plugin 唯一语义入口 + 全局 Skills/payload + 用户 Hooks”。
+ - 验收:不再出现每仓安装项目 Rule、Plugin 已等价覆盖 CLI、文件存在等于行为通过等过强口径。
+ - 视觉同步:README 顶部 badge、架构图和中英文产品形态图已加入 Cursor baseline;具体场景图保持示例语义,不扩写为宿主矩阵。
+ - 独立视觉/文档复审:修复 Cursor 用户级安装后的 Verify Setup 断点、badge 导航和顶部跨宿主过强口径后返回 `accept`;P0/P1/P2 均无。
+- [x] 4.2 同步 `.sopify/blueprint/` 中受影响的宿主架构与任务索引,不扩写与 Cursor 无关的协议内容。
+ - 验收:长期文档与本方案在入口、Hook 边界、支持档位和证据门上无冲突。
+- [x] 4.3 补 installer、distribution、Doctor、Plugin 资产与 Hook root-selection 测试,并重跑相关定向测试和全量 `python3 -m pytest tests -q`。
+ - 验收:全量通过,`git diff --check` 与 Python compile 检查通过;其他四个 host 的安装快照与行为不变。
+
+## 5. 黑盒与交付
+
+- [x] 5.1 在隔离 home 安装 Cursor,证明 Plugin manifest/Rule、Skills、payload、helper、Hooks 路径正确,目标仓库没有 `.cursor/rules/sopify.mdc`,Cursor settings 与代理相关文件哈希不变。
+- [-] 5.2 在不含项目 `.mdc` 的临时仓库运行真实 Cursor Agent CLI 自动入口验收。
+ - 原自动入口判据:观察全局 Plugin 是否生效,以及 consult、Analyze 与 Hooks 的工具轨迹和文件哈希;该能力现已明确不在产品承诺内。
+ - 2026-08-20 自动入口实测失败:session `4c24eab4-dd82-46f3-8fd7-67888baa1a53` 未加载用户 Plugin Rule,实际读取 `~/.claude/skills/sopify/kb/SKILL.md` 并在 consult 中读取 active plan/handoff/receipt;三份 machine truth 哈希未变。
+ - 2026-08-20 纯 launcher 前置门失败:显式 `--plugin-dir ~/.cursor/plugins/local/sopify` 的 session `7626c137-9668-450f-ab00-a44619f90f9c` 未读取 Cursor shared-writing-dna,仍读取旧 `~/.claude/skills/sopify/develop/SKILL.md` 与 managed plan/state;唯一标记 Cursor Plugin 探针 session `109d2991-78e6-46d8-a19a-5bd86f3198f0` 未读取 marker 且直接回复 `OK`。依据“验证后实施”决策,不增加无效 launcher。
+ - 产品收口:用户接受 IDE/CLI 能力不对称;CLI 低频使用时手工调用已安装 Skill,不承诺自动 Plugin Rule,本自动入口任务按范围取消。
+- [x] 5.3 经用户授权安装到真实 `~/.cursor`,在 Cursor IDE 单独验证 Customize 中 Rule 为 Always、consult/analyze 路由与 direct-write deny;CLI 证据不得外推 IDE。
+ - 真实安装与 2026-08-20 重装已完成;settings、CLI config、hooks 与代理哈希安装前后不变。Plugin 可见已通过;Local 详情页不渲染 description/README 的宿主限制已由用户接受,不再作为行为门。
+ - 2026-08-20 首轮 Rule 复验失败:Customize Rules 为空,日志显示 Plugin `ruleCount: 0`,相关回答由 Agent 主动读取 Rule 后产生。已删除 manifest 的显式 `rules` 字段、保留 `rules/sopify.mdc` 目录自动发现并完成回归与真实重装;等待 Reload 后复验,不增加 fallback。
+ - 2026-08-20 Reload 复验通过:Customize 在 User 范围显示 `sopify`,打开后真实文件为 `rules/sopify.mdc` 且为 Always Apply;多个最新窗口日志中的 Cursor Plugin 服务均为 `ruleCount: 1`。这关闭注册门,不外推 consult/Analyze 行为。
+ - 2026-08-20 consult 会话 `067ce461-c667-46fb-88ce-bffe4c4362a1` 只读取 Cursor shared-writing-dna 和只读 Git 状态;Analyze 会话 `7101b90b-ce5d-4acb-8fc3-930056f95e68` 读取精确 Cursor Skill 并运行评分脚本得到 `3/10, pass=false`,两轮均无写入。
+ - 2026-08-20 原位切换 `cursor:zh-CN` 后只有一个 Plugin 根和一个 Skill 根,英文阶段模板无残留;保护配置与系统代理不变。`sessionStart` 只保留自动化与既有补充线索,不把模型转述升级为独立行为通过。
+ - 2026-08-20 AskQuestion baseline 证据完成:会话 `84e9fa01-c2dc-4db5-9611-0f15c120bcb8` 保留 AskQuestion tool use 与 `Tool not found` 回退。UI 卡片属于人工观察,transcript 不含 tool result 或选项回传;本期不承诺每个模型/会话都提供。
+ - 2026-08-22 managed 黑盒完成:临时仓库 `/private/tmp/sopify-cursor-managed-blackbox.WRxVtd` 的 session `2448476f-57b8-4e1d-a522-c19e8202d658` 完成 Analyze → Design → 停车 → Develop,业务文件精确变更,state/handoff/receipt 只经 writer 写入;直接 Shell 改写 active plan 被 Hook 拒绝且哈希不变,未 finalize/commit。
+- [x] 5.4 更新现有独立审计包,交 Cursor 只读复审;通过 `sopify_writer` 追加新的 verify receipt,不覆盖历史 receipt。
+ - 前置 IDE baseline 证据已齐:Rule/consult/Analyze/AskQuestion/managed develop/writer/Hook 分别保留工具轨迹或文件结果;模型自述不单独计证据。
+ - 2026-08-22 最终候选验证:308 passed / 78 subtests,Python compileall、`bash -n install.sh` 与 `git diff --check` 均通过。
+ - 最终复审返回 `accept`,P0/P1 无。其三个 P2 已以真实安装与候选对齐、AskQuestion 口径降级和 UI/文件证据分层关闭;后续独立审计的两处术语修正由 `verify_005` 绑定最终方案版本。
+- [x] 5.5 保持 `BASELINE_SUPPORTED`,清楚记录自动化、CLI、IDE 各自证据与缺口。
+ - 独立 sessionStart、显式 finalize、AskQuestion 跨模型稳定性、CLI 自动 Plugin Rule 与 Cloud Agent 均记录为未验证或不支持边界,不阻塞本次 baseline 收口。
+
+## 6. Release 收口修正
+
+- [x] 6.1 将双语 Cursor Plugin Rule 的 `SOPIFY_VERSION` 纳入现有 release-sync、版本一致性检查和 pre-commit 回滚/暂存,不新增版本机制。
+- [x] 6.2 将 Cursor 用户文档与宿主矩阵改为显式语言 target,并提供 stable release 一键安装命令。
+- [x] 6.3 运行定向与全量回归,并追加新的 verify receipt,确认当前提交可以进入合并与发布步骤。
+ - 结果:定向测试 33 passed;全量测试 311 passed / 78 subtests;Python 语法、Shell 语法、SVG XML 与 `git diff --check` 均通过。
+ - 安装检查:隔离 home 中的 Cursor Plugin、Rule、Skills、payload 和 Hooks 均通过 Doctor;IDE/CLI 行为项仍保持未认证,不从文件安装结果外推。
+- [x] 6.4 同步 GitHub Pages 的中英文宿主列表、安装支持范围和 FAQ;复用现有标签与响应式布局,不新增页面结构或样式机制。
+ - 结果:双语内容测试 20 passed;桌面首页与中文移动端完成本地浏览器渲染检查,Cursor 标签沿用现有 baseline 样式。
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 35204841..13302a2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,20 @@ Format: Summary → Changed → Plan Packages. File-level details live in `git l
## [Unreleased]
+## [2026-08-22.134737] - 2026-08-22
+
+### Summary
+
+Added baseline support for local Cursor IDE without per-project Sopify setup.
+
+### Changed
+
+- **Cursor host**: Added a user Plugin entry, global Skills/payload, user Hooks, and matching GitHub Pages content. Local IDE evidence covers routing, planning, writer writes, and direct-write rejection. Agent CLI automatic entry and Cloud Agent remain outside scope.
+
+### Plan Packages
+
+- `20260819_cursor_support` → release candidate; explicit archive remains a later `~go finalize` action
+
## [2026-07-21] - 2026-07-21
### Summary
diff --git a/README.md b/README.md
index ea217fe6..f23f0ac9 100644
--- a/README.md
+++ b/README.md
@@ -6,13 +6,14 @@
[](./LICENSE)
[](./LICENSE-docs)
-[](#version-history)
+[](#version-history)
[](./CONTRIBUTING.md)
[](#quick-start)
[](#quick-start)
[](#quick-start)
[](#quick-start)
+[](./docs/cursor-host.md)
English · [简体中文](./README.zh-CN.md) · [Quick Start](#quick-start) · [Contributors](./CONTRIBUTORS.md)
@@ -26,14 +27,14 @@ English · [简体中文](./README.zh-CN.md) · [Quick Start](#quick-start) · [
AI coding tools are fast. But when they jump to code before the facts are clear, speed turns into rework. Sopify is a development process protocol layer for AI coding: in managed workflows, the host asks before coding when requirements are incomplete or a decision still needs you.
-Sopify stores plans and verification receipts in `.sopify/` as project files tracked by git. Only the local resume pointers stay out of git. In the same repo, explicitly ask to continue or use `~go` to resume managed work from those files on any supported host.
+Sopify stores plans and verification receipts in `.sopify/` as project files tracked by git. Only the local resume pointers stay out of git. In the same repo, use the managed entry supported by your current host to resume from those files; entry shape and verification level stay explicit in the host support matrix.
-No new editor, no new CLI. Install into the host you already use — Codex, Claude, Qoder, or Copilot.
+No new editor, no new CLI. Install into the host you already use — Codex, Claude, Qoder, Copilot, or Cursor.
**Design principles:**
- **Stop when unsure** — score every requirement; ask before assuming
-- **Resume from anywhere** — plans and verification receipts are tracked in `.sopify/`; open the repo on any host and pick up where you left off
+- **Resume across managed hosts** — plans and verification receipts are tracked in `.sopify/`; hosts with continuation support can pick up where you left off
- **Trace every decision** — plans, choices, and reviews persist in `.sopify/`
**What Sopify prevents:**
@@ -99,7 +100,7 @@ The host LLM executes. Sopify supplies shared workflow rules and preserves plans
Sopify does three things:
-- **Shared workflow rules** — supported hosts load the same Sopify workflow rules, so switching hosts does not change the process and boundaries you already confirmed
+- **Shared workflow protocol** — each adapter delivers the same protocol through the host surfaces it supports; entry mode and verification level remain explicit
- **Git-tracked project record** — plans, decisions, and verification evidence travel with the repository; local pointers do not
- **Explicit resume and closeout** — ask to continue or use `~go` before the host resumes; `~go finalize` archives the plan only after delivery evidence is ready
@@ -137,8 +138,9 @@ Host support:
| Claude | PROTOCOL_VERIFIED | `claude:en-US` / `claude:zh-CN` | Full capability continuation |
| Qoder | PROTOCOL_VERIFIED | `qoder` | Validated on Qoder CLI |
| Copilot | BASELINE_SUPPORTED | `copilot:en-US` / `copilot:zh-CN` | Prompt-only; payload uplift planned |
+| [Cursor](docs/cursor-host.md) | BASELINE_SUPPORTED | `cursor:en-US` / `cursor:zh-CN` | IDE user Plugin + global Skills/payload + user hooks |
-Pass `--workspace ` to target another repo, `--language ` to control output language.
+Pass `--workspace ` to target another repo for workspace-scope or prewarm installs; Cursor installs once at user scope and does not require it. Use `--language ` to control output language.
[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
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 1cee5fa3..bba8fede 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -6,13 +6,14 @@
[](./LICENSE)
[](./LICENSE-docs)
-[](#版本历史)
+[](#版本历史)
[](./CONTRIBUTING_CN.md)
[](#快速开始)
[](#快速开始)
[](#快速开始)
[](#快速开始)
+[](./docs/cursor-host.md)
[English](./README.md) · 简体中文 · [快速开始](#快速开始) · [贡献者](./CONTRIBUTORS.md)
@@ -26,14 +27,14 @@
AI 工具写代码很快。但在事实没搞清、关键决策还没拍板前就直接动手,快就会变成返工。Sopify 是 AI 编程的开发过程协议层:在托管流程里,需求不全或决策未定时,宿主会先追问,再写代码。
-Sopify 把方案和验证收据保存在 `.sopify/` 中,作为可纳入 git 的项目文件;只有恢复用的本地指针不进 git。在同一个仓库中,明确说“继续”或使用 `~go`,任一受支持的宿主都可以读取这些文件,从上次停点恢复托管任务。
+Sopify 把方案和验证收据保存在 `.sopify/` 中,作为可纳入 git 的项目文件;只有恢复用的本地指针不进 git。在同一个仓库中,通过当前宿主已支持的托管入口从这些文件接续;具体入口与验证档位以宿主支持矩阵为准。
-无需新编辑器、无需新 CLI。安装到你已有的宿主:Codex、Claude、Qoder、Copilot 均支持。
+无需新编辑器、无需新 CLI。安装到你已有的宿主:Codex、Claude、Qoder、Copilot、Cursor 均支持。
**设计原则:**
- **不确定就停下** — 需求不全时先追问,再动手
-- **随时恢复** — 方案和验证收据都持久保存在 `.sopify/` 里;换宿主、换机器、换人接手都能从项目状态继续
+- **跨托管宿主恢复** — 方案和验证收据都持久保存在 `.sopify/` 里;支持接续的宿主可从项目状态继续
- **决策留痕** — 方案、取舍、审查持久保存在 `.sopify/`
**Sopify 主要在防什么:**
@@ -98,7 +99,7 @@ Sopify 需要 Python 3.11 或更高版本;安装器会在下载前检查。这
Sopify 主要做三件事:
-- **共享工作流规则** — 支持的宿主加载同一套 Sopify 工作流规则,换宿主也不会改变已经确认的流程和边界
+- **共享工作流协议** — 各适配器通过宿主已支持的入口交付同一协议;入口形态与验证档位保持显式
- **项目记录随 Git 携带** — 方案、决策和验证证据跟着仓库走,本地指针不跟随
- **明确接续与收口** — 说“继续”或输入 `~go` 后,宿主才会恢复;交付证据就绪后,`~go finalize` 才归档方案
@@ -136,8 +137,9 @@ Get-Content sopify-install.ps1 | more
| Claude | PROTOCOL_VERIFIED | `claude:zh-CN` / `claude:en-US` | 全能力接续 |
| Qoder | PROTOCOL_VERIFIED | `qoder` | 已在 Qoder CLI 验证 |
| Copilot | BASELINE_SUPPORTED | `copilot:zh-CN` / `copilot:en-US` | 仅 prompt;payload 升级计划中 |
+| [Cursor](docs/cursor-host.md) | BASELINE_SUPPORTED | `cursor:zh-CN` / `cursor:en-US` | IDE 用户 Plugin + 全局 Skills/payload + 用户 Hooks |
-可用 `--workspace ` 指定目标仓库,`--language ` 控制输出语言。
+可用 `--workspace ` 指定目标仓库,`--language ` 控制输出语言;Cursor 为用户级一次安装,不需要 `--workspace`。
[EvidentLoop](https://github.com/evidentloop/evidentloop) 是一个可选的代码变更审计工具,
可基于本地 Git diff 生成交互式报告,将审计发现定位到具体修改行,并支持用户通过反馈
diff --git a/assets/sopify-architecture.svg b/assets/sopify-architecture.svg
index 3689b3ff..4cebe173 100644
--- a/assets/sopify-architecture.svg
+++ b/assets/sopify-architecture.svg
@@ -30,24 +30,28 @@
HOST LAYER
Host LLM executes code; Sopify preserves the process as files
-
-
- Codex
+
+
+ Codex
-
-
- Claude
+
+
+ Claude
-
-
- Qoder
+
+
+ Qoder
-
-
- Copilot
+
+
+ Copilot
-
- Other adapters
+
+
+ Cursor
+
+
+ Other adapters
@@ -163,5 +167,5 @@
Convention mode only — host self-drives, writer validates
- Sopify · 2026-06
+ Sopify · 2026-08
diff --git a/assets/sopify-product-form-release-en.svg b/assets/sopify-product-form-release-en.svg
index 885bb288..720a9b39 100644
--- a/assets/sopify-product-form-release-en.svg
+++ b/assets/sopify-product-form-release-en.svg
@@ -40,9 +40,9 @@
I
HOST
User asks in a supported host
- One protocol, four entry points
+ One protocol, five host adapters
-
+
@@ -68,12 +68,19 @@
verified
- Same .sopify/ project
- Supported hosts load the same rules
- Project records stay consistent
+
+
+
+ Cursor
+ baseline
+
+
+ Same .sopify/ project
+ Supported hosts consume one protocol
+ Project records stay consistent
- ~go
- · user starts the work
+ request
+ · user starts the work
@@ -241,5 +248,5 @@
baseline
-
+
diff --git a/assets/sopify-product-form-release.svg b/assets/sopify-product-form-release.svg
index 7ccc59f6..f39e803c 100644
--- a/assets/sopify-product-form-release.svg
+++ b/assets/sopify-product-form-release.svg
@@ -40,9 +40,9 @@
I
HOST
用户在受支持宿主提需求
- 同一协议,四个入口
+ 同一协议,五个宿主适配
-
+
@@ -68,12 +68,19 @@
verified
- 同一 .sopify/ 项目
- 受支持宿主加载同一套规则
- 项目记录保持一致
+
+
+
+ Cursor
+ baseline
+
+
+ 同一 .sopify/ 项目
+ 受支持宿主消费同一协议
+ 项目记录保持一致
- ~go
- · 用户发出请求
+ request
+ · 用户发出请求
@@ -241,5 +248,5 @@
baseline supported
-
+
diff --git a/docs/cursor-host.md b/docs/cursor-host.md
new file mode 100644
index 00000000..5cde51ff
--- /dev/null
+++ b/docs/cursor-host.md
@@ -0,0 +1,69 @@
+# Cursor 宿主接入
+
+主要支持本地 Cursor IDE。Cursor Agent CLI 不提供自动 Plugin Rule 入口,Cloud Agent 不在本期范围。支持档位保持 `BASELINE_SUPPORTED`。真实 IDE 已观察到 Plugin Rule 路由、Analyze 评分、AskQuestion 调用尝试与文本回退、managed develop、writer 写回和 Hook 拒绝明显直写;这些证据不代表跨模型稳定性或完整协议认证。本期不为 CLI 增加 launcher、项目 Rule 或 runtime。
+
+## 安装面
+
+```bash
+curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/install.sh | bash -s -- --target cursor:zh-CN
+```
+
+首次安装或更新本地 Plugin 后,重启 Cursor,或执行 `Developer: Reload Window`,再检查 Plugin 与 Rule。Cursor 官方将 reload/restart 作为 `~/.cursor/plugins/local` 本地测试流程的一部分。
+
+安装一次,管理四个用户级落点:
+
+- Plugin:`~/.cursor/plugins/local/sopify/.cursor-plugin/plugin.json`、`README.md` 与 `rules/sopify.mdc`。README 渐进介绍用途与边界;薄 Always Rule 是 IDE 的语义入口。
+- Skills:`~/.cursor/skills/sopify/` 下的 `analyze`、`design`、`develop`、`kb`、`templates` 和共享 references。
+- Payload:`~/.cursor/sopify/`,包含版本化 bundle 与 Hook helper。
+- Hooks:合并到 `~/.cursor/hooks.json`;不写仓库级 hooks。
+
+Cursor 不需要 `--workspace`,安装器不在目标仓库创建 `.cursor/rules/sopify.mdc` 或预热 `.sopify`。它也不创建或修改 Cursor 的 `settings.json`、代理、模型、账号、API Key、钥匙串或 MCP。兼容目录中的同名 Skills 不作为安装成功依据;Rule 明确路由到 `~/.cursor/skills/sopify`。
+
+`--with-evidentloop` 对 Cursor 当前版本 fail closed。
+
+## 语义与 Hook 边界
+
+IDE Plugin Rule 负责意图分类与 Skill 路由:
+
+- `consult_readonly` 直接只读回答,不自动恢复 active plan;
+- `quick_fix` 可修改用户授权的产品代码,但不直接写协议 state、handoff、receipt 或知识库;
+- `new_plan`、`continue_plan`、`finalize` 和明确的 managed develop 才进入对应 Skill 与四步协议入口;
+- Analyze 必须实际执行 Skill 中的评分脚本,machine truth 只经 `sopify_writer` 库 API 写入。
+
+用户级 Hooks 只补确定性边界,不做意图分类:
+
+- `.sopify/` 是 managed-root 信号;没有该目录时文件与 Shell guard no-op。
+- `sessionStart` 只在唯一 managed root 且 active plan 方案包有效时注入事实;多根不唯一或状态无效时不猜测。
+- `preToolUse` 按文件目标路径拒绝明显直接写入 `active_plan.json`、`current_handoff.json` 与 `plan/*/receipts/*.json`。
+- `beforeShellExecution` 只在 `cwd` 所属 managed root 中拒绝同类明显直接写入;`plan.md`、`tasks.md`、`design.md` 和业务代码不在保护范围。
+- helper 缺失或异常 fail-open,由 Doctor 报告;这不是完整 Shell parser 或安全沙箱。
+
+## Doctor 语义
+
+```bash
+python3 scripts/sopify_doctor.py --format json --home-root "$HOME"
+```
+
+Cursor 分开报告:
+
+- `cursor_plugin_present`:Plugin manifest 与 Rule 是否存在;README 是包内说明,不作为行为或安装健康硬门槛;
+- `global_skill_tree_present`:五项 Skills、共享写作/输出契约与 Analyze 评分脚本是否存在;
+- `payload_present`:payload/bundle 是否结构完整;
+- `cursor_hooks_present`:用户 hooks 与 helper 是否安全、完整;
+- `cursor_ide_behavior`、`cursor_cli_behavior`:真实宿主黑盒,默认 `skip (BLACK_BOX_NOT_VERIFIED)`。
+
+缺席判定只看 Sopify 自有落点,不把整个 `~/.cursor` 或现有 IDE settings 当成已安装。文件检查通过不代表 Cursor 已遵循工作流。
+
+## IDE / CLI 证据边界
+
+IDE 与 CLI 分开记录,不互相外推:
+
+1. IDE baseline 已有可观察证据:已安装 Rule 含 `alwaysApply: true`,consult 实际读取 Cursor 写作规范并保持只读;Analyze 读取 Cursor Skill 并执行评分脚本;有限选项澄清留下 AskQuestion tool use 与 `Tool not found` 文本回退,结构化问卷 UI 仅作为人工观察,transcript 不含 tool result 或选项回传;managed 场景按 Analyze → Design → Develop 推进,经 `sopify_writer` 写 state、handoff 与 receipts;明显 Shell 直写 machine truth 被 Hook 拒绝且文件哈希不变。
+2. IDE 未验证边界:AskQuestion 是否跨模型稳定提供、独立 `sessionStart` 行为与显式 finalize。本期依赖文本追问回退和现有协议边界,不把这些项目作为 baseline 发布阻塞。
+3. CLI 未验证自动语义入口。CLI 的结果不能用于证明 IDE 行为,IDE 的结果也不能用于证明 CLI 行为。
+
+Cursor 官方 CLI 文档明确项目 Rules,但没有把用户 Plugin Rule 写成 CLI 加载契约。2026-08-20 的真实会话也未读取 Cursor Plugin Rule 指定的共享规范,显式 `--plugin-dir` 唯一标记探针同样未生效。因此 CLI 自动入口明确不支持,也不阻塞本次 IDE baseline 发布。
+
+一次临时仓库黑盒已观察到四步读链、managed develop 与 `sopify_writer` 写回;显式 finalize 仍按用户命令触发,未纳入本次 baseline 黑盒。Doctor 的行为项继续保持静态 `BLACK_BOX_NOT_VERIFIED`,不把某台机器上的临时 transcript 写成可移植产品认证。
+
+官方依据:[`Cursor Plugins`](https://cursor.com/docs/plugins)、[`Plugin format`](https://cursor.com/docs/reference/plugins)、[`Cursor Rules`](https://cursor.com/docs/rules)、[`Cursor Agent Skills`](https://cursor.com/docs/skills)、[`Cursor Hooks`](https://cursor.com/docs/hooks)、[`Cursor CLI`](https://cursor.com/docs/cli/using)。
diff --git a/docs/dev/release-process.md b/docs/dev/release-process.md
index 197a4a66..3725a449 100644
--- a/docs/dev/release-process.md
+++ b/docs/dev/release-process.md
@@ -55,11 +55,13 @@ bash scripts/check-version-consistency.sh
version-2026--03--26.103000-orange.svg
```
-**两个源模板顶部注释**:
+**四个发布版本源文件顶部注释**:
```
skills/zh/header.md.template →
skills/en/header.md.template
+skills/zh/cursor-plugin-rule.mdc.template
+skills/en/cursor-plugin-rule.mdc.template
```
更新后验证:
@@ -74,7 +76,8 @@ bash scripts/check-version-consistency.sh
```bash
TAG="2026-03-26.103000"
git add CHANGELOG.md README.md README.zh-CN.md \
- skills/zh/header.md.template skills/en/header.md.template
+ skills/zh/header.md.template skills/en/header.md.template \
+ skills/zh/cursor-plugin-rule.mdc.template skills/en/cursor-plugin-rule.mdc.template
git commit -m "release: $TAG"
git push
```
@@ -120,6 +123,10 @@ grep "SourceChannel\|SourceRef" "$OUT_DIR/install.ps1"
# 快速验证(推荐)
HOME="$(mktemp -d)" bash -c \
'curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/install.sh | bash -s -- --target codex:zh-CN'
+
+# 本次 release 包含 Cursor 变更时,再验证 Cursor
+HOME="$(mktemp -d)" bash -c \
+ 'curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/install.sh | bash -s -- --target cursor:zh-CN'
```
或用 smoke 脚本:
diff --git a/docs/getting-started.md b/docs/getting-started.md
index c11efb07..eac68a88 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -15,7 +15,7 @@ Sopify adds resumable, traceable AI workflows to any project. After setup:
- Git repository (local or remote)
- Python 3.11+
-- An AI host: Codex, Claude, Qoder, or Copilot
+- An AI host: Codex, Claude, Qoder, Copilot, or Cursor
## Quick Setup (One Command)
@@ -139,9 +139,44 @@ Copilot reads project-level instruction files across its supported surfaces
Full trigger wiring (equivalent to Codex/Claude `~go`) is coming in a future
release.
+### Cursor IDE (optional Agent CLI)
+
+Cursor IDE uses one user-level Plugin rule. The same installation provides a
+global Skill/payload tree under `~/.cursor/` and user-level hooks in
+`~/.cursor/hooks.json`:
+
+```bash
+curl -fsSL https://github.com/evidentloop/sopify/releases/latest/download/install.sh | bash -s -- --target cursor:en-US
+```
+
+After the first install or any local Plugin update, restart Cursor or run
+`Developer: Reload Window` before checking the Plugin and its Rule.
+
+`--workspace` is not required and no project Rule or `.sopify` state is
+created during install. `--with-evidentloop` is not supported for Cursor in
+this release. The Plugin Rule uses valid `.mdc` frontmatter with
+`alwaysApply: true` and routes to `~/.cursor/skills/sopify`.
+
+The tested Agent CLI does not automatically load this user Plugin Rule.
+Automatic entry for Agent CLI and Cloud Agent is outside this release. No
+launcher, project Rule, prompt injector, or runtime is added. See
+[Cursor host acceptance](./cursor-host.md). The current tier remains
+`BASELINE_SUPPORTED`.
+
## Verify Setup
-After bootstrap, check the workspace marker:
+Cursor installs at user scope and intentionally does not create a workspace
+marker. From the Sopify checkout, verify its install surface with:
+
+```bash
+python3 scripts/sopify_doctor.py --format json --home-root "$HOME"
+```
+
+After reloading Cursor, also confirm that the user-scope Sopify Plugin rule
+appears under Customize > Rules with mode Always. These checks prove
+installation and Plugin rule registration, not model behavior.
+
+For a separately bootstrapped workspace, check its marker with:
```bash
cat .sopify/sopify.json
diff --git a/docs/how-sopify-works.en.md b/docs/how-sopify-works.en.md
index b308443e..eb20d543 100644
--- a/docs/how-sopify-works.en.md
+++ b/docs/how-sopify-works.en.md
@@ -16,7 +16,7 @@ Official reference: [`Harness engineering: leveraging Codex in an agent-first wo
Sopify preserves the **process** of AI development — plans, decisions, handoffs, execution evidence, and archival records — as traceable assets. Cross-session and cross-host continuation is the natural result of these assets being portable and verifiable.
-The host (Codex, Claude, Qoder, Copilot) executes. Sopify ensures every decision leaves a trace that survives session boundaries, host switches, and team handoffs.
+The host (Codex, Claude, Qoder, Copilot, or Cursor) executes. Sopify ensures every decision leaves a trace that survives session boundaries, host switches, and team handoffs.
**Runtime retired; workflow retained.** The analyze → design → develop → finalize workflow is unchanged. What changed is that workflow rules now live in protocol files and host prompt assets, not in a runtime process.
@@ -109,6 +109,7 @@ Layer notes:
| Claude | PROTOCOL_VERIFIED | `install.sh --target claude:en-US` | Full capability continuation |
| Qoder | PROTOCOL_VERIFIED | `install.sh --target qoder` | Validated on Qoder CLI |
| Copilot | BASELINE_SUPPORTED | `install.sh --target copilot` | Prompt-only; payload uplift planned |
+| Cursor | BASELINE_SUPPORTED | `install.sh --target cursor:en-US` | IDE user Plugin + global Skills/payload + user hooks |
## Appendix: Plan Lifecycle
diff --git a/docs/how-sopify-works.md b/docs/how-sopify-works.md
index 20d1435e..337376d2 100644
--- a/docs/how-sopify-works.md
+++ b/docs/how-sopify-works.md
@@ -16,7 +16,7 @@ Sopify 借鉴 harness engineering 的设计思路,但不把它作为仓库首
Sopify 把 AI 开发过程中的**方案、决策、交接、执行/验证证据和归档记录**沉淀为可追溯资产。跨 session、跨宿主的接续是这些资产可携带、可验证后的自然结果。
-宿主(Codex、Claude、Qoder、Copilot)负责执行。Sopify 确保每个决策都留下痕迹,且这些痕迹能跨越 session 边界、宿主切换和团队交接。
+宿主(Codex、Claude、Qoder、Copilot、Cursor)负责执行。Sopify 确保每个决策都留下痕迹,且这些痕迹能跨越 session 边界、宿主切换和团队交接。
**Runtime 已退场;工作流保留。** analyze → design → develop → finalize 的默认工作流不变。变化的是:工作流规则现在活在协议文件和宿主 prompt 资产里,而不是 runtime 进程里。
@@ -109,6 +109,7 @@ Sopify 不默认安装或运行验证器。只用 Sopify、使用其他 Verifier
| Claude | PROTOCOL_VERIFIED | `install.sh --target claude:zh-CN` | 全能力接续 |
| Qoder | PROTOCOL_VERIFIED | `install.sh --target qoder` | 已在 Qoder CLI 验证 |
| Copilot | BASELINE_SUPPORTED | `install.sh --target copilot` | 仅 prompt;payload 升级计划中 |
+| Cursor | BASELINE_SUPPORTED | `install.sh --target cursor:zh-CN` | IDE 用户 Plugin + 全局 Skills/payload + 用户 Hooks |
## 附录:Plan 生命周期
diff --git a/index.html b/index.html
index d30e065b..a0a66d92 100644
--- a/index.html
+++ b/index.html
@@ -64,7 +64,7 @@ Resumable, traceable AI coding
Install Sopify ↓
View on GitHub
- No new editor. No new CLI. Start with ~go in the host you already use.
+ No new editor. No new CLI. Install into the host you already use and follow its documented entry.
@@ -155,8 +155,8 @@ The host executes. Sopify keeps the work resumable
preserves plans, decisions, and verification evidence as project files in .sopify/.
- Ask to continue or run ~go in a supported host to resume an unfinished plan. A local handoff pointer
- provides a same-workspace resume hint.
+ Ask to continue or use the managed entry supported by the current host to resume an unfinished plan. A
+ local handoff pointer provides a same-workspace resume hint.
.sopify/plan/…
@@ -164,6 +164,7 @@ The host executes. Sopify keeps the work resumable
Claude · verified
Qoder · verified
Copilot · baseline
+ Cursor · baseline
Add Sopify to the host you already use
- Then run ~go
+ Choose a host, then start
- Codex, Claude, Qoder, and Copilot targets
+ Codex, Claude, Qoder, Copilot, and Cursor targets
English and Simplified Chinese instructions
macOS, Linux, and Windows setup paths
@@ -243,7 +244,7 @@ The short version
Does every question start a plan?
- No. Ordinary questions and small fixes are handled directly. Sopify resumes an unfinished plan only when you ask to continue or use ~go.
+ No. Ordinary questions and small fixes are handled directly. Sopify resumes an unfinished plan only when you ask to continue or use the current host's managed entry.
What actually travels with the repo?
@@ -251,7 +252,7 @@ The short version
Does switching hosts give every host identical capabilities?
- Codex, Claude, and Qoder have protocol-verified paths. Copilot currently has baseline prompt support; its payload path is more limited.
+ Codex, Claude, and Qoder have protocol-verified paths. Copilot and Cursor are baseline-supported. Cursor's automatic Plugin Rule entry applies to the local IDE, not Agent CLI or Cloud Agent.
Is EvidentLoop required?
diff --git a/install.ps1 b/install.ps1
index d1fa67a6..f54cc9b0 100644
--- a/install.ps1
+++ b/install.ps1
@@ -22,7 +22,9 @@ Options:
--target Host and language to install, for example codex:zh-CN
or copilot.
--workspace For copilot: target project directory (defaults to
- current directory). For other hosts: advanced prewarm.
+ current directory). Cursor does not require this and
+ does not modify the project. For other hosts: advanced
+ prewarm.
--language Copilot only: bootstrap output language (en-US/zh-CN).
--no-copilot Copilot only: skip Copilot instruction file
distribution.
diff --git a/install.sh b/install.sh
index 80cf33b2..42e5adce 100755
--- a/install.sh
+++ b/install.sh
@@ -22,7 +22,9 @@ Options:
--target Host and language to install, for example codex:zh-CN
or copilot.
--workspace For copilot: target project directory (defaults to
- current directory). For other hosts: advanced prewarm.
+ current directory). Cursor does not require this and
+ does not modify the project. For other hosts: advanced
+ prewarm.
--language Copilot only: bootstrap output language (en-US/zh-CN).
--no-copilot Copilot only: skip Copilot instruction file
distribution and only write workspace markers.
diff --git a/installer/cursor_hook.py b/installer/cursor_hook.py
new file mode 100644
index 00000000..d244706c
--- /dev/null
+++ b/installer/cursor_hook.py
@@ -0,0 +1,385 @@
+#!/usr/bin/env python3
+"""Cursor stdio hook helper for Sopify machine-truth guards.
+
+Copied to ``/.cursor/sopify/helpers/cursor_hook.py``. Fail-open on any
+unexpected error so a broken helper cannot block the Cursor session.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+import json
+import re
+import sys
+from pathlib import Path
+from typing import Any, Iterable
+
+PROTECTED_STATE_FILES = frozenset(
+ {
+ ".sopify/state/active_plan.json",
+ ".sopify/state/current_handoff.json",
+ }
+)
+RECEIPT_PATH_RE = re.compile(r"(?:^|/)\.sopify/plan/[^/]+/receipts/[^/]+\.json$")
+RECEIPT_COMMAND_RE = re.compile(
+ r"(?:^|[>/\s'\"])\.sopify/plan/[^/\s'\"]+/receipts/[^/\s'\"]+\.json(?=$|[\s;&|)'\"])"
+)
+MUTATING_FILE_TOOLS = frozenset(
+ {
+ "write",
+ "strreplace",
+ "delete",
+ "editnotebook",
+ "applypatch",
+ "searchreplace",
+ }
+)
+PATH_KEYS = frozenset({"path", "file_path", "target_file", "notebook_path"})
+CONTENT_KEYS = frozenset(
+ {"contents", "content", "old_string", "new_string", "patch", "diff", "command", "new_source"}
+)
+SHELL_OUTPUT_REDIRECT_RE = re.compile(r"(?=])>{1,2}(?![=>])")
+SHELL_WRITE_HINTS = ("tee ", "sed -i", "rm ", "rm\t", "mv ", "cp ")
+WRITER_ALLOW_MARKERS = ("sopify_writer", "ProtocolStore")
+PLAN_FILES_BY_LEVEL = {
+ "light": ("plan.md",),
+ "standard": ("plan.md", "tasks.md"),
+ "architecture": ("plan.md", "tasks.md", "design.md"),
+}
+SEMANTIC_PLAN_FILES = frozenset({"plan.md", "tasks.md", "design.md", "background.md"})
+PLAN_ID_RE = re.compile(r"^[A-Za-z0-9_]+$")
+PLAN_LEVEL_RE = re.compile(r"^level\s*:\s*(.+?)\s*$")
+NON_RESUME_CLAUSE = (
+ "这是状态事实,不是恢复命令;先按本轮用户意图分类。"
+ "consult_readonly 和 quick_fix 不自动接续 active plan。"
+)
+
+
+def main() -> int:
+ try:
+ raw = sys.stdin.read()
+ payload = json.loads(raw) if raw.strip() else {}
+ if not isinstance(payload, dict):
+ payload = {}
+ result = handle(payload)
+ sys.stdout.write(json.dumps(result, ensure_ascii=False))
+ sys.stdout.write("\n")
+ except Exception:
+ sys.stdout.write("{}\n")
+ return 0
+
+
+def handle(payload: dict[str, Any]) -> dict[str, Any]:
+ event = str(payload.get("hook_event_name") or "")
+ if event == "sessionStart":
+ workspaces = _find_session_managed_roots(payload)
+ if not workspaces:
+ return {}
+ workspace = _select_session_workspace(payload, workspaces)
+ if workspace is None:
+ return _ambiguous_session_start(len(workspaces))
+ return _session_start(workspace)
+ if event == "preToolUse":
+ return _pre_tool_use(payload)
+ if event == "beforeShellExecution":
+ return _before_shell(payload)
+ return _noop(event)
+
+
+def _noop(event: str) -> dict[str, Any]:
+ if event in {"preToolUse", "beforeShellExecution"}:
+ return {"permission": "allow"}
+ return {}
+
+
+def _find_session_managed_roots(payload: dict[str, Any]) -> list[Path]:
+ candidates: list[Path] = []
+ for raw in payload.get("workspace_roots") or ():
+ if raw:
+ candidates.append(Path(str(raw)))
+ cwd = payload.get("cwd")
+ if not cwd and isinstance(payload.get("tool_input"), dict):
+ cwd = payload["tool_input"].get("working_directory") or payload["tool_input"].get("cwd")
+ if cwd:
+ candidates.append(Path(str(cwd)))
+ seen: set[Path] = set()
+ managed: list[Path] = []
+ for candidate in candidates:
+ root = _nearest_managed_root(candidate)
+ if root is None or root in seen:
+ continue
+ seen.add(root)
+ managed.append(root)
+ return managed
+
+
+def _nearest_managed_root(path: Path) -> Path | None:
+ try:
+ current = path.expanduser().resolve()
+ except OSError:
+ return None
+ while True:
+ if (current / ".sopify").is_dir():
+ return current
+ if current.parent == current:
+ return None
+ current = current.parent
+
+
+def _session_start(workspace: Path) -> dict[str, Any]:
+ active = _read_json(workspace / ".sopify" / "state" / "active_plan.json")
+ handoff = _read_json(workspace / ".sopify" / "state" / "current_handoff.json")
+ plan_id = str((active or {}).get("plan_id") or "")
+ if not PLAN_ID_RE.fullmatch(plan_id):
+ return {}
+ plan_dir = workspace / ".sopify" / "plan" / plan_id
+ if not _plan_package_is_valid(plan_dir):
+ return {}
+ handoff_matches = str((handoff or {}).get("plan_id") or "") == plan_id
+ handoff_action = ((handoff or {}).get("required_host_action") if handoff_matches else None) or "(none)"
+ latest_receipt = _latest_receipt_id(plan_dir)
+ lines = [
+ "Sopify status facts (not a resume order).",
+ NON_RESUME_CLAUSE,
+ f"active_plan: {plan_id}",
+ "plan_present: true",
+ f"handoff_action: {handoff_action}",
+ f"latest_receipt: {latest_receipt or '(none)'}",
+ ]
+ return {"additional_context": "\n".join(lines)}
+
+
+def _select_session_workspace(payload: dict[str, Any], workspaces: list[Path]) -> Path | None:
+ if len(workspaces) == 1:
+ return workspaces[0]
+ cwd = payload.get("cwd")
+ if not cwd:
+ return None
+ try:
+ current = Path(str(cwd)).expanduser().resolve()
+ except OSError:
+ return None
+ matches = [workspace for workspace in workspaces if _path_is_within(current, workspace)]
+ return matches[0] if len(matches) == 1 else None
+
+
+def _ambiguous_session_start(workspace_count: int) -> dict[str, Any]:
+ return {
+ "additional_context": (
+ "Sopify status facts were not injected because multiple enabled workspaces are open. "
+ f"enabled_workspaces: {workspace_count}. Resolve the target workspace before reading plan state.\n"
+ f"{NON_RESUME_CLAUSE}"
+ )
+ }
+
+
+def _pre_tool_use(payload: dict[str, Any]) -> dict[str, Any]:
+ tool_name = str(payload.get("tool_name") or "")
+ if tool_name.casefold() not in MUTATING_FILE_TOOLS:
+ return {"permission": "allow"}
+ for path in _extract_paths(payload.get("tool_input")):
+ target = _resolve_tool_path(path, payload)
+ if target is None:
+ continue
+ workspace = _nearest_managed_root(target)
+ if workspace is not None and _is_protected_path(str(target), workspace):
+ return _deny(path)
+ return {"permission": "allow"}
+
+
+def _before_shell(payload: dict[str, Any]) -> dict[str, Any]:
+ cwd = _event_cwd(payload)
+ workspace = _nearest_managed_root(Path(cwd)) if cwd else None
+ if workspace is None:
+ return {"permission": "allow"}
+ command = str(payload.get("command") or "")
+ has_write_hint = bool(SHELL_OUTPUT_REDIRECT_RE.search(command)) or any(
+ hint in command for hint in SHELL_WRITE_HINTS
+ )
+ if has_write_hint and _command_mentions_protected_path(command, workspace):
+ return _deny("protected Sopify machine-truth path")
+ if any(marker in command for marker in WRITER_ALLOW_MARKERS):
+ return {"permission": "allow"}
+ return {"permission": "allow"}
+
+
+def _resolve_tool_path(path: str, payload: dict[str, Any]) -> Path | None:
+ raw = Path(path.strip()).expanduser()
+ cwd = _event_cwd(payload)
+ try:
+ if raw.is_absolute():
+ return raw.resolve()
+ elif cwd:
+ return (Path(cwd).expanduser() / raw).resolve()
+ else:
+ roots = _find_session_managed_roots(payload)
+ if len(roots) == 1:
+ return (roots[0] / raw).resolve()
+ except OSError:
+ return None
+ return None
+
+
+def _event_cwd(payload: dict[str, Any]) -> str:
+ cwd = payload.get("cwd")
+ if not cwd and isinstance(payload.get("tool_input"), dict):
+ cwd = payload["tool_input"].get("working_directory") or payload["tool_input"].get("cwd")
+ return str(cwd or "")
+
+
+def _path_is_within(path: Path, root: Path) -> bool:
+ try:
+ path.relative_to(root)
+ except ValueError:
+ return False
+ return True
+
+
+def _deny(target: str) -> dict[str, Any]:
+ message = (
+ "Direct writes to Sopify machine-truth files are blocked. "
+ "Use the installed sopify_writer library API."
+ )
+ return {
+ "permission": "deny",
+ "user_message": message,
+ "agent_message": f"{message} Blocked target: {target}",
+ }
+
+
+def _extract_paths(tool_input: Any) -> Iterable[str]:
+ if isinstance(tool_input, str):
+ if _looks_like_path(tool_input):
+ yield tool_input
+ return
+ if isinstance(tool_input, dict):
+ for key, value in tool_input.items():
+ key_l = str(key).casefold()
+ if key_l in CONTENT_KEYS:
+ continue
+ if key_l in PATH_KEYS and isinstance(value, str):
+ yield value
+ elif key_l == "paths" and isinstance(value, list):
+ for item in value:
+ if isinstance(item, str):
+ yield item
+ else:
+ yield from _extract_paths(value)
+ return
+ if isinstance(tool_input, list):
+ for item in tool_input:
+ yield from _extract_paths(item)
+
+
+def _looks_like_path(value: str) -> bool:
+ return "/" in value or value.endswith((".json", ".md", ".py"))
+
+
+def _is_protected_path(path: str, workspace: Path) -> bool:
+ normalized = _normalize_relpath(path, workspace)
+ if normalized in PROTECTED_STATE_FILES:
+ return True
+ return bool(RECEIPT_PATH_RE.search(normalized))
+
+
+def _command_mentions_protected_path(command: str, workspace: Path) -> bool:
+ compact = command.replace("\\", "/")
+ if any(item in compact for item in PROTECTED_STATE_FILES):
+ return True
+ if RECEIPT_COMMAND_RE.search(compact):
+ return True
+ workspace_text = str(workspace).replace("\\", "/")
+ return any(
+ f"{workspace_text}/{item}" in compact for item in PROTECTED_STATE_FILES
+ )
+
+
+def _normalize_relpath(path: str, workspace: Path) -> str:
+ raw = path.strip().replace("\\", "/")
+ try:
+ candidate = Path(raw)
+ if not candidate.is_absolute():
+ candidate = workspace / candidate
+ relative = candidate.expanduser().resolve().relative_to(workspace.resolve())
+ return relative.as_posix()
+ except (OSError, ValueError):
+ if raw.startswith("./"):
+ raw = raw[2:]
+ return raw.lstrip("/")
+
+
+def _plan_package_is_valid(plan_dir: Path) -> bool:
+ plan_md = plan_dir / "plan.md"
+ if not plan_md.is_file():
+ return False
+ try:
+ lines = plan_md.read_text(encoding="utf-8").splitlines()
+ except (OSError, UnicodeError):
+ return False
+ if not lines or lines[0].strip() != "---":
+ return False
+ levels: list[str] = []
+ for line in lines[1:]:
+ if line.strip() == "---":
+ break
+ match = PLAN_LEVEL_RE.match(line)
+ if match:
+ levels.append(match.group(1).strip().strip("'\""))
+ else:
+ return False
+ if len(levels) != 1 or levels[0] not in PLAN_FILES_BY_LEVEL:
+ return False
+ expected = set(PLAN_FILES_BY_LEVEL[levels[0]])
+ present = {name for name in SEMANTIC_PLAN_FILES if (plan_dir / name).is_file()}
+ return present == expected
+
+
+def _latest_receipt_id(plan_dir: Path) -> str | None:
+ receipts = plan_dir / "receipts"
+ if not receipts.is_dir():
+ return None
+ final = receipts / "final.json"
+ if final.is_file():
+ return "final"
+ candidates = [path for path in receipts.glob("*.json") if path.is_file()]
+ if not candidates:
+ return None
+ return max(candidates, key=_receipt_sort_key).stem
+
+
+def _receipt_sort_key(path: Path) -> tuple[int, float, int]:
+ payload = _read_json(path) or {}
+ timestamp = _timestamp_value(payload.get("timestamp"))
+ receipt_id = str((payload.get("provenance") or {}).get("receipt_id") or path.stem)
+ number_match = re.search(r"(\d+)$", receipt_id)
+ number = int(number_match.group(1)) if number_match else -1
+ if timestamp is not None:
+ return (1, timestamp, number)
+ return (0, float(number), number)
+
+
+def _timestamp_value(value: Any) -> float | None:
+ if not isinstance(value, str) or not value.strip():
+ return None
+ normalized = value.strip().replace("Z", "+00:00")
+ try:
+ parsed = datetime.fromisoformat(normalized)
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.timestamp()
+
+
+def _read_json(path: Path) -> dict[str, Any] | None:
+ if not path.is_file():
+ return None
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ return payload if isinstance(payload, dict) else None
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/installer/cursor_hooks.py b/installer/cursor_hooks.py
new file mode 100644
index 00000000..f825c3ba
--- /dev/null
+++ b/installer/cursor_hooks.py
@@ -0,0 +1,186 @@
+"""Install and inspect user-level Cursor hooks for Sopify."""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+import shlex
+import subprocess
+import sys
+import tempfile
+from typing import Any
+
+from installer.models import InstallError
+
+HOOKS_RELATIVE_PATH = Path(".cursor") / "hooks.json"
+HELPER_RELATIVE_PATH = Path("helpers") / "cursor_hook.py"
+SOPIFY_HOOK_MARKER = "helpers/cursor_hook.py"
+SOPIFY_HOOK_EVENTS = ("sessionStart", "preToolUse", "beforeShellExecution")
+HOOKS_SCHEMA_VERSION = 1
+
+
+def user_hooks_path(home_root: Path) -> Path:
+ return home_root / HOOKS_RELATIVE_PATH
+
+
+def hook_helper_path(payload_root: Path) -> Path:
+ return payload_root / HELPER_RELATIVE_PATH
+
+
+def preflight_cursor_user_hooks(*, home_root: Path) -> None:
+ """Validate the existing user hooks file before any Cursor install writes."""
+ _read_existing_hooks(user_hooks_path(home_root))
+
+
+def install_cursor_user_hooks(*, home_root: Path, payload_root: Path) -> Path:
+ """Merge Sopify-owned user hooks; refuse to overwrite invalid JSON."""
+ helper = hook_helper_path(payload_root)
+ if not helper.is_file():
+ raise InstallError(f"Missing Cursor hook helper: {helper}")
+
+ path = user_hooks_path(home_root)
+ existing = _read_existing_hooks(path)
+ hooks = existing.setdefault("hooks", {})
+ if not isinstance(hooks, dict):
+ raise InstallError(f"Refusing to overwrite invalid Cursor hooks.json: {path}")
+
+ command = _hook_command(helper)
+ sopify_entry = {"command": command, "failClosed": False}
+ for event in SOPIFY_HOOK_EVENTS:
+ entries = [item for item in _as_hook_list(hooks.get(event)) if not _is_sopify_hook(item)]
+ entries.append(dict(sopify_entry))
+ hooks[event] = entries
+ existing["version"] = existing.get("version") or HOOKS_SCHEMA_VERSION
+ existing["hooks"] = hooks
+
+ _atomic_write_json(path, existing)
+ return path
+
+
+def sopify_hooks_are_present(*, home_root: Path, payload_root: Path) -> tuple[bool, str | None]:
+ """Return whether Sopify user hooks and helper are structurally present."""
+ helper = hook_helper_path(payload_root)
+ if not helper.is_file():
+ return False, f"Missing Cursor hook helper: {helper}"
+ path = user_hooks_path(home_root)
+ if not path.is_file():
+ return False, f"Missing Cursor hooks.json: {path}"
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return False, f"Invalid Cursor hooks.json: {path}"
+ if not isinstance(payload, dict):
+ return False, f"Invalid Cursor hooks.json: {path}"
+ hooks = payload.get("hooks")
+ if not isinstance(hooks, dict):
+ return False, f"Invalid Cursor hooks.json: {path}"
+ for event in SOPIFY_HOOK_EVENTS:
+ matches = [item for item in _as_hook_list(hooks.get(event)) if _is_sopify_hook(item)]
+ if not matches:
+ return False, f"Missing Sopify {event} hook in {path}"
+ if not any(_hook_entry_is_healthy(item, helper) for item in matches):
+ return False, f"Stale or unsafe Sopify {event} hook in {path}"
+ return True, None
+
+
+def sopify_hook_entries_present(*, home_root: Path) -> bool:
+ """Return whether a readable hooks file still contains Sopify-owned entries."""
+ path = user_hooks_path(home_root)
+ if not path.is_file():
+ return False
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return False
+ hooks = payload.get("hooks") if isinstance(payload, dict) else None
+ if not isinstance(hooks, dict):
+ return False
+ return any(
+ _is_sopify_hook(item)
+ for event in SOPIFY_HOOK_EVENTS
+ for item in _as_hook_list(hooks.get(event))
+ )
+
+
+def _read_existing_hooks(path: Path) -> dict[str, Any]:
+ if not path.exists():
+ return {"version": HOOKS_SCHEMA_VERSION, "hooks": {}}
+ try:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ raise InstallError(f"Refusing to overwrite invalid Cursor hooks.json: {path}") from exc
+ if not isinstance(payload, dict):
+ raise InstallError(f"Refusing to overwrite invalid Cursor hooks.json: {path}")
+ hooks = payload.get("hooks")
+ if hooks is not None and not isinstance(hooks, dict):
+ raise InstallError(f"Refusing to overwrite invalid Cursor hooks.json: {path}")
+ return payload
+
+
+def _hook_command(helper: Path) -> str:
+ parts = (str(Path(sys.executable).resolve()), str(helper.resolve()))
+ if os.name == "nt":
+ return subprocess.list2cmdline(parts)
+ return shlex.join(parts)
+
+
+def _hook_entry_is_healthy(entry: Any, helper: Path) -> bool:
+ if not isinstance(entry, dict) or entry.get("failClosed") is not False:
+ return False
+ try:
+ parts = shlex.split(str(entry.get("command") or ""), posix=os.name != "nt")
+ except ValueError:
+ return False
+ if len(parts) != 2:
+ return False
+ executable_text = parts[0].strip('"')
+ helper_text = parts[1].strip('"')
+ executable = Path(executable_text).expanduser()
+ configured_helper = Path(helper_text).expanduser()
+ try:
+ return (
+ executable.is_file()
+ and os.access(executable, os.X_OK)
+ and configured_helper.resolve() == helper.resolve()
+ )
+ except OSError:
+ return False
+
+
+def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ existing_mode = path.stat().st_mode & 0o777 if path.exists() else None
+ temporary_path: Path | None = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w",
+ encoding="utf-8",
+ dir=path.parent,
+ prefix=f".{path.name}.",
+ delete=False,
+ ) as temporary:
+ temporary.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
+ temporary.flush()
+ os.fsync(temporary.fileno())
+ temporary_path = Path(temporary.name)
+ if existing_mode is not None:
+ temporary_path.chmod(existing_mode)
+ temporary_path.replace(path)
+ finally:
+ if temporary_path is not None and temporary_path.exists():
+ temporary_path.unlink()
+
+
+def _as_hook_list(value: Any) -> list[Any]:
+ if value is None:
+ return []
+ if isinstance(value, list):
+ return list(value)
+ return [value]
+
+
+def _is_sopify_hook(entry: Any) -> bool:
+ if not isinstance(entry, dict):
+ return False
+ return SOPIFY_HOOK_MARKER in str(entry.get("command") or "").replace("\\", "/")
diff --git a/installer/cursor_plugin.py b/installer/cursor_plugin.py
new file mode 100644
index 00000000..97ba6577
--- /dev/null
+++ b/installer/cursor_plugin.py
@@ -0,0 +1,91 @@
+"""Install the user-level Cursor Plugin entry owned by Sopify."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+import shutil
+
+from installer.hosts.base import HostAdapter, render_user_plugin_rule, read_sopify_version
+from installer.models import InstallError, InstallPhaseResult
+
+_IGNORE_PATTERNS = shutil.ignore_patterns(".DS_Store", "Thumbs.db", "__pycache__")
+_README_TEMPLATE_NAME = "cursor-plugin-readme.md.template"
+
+
+def install_cursor_user_plugin_assets(
+ adapter: HostAdapter,
+ *,
+ repo_root: Path,
+ home_root: Path,
+ language_directory: str,
+) -> InstallPhaseResult:
+ """Install one thin user Plugin rule and the canonical global Skill tree."""
+ if adapter.host_name != "cursor" or not adapter.is_user_plugin_scope:
+ raise InstallError("Cursor user Plugin installer received an incompatible host adapter")
+
+ rule_source = adapter.instruction_source(repo_root, language_directory)
+ readme_source = rule_source.with_name(_README_TEMPLATE_NAME)
+ skills_source = adapter.source_root(repo_root, language_directory) / "skills" / "sopify"
+ if not rule_source.is_file():
+ raise InstallError(f"Missing source Cursor Plugin rule: {rule_source}")
+ if not readme_source.is_file():
+ raise InstallError(f"Missing source Cursor Plugin README: {readme_source}")
+ if not skills_source.is_dir():
+ raise InstallError(f"Missing source skills directory: {skills_source}")
+
+ manifest_path, rule_path = adapter.user_plugin_paths(home_root)
+ readme_path = adapter.user_plugin_readme_path(home_root)
+ plugin_root = rule_path.parent.parent
+ skills_destination = adapter.destination_root(home_root) / "skills" / "sopify"
+ manifest = _manifest_text()
+ rule = render_user_plugin_rule(rule_source, adapter)
+ readme = readme_source.read_text(encoding="utf-8").rstrip("\n") + "\n"
+ expected_paths = adapter.expected_paths(home_root)
+ if (
+ manifest_path.is_file()
+ and manifest_path.read_text(encoding="utf-8") == manifest
+ and rule_path.is_file()
+ and rule_path.read_text(encoding="utf-8") == rule
+ and readme_path.is_file()
+ and readme_path.read_text(encoding="utf-8") == readme
+ and all(path.exists() for path in adapter.global_skill_paths(home_root))
+ ):
+ return InstallPhaseResult(
+ action="skipped",
+ root=plugin_root,
+ version=read_sopify_version(rule_source),
+ paths=expected_paths,
+ )
+
+ action = "updated" if plugin_root.exists() or skills_destination.exists() else "installed"
+ if plugin_root.exists():
+ shutil.rmtree(plugin_root)
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
+ manifest_path.write_text(manifest, encoding="utf-8")
+ rule_path.parent.mkdir(parents=True, exist_ok=True)
+ rule_path.write_text(rule, encoding="utf-8")
+ readme_path.write_text(readme, encoding="utf-8")
+
+ if skills_destination.exists():
+ shutil.rmtree(skills_destination)
+ skills_destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copytree(skills_source, skills_destination, ignore=_IGNORE_PATTERNS)
+
+ return InstallPhaseResult(
+ action=action,
+ root=plugin_root,
+ version=read_sopify_version(rule_source),
+ paths=expected_paths,
+ )
+
+
+def _manifest_text() -> str:
+ return json.dumps(
+ {
+ "name": "sopify",
+ "description": "Sopify adaptive workflow entry for Cursor IDE",
+ },
+ ensure_ascii=False,
+ indent=2,
+ ) + "\n"
diff --git a/installer/distribution.py b/installer/distribution.py
index b6da1c0f..5e383586 100644
--- a/installer/distribution.py
+++ b/installer/distribution.py
@@ -529,6 +529,12 @@ def _build_next_step(target: InstallTarget, workspace_root: Path | None) -> str:
if workspace_root is None:
return f"Open {target.host.title()} in your project workspace to start using Sopify."
return f"Open {target.host.title()} in {workspace_root} to start using Sopify."
+ if adapter.is_user_plugin_scope:
+ return (
+ "Restart Cursor or run Developer: Reload Window, then open a target repository and "
+ "verify the Sopify user Plugin is enabled. "
+ "The installer does not modify project files."
+ )
if workspace_root is None:
return (
f"Open {target.host} in any project workspace and trigger Sopify. "
@@ -561,7 +567,19 @@ def _render_distribution_user_result_en(report: DistributionInstallReport) -> st
"",
"Project:",
]
- if install_result.workspace_root is None:
+ if _is_user_plugin_install(install_result):
+ lines.extend(
+ [
+ f" User Plugin rule: {_user_plugin_rule_path(install_result)}",
+ " No project directory or .sopify state was changed.",
+ "",
+ "Next:",
+ " 1. Restart Cursor or run `Developer: Reload Window`.",
+ f" 2. Open {host_name} in a target repository.",
+ " 3. Confirm the Sopify Plugin and its Always Rule in Cursor Settings before relying on host behavior.",
+ ]
+ )
+ elif install_result.workspace_root is None:
lines.extend(
[
" No project directory was changed.",
@@ -618,7 +636,19 @@ def _render_distribution_user_result_zh(report: DistributionInstallReport) -> st
"",
"项目:",
]
- if install_result.workspace_root is None:
+ if _is_user_plugin_install(install_result):
+ lines.extend(
+ [
+ f" 用户 Plugin 规则:{_user_plugin_rule_path(install_result)}",
+ " 未修改任何项目目录,也未初始化 .sopify 状态。",
+ "",
+ "下一步:",
+ " 1. 重启 Cursor,或执行 `Developer: Reload Window`。",
+ f" 2. 在目标仓库中打开 {host_name}。",
+ " 3. 使用前请在 Cursor Settings 中确认 Sopify Plugin 及其 Always Rule 已启用。",
+ ]
+ )
+ elif install_result.workspace_root is None:
lines.extend(
[
" 这次没有修改任何项目目录。",
@@ -767,11 +797,27 @@ def _select_host_checks(payload: dict[str, object], target: InstallTarget) -> tu
def _render_workspace_line(install_result: InstallResult) -> str:
+ if _is_user_plugin_install(install_result):
+ return f"project unchanged; user Plugin rule installed at {_user_plugin_rule_path(install_result)}"
if install_result.workspace_root is None:
return "will bootstrap on first project trigger"
return f"pre-warmed at {install_result.workspace_root}"
+def _is_user_plugin_install(install_result: InstallResult) -> bool:
+ try:
+ return get_host_adapter(install_result.target.host).is_user_plugin_scope
+ except ValueError:
+ return False
+
+
+def _user_plugin_rule_path(install_result: InstallResult) -> Path:
+ for path in install_result.host_install.paths:
+ if path.suffix == ".mdc":
+ return path
+ raise ValueError("User Plugin installation did not report its rule path")
+
+
def _workspace_bootstrap_action(install_result: InstallResult) -> str:
if install_result.workspace_bootstrap is None:
return "not requested"
diff --git a/installer/hosts/__init__.py b/installer/hosts/__init__.py
index 3b7ce684..b0cfa522 100644
--- a/installer/hosts/__init__.py
+++ b/installer/hosts/__init__.py
@@ -11,12 +11,14 @@
from .claude import CLAUDE_ADAPTER, CLAUDE_HOST
from .codex import CODEX_ADAPTER, CODEX_HOST
from .copilot import COPILOT_ADAPTER, COPILOT_HOST
+from .cursor import CURSOR_ADAPTER, CURSOR_HOST
from .qoder import QODER_ADAPTER, QODER_HOST
_REGISTRATIONS = {
CODEX_HOST.capability.host_id: CODEX_HOST,
CLAUDE_HOST.capability.host_id: CLAUDE_HOST,
COPILOT_HOST.capability.host_id: COPILOT_HOST,
+ CURSOR_HOST.capability.host_id: CURSOR_HOST,
QODER_HOST.capability.host_id: QODER_HOST,
}
diff --git a/installer/hosts/base.py b/installer/hosts/base.py
index c6c8b341..44dcc0c2 100644
--- a/installer/hosts/base.py
+++ b/installer/hosts/base.py
@@ -18,6 +18,7 @@
INSTRUCTION_SURFACE_HEADER_EMBEDDED = "header_embedded"
INSTRUCTION_SURFACE_SINGLE_FILE = "single_file"
+INSTRUCTION_SURFACE_USER_PLUGIN = "user_plugin"
@dataclass(frozen=True)
@@ -30,6 +31,7 @@ class HostAdapter:
config_dir: str | None = None
instruction_surface: str = field(default=INSTRUCTION_SURFACE_HEADER_EMBEDDED)
instruction_file_relpath: str | None = field(default=None)
+ instruction_source_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)
@@ -38,9 +40,26 @@ class HostAdapter:
def is_workspace_scope(self) -> bool:
return self.instruction_surface == INSTRUCTION_SURFACE_SINGLE_FILE
+ @property
+ def is_user_plugin_scope(self) -> bool:
+ return self.instruction_surface == INSTRUCTION_SURFACE_USER_PLUGIN
+
+ @property
+ def requires_workspace(self) -> bool:
+ """Whether installation needs a project root for its instruction surface."""
+ return self.is_workspace_scope
+
def source_root(self, repo_root: Path, language_directory: str) -> Path:
return repo_root / "skills" / language_to_source_dir(language_directory)
+ def instruction_source(self, repo_root: Path, language_directory: str) -> Path:
+ """Return the source asset for this host's instruction surface."""
+ source_root = self.source_root(repo_root, language_directory)
+ if self.instruction_source_relpath is not None:
+ return source_root / self.instruction_source_relpath
+ template = source_root / HEADER_TEMPLATE_NAME
+ return template if template.is_file() else source_root / self.header_filename
+
def destination_root(self, home_root: Path) -> Path:
return home_root / self.destination_dirname
@@ -54,6 +73,8 @@ def expected_paths(self, home_root: Path) -> tuple[Path, ...]:
if self.instruction_file_relpath is not None:
paths.append(home_root / self.instruction_file_relpath)
return tuple(paths)
+ if self.is_user_plugin_scope:
+ return (*self.user_plugin_paths(home_root), *self.global_skill_paths(home_root))
return (
root / self.header_filename,
root / "skills" / "sopify" / "analyze" / "SKILL.md",
@@ -67,6 +88,31 @@ def workspace_expected_paths(self, workspace_root: Path) -> tuple[Path, ...]:
paths.append(workspace_root / self.instruction_file_relpath)
return tuple(paths)
+ def global_skill_paths(self, home_root: Path) -> tuple[Path, ...]:
+ skills_root = self.destination_root(home_root) / "skills" / "sopify"
+ return (
+ skills_root / "analyze" / "SKILL.md",
+ skills_root / "design" / "SKILL.md",
+ skills_root / "develop" / "SKILL.md",
+ skills_root / "kb" / "SKILL.md",
+ skills_root / "templates" / "SKILL.md",
+ skills_root / "references" / "shared-writing-dna.md",
+ skills_root / "references" / "output-contract.md",
+ skills_root / "analyze" / "scripts" / "score_requirement.py",
+ )
+
+ def user_plugin_paths(self, home_root: Path) -> tuple[Path, ...]:
+ if self.instruction_file_relpath is None:
+ raise InstallError(f"Host '{self.host_name}' has no user Plugin rule path")
+ rule = home_root / self.instruction_file_relpath
+ plugin_root = rule.parent.parent
+ return (plugin_root / ".cursor-plugin" / "plugin.json", rule)
+
+ def user_plugin_readme_path(self, home_root: Path) -> Path:
+ """Return the user-visible README path for a user Plugin surface."""
+ _, rule = self.user_plugin_paths(home_root)
+ return rule.parent.parent / "README.md"
+
def expected_payload_paths(self, home_root: Path) -> tuple[Path, ...]:
payload_root = self.payload_root(home_root)
return (
@@ -135,6 +181,15 @@ def install_host_assets(
workspace_root=workspace_root,
language_directory=language_directory,
)
+ if adapter.is_user_plugin_scope:
+ from installer.cursor_plugin import install_cursor_user_plugin_assets
+
+ return install_cursor_user_plugin_assets(
+ adapter,
+ repo_root=repo_root,
+ home_root=home_root,
+ language_directory=language_directory,
+ )
return _install_home_host_assets(
adapter,
repo_root=repo_root,
@@ -152,9 +207,7 @@ def _install_home_host_assets(
) -> InstallPhaseResult:
"""Install header + skills tree to home directory (Claude/Codex path)."""
source_root = adapter.source_root(repo_root, language_directory)
- header_template = source_root / HEADER_TEMPLATE_NAME
- # Fallback to old-style header if template doesn't exist
- header_source = header_template if header_template.is_file() else source_root / adapter.header_filename
+ header_source = adapter.instruction_source(repo_root, language_directory)
skills_source = source_root / "skills" / "sopify"
if not header_source.is_file():
raise InstallError(f"Missing source header file: {header_source}")
@@ -347,6 +400,17 @@ def _render_header(source: Path, destination: Path, adapter: HostAdapter) -> Non
destination.write_text(content, encoding="utf-8")
+def render_user_plugin_rule(source: Path, adapter: HostAdapter) -> str:
+ """Render a Cursor Plugin rule with a portable user-directory Skill path."""
+ content = source.read_text(encoding="utf-8")
+ content = content.replace("{{config_dir}}", adapter.config_dir or "")
+ if adapter.skill_install_dirname is None:
+ raise InstallError(f"Host '{adapter.host_name}' has no global Skill install path")
+ global_skill_root = f"~/{adapter.skill_install_dirname}/sopify"
+ content = content.replace("{{skills_root}}", global_skill_root)
+ return content.rstrip("\n") + "\n"
+
+
def _write_managed_block(path: Path, content: str) -> bool:
"""Upsert a managed instruction block, preserving user content outside markers.
diff --git a/installer/hosts/cursor.py b/installer/hosts/cursor.py
new file mode 100644
index 00000000..0ff682d3
--- /dev/null
+++ b/installer/hosts/cursor.py
@@ -0,0 +1,50 @@
+"""Cursor IDE adapter with optional Agent CLI Skill and Hook compatibility."""
+
+from __future__ import annotations
+
+from installer.models import EntryMode, FeatureId, HostCapability, SupportTier
+
+from .base import INSTRUCTION_SURFACE_USER_PLUGIN, HostAdapter, HostRegistration
+
+CURSOR_ADAPTER = HostAdapter(
+ host_name="cursor",
+ destination_dirname=".cursor",
+ header_filename="sopify.mdc",
+ config_dir="~/.cursor",
+ instruction_surface=INSTRUCTION_SURFACE_USER_PLUGIN,
+ instruction_file_relpath=".cursor/plugins/local/sopify/rules/sopify.mdc",
+ instruction_source_relpath="cursor-plugin-rule.mdc.template",
+ default_language="en-US",
+ skills_cli_agent=None,
+ skill_install_dirname=".cursor/skills",
+)
+
+CURSOR_CAPABILITY = HostCapability(
+ host_id="cursor",
+ support_tier=SupportTier.BASELINE_SUPPORTED,
+ install_enabled=True,
+ declared_features=(
+ FeatureId.PROMPT_INSTALL,
+ FeatureId.PAYLOAD_INSTALL,
+ ),
+ verified_features=(
+ FeatureId.PROMPT_INSTALL,
+ FeatureId.PAYLOAD_INSTALL,
+ ),
+ declared_enhancements=(),
+ entry_modes=(
+ EntryMode.PROMPT_ONLY,
+ EntryMode.HOOKS,
+ ),
+ doctor_checks=(
+ "cursor_plugin_present",
+ "global_skill_tree_present",
+ "payload_present",
+ "cursor_hooks_present",
+ "cursor_ide_behavior",
+ "cursor_cli_behavior",
+ ),
+ smoke_targets=(),
+)
+
+CURSOR_HOST = HostRegistration(adapter=CURSOR_ADAPTER, capability=CURSOR_CAPABILITY)
diff --git a/installer/inspection.py b/installer/inspection.py
index 5e89858c..195f4062 100644
--- a/installer/inspection.py
+++ b/installer/inspection.py
@@ -15,6 +15,7 @@
_classify_workspace_bundle,
_resolve_selected_payload_bundle,
)
+from installer.cursor_hooks import sopify_hook_entries_present, sopify_hooks_are_present
from installer.hosts import iter_host_registrations
from installer.hosts.base import HostAdapter, HostRegistration
from installer.models import HostCapability, InstallError
@@ -148,6 +149,10 @@ class HostInspection:
workspace_bundle: InspectionCheck
handoff_first: InspectionCheck
smoke: InspectionCheck
+ global_skill_tree: InspectionCheck | None = None
+ user_hooks: InspectionCheck | None = None
+ ide_behavior: InspectionCheck | None = None
+ cli_behavior: InspectionCheck | None = None
@property
def capability(self) -> HostCapability:
@@ -171,11 +176,25 @@ def to_status_dict(self) -> dict[str, object]:
},
}
configured = self.payload.status == CHECK_PASS
+ if self.adapter.is_user_plugin_scope:
+ skill_ok = self.global_skill_tree is not None and self.global_skill_tree.status == CHECK_PASS
+ installed = skill_ok and self.host_prompt.status == CHECK_PASS
+ return {
+ **self.capability.to_dict(),
+ "state": {
+ "installed": STATUS_YES if installed else STATUS_NO,
+ "configured": STATUS_YES if configured else STATUS_NO,
+ "workspace_bundle_healthy": STATUS_NOT_REQUESTED,
+ },
+ "payload_bundle": self.payload_bundle.to_status_dict(),
+ "workspace_bundle": self.workspace_bundle.to_dict(),
+ }
+ installed = self.host_prompt.status == CHECK_PASS
workspace_bundle_healthy = _check_state_value(self.workspace_bundle)
return {
**self.capability.to_dict(),
"state": {
- "installed": STATUS_YES if self.host_prompt.status == CHECK_PASS else STATUS_NO,
+ "installed": STATUS_YES if installed else STATUS_NO,
"configured": STATUS_YES if configured else STATUS_NO,
"workspace_bundle_healthy": workspace_bundle_healthy,
},
@@ -186,13 +205,17 @@ def to_status_dict(self) -> dict[str, object]:
def doctor_checks(self) -> tuple[InspectionCheck, ...]:
if self.adapter.is_workspace_scope:
return (self.host_prompt,)
- return (
+ checks: list[InspectionCheck] = [
self.host_prompt,
self.payload,
self.payload_bundle.to_check(host_id=self.capability.host_id),
- self.workspace_bundle,
- self.handoff_first,
- )
+ ]
+ if not self.adapter.is_user_plugin_scope:
+ checks.extend((self.workspace_bundle, self.handoff_first))
+ for check in (self.global_skill_tree, self.user_hooks, self.ide_behavior, self.cli_behavior):
+ if check is not None:
+ checks.append(check)
+ return tuple(checks)
def classify_workspace_version_state(
@@ -252,9 +275,10 @@ def inspect_host(
adapter = registration.adapter
capability = registration.capability
if _host_is_absent(adapter=adapter, home_root=home_root, workspace_root=workspace_root):
+ project_rule_check_id = "cursor_plugin_present" if adapter.is_user_plugin_scope else "host_prompt_present"
skipped = InspectionCheck(
host_id=capability.host_id,
- check_id="host_prompt_present",
+ check_id=project_rule_check_id,
status=CHECK_SKIP,
reason_code=REASON_OK,
recommendation=f"Install Sopify for {capability.host_id} to enable host-local diagnostics.",
@@ -294,24 +318,51 @@ def inspect_host(
status=CHECK_SKIP,
reason_code=REASON_OK,
),
+ global_skill_tree=_cursor_not_verified_check(
+ capability=capability,
+ check_id="global_skill_tree_present",
+ reason_code="HOST_NOT_INSTALLED",
+ ) if adapter.is_user_plugin_scope else None,
+ user_hooks=_cursor_not_verified_check(
+ capability=capability,
+ check_id="cursor_hooks_present",
+ reason_code="HOST_NOT_INSTALLED",
+ ) if adapter.host_name == "cursor" else None,
+ ide_behavior=_cursor_behavior_check(capability=capability, check_id="cursor_ide_behavior") if adapter.host_name == "cursor" else None,
+ cli_behavior=_cursor_behavior_check(capability=capability, check_id="cursor_cli_behavior") if adapter.host_name == "cursor" else None,
)
host_prompt = _inspect_host_prompt(adapter=adapter, capability=capability, home_root=home_root, workspace_root=workspace_root)
payload = _inspect_payload(adapter=adapter, capability=capability, home_root=home_root)
+ global_skill_tree = (
+ _inspect_global_skill_tree(adapter=adapter, capability=capability, home_root=home_root)
+ if adapter.is_user_plugin_scope
+ else None
+ )
+ user_hooks = (
+ _inspect_cursor_hooks(adapter=adapter, capability=capability, home_root=home_root)
+ if adapter.host_name == "cursor"
+ else None
+ )
payload_bundle = inspect_payload_bundle_resolution(payload_root=adapter.payload_root(home_root), host_id=capability.host_id)
- if workspace_root is None:
+ if workspace_root is None or adapter.is_user_plugin_scope:
+ workspace_recommendation = (
+ "The Cursor user Plugin install does not modify a project. Trigger a managed workflow in the target repository to initialize `.sopify` on demand."
+ if adapter.is_user_plugin_scope
+ else "Workspace bootstrap was not requested. Trigger Sopify in a project workspace to bootstrap on demand."
+ )
workspace_bundle = InspectionCheck(
host_id=capability.host_id,
check_id="workspace_bundle_manifest",
status=CHECK_SKIP,
reason_code=REASON_WORKSPACE_NOT_REQUESTED,
- recommendation="Workspace bootstrap was not requested. Trigger Sopify in a project workspace to bootstrap on demand.",
+ recommendation=workspace_recommendation,
)
handoff_first = InspectionCheck(
host_id=capability.host_id,
check_id="workspace_handoff_first",
status=CHECK_SKIP,
reason_code=REASON_WORKSPACE_NOT_REQUESTED,
- recommendation="Trigger Sopify in a project workspace to bootstrap on demand.",
+ recommendation=workspace_recommendation,
)
smoke = _inspect_smoke(
adapter=adapter,
@@ -327,6 +378,10 @@ def inspect_host(
workspace_bundle=workspace_bundle,
handoff_first=handoff_first,
smoke=smoke,
+ global_skill_tree=global_skill_tree,
+ user_hooks=user_hooks,
+ ide_behavior=_cursor_behavior_check(capability=capability, check_id="cursor_ide_behavior") if adapter.host_name == "cursor" else None,
+ cli_behavior=_cursor_behavior_check(capability=capability, check_id="cursor_cli_behavior") if adapter.host_name == "cursor" else None,
)
workspace_bundle = _inspect_workspace_bundle(
adapter=adapter,
@@ -362,6 +417,10 @@ def inspect_host(
workspace_bundle=workspace_bundle,
handoff_first=handoff_first,
smoke=smoke,
+ global_skill_tree=global_skill_tree,
+ user_hooks=user_hooks,
+ ide_behavior=_cursor_behavior_check(capability=capability, check_id="cursor_ide_behavior") if adapter.host_name == "cursor" else None,
+ cli_behavior=_cursor_behavior_check(capability=capability, check_id="cursor_cli_behavior") if adapter.host_name == "cursor" else None,
)
@@ -671,8 +730,14 @@ def _protocol_state_checks(workspace_state: dict[str, object]) -> tuple[Inspecti
def _inspect_host_prompt(*, adapter: HostAdapter, capability: HostCapability, home_root: Path, workspace_root: Path | None = None) -> InspectionCheck:
+ check_id = "cursor_plugin_present" if adapter.is_user_plugin_scope else "host_prompt_present"
try:
- if adapter.is_workspace_scope:
+ if adapter.is_user_plugin_scope:
+ paths = adapter.user_plugin_paths(home_root)
+ missing = [path for path in paths if not path.exists()]
+ if missing:
+ raise InstallError(f"Host install verification failed: {missing[0]}")
+ elif adapter.requires_workspace:
if workspace_root is None:
raise InstallError("Workspace-scope host requires --workspace for verification")
paths = adapter.workspace_expected_paths(workspace_root)
@@ -683,7 +748,7 @@ def _inspect_host_prompt(*, adapter: HostAdapter, capability: HostCapability, ho
paths = validate_host_install(adapter, home_root=home_root)
return InspectionCheck(
host_id=capability.host_id,
- check_id="host_prompt_present",
+ check_id=check_id,
status=CHECK_PASS,
reason_code=REASON_OK,
evidence=tuple(str(path) for path in paths),
@@ -691,7 +756,7 @@ def _inspect_host_prompt(*, adapter: HostAdapter, capability: HostCapability, ho
except InstallError as exc:
return InspectionCheck(
host_id=capability.host_id,
- check_id="host_prompt_present",
+ check_id=check_id,
status=CHECK_FAIL,
reason_code=_reason_code_from_install_error(exc),
evidence=_paths_from_error(exc),
@@ -699,6 +764,93 @@ def _inspect_host_prompt(*, adapter: HostAdapter, capability: HostCapability, ho
)
+def _inspect_cursor_hooks(*, adapter: HostAdapter, capability: HostCapability, home_root: Path) -> InspectionCheck:
+ present, detail = sopify_hooks_are_present(
+ home_root=home_root,
+ payload_root=adapter.payload_root(home_root),
+ )
+ if present:
+ return InspectionCheck(
+ host_id=capability.host_id,
+ check_id="cursor_hooks_present",
+ status=CHECK_PASS,
+ reason_code=REASON_OK,
+ evidence=(str(adapter.payload_root(home_root) / "helpers" / "cursor_hook.py"),),
+ )
+ return InspectionCheck(
+ host_id=capability.host_id,
+ check_id="cursor_hooks_present",
+ status=CHECK_FAIL,
+ reason_code="MISSING_REQUIRED_FILE",
+ evidence=(detail,) if detail else (),
+ recommendation=(
+ f"Run python3 scripts/install_sopify.py --target {_recommend_target(capability.host_id)} "
+ "to install the user-level Cursor hooks and helper."
+ ),
+ )
+
+
+def _inspect_global_skill_tree(*, adapter: HostAdapter, capability: HostCapability, home_root: Path) -> InspectionCheck:
+ try:
+ paths = adapter.global_skill_paths(home_root)
+ missing = [path for path in paths if not path.exists()]
+ if missing:
+ raise InstallError(f"Host install verification failed: {missing[0]}")
+ return InspectionCheck(
+ host_id=capability.host_id,
+ check_id="global_skill_tree_present",
+ status=CHECK_PASS,
+ reason_code=REASON_OK,
+ evidence=tuple(str(path) for path in paths),
+ )
+ except InstallError as exc:
+ return _cursor_not_verified_check(
+ capability=capability,
+ check_id="global_skill_tree_present",
+ reason_code=_reason_code_from_install_error(exc),
+ evidence=_paths_from_error(exc),
+ recommendation=f"Run python3 scripts/install_sopify.py --target {_recommend_target(capability.host_id)} to install the global Cursor Skill tree.",
+ status=CHECK_FAIL,
+ )
+
+
+def _cursor_not_verified_check(
+ *,
+ capability: HostCapability,
+ check_id: str,
+ reason_code: str,
+ evidence: tuple[str, ...] = (),
+ recommendation: str | None = None,
+ status: str = CHECK_SKIP,
+) -> InspectionCheck:
+ return InspectionCheck(
+ host_id=capability.host_id,
+ check_id=check_id,
+ status=status,
+ reason_code=reason_code,
+ evidence=evidence,
+ recommendation=recommendation,
+ )
+
+
+def _cursor_behavior_check(*, capability: HostCapability, check_id: str) -> InspectionCheck:
+ if check_id == "cursor_ide_behavior":
+ recommendation = (
+ "Run the Cursor IDE black-box acceptance flow; file presence alone is not behavior evidence."
+ )
+ else:
+ recommendation = (
+ "Optionally run the Cursor Agent CLI manual-Skill and Hook black-box flow. "
+ "Automatic loading of the user Plugin Rule is not a supported CLI entry."
+ )
+ return _cursor_not_verified_check(
+ capability=capability,
+ check_id=check_id,
+ reason_code="BLACK_BOX_NOT_VERIFIED",
+ recommendation=recommendation,
+ )
+
+
def _inspect_payload(*, adapter: HostAdapter, capability: HostCapability, home_root: Path) -> InspectionCheck:
payload_root = adapter.payload_root(home_root)
try:
@@ -1164,4 +1316,12 @@ def _host_is_absent(*, adapter: HostAdapter, home_root: Path, workspace_root: Pa
if workspace_root is None:
return True
return not any(p.exists() for p in adapter.workspace_expected_paths(workspace_root))
+ if adapter.is_user_plugin_scope:
+ owned = [
+ *adapter.expected_paths(home_root),
+ adapter.payload_root(home_root),
+ ]
+ return not any(path.exists() for path in owned) and not (
+ adapter.host_name == "cursor" and sopify_hook_entries_present(home_root=home_root)
+ )
return not adapter.destination_root(home_root).exists() and not adapter.payload_root(home_root).exists()
diff --git a/installer/payload.py b/installer/payload.py
index 48159fc9..995c7599 100644
--- a/installer/payload.py
+++ b/installer/payload.py
@@ -20,6 +20,7 @@
PAYLOAD_DIRNAME = "sopify"
PAYLOAD_BUNDLES_RELATIVE_PATH = Path("bundles")
PAYLOAD_HELPER_RELATIVE_PATH = Path("helpers") / "bootstrap_workspace.py"
+CURSOR_HOOK_HELPER_RELATIVE_PATH = Path("helpers") / "cursor_hook.py"
PAYLOAD_INSTRUCTION_RESOURCES_DIR = Path("resources")
_REQUIRED_BUNDLE_CAPABILITIES = {
"bundle_role": "control_plane",
@@ -41,6 +42,8 @@ def install_global_payload(
if _payload_is_current(payload_root, desired_version):
resources_changed = _ensure_workspace_instruction_resources(repo_root=repo_root, payload_root=payload_root)
+ if adapter.host_name == "cursor":
+ resources_changed = _install_cursor_hook_helper(repo_root=repo_root, payload_root=payload_root) or resources_changed
return InstallPhaseResult(
action="updated" if resources_changed else "skipped",
root=payload_root,
@@ -55,6 +58,8 @@ def install_global_payload(
desired_bundle_version=desired_version,
)
_install_bootstrap_helper(repo_root=repo_root, payload_root=payload_root)
+ if adapter.host_name == "cursor":
+ _install_cursor_hook_helper(repo_root=repo_root, payload_root=payload_root)
_install_workspace_instruction_resources(repo_root=repo_root, payload_root=payload_root)
_write_payload_manifest(payload_root=payload_root, bundle_root=bundle_root, payload_version=desired_version)
return InstallPhaseResult(
@@ -131,12 +136,7 @@ def _payload_is_current(payload_root: Path, desired_version: str | None) -> bool
def _source_payload_version(adapter: HostAdapter, repo_root: Path) -> str | None:
language_directory = "CN"
- source = adapter.source_root(repo_root, language_directory)
- # Prefer template; fall back to host-specific header
- header_path = source / HEADER_TEMPLATE_NAME
- if not header_path.is_file():
- header_path = source / adapter.header_filename
- return read_sopify_version(header_path)
+ return read_sopify_version(adapter.instruction_source(repo_root, language_directory))
def _install_bootstrap_helper(*, repo_root: Path, payload_root: Path) -> Path:
@@ -150,6 +150,20 @@ def _install_bootstrap_helper(*, repo_root: Path, payload_root: Path) -> Path:
return helper_target
+def _install_cursor_hook_helper(*, repo_root: Path, payload_root: Path) -> bool:
+ helper_source = repo_root / "installer" / "cursor_hook.py"
+ if not helper_source.is_file():
+ raise InstallError(f"Missing Cursor hook helper source: {helper_source}")
+ helper_target = payload_root / CURSOR_HOOK_HELPER_RELATIVE_PATH
+ desired = helper_source.read_bytes()
+ if helper_target.is_file() and helper_target.read_bytes() == desired:
+ return False
+ helper_target.parent.mkdir(parents=True, exist_ok=True)
+ helper_target.write_bytes(desired)
+ helper_target.chmod(0o755)
+ return True
+
+
def _install_workspace_instruction_resources(*, repo_root: Path, payload_root: Path) -> None:
"""Render instruction resources for all workspace-scope hosts into payload."""
from installer.hosts import iter_host_registrations
diff --git a/scripts/check-version-consistency.sh b/scripts/check-version-consistency.sh
index eaeb0393..4894a66d 100755
--- a/scripts/check-version-consistency.sh
+++ b/scripts/check-version-consistency.sh
@@ -8,6 +8,8 @@ README_ZH="$ROOT_DIR/README.zh-CN.md"
CHANGELOG="$ROOT_DIR/CHANGELOG.md"
SKILLS_ZH="$ROOT_DIR/skills/zh/header.md.template"
SKILLS_EN="$ROOT_DIR/skills/en/header.md.template"
+CURSOR_RULE_ZH="$ROOT_DIR/skills/zh/cursor-plugin-rule.mdc.template"
+CURSOR_RULE_EN="$ROOT_DIR/skills/en/cursor-plugin-rule.mdc.template"
usage() {
cat <<'EOF'
@@ -16,7 +18,7 @@ Usage: scripts/check-version-consistency.sh
Validate version consistency across:
- README.md / README.zh-CN.md version badges
- Latest released version in CHANGELOG.md
- - SOPIFY_VERSION headers in skills/ source templates
+ - SOPIFY_VERSION in the standard and Cursor Rule templates
Exit codes:
0: all checks passed
@@ -35,6 +37,8 @@ required_files=(
"$CHANGELOG"
"$SKILLS_ZH"
"$SKILLS_EN"
+ "$CURSOR_RULE_ZH"
+ "$CURSOR_RULE_EN"
)
for file in "${required_files[@]}"; do
@@ -125,6 +129,8 @@ fi
skills_zh_version="$(extract_sopify_version "$SKILLS_ZH")"
skills_en_version="$(extract_sopify_version "$SKILLS_EN")"
+cursor_zh_version="$(extract_sopify_version "$CURSOR_RULE_ZH")"
+cursor_en_version="$(extract_sopify_version "$CURSOR_RULE_EN")"
if [[ -z "$skills_zh_version" ]]; then
add_error "skills/zh/header.md.template: missing SOPIFY_VERSION header."
@@ -132,11 +138,17 @@ fi
if [[ -z "$skills_en_version" ]]; then
add_error "skills/en/header.md.template: missing SOPIFY_VERSION header."
fi
-header_versions=("$skills_zh_version" "$skills_en_version")
-first_header_version="${header_versions[0]}"
-for version in "${header_versions[@]}"; do
+if [[ -z "$cursor_zh_version" ]]; then
+ add_error "skills/zh/cursor-plugin-rule.mdc.template: missing SOPIFY_VERSION header."
+fi
+if [[ -z "$cursor_en_version" ]]; then
+ add_error "skills/en/cursor-plugin-rule.mdc.template: missing SOPIFY_VERSION header."
+fi
+source_versions=("$skills_zh_version" "$skills_en_version" "$cursor_zh_version" "$cursor_en_version")
+first_header_version="${source_versions[0]}"
+for version in "${source_versions[@]}"; do
if [[ -n "$version" && "$version" != "$first_header_version" ]]; then
- add_error "Header SOPIFY_VERSION mismatch: skills=$skills_zh_version/$skills_en_version."
+ add_error "Source SOPIFY_VERSION mismatch: generic=$skills_zh_version/$skills_en_version, cursor=$cursor_zh_version/$cursor_en_version."
break
fi
done
diff --git a/scripts/install_sopify.py b/scripts/install_sopify.py
index 8abc6d1a..fd8accd1 100755
--- a/scripts/install_sopify.py
+++ b/scripts/install_sopify.py
@@ -27,6 +27,7 @@
install_evidentloop_companion,
prepare_evidentloop_install,
)
+from installer.cursor_hooks import install_cursor_user_hooks, preflight_cursor_user_hooks
from installer.hosts import get_host_adapter, iter_host_registrations, iter_installable_hosts
from installer.hosts.base import HostAdapter, install_host_assets
from installer.models import BootstrapResult, EvidentLoopInstallResult, InstallError, InstallPhaseResult, InstallResult, LANGUAGE_DIRECTORY_MAP, parse_install_target
@@ -70,10 +71,9 @@ def build_parser() -> argparse.ArgumentParser:
"--workspace",
default=None,
help=(
- "For workspace-scope hosts (e.g. copilot): bootstrap this workspace now "
- "(defaults to current directory when omitted). "
- "For other hosts: advanced prewarm path. Most users should omit this and let `~go` initialize "
- "project files on first use."
+ "For Copilot: bootstrap this workspace now (defaults to current directory). "
+ "For Cursor: optional context only; the user Plugin install does not modify this workspace. "
+ "For Codex / Claude / Qoder: advanced prewarm path. Most of those users should omit this."
),
)
parser.add_argument("--language", choices=("en-US", "zh-CN"), default=None, help="Override output language for bare targets.")
@@ -109,7 +109,11 @@ def run_install(
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:
+ if with_evidentloop and adapter.skills_cli_agent is None:
+ raise InstallError(
+ f"Host '{adapter.host_name}' does not support --with-evidentloop in this release."
+ )
+ if adapter.requires_workspace 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}")
@@ -117,6 +121,8 @@ def run_install(
raise InstallError(f"Workspace is not a directory: {workspace_root}")
resolved_home = (home_root or Path.home()).expanduser().resolve()
+ if adapter.host_name == "cursor":
+ preflight_cursor_user_hooks(home_root=resolved_home)
if adapter.is_workspace_scope:
# Workspace-scope hosts (e.g. Copilot): render single file to workspace,
# skip payload install and workspace bootstrap.
@@ -156,14 +162,27 @@ def run_install(
repo_root=repo_root,
home_root=resolved_home,
language_directory=target.language_directory,
+ workspace_root=workspace_root,
)
payload_install = install_global_payload(adapter, repo_root=repo_root, home_root=resolved_home)
verified_host_paths = validate_host_install(adapter, home_root=resolved_home)
+ if adapter.requires_workspace:
+ workspace_paths = adapter.workspace_expected_paths(workspace_root)
+ missing_workspace_paths = [path for path in workspace_paths if not path.exists()]
+ if missing_workspace_paths:
+ raise InstallError(f"Host install verification failed: {missing_workspace_paths[0]}")
+ verified_host_paths = tuple(dict.fromkeys((*verified_host_paths, *workspace_paths)))
verified_payload_paths = validate_payload_install(payload_install.root)
+ if adapter.host_name == "cursor":
+ hook_path = install_cursor_user_hooks(
+ home_root=resolved_home,
+ payload_root=payload_install.root,
+ )
+ verified_host_paths = tuple(dict.fromkeys((*verified_host_paths, hook_path)))
workspace_bootstrap: BootstrapResult | None = None
bundle_root: Path | None = None
- if workspace_root is not None:
+ if workspace_root is not None and not adapter.is_user_plugin_scope:
workspace_bootstrap = run_workspace_bootstrap(payload_install.root, workspace_root)
bundle_root = workspace_bootstrap.bundle_root
validate_workspace_stub_manifest(workspace_root / ".sopify")
diff --git a/scripts/release-sync.sh b/scripts/release-sync.sh
index 2c53f9eb..4af0f36c 100755
--- a/scripts/release-sync.sh
+++ b/scripts/release-sync.sh
@@ -8,6 +8,8 @@ README_ZH="$ROOT_DIR/README.zh-CN.md"
CHANGELOG="$ROOT_DIR/CHANGELOG.md"
SKILLS_ZH="$ROOT_DIR/skills/zh/header.md.template"
SKILLS_EN="$ROOT_DIR/skills/en/header.md.template"
+CURSOR_RULE_ZH="$ROOT_DIR/skills/zh/cursor-plugin-rule.mdc.template"
+CURSOR_RULE_EN="$ROOT_DIR/skills/en/cursor-plugin-rule.mdc.template"
CHANGELOG_DRAFT_SCRIPT="$ROOT_DIR/scripts/release-draft-changelog.py"
usage() {
@@ -19,7 +21,7 @@ Synchronize release version across key files:
2) CHANGELOG.md:
- move current [Unreleased] content into
## [] -
- 3) skills/ source template SOPIFY_VERSION headers (zh/en)
+ 3) SOPIFY_VERSION in the standard and Cursor Rule templates (zh/en)
4) Run consistency checks
Arguments:
@@ -71,6 +73,8 @@ required_files=(
"$CHANGELOG"
"$SKILLS_ZH"
"$SKILLS_EN"
+ "$CURSOR_RULE_ZH"
+ "$CURSOR_RULE_EN"
"$CHANGELOG_DRAFT_SCRIPT"
"$ROOT_DIR/scripts/check-version-consistency.sh"
)
@@ -279,6 +283,8 @@ require_single_match "$README_PRIMARY" 'img\.shields\.io/badge/version-.*-orange
require_single_match "$README_ZH" 'img\.shields\.io/badge/version-.*-orange\.svg' "README zh-CN version badge"
require_single_match "$SKILLS_ZH" '^$' "skills/zh SOPIFY_VERSION"
require_single_match "$SKILLS_EN" '^$' "skills/en SOPIFY_VERSION"
+require_single_match "$CURSOR_RULE_ZH" '^$' "skills/zh Cursor SOPIFY_VERSION"
+require_single_match "$CURSOR_RULE_EN" '^$' "skills/en Cursor SOPIFY_VERSION"
if grep -Fq "## [$VERSION] - " "$CHANGELOG"; then
echo "CHANGELOG already contains version $VERSION." >&2
@@ -294,6 +300,8 @@ promote_unreleased_to_release "$CHANGELOG"
replace_once "$SKILLS_ZH" '^$' ""
replace_once "$SKILLS_EN" '^$' ""
+replace_once "$CURSOR_RULE_ZH" '^$' ""
+replace_once "$CURSOR_RULE_EN" '^$' ""
bash "$ROOT_DIR/scripts/check-version-consistency.sh"
diff --git a/scripts/render-host-skills.py b/scripts/render-host-skills.py
index 26baf8ce..cdb11fc6 100644
--- a/scripts/render-host-skills.py
+++ b/scripts/render-host-skills.py
@@ -106,6 +106,8 @@ def render_header(template_path: Path, host_vars: dict) -> str:
content = content.replace("{{config_dir}}", config_dir)
else:
content = content.replace("{{config_dir}}", "")
+ skills_root = f"{config_dir}/skills/sopify" if config_dir else ""
+ content = content.replace("{{skills_root}}", skills_root)
# Warn on unresolved variables
unresolved = re.findall(r"\{\{(\w+)\}\}", content)
if unresolved:
@@ -146,7 +148,7 @@ def main() -> None:
if args.verify_all:
for host_id, host_vars in sorted(hosts.items()):
for lang in ("en", "zh"):
- template_path = args.skills_root / lang / "header.md.template"
+ template_path = _template_path(args.skills_root, lang, host_vars)
if not template_path.exists():
print(f" SKIP {host_id}:{lang} — template not found")
continue
@@ -161,7 +163,7 @@ def main() -> None:
if args.host not in hosts:
parser.error(f"Unknown host: {args.host}. Available: {list(hosts.keys())}")
- template_path = args.skills_root / args.lang / "header.md.template"
+ template_path = _template_path(args.skills_root, args.lang, hosts[args.host])
if not template_path.exists():
parser.error(f"Template not found: {template_path}")
@@ -175,5 +177,10 @@ def main() -> None:
sys.stdout.write(rendered)
+def _template_path(skills_root: Path, language: str, host_vars: dict[str, object]) -> Path:
+ relpath = str(host_vars.get("instruction_source_relpath") or "header.md.template")
+ return skills_root / language / relpath
+
+
if __name__ == "__main__":
main()
diff --git a/skills/catalog/analyze/skill.yaml b/skills/catalog/analyze/skill.yaml
index 8a224b37..d1f98eca 100644
--- a/skills/catalog/analyze/skill.yaml
+++ b/skills/catalog/analyze/skill.yaml
@@ -18,4 +18,5 @@ host_support:
- claude
- qoder
- copilot
+ - cursor
permission_mode: default
diff --git a/skills/catalog/builtin_catalog.generated.json b/skills/catalog/builtin_catalog.generated.json
index f36253c5..46d211b2 100644
--- a/skills/catalog/builtin_catalog.generated.json
+++ b/skills/catalog/builtin_catalog.generated.json
@@ -17,7 +17,8 @@
"codex",
"claude",
"qoder",
- "copilot"
+ "copilot",
+ "cursor"
],
"id": "analyze",
"metadata": {},
@@ -48,7 +49,8 @@
"codex",
"claude",
"qoder",
- "copilot"
+ "copilot",
+ "cursor"
],
"id": "design",
"metadata": {},
@@ -79,7 +81,8 @@
"codex",
"claude",
"qoder",
- "copilot"
+ "copilot",
+ "cursor"
],
"id": "develop",
"metadata": {},
@@ -111,7 +114,8 @@
"codex",
"claude",
"qoder",
- "copilot"
+ "copilot",
+ "cursor"
],
"id": "kb",
"metadata": {},
@@ -143,7 +147,8 @@
"codex",
"claude",
"qoder",
- "copilot"
+ "copilot",
+ "cursor"
],
"id": "templates",
"metadata": {},
diff --git a/skills/catalog/design/skill.yaml b/skills/catalog/design/skill.yaml
index 7904369c..a669c0e4 100644
--- a/skills/catalog/design/skill.yaml
+++ b/skills/catalog/design/skill.yaml
@@ -18,4 +18,5 @@ host_support:
- claude
- qoder
- copilot
+ - cursor
permission_mode: default
diff --git a/skills/catalog/develop/skill.yaml b/skills/catalog/develop/skill.yaml
index c9d0c2db..e995ade2 100644
--- a/skills/catalog/develop/skill.yaml
+++ b/skills/catalog/develop/skill.yaml
@@ -19,4 +19,5 @@ host_support:
- claude
- qoder
- copilot
+ - cursor
permission_mode: default
diff --git a/skills/catalog/kb/skill.yaml b/skills/catalog/kb/skill.yaml
index a4d931c0..25ec0157 100644
--- a/skills/catalog/kb/skill.yaml
+++ b/skills/catalog/kb/skill.yaml
@@ -19,4 +19,5 @@ host_support:
- claude
- qoder
- copilot
+ - cursor
permission_mode: default
diff --git a/skills/catalog/templates/skill.yaml b/skills/catalog/templates/skill.yaml
index aa1b7db6..5f30e947 100644
--- a/skills/catalog/templates/skill.yaml
+++ b/skills/catalog/templates/skill.yaml
@@ -18,4 +18,5 @@ host_support:
- claude
- qoder
- copilot
+ - cursor
permission_mode: default
diff --git a/skills/en/cursor-plugin-readme.md.template b/skills/en/cursor-plugin-readme.md.template
new file mode 100644
index 00000000..4c52abd0
--- /dev/null
+++ b/skills/en/cursor-plugin-readme.md.template
@@ -0,0 +1,19 @@
+# Sopify
+
+Sopify is an adaptive workflow layer for Cursor IDE. It routes each request to the smallest appropriate path—read-only consultation, quick fix, analysis, design, or managed development—while keeping evidence and project-state boundaries explicit.
+
+## Use Sopify
+
+Describe the goal naturally. Sopify uses the current repository's instructions and `.sopify/` context, then loads only the relevant Skill. For explicit phase control, invoke the installed `/analyze`, `/design`, or `/develop` Skill.
+
+## How it works
+
+- **Always Rule** classifies intent and routes the request.
+- **Skills** provide the phase-specific procedures.
+- **User Hooks** guard protocol state and receipt files against obvious direct writes; they are not a security sandbox.
+
+## Support boundary
+
+- Cursor IDE is the automatic semantic entry. Cursor Agent CLI uses the installed Skills manually.
+- Support remains `BASELINE_SUPPORTED`: the primary IDE path has observable evidence, but automatic CLI entry, Cloud Agents, and identical behavior across models are not promised.
+- Sopify does not modify Cursor settings, proxy, model, account, API keys, keychain, or MCP configuration. Cloud Agents are out of scope.
diff --git a/skills/en/cursor-plugin-rule.mdc.template b/skills/en/cursor-plugin-rule.mdc.template
new file mode 100644
index 00000000..e7d4b14d
--- /dev/null
+++ b/skills/en/cursor-plugin-rule.mdc.template
@@ -0,0 +1,37 @@
+---
+description: "Sopify workflow entry for Cursor IDE"
+alwaysApply: true
+---
+
+
+# Sopify Cursor IDE Entry
+
+This is the user-level Cursor Plugin rule for the IDE. When the IDE loads it, it is the Sopify semantic entry. It governs intent routing, protocol boundaries, and output; the installed Sopify Skills retain the phase procedures. The Agent CLI does not automatically load this Rule in the validated release and is outside this automatic-entry contract.
+
+## Entry for every Agent request
+
+1. Read `{{skills_root}}/references/shared-writing-dna.md` before producing any Sopify response. Do not merely claim that it was referenced.
+2. Classify intent first: `consult_readonly`, `quick_fix`, `new_plan`, `continue_plan`, or `finalize`. Never resume merely because an active plan exists.
+3. Treat `AGENTS.md`, `CLAUDE.md`, project Rules, and `.sopify/` in the current target repository as project facts. A multi-repository parent directory is not automatically the target repository.
+4. Answer `consult_readonly` directly and read-only without loading a phase Skill. `quick_fix` may modify user-authorized product code, but neither mode resumes an old plan or directly writes state, handoff, receipts, or the knowledge base.
+5. For workflow work, read and execute the exact `~/.cursor` path listed below. Do not substitute a same-named Skill from `~/.claude`, `~/.codex`, or another compatibility directory:
+ - Requirements analysis: `{{skills_root}}/analyze/SKILL.md`
+ - Solution design: `{{skills_root}}/design/SKILL.md`
+ - Development: `{{skills_root}}/develop/SKILL.md`
+ - Knowledge base: `{{skills_root}}/kb/SKILL.md`
+ - Templates: `{{skills_root}}/templates/SKILL.md`
+
+## Analysis gate and managed entry
+
+- `analyze` must read `analyze/SKILL.md` and call its existing `scripts/score_requirement.py` for the deterministic scoring gate. If completeness is below `workflow.require_score` (7 by default), ask questions before materializing a plan. Treat `auto_decide` as `false` when neither project nor global configuration sets it explicitly.
+- When clarification is needed and the question fits a finite set of options, prefer Cursor's built-in AskQuestion (`ask_question`) when the current conversation exposes it. It is not an MCP tool; do not use `GetMcpTools` to decide that it is unavailable. Use the questionnaire's free-form input instead of adding a synthetic “Other” option; if the current conversation does not expose the tool or the call fails, fall back to a short text question. Ask open-ended factual questions in text. Until the questionnaire is answered, do not enter Design/Develop or write plan, state, handoff, or receipts.
+- Only `new_plan`, `continue_plan`, `finalize`, or an explicitly managed develop request enters the four-step read chain:
+ `state/active_plan.json` → `plan//plan.md` → `state/current_handoff.json` → `plan//receipts/`.
+- Machine truth may be written only through the installed payload's `sopify_writer` library API. Do not invent `python3 -m sopify_writer`; do not edit state, handoff, or receipts directly.
+
+## Support boundary and output
+
+- Cursor remains `BASELINE_SUPPORTED`: file presence or a single model claim is not protocol-level verification; support conclusions require observable host evidence.
+- Agent CLI users may invoke the installed Skills manually; user Hooks remain available where Cursor loads them. Sopify does not claim automatic CLI routing through this Plugin Rule.
+- Follow the already-read `{{skills_root}}/references/shared-writing-dna.md`. After entering a phase Skill, read `{{skills_root}}/references/output-contract.md`. For consult, give only the conclusion and necessary evidence; do not add a banner, `Changes`, or `Next` to fill a template.
+- Do not modify Cursor settings, proxy, model, account, API key, keychain, or MCP. Cloud Agents are out of scope.
diff --git a/skills/en/header.md.template b/skills/en/header.md.template
index 35e53739..a5775e69 100644
--- a/skills/en/header.md.template
+++ b/skills/en/header.md.template
@@ -1,5 +1,5 @@
-
+
# Sopify - Adaptive AI Programming Assistant
diff --git a/skills/hosts.yaml b/skills/hosts.yaml
index 47f153b5..bfd815ef 100644
--- a/skills/hosts.yaml
+++ b/skills/hosts.yaml
@@ -24,6 +24,14 @@ hosts:
destination_dir: ".github"
instruction_surface: copilot_instructions_md
install_enabled: true
+ cursor:
+ host_id: cursor
+ config_dir: "~/.cursor"
+ header_filename: sopify.mdc
+ destination_dir: ".cursor"
+ instruction_surface: user_plugin
+ instruction_source_relpath: cursor-plugin-rule.mdc.template
+ install_enabled: true
qoder:
host_id: qoder
config_dir: "~/.qoder"
diff --git a/skills/zh/cursor-plugin-readme.md.template b/skills/zh/cursor-plugin-readme.md.template
new file mode 100644
index 00000000..50b974fe
--- /dev/null
+++ b/skills/zh/cursor-plugin-readme.md.template
@@ -0,0 +1,19 @@
+# Sopify
+
+Sopify 是 Cursor IDE 的自适应工作流层。它先把请求路由到最小适用路径——只读咨询、快速修复、需求分析、方案设计或受管开发——并明确约束证据和项目状态边界。
+
+## 使用方式
+
+直接用自然语言说明目标。Sopify 会结合当前仓库的项目指令与 `.sopify/` 上下文,只加载相关 Skill。需要明确指定阶段时,可以调用已安装的 `/analyze`、`/design` 或 `/develop` Skill。
+
+## 工作方式
+
+- **Always Rule**:分类意图并路由请求。
+- **Skills**:提供各阶段的执行细则。
+- **用户级 Hooks**:对协议 state、handoff 与 receipt 提供明显直写保护;它们不是安全沙箱。
+
+## 支持边界
+
+- Cursor IDE 是自动语义入口;Cursor Agent CLI 手工使用已安装 Skills。
+- 当前支持档位为 `BASELINE_SUPPORTED`;IDE 主路径已有可观察证据,但不承诺 CLI 自动入口、Cloud Agent 或跨模型完全一致。
+- Sopify 不修改 Cursor settings、代理、模型、账号、API Key、钥匙串或 MCP 配置;不支持 Cloud Agent。
diff --git a/skills/zh/cursor-plugin-rule.mdc.template b/skills/zh/cursor-plugin-rule.mdc.template
new file mode 100644
index 00000000..151e15b0
--- /dev/null
+++ b/skills/zh/cursor-plugin-rule.mdc.template
@@ -0,0 +1,37 @@
+---
+description: "Sopify workflow entry for Cursor IDE"
+alwaysApply: true
+---
+
+
+# Sopify Cursor IDE 入口
+
+这是 Cursor IDE 的用户级 Plugin Rule;IDE 实际加载它时,它作为 Sopify 语义入口。它只负责意图路由、协议边界和输出治理;阶段细则仍由已安装的 Sopify Skills 提供。已验证版本的 Agent CLI 不会自动加载此 Rule,因此不属于本自动入口契约。
+
+## 每次 Agent 请求的入口
+
+1. 先读取 `{{skills_root}}/references/shared-writing-dna.md`,再输出任何 Sopify 回复;不得只声称参考过。
+2. 先分类意图:`consult_readonly`、`quick_fix`、`new_plan`、`continue_plan` 或 `finalize`。不得因存在 active plan 就自动恢复。
+3. 当前目标仓库中的 `AGENTS.md`、`CLAUDE.md`、项目 Rules 和 `.sopify/` 都是项目事实;多仓父目录不自动等同于目标仓库。
+4. `consult_readonly` 直接只读回答,不加载阶段 Skill;`quick_fix` 可以修改用户授权的产品代码,但两者都不自动接续旧方案,也不直接写 state、handoff、receipt 或知识库。
+5. 需要进入工作流时,读取并执行下面列出的 `~/.cursor` 精确路径;不得替换为 `~/.claude`、`~/.codex` 或其他兼容目录中的同名 Skill:
+ - 需求分析:`{{skills_root}}/analyze/SKILL.md`
+ - 方案设计:`{{skills_root}}/design/SKILL.md`
+ - 开发实施:`{{skills_root}}/develop/SKILL.md`
+ - 知识库:`{{skills_root}}/kb/SKILL.md`
+ - 模板:`{{skills_root}}/templates/SKILL.md`
+
+## 分析门与 managed 入口
+
+- `analyze` 必须先读 `analyze/SKILL.md`,并调用其中现有的 `scripts/score_requirement.py` 执行确定性评分门;完整性评分低于 `workflow.require_score`(默认 7)时先追问,不物化方案。项目与全局配置均未显式设置 `auto_decide` 时按 `false` 处理。
+- 需要澄清且问题适合有限选项时,如果当前会话提供 Cursor 内建 AskQuestion(`ask_question`),则优先调用;它不是 MCP 工具,不得通过 `GetMcpTools` 判断其不存在。使用问卷自带的自由输入,不要手工追加“其他”选项;当前会话未提供该工具或调用失败时,回退为简短文本追问。开放式事实直接用文本询问。问卷未回答前不得进入 Design/Develop,也不得写 plan、state、handoff 或 receipt。
+- 只有 `new_plan`、`continue_plan`、`finalize` 或明确的 managed develop 才进入四步读链:
+ `state/active_plan.json` → `plan//plan.md` → `state/current_handoff.json` → `plan//receipts/`。
+- machine truth 只能经已安装 payload 中的 `sopify_writer` 库 API 写入;不得虚构 `python3 -m sopify_writer`,不得直接编辑 state、handoff 或 receipt。
+
+## 支持边界与输出
+
+- Cursor 首发保持 `BASELINE_SUPPORTED`:文件存在或单次模型自述不等于协议级验证;支持结论以可观察宿主证据为准。
+- Agent CLI 可按需手工调用已安装的 Skills;Cursor 加载用户 Hooks 时继续保留写保护。Sopify 不宣称此 Plugin Rule 能为 CLI 提供自动路由。
+- 表达遵循已读的 `{{skills_root}}/references/shared-writing-dna.md`。进入阶段 Skill 后再读 `{{skills_root}}/references/output-contract.md`;咨询只给结论和必要证据,不要为凑模板添加标题、`Changes` 或 `Next`。
+- 不修改 Cursor settings、代理、模型、账号、API Key、钥匙串或 MCP;不启用 Cloud Agent。
diff --git a/skills/zh/header.md.template b/skills/zh/header.md.template
index fab4647d..e577305d 100644
--- a/skills/zh/header.md.template
+++ b/skills/zh/header.md.template
@@ -1,5 +1,5 @@
-
+
# Sopify - 自适应 AI 编程助手
diff --git a/tests/golden-snapshots.json b/tests/golden-snapshots.json
index 0608c89c..a5f07bb4 100644
--- a/tests/golden-snapshots.json
+++ b/tests/golden-snapshots.json
@@ -8,13 +8,13 @@
},
"protocol_version": "1.0",
"snapshots": {
- "codex:zh-CN:header": "b70f95b07b89c5eb4c91db6b79cb2b9924d7dde5ce087a45eceb9c79fe0eb4ba",
- "codex:en-US:header": "a8dced69b654d5dc9fc81034fda7fefc9b686a02004e2996bc52e04fb1c7ee03",
- "claude:zh-CN:header": "b25b09db3cfc17b76b721090f8418ca732c84c1cbd483868f28e2c7512e18176",
- "claude:en-US:header": "fd00126d0b0d3b15b6d3e610aef3876fa0c2d4f401ec8e99426aa190673d7ae6",
- "copilot:zh-CN:managed_block_payload": "5cc188b8d78de5a10a09e2b5b16fc34193757c295b9ef33f6d43883f054b0dfe",
+ "codex:zh-CN:header": "4457975f24d89cb3edc3d6e0100e820a11d0d36a0a5dffe5218d367fddba5df6",
+ "codex:en-US:header": "58e5755ea02340128ae6a4fbe17b1f90283162f2eff2f3059e7af350e8b79b93",
+ "claude:zh-CN:header": "dd0e4c898df9414d694d85cb89baf15cdf95f88c87aa8c7dc6691bf2e1819a38",
+ "claude:en-US:header": "bbb30be2d36f734cb612ba295a9b5e049196492db3c6deb7cf95660cfd44b6c7",
+ "copilot:zh-CN:managed_block_payload": "ab3ed68140fd4f3c1e83007b8b6a87eb56f1c553dce1ae18f8194388b680c3d3",
"skills:zh-CN:tree": "cd21df587f683acc612c833fc5d57e5d6fb8d954f69e68585b62f4bf5fd571c3",
- "copilot:en-US:managed_block_payload": "6a6a3b9a227a789ccdc96e34f1b4d7d911ae7185eec7a0dfbad0f6ac0fd4b5e9",
+ "copilot:en-US:managed_block_payload": "fb960837b805de4c210097bc142e908995987db8b9fd2628eb0c65eec07f5e7c",
"skills:en-US:tree": "601ab7dacf8157848dc9d85f7420089c287a788d6746575115bcf74114b8113f"
}
}
diff --git a/tests/test_cursor_hooks.py b/tests/test_cursor_hooks.py
new file mode 100644
index 00000000..5bb15053
--- /dev/null
+++ b/tests/test_cursor_hooks.py
@@ -0,0 +1,487 @@
+# Test classification: distribution
+from __future__ import annotations
+
+import io
+import json
+from pathlib import Path
+import shlex
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+if str(REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPO_ROOT))
+
+from installer.cursor_hook import NON_RESUME_CLAUSE, handle, main
+from installer.cursor_hooks import install_cursor_user_hooks, sopify_hooks_are_present
+from installer.models import InstallError
+
+
+def _enable_workspace(root: Path) -> Path:
+ (root / ".sopify").mkdir(parents=True, exist_ok=True)
+ return root
+
+
+def _write_light_plan(plan_dir: Path) -> None:
+ plan_dir.mkdir(parents=True, exist_ok=True)
+ (plan_dir / "plan.md").write_text("---\nlevel: light\n---\n# Plan\n", encoding="utf-8")
+
+
+def _payload_with_helper(home_root: Path) -> Path:
+ helper = home_root / "sopify" / "helpers" / "cursor_hook.py"
+ helper.parent.mkdir(parents=True, exist_ok=True)
+ helper.write_text("# helper\n", encoding="utf-8")
+ return helper.parent.parent
+
+
+class CursorHookHelperTests(unittest.TestCase):
+ def test_noop_without_managed_root(self) -> None:
+ with tempfile.TemporaryDirectory() as workspace_dir:
+ workspace = Path(workspace_dir)
+ self.assertEqual(
+ handle(
+ {
+ "hook_event_name": "sessionStart",
+ "workspace_roots": [str(workspace)],
+ }
+ ),
+ {},
+ )
+ self.assertEqual(
+ handle(
+ {
+ "hook_event_name": "preToolUse",
+ "workspace_roots": [str(workspace)],
+ "tool_name": "StrReplace",
+ "tool_input": {"path": ".sopify/state/active_plan.json"},
+ }
+ ),
+ {"permission": "allow"},
+ )
+
+ def test_session_start_injects_facts_and_non_resume_clause(self) -> None:
+ with tempfile.TemporaryDirectory() as workspace_dir:
+ workspace = _enable_workspace(Path(workspace_dir))
+ state = workspace / ".sopify" / "state"
+ state.mkdir(parents=True)
+ (state / "active_plan.json").write_text(
+ json.dumps({"plan_id": "20260819_cursor_support"}),
+ encoding="utf-8",
+ )
+ (state / "current_handoff.json").write_text(
+ json.dumps({"required_host_action": "confirm_decision", "plan_id": "20260819_cursor_support"}),
+ encoding="utf-8",
+ )
+ plan_dir = workspace / ".sopify" / "plan" / "20260819_cursor_support"
+ (plan_dir / "receipts").mkdir(parents=True)
+ _write_light_plan(plan_dir)
+ (plan_dir / "receipts" / "exec_001.json").write_text("{}\n", encoding="utf-8")
+
+ result = handle(
+ {
+ "hook_event_name": "sessionStart",
+ "workspace_roots": [str(workspace)],
+ }
+ )
+ context = result["additional_context"]
+ self.assertIn("Sopify status facts (not a resume order).", context)
+ self.assertIn(NON_RESUME_CLAUSE, context)
+ self.assertIn("active_plan: 20260819_cursor_support", context)
+ self.assertIn("plan_present: true", context)
+ self.assertIn("handoff_action: confirm_decision", context)
+ self.assertIn("latest_receipt: exec_001", context)
+
+ def test_session_start_ignores_mismatched_handoff(self) -> None:
+ with tempfile.TemporaryDirectory() as workspace_dir:
+ workspace = _enable_workspace(Path(workspace_dir))
+ state = workspace / ".sopify" / "state"
+ state.mkdir(parents=True)
+ (state / "active_plan.json").write_text(
+ json.dumps({"plan_id": "plan_a"}),
+ encoding="utf-8",
+ )
+ (state / "current_handoff.json").write_text(
+ json.dumps({"plan_id": "plan_b", "required_host_action": "confirm_wrong"}),
+ encoding="utf-8",
+ )
+ plan_dir = workspace / ".sopify" / "plan" / "plan_a"
+ _write_light_plan(plan_dir)
+
+ result = handle(
+ {
+ "hook_event_name": "sessionStart",
+ "workspace_roots": [str(workspace)],
+ }
+ )
+
+ self.assertIn("active_plan: plan_a", result["additional_context"])
+ self.assertIn("handoff_action: (none)", result["additional_context"])
+ self.assertNotIn("confirm_wrong", result["additional_context"])
+
+ def test_session_start_selects_receipt_by_timestamp_and_prefers_final(self) -> None:
+ with tempfile.TemporaryDirectory() as workspace_dir:
+ workspace = _enable_workspace(Path(workspace_dir))
+ state = workspace / ".sopify" / "state"
+ state.mkdir(parents=True)
+ (state / "active_plan.json").write_text(json.dumps({"plan_id": "plan_a"}), encoding="utf-8")
+ plan_dir = workspace / ".sopify" / "plan" / "plan_a"
+ receipts = plan_dir / "receipts"
+ receipts.mkdir(parents=True)
+ _write_light_plan(plan_dir)
+ (receipts / "exec_002.json").write_text(
+ json.dumps(
+ {
+ "timestamp": "2026-08-19T10:00:00+00:00",
+ "provenance": {"receipt_id": "exec_002"},
+ }
+ ),
+ encoding="utf-8",
+ )
+ (receipts / "verify_999.json").write_text(
+ json.dumps(
+ {
+ "timestamp": "2026-08-19T09:00:00+00:00",
+ "provenance": {"receipt_id": "verify_999"},
+ }
+ ),
+ encoding="utf-8",
+ )
+ payload = {"hook_event_name": "sessionStart", "workspace_roots": [str(workspace)]}
+
+ self.assertIn("latest_receipt: exec_002", handle(payload)["additional_context"])
+
+ (receipts / "final.json").write_text("{}\n", encoding="utf-8")
+ self.assertIn("latest_receipt: final", handle(payload)["additional_context"])
+
+ def test_session_start_falls_back_to_receipt_number_without_timestamps(self) -> None:
+ with tempfile.TemporaryDirectory() as workspace_dir:
+ workspace = _enable_workspace(Path(workspace_dir))
+ state = workspace / ".sopify" / "state"
+ state.mkdir(parents=True)
+ (state / "active_plan.json").write_text(json.dumps({"plan_id": "plan_a"}), encoding="utf-8")
+ plan_dir = workspace / ".sopify" / "plan" / "plan_a"
+ receipts = plan_dir / "receipts"
+ receipts.mkdir(parents=True)
+ _write_light_plan(plan_dir)
+ (receipts / "exec_100.json").write_text("{}\n", encoding="utf-8")
+ (receipts / "verify_999.json").write_text("{}\n", encoding="utf-8")
+
+ result = handle({"hook_event_name": "sessionStart", "workspace_roots": [str(workspace)]})
+
+ self.assertIn("latest_receipt: verify_999", result["additional_context"])
+
+ def test_session_start_does_not_guess_between_multiple_enabled_workspaces(self) -> None:
+ with tempfile.TemporaryDirectory() as first_dir, tempfile.TemporaryDirectory() as second_dir:
+ first = _enable_workspace(Path(first_dir))
+ second = _enable_workspace(Path(second_dir))
+ for workspace, plan_id in ((first, "plan_first"), (second, "plan_second")):
+ state = workspace / ".sopify" / "state"
+ state.mkdir(parents=True)
+ (state / "active_plan.json").write_text(
+ json.dumps({"plan_id": plan_id}),
+ encoding="utf-8",
+ )
+ _write_light_plan(workspace / ".sopify" / "plan" / plan_id)
+
+ ambiguous = handle(
+ {
+ "hook_event_name": "sessionStart",
+ "workspace_roots": [str(first), str(second)],
+ }
+ )["additional_context"]
+ self.assertIn("multiple enabled workspaces", ambiguous)
+ self.assertNotIn("plan_first", ambiguous)
+ self.assertNotIn("plan_second", ambiguous)
+
+ selected = handle(
+ {
+ "hook_event_name": "sessionStart",
+ "workspace_roots": [str(first), str(second)],
+ "cwd": str(second),
+ }
+ )["additional_context"]
+ self.assertIn("active_plan: plan_second", selected)
+ self.assertNotIn("plan_first", selected)
+
+ def test_session_start_skips_invalid_plan_package(self) -> None:
+ with tempfile.TemporaryDirectory() as workspace_dir:
+ workspace = _enable_workspace(Path(workspace_dir))
+ state = workspace / ".sopify" / "state"
+ state.mkdir(parents=True)
+ (state / "active_plan.json").write_text(
+ json.dumps({"plan_id": "plan_a"}),
+ encoding="utf-8",
+ )
+ plan_dir = workspace / ".sopify" / "plan" / "plan_a"
+ plan_dir.mkdir(parents=True)
+ (plan_dir / "plan.md").write_text("# missing contract frontmatter\n", encoding="utf-8")
+
+ self.assertEqual(
+ handle({"hook_event_name": "sessionStart", "workspace_roots": [str(workspace)]}),
+ {},
+ )
+
+ def test_pre_tool_use_denies_strreplace_on_state_allows_plan_docs(self) -> None:
+ with tempfile.TemporaryDirectory() as workspace_dir:
+ workspace = _enable_workspace(Path(workspace_dir))
+ deny = handle(
+ {
+ "hook_event_name": "preToolUse",
+ "workspace_roots": [str(workspace)],
+ "tool_name": "StrReplace",
+ "tool_input": {
+ "path": str(workspace / ".sopify" / "state" / "active_plan.json"),
+ "old_string": "x",
+ "new_string": "y",
+ },
+ }
+ )
+ self.assertEqual(deny["permission"], "deny")
+
+ receipt_deny = handle(
+ {
+ "hook_event_name": "preToolUse",
+ "workspace_roots": [str(workspace)],
+ "tool_name": "Write",
+ "tool_input": {
+ "path": ".sopify/plan/20260819_cursor_support/receipts/verify_001.json",
+ "contents": "{}",
+ },
+ }
+ )
+ self.assertEqual(receipt_deny["permission"], "deny")
+
+ allow = handle(
+ {
+ "hook_event_name": "preToolUse",
+ "workspace_roots": [str(workspace)],
+ "tool_name": "StrReplace",
+ "tool_input": {
+ "path": str(workspace / ".sopify" / "plan" / "20260819_cursor_support" / "plan.md"),
+ "old_string": "read .sopify/state/active_plan.json",
+ "new_string": "keep mentioning current_handoff.json",
+ },
+ }
+ )
+ self.assertEqual(allow, {"permission": "allow"})
+
+ def test_pre_tool_use_selects_the_target_workspace_in_multi_root_input(self) -> None:
+ with tempfile.TemporaryDirectory() as first_dir, tempfile.TemporaryDirectory() as second_dir:
+ first = _enable_workspace(Path(first_dir))
+ second = _enable_workspace(Path(second_dir))
+
+ denied = handle(
+ {
+ "hook_event_name": "preToolUse",
+ "workspace_roots": [str(first), str(second)],
+ "tool_name": "Write",
+ "tool_input": {"path": str(second / ".sopify" / "state" / "active_plan.json")},
+ }
+ )
+
+ self.assertEqual(denied["permission"], "deny")
+
+ def test_before_shell_allows_writer_and_denies_obvious_direct_write(self) -> None:
+ with tempfile.TemporaryDirectory() as workspace_dir:
+ workspace = _enable_workspace(Path(workspace_dir))
+ self.assertEqual(
+ handle(
+ {
+ "hook_event_name": "beforeShellExecution",
+ "cwd": str(workspace),
+ "command": "python3 -c 'from sopify_writer import ProtocolStore; print(1)'",
+ }
+ ),
+ {"permission": "allow"},
+ )
+ denied = handle(
+ {
+ "hook_event_name": "beforeShellExecution",
+ "cwd": str(workspace),
+ "command": "echo '{}' > .sopify/state/current_handoff.json",
+ }
+ )
+ self.assertEqual(denied["permission"], "deny")
+ marker_bypass = handle(
+ {
+ "hook_event_name": "beforeShellExecution",
+ "cwd": str(workspace),
+ "command": "echo '{}' > .sopify/state/active_plan.json # sopify_writer",
+ }
+ )
+ self.assertEqual(marker_bypass["permission"], "deny")
+ no_space_redirect = handle(
+ {
+ "hook_event_name": "beforeShellExecution",
+ "cwd": str(workspace),
+ "command": "echo '{}' >.sopify/state/active_plan.json",
+ }
+ )
+ self.assertEqual(no_space_redirect["permission"], "deny")
+ for command in (
+ "echo '{}' >.sopify/plan/plan_a/receipts/exec_003.json",
+ "echo '{}' >>.sopify/plan/plan_a/receipts/exec_003.json",
+ "echo '{}'>.sopify/plan/plan_a/receipts/exec_003.json",
+ ):
+ with self.subTest(command=command):
+ receipt_redirect = handle(
+ {
+ "hook_event_name": "beforeShellExecution",
+ "cwd": str(workspace),
+ "command": command,
+ }
+ )
+ self.assertEqual(receipt_redirect["permission"], "deny")
+ chained_receipt = handle(
+ {
+ "hook_event_name": "beforeShellExecution",
+ "cwd": str(workspace),
+ "command": "echo '{}' > .sopify/plan/plan_a/receipts/exec_003.json && echo done",
+ }
+ )
+ self.assertEqual(chained_receipt["permission"], "deny")
+ self.assertEqual(
+ handle(
+ {
+ "hook_event_name": "beforeShellExecution",
+ "cwd": str(workspace),
+ "command": "python3 -c \"open('.sopify/state/active_plan.json', 'w').write('x')\"",
+ }
+ ),
+ {"permission": "allow"},
+ )
+ self.assertEqual(
+ handle(
+ {
+ "hook_event_name": "beforeShellExecution",
+ "cwd": str(workspace),
+ "command": "ls .sopify/state",
+ }
+ ),
+ {"permission": "allow"},
+ )
+
+ def test_unknown_event_does_not_guess_from_payload_shape(self) -> None:
+ with tempfile.TemporaryDirectory() as workspace_dir:
+ workspace = _enable_workspace(Path(workspace_dir))
+ self.assertEqual(
+ handle({"workspace_roots": [str(workspace)], "session_id": "session"}),
+ {},
+ )
+ self.assertEqual(
+ handle({"workspace_roots": [str(workspace)], "command": "echo hi"}),
+ {},
+ )
+
+ def test_main_fail_open_on_invalid_json(self) -> None:
+ with patch("sys.stdin", io.StringIO("not-json")), patch("sys.stdout", io.StringIO()) as out:
+ self.assertEqual(main(), 0)
+ self.assertEqual(out.getvalue(), "{}\n")
+
+
+class CursorUserHooksInstallTests(unittest.TestCase):
+ def test_merges_sopify_hooks_and_preserves_existing_entries(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir:
+ home_root = Path(home_dir)
+ hooks_path = home_root / ".cursor" / "hooks.json"
+ hooks_path.parent.mkdir(parents=True)
+ hooks_path.write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "hooks": {
+ "sessionStart": [{"command": "echo mine"}],
+ "stop": {"command": "echo leftover"},
+ },
+ },
+ indent=2,
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+ payload_root = _payload_with_helper(home_root)
+ written = install_cursor_user_hooks(home_root=home_root, payload_root=payload_root)
+ payload = json.loads(written.read_text(encoding="utf-8"))
+ session_commands = [item["command"] for item in payload["hooks"]["sessionStart"]]
+ self.assertIn("echo mine", session_commands)
+ self.assertTrue(any("helpers/cursor_hook.py" in command for command in session_commands))
+ self.assertEqual(payload["hooks"]["stop"]["command"], "echo leftover")
+ self.assertTrue(any("helpers/cursor_hook.py" in item["command"] for item in payload["hooks"]["preToolUse"]))
+ self.assertTrue(
+ any("helpers/cursor_hook.py" in item["command"] for item in payload["hooks"]["beforeShellExecution"])
+ )
+ self.assertTrue(all(item.get("failClosed") is False for item in payload["hooks"]["preToolUse"]))
+ present, detail = sopify_hooks_are_present(home_root=home_root, payload_root=payload_root)
+ self.assertTrue(present)
+ self.assertIsNone(detail)
+
+ install_cursor_user_hooks(home_root=home_root, payload_root=payload_root)
+ again = json.loads(written.read_text(encoding="utf-8"))
+ sopify_session = [item for item in again["hooks"]["sessionStart"] if "helpers/cursor_hook.py" in item["command"]]
+ self.assertEqual(len(sopify_session), 1)
+
+ def test_invalid_hooks_json_stops_without_overwrite(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir:
+ home_root = Path(home_dir)
+ hooks_path = home_root / ".cursor" / "hooks.json"
+ hooks_path.parent.mkdir(parents=True)
+ hooks_path.write_text("{not json\n", encoding="utf-8")
+ payload_root = _payload_with_helper(home_root)
+ with self.assertRaisesRegex(InstallError, "Refusing to overwrite invalid Cursor hooks.json"):
+ install_cursor_user_hooks(home_root=home_root, payload_root=payload_root)
+ self.assertEqual(hooks_path.read_text(encoding="utf-8"), "{not json\n")
+
+ def test_stale_or_fail_closed_sopify_hooks_are_not_reported_present(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir:
+ home_root = Path(home_dir)
+ payload_root = _payload_with_helper(home_root)
+ hooks_path = install_cursor_user_hooks(home_root=home_root, payload_root=payload_root)
+ payload = json.loads(hooks_path.read_text(encoding="utf-8"))
+ for event in ("sessionStart", "preToolUse", "beforeShellExecution"):
+ payload["hooks"][event][-1] = {
+ "command": "/missing/python /missing/.cursor/sopify/helpers/cursor_hook.py",
+ "failClosed": True,
+ }
+ hooks_path.write_text(json.dumps(payload), encoding="utf-8")
+
+ present, detail = sopify_hooks_are_present(home_root=home_root, payload_root=payload_root)
+
+ self.assertFalse(present)
+ self.assertIn("Stale or unsafe", detail or "")
+
+ def test_hook_health_accepts_a_different_existing_python_executable(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir:
+ home_root = Path(home_dir)
+ payload_root = _payload_with_helper(home_root)
+ helper = payload_root / "helpers" / "cursor_hook.py"
+ executable = home_root / "alternate-python"
+ executable.write_text("#!/bin/sh\n", encoding="utf-8")
+ executable.chmod(0o755)
+ hooks_path = install_cursor_user_hooks(home_root=home_root, payload_root=payload_root)
+ payload = json.loads(hooks_path.read_text(encoding="utf-8"))
+ command = shlex.join((str(executable), str(helper)))
+ for event in ("sessionStart", "preToolUse", "beforeShellExecution"):
+ payload["hooks"][event][-1]["command"] = command
+ hooks_path.write_text(json.dumps(payload), encoding="utf-8")
+
+ present, detail = sopify_hooks_are_present(home_root=home_root, payload_root=payload_root)
+
+ self.assertTrue(present)
+ self.assertIsNone(detail)
+
+ def test_hook_command_quotes_helper_paths_with_spaces(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ home_root = Path(temp_dir) / "home with spaces"
+ payload_root = _payload_with_helper(home_root)
+
+ hooks_path = install_cursor_user_hooks(home_root=home_root, payload_root=payload_root)
+ payload = json.loads(hooks_path.read_text(encoding="utf-8"))
+ command = payload["hooks"]["sessionStart"][-1]["command"]
+
+ self.assertEqual(shlex.split(command)[1], str((payload_root / "helpers" / "cursor_hook.py").resolve()))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_distribution.py b/tests/test_distribution.py
index 507e3675..4403922d 100644
--- a/tests/test_distribution.py
+++ b/tests/test_distribution.py
@@ -213,6 +213,53 @@ def test_distribution_install_with_workspace_reports_prewarmed_bundle(self) -> N
self.assertIn("workspace outcome: stub_selected [continue]", rendered)
self.assertIn("workspace bundle: pass (STUB_SELECTED)", rendered)
+ def test_cursor_distribution_reports_user_plugin_without_project_write(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as workspace_dir:
+ home_root = Path(home_dir)
+ workspace_root = Path(workspace_dir)
+ request = DistributionRequest(
+ target="cursor:zh-CN",
+ workspace=str(workspace_root),
+ ref_override=None,
+ interactive=False,
+ source_channel="repo-local",
+ source_metadata=DistributionSourceMetadata(
+ resolved_ref="working-tree",
+ asset_name="scripts/install_sopify.py",
+ ),
+ )
+
+ report = run_distribution_install(
+ request=request,
+ repo_root=REPO_ROOT,
+ home_root=home_root,
+ install_executor=run_install,
+ )
+
+ rendered = render_distribution_user_result(report)
+ rule_path = (
+ home_root.resolve()
+ / ".cursor"
+ / "plugins"
+ / "local"
+ / "sopify"
+ / "rules"
+ / "sopify.mdc"
+ )
+ readme_path = rule_path.parent.parent / "README.md"
+ self.assertIn(f"用户 Plugin 规则:{rule_path}", rendered)
+ self.assertTrue(readme_path.is_file())
+ self.assertIn("自适应工作流层", readme_path.read_text(encoding="utf-8"))
+ self.assertIn("未修改任何项目目录", rendered)
+ self.assertIn("Developer: Reload Window", rendered)
+ self.assertIn("确认 Sopify Plugin 及其 Always Rule 已启用", rendered)
+ self.assertFalse((workspace_root / ".cursor").exists())
+ self.assertFalse((workspace_root / ".sopify").exists())
+ self.assertNotIn("已预热", rendered)
+ verbose = render_distribution_result(report)
+ self.assertIn(f"workspace: project unchanged; user Plugin rule installed at {rule_path}", verbose)
+ self.assertNotIn("workspace: pre-warmed", verbose)
+
def test_distribution_install_rejects_ambiguous_nested_workspace_prewarm(self) -> None:
with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as repo_dir:
home_root = Path(home_dir)
diff --git a/tests/test_evidentloop_installer.py b/tests/test_evidentloop_installer.py
index 42838ab0..ffbc97f8 100644
--- a/tests/test_evidentloop_installer.py
+++ b/tests/test_evidentloop_installer.py
@@ -31,6 +31,7 @@
from installer.hosts.claude import CLAUDE_ADAPTER
from installer.hosts.codex import CODEX_ADAPTER
from installer.hosts.copilot import COPILOT_ADAPTER
+from installer.hosts.cursor import CURSOR_ADAPTER
from installer.hosts.qoder import QODER_ADAPTER
from installer.models import EvidentLoopInstallResult, InstallError
from scripts.install_sopify import build_parser, run_install
@@ -88,6 +89,7 @@ def test_official_sources_and_host_native_paths(self) -> None:
),
path,
)
+ self.assertIsNone(CURSOR_ADAPTER.skills_cli_agent)
def test_flag_is_disabled_by_default_and_does_no_companion_work(self) -> None:
parser = build_parser()
@@ -118,6 +120,17 @@ def test_flag_is_disabled_by_default_and_does_no_companion_work(self) -> None:
install.assert_not_called()
self.assertIsNone(result.evidentloop_install)
+ def test_cursor_rejects_explicit_evidentloop_flag(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as workspace_dir:
+ with self.assertRaisesRegex(InstallError, "does not support --with-evidentloop"):
+ run_install(
+ target_value="cursor",
+ workspace_value=workspace_dir,
+ repo_root=REPO_ROOT,
+ home_root=Path(home_dir),
+ with_evidentloop=True,
+ )
+
def test_missing_mapping_stops_before_command_lookup(self) -> None:
adapter = HostAdapter(
host_name="future-host",
diff --git a/tests/test_installer_status_doctor.py b/tests/test_installer_status_doctor.py
index f94af9b0..f68d5362 100644
--- a/tests/test_installer_status_doctor.py
+++ b/tests/test_installer_status_doctor.py
@@ -1,12 +1,14 @@
# Test classification: distribution
from __future__ import annotations
+from dataclasses import replace
import json
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
+from unittest.mock import patch
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
@@ -16,11 +18,14 @@
from installer.hosts.base import install_host_assets
from installer.hosts.claude import CLAUDE_ADAPTER
from installer.hosts.codex import CODEX_ADAPTER
+from installer.hosts.cursor import CURSOR_ADAPTER
from installer.inspection import build_doctor_payload, build_status_payload, render_doctor_text, render_status_text
+from installer.models import InstallError
from installer.payload import _REQUIRED_BUNDLE_CAPABILITIES, install_global_payload, run_workspace_bootstrap
from installer.validate import validate_host_install, validate_payload_install
from scripts.sopify_doctor import main as doctor_main
from scripts.sopify_status import main as status_main
+from scripts.install_sopify import run_install
def _write_json(path: Path, payload: dict[str, object]) -> None:
@@ -80,8 +85,317 @@ def test_installable_hosts_only_return_install_enabled_entries(self) -> None:
installable = [capability.host_id for capability in iter_installable_hosts()]
declared = [capability.host_id for capability in iter_declared_hosts()]
- self.assertEqual(set(installable), {"codex", "claude", "copilot", "qoder"})
- self.assertEqual(set(declared), {"codex", "claude", "copilot", "qoder"})
+ self.assertEqual(set(installable), {"codex", "claude", "copilot", "cursor", "qoder"})
+ self.assertEqual(set(declared), {"codex", "claude", "copilot", "cursor", "qoder"})
+
+ def test_cursor_capability_is_baseline_split_surface(self) -> None:
+ cursor = get_host_capability("cursor")
+ adapter = CURSOR_ADAPTER
+
+ self.assertEqual(cursor.support_tier.value, "baseline_supported")
+ self.assertTrue(cursor.install_enabled)
+ self.assertEqual(adapter.destination_dirname, ".cursor")
+ self.assertEqual(
+ adapter.instruction_file_relpath,
+ ".cursor/plugins/local/sopify/rules/sopify.mdc",
+ )
+ self.assertTrue(adapter.is_user_plugin_scope)
+ self.assertFalse(adapter.is_workspace_scope)
+ self.assertFalse(adapter.requires_workspace)
+ self.assertIsNone(adapter.skills_cli_agent)
+ self.assertEqual(adapter.skill_install_dirname, ".cursor/skills")
+ self.assertEqual(cursor.smoke_targets, ())
+ self.assertEqual(tuple(mode.value for mode in cursor.entry_modes), ("prompt_only", "hooks"))
+ self.assertIn("cursor_hooks_present", cursor.doctor_checks)
+
+ def test_cursor_install_proves_split_paths_without_touching_settings(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as workspace_dir:
+ home_root = Path(home_dir)
+ workspace_root = Path(workspace_dir)
+ settings = home_root / ".cursor" / "settings.json"
+ settings.parent.mkdir(parents=True)
+ settings.write_text('{"proxy": "127.0.0.1:7898"}\n', encoding="utf-8")
+
+ result = run_install(
+ target_value="cursor",
+ workspace_value=str(workspace_root),
+ repo_root=REPO_ROOT,
+ home_root=home_root,
+ )
+
+ plugin_root = home_root / ".cursor" / "plugins" / "local" / "sopify"
+ manifest = plugin_root / ".cursor-plugin" / "plugin.json"
+ rule = plugin_root / "rules" / "sopify.mdc"
+ readme = plugin_root / "README.md"
+ skill_root = home_root / ".cursor" / "skills" / "sopify"
+ self.assertTrue(manifest.is_file())
+ self.assertTrue(rule.is_file())
+ self.assertTrue(readme.is_file())
+ manifest_payload = json.loads(manifest.read_text(encoding="utf-8"))
+ self.assertNotIn("rules", manifest_payload)
+ self.assertIn("adaptive workflow layer", readme.read_text(encoding="utf-8"))
+ self.assertIn("BASELINE_SUPPORTED", readme.read_text(encoding="utf-8"))
+ self.assertTrue((skill_root / "analyze" / "SKILL.md").is_file())
+ self.assertTrue((skill_root / "design" / "SKILL.md").is_file())
+ self.assertTrue((skill_root / "develop" / "SKILL.md").is_file())
+ self.assertTrue((skill_root / "kb" / "SKILL.md").is_file())
+ self.assertTrue((skill_root / "templates" / "SKILL.md").is_file())
+ self.assertTrue((skill_root / "references" / "shared-writing-dna.md").is_file())
+ self.assertTrue((skill_root / "references" / "output-contract.md").is_file())
+ self.assertTrue((skill_root / "analyze" / "scripts" / "score_requirement.py").is_file())
+ self.assertTrue((home_root / ".cursor" / "sopify" / "payload-manifest.json").is_file())
+ self.assertTrue((home_root / ".cursor" / "sopify" / "bundles").is_dir())
+ self.assertEqual(settings.read_text(encoding="utf-8"), '{"proxy": "127.0.0.1:7898"}\n')
+ self.assertFalse((home_root / ".cursor" / "AGENTS.md").exists())
+ self.assertFalse((workspace_root / ".cursor" / "skills").exists())
+ self.assertFalse((workspace_root / ".cursor").exists())
+ self.assertFalse((workspace_root / ".sopify").exists())
+ self.assertFalse((workspace_root / ".cursor" / "hooks.json").exists())
+ self.assertTrue((home_root / ".cursor" / "hooks.json").is_file())
+ self.assertTrue((home_root / ".cursor" / "sopify" / "helpers" / "cursor_hook.py").is_file())
+ self.assertIn("alwaysApply: true", rule.read_text(encoding="utf-8"))
+ self.assertIn("~/.cursor/skills/sopify/references/shared-writing-dna.md", rule.read_text(encoding="utf-8"))
+ self.assertIn("Cursor IDE", rule.read_text(encoding="utf-8"))
+ self.assertIn("does not automatically load", rule.read_text(encoding="utf-8"))
+ self.assertNotIn(str(skill_root), rule.read_text(encoding="utf-8"))
+ self.assertIsNone(result.workspace_bootstrap)
+ self.assertEqual(result.target.host, "cursor")
+
+ doctor = build_doctor_payload(home_root=home_root, workspace_root=workspace_root)
+ cursor_checks = {check["check_id"]: check for check in doctor["checks"] if check.get("host_id") == "cursor"}
+ self.assertEqual(cursor_checks["cursor_plugin_present"]["status"], "pass")
+ self.assertEqual(cursor_checks["global_skill_tree_present"]["status"], "pass")
+ self.assertEqual(cursor_checks["payload_present"]["status"], "pass")
+ self.assertEqual(cursor_checks["cursor_hooks_present"]["status"], "pass")
+ self.assertEqual(cursor_checks["cursor_ide_behavior"]["reason_code"], "BLACK_BOX_NOT_VERIFIED")
+ self.assertEqual(cursor_checks["cursor_cli_behavior"]["reason_code"], "BLACK_BOX_NOT_VERIFIED")
+ self.assertNotEqual(doctor["summary"]["overall_status"], "fail")
+ cursor_fails = [
+ check
+ for check in doctor["checks"]
+ if check.get("host_id") == "cursor" and check["status"] == "fail"
+ ]
+ self.assertEqual(cursor_fails, [])
+
+ status = build_status_payload(home_root=home_root, workspace_root=workspace_root)
+ cursor_status = next(host for host in status["hosts"] if host["host_id"] == "cursor")
+ self.assertEqual(cursor_status["state"]["installed"], "yes")
+ self.assertEqual(cursor_status["state"]["configured"], "yes")
+ self.assertEqual(cursor_status["state"]["workspace_bundle_healthy"], "not_requested")
+
+ readme.unlink()
+ doctor_without_readme = build_doctor_payload(
+ home_root=home_root,
+ workspace_root=workspace_root,
+ )
+ plugin_check = next(
+ check
+ for check in doctor_without_readme["checks"]
+ if check.get("host_id") == "cursor"
+ and check["check_id"] == "cursor_plugin_present"
+ )
+ self.assertEqual(plugin_check["status"], "pass")
+
+ status_without_readme = build_status_payload(
+ home_root=home_root,
+ workspace_root=workspace_root,
+ )
+ cursor_without_readme = next(
+ host
+ for host in status_without_readme["hosts"]
+ if host["host_id"] == "cursor"
+ )
+ self.assertEqual(cursor_without_readme["state"]["installed"], "yes")
+
+ repair_result = run_install(
+ target_value="cursor",
+ workspace_value=str(workspace_root),
+ repo_root=REPO_ROOT,
+ home_root=home_root,
+ )
+ self.assertEqual(repair_result.host_install.action, "updated")
+ self.assertTrue(readme.is_file())
+
+ def test_cursor_install_preflights_invalid_hooks_before_any_product_write(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as workspace_dir:
+ home_root = Path(home_dir)
+ workspace_root = Path(workspace_dir)
+ hooks = home_root / ".cursor" / "hooks.json"
+ settings = home_root / ".cursor" / "settings.json"
+ hooks.parent.mkdir(parents=True)
+ hooks.write_text("{not json\n", encoding="utf-8")
+ settings.write_text('{"proxy": "127.0.0.1:7898"}\n', encoding="utf-8")
+
+ with self.assertRaisesRegex(InstallError, "Refusing to overwrite invalid Cursor hooks.json"):
+ run_install(
+ target_value="cursor",
+ workspace_value=str(workspace_root),
+ repo_root=REPO_ROOT,
+ home_root=home_root,
+ )
+
+ self.assertEqual(hooks.read_text(encoding="utf-8"), "{not json\n")
+ self.assertEqual(settings.read_text(encoding="utf-8"), '{"proxy": "127.0.0.1:7898"}\n')
+ self.assertFalse((home_root / ".cursor" / "plugins" / "local" / "sopify").exists())
+ self.assertFalse((home_root / ".cursor" / "skills" / "sopify").exists())
+ self.assertFalse((home_root / ".cursor" / "sopify").exists())
+
+ def test_cursor_doctor_rejects_stale_or_fail_closed_hooks(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as workspace_dir:
+ home_root = Path(home_dir)
+ workspace_root = Path(workspace_dir)
+ run_install(
+ target_value="cursor",
+ workspace_value=str(workspace_root),
+ repo_root=REPO_ROOT,
+ home_root=home_root,
+ )
+ hooks_path = home_root / ".cursor" / "hooks.json"
+ hooks_payload = json.loads(hooks_path.read_text(encoding="utf-8"))
+ for event in ("sessionStart", "preToolUse", "beforeShellExecution"):
+ hooks_payload["hooks"][event][-1] = {
+ "command": "/missing/python /missing/.cursor/sopify/helpers/cursor_hook.py",
+ "failClosed": True,
+ }
+ hooks_path.write_text(json.dumps(hooks_payload), encoding="utf-8")
+
+ doctor = build_doctor_payload(home_root=home_root, workspace_root=workspace_root)
+ cursor_hooks = next(
+ check
+ for check in doctor["checks"]
+ if check.get("host_id") == "cursor" and check["check_id"] == "cursor_hooks_present"
+ )
+
+ self.assertEqual(cursor_hooks["status"], "fail")
+ self.assertTrue(any("Stale or unsafe" in item for item in cursor_hooks.get("evidence", [])))
+
+ def test_user_plugin_surface_is_not_a_generic_hook_framework(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as workspace_dir:
+ home_root = Path(home_dir)
+ workspace_root = Path(workspace_dir)
+ future_adapter = replace(
+ CURSOR_ADAPTER,
+ host_name="future-project-rules",
+ destination_dirname=".future-project-rules",
+ config_dir="~/.future-project-rules",
+ skill_install_dirname=".future-project-rules/skills",
+ )
+
+ with patch("scripts.install_sopify.get_host_adapter", return_value=future_adapter):
+ with self.assertRaisesRegex(InstallError, "incompatible host adapter"):
+ run_install(
+ target_value="cursor",
+ workspace_value=str(workspace_root),
+ repo_root=REPO_ROOT,
+ home_root=home_root,
+ )
+
+ self.assertFalse((home_root / ".cursor" / "hooks.json").exists())
+ self.assertFalse(
+ (home_root / ".future-project-rules" / "sopify" / "helpers" / "cursor_hook.py").exists()
+ )
+
+ def test_cursor_doctor_skips_when_only_ide_settings_exist(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir:
+ home_root = Path(home_dir)
+ settings = home_root / ".cursor" / "settings.json"
+ settings.parent.mkdir(parents=True)
+ settings.write_text("{}\n", encoding="utf-8")
+
+ doctor = build_doctor_payload(home_root=home_root, workspace_root=None)
+ cursor_checks = {check["check_id"]: check for check in doctor["checks"] if check.get("host_id") == "cursor"}
+ self.assertEqual(cursor_checks["cursor_plugin_present"]["status"], "skip")
+ self.assertEqual(cursor_checks["global_skill_tree_present"]["status"], "skip")
+ self.assertEqual(cursor_checks["payload_present"]["status"], "skip")
+ self.assertEqual(cursor_checks["cursor_hooks_present"]["status"], "skip")
+ self.assertNotEqual(cursor_checks["cursor_plugin_present"]["status"], "fail")
+ self.assertEqual(doctor["summary"]["fail_count"], 0)
+
+ status = build_status_payload(home_root=home_root, workspace_root=None)
+ cursor_status = next(host for host in status["hosts"] if host["host_id"] == "cursor")
+ self.assertEqual(cursor_status["state"]["installed"], "no")
+
+ def test_cursor_doctor_does_not_skip_a_stale_sopify_hook_only_install(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir:
+ home_root = Path(home_dir)
+ hooks_path = home_root / ".cursor" / "hooks.json"
+ _write_json(
+ hooks_path,
+ {
+ "version": 1,
+ "hooks": {
+ "sessionStart": [
+ {
+ "command": "/missing/.cursor/sopify/helpers/cursor_hook.py",
+ "failClosed": False,
+ }
+ ]
+ },
+ },
+ )
+
+ doctor = build_doctor_payload(home_root=home_root, workspace_root=None)
+ cursor_hooks = next(
+ check
+ for check in doctor["checks"]
+ if check.get("host_id") == "cursor" and check["check_id"] == "cursor_hooks_present"
+ )
+
+ self.assertEqual(cursor_hooks["status"], "fail")
+
+ def test_cursor_status_installed_requires_plugin_and_skills(self) -> None:
+ with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as workspace_dir:
+ home_root = Path(home_dir)
+ workspace_root = Path(workspace_dir)
+ run_install(
+ target_value="cursor",
+ workspace_value=str(workspace_root),
+ repo_root=REPO_ROOT,
+ home_root=home_root,
+ )
+ plugin_rule = (
+ home_root / ".cursor" / "plugins" / "local" / "sopify" / "rules" / "sopify.mdc"
+ )
+ plugin_rule.unlink()
+
+ missing_rule = build_status_payload(home_root=home_root, workspace_root=workspace_root)
+ cursor_missing = next(host for host in missing_rule["hosts"] if host["host_id"] == "cursor")
+ self.assertEqual(cursor_missing["state"]["installed"], "no")
+
+ run_install(
+ target_value="cursor",
+ workspace_value=None,
+ repo_root=REPO_ROOT,
+ home_root=home_root,
+ )
+ (home_root / ".cursor" / "skills" / "sopify" / "analyze" / "SKILL.md").unlink()
+ missing_skill = build_status_payload(home_root=home_root, workspace_root=None)
+ cursor_missing_skill = next(host for host in missing_skill["hosts"] if host["host_id"] == "cursor")
+ self.assertEqual(cursor_missing_skill["state"]["installed"], "no")
+
+ for required_path in (
+ home_root / ".cursor" / "skills" / "sopify" / "references" / "output-contract.md",
+ home_root
+ / ".cursor"
+ / "skills"
+ / "sopify"
+ / "analyze"
+ / "scripts"
+ / "score_requirement.py",
+ ):
+ with self.subTest(required_path=required_path):
+ run_install(
+ target_value="cursor",
+ workspace_value=None,
+ repo_root=REPO_ROOT,
+ home_root=home_root,
+ )
+ required_path.unlink()
+ incomplete = build_status_payload(home_root=home_root, workspace_root=None)
+ cursor_incomplete = next(
+ host for host in incomplete["hosts"] if host["host_id"] == "cursor"
+ )
+ self.assertEqual(cursor_incomplete["state"]["installed"], "no")
class StatusDoctorContractTests(unittest.TestCase):
diff --git a/tests/test_plain_language_contracts.py b/tests/test_plain_language_contracts.py
index 621f72b7..d976cc42 100644
--- a/tests/test_plain_language_contracts.py
+++ b/tests/test_plain_language_contracts.py
@@ -199,6 +199,29 @@ def test_static_pages_keep_release_metadata(
assert (REPO_ROOT / ".nojekyll").is_file()
+@pytest.mark.parametrize(
+ "page,host_label,install_copy",
+ [
+ (
+ "index.html",
+ "Cursor · baseline",
+ "Codex, Claude, Qoder, Copilot, and Cursor targets",
+ ),
+ (
+ "zh-CN.html",
+ "Cursor · 基线支持",
+ "支持 Codex、Claude、Qoder、Copilot 与 Cursor",
+ ),
+ ],
+)
+def test_static_pages_include_cursor_host(
+ page: str, host_label: str, install_copy: str
+) -> None:
+ html = (REPO_ROOT / page).read_text(encoding="utf-8")
+ assert host_label in html
+ assert install_copy in html
+
+
@pytest.mark.parametrize(
"page,readme,asset,label,required",
[
diff --git a/tests/test_plan_contract_assets.py b/tests/test_plan_contract_assets.py
index 6e48a2e9..617de36e 100644
--- a/tests/test_plan_contract_assets.py
+++ b/tests/test_plan_contract_assets.py
@@ -12,7 +12,7 @@
REPO_ROOT = Path(__file__).resolve().parents[1]
BUILTIN_SKILL_IDS = ("analyze", "design", "develop", "kb", "templates")
-SUPPORTED_HOSTS = ["codex", "claude", "qoder", "copilot"]
+SUPPORTED_HOSTS = ["codex", "claude", "qoder", "copilot", "cursor"]
class PlanContractAssetTests(unittest.TestCase):
@@ -118,6 +118,57 @@ def test_rendered_host_headers_keep_the_same_contract(self) -> None:
self.assertIn("architecture", rendered)
self.assertNotIn("light/standard/full", rendered)
+ def test_cursor_plugin_rule_uses_legal_frontmatter_and_thin_routing_contract(self) -> None:
+ render_script = REPO_ROOT / "scripts" / "render-host-skills.py"
+ for language in ("en", "zh"):
+ with self.subTest(language=language), tempfile.TemporaryDirectory() as temp_dir:
+ output = Path(temp_dir) / "sopify.mdc"
+ subprocess.run(
+ [
+ sys.executable,
+ str(render_script),
+ "--hosts-file",
+ str(REPO_ROOT / "skills" / "hosts.yaml"),
+ "--skills-root",
+ str(REPO_ROOT / "skills"),
+ "--lang",
+ language,
+ "--host",
+ "cursor",
+ "--output",
+ str(output),
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ rendered = output.read_text(encoding="utf-8")
+ self.assertTrue(rendered.startswith("---\n"))
+ self.assertIn("alwaysApply: true", rendered)
+ self.assertIn("~/.cursor/skills/sopify/references/shared-writing-dna.md", rendered)
+ self.assertIn("scripts/score_requirement.py", rendered)
+ self.assertIn("auto_decide", rendered)
+ self.assertIn("AskQuestion", rendered)
+ self.assertIn("ask_question", rendered)
+ self.assertIn("GetMcpTools", rendered)
+ self.assertIn("Design/Develop", rendered)
+ self.assertIn(
+ "free-form input" if language == "en" else "自由输入", rendered
+ )
+ self.assertNotRegex(rendered, r"(?m)^/.+/.cursor/skills/sopify")
+ self.assertIn("analyze/SKILL.md", rendered)
+ self.assertIn("consult_readonly", rendered)
+ self.assertIn("IDE Entry" if language == "en" else "IDE 入口", rendered)
+ self.assertIn(
+ "does not automatically load" if language == "en" else "不会自动加载",
+ rendered,
+ )
+ self.assertIn("~/.claude", rendered)
+ self.assertIn("~/.codex", rendered)
+ self.assertIn("state/active_plan.json", rendered)
+ self.assertIn("sopify_writer", rendered)
+ self.assertNotIn("{{skills_root}}", rendered)
+
def test_develop_completion_requires_explicit_finalize(self) -> None:
retired_phrases = {
"zh": ("迁移方案至 history/", "方案完成后迁移到 `history/`", "## 步骤 4:方案迁移"),
diff --git a/tests/test_release_hooks.py b/tests/test_release_hooks.py
index c7b9eb75..1b372522 100644
--- a/tests/test_release_hooks.py
+++ b/tests/test_release_hooks.py
@@ -97,6 +97,18 @@ def _minimal_source_template(version: str, *, english: bool) -> str:
)
+def _minimal_cursor_rule(version: str) -> str:
+ return textwrap.dedent(
+ f"""\
+ ---
+ description: "Sopify workflow entry for Cursor IDE"
+ alwaysApply: true
+ ---
+
+ """
+ )
+
+
def _minimal_agents(version: str, *, claude: bool, english: bool) -> str:
header = "CLAUDE" if claude else "AGENTS"
body = "Note: ~/.claude/sopify/" if claude else "说明:~/.codex/sopify/"
@@ -162,6 +174,8 @@ def _init_release_hook_fixture(root: Path, *, inject_sync_failure: bool = False)
# Source templates (skills/ is the source of truth)
_write(root / "skills/zh/header.md.template", _minimal_source_template(old_version, english=False))
_write(root / "skills/en/header.md.template", _minimal_source_template(old_version, english=True))
+ _write(root / "skills/zh/cursor-plugin-rule.mdc.template", _minimal_cursor_rule(old_version))
+ _write(root / "skills/en/cursor-plugin-rule.mdc.template", _minimal_cursor_rule(old_version))
_write(root / "skills/zh/skills/sopify/SKILL.md", "# skill\n")
_write(root / "skills/en/skills/sopify/SKILL.md", "# skill\n")
@@ -295,6 +309,32 @@ def test_release_sync_auto_drafts_unreleased_before_version_bump(self) -> None:
self.assertIn("badge/version-2026--03--21.010203-orange.svg", (root / "README.md").read_text(encoding="utf-8"))
self.assertIn("", (root / "skills/zh/header.md.template").read_text(encoding="utf-8"))
self.assertIn("", (root / "skills/en/header.md.template").read_text(encoding="utf-8"))
+ self.assertIn(
+ "",
+ (root / "skills/zh/cursor-plugin-rule.mdc.template").read_text(encoding="utf-8"),
+ )
+ self.assertIn(
+ "",
+ (root / "skills/en/cursor-plugin-rule.mdc.template").read_text(encoding="utf-8"),
+ )
+
+ def test_version_consistency_rejects_stale_cursor_rule(self) -> None:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ root = Path(temp_dir)
+ _init_release_hook_fixture(root)
+ _write(root / "skills/en/cursor-plugin-rule.mdc.template", _minimal_cursor_rule("stale-version"))
+
+ completed = subprocess.run(
+ ["bash", str(root / "scripts" / "check-version-consistency.sh")],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ check=False,
+ env=_git_subprocess_env(),
+ )
+
+ self.assertNotEqual(completed.returncode, 0)
+ self.assertIn("Source SOPIFY_VERSION mismatch", completed.stdout)
def test_release_draft_only_renders_non_empty_sections(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
@@ -401,6 +441,7 @@ def test_pre_commit_restores_release_managed_files_when_release_sync_fails(self)
original_readme = (root / "README.md").read_text(encoding="utf-8")
original_changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8")
original_template = (root / "skills/zh/header.md.template").read_text(encoding="utf-8")
+ original_cursor_rule = (root / "skills/zh/cursor-plugin-rule.mdc.template").read_text(encoding="utf-8")
completed = subprocess.run(
["bash", str(root / ".githooks" / "pre-commit")],
@@ -415,6 +456,10 @@ def test_pre_commit_restores_release_managed_files_when_release_sync_fails(self)
self.assertEqual((root / "README.md").read_text(encoding="utf-8"), original_readme)
self.assertEqual((root / "CHANGELOG.md").read_text(encoding="utf-8"), original_changelog)
self.assertEqual((root / "skills/zh/header.md.template").read_text(encoding="utf-8"), original_template)
+ self.assertEqual(
+ (root / "skills/zh/cursor-plugin-rule.mdc.template").read_text(encoding="utf-8"),
+ original_cursor_rule,
+ )
self.assertFalse((root / ".git" / ".sopify-release-sync-state").exists())
diff --git a/zh-CN.html b/zh-CN.html
index 1af9c35c..000668e6 100644
--- a/zh-CN.html
+++ b/zh-CN.html
@@ -63,7 +63,7 @@ 可恢复、 安装 Sopify ↓
查看 GitHub
- 不用换编辑器,也不用多装一个 CLI。在现有宿主中输入 ~go 即可开始。
+ 不用换编辑器,也不用多装一个 CLI。安装到现有宿主,按对应宿主入口开始。
@@ -150,13 +150,14 @@ 收口归档
产品形态
宿主负责执行, Sopify 让工作可接续
Sopify 是装进现有 AI 编程宿主的工作流协议层。它提供共享规则,并把方案、决策和验证证据作为项目文件保存在 .sopify/。
- 明确要求继续或输入 ~go 后,宿主才会从这些记录接续未完成的方案;本地交接指针为同一工作区提供接续提示。
+ 明确要求继续或使用当前宿主支持的托管入口后,宿主才会从这些记录接续未完成的方案;本地交接指针为同一工作区提供接续提示。
.sopify/plan/…
Codex · 已验证
Claude · 已验证
Qoder · 已验证
Copilot · 基线支持
+ Cursor · 基线支持
装进你现有的宿主
- 然后输入 ~go
+ 选择宿主,然后开始
- 支持 Codex、Claude、Qoder 与 Copilot
+ 支持 Codex、Claude、Qoder、Copilot 与 Cursor
提供中文和英文指令
包含 macOS、Linux 与 Windows 安装路径
@@ -236,7 +237,7 @@ 先说最重要的
普通问题也会自动进入方案吗?
- 不会。普通问答和小范围修改直接处理;只有你明确说“继续”或使用 ~go 时,才恢复未完成的方案。
+ 不会。普通问答和小范围修改直接处理;只有你明确说“继续”或使用当前宿主的托管入口时,才恢复未完成的方案。
真正跟着项目走的是什么?
@@ -244,7 +245,7 @@ 先说最重要的
换宿主后能力完全一样吗?
- Codex、Claude 和 Qoder 已通过协议路径验证。Copilot 当前是 prompt 基线支持,payload 路径仍有限。
+ Codex、Claude 和 Qoder 已通过协议路径验证。Copilot 和 Cursor 为基线支持;Cursor 的 Plugin Rule 自动入口只适用于本地 IDE,不覆盖 Agent CLI 或 Cloud Agent。
必须安装 EvidentLoop 吗?