Compare commits

...
4 Commits
Author SHA1 Message Date
Myfro9andClaude Opus 5 58b90f16a6 tools-sync: 合并 gitea 自愈与 junction/ingest 两条演进线
rebase 到 origin/main 时 tools-sync 三个文件都冲突——上游(SHANGHAI-U9)加了
gitea 传输自愈与 ls-remote 同步闸门,本机加了 registry 双向 ingest/修复模型。
两者互不相干,按并集合并:

- sync_repos.ps1: 保留 -NoDoctor/-AllowDestructive 与 Test-TransportFault /
  Invoke-Doctor / ls-remote 闸门,叠加 -Registry/-MirrorRegistry/-NoIngest 与
  registry 对账。上游末尾那段 stale-junction 清理引用了已删除的 $CodexSkills,
  且已被新 registry 循环覆盖,故弃用。
- SKILL.md: 保留阶段 1.5(gitea 医生)整节,阶段 2 换成 registry 对账;
  description 与阶段 6 报告模板取两侧并集。
- test_sync_repos_registries.ps1: 阶段 1 的成功串改成上游 ls-remote 闸门的
  "verified: origin/main ="。两个测试套件均通过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01438Zbcx49e4o46uSA71mFF
2026-09-07 16:23:13 +08:00
Myfro9andClaude Opus 5 f665448376 sync: BOWEI 2026-09-07 tools-sync 改为 junction 模型(registry 双向 ingest + 修复)
- sync_repos.ps1: 每个 skill 只在 ~\.claude\skills 有一份物理副本,其余 registry
  一律 junction;新增 registry-only skill 的自动 ingest、MISDIRECTED 链接修复、
  同名内容分叉的 CONFLICT 报告,以及只对 -MirrorRegistry 做全量镜像。
- SKILL.md: 重写阶段 1/2,补 Codex MCP 存储模型(config.toml 惰性,
  codex mcp list --json 是唯一权威口径)与 CLI 升级会清空注册表的事实。
- sync_mcp_registrations.ps1 + references/codex_mcp_wanted_keys.json:
  报告 codex-cli 无接口可设的键(tool_timeout_sec / env_vars)为
  "registered, but incomplete" 而非失败。
- tests/test_sync_repos_registries.ps1: registry 对账的回归测试。
- .gitignore: orchestration(Orca 自带的发现桩,同 orca-cli)列为 local-only。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01438Zbcx49e4o46uSA71mFF
2026-09-07 16:22:09 +08:00
boweiandMyfro9 aca7c799a8 tools-sync: MCP 审计改用 codex mcp list --json 作为 Codex 侧权威口径
Codex 26.825 起不再把用户 MCP 注册写入 ~/.codex/config.toml,导致原来解析
config.toml 的审计每次都把 7 个已注册 MCP 误报成 "Claude only" 并重复 add。

- Get-CodexMcps 优先调用 `codex mcp list --json`,CLI 不可用时回落到原 TOML 解析
- cua_repl(Codex 自带 ChatGPT.exe 运行时,装在 WindowsApps 下,原 .codex/runtimes
  正则匹配不到)纳入 Test-CodexManaged 自动排除,不再需要每次 -ExcludeName
2026-09-07 16:17:45 +08:00
boweiandMyfro9 d631dfef5c sync: BOWEI 2026-08-29 新增 dpd-mig-flow / sim-rtl-flow 两个 skill,去硬编码
- dpd-mig-flow、sim-rtl-flow 首次入库
- 两者的 LangGraph 检出路径、SMOD 参考工程路径、N9320B/板卡 IP 改为
  引用 ~/skills_env.md(新增 DPD_MIG_FLOW_ROOT / SIM_RTL_FLOW_ROOT /
  XILINX_SMOD_ROOT / SMOD_ATSC_REF 等条目)
