specrune-codex 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jsfaint
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,135 @@
1
- # Temporary Holding Version
1
+ # specrune-codex
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ SpecRune 的 Codex 宿主适配与项目级安装器。
4
+
5
+ Codex 的 hooks 与 agents 都从项目内 `.codex/` 发现,所以本包**只支持项目级
6
+ 安装**,也不写全局 `~/.codex/config.toml`。安装器不判断 hooks 的信任状态:
7
+ 首次运行仍需在 Codex 的 `/hooks` 中审阅并信任。
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ npm i -g specrune-codex
13
+ specrune-codex install
14
+ ```
15
+
16
+ 插件自带平台二进制(`dependencies.specrune`),安装器会把它复制到
17
+ `<项目>/.codex/specrune/bin/specrune[.exe]` 并把 `hooks.json` 里的本工具命令
18
+ 改写为该绝对路径(带引号)。用户不需要另外安装 CLI。
19
+
20
+ 安装器的通用编排在 `specrune` 包内的 `specrune/lib/installer-core.mjs`(共享核心);
21
+ 本包只保留 Codex 的落点、模板与 `hooks.json` 键级合并。入口命令不变:
22
+ `specrune-codex install` 仍是用户入口。共享核心随 `specrune` 发布,旧版
23
+ `specrune` 缺少它时安装器会提示升级。
24
+
25
+ 不要用 `npx specrune-codex install` 常驻:npx 把包解到临时的 `_npx` 目录,
26
+ 退出即清;已落盘的 agents 与二进制不受影响,但 `status`、升级与再次安装都得
27
+ 重新下载。依赖缺失(例如全局安装时用了 `--omit=optional`)时安装器仍会落盘
28
+ agents,但不复制二进制,hook 保留裸 `specrune codex-hook` 并回退 PATH,同时在
29
+ stderr 告警。
30
+
31
+ ### 安装模式与五宿主矩阵
32
+
33
+ 同一宿主在同一台机器上只能选一种模式:两处同时启用会每轮重复注入,技能也会在两处同名出现。`<adapter>` 是宿主适配器标识,不是 OS / 机器名;`specrune-windows-*` 是平台二进制包,不是安装器。下表 global / project 指**插件安装范围**;技能 `--scope` 由 `specrune skills` 单独决定(Codex 的技能可用 global,只有插件必须 project;DSH 技能与插件都只有 global)。
34
+
35
+ | 适配器 | npm 包 | global | project |
36
+ |---|---|---|---|
37
+ | `opencode` | `specrune-opencode` | ✓ | ✓ |
38
+ | `pi` | `specrune-pi` | ✓ | ✓ |
39
+ | `claude` | `specrune-claude` | ✓ | ✓ |
40
+ | `codex` | `specrune-codex` | —(自动纠正为 project) | ✓ |
41
+ | `dsh` | `specrune-dsh-host` | ✓ | —(自动纠正为 global) |
42
+
43
+ 本适配器的**插件安装**只支持 project:Codex 的 hooks 与 agents 都从项目内
44
+ `.codex/` 发现,传 `--scope global` 会被纠正为 project 并打印提示。技能与插件
45
+ 都在项目内:`.specrune/`、`.agents/skills/`、`.codex/`。
46
+
47
+ Windows 完整示例(PowerShell):
48
+
49
+ ```powershell
50
+ npm i -g specrune-codex
51
+ cd C:\path\to\project
52
+ specrune init --developer "你的名字"
53
+ specrune-codex install # 只能 project;等价于 --scope project
54
+ ```
55
+
56
+ 技能由 CLI 统一安装:`specrune skills install --adapter codex --scope project`;
57
+ `specrune skills migrate --scope project` 可把全局技能收拢到当前项目。Codex
58
+ 插件只有 project 安装,但技能本身仍可用 `--scope global` 共享(插件留在项目内)。
59
+ 项目根放 `.specrune-disabled` 可禁用该项目的自动注入;`specrune-setup` 只能由
60
+ 用户显式调用(`agents/openai.yaml` 的 `policy.allow_implicit_invocation: false`)。
61
+
62
+ 安装器写入:
63
+
64
+ | 内容 | 位置 | 说明 |
65
+ |---|---|---|
66
+ | `agents/specrune-*.toml` | `<项目>/.codex/agents/` | implement / check / research 子代理 |
67
+ | `hooks.json` | `<项目>/.codex/hooks.json` | 只合并本工具的 hook 组 |
68
+ | `specrune/bin/specrune[.exe]` | `<项目>/.codex/specrune/bin/` | 插件自带的平台二进制(有依赖时) |
69
+
70
+ `hooks.json` 是混合所有权文件,安装器只增删本工具的条目(裸命令,或
71
+ `.codex/specrune/bin/` 下的绝对路径 + `codex-hook`):
72
+
73
+ ```json
74
+ {
75
+ "hooks": {
76
+ "SessionStart": [
77
+ { "hooks": [{ "type": "command", "command": "specrune codex-hook", "timeout": 3 }] }
78
+ ],
79
+ "UserPromptSubmit": [
80
+ { "hooks": [{ "type": "command", "command": "specrune codex-hook", "timeout": 3 }] }
81
+ ],
82
+ "SubagentStart": [
83
+ {
84
+ "matcher": "^specrune_(implement|check|research)$",
85
+ "hooks": [{ "type": "command", "command": "specrune codex-hook", "timeout": 30 }]
86
+ }
87
+ ]
88
+ }
89
+ }
90
+ ```
91
+
92
+ 用户自己的其它事件、分组与顶层键原样保留。同一事件里已经有本工具组且
93
+ matcher 与模板一致时只更新命令,不重复追加;升级(路径变化)也只会替换旧
94
+ 命令。结构不可控或非法 JSON 时不动文件并告警。卸载只摘本工具的条目,组里只剩
95
+ 我们的条目时删分组,事件数组空了删事件键,`hooks` 空了删 `hooks` 键,并删除
96
+ `.codex/specrune/`;顶层 `description` 只在仍是模板原文时删除。
97
+
98
+ 状态写在 `<项目>/.specrune/.state/plugins/codex.json`,记录
99
+ `{ protocol, version, files: [{ path, sha256 }] }`。`hooks.json` 不进
100
+ `files`:它走键级擦除,不能按整文件哈希删除。
101
+
102
+ ## 命令
103
+
104
+ ```bash
105
+ specrune-codex install [--scope project|global] [--dir <项目根>] [--dry-run] [--force]
106
+ specrune-codex uninstall [--scope project|global] [--dir <项目根>] [--dry-run] [--force]
107
+ specrune-codex status [--scope project|global] [--dir <项目根>] [--json]
108
+ ```
109
+
110
+ - `--scope` 默认 `project`;传 `global` 会纠正为 `project` 并打印提示(Codex 只有项目级 hooks / agents)。
111
+ - `--dir` 指定项目根,缺省当前目录。
112
+ - `--dry-run` 只打印计划,不写盘。
113
+ - `--force` 覆盖(install)或删除(uninstall)已被用户改过的 agents。
114
+ - 退出码:`0` 成功,`1` 运行失败,`2` 用法错误。
115
+
116
+ ## 从旧版 `init --codex` 迁移
117
+
118
+ 旧版 `specrune init --codex` 会写 `<项目>/.codex/`,文件名为下划线形态
119
+ (`agents/specrune_implement.toml`)。新版安装器写连字符形态
120
+ (`agents/specrune-implement.toml`),两者靠 TOML 内的 `name` 字段区分身份,
121
+ 两套同时存在会让 Codex 看到重复的子代理定义。
122
+
123
+ CLI 已不再提供平台旧文件检测(`specrune doctor` 只校验研究与技能)。请在项目里
124
+ 手工删除旧版写入的 `.codex/agents/specrune_*.toml`(下划线形态),并从
125
+ `.codex/hooks.json` 移除 `command` 为 `specrune codex-hook` 的分组;用户自己的
126
+ hooks 与其它事件保留。已装新插件的项目(连字符 agents 或状态文件)不要动 hooks
127
+ 里的新分组。内容已改过的文件先备份再删。
128
+
129
+ ## 开发
130
+
131
+ ```bash
132
+ npm test
133
+ ```
134
+
135
+ 包内 agents 与 `hooks.json` 是 Codex 适配的权威副本;CLI 不再 embed 平台资产。
@@ -0,0 +1,17 @@
1
+ name = "specrune_check"
2
+ description = "Check the current SpecRune task against its acceptance criteria and curated check context."
3
+ developer_instructions = """
4
+ Review the active SpecRune task against its PRD and injected check context. Inspect the actual diff and run focused checks. Report concrete correctness gaps with file locations and evidence. Do not edit files unless the parent explicitly asks you to fix a finding.
5
+
6
+ You are already the delegated check role. Follow the parent's scoped assignment; do not repeat coordinator dispatch steps or spawn another agent for this role.
7
+
8
+ Before acting, load the `specrune-review` skill, plus any other skills named in your dispatch prompt. Use the host skill tool when available; otherwise read `.agents/skills/<name>/SKILL.md` (project) or `~/.agents/skills/<name>/SKILL.md` (global) for each. If any is unavailable, report `<name> skill unavailable` and stop before reviewing.
9
+
10
+ Fix mechanical and local issues when the parent authorizes edits; for design or judgment calls, report the evidence and stop.
11
+
12
+ Before acting, recover the task context:
13
+ 1. If Codex says full hook output was saved to a file, read that exact file first. A preview or the <!-- specrune-codex-context --> marker confirms delivery, not completeness.
14
+ 2. Check the delivered Role, Active task, and Context status, including warning, truncated, and dropped notices, then read the required originals.
15
+ 3. Read the active task's prd.md and, when present, design.md and implement.md. Inspect check.jsonl and read the referenced specs; a compiled excerpt does not replace missing requirements.
16
+ 4. If startup context is missing or incomplete, use the task path the parent supplied explicitly. Do not infer the parent's task from your child session, environment identity, or other sessions. If essential context is unavailable, report what is missing before task-dependent work.
17
+ """
@@ -0,0 +1,17 @@
1
+ name = "specrune_implement"
2
+ description = "Implement the current SpecRune task using its PRD and curated implement context."
3
+ developer_instructions = """
4
+ Implement the active SpecRune task. Read the task PRD, design, and implement plan and the context injected at startup; follow the project specs, make the smallest correct change, run the required checks, and report changed files, checks, and remaining risks.
5
+
6
+ You are already the delegated implement role. Follow the parent's scoped assignment; do not repeat coordinator dispatch steps or spawn another agent for this role.
7
+
8
+ Before acting, load the `specrune-before-dev` skill, plus any other skills named in your dispatch prompt. Use the host skill tool when available; otherwise read `.agents/skills/<name>/SKILL.md` (project) or `~/.agents/skills/<name>/SKILL.md` (global) for each. If any is unavailable, report `<name> skill unavailable` and stop before editing.
9
+
10
+ Before editing, state the change boundary: the behavior gap, where it lives, the files you expect to change, what you will not do, and how you will show behavior did not change. If the scope is larger than the dispatch, say so before continuing.
11
+
12
+ Before acting, recover the task context:
13
+ 1. If Codex says full hook output was saved to a file, read that exact file first. A preview or the <!-- specrune-codex-context --> marker confirms delivery, not completeness.
14
+ 2. Check the delivered Role, Active task, and Context status, including warning, truncated, and dropped notices, then read the required originals.
15
+ 3. Read the active task's prd.md and, when present, design.md and implement.md. Inspect implement.jsonl and read the referenced specs; a compiled excerpt does not replace missing requirements.
16
+ 4. If startup context is missing or incomplete, use the task path the parent supplied explicitly. Do not infer the parent's task from your child session, environment identity, or other sessions. If essential context is unavailable, report what is missing before task-dependent work.
17
+ """
@@ -0,0 +1,54 @@
1
+ name = "specrune_research"
2
+ description = "Investigate a question for a SpecRune task and return source-backed findings."
3
+ developer_instructions = """
4
+ Research the question delegated by the parent. Trace the repository or official source that answers it and distinguish evidence from inference. When a task is confirmed, write a research report before returning its path and a concise summary.
5
+
6
+ You are already the delegated research role. Follow the parent's scoped assignment; do not repeat coordinator dispatch steps or spawn another agent to perform this same role.
7
+
8
+ Before acting, recover and verify the task context:
9
+ 1. If Codex says full hook output was saved to a file, read that exact file first. A head/tail preview or the <!-- specrune-codex-context --> marker confirms delivery, not completeness.
10
+ 2. Check the delivered Role, Active task, and Context status. Read warning, truncated, and dropped notices. If the parent supplies an expected task path for a strict Codex dispatch, compare its resolved task directory with the Hook's Active task before proceeding. Both must identify the same existing task directory, with delivered Role research and usable Context status. If they differ, either task is absent or invalid, or the startup context is unavailable, stop task-dependent work before reading task artifacts or writing a report and report the mismatch to the parent. The expected path is a consistency check: never let an explicit path override a different Hook task. Do not change a session binding or use the parent's context key. For assignments without a strict expected task, resolve any role/task mismatch with the parent before proceeding.
11
+ 3. Read the confirmed task's prd.md and, when present, design.md and implement.md. A compiled excerpt is not a replacement for missing requirements.
12
+ 4. For assignments without a strict expected task, if startup context is missing or incomplete, use the task path explicitly supplied by the parent. Do not infer the parent's task from your child session's `specrune task current`, environment identity, or other sessions. If essential task context is unavailable, report what is missing to the parent before task-dependent work.
13
+
14
+ ## Progressive disclosure
15
+
16
+ The research context is a navigation map, not preloaded bodies. Start from its paths and titles, read relevant contracts, then trace code and tests. Skip specs that are merely listed. `kind: navigation` entries record delivered pointers, not files read; a body intentionally not preloaded is not a dropped candidate. Search `.specrune/spec/` when incomplete. Record the sources the conclusions actually use in the evidence card; the ledger records delivery, not reading.
17
+
18
+ Use the navigation delivered by the startup Hook; do not fetch it again merely because bodies were not preloaded. Complete the strict task comparison above before any recovery; navigation self-fetch must never override a different or unavailable Hook task in a strict dispatch.
19
+
20
+ ## Task and report
21
+
22
+ When the Hook's Active task or the parent's explicit path confirms a task:
23
+ For a strict dispatch, the startup task comparison above must have succeeded first.
24
+ 1. Create `<task-dir>/research/` if it is not there yet.
25
+ 2. Look through the code, the specs, and any external documentation that actually bears on the question.
26
+ 3. Give each distinct topic its own file: `<task-dir>/research/<slug>.md`, headed by its evidence card.
27
+ 4. Report file paths and a short summary, not the full contents. If writing the report fails, report that failure; never claim the file exists.
28
+
29
+ If no task can be confirmed but the delegated question is self-contained, complete the investigation and return source-backed findings without writing a task report. Never guess a task directory.
30
+
31
+ ## Evidence card
32
+
33
+ Every report file opens with this YAML header:
34
+
35
+ ---
36
+ status: VERIFIED # VERIFIED | INCONCLUSIVE | NOT_FOUND
37
+ sources: # files the conclusion actually rests on
38
+ - path: core/internal/specrune/context.go
39
+ lines: 654-682
40
+ - path: https://github.com/<org>/<repo>/blob/<rev>/<path>
41
+ revision: a1b2c3d # git rev at investigation time
42
+ ---
43
+
44
+ - `VERIFIED` — you read the evidence; `sources` is mandatory.
45
+ - `INCONCLUSIVE` — the evidence does not settle the question.
46
+ - `NOT_FOUND` — the thing genuinely is not there.
47
+ - External repo? `sources[].path` may be an `http(s)://` permalink embedding the rev; `revision` then records that repo's rev (req:sessions-memory#session-record-authoritative; the doctor skips URL checks).
48
+
49
+ Pick exactly one. No `sources` entry means it is not a fact: cite, downgrade, or drop it — never dress a guess up as a conclusion. Cite `file:line` so a stale reference can be re-located from `revision`.
50
+
51
+ ## Stay in your lane
52
+
53
+ Write only a plain `<slug>.md` report under this task's `research/` — no subdirectories, and never copy source into the task directory; cite it by `file:line` plus the `revision` in the card. Do not edit code, specs, platform config, or task files; patching is no longer investigating.
54
+ """
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ // specrune-codex 的可执行入口。
3
+ //
4
+ // 安装器只写项目内 `.codex/`:Codex 的 hooks 与 agents 都是项目级发现,
5
+ // 现有规范还明确不写全局 `~/.codex/config.toml`。装包时偷偷改用户全局配置
6
+ // 既不可见也不可控,pnpm 10 也会拦截依赖的生命周期脚本。
7
+ // 共享核心缺失(旧版 specrune)等启动错误只打印可操作消息,不甩模块堆栈。
8
+ try {
9
+ const { main } = await import("../src/install.mjs");
10
+ process.exitCode = await main(process.argv.slice(2));
11
+ } catch (err) {
12
+ process.stderr.write(`specrune-codex: ${err?.message ?? err}\n`);
13
+ process.exitCode = 1;
14
+ }
package/hooks.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "description": "SpecRune session context and task workflow for Codex",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "specrune codex-hook",
10
+ "timeout": 3
11
+ }
12
+ ]
13
+ }
14
+ ],
15
+ "UserPromptSubmit": [
16
+ {
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "specrune codex-hook",
21
+ "timeout": 3
22
+ }
23
+ ]
24
+ }
25
+ ],
26
+ "SubagentStart": [
27
+ {
28
+ "matcher": "^specrune_(implement|check|research)$",
29
+ "hooks": [
30
+ {
31
+ "type": "command",
32
+ "command": "specrune codex-hook",
33
+ "timeout": 30
34
+ }
35
+ ]
36
+ }
37
+ ]
38
+ }
39
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "specrune-codex",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.0",
4
+ "description": "SpecRune 的 Codex 宿主适配与项目级安装器:装配 .codex/agents 子代理,并合并 .codex/hooks.json 的事件 hook",
5
+ "type": "module",
6
+ "bin": {
7
+ "specrune-codex": "bin/specrune-codex.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "agents",
13
+ "hooks.json",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "dependencies": {
20
+ "specrune": "^0.9.0"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/jsfaint/SpecRune.git",
28
+ "directory": "plugins/codex"
29
+ },
30
+ "homepage": "https://github.com/jsfaint/SpecRune#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/jsfaint/SpecRune/issues"
33
+ },
34
+ "license": "MIT",
35
+ "keywords": [
36
+ "specrune",
37
+ "codex",
38
+ "codex-cli",
39
+ "plugin",
40
+ "spec-driven",
41
+ "workflow"
42
+ ]
43
+ }
@@ -0,0 +1,906 @@
1
+ // specrune-codex 安装器 adapter。
2
+ //
3
+ // 通用编排(参数解析、CLI 定位、状态/哈希/原子写、scope 校验、互斥检测、
4
+ // skills 调用、install/uninstall/status 流程)在 specrune/lib/installer-core.mjs;
5
+ // 这里只保留 Codex 的落点、agents/*.toml、hooks.json 键级合并/擦除、
6
+ // scope 强制 project 与旧 agent 警告。安装器放在插件包而不是 Go CLI 的原因见
7
+ // README:宿主装配是插件的版本轴。
8
+ //
9
+ // Codex 只支持项目级:hooks 与 agents 都从 `<项目>/.codex/` 发现,现有规范
10
+ // 明确不写全局 `~/.codex/config.toml`。因此没有 `--scope global`,也不推断
11
+ // Codex 的 hooks 信任状态——那只能由用户在 `/hooks` 里审阅。
12
+ import { existsSync, readFileSync, readdirSync, rmSync, unlinkSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ // 发布后从 specrune 依赖导入;源码仓库未执行 npm install,回退到仓库内的父包。
18
+ // 旧版 specrune 没有 lib/installer-core.mjs:两档都解析不到时给可操作错误,
19
+ // 不让裸 ERR_MODULE_NOT_FOUND 堆栈直接抛给用户。
20
+ async function loadInstallerCore() {
21
+ try {
22
+ return await import("specrune/lib/installer-core.mjs");
23
+ } catch (err) {
24
+ if (!err || err.code !== "ERR_MODULE_NOT_FOUND") throw err;
25
+ try {
26
+ return await import("../../../npm/cli/lib/installer-core.mjs");
27
+ } catch (fallbackErr) {
28
+ if (!fallbackErr || fallbackErr.code !== "ERR_MODULE_NOT_FOUND") throw fallbackErr;
29
+ throw new Error(
30
+ "无法加载共享安装核心 specrune/lib/installer-core.mjs:当前 specrune 版本不含 installer-core。" +
31
+ "请升级 specrune 后重试(npm i -g specrune@latest);" +
32
+ "若 specrune 尚未安装,请重新安装本插件。" +
33
+ `\n原始错误: ${err.message}`,
34
+ );
35
+ }
36
+ }
37
+ }
38
+
39
+ const core = await loadInstallerCore();
40
+ const {
41
+ createInstaller,
42
+ contentHash,
43
+ hashContent,
44
+ loadState,
45
+ rawHash,
46
+ readIfExists,
47
+ readTemplateExisting,
48
+ resolveWithin,
49
+ } = core;
50
+
51
+ const HERE = dirname(fileURLToPath(import.meta.url));
52
+ const PKG_ROOT = resolve(HERE, "..");
53
+ const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
54
+
55
+ // protocol 是后端钩子帧协议的版本,不是产品版本。插件只检查它,产品命令可以
56
+ // 自由演进而不打断已装插件(.specrune/spec/backend/agent-plugin-contract.md)。
57
+ export const PROTOCOL = 1;
58
+
59
+ // 插件声明编写时依赖的 installer-core API 版本。必须写字面量:从核心导入
60
+ // 会让声明永远等于当前核心,旧插件在新核心上就无法被拦下。
61
+ const REQUIRED_CORE_VERSION = 1;
62
+
63
+ export const AGENTS_REL = "agents";
64
+ export const HOOKS_REL = "hooks.json";
65
+
66
+ // 与 CLI 的 codex-hook 命令逐字一致:它是合并幂等判据,也是卸载摘除判据——
67
+ // hooks.json 里认领本工具条目的唯一标识。
68
+ export const HOOK_COMMAND = "specrune codex-hook";
69
+ export const HOOK_SUBCOMMAND = "codex-hook";
70
+ export const HOOK_DESCRIPTION = "SpecRune session context and task workflow for Codex";
71
+
72
+ export const STATE_REL = ".specrune/.state/plugins/codex.json";
73
+
74
+ const USAGE = `specrune-codex — 把 SpecRune 的 Codex 适配装进项目
75
+
76
+ 用法:
77
+ specrune-codex install [--scope project|global] [--dir <项目根>] [--dry-run] [--force]
78
+ specrune-codex uninstall [--scope project|global] [--dir <项目根>] [--dry-run] [--force]
79
+ specrune-codex status [--scope project|global] [--dir <项目根>] [--json]
80
+
81
+ 选项:
82
+ --scope project|global 只支持项目级:显式传 global 会纠正为 project 并提示
83
+ --dir <路径> 项目根(默认当前目录)
84
+ --dry-run 只打印计划,不写盘、不改状态文件
85
+ --force install 覆盖已修改的 agents,并在检测到全局 ~/.codex 痕迹时继续;
86
+ uninstall 连改过的文件一起删
87
+ --json status 以机器可读 JSON 输出
88
+ -h, --help 显示本帮助
89
+
90
+ 安装内容:
91
+ <项目根>/.codex/agents/specrune-*.toml implement / check / research 子代理
92
+ <项目根>/.codex/hooks.json 只增删本工具的 hook 组
93
+
94
+ hooks.json 是混合所有权文件:用户自己的分组、事件与顶层键原样保留;只替换
95
+ 本工具的条目(裸命令,或旧版 .codex/specrune/bin/ 下的绝对路径 + codex-hook)。
96
+ project 模式不复制 CLI 二进制(CLI 全局唯一),命令写为解析到的全局 CLI 绝对
97
+ 路径 + codex-hook;找不到时保留裸 "specrune codex-hook" 并回退 PATH。非法 JSON
98
+ 或结构不可控时不动并告警。状态写在 <项目根>/.specrune/.state/plugins/codex.json。
99
+ 技能由 'specrune skills install --adapter codex --scope project --dir <项目根>'
100
+ 统一落位;project 模式要求目标目录已有 .specrune/(先运行 specrune init)。
101
+ 技能失败只告警,插件安装仍成功。
102
+
103
+ 安装器不写 ~/.codex/config.toml,也不判断 hooks 是否已被信任;首次运行请在
104
+ Codex 的 /hooks 中审阅并信任。
105
+
106
+ 退出码: 0 成功,1 运行失败,2 用法错误。
107
+ `;
108
+
109
+ // locateBundledBinary 以本模块为锚解析平台二进制:适配器自己的依赖树决定
110
+ // 查找起点,不能换成共享核心的位置。
111
+ export function locateBundledBinary() {
112
+ return core.locateBundledBinary({ anchor: import.meta.url });
113
+ }
114
+
115
+ export { contentHash, rawHash };
116
+
117
+ // bundledRel 是自带 CLI 在宿主根(`.codex/`)下的相对路径。放进
118
+ // `<项目根>/.codex/specrune/bin/`:这个目录只归本工具,卸载时整目录清掉,
119
+ // 不会碰到用户自己的文件。
120
+ export function bundledRel() {
121
+ return process.platform === "win32" ? "specrune/bin/specrune.exe" : "specrune/bin/specrune";
122
+ }
123
+
124
+ function isBinaryRel(rel) {
125
+ return rel === bundledRel();
126
+ }
127
+
128
+ // missingBinaryWarning 在没产出二进制时给出可行动的告警。解析到的路径存在但
129
+ // 读不到,与「压根没解析到」是两种故障:前者多半是 SPECRUNE_BIN 指错,必须把
130
+ // 用户填的路径回显出来,否则他会去重装依赖。
131
+ function missingBinaryWarning(bundledBinary) {
132
+ const tail =
133
+ "hook 命令保留裸 `specrune codex-hook`,运行时回退 PATH 上的 specrune。" +
134
+ "请用 `npm i -g specrune-codex` 安装(npx 的临时目录会被清理),或设置 SPECRUNE_BIN 指向二进制。";
135
+ if (bundledBinary) return `解析到的 specrune 二进制读不到(${bundledBinary})。${tail}`;
136
+ return `未找到本机平台的 specrune 二进制(缺少 specrune 依赖)。${tail}`;
137
+ }
138
+
139
+ // 二进制要按字节读,不能走 utf8——否则落盘的是替换字符而不是原可执行文件。
140
+
141
+ // templateAgents 读包内 agents/*.toml,返回安装后的 POSIX 相对路径与正文。
142
+ // 排序让安装计划与状态文件稳定:每次 install 给出不同顺序会让「这次改了什么」
143
+ // 没法用 diff 看。
144
+ //
145
+ // 文件名用连字符(specrune-implement.toml),与旧 CLI 写的下划线
146
+ // (specrune_implement.toml)区分开:旧 CLI 只写下划线文件,
147
+ // 不会把插件当前安装误判成旧装配再删掉。Codex 以 TOML 内的 name 字段为身份,
148
+ // 文件名只影响磁盘可读性。
149
+ export function templateAgents() {
150
+ const abs = join(PKG_ROOT, AGENTS_REL);
151
+ return readdirSync(abs)
152
+ .filter((name) => name.endsWith(".toml"))
153
+ .sort()
154
+ .map((name) => ({
155
+ rel: `${AGENTS_REL}/${name}`,
156
+ content: readFileSync(join(abs, name), "utf8"),
157
+ }));
158
+ }
159
+
160
+ // templateFiles 是本次安装要落盘的全部自有文件。hooks.json 不在其中:
161
+ // 它是混合所有权文件,走键级合并与擦除,不能按「整文件哈希」删除。
162
+ //
163
+ // CLI 全局唯一:Codex 没有全局安装,project 模式也不复制二进制;hook 指向
164
+ // 解析到的全局 CLI(或裸 specrune 回退 PATH)。<root>/specrune/ 仍按旧版
165
+ // 安装痕迹处理,卸载时整目录清掉。
166
+ export function templateFiles() {
167
+ return templateAgents();
168
+ }
169
+
170
+ // hookCommandFor 是 hooks.json 里本工具的完整命令。路径加引号:项目目录可以
171
+ // 在含空格的路径下(Windows 的 `C:\Users\John Doe`),裸路径会被 shell 拆成
172
+ // 两个参数;引号不影响不含空格的路径。
173
+ export function hookCommandFor(root) {
174
+ if (!root) return HOOK_COMMAND;
175
+ return hookCommandForBinary(join(resolve(root), ...bundledRel().split("/")));
176
+ }
177
+
178
+ // hookCommandForBinary 把「要执行的 CLI 路径」写成 hook 命令。空路径表示
179
+ // 解析不到 CLI,保留裸命令回退 PATH。
180
+ export function hookCommandForBinary(binary) {
181
+ if (!binary) return HOOK_COMMAND;
182
+ return `${JSON.stringify(resolve(binary))} ${HOOK_SUBCOMMAND}`;
183
+ }
184
+
185
+ // defaultHooks 直接取包内 hooks.json:命令等于裸命令时逐字节返回,保持全新
186
+ // 安装(以及迁移期同步测试)与包内模板一致;有自带二进制时把模板里的裸命令
187
+ // 换成绝对路径,返回结构与 merge 产物同形。
188
+ export function defaultHooks(command = HOOK_COMMAND) {
189
+ const raw = readFileSync(join(PKG_ROOT, HOOKS_REL), "utf8");
190
+ if (command === HOOK_COMMAND) return raw;
191
+ const doc = JSON.parse(raw);
192
+ for (const groups of Object.values(doc.hooks ?? {})) {
193
+ if (!Array.isArray(groups)) continue;
194
+ for (const group of groups) {
195
+ if (!Array.isArray(group?.hooks)) continue;
196
+ for (const entry of group.hooks) {
197
+ if (entry && typeof entry === "object" && entry.command === HOOK_COMMAND) {
198
+ entry.command = command;
199
+ }
200
+ }
201
+ }
202
+ }
203
+ return JSON.stringify(doc, null, 2) + "\n";
204
+ }
205
+
206
+ function parseHooksDoc(raw) {
207
+ let doc;
208
+ try {
209
+ doc = JSON.parse(raw);
210
+ } catch (err) {
211
+ return { error: `不是合法 JSON: ${err.message}` };
212
+ }
213
+ if (doc === null) return { doc: {} };
214
+ if (typeof doc !== "object" || Array.isArray(doc)) {
215
+ return { error: "顶层不是 JSON 对象" };
216
+ }
217
+ return { doc };
218
+ }
219
+
220
+ function normalizePathForCompare(path) {
221
+ const abs = resolve(path);
222
+ return process.platform === "win32" ? abs.replace(/\//g, "\\").toLowerCase() : abs;
223
+ }
224
+
225
+ // commandExecutable 拆出「命令首段」:带引号路径与裸命令都可能出现。
226
+ // 返回 null 表示这条命令不是 `... codex-hook` 的形状。
227
+ function commandExecutable(command) {
228
+ const text = String(command).trim();
229
+ if (!text.endsWith(` ${HOOK_SUBCOMMAND}`)) return null;
230
+ let head = text.slice(0, -HOOK_SUBCOMMAND.length).trim();
231
+ if (head.length >= 2 && head.startsWith('"') && head.endsWith('"')) {
232
+ head = head.slice(1, -1);
233
+ }
234
+ return head;
235
+ }
236
+
237
+ // isOurHookCommand 认三类命令:
238
+ // - 裸 `specrune codex-hook`(旧安装,或没有依赖时的 PATH 回退);
239
+ // - `<root>/specrune/bin/` 下的绝对路径 + codex-hook(旧版把二进制复制
240
+ // 进项目时的安装);
241
+ // - 当前解析到的全局 CLI 或状态文件里记录的 cliPath(新版 project 模式
242
+ // 不复制二进制,hook 直接指向全局 CLI)。
243
+ // 第三类必须显式传入,避免把用户自己指向别处 specrune 的 hook 当成我们的。
244
+ export function isOurHookCommand(command, root = "", ownedExecutables = []) {
245
+ if (typeof command !== "string") return false;
246
+ if (command.trim() === HOOK_COMMAND) return true;
247
+ const executable = commandExecutable(command);
248
+ if (!executable || !isAbsolute(executable)) return false;
249
+ const candidate = normalizePathForCompare(executable);
250
+ for (const owned of ownedExecutables) {
251
+ if (owned && normalizePathForCompare(owned) === candidate) return true;
252
+ }
253
+ if (!root) return false;
254
+ const expectedDir = normalizePathForCompare(join(resolve(root), "specrune", "bin"));
255
+ return normalizePathForCompare(dirname(executable)) === expectedDir;
256
+ }
257
+
258
+ function isOursCommand(value, root = "", ownedExecutables = []) {
259
+ return (
260
+ value !== null &&
261
+ typeof value === "object" &&
262
+ !Array.isArray(value) &&
263
+ typeof value.command === "string" &&
264
+ isOurHookCommand(value.command, root, ownedExecutables)
265
+ );
266
+ }
267
+
268
+ function isOursGroup(group, root = "", ownedExecutables = []) {
269
+ if (!group || typeof group !== "object" || Array.isArray(group)) return false;
270
+ return (
271
+ Array.isArray(group.hooks) &&
272
+ group.hooks.some((entry) => isOursCommand(entry, root, ownedExecutables))
273
+ );
274
+ }
275
+
276
+ // withoutOurs 保留分组元数据与相邻的用户命令,只摘掉本工具的命令。
277
+ // 分组里只剩我们的命令时返回 null(调用方据此丢弃整个分组)。
278
+ function withoutOurs(group, root = "", ownedExecutables = []) {
279
+ if (!isOursGroup(group, root, ownedExecutables)) return group;
280
+ const kept = group.hooks.filter(
281
+ (entry) => !isOursCommand(entry, root, ownedExecutables),
282
+ );
283
+ if (kept.length === 0) return null;
284
+ return { ...group, hooks: kept };
285
+ }
286
+
287
+ // upsertOursCommand 把分组内的本工具条目收敛成一条当前命令:命中旧命令
288
+ // (裸命令或旧路径)时原地替换,保留 timeout 等其它字段;重复命中的后续条目
289
+ // 删除。这样升级只改命令字符串,不会越追加越多。
290
+ function upsertOursCommand(group, command, root, ownedExecutables = []) {
291
+ const kept = [];
292
+ let found = false;
293
+ let changed = false;
294
+ for (const entry of group.hooks) {
295
+ if (isOursCommand(entry, root, ownedExecutables)) {
296
+ if (found) {
297
+ changed = true;
298
+ continue;
299
+ }
300
+ found = true;
301
+ if (entry.command !== command) {
302
+ entry.command = command;
303
+ changed = true;
304
+ }
305
+ kept.push(entry);
306
+ continue;
307
+ }
308
+ kept.push(entry);
309
+ }
310
+ group.hooks = kept;
311
+ return changed;
312
+ }
313
+
314
+ // sameMatcher 对齐 Go 的 reflect.DeepEqual:模板里 SessionStart /
315
+ // UserPromptSubmit 没有 matcher(undefined),SubagentStart 是字符串。
316
+ function sameMatcher(a, b) {
317
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
318
+ }
319
+
320
+ // mergeHooks 是 Go 侧 mergeCodexHooks 的移植,并补上自带二进制后的命令改写。
321
+ //
322
+ // 只替换本工具的 hook 组:用户分组与其它事件原样保留;同一事件里已有本工具
323
+ // 组且 matcher 与模板一致时只更新命令,避免重复安装叠加。结构不可控(hooks 或
324
+ // 事件不是预期形态)时拒绝整个文件,由调用方给出可执行指引。
325
+ export function mergeHooks(raw, root = "", command = "", ownedExecutables = []) {
326
+ const parsed = parseHooksDoc(raw);
327
+ if (parsed.error) return { error: parsed.error };
328
+ const doc = parsed.doc;
329
+ const wanted = command || (root ? hookCommandFor(root) : HOOK_COMMAND);
330
+ const generated = JSON.parse(defaultHooks(wanted));
331
+ const ours = generated.hooks;
332
+
333
+ let hooks = doc.hooks;
334
+ if (hooks !== undefined && hooks !== null) {
335
+ if (typeof hooks !== "object" || Array.isArray(hooks)) {
336
+ return { error: "hooks 不是对象" };
337
+ }
338
+ } else {
339
+ hooks = {};
340
+ }
341
+
342
+ let changed = false;
343
+ for (const event of Object.keys(ours)) {
344
+ const value = hooks[event];
345
+ if (value !== undefined && value !== null && !Array.isArray(value)) {
346
+ return { error: `${event} 不是数组` };
347
+ }
348
+ const groups = Array.isArray(value) ? value : [];
349
+ const expected = ours[event];
350
+ const match = groups.find(
351
+ (group) =>
352
+ isOursGroup(group, root, ownedExecutables) &&
353
+ sameMatcher(group.matcher, expected[0].matcher),
354
+ );
355
+
356
+ if (match) {
357
+ // 同 matcher 的自有组:只改命令,保留分组内用户条目与 timeout。
358
+ if (upsertOursCommand(match, wanted, root, ownedExecutables)) changed = true;
359
+ // 其它分组里若还有本工具条目(旧 matcher、重复安装),一并摘掉。
360
+ const kept = [];
361
+ for (const group of groups) {
362
+ if (group === match) {
363
+ kept.push(group);
364
+ continue;
365
+ }
366
+ const remaining = withoutOurs(group, root, ownedExecutables);
367
+ if (remaining === null && isOursGroup(group, root, ownedExecutables)) changed = true;
368
+ else if (remaining !== group) changed = true;
369
+ if (remaining !== null || !isOursGroup(group, root, ownedExecutables)) {
370
+ kept.push(remaining);
371
+ }
372
+ }
373
+ if (changed) hooks[event] = kept;
374
+ continue;
375
+ }
376
+
377
+ const kept = [];
378
+ for (const group of groups) {
379
+ const remaining = withoutOurs(group, root, ownedExecutables);
380
+ if (remaining !== null || !isOursGroup(group, root, ownedExecutables)) kept.push(remaining);
381
+ }
382
+ hooks[event] = [...kept, ...expected];
383
+ changed = true;
384
+ }
385
+
386
+ if (!changed) return { content: raw, changed: false };
387
+ doc.hooks = hooks;
388
+ if (!("description" in doc)) doc.description = generated.description;
389
+ return { content: JSON.stringify(doc, null, 2) + "\n", changed: true };
390
+ }
391
+
392
+ // updateHooksTemplate 是 Go 侧 CodexHooksUpdateTemplate 的移植:升级时把本工具
393
+ // 的组整体替换为当前模板(不比较 matcher),用户组与其它事件保留。无法安全
394
+ // 合并时返回 ok=false,调用方不得写回。
395
+ export function updateHooksTemplate(raw, root = "", command = "", ownedExecutables = []) {
396
+ const parsed = parseHooksDoc(raw);
397
+ if (parsed.error) return { content: raw, ok: false };
398
+ const doc = parsed.doc;
399
+ const wanted = command || (root ? hookCommandFor(root) : HOOK_COMMAND);
400
+ const generated = JSON.parse(defaultHooks(wanted));
401
+ const ours = generated.hooks;
402
+ const hooks = doc.hooks;
403
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) {
404
+ return { content: raw, ok: false };
405
+ }
406
+ for (const event of Object.keys(ours)) {
407
+ const value = hooks[event];
408
+ if (value !== undefined && value !== null && !Array.isArray(value)) {
409
+ return { content: raw, ok: false };
410
+ }
411
+ }
412
+
413
+ let changed = false;
414
+ for (const event of Object.keys(ours)) {
415
+ const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
416
+ const expected = ours[event];
417
+ const kept = [];
418
+ for (const group of groups) {
419
+ const remaining = withoutOurs(group, root, ownedExecutables);
420
+ if (remaining !== null || !isOursGroup(group, root, ownedExecutables)) {
421
+ kept.push(remaining);
422
+ }
423
+ }
424
+ const next = [...kept, ...expected];
425
+ if (JSON.stringify(groups) === JSON.stringify(next)) continue;
426
+ hooks[event] = next;
427
+ changed = true;
428
+ }
429
+
430
+ if (!changed) return { content: raw, ok: true };
431
+ doc.hooks = hooks;
432
+ if (!("description" in doc)) doc.description = generated.description;
433
+ return { content: JSON.stringify(doc, null, 2) + "\n", ok: true };
434
+ }
435
+
436
+ // scrubHooks 是 Go 侧 ScrubCodexHooks 的移植:只摘本工具的命令(裸命令或
437
+ // `<root>/specrune/bin/` 下的绝对路径)。分组里只剩我们的条目时删分组、事件
438
+ // 数组空了删事件键、hooks 对象空了删 hooks 键;顶层 description 只在仍是模板
439
+ // 原文时删。
440
+ export function scrubHooks(raw, root = "", ownedExecutables = []) {
441
+ const parsed = parseHooksDoc(raw);
442
+ if (parsed.error) return { error: parsed.error };
443
+ const doc = parsed.doc;
444
+ let changed = false;
445
+
446
+ const hooks = doc.hooks;
447
+ if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) {
448
+ for (const event of Object.keys(hooks)) {
449
+ const groups = hooks[event];
450
+ if (!Array.isArray(groups)) continue;
451
+ const kept = [];
452
+ for (const group of groups) {
453
+ if (isOursGroup(group, root, ownedExecutables)) changed = true;
454
+ const remaining = withoutOurs(group, root, ownedExecutables);
455
+ if (remaining !== null || !isOursGroup(group, root, ownedExecutables)) {
456
+ kept.push(remaining);
457
+ }
458
+ }
459
+ if (kept.length === 0) {
460
+ delete hooks[event];
461
+ } else {
462
+ hooks[event] = kept;
463
+ }
464
+ }
465
+ if (changed && Object.keys(hooks).length === 0) {
466
+ delete doc.hooks;
467
+ }
468
+ }
469
+
470
+ if (doc.description === HOOK_DESCRIPTION) {
471
+ delete doc.description;
472
+ changed = true;
473
+ }
474
+
475
+ if (!changed) return { content: raw, changed: false, fullyEmpty: false };
476
+ const fullyEmpty = Object.keys(doc).length === 0;
477
+ return {
478
+ // 与 Go 的 finishScrub 同形:即便对象已空也返回规范 JSON,由调用方按
479
+ // fullyEmpty 决定删文件;返回值本身永远是可解析的 JSON 字节。
480
+ content: JSON.stringify(doc, null, 2) + "\n",
481
+ changed: true,
482
+ fullyEmpty,
483
+ };
484
+ }
485
+
486
+ // hooksHaveOurHook 判断 hooks.json 里有没有本工具的 hook 条目。
487
+ export function hooksHaveOurHook(raw, root = "", ownedExecutables = []) {
488
+ const parsed = parseHooksDoc(raw);
489
+ if (parsed.error) return false;
490
+ const hooks = parsed.doc.hooks;
491
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return false;
492
+ for (const value of Object.values(hooks)) {
493
+ if (
494
+ Array.isArray(value) &&
495
+ value.some((group) => isOursGroup(group, root, ownedExecutables))
496
+ ) {
497
+ return true;
498
+ }
499
+ }
500
+ return false;
501
+ }
502
+
503
+ function resolveTargets(opts, ctx) {
504
+ if (ctx) ctx.command = opts.command;
505
+ // 只支持项目级:--dir 是项目根,默认当前目录。这里不做向上查找——
506
+ // 安装位置必须由用户显式所在的项目决定,猜祖先仓库只会写错地方。
507
+ const projectRoot = resolve(opts.dir || process.cwd());
508
+ return {
509
+ scope: "project",
510
+ projectRoot,
511
+ root: join(projectRoot, ".codex"),
512
+ statePath: join(projectRoot, ...STATE_REL.split("/")),
513
+ };
514
+ }
515
+
516
+ function binaryUsable(path) {
517
+ if (!path) return false;
518
+ try {
519
+ readFileSync(path);
520
+ return true;
521
+ } catch {
522
+ return false;
523
+ }
524
+ }
525
+
526
+ function ownedCliPaths(bundledBinary, state) {
527
+ return [bundledBinary, state?.cliPath].filter(
528
+ (path) => typeof path === "string" && path !== "",
529
+ );
530
+ }
531
+
532
+ // Codex 只能项目级:显式传 global 时纠正为 project,并让调用方输出实际 scope。
533
+ function actualScope(requested) {
534
+ return { scope: "project", correctedFrom: requested === "global" ? "global" : "" };
535
+ }
536
+
537
+ // 互斥检测的另一 scope:Codex 没有全局安装,但用户可能手工留过 ~/.codex
538
+ // 的 agents/hooks;两处都在会让 Codex 看到重复子代理与 hook。
539
+ function detectOtherScope(targets, ctx) {
540
+ const bundledBinary = ctx?.locateBinary ? ctx.locateBinary() : locateBundledBinary();
541
+ const ownedPaths = ownedCliPaths(bundledBinary, loadState(targets.statePath).state);
542
+ const root = join(homedir(), ".codex");
543
+ const found = [];
544
+ for (const file of templateAgents()) {
545
+ try {
546
+ if (existsSync(resolveWithin(root, file.rel))) found.push(file.rel);
547
+ } catch {
548
+ /* 越界,跳过 */
549
+ }
550
+ }
551
+ const raw = readIfExists(join(root, HOOKS_REL));
552
+ if (raw !== null && hooksHaveOurHook(raw, root, ownedPaths)) found.push(HOOKS_REL);
553
+ if (existsSync(join(root, ...bundledRel().split("/")))) found.push(bundledRel());
554
+ return { scope: "global", root, found, installed: found.length > 0 };
555
+ }
556
+
557
+ // 与迁移前逐字一致:测试锁的是「拒绝 + 指出全局 ~/.codex + 提示 --force」。
558
+ function otherScopeError(other) {
559
+ return (
560
+ `specrune-codex: 检测到全局 ~/.codex 已有 Codex 安装痕迹` +
561
+ `(${other.found.length} 个,位于 ${other.root});继续安装会重复定义子代理/hook。` +
562
+ `请先清理这些全局文件;确认要并存时加 --force。\n`
563
+ );
564
+ }
565
+
566
+ function forceWarning(other) {
567
+ return `已按 --force 忽略全局 ~/.codex 的既有安装痕迹(${other.root});两处并存会重复加载,请尽快清理。`;
568
+ }
569
+
570
+ function scopeCorrectionWarning() {
571
+ return "已按 project 安装(Codex 适配只支持项目级,不写全局配置)";
572
+ }
573
+
574
+ function hookInfo(bundledBinary) {
575
+ const cliUsable = binaryUsable(bundledBinary);
576
+ // CLI 全局唯一:project 模式不复制二进制,hook 指向解析到的全局 CLI。
577
+ return {
578
+ cliUsable,
579
+ cliPath: cliUsable ? resolve(bundledBinary) : "",
580
+ command: hookCommandForBinary(cliUsable ? resolve(bundledBinary) : ""),
581
+ };
582
+ }
583
+
584
+ function mergeOwnedConfig(targets, templates, opts, ctx) {
585
+ const bundledBinary = ctx.bundledBinary ?? (ctx.locateBinary ? ctx.locateBinary() : "");
586
+ if (bundledBinary) ctx.bundledBinary = bundledBinary;
587
+ const info = hookInfo(bundledBinary);
588
+ const ownedPaths = ownedCliPaths(bundledBinary, ctx.state);
589
+
590
+ // hooks.json 是混合所有权文件:只增删本工具条目,绝不整体替换。
591
+ const hooksAbs = join(targets.root, HOOKS_REL);
592
+ const hooksRaw = ctx.readIfExists(hooksAbs);
593
+ let action = "unchanged";
594
+ let content = null;
595
+ if (hooksRaw === null || hooksRaw.trim() === "") {
596
+ action = "created";
597
+ content = defaultHooks(info.command);
598
+ } else {
599
+ const merged = mergeHooks(hooksRaw, targets.root, info.command, ownedPaths);
600
+ if (merged.error) {
601
+ ctx.warn(
602
+ `${HOOKS_REL}: ${merged.error},已保留原文件未改动;请手工把 ` +
603
+ `"${info.command}" 加进对应事件。`,
604
+ );
605
+ action = "skipped";
606
+ } else if (merged.changed) {
607
+ action = "updated";
608
+ content = merged.content;
609
+ }
610
+ }
611
+ if (content !== null && !opts.dryRun) {
612
+ ctx.atomicWriteFile(hooksAbs, content);
613
+ }
614
+ return { label: "hooks", action, path: hooksAbs };
615
+ }
616
+
617
+ function scrubOwnedConfig(targets, state, opts, ctx) {
618
+ const bundledBinary = ctx.locateBinary ? ctx.locateBinary() : locateBundledBinary();
619
+ const ownedPaths = ownedCliPaths(bundledBinary, state);
620
+
621
+ // hooks.json 走键级擦除:用户其它分组、事件与顶层键必须原样留下。
622
+ // 擦完为空才删文件。
623
+ const hooksAbs = join(targets.root, HOOKS_REL);
624
+ const hooksRaw = ctx.readIfExists(hooksAbs);
625
+ if (hooksRaw === null) {
626
+ return { label: "hooks", action: "absent", path: hooksAbs };
627
+ }
628
+ const scrubbed = scrubHooks(hooksRaw, targets.root, ownedPaths);
629
+ if (scrubbed.error) {
630
+ ctx.warn(`${HOOKS_REL}: ${scrubbed.error},未改动;请手工移除本工具的 hook 条目。`);
631
+ return { label: "hooks", action: "skipped", path: hooksAbs };
632
+ }
633
+ if (!scrubbed.changed) {
634
+ return { label: "hooks", action: "unchanged", path: hooksAbs };
635
+ }
636
+ if (scrubbed.fullyEmpty) {
637
+ if (!opts.dryRun) unlinkSync(hooksAbs);
638
+ return {
639
+ label: "hooks",
640
+ action: "removed",
641
+ path: hooksAbs,
642
+ removedRel: HOOKS_REL,
643
+ };
644
+ }
645
+ if (!opts.dryRun) ctx.atomicWriteFile(hooksAbs, scrubbed.content);
646
+ return { label: "hooks", action: "updated", path: hooksAbs };
647
+ }
648
+
649
+ // ---- 输出:保持迁移前逐字一致 ----
650
+
651
+ function installPlanLines(result) {
652
+ const { targets, bundledBinary, skills, stats, merge } = result;
653
+ const info = hookInfo(bundledBinary);
654
+ const lines = [];
655
+ lines.push(`[dry-run] specrune 二进制: ${bundledBinary || "(未找到,hook 回退 PATH)"}`);
656
+ if (skills) lines.push(`[dry-run] 技能: ${skills.scope} ${skills.sharedRoot}`);
657
+ lines.push(`[dry-run] hook 命令: ${info.command}`);
658
+ for (const rel of stats.created) {
659
+ lines.push(`[dry-run] 写入 ${join(targets.root, ...rel.split("/"))}`);
660
+ }
661
+ for (const rel of stats.updated) {
662
+ lines.push(`[dry-run] 更新 ${join(targets.root, ...rel.split("/"))}`);
663
+ }
664
+ for (const rel of stats.unchanged) {
665
+ lines.push(`[dry-run] 未变 ${join(targets.root, ...rel.split("/"))}`);
666
+ }
667
+ for (const rel of stats.skipped) {
668
+ lines.push(`[dry-run] 保留 ${join(targets.root, ...rel.split("/"))}`);
669
+ }
670
+ for (const rel of stats.stale) {
671
+ lines.push(`[dry-run] 删除 ${join(targets.root, ...rel.split("/"))}`);
672
+ }
673
+ if (merge && (merge.action === "created" || merge.action === "updated")) {
674
+ lines.push(`[dry-run] ${merge.action === "created" ? "写入" : "更新"} ${merge.path}`);
675
+ }
676
+ lines.push(`[dry-run] 写状态 ${targets.statePath}`);
677
+ return lines.join("\n") + "\n";
678
+ }
679
+
680
+ function installSummary(result) {
681
+ const { targets, bundledBinary, skills, stats, merge, opts } = result;
682
+ const info = hookInfo(bundledBinary);
683
+ const prefix = opts.dryRun ? "[dry-run] " : "";
684
+ const stale = stats.stale.length > 0 ? ` / 清理 ${stats.stale.length}` : "";
685
+ return (
686
+ `${prefix}specrune-codex ${PKG.version}(scope=project)\n` +
687
+ ` 项目根: ${targets.projectRoot}\n` +
688
+ ` 状态文件: ${targets.statePath}\n` +
689
+ ` CLI: global(${info.cliUsable ? info.cliPath : "PATH 回退"})\n` +
690
+ ` 技能: ${skills ? `${skills.scope} ${skills.sharedRoot}` : "未安装(CLI 不可用或失败)"}\n` +
691
+ ` hook 命令: ${info.command}\n` +
692
+ ` 新增 ${stats.created.length} / 更新 ${stats.updated.length} / 未变 ${stats.unchanged.length} / 保留 ${stats.skipped.length}${stale}\n` +
693
+ ` hooks: ${merge ? merge.action : "unchanged"}\n`
694
+ );
695
+ }
696
+
697
+ function uninstallSummary(result) {
698
+ const { targets, skill, scrub, removed, kept, opts } = result;
699
+ const prefix = opts.dryRun ? "[dry-run] " : "";
700
+ const lines = [
701
+ `${prefix}specrune-codex 卸载(scope=project)`,
702
+ ` 项目根: ${targets.projectRoot}`,
703
+ ` 技能: ${skill.record ? `${skill.record.scope} ${skill.record.sharedRoot}` : "未变更(CLI 不可用或失败)"}`,
704
+ ` 删除 ${removed.length} / 保留 ${kept.length}`,
705
+ ` hooks: ${scrub ? scrub.action : "absent"}`,
706
+ ];
707
+ if (opts.dryRun && result.extra?.binDirExists) {
708
+ lines.push(` [dry-run] 删除 ${result.extra.binDir}`);
709
+ }
710
+ for (const rel of removed) lines.push(` - ${rel}`);
711
+ return lines.join("\n") + "\n";
712
+ }
713
+
714
+ // 返回完整 payload:binary 与 hooks 在旧输出里位于 files 与 skills 之间,
715
+ // 只有控制字段顺序才能保持 status --json 的字节级兼容。
716
+ function statusPayload(targets, state, ctx, payload) {
717
+ const bundledBinary = ctx.locateBinary ? ctx.locateBinary() : locateBundledBinary();
718
+ const ownedPaths = ownedCliPaths(bundledBinary, state);
719
+
720
+ // 二进制即使不在状态里(旧版状态、手工删过状态)也要单独报告:它决定 hook
721
+ // 绝对路径是否还指向一个真实可执行文件。
722
+ const binaryAbs = join(targets.root, ...bundledRel().split("/"));
723
+ const binaryExisting = readTemplateExisting(binaryAbs, true);
724
+ const binaryEntry = (state?.files ?? []).find((entry) => isBinaryRel(entry.path));
725
+ let binaryState = "missing";
726
+ if (binaryExisting !== null) {
727
+ binaryState = !binaryEntry
728
+ ? "present"
729
+ : hashContent(binaryExisting) === binaryEntry.sha256
730
+ ? "ok"
731
+ : "modified";
732
+ }
733
+ const binary = { path: binaryAbs, present: binaryExisting !== null, state: binaryState };
734
+
735
+ const hooksAbs = join(targets.root, HOOKS_REL);
736
+ const hooksRaw = ctx.readIfExists(hooksAbs);
737
+ let hooksCommand = null;
738
+ if (hooksRaw !== null) {
739
+ const parsed = parseHooksDoc(hooksRaw);
740
+ if (!parsed.error) {
741
+ outer: for (const groups of Object.values(parsed.doc.hooks ?? {})) {
742
+ if (!Array.isArray(groups)) continue;
743
+ for (const group of groups) {
744
+ if (!Array.isArray(group?.hooks)) continue;
745
+ for (const entry of group.hooks) {
746
+ if (isOursCommand(entry, targets.root, ownedPaths)) {
747
+ hooksCommand = entry.command;
748
+ break outer;
749
+ }
750
+ }
751
+ }
752
+ }
753
+ }
754
+ }
755
+ const hooks = {
756
+ path: hooksAbs,
757
+ present: hooksRaw !== null,
758
+ hook: hooksRaw !== null && hooksHaveOurHook(hooksRaw, targets.root, ownedPaths),
759
+ command: hooksCommand,
760
+ };
761
+
762
+ return {
763
+ scope: "project",
764
+ projectRoot: targets.projectRoot,
765
+ root: payload.root,
766
+ statePath: payload.statePath,
767
+ installed: Boolean(payload.installed) || hooks.hook,
768
+ protocol: payload.protocol,
769
+ version: payload.version,
770
+ cli: payload.cli,
771
+ cliPath: state?.cliPath ?? null,
772
+ files: payload.files,
773
+ binary,
774
+ hooks,
775
+ skills: payload.skills,
776
+ otherScope: payload.otherScope,
777
+ };
778
+ }
779
+
780
+ function statusSummary(payload, targets, ctx) {
781
+ const other = payload.otherScope || {
782
+ scope: "global",
783
+ root: null,
784
+ installed: false,
785
+ found: [],
786
+ };
787
+ const hooks = payload.hooks;
788
+ const lines = [
789
+ `scope: project`,
790
+ `项目根: ${payload.projectRoot}`,
791
+ `宿主根: ${payload.root}`,
792
+ `状态文件: ${payload.statePath}`,
793
+ `CLI: ${payload.cli ?? "unknown"}${payload.cliPath ? `(${payload.cliPath})` : ""}`,
794
+ `技能: ${core.skillsLabel(payload.skills)}`,
795
+ `其它 scope: ${other.scope} ${other.root} — ${
796
+ other.installed ? `已安装(${other.found.length} 个文件)` : "未安装"
797
+ }`,
798
+ `旧版宿主目录二进制: ${
799
+ payload.binary.present
800
+ ? `${payload.binary.path}(${payload.binary.state})`
801
+ : "缺失(新版不复制,hook 指向全局 CLI)"
802
+ }`,
803
+ `hook 命令: ${hooks.command ?? "未挂载"}`,
804
+ ];
805
+ if (!payload.installed) {
806
+ const suffix = ctx.state === null && !ctx.stateError ? "(无状态文件)" : "";
807
+ lines.push(`状态: 未安装${suffix}`);
808
+ return lines.join("\n") + "\n";
809
+ }
810
+ lines.push(
811
+ `状态: 已安装(specrune-codex ${payload.version ?? "-"},protocol ${payload.protocol ?? "-"})`,
812
+ );
813
+ for (const file of payload.files) {
814
+ const label = { ok: "正常", modified: "已修改", missing: "缺失" }[file.state] || file.state;
815
+ lines.push(` ${file.path}: ${label}`);
816
+ }
817
+ lines.push(` ${HOOKS_REL}: ${hooks.hook ? "已挂载" : "未挂载"}`);
818
+ return lines.join("\n") + "\n";
819
+ }
820
+
821
+ // legacyAgentRels 报旧 CLI 写的下划线文件落位。它们与插件当前安装的连字符
822
+ // 文件同名(TOML 的 name 字段相同),两套都在会让 Codex 看到重复子代理定义。
823
+ //
824
+ // 命名不合 `specrune-<role>.toml` 约定的文件推导不出旧落位:跳过而不是原样
825
+ // 保留相对路径,否则刚装好的当前文件会被当成 legacy 误报。
826
+ function legacyAgentRels() {
827
+ const out = [];
828
+ for (const file of templateAgents()) {
829
+ const match = /^specrune-([a-z0-9]+)\.toml$/.exec(basename(file.rel));
830
+ if (match) out.push({ rel: `${AGENTS_REL}/specrune_${match[1]}.toml` });
831
+ }
832
+ return out;
833
+ }
834
+
835
+ function warnLegacyAgents(targets, warnings) {
836
+ const found = [];
837
+ for (const file of legacyAgentRels()) {
838
+ if (existsSync(resolveWithin(targets.root, file.rel))) found.push(file.rel);
839
+ }
840
+ if (found.length > 0) {
841
+ warnings.push(
842
+ `检测到旧 CLI 写过的 Codex 子代理(${found.join(", ")});它们会与本次安装重复定义同名子代理。` +
843
+ `请手工删除这些旧文件(保留内容已改过的)。`,
844
+ );
845
+ }
846
+ }
847
+
848
+ // 解析到的路径存在但读不到时,核心不会替你告警(它只按「解析结果为空」判断),
849
+ // 这里补上同一条可行动提示。
850
+ function legacyWarnings(targets, warnings, ctx) {
851
+ const bundledBinary = ctx.bundledBinary ?? (ctx.locateBinary ? ctx.locateBinary() : "");
852
+ if (bundledBinary && !binaryUsable(bundledBinary)) {
853
+ warnings.push(missingBinaryWarning(bundledBinary));
854
+ }
855
+ warnLegacyAgents(targets, warnings);
856
+ }
857
+
858
+ // `<root>/specrune/` 整个目录归本工具:即便状态文件丢了、二进制被改过,
859
+ // 卸载语义也是清场。只删这一个目录,不碰宿主根下其它内容。
860
+ function uninstallExtra(targets, state, opts, ctx) {
861
+ const binDir = join(targets.root, "specrune");
862
+ const binDirExists = existsSync(binDir);
863
+ if (binDirExists && !opts.dryRun) {
864
+ rmSync(binDir, { recursive: true, force: true });
865
+ }
866
+ return { binDir, binDirExists };
867
+ }
868
+
869
+ const adapter = {
870
+ name: "codex",
871
+ displayName: "specrune-codex",
872
+ command: "specrune-codex",
873
+ packageName: "specrune-codex",
874
+ version: PKG.version,
875
+ protocol: PROTOCOL,
876
+ coreVersion: REQUIRED_CORE_VERSION,
877
+ moduleUrl: import.meta.url,
878
+ usage: USAGE,
879
+ rootLabel: "宿主根",
880
+ defaultScope: "project",
881
+ scopeHint: "Codex 只装项目级,global 会纠正为 project",
882
+ resolveTargets,
883
+ templateFiles,
884
+ actualScope,
885
+ detectOtherScope,
886
+ otherScopeError,
887
+ forceWarning,
888
+ scopeCorrectionWarning,
889
+ missingBinaryWarning,
890
+ mergeOwnedConfig,
891
+ scrubOwnedConfig,
892
+ stateFields(result) {
893
+ const info = hookInfo(result.bundledBinary);
894
+ return { cli: "global", cliPath: info.cliPath };
895
+ },
896
+ installPlanLines,
897
+ installSummary,
898
+ uninstallExtra,
899
+ uninstallSummary,
900
+ statusPayload,
901
+ statusSummary,
902
+ legacyWarnings,
903
+ };
904
+
905
+ export const { install, uninstall, status, parse, main } = createInstaller(adapter);
906
+ export { adapter };