- sim-rtl-flow 示例里写死的 /c/Users/Administrator 临时路径改为 $TMPDIR
- 撤出上次遗留的误入库 __pycache__/run_fpga_local.cpython-311.pyc
- .gitignore 忽略 orca-cli(指向 ~/.agents/skills 的外部链接)
2026-09-07 16:17:45 +08:00
11 changed files with 732 additions and 112 deletions
+2
View File
@@ -369,5 +369,7 @@ __pycache__/
/vercel-react-native-skills/
/vercel-react-view-transitions/
/web-design-guidelines/
/orca-cli/
/orchestration/
/.stfolder/
/.claude/
+236
View File
@@ -0,0 +1,236 @@
---
name: dpd-mig-flow
description: >
Run the dpd-mig-flow LangGraph workflow (checkout path 见 `~\skills_env.md` 的
`DPD_MIG_FLOW_ROOT`) — the
pipeline that ports the DPD + CFR functionality (FPGA RTL + i.MX6 app) from the ATSC
reference project (SMOD_ATSC\my_exciter) into **another SMOD project**, then verifies it
on real hardware: survey the reference design, port FPGA and imx6 sides in parallel,
build the bitstream and cross-compile the app (both via the fpga-imx6-flow skill),
debug on the board with the fake-PA distortion model, and gate on two objective
measurements — DPD trains without error and settles to Hold with shoulder ≥ 40 dB, and
the shoulder survives a power cycle. It drives the graph in-process (no langgraph dev)
and hands its 6 interrupts back to this Claude session to execute.
ALWAYS use this skill whenever the user wants to port, migrate, or transplant DPD/CFR
into another SMOD project, or wants to run/start/drive/resume/continue this workflow —
including phrasings like "移植 DPD", "把 DPD 和 CFR 移到 DAB/DVBT2 工程", "跑 dpd-mig-flow",
"启动 DPD 移植流程", "继续 DPD 移植", "resume the DPD migration", "port DPD to the DVB-T2
exciter", "DPD 带肩不达标怎么迭代", "验证 DPD 断电保持", even when they don't name the
project path or say the word "skill". Because the target project is never fixed, this
skill always asks the user which Vivado project to port INTO before starting.
---
## What this skill does
Drives the **dpd-mig-flow** graph (10 nodes / 12 flow-edges, 1:1 with
`Notes/dpd-mig-flow.drawio`; plan in `Notes/Task00.md`). Two node classes:
- **6 interrupt nodes** pause the graph and hand control to **this Claude session**,
which does the real work (read reference RTL/C, copy and rewire code, invoke the
`fpga-imx6-flow` skill to synthesize / cross-compile / debug on the board).
- **2 measurement nodes** run in-process and decide objectively: read DPD state over
HTTP/SSH, measure shoulder with the N9320B, power-cycle the board via the COM relay.
Persistence is a SqliteSaver file, so `start` / `resume` survive across separate process
calls, and each target project gets its own thread.
| Item | Value |
|---|---|
| Project root | 见 `~\skills_env.md` 的 `DPD_MIG_FLOW_ROOT` |
| Conda env | `langgraph` (path 见 `~\skills_env.md` 的 `CONDA_LANGGRAPH_PYTHON`) |
| Graph | `app.main_graph:build_graph` (in-process, no langgraph dev) |
| Driver | `<project>\run.py`(`start` / `resume` / `status` 三个子命令) |
| Checkpoint | `<project>\runs\checkpoint.sqlite`(按 `thread_id` 分区) |
| Reference project | 见 `~\skills_env.md` 的 `SMOD_ATSC_REF`,顶层 `...\sources_1\top\smod_top.v` |
| Target project | **每次运行由用户指定**,见 Step 1 |
| Design source | `Notes/dpd-mig-flow.drawio`,方案 `Notes/Task00.md` |
| Self-test | `<project>\smoke_test.py`(mock,68 项检查) |
```
PY = <CONDA_LANGGRAPH_PYTHON — 见 ~\skills_env.md>
PROJ = <DPD_MIG_FLOW_ROOT — 见 ~\skills_env.md>
RUN = %PROJ%\run.py
```
> **直接用环境里的 python.exe 跑,别用裸 `conda run`**:本机 GBK 控制台下 `conda run` 会用
> GBK 重新编码子进程的 UTF-8 stdout,**遇到中文 interrupt payload 会崩**。用环境
> `python.exe`,或 `conda run --no-capture-output`。
## ⚠️ 硬件时序铁律
**绝不在 USB-Blast 处于连接状态时给板卡上电或断电。** 本工作流的 `check_power_cycle`
节点会真的断电重启,其内部顺序已固定为:
```
断开 USB-Blast → 断电 → 等待 → 上电 → 等 app 就绪
```
`power_on()` / `power_off()` 各自还会兜底确认一次 USB-Blast 已断开。断电保持判定不需要
JTAG,所以默认**不再接回** USB-Blast(需要接回时把 `env_config.json` 的
`power.reconnect_blast` 置 `true`)。手工介入板卡时同样遵守这条。
继电器 COM 号**唯一来源是 `~/vivado_mcp_usb.conf`**(`[power]` / `[usb_blast]`,逐机而异、
不入库),与 vivado-mcp 的 `power_on`/`USB_Blast_on` 共用同一份配置;文件缺失时调 vivado-mcp
的 **`detect_serial_relays`** 工具自动探测生成。
## Step 0 — 确认环境(驱动前必做)
```powershell
$py = @("$env:USERPROFILE\miniconda3\envs\langgraph\python.exe","$env:USERPROFILE\.conda\envs\langgraph\python.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $py) { Write-Host "[MISSING] langgraph env" } else {
& $py -c "import langgraph; from langgraph.checkpoint.sqlite import SqliteSaver; from langgraph.types import Command; print('[OK] langgraph + checkpoint-sqlite')"
}
```
`[OK]` → 进 Step 1。缺失/导入失败 → 告诉用户先跑 **`/langgraph-vivado-install`**,别硬上。
想先确认工作流本身没坏(不碰任何硬件):
```powershell
& $py "$PROJ\smoke_test.py" # 期望 ALL PASS (68 checks)
```
## Step 1 — 问用户:移植到哪个工程?(这一步不能跳过)
设计图里全程只说"当前 vivado 工程",因为**它本来就不固定**——这条流水线是为"把 ATSC 的
DPD/CFR 搬到任意 SMOD 工程"设计的。所以目标工程**必须每次运行由用户显式给出**:没有默认值、
不沿用上次、不从配置文件里读、不替用户猜。`run.py` 会强制校验,缺参数直接非零退出。
`XILINX_SMOD_ROOT`(见 `~\skills_env.md`)下的候选通常有 `SMOD_DAB`、`SMOD_DVBT2`、`SMOD_DTMB_CG`、
`SMOD_DVBS`、`SMOD_SAT`。用 AskUserQuestion 或直接问,把这 7 项凑齐:
| 参数 | 含义 | 怎么定 |
|---|---|---|
| `--target-vivado` | 目标 Vivado 工程目录 | 用户指定 |
| `--target-xpr` | 目标工程 `.xpr` | 在上面目录里找,找到多个就让用户选 |
| `--target-imx6` | 目标配套 imx6 工程目录 | 通常是工程下的 `imx6` |
| `--standard` | 调制标准 | DAB / DVBT2 / DTMB / DVBS / SAT |
| `--symbol-clk-mhz` | 调制核输出符号采样率时钟 | **决定 DPD/CFR 挂在哪条数据链上**,问用户或从目标顶层读出来核实 |
| `--center-hz` | 频谱测量中心频率 | 决定带肩怎么测 |
| `--ch-bw-hz` | 信道带宽 | 同上 |
参数多,可以先写成档案文件再点名(**档案名仍须每次显式给出**,不许自动挑最新的):
```powershell
& $py $RUN start --target-profile "$PROJ\targets\smod_dab.json"
```
`start` 会校验目录/`.xpr` 真实存在、目标不是参考工程(防止误改参考工程 `my_exciter`),派生
`thread_id`(含工程指纹,不同工程绝不共用 checkpoint),并把参数落到
`runs/<thread_id>/launch.json` 留痕。
## Step 2 — 启动
```powershell
& $py $RUN start `
--target-vivado "<目标工程目录>" `
--target-xpr "<目标工程目录>\<name>.xpr" `
--target-imx6 "<目标工程目录>\imx6" `
--standard DAB --symbol-clk-mhz 8.192 --center-hz 214e6 --ch-bw-hz 1.536e6
```
输出一行 JSON:`{"status":"waiting","thread_id":...,"interrupts":[{"id":...,"task":...,"value":{...}}]}`。
**记下 `thread_id`**,后面每次 resume 都要用。
想先空跑一遍看流程(不碰仪器/串口/板卡):设 `$env:DPD_MIG_MOCK="1"` 再 start。
## Step 3 — 执行 interrupt,然后 resume(主循环)
每个 interrupt 的 `value` 里带 `action`、`instructions`(这一步的完整任务书)、`target`
(本次运行指定的目标工程,**据此决定往哪个工程写**,别靠记忆)、`expected_resume`(该回填什么)。
### ⚠️ 并行阶段会同时抛出 2 个 interrupt
`study_ref` 之后图分成 FPGA 侧和 imx6 侧两条并行支路,`invoke` 会**同时**返回 2 个
interrupt(构建阶段同理)。因此 resume **统一用 `{interrupt_id: payload}` 映射**,
单值形式会因为无法指定目标而报错卡死。可以分批恢复:只给一个 id,另一个保持挂起。
### resume 的正确姿势:写文件,别贴 JSON
Windows PowerShell 5.1 会**吞掉**传给原生 exe 的双引号,直接把 JSON 贴到命令行必然变成非法
JSON。用 `--mapping-file`(最省事,Write 出一个文件即可)或 `--mapping-b64`:
```powershell
# resume.json 内容形如:
# { "<id_of_port_fpga_rtl>": { "fpga_port_done": true, ... },
# "<id_of_port_imx6_src>": { "imx6_port_done": true, ... } }
& $py $RUN resume --thread-id "<thread_id>" --mapping-file "<scratch>\resume.json"
# 随时看卡在哪
& $py $RUN status --thread-id "<thread_id>"
```
### 6 个 interrupt 各自要做什么
| action | 你要做的事 | resume 回填 |
|---|---|---|
| `survey_reference`<br>(#3) | 通读参考工程的 DPD/CFR 用法,产出调研 md + 待移植文件清单。重点:旁路宏 `DPDENABLE`/`CFR_ENABLE`;发射链 `digital_rms_agc → CFR_cfr_conf → dacfr → DPD`;ORx 反馈链 `120M → rx1_dec3_120to40 → orx_dec_hb_40to10 → RAM2 → orx_iq_udp_tx`;SPI3 仲裁 `spi3_agc_arbiter`;DPD 系数存取 `spi_slave_2_DPD_SL.v` | `ref_survey_md`, `ref_file_list` |
| `port_fpga`<br>(#4) | 复制 DPD/CFR 源码进目标工程并加入 `.xpr`;参考 `smod_top.v` 接进目标顶层;**做速率转换**(参考工程 DPD/CFR 跑 40 MHz,目标符号率不同,必须接在调制核输出的正确符号采样率链上并与后续 204b 接口时序匹配);宏旁路;**ORx AGC + SPI3 仲裁与参考工程完全一致**;**保留 fake-PA**;建独立 DPD 寄存器模块(对标 `reg_map_dpd.v`) | `fpga_port_done`, `fpga_ported_files`, `rate_conv_scheme`, `reg_map_dpd_path`, `bypass_macros`, `fake_pa_kept`, `orx_agc_ported` |
| `port_imx6`<br>(#5) | 整理并移植 6 项功能:①寄存器控制 ②**DPD 控制状态流程 md(含 mermaid)** ③前面板键盘+液晶 ④SPI3 仲裁 ⑤网页控制+状态显示 ⑥隐藏菜单参数。入口:`bsp/dpd_proc.c`、`bsp/eeprom.c`、`task/lcd_task.c`、`task/uart_user.c`、`adrv9009/adrv9009_config.c`、`web/www*/sys_set.html`。**顺手把 `env_config.json` 的 `board.endpoints` 按实际接口填实** | `imx6_port_done`, `imx6_ported_files`, `dpd_state_md`, `imx6_feature_md`, `imx6_features_ok`(6 项全 true 才放行) |
| `build_fpga`<br>(#11) | 调 **`fpga-imx6-flow` skill** 综合实现出 bitstream + flash 文件;重点看速率转换到 204b 那段时序有没有违例 | `build_fpga_ok`, `bit_path`(必须真实存在), `flash_path` |
| `build_imx6`<br>(#6) | 调 **`fpga-imx6-flow` skill** 交叉编译出 app | `build_imx6_ok`, `app_path` |
| `hw_debug`<br>(#10) | 调 **`fpga-imx6-flow` skill** 下 bit、部署 app、重启;**开 fake-PA**;网页 DPD start 训练;看 ILA/串口;用 N9320B 看 DPD 前后带肩。离开本节点前确保 fake-PA 已开、DPD 已 start、板卡与频谱仪在线——下一个节点会自动读状态+测带肩来判定 | `debug_log`, `fake_pa_on`(必须 true), `dpd_started`(必须 true), `shoulder_before_db`(可选,用于判定改善量) |
第 2 轮起 `hw_debug` 的 payload 里会带 `previous_failure`:**先看它再动手**。带肩不达标 →
查 DPD 收敛 / ORx AGC / 速率转换;断电后带肩掉了 → 查 DPD 系数 save/load 与 eeprom 持久化。
### 两个判定节点(不需要你介入,除非降级)
- `check_dpd_train`(#15):等训练收敛 → 读 DPD 状态 → 测带肩。四个条件全过才算通过:
训练无报错、状态为 `Hold`、带肩 ≥ 40 dB、(若已知 DPD 前带肩)确有改善。否则回 `hw_debug`。
- `check_power_cycle`(#19):测重启前带肩 → 按铁律断电重启 → 等 app 起来 → 复测 →
变化 ≤ 1.5 dB 且仍 ≥ 40 dB 且状态仍为 Hold 才算通过。否则回 `hw_debug`。
**降级裁决**:仪器/板卡够不着或读数矛盾时,节点会抛 `action: "adjudicate"` 的 interrupt,
payload 的 `evidence` 里带着已采到的数据。你据此给结论:`{"pass": true/false, "reason": "..."}`。
别为了让流程往下走而随手判 true——这两个判据就是整个任务的验收标准。
> 测量结果按 `(thread, iteration)` 落盘缓存(`runs/<thread>/iter<N>/verify_*.json`)。
> interrupt-resume 会让节点从头重跑,缓存保证**一轮之内绝不会把板子断电两次**。
## Step 4 — 结束
两个判定都过 → `task_done`,`status` 变 `done`,汇总落 `runs/<thread_id>/summary.json`
(含最终带肩、断电前后读数、bit/app 路径、产出的几份 md)。把这些回报给用户。
迭代超过 `max_iterations`(默认 10)会优雅报错退出——这是让人介入判断"是移植方案的问题还是
指标定义的问题",不是让你调大上限硬撑。
## 真机运行前的检查清单
`DPD_MIG_MOCK` **不设**(或设为 0)就是真机模式。开跑前确认:
| 项 | 怎么确认 | 缺了怎么办 |
|---|---|---|
| 继电器 COM 号 | `~/vivado_mcp_usb.conf` 有 `[power]` 和 `[usb_blast]` | 调 vivado-mcp 的 `detect_serial_relays` |
| 板卡 DPD 接口 | `env_config.json` 的 `board.endpoints`(或 `ssh_commands`)已按目标工程实际接口填实 | 还是占位模板时 `tools_board` 会**明确报错**,不会对着不存在的接口测出假结论;先完成 `port_imx6` 再来 |
| N9320B | `spectrum.host`(默认取 `~\skills_env.md` 的 `N9320B_HOST`)能 ping 通 | 检查网络/仪器电源 |
| 板卡 | `board.ip`(默认取 `~\skills_env.md` 的 `SMOD_BOARD_IP`)能通 | 先上电 |
| 频谱判据 | `center_hz` / `ch_bw_hz` 是本次目标标准的正确值 | 问用户 |
带肩定义(DPD 前后、断电前后必须用**完全相同**的仪器设置,否则数值不可比):带内参考取
信道内 PSD 均值,肩部取 `±(ch_bw/2 + ch_bw × shoulder_offset_ratio)` 处 PSD,
`shoulder_db = 带内 − 肩部`,取上下边带较差者。RBW/VBW/检测器/平均/参考电平/衰减全部由
`env_config.json` 固定。
## 常见问题
| 现象 | 原因 / 处理 |
|---|---|
| `缺少必填的目标工程参数` | 按设计如此(每次必须显式指定),把 7 个参数补齐 |
| `目标工程不能是参考工程` | 防误改 `my_exciter` 的保护,检查 `--target-vivado` |
| `目标工程参数与本 thread 已记录的不一致` | 中途改了参数。换工程会自动换 thread;只是想改测量频点/带宽就用 `--tag` 开新 thread,别在半途改判据 |
| `resume 参数不是合法 JSON` 且引号丢了 | PowerShell 吞引号,改用 `--mapping-file` / `--mapping-b64` |
| `未知的 interrupt id` | id 过期了(已被恢复过)。先 `status` 拿当前 id |
| `board 接口仍是占位模板` | `port_imx6` 还没把实际网页/命令行接口填进 `env_config.json` |
| 控制台中文乱码/崩溃 | 别用裸 `conda run`,直接用环境 `python.exe` |
| 改了 `app/*.py` 后行为没变 | 无 dev server 缓存,直接重跑即可;若图结构改到与旧 checkpoint 不兼容,换 `--tag` 或删 `runs/checkpoint.sqlite` |
## 相关
- **`fpga-imx6-flow`** — 综合/交叉编译/下载/上板调试都转交它(本图不嵌套调用另一张图,
两张图各有 checkpointer 与 interrupt 语义,进程内嵌套会互相污染)
- **`smod_atsc_dev`** — SMOD 平台事实、两条烧写路径的选择、上电时序
- **`fpga_flash`** / **`fpga_update`** — JTAG 直烧 / 远程升级 mcs
- **`langgraph-vivado-install`** — 创建 `langgraph` conda 环境
+4 -3
View File
@@ -1,7 +1,8 @@
---
name: sim-rtl-flow
description: >
Run the sim-rtl-flow LangGraph workflow at E:\LangGraph_Design\sim-rtl-flow — the
Run the sim-rtl-flow LangGraph workflow (checkout path 见 `~\skills_env.md` 的
`SIM_RTL_FLOW_ROOT`) — the
Simulink-to-RTL-to-hardware design pipeline: read the previous run's design version-tree
doc xxx_tree.md to decide what may be reused → Simulink -x principle sim
(sim-golden) → HUMAN sign-off on simulation performance → optional -q fixed-point →
@@ -228,7 +229,7 @@ regenerated tree has lost last round's attributes. Default, as everywhere here,
`<work_dir>/.sim_rtl_flow/`** — that is where `resume`/`record`/`status` will look.
```bash
cat > /c/Users/Administrator/AppData/Local/Temp/claude/input.json <<'EOF'
cat > $TMPDIR/sim_rtl_flow_input.json <<'EOF'
{
"requirements_path": "Notes/requirements.md",
"work_dir": "E:/Xilinx_design/my_project",
@@ -257,7 +258,7 @@ EOF
### 2. Start — exits 10 with a handoff
```bash
"$PY" "$RUN" --thread-id my-design-001 start --input /c/Users/Administrator/AppData/Local/Temp/claude/input.json
"$PY" "$RUN" --thread-id my-design-001 start --input $TMPDIR/sim_rtl_flow_input.json
```
`start` takes `work_dir` from the input JSON and prints where state landed. Verified:
+96 -38
View File
@@ -1,6 +1,6 @@
---
name: tools-sync
description: 在本机统一同步并配置 Claude Code 与 Codex 的 skills、MCP server 和开发环境:将 ~\.claude\skills 和 ~\.codex\skills 与 git.rpchannel.com 上的 bowei/skills-win 仓库保持同步,审计 skills 中的硬编码参数并迁移到本机 ~\skills_env.md,校验 langgraph conda 环境,双向发现并补齐 Claude Code 与 Codex 的用户 MCP 注册,同时确保 E:\MCP_design 下所有 MCP 均已注册。同步的传输一旦出问题,本 skill 还负责登录 gitea 服务器(上海 mac 的 Docker 容器)诊断并修复,直到同步真正成功。当用户提到"同步skills"、"skills同步"、"tools sync"、"tools-sync"、"同步codex"、"同步MCP"、"环境同步"、"skills-win"、"把skills推上去/拉下来"、"新电脑配置claude/codex环境"、"检查MCP注册"、"skills_env",或者 git.rpchannel.com 出现"clone/fetch 失败"、"bad pack header"、"early EOF"、"RPC failed"、"推不上去/拉不下来"、"gitea 坏了/连不上/要修"时,必须使用此技能,即使用户没有明确说"skill"二字。
description: 在本机统一同步并配置 Claude Code 与 Codex 的 skills(~\.claude\skills / ~\.codex\skills / ~\.agents\skills)、用户级 MCP 注册和开发环境:主副本与 git.rpchannel.com 上的 bowei/skills-win 仓库保持同步(其余目录一律是 junction),审计 skills 中的硬编码参数并迁移到本机 ~\skills_env.md,校验 langgraph conda 环境,双向发现并补齐 Claude Code 与 Codex 的用户 MCP 注册,同时确保 E:\MCP_design 下所有 MCP 均已注册。同步的传输一旦出问题,本 skill 还负责登录 gitea 服务器(上海 mac 的 Docker 容器)诊断并修复,直到同步真正成功。当用户提到“同步skills”、“skills同步”、“tools sync”、“tools-sync”、“同步codex”、“codex 里的 skill 怎么进仓库”、“把 codex 新写的 skill 收进来”、“同步MCP”、“MCP 注册/更新/补齐”、“检查MCP注册”、“codex mcp 没了”、“环境同步”、“skills-win”、“把skills推上去/拉下来”、“新电脑配置claude/codex环境”、“skills_env”、“skills 硬编码路径”,或者 git.rpchannel.com 出现“clone/fetch 失败”、“bad pack header”、“early EOF”、“RPC failed”、“推不上去/拉不下来”、“gitea 坏了/连不上/要修”时,必须使用此技能,即使用户没有明确说“skill”二字。也用于处理 junction 指错、两侧 skill 分叉、以及 skills 里的硬编码路径/密码需要迁进 ~\skills_env.md 的场合。
---
# tools-sync:Claude Code / Codex 工具链同步
@@ -11,12 +11,22 @@ description: 在本机统一同步并配置 Claude Code 与 Codex 的 skills、M
注册表。这个 skill 的职责是让它们保持一致,并且可以在一台新电脑上从零复原整套配置:
- **skills 的唯一真源**是 `http://git.rpchannel.com/bowei/skills-win.git`(remote 名 `origin`)。
`~\.claude\skills` 是唯一的 git 检出(主副本);`~\.codex\skills` 下的每个 skill
是指向主副本对应目录的 **junction(目录联接)**——两个工具共享同一份物理文件,
从机制上杜绝两侧漂移,git 操作只发生在主副本一处。
Codex 没有注册命令——它在会话启动时自动扫描 `~\.codex\skills` 下所有含 `SKILL.md`
的目录(junction 对它透明),所以"注册到 Codex" = "junction 存在且指向的目录含
SKILL.md",新开的 Codex 会话才会扫到新 skill。
`~\.claude\skills` 是唯一的 git 检出(主副本);其余目录里的每个 skill 都是指向主副本
对应目录的 **junction(目录联接)**——所有工具共享同一份物理文件,从机制上杜绝漂移,
git 操作只发生在主副本一处。
受管的 registry 有两个:`~\.codex\skills`(Codex 自己的目录,主副本在这里做全量镜像)和
`~\.agents\skills`(Codex / Copilot CLI / Gemini CLI 都会扫的跨工具别名目录)。
**`~\.agents\skills` 只做发现与修复,不做全量镜像**:Codex 两个目录都扫,全量铺进去会让
同一个 skill 注册两遍。
- **同步是双向的**。Codex 侧新建的 skill(真实目录、含 SKILL.md、主副本里没有同名)会被
**自动 ingest**:移进 `~\.claude\skills` 成为主副本,原地换成 junction,随后跟着阶段 1 入库。
这是"Codex 里写的 skill 如何进仓库"的唯一正规路径,不需要人工搬运。
Codex 没有注册命令——它在会话启动时自动扫描上述目录下所有含 `SKILL.md` 的目录
(junction 对它透明),所以"注册到 Codex" = "junction 存在且指向的目录含 SKILL.md",
新开的 Codex 会话才会扫到新 skill。
- **Codex 插件(`codex plugin list`)自带的 skill 和 MCP 归 marketplace 所有**,不属于可移植
用户配置,任何阶段都不要 ingest、不要复制到 Claude 侧。同理 `~\.agents\skills` 里指向
`~\.matlab\agentic-toolkits` 的 junction 是 MathWorks 装的,脚本只计数不动它。
- **机器相关的参数不进 git**。每台电脑的软件安装路径、IP、账号等都记录在
`~\skills_env.md`(用户主目录下,天然在两个仓库检出之外,不会被 git 同步)。
skills 里引用参数时写"见 ~\skills_env.md 的 XXX 条目",不写死路径。
@@ -137,23 +147,38 @@ powershell -File <skill目录>\scripts\gitea_doctor.ps1 -Fix # 检查→登
第三步能直接看出是 gitea 丢了 pack 还是 git 自己没产出——上次就是这一步定的案。
定位到新根因后,把它补进 `gitea_triage.sh` 的检查表,别只修这一次。
## 阶段 2 — 对齐 ~\.codex\skills(junction 对账)
同一个脚本接着处理 codex 侧。它对主副本的每个顶层 skill 目录做三件事:
## 阶段 2 — registry 对账(ingest + 修复 + 镜像)
- codex 侧没有同名条目 → 创建 junction(`New-Item -ItemType Junction`,
Windows 上不需要管理员权限,Codex 对 junction 完全透明)。
- 已是 junction → 跳过(内容天然一致,无需任何拷贝)。
- 是**真实目录**(挡住了链接)→ 脚本停下来列出这些目录。停的原因:真实目录
里可能有 codex 独有的、还没入库的内容。处理原则——有价值的内容先复制进
`~\.claude\skills` 走阶段 1 入库,确认无需保留后用 `-Force` 重跑,脚本会
删掉真实目录并建 junction。
同一个脚本接着扫 `~\.codex\skills` 和 `~\.agents\skills`,对每个顶层条目分类处理:
脚本还会清理"指向已删除主副本目录"的失效 junction(skill 在仓库里被删除或
改名后残留的链接)。codex 自带的系统 skills(`.system\`)永远不动。
| 现状 | 脚本动作 |
|------|----------|
| junction 指向主副本,目标存在 | 跳过(正确状态) |
| junction 指向主副本,目标已消失 | 删除失效链接(skill 在仓库里被删或改名后的残留) |
| junction 指向受管 registry 之外(如 `~\.matlab\agentic-toolkits`) | 只计数,不动(宿主自己装的) |
| junction 指向**另一个 registry** 里的副本 | 报 MISDIRECTED,解析那份副本后把链接改指主副本 |
| 真实目录,含 SKILL.md,主副本**无**同名 | **INGEST**:移进主副本 + 原地建 junction(`-NoIngest` 可关闭) |
| 真实目录,与主副本同名且内容**相同** | 直接塌缩成 junction(丢不了东西) |
| 真实目录,与主副本同名但内容**不同** | 报 CONFLICT,两边都不动,退出码 2 |
| 目录里没有 SKILL.md | 忽略并说明 |
校验注册:主副本每个 skill 目录在 codex 侧都应有同名 junction 且透出
`SKILL.md`(脚本已内置该检查)。提醒用户:Codex 要开新会话才会扫到新 skills。
最后把主副本每个 skill 镜像进 `-MirrorRegistry`(默认 `~\.codex\skills`)。
`.system\` 等点开头目录永远不动。
判断力要点:
- **CONFLICT 是唯一需要人裁决的分支。** 内容指纹排除了 `.git`、`__pycache__`、
`.langgraph_api`、`*.pckl`、`*.sqlite` 这类运行时产物,所以报出来的分叉是真分叉。
处理方式:把想留的内容并进主副本,再带 `-Force` 重跑(`-Force` 一律主副本胜出)。
- **ingest 进来的 skill 可能命中 `.gitignore` 的 local-only 段**(仓库里有一段
`/orca-cli/` 之类的本机专用规则)。脚本会 `git check-ignore` 并在报告里点名——
这种 skill 留在本机、不会推上远端,换电脑时不会出现。要它进仓库就删掉那条规则。
- **registry 自带 `.git` 是历史遗留的竞争检出。** 本机 `~\.agents\skills` 就有一个指向
github 镜像的检出。脚本只报告不动手;等它的独有内容都 ingest 完,再由用户决定删。
校验注册:主副本每个 skill 在镜像 registry 都应有同名 junction 且透出 `SKILL.md`
(脚本已内置该检查)。提醒用户:Codex 要开新会话才会扫到新 skills。
## 阶段 3 — 硬编码审计与 skills_env.md 回填
@@ -194,8 +219,31 @@ commit + push 一次(junction 模型下 codex 侧自动同步,无需额外
## 阶段 5 — MCP server 双向发现、注册与校验(Claude Code ↔ Codex)
### Codex 的 MCP 存储模型(codex-cli 0.150.1 实测,官方无文档)
动手前必须知道这三条,否则会写出"看起来成功、实际无效"的配置:
- **`codex mcp list --json` / `add` / `remove` 是唯一有效接口。** 审计读它,注册写它。
- **`~\.codex\config.toml` 的 `[mcp_servers.*]` 节是惰性的,Codex 不读。** 实测:把
`node_repl` 的 `startup_timeout_sec` 从 120 改成 137,`codex mcp list` 仍报 120;
手写一整节则完全不出现。config.toml 里现存的那几节是历史遗留。
**本 skill 旧版"缺失的补写 TOML 节"是错的,别再那样做——它静默地什么也没干。**
- **这个存储不跨 CLI 升级存活。** 2026-08-29 注册好的 7 个用户 MCP,在升级到 0.150.1
后全部消失。所以每次同步都要重新审计 Codex 侧,`~\skills_env.md` 的 MCP 配置节
才是可重建的真源。
由此还有一个已知缺口:`codex mcp add` 没有 `--tool-timeout-sec` / `env_vars` 之类的参数,
config.toml 又不生效,所以这两个键**目前无任何设置入口**。需要它们的 MCP 记在
`references/codex_mcp_wanted_keys.json`,脚本注册完会单独报"registered, but incomplete",
不要把它当失败,也不要假装已配好。每次 codex-cli 升级后复查是否已有新接口。
另:这两个脚本都要用 PowerShell 跑,不要用 git-bash——MSYS 会改写 `/c` 形式的参数和
Windows 路径(实测 `codex mcp add ... /c echo hi` 的 `/c` 被改成了 `C:/`)。
### 双向审计
先运行双向审计脚本。它读取 Claude Code 用户级 `~\.claude.json` 的 `mcpServers` 和
Codex 用户级 `~\.codex\config.toml` 的 `[mcp_servers.*]`,对用户可配置 MCP 做并集:
`codex mcp list --json`,对用户可配置 MCP 做并集:
```powershell
# 只审计;发现差集或漂移时退出码为 2,不修改配置
@@ -231,17 +279,17 @@ powershell -File <skill目录>\scripts\sync_mcp_registrations.ps1 -Apply -Confli
1. **Claude Code 侧**:`claude mcp list` 或读 `~\.claude.json` 的 `mcpServers`,
确认存在对应条目。缺失的用 `claude mcp add <name> ...` 注册,命令、解释器
路径和 env 变量都取自 `~\skills_env.md` 的 MCP 配置节。
2. **Codex 侧**:读 `~\.codex\config.toml` 的 `[mcp_servers.<name>]` 节。
缺失的按 skills_env.md 里的同一份配置补写 TOML 节(stdio 类型,
记得带 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`,Codex 在 Windows 上
没有这两个变量时中文输出会乱码)。
2. **Codex 侧**:`codex mcp list --json` 确认存在对应条目。缺失的用
`codex mcp add <name> --env K=V -- <command> <args...>` 注册,配置取自 skills_env.md
的同一份表(stdio 类型记得带 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`,Codex 在
Windows 上没有这两个变量时中文输出会乱码)。
3. **可启动性抽查**:对新注册的 MCP,直接运行其 command + args 数秒,
确认进程能起来、没有立刻报 import/路径错误(stdio MCP 起来后会静默等
输入,这就算通过,Ctrl-C 掉即可)。
注意两侧配置语义相同但格式不同(JSON vs TOML)。先由双向脚本发现差集和漂移,再用
`mcp_list.md` 查漏,最后以 skills_env.md 和实际可启动结果判定正确值。不要因为某项当前
只在一侧出现,就假设另一侧没有安装需求。
先由双向脚本发现差集和漂移,再用 `mcp_list.md` 查漏,最后以 skills_env.md 和实际可启动
结果判定正确值。不要因为某项当前只在一侧出现,就假设另一侧没有安装需求——尤其 Codex 侧
可能只是被上一次 CLI 升级清空了。
### MATLAB / Simulink Agentic Toolkit 专项检查与安装
@@ -256,13 +304,17 @@ powershell -File <skill目录>\scripts\sync_mcp_registrations.ps1 -Apply -Confli
且已安装 Simulink。实际 MATLAB 根目录记录到 `~\skills_env.md` 的 `MATLAB_ROOT`。
2. `~\.matlab\agentic-toolkits\bin\matlab-mcp-server.exe` 存在且 `--version` 可运行;
`config.json` 的 `toolkits` 同时包含 `matlab` 与 `simulink`。
3. Claude `~\.claude.json` 与 Codex `~\.codex\config.toml` 均有用户级 `matlab` MCP,
3. Claude `~\.claude.json` 与 `codex mcp list --json` 均有用户级 `matlab` MCP,
command 指向同一二进制,args 同时包含 `--matlab-session-mode=existing` 和正确的
`--extension-file`。Claude 条目应有 `alwaysLoad=true`。
4. Codex 的 `[mcp_servers.matlab]` 还必须有 `tool_timeout_sec = 600` 和 Windows 所需的
`env_vars = ["WINDIR"]`;缺少 `WINDIR` 时 Simulink 子进程可能无法正常工作。
5. `~\.agents\skills` 中存在指向两套 Toolkit 的 junction;Claude Code 的 MATLAB /
Simulink plugin 或等效 skill 注册存在。
4. Codex 侧本应还有 `tool_timeout_sec = 600` 和 Windows 所需的 `env_vars = ["WINDIR"]`
(缺 `WINDIR` 时 Simulink 子进程可能起不来),但 0.150.1 **没有任何接口能设置它们**
(见本阶段开头的存储模型)。所以这一条是**已知缺口,不作为安装完整性的判定项**:
脚本报出 "registered, but incomplete" 即可,写进报告的遗留事项,不要标记为失败,
也不要试图靠改 config.toml 绕过——那不生效。
5. `~\.agents\skills` 中存在指向两套 Toolkit 的 junction(阶段 2 会把这类指向
`~\.matlab\agentic-toolkits` 的 junction 识别为宿主自有,只计数不动);Claude Code 的
MATLAB / Simulink plugin 或等效 skill 注册存在。
缺失时使用 MathWorks 官方统一安装器,不使用旧版 agent-driven 安装法:
@@ -290,8 +342,9 @@ setupAgenticToolkit("configure", Agents=["claude-code","codex"], ...
Setup” 与 “MATLAB MCP Server Toolbox” 两个官方 Toolbox 根目录加入 path 并
`savepath`,再用全新 MATLAB 进程通过 `which setupAgenticToolkit`、
`which shareMATLABSession` 验证。
- 安装后重新运行双向审计脚本;`matlab` 必须显示为 Matched。再直接解析两边配置,确认
Simulink extension、Codex timeout 与 `WINDIR` 未被规范化比较遗漏。
- 安装后重新运行双向审计脚本;`matlab` 必须显示为 Matched。再直接比对两边的 command/args,
确认 Simulink extension 未被规范化比较遗漏(Codex timeout 与 `WINDIR` 见上面第 4 条,
当前无法设置,不必核对)。
- 使用 Simulink 工具前,每个 MATLAB 会话运行
`addpath("~/.matlab/agentic-toolkits/simulink"); satk_initialize`,然后重启 Claude Code /
Codex 会话。MCP 健康检查在没有共享 MATLAB 会话时可能显示连接失败,这不等于未安装。
@@ -306,15 +359,17 @@ setupAgenticToolkit("configure", Agents=["claude-code","codex"], ...
- ~\.claude\skills: <pull/push 结果,commit 摘要>
- 远端校验: origin/main = <sha>,与本地一致 / **不一致:N 个 commit 滞留本机**
- 服务端: 传输两面均正常 / 医生修复了 <哪几项> / **仍不可用:<RESULT 行>**
- ~\.codex\skills: <pull 结果,注册差集>
- 从 registry ingest: <被自动收进主副本的 codex/agents 侧 skill;没有则写"无">
- junction 对账: <修复的 MISDIRECTED、塌缩的重复、清理的失效链接、镜像新建数>
## 硬编码审计
- 本次修复: <清单>
- 遗留待改: <清单>
## 环境
- langgraph env: OK / 已重装 / 失败原因
- MCP 注册: <Claude 独有、Codex 独有、同名漂移、已补齐、排除项、清单查漏、MATLAB/Simulink 专项检查、抽查结果>
- MCP 已注册但不完整: <codex-cli 无接口可设的键;没有则写"无">
## 需要用户决定的事项
- <冲突、密码泄露、codex 独有 skill 去留……没有则写"无">
- <CONFLICT 分叉、密码泄露、local-only 的 ingest 项、registry 里的竞争检出……没有则写"无">
```
## 边界与安全
@@ -324,6 +379,9 @@ setupAgenticToolkit("configure", Agents=["claude-code","codex"], ...
- `~\skills_env.md` 含凭据,任何情况下不将其内容或凭据字段写入仓库内文件、
commit message 或对外输出。
- 不动 `~\.codex\skills\.system\`(Codex 自带系统 skills)和
`~\.claude\skills\.system\`。
`~\.claude\skills\.system\`;也不动 `codex plugin` 装的 marketplace 插件。
- 修改 `~\.codex\config.toml` 前先备份为 `config.toml.bak-<日期>`,TOML 写坏
会导致 Codex 起不来。
- **ingest 会 `Move-Item`,`-Force` 会 `Remove-Item -Recurse`。** 先 `-DryRun` 看清单,
确认没有误判再实跑;CONFLICT 项在用户裁决前脚本不会碰。
- 两个脚本都从 PowerShell 运行,不要用 git-bash(MSYS 会改写参数里的 `/c` 和 Windows 路径)。
@@ -0,0 +1,9 @@
{
"_comment": "Codex-side [mcp_servers.<name>] keys certain MCPs need that codex-cli 0.150.1 offers NO way to set: `codex mcp add` has no flag for them, and config.toml's [mcp_servers.*] tables are inert (measured -- editing one there does not change what `codex mcp list` reports). sync_mcp_registrations.ps1 reports these after registering so the gap stays visible instead of looking complete. Re-check on each codex-cli upgrade; delete an entry once a real interface exists and the key is set.",
"matlab": {
"tool_timeout_sec": 600,
"env_vars": ["WINDIR"],
"_why": "Simulink model_edit/model_test round-trips exceed the default tool timeout; the Simulink subprocess needs WINDIR inherited from the parent environment on Windows."
}
}
+13 -8
View File
@@ -10,8 +10,9 @@
SKILLS_REPO_URL = http://git.rpchannel.com/bowei/skills-win.git
SKILLS_REPO_USER = bowei
SKILLS_REPO_PASS = <填写,或留空表示已存入 Windows 凭据管理器>
CLAUDE_SKILLS_DIR = C:\Users\<user>\.claude\skills
CODEX_SKILLS_DIR = C:\Users\<user>\.codex\skills
CLAUDE_SKILLS_DIR = C:\Users\<user>\.claude\skills # 唯一 git 检出(主副本)
CODEX_SKILLS_DIR = C:\Users\<user>\.codex\skills # 全量镜像,全是 junction
AGENTS_SKILLS_DIR = C:\Users\<user>\.agents\skills # 跨工具别名目录;只做发现/修复,不全量铺
```
凭据说明:首次 clone 成功后运行 `git config --global credential.helper manager`,
@@ -93,11 +94,13 @@ CROSS_VM_PASS = <填写>
MCP_DESIGN_ROOT = E:\MCP_design
```
每个 MCP 在 Claude Code(`~\.claude.json` 的 `mcpServers`)和 Codex
(`~\.codex\config.toml` 的 `[mcp_servers.*]`)中应使用**同一份** command/args/env。
同步时还要对两侧用户级注册表取并集,确保任一侧新增的可移植 MCP 都同步到另一侧;
每个 MCP 在 Claude Code(`~\.claude.json` 的 `mcpServers`)和 Codex(`codex mcp add`,
读用 `codex mcp list --json`)中应使用**同一份** command/args/env。注意 Codex 侧
`~\.codex\config.toml` 的 `[mcp_servers.*]` 节**不生效**,手写无用;且它的注册存储
不跨 codex-cli 升级存活,所以下表是 Codex 侧唯一可重建的真源,每次同步都要核对。
同步时对两侧用户级注册表取并集,确保任一侧新增的可移植 MCP 都同步到另一侧;
`mcp_list.md` 继续用于发现“两侧都漏装”的本地工程。托管连接器、插件动态 MCP、
Codex 内置运行时 MCP 不进入这个并集。
Codex 内置运行时 MCP(node_repl / cua_repl)不进入这个并集。
标准配置如下(路径变量引用上文条目):
| name | command | args | env |
@@ -110,8 +113,10 @@ Codex 内置运行时 MCP 不进入这个并集。
| matlab | {MATLAB_MCP_EXE} | --matlab-session-mode=existing --extension-file=...\simulink\tools\tools.json | |
Codex 侧所有 stdio MCP 额外加 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`。
MathWorks `matlab` MCP 是特例:Codex 还要设置 `tool_timeout_sec=600` 与
`env_vars=["WINDIR"]`;Claude 侧要设置 `alwaysLoad=true`。MATLAB 与 Simulink 共用该条目。
MathWorks `matlab` MCP 是特例:Claude 侧要设 `alwaysLoad=true`;Codex 侧本应有
`tool_timeout_sec=600` 与 `env_vars=["WINDIR"]`,但 codex-cli 0.150.1 无接口可设,
属已知缺口(记录在 tools-sync 的 `references/codex_mcp_wanted_keys.json`)。
MATLAB 与 Simulink 共用该条目。
## 本机状态备注(同步时更新)
+92 -1
View File
@@ -1,6 +1,20 @@
# tools-sync: audit and reconcile user-configured MCP registrations between
# Claude Code and Codex. Managed connectors, plugins, and Codex runtime MCPs are
# intentionally excluded because they are owned by their host application.
#
# Codex storage model (measured on codex-cli 0.150.1, not documented anywhere):
# * `codex mcp list --json` / `add` / `remove` are the ONLY working interface.
# * config.toml's [mcp_servers.*] tables are INERT. Editing node_repl's startup_timeout_sec
# there from 120 to 137 left `codex mcp list` reporting 120, and a section written by hand
# never appeared at all. Hand-writing TOML sections -- what this skill used to instruct --
# silently does nothing. The sections still in config.toml are legacy leftovers.
# * `codex mcp add` persists outside config.toml and has no flag for tool_timeout_sec or
# env_vars, so those keys currently have no setting entry point at all.
# * That store is not durable across CLI upgrades: seven user MCPs registered on 2026-08-29
# were gone after the upgrade to 0.150.1. Re-audit Codex MCPs on every sync; ~/skills_env.md
# is the durable record to re-register from.
#
# Run from PowerShell, NOT git-bash: MSYS rewrites `/c`-style args and Windows paths.
[CmdletBinding()]
param(
@@ -10,12 +24,21 @@ param(
[ValidateSet("None", "Claude", "Codex")]
[string]$ConflictAuthority = "None",
[string[]]$ExcludeName = @(),
[string]$PythonCommand = "python"
[string]$PythonCommand = "python",
# Where to read Codex's registrations. Auto = `codex mcp list --json`, falling back to
# parsing -CodexConfig when the CLI is unavailable (older codex, or a test fixture).
# Toml forces the fixture path so tests can isolate from the real machine.
[ValidateSet("Auto", "Cli", "Toml")]
[string]$CodexSource = "Auto",
# Codex-side keys some MCPs need that codex-cli offers no way to set (see the note above).
# Resolved after the param block: $PSScriptRoot is not yet bound in default expressions here.
[string]$OverridesFile
)
$ErrorActionPreference = "Stop"
$script:CodexBackupMade = $false
$script:Failures = @()
$script:Unsupported = @()
$CodexOnlyEnv = @("PYTHONIOENCODING", "PYTHONUTF8")
function Test-TomllibPython([string]$Candidate) {
@@ -53,6 +76,19 @@ function Resolve-PythonCommand([string]$Requested, [bool]$Explicit) {
$PythonCommand = Resolve-PythonCommand $PythonCommand $PSBoundParameters.ContainsKey('PythonCommand')
if (-not $OverridesFile) {
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$OverridesFile = Join-Path (Split-Path -Parent $here) "references\codex_mcp_wanted_keys.json"
}
$script:Overrides = @{}
if (Test-Path -LiteralPath $OverridesFile) {
$raw = Get-Content -Raw -LiteralPath $OverridesFile | ConvertFrom-Json
foreach ($property in $raw.psobject.Properties) {
if ($property.Name -notlike "_*") { $script:Overrides[$property.Name] = $property.Value }
}
}
function ConvertTo-StringMap($Object) {
$result = [ordered]@{}
if ($null -eq $Object) { return $result }
@@ -105,7 +141,35 @@ function Get-ClaudeMcps {
return $result
}
function Get-CodexMcpsFromCli {
# Codex >= 26.8 no longer persists user MCP registrations in config.toml;
# `codex mcp list --json` is the authoritative view. Returns $null when the
# CLI is unavailable so the caller can fall back to TOML parsing.
$codexExe = Get-Command codex -ErrorAction SilentlyContinue
if (-not $codexExe) { return $null }
$raw = @(& $codexExe.Source mcp list --json 2>$null)
if ($LASTEXITCODE -ne 0 -or -not $raw) { return $null }
try { $servers = ($raw -join "`n") | ConvertFrom-Json } catch { return $null }
if ($null -eq $servers) { return $null }
$result = @{}
foreach ($s in @($servers)) {
$t = $s.transport
$transport = if ($t.type) { [string]$t.type } elseif ($t.url) { "http" } else { "stdio" }
if ($transport -eq "streamable_http") { $transport = "http" }
$result[[string]$s.name] = New-CanonicalMcp ([string]$s.name) $transport ([string]$t.command) `
@($t.args) (ConvertTo-StringMap $t.env) ([string]$t.url) `
(ConvertTo-StringMap $t.http_headers) ([string]$t.bearer_token_env_var) "Codex"
}
return $result
}
function Get-CodexMcps {
if ($CodexSource -ne "Toml") {
$fromCli = Get-CodexMcpsFromCli
if ($null -ne $fromCli) { return $fromCli }
if ($CodexSource -eq "Cli") { throw "codex mcp list --json unavailable and -CodexSource Cli forbids the TOML fallback" }
}
if (-not (Test-Path -LiteralPath $CodexConfig)) {
throw "Codex config not found: $CodexConfig"
}
@@ -209,6 +273,7 @@ function Add-ToCodex($Mcp) {
$arguments = @("mcp", "add", $Mcp.name)
$envMap = [ordered]@{}
foreach ($key in $Mcp.env.Keys) { $envMap[$key] = $Mcp.env[$key] }
# Without these, Codex mangles non-ASCII stdio output on Windows.
$envMap["PYTHONIOENCODING"] = "utf-8"
$envMap["PYTHONUTF8"] = "1"
foreach ($key in $envMap.Keys) { $arguments += @("--env", "$key=$($envMap[$key])") }
@@ -216,6 +281,7 @@ function Add-ToCodex($Mcp) {
$arguments += $Mcp.command
$arguments += @($Mcp.args)
Invoke-External "codex add $($Mcp.name)" "codex" $arguments
Report-UnsupportedKeys $Mcp.name
return
}
if ($Mcp.headers.Count -gt 0) {
@@ -223,6 +289,17 @@ function Add-ToCodex($Mcp) {
}
Backup-CodexConfig
Invoke-External "codex add $($Mcp.name)" "codex" @("mcp", "add", $Mcp.name, "--url", $Mcp.url)
Report-UnsupportedKeys $Mcp.name
}
# Some MCPs need Codex keys that `codex mcp add` has no flag for and that config.toml
# cannot supply either (see the storage-model note at the top). Surface them rather than
# pretending the registration is complete.
function Report-UnsupportedKeys([string]$Name) {
if (-not $script:Overrides.ContainsKey($Name)) { return }
$keys = @($script:Overrides[$Name].psobject.Properties | Where-Object { $_.Name -notlike "_*" } |
ForEach-Object { "$($_.Name)=$($_.Value)" })
if ($keys) { $script:Unsupported += "$Name wants $($keys -join ', ') - no interface in codex-cli to set these" }
}
function Add-ToClaude($Mcp) {
@@ -319,6 +396,20 @@ if ($Apply) {
}
}
if ($Apply) {
# Codex re-reads config.toml on every invocation, so this confirms what it will actually load.
$after = Get-PortableMcps (Get-CodexMcps) "Codex"
$stillMissing = @($claude.Keys | Where-Object { -not $after.ContainsKey($_) } | Sort-Object)
Write-Host "=== post-apply verification (codex mcp list) ==="
Write-Host "Codex now serves: $(@($after.Keys | Sort-Object) -join ', ')"
if ($stillMissing) { $script:Failures += "still absent from Codex after apply: $($stillMissing -join ', ')" }
}
if ($script:Unsupported.Count -gt 0) {
Write-Host "=== registered, but incomplete (codex-cli cannot express these keys) ==="
$script:Unsupported | ForEach-Object { Write-Host "- $_" }
}
if ($script:Failures.Count -gt 0) {
Write-Host "=== failures ==="
$script:Failures | ForEach-Object { Write-Host "- $_" }
+205 -59
View File
@@ -1,29 +1,51 @@
# tools-sync: mechanical sync of skills between Claude Code and Codex.
# Phase 1 -- git sync of the primary checkout (~\.claude\skills): commit -> pull --rebase -> push.
# Phase 2 -- junction reconciliation: every top-level skill dir in the primary gets a
# directory junction of the same name in ~\.codex\skills, so both tools share
# one physical copy and can never drift. Codex auto-registers any dir there
# containing SKILL.md (new sessions only).
# Judgment calls (rebase conflicts, codex-local dirs worth keeping) are left to the caller.
# tools-sync: mechanical sync of skills across Claude Code, Codex, and the
# cross-runtime ~\.agents\skills directory.
#
# Phase 1 — git sync of the primary checkout (~\.claude\skills): commit -> pull --rebase -> push.
# Phase 2 — registry reconciliation. Every skill has exactly ONE physical home (the primary
# checkout); every other registry holds a directory junction pointing at it, so the
# tools cannot drift. This phase is bidirectional:
# * a real skill dir that exists ONLY in a registry is INGESTED into the primary
# (this is how a skill created from the Codex side enters the repo), then linked back;
# * a link that points somewhere other than the primary is repaired;
# * a real dir duplicating a primary skill collapses to a junction when the content
# is identical, and is reported as a conflict when it differs.
# Judgment calls (rebase conflicts, divergent duplicates) are left to the caller.
#
# Exit codes: 0 = clean, 1 = hard failure, 2 = conflicts需要人工裁决.
#
# Run from PowerShell, NOT git-bash: MSYS rewrites `/c`-style arguments and Windows paths.
#
# A server-side transport fault (bad pack header / early EOF / RPC failed) is not fatal:
# gitea_doctor.ps1 is invoked to repair the gitea host and the push is retried once (-NoDoctor opts out).
param(
[Parameter(Mandatory = $true)][string]$RepoUrl,
[string]$ClaudeSkills = "$env:USERPROFILE\.claude\skills",
[string]$CodexSkills = "$env:USERPROFILE\.codex\skills",
# Registries mirroring the primary. ~\.codex\skills is Codex's own directory;
# ~\.agents\skills is the cross-runtime alias Codex / Copilot CLI / Gemini CLI also scan.
[string[]]$Registry = @("$env:USERPROFILE\.codex\skills", "$env:USERPROFILE\.agents\skills"),
# Registry that gets a junction for EVERY primary skill. The others are only scanned for
# ingest/repair, because linking a skill into two directories Codex scans registers it twice.
[string]$MirrorRegistry = "$env:USERPROFILE\.codex\skills",
[string]$Branch = "main",
# A real (non-junction) dir in the codex side that shadows a primary skill blocks
# linking; -Force deletes it and links (caller must have rescued unique content first).
# Resolve divergent duplicates by declaring the primary authoritative (deletes the registry copy).
[switch]$Force,
# Report what would happen without committing/pushing/linking.
[switch]$DryRun,
# A transport failure normally aborts the run and leaves commits stranded here. By
# default we instead log into the gitea host, repair it, and retry once. -NoDoctor
# keeps the old abort-and-report behaviour.
[switch]$NoDoctor,
[switch]$AllowDestructive
[switch]$AllowDestructive,
# Leave registry-only skills where they are instead of moving them into the primary checkout.
[switch]$NoIngest,
# Report what would happen without committing/pushing/linking/moving.
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
$script:Conflicts = @()
$script:Ingested = @()
$script:Notes = @()
function Invoke-Git {
param([string]$Dir, [string[]]$GitArgs)
@@ -68,8 +90,41 @@ function Invoke-Doctor([string]$Why) {
return $ok
}
function Get-LinkTarget($Item) {
if (-not $Item.Target) { return $null }
return (@($Item.Target) | Select-Object -First 1)
}
function Test-IsLink($Item) {
return [bool]($Item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)
}
function Test-UnderPath([string]$Path, [string]$Root) {
if (-not $Path -or -not $Root) { return $false }
return $Path.TrimEnd('\').ToLowerInvariant().StartsWith($Root.TrimEnd('\').ToLowerInvariant() + '\')
}
# Content fingerprint used to decide whether a duplicate is a harmless copy or a real fork.
# Runtime junk that legitimately differs between machines is excluded, mirroring .gitignore.
function Get-DirFingerprint([string]$Path) {
$skip = '(\\\.git\\|\\__pycache__\\|\\\.langgraph_api\\|\.pyc$|\.pckl$|\.sqlite(-shm|-wal)?$)'
$files = Get-ChildItem -LiteralPath $Path -Recurse -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notmatch $skip }
$lines = foreach ($f in $files) {
$rel = $f.FullName.Substring($Path.Length).TrimStart('\').ToLowerInvariant()
"$rel $((Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash)"
}
return (($lines | Sort-Object) -join "`n")
}
function New-SkillJunction([string]$LinkPath, [string]$TargetPath, [string]$Why) {
if ($DryRun) { Write-Host " (dry-run) would link $LinkPath -> $TargetPath [$Why]"; return }
New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null
Write-Host " linked: $LinkPath -> $TargetPath [$Why]"
}
# ---------- Phase 1: primary checkout (~\.claude\skills) ----------
Write-Host "=== [1/2] primary git sync: $ClaudeSkills ==="
Write-Host "=== [1/3] primary git sync: $ClaudeSkills ==="
if (-not (Test-Path (Join-Path $ClaudeSkills ".git"))) {
Fail "$ClaudeSkills is not a git repo. Clone $RepoUrl there first (new machine: git clone $RepoUrl `"$ClaudeSkills`")."
}
@@ -162,61 +217,152 @@ if ($DryRun) {
}
}
# ---------- Phase 2: junction reconciliation (~\.codex\skills) ----------
Write-Host "=== [2/2] codex junctions: $CodexSkills ==="
if (-not (Test-Path $CodexSkills)) { New-Item -ItemType Directory -Force $CodexSkills | Out-Null }
# ---------- Phase 2: ingest + repair every registry ----------
# Walk registries FIRST so skills that only exist on the Codex side land in the primary
# before Phase 3 mirrors the primary back out.
$registries = @($Registry | Where-Object { $_ } | ForEach-Object { $_.TrimEnd('\') })
Write-Host "=== [2/3] registry ingest + repair ==="
# Left over from the older mirror-checkout approach; junctions need no repo here.
if (Test-Path (Join-Path $CodexSkills ".git")) {
Write-Host "removing stale .git from codex dir (junction model needs no second checkout)"
if (-not $DryRun) { Remove-Item -Recurse -Force (Join-Path $CodexSkills ".git") }
}
foreach ($reg in $registries) {
Write-Host "-- $reg"
if (-not (Test-Path $reg)) { Write-Host " (absent, skipped)"; continue }
$foreign = 0
$primarySkills = Get-ChildItem $ClaudeSkills -Directory | Where-Object { $_.Name -notlike ".*" }
$blocked = @()
foreach ($d in $primarySkills) {
$link = Join-Path $CodexSkills $d.Name
if (Test-Path $link) {
$item = Get-Item $link
if ($item.LinkType -eq "Junction") { continue }
# A real dir shadows the primary skill; its content may be unique.
if ($Force) {
if ($DryRun) { Write-Host "(dry-run) would replace real dir with junction: $($d.Name)" ; continue }
Remove-Item -Recurse -Force $link
New-Item -ItemType Junction -Path $link -Target $d.FullName | Out-Null
Write-Host "replaced real dir with junction: $($d.Name)"
} else {
$blocked += $d.Name
# A registry carrying its own .git is a competing checkout; the junction model needs only one.
if (Test-Path (Join-Path $reg ".git")) {
$remote = (Invoke-Git $reg @("remote", "get-url", "origin")).Out
$script:Notes += "$reg holds its own git checkout (origin: $remote). The junction model needs only the primary checkout; review and remove it once its unique content is ingested."
Write-Host " NOTE: competing git checkout here (origin: $remote) - reported, not touched"
}
} else {
if ($DryRun) { Write-Host "(dry-run) would link: $($d.Name)"; continue }
New-Item -ItemType Junction -Path $link -Target $d.FullName | Out-Null
Write-Host "linked: $($d.Name)"
foreach ($entry in Get-ChildItem $reg -Directory -Force | Where-Object { $_.Name -notlike ".*" }) {
# $copyPath is the real directory to resolve; $repairLinks are registry entries that must
# end up pointing at the primary once it does.
$copyPath = $entry.FullName
$repairLinks = @()
if (Test-IsLink $entry) {
$target = Get-LinkTarget $entry
if (Test-UnderPath $target $ClaudeSkills) {
if (-not (Test-Path $target)) {
# Skill deleted/renamed upstream; the dangling link confuses Codex.
if ($DryRun) { Write-Host " (dry-run) would remove stale link: $($entry.Name)"; continue }
$entry.Delete()
Write-Host " removed stale link: $($entry.Name)"
}
}
# Junctions whose primary target is gone (skill deleted/renamed upstream) confuse Codex.
foreach ($item in Get-ChildItem $CodexSkills -Directory | Where-Object { $_.LinkType -eq "Junction" }) {
$target = ($item.Target | Select-Object -First 1)
if ($target -and $target.StartsWith($ClaudeSkills) -and -not (Test-Path $target)) {
if ($DryRun) { Write-Host "(dry-run) would remove stale junction: $($item.Name)"; continue }
# A junction is just a link -- removing it never touches target content.
$item.Delete()
Write-Host "removed stale junction: $($item.Name)"
continue # correctly pointed at the primary
}
$inAnotherRegistry = $false
foreach ($other in $registries) { if (Test-UnderPath $target $other) { $inAnotherRegistry = $true } }
if (-not $inAnotherRegistry) {
# Host-owned catalog (e.g. the MathWorks agentic toolkits). Not ours to manage.
# Counted rather than listed: these run into the hundreds and drown the report.
$foreign++
continue
}
# Points at a copy living in another registry: the exact drift this model exists to
# prevent. Resolve that copy, then re-point this link at the primary.
Write-Host " MISDIRECTED link: $($entry.Name) -> $target"
$repairLinks += $entry.FullName
$copyPath = $target
}
$name = Split-Path $copyPath -Leaf
$primaryPath = Join-Path $ClaudeSkills $name
if (-not (Test-Path $copyPath)) { continue } # already resolved earlier this run
$repairLinks += $copyPath
# --- real directory holding a skill ---
if (-not (Test-Path (Join-Path $copyPath "SKILL.md"))) {
Write-Host " not a skill dir (no SKILL.md), ignored: $name"
continue
}
$resolved = $false
if (-not (Test-Path $primaryPath)) {
if ($NoIngest) {
$script:Conflicts += "$name exists only in $copyPath; -NoIngest kept it out of the primary."
Write-Host " registry-only skill NOT ingested (-NoIngest): $name"
continue
}
Write-Host " INGEST registry-only skill: $name ($copyPath -> $primaryPath)"
if (-not $DryRun) { Move-Item -LiteralPath $copyPath -Destination $primaryPath }
$script:Ingested += $name
$resolved = $true
}
elseif ((Get-DirFingerprint $copyPath) -eq (Get-DirFingerprint $primaryPath)) {
# Identical copy: collapsing it loses nothing.
Write-Host " duplicate identical to primary, collapsing to junction: $name"
if (-not $DryRun) { Remove-Item -Recurse -Force -LiteralPath $copyPath }
$resolved = $true
}
elseif ($Force) {
Write-Host " -Force: primary wins over divergent copy: $name"
if (-not $DryRun) { Remove-Item -Recurse -Force -LiteralPath $copyPath }
$resolved = $true
}
else {
$script:Conflicts += "$name diverges between $primaryPath and $copyPath. Merge what you want to keep into the primary, then re-run with -Force."
Write-Host " CONFLICT: $name diverges from primary (registry copy left untouched)"
}
if ($resolved) {
foreach ($lnk in ($repairLinks | Select-Object -Unique)) {
if ((-not $DryRun) -and (Test-Path $lnk)) { (Get-Item $lnk).Delete() }
New-SkillJunction $lnk $primaryPath "repaired"
}
}
}
if ($foreign -gt 0) { Write-Host " $foreign host-owned links left alone (targets outside the managed registries)" }
}
if ($blocked) {
Write-Host "these codex dirs are REAL directories shadowing primary skills (not linked):"
$blocked | ForEach-Object { Write-Host " $_" }
Fail "rescue any unique content into the primary checkout, then re-run with -Force."
}
# ---------- Phase 3: mirror the primary into the mirror registry ----------
Write-Host "=== [3/3] mirror primary -> $MirrorRegistry ==="
if ($MirrorRegistry) {
if (-not (Test-Path $MirrorRegistry)) {
if ($DryRun) { Write-Host "(dry-run) would create $MirrorRegistry" }
else { New-Item -ItemType Directory -Force $MirrorRegistry | Out-Null }
}
# Left over from the older mirror-checkout approach; junctions need no repo here.
$mirrorGit = Join-Path $MirrorRegistry ".git"
if ((Test-Path $mirrorGit) -and $MirrorRegistry -ne $ClaudeSkills) {
Write-Host "removing stale .git from mirror registry (junction model needs no second checkout)"
if (-not $DryRun) { Remove-Item -Recurse -Force $mirrorGit }
}
# Registration sanity: every linked skill must expose a SKILL.md for Codex to register it.
$missing = $primarySkills | Where-Object { -not (Test-Path (Join-Path $CodexSkills "$($_.Name)\SKILL.md")) }
if ($missing) {
$primarySkills = Get-ChildItem $ClaudeSkills -Directory | Where-Object { $_.Name -notlike ".*" }
foreach ($d in $primarySkills) {
$link = Join-Path $MirrorRegistry $d.Name
if (Test-Path $link) { continue }
New-SkillJunction $link $d.FullName "mirror"
}
# Codex only registers a directory that exposes SKILL.md.
$missing = $primarySkills | Where-Object { -not (Test-Path (Join-Path $MirrorRegistry "$($_.Name)\SKILL.md")) }
if ($missing -and -not $DryRun) {
Write-Host "WARNING: no SKILL.md visible through these links (Codex will ignore them):"
$missing | ForEach-Object { Write-Host " $($_.Name)" }
}
}
# An ingested skill matching a local-only .gitignore rule never reaches the remote —
# it would silently fail to appear on the next machine.
foreach ($name in $script:Ingested) {
$ci = Invoke-Git $ClaudeSkills @("check-ignore", "-v", $name)
if ($ci.Code -eq 0) {
$script:Notes += "ingested '$name' is excluded by .gitignore ($($ci.Out)); it stays local-only until you remove that rule."
}
}
Write-Host "=== summary ==="
if ($script:Ingested) { Write-Host "ingested into primary: $($script:Ingested -join ', ')" }
foreach ($n in $script:Notes) { Write-Host "NOTE: $n" }
if ($script:Conflicts) {
Write-Host "conflicts needing a decision:"
$script:Conflicts | ForEach-Object { Write-Host " - $_" }
Write-Host "=== sync_repos finished with conflicts ==="
exit 2
}
Write-Host "=== sync_repos done (Codex picks up changes on its next new session) ==="
@@ -38,8 +38,10 @@ command = "C:\\Users\\tester\\.codex\\runtimes\\node_repl.exe"
$oldErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
# -CodexSource Toml pins the read to the fixture; the default Auto path would query the
# real `codex mcp list` and make this test measure the machine instead of the fixtures.
$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $scriptUnderTest `
-ClaudeConfig $claudeConfig -CodexConfig $codexConfig -Verbose 2>&1 | Out-String
-ClaudeConfig $claudeConfig -CodexConfig $codexConfig -CodexSource Toml -Verbose 2>&1 | Out-String
$code = $LASTEXITCODE
$ErrorActionPreference = $oldErrorActionPreference
if ($code -ne 2) { throw "expected audit exit 2, got $code`n$output" }
@@ -0,0 +1,70 @@
# Covers Phase 2 of sync_repos.ps1 -- the registry reconciliation that decides whether a
# directory found on the Codex side gets ingested, collapsed, or reported. Runs against a
# throwaway primary + registries + bare origin under TEMP; the real machine is never touched.
$ErrorActionPreference = "Stop"
$scriptUnderTest = Join-Path (Split-Path $PSScriptRoot -Parent) "scripts\sync_repos.ps1"
$root = Join-Path ([IO.Path]::GetTempPath()) ("tools-sync-repos-test-" + [guid]::NewGuid().ToString("N"))
function New-Skill([string]$Path, [string]$Body) {
New-Item -ItemType Directory -Force -Path $Path | Out-Null
Set-Content -LiteralPath (Join-Path $Path "SKILL.md") -Value $Body -Encoding utf8
}
try {
$primary = Join-Path $root "primary"
$codex = Join-Path $root "codex"
$agents = Join-Path $root "agents"
New-Item -ItemType Directory -Force -Path $primary, $codex, $agents | Out-Null
# One skill of each shape the reconciler must tell apart.
New-Skill (Join-Path $primary "shared") "shared body"
New-Skill (Join-Path $primary "forked") "primary body"
New-Skill (Join-Path $agents "shared") "shared body" # identical duplicate -> collapse
New-Skill (Join-Path $agents "forked") "registry body" # divergent -> conflict
New-Skill (Join-Path $agents "codexborn") "born on the codex side" # registry-only -> ingest
New-Item -ItemType Directory -Force -Path (Join-Path $agents "notaskill") | Out-Null # no SKILL.md
# A link into the other registry rather than the primary: the drift case.
New-Item -ItemType Junction -Path (Join-Path $codex "shared") -Target (Join-Path $agents "shared") | Out-Null
# A local bare repo stands in for origin so Phase 1 completes and Phase 2 actually runs.
$origin = Join-Path $root "origin.git"
& git init --quiet --bare $origin
& git init --quiet --initial-branch=main $primary
& git -C $primary remote add origin $origin
& git -C $primary -c user.email=t@t -c user.name=t commit --quiet --allow-empty -m init
& git -C $primary push --quiet -u origin main
# Invoked via -Command, not -File: -File stringifies every argument, so a [string[]]
# parameter silently takes only its first element and the rest slide into later positions.
$ErrorActionPreference = "Continue"
$invocation = "& '$scriptUnderTest' -RepoUrl '$origin' -ClaudeSkills '$primary' " +
"-Registry @('$codex','$agents') -MirrorRegistry '$codex'"
$output = & powershell -NoProfile -ExecutionPolicy Bypass -Command $invocation 2>&1 | Out-String
$ErrorActionPreference = "Stop"
if ($output -notmatch 'INGEST registry-only skill:\s*codexborn') { throw "codex-side skill was not ingested`n$output" }
if (-not (Test-Path (Join-Path $primary "codexborn\SKILL.md"))) { throw "ingested skill is not in the primary`n$output" }
if ((Get-Item (Join-Path $agents "codexborn")).LinkType -ne "Junction") { throw "ingested skill was not linked back`n$output" }
if ($output -notmatch 'MISDIRECTED link:\s*shared') { throw "link into another registry not detected`n$output" }
$sharedTarget = (Get-Item (Join-Path $codex "shared")).Target | Select-Object -First 1
if ($sharedTarget -ne (Join-Path $primary "shared")) { throw "misdirected link not repointed at the primary, got $sharedTarget`n$output" }
if ((Get-Item (Join-Path $agents "shared")).LinkType -ne "Junction") { throw "identical duplicate did not collapse`n$output" }
if ($output -notmatch 'CONFLICT:\s*forked') { throw "divergent duplicate not reported`n$output" }
if ((Get-Item (Join-Path $agents "forked")).LinkType -eq "Junction") { throw "divergent duplicate must be left untouched`n$output" }
if ((Get-Content -Raw (Join-Path $primary "forked\SKILL.md")) -notmatch 'primary body') { throw "primary was overwritten by a conflict`n$output" }
if ($output -notmatch 'not a skill dir') { throw "directory without SKILL.md not reported`n$output" }
# Mirror registry must expose every primary skill, including the freshly ingested one.
foreach ($name in @("shared", "forked", "codexborn")) {
if (-not (Test-Path (Join-Path $codex "$name\SKILL.md"))) { throw "mirror missing $name`n$output" }
}
# Phase 1's success line is the ls-remote gate's verdict (upstream wording).
if ($output -notmatch 'verified: origin/main =') { throw "Phase 1 did not complete`n$output" }
Write-Host "PASS: registry ingest / repair / conflict fixtures"
} finally {
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
}