From 96a3d15efc3cd301323bd6c0c345673573a1241b Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Sun, 15 Nov 2026 19:39:56 +0800 Subject: [PATCH] =?UTF-8?q?chore(gate):=20=E9=97=A8=E7=A6=81=E9=92=A9?= =?UTF-8?q?=E5=AD=90=E6=94=B9=E8=B5=B0=20gate=5Fpython=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=99=A8=EF=BC=8Cworktree=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 门禁解释器解耦 44 条 language: system 钩子此前把解释器写成仓库检出的绝对路径,换机、 换克隆目录都要重写。改为 entry 只写 scripts/gate_python.py + 脚本名, 由启动器按候选链解析:$DTU_GATE_PYTHON > <仓库>/.venv > <仓库>/.gate/python > PATH。启动器用 execve 替换自身,并把选中的 解释器经 DTU_GATE_PYTHON 传给子进程,保证脚本内再调 Python 时一致。 实测 pre-commit 4.6.2 不展开 ${VAR};language: script 的 shebang 只做 纯 PATH 改写,会把决定权交给 PATH 首个 python,故不采用。 ci_local.py 预检识别启动器并报出解析结果。 并行工作树统一 task-worktree 新增 bench-gate(强制台架验收)与 merge(编排式合并), 删除 new/abort,done 标 DEPRECATED;台架通过记录改存 .agents/worktrees/.bench-pass-log。keil_build.py 的 plugin_tree_owner 识别 .agents/worktrees/ 并返回 pw:。 check_doc_links / gen_refgraph / check_layer_deps 的扫描排除面补上 .agents;并行任务工作树方案重写为单一模型(7 节),取消工作树数量上限, 删历史实测记录节,两处 §7.3 悬空引用改为按语义引用。 文档与约定 本地工具路径环境变量方案 §5 重写为四节:谁跑在哪个 Python 上、写新钩子、 预检对号入座、动环境五步。文档规范的变更记录例外名单由 5 篇减为 4 篇 (本篇实际没有该节)。AGENTS.md 补委派验收口径三条。 验证:pre_commit run --all-files 48 钩子全 Passed;ci_local.py --preflight-only 报 44 条经启动器 -> 仓库 .venv;keil-build 0 errors 0 warnings;check_doc_links --sections-strict 全通过。 --- .dsh/skills/keil-build/SKILL.md | 4 +- .dsh/skills/keil-build/scripts/keil_build.py | 28 +- .dsh/skills/task-worktree/SKILL.md | 37 +- .../task-worktree/scripts/task_worktree.py | 364 +++++++----- .gitignore | 10 +- .pre-commit-config.yaml | 88 +-- AGENTS.md | 3 + docs/README.md | 2 +- docs/任务规划/README.md | 2 +- docs/子系统设计/故障录波设计说明.md | 2 +- docs/文档规范.md | 4 +- docs/环境与部署/并行任务工作树方案.md | 547 +++++------------- docs/环境与部署/本地工具路径环境变量方案.md | 176 +++--- scripts/check_layer_deps.py | 2 +- scripts/ci_local.py | 156 ++++- scripts/gate_python.py | 113 ++++ tools/refgraph/gen_refgraph.py | 8 +- 17 files changed, 826 insertions(+), 720 deletions(-) create mode 100644 scripts/gate_python.py diff --git a/.dsh/skills/keil-build/SKILL.md b/.dsh/skills/keil-build/SKILL.md index 2d7173f1..a1944caa 100644 --- a/.dsh/skills/keil-build/SKILL.md +++ b/.dsh/skills/keil-build/SKILL.md @@ -96,7 +96,7 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被 | 开关 | 作用 | |---|---| -| `--lock-task ` | 持有者名。默认 `$DTU_TASK_ID` → 从 cwd 反推:`_wt/-/` → ``(`@task` 那批);`.dsh-worktrees/worktree//` → `pw:`(DSH worktree **插件**建的树,`plugin_tree_owner()`)→ 都没有才落 `build-cli` | +| `--lock-task ` | 持有者名。默认 `$DTU_TASK_ID` → 从 cwd 反推:`_wt/-/` → ``(`@task` 那批);`.agents/worktrees//` 或旧 `.dsh-worktrees/worktree//` → `pw:`(DSH worktree **插件**建的树,`plugin_tree_owner()`)→ 都没有才落 `build-cli` | | `--lock-wait ` | 锁被占时等待 N 秒(默认 0 = 立即失败,退出码 4) | | `--no-lock` | 完全不加锁(危险:并发 `-b` 互相干扰) | | `DTU_LOCK_HELD=build` | 环境变量:**调用方已持锁**时设它,本脚本跳过自持自释(`task-worktree done` 的门禁即如此用) | @@ -108,7 +108,7 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被 并告警**(保持本 skill 可独立搬运)。 - ⚠ 从 `_wt/-/` 反推持有者名,意味着"手工 `lock acquire build --task ` 后再跑 `/keil-build`"仍**幂等**(同名即视为自己持有),不会自我死锁。 -- **退出码 4 的持有者名带 `pw:`(如 `pw:refactor-logging`)= 插件在 `.dsh-worktrees/worktree//` 里跑的构建占着 UV4,不是某个 `@task` 任务 ID**: +- **退出码 4 的持有者名带 `pw:`(如 `pw:refactor-logging`)= 插件在 `.agents/worktrees//`(或旧 `.dsh-worktrees/worktree//`)里跑的构建占着 UV4,不是某个 `@task` 任务 ID**: 插件树**没有台账**,别拿它去 `lock release `(释放不掉别人的锁)、更别去杀 UV4 进程。正解 = 等那次构建自己 `finally` 释放(自持自释;崩溃则按自报 TTL 兜底),或本次加 `--lock-wait ` 排队; 想知道是哪棵树的谁:`"$PY" "$TW" plugin list`(缺联接再 `plugin adopt `)→ 细节归 diff --git a/.dsh/skills/keil-build/scripts/keil_build.py b/.dsh/skills/keil-build/scripts/keil_build.py index 150339f8..9368e1ab 100644 --- a/.dsh/skills/keil-build/scripts/keil_build.py +++ b/.dsh/skills/keil-build/scripts/keil_build.py @@ -274,20 +274,24 @@ def find_lock_tool(): def plugin_tree_owner(cwd: Path): - """DSH worktree 插件的树(仓库内 .dsh-worktrees/worktree/)→ 持有者名 pw:。 + """现役/旧版 DSH 插件树 → 同一个持有者名 pw:(多段名压平成 -)。 - 2026-10-29 与 task-worktree skill 的 `plugin` 子命令对齐:插件建的树不在 _wt/ 约定里, - 以前在这里跑构建,持有者名一律落成 "build-cli"——`lock status` 看不出是谁在占 UV4, - 多个插件树 / 主树并发时就只剩猜。名字里的多段斜杠压平成 -,与 - task_worktree.py 的 plugin_lock_owner() 完全一致(同一个可 grep 的标识)。 + 现役 @deepseek-ai/dsh-experimental-tool-worktree 默认把树放在 + .agents/worktrees//;旧版路径是 .dsh-worktrees/worktree//。 + 只认这两个完整目录形态,避免把偶然同名目录或路径之外的普通目录算成插件树。 + 与 task_worktree.py 的 plugin_lock_owner() 保持同一个可 grep 的标识。 """ - parts = cwd.parts - if ".dsh-worktrees" not in parts: - return None - tail = parts[parts.index(".dsh-worktrees") + 1:] - if tail and tail[0] == "worktree": - tail = tail[1:] - return "pw:" + "-".join(tail) if tail else "pw:?" + parts = cwd.resolve().parts + for marker, namespace in ((".agents", "worktrees"), + (".dsh-worktrees", "worktree")): + if marker not in parts: + continue + idx = parts.index(marker) + if len(parts) <= idx + 2 or parts[idx + 1] != namespace: + return None + tail = parts[idx + 2:] + return "pw:" + "-".join(tail) if tail else "pw:?" + return None def default_lock_task(): diff --git a/.dsh/skills/task-worktree/SKILL.md b/.dsh/skills/task-worktree/SKILL.md index dfcd8e1f..ab065dfe 100644 --- a/.dsh/skills/task-worktree/SKILL.md +++ b/.dsh/skills/task-worktree/SKILL.md @@ -38,30 +38,39 @@ PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktr | 命令 | 作用 | |---|---| -| `new ` | 建分支 `task/-` + 工作树 + 共享联接 + 台账,打印新空间路径 | +| `bench-gate ` | 检查指定分支是否有 bench-pass 记录(存储于 `.agents/worktrees/.bench-pass-log`) | +| `merge ` | 在主树执行编排式合并:bench-gate → 预演 → 合并(`--skip-bench` 跳过台架检查) | | `list` | 分支 / 落后领先 / 脏文件数 / 台架状态 + 两把锁归属。**只遍历台账** → 非登记树看不见(E 节) | | `status ` | 未提交清单、相对基线漂移、台架记录、已建联接 | | `sync ` | 在任务树里 `rebase` 追平主干,把冲突留在任务分支上 | | `lock acquire|release|status ` | 独占资源锁(→ 铁律 3、B 节) | | `plugin list|adopt|unadopt [插件树名]` | 插件桥:看清单 / 补共享联接 / 摘联接(**不删树**)→ F 节 | -| `done ` | 预演 → 合入主干 → 并集残留审计 → 重生成产物 → 门禁 → 清场 | -| `abort ` | 工作树 + 分支一起删,**永久丢弃** | +| `done ` | **[DEPRECATED]** 已废弃,请用 `merge`。预演 → 合入主干 → 审计 → 重生成 → 门禁 → 清场 | | `precheck` | 树内自查(**只读**):BOM 漂移 / CR / U+FFFD + docs 逐档门禁 + `--msgfile --ban` 消息检查;顺手补 `ci-artifacts/`(G 节机械项) | | `postmerge` | 主树合并后补绿:体积凭证**自动诊断**(新鲜跳过;红→先短链 `--update`,只读复检真漂了才升全链)+ `check_doc_links --all` + 列待提交文件,**不代提交** | - **退出码** `0` 成功 / `1` 操作失败 / `2` 前置或环境错误 / `3` 用法——**只有不接管道才读得到**(→ 读输出)。 -- `new`:`--base`(默认 `master`)· `--needs-bench`(无台架 PASS 则 `done` 拒合)· `--lean`(跳 LFS smudge,只省磁盘不省时间)· `--no-bootstrap`(不建联接)· `--force`(**只跳过并发上限检查**,不解决谁让位 → A) -- `done`:`--dry-run` · `--skip-build` · `--skip-gates` · `--force`。⚠ `--dry-run` **只保护预演段、不保护清场段**(实测:对已合入的分支它照样摘联接、删分支、清台账;且它的前置也要抢 `build` 锁)→ 用完复验 `git worktree list`,细节 → 陷阱清单。 -- `lock`:`--task`(acquire 必填)· `--wait <秒>` · `--ttl <小时>`(默认 `build`=1h / `bench`=6h,超期视为陈旧、下次 acquire 自动接管)· `--note` · `--bench-pass` · `--force`。⚠ `--note` 只在 **acquire** 写进 payload,**release 时被忽略** → 验收证据写主干提交说明或文档。 +- `bench-gate`:无参数选项。检查 `.agents/worktrees/.bench-pass-log` 中是否有该分支的记录。 +- `merge`:`--skip-bench`(跳过 bench-gate 检查)。合并后不自动删除任务分支和目录。 +- `done` **[DEPRECATED]**:`--dry-run` · `--skip-build` · `--skip-gates` · `--force`。⚠ 已废弃,请用 `merge`。 +- `lock`:`--task`(acquire 必填)· `--wait <秒>` · `--ttl <小时>`(默认 `build`=1h / `bench`=6h,超期视为陈旧、下次 acquire 自动接管)· `--note` · `--bench-pass` · `--force`。⚠ `--note` 只在 **acquire** 写进 payload,**release 时被忽略** → 验收证据写主干提交说明或文档。`--bench-pass` 现在同时写入 `.agents/worktrees/.bench-pass-log`。 - 环境变量:`DTU_WT_ROOT`(默认仓库同级 `_wt/`)· `DTU_TASK_BASE`(默认基线)· `DTU_INTEGRATION_WORKTREE`(集成点,用于隔离演练)。 ## 典型流程 ```bash -"$PY" "$TW" new N21 dhara-isfree --needs-bench # 把新会话工作目录切到打印出来的 _wt/N21-dhara-isfree/ -"$PY" "$TW" lock acquire bench --task N21 # 烧录 / /serial-boot-test / 板级套件 -"$PY" "$TW" lock release bench --task N21 --bench-pass -"$PY" "$TW" done N21 # 回主工作树(铁律 1);先 --dry-run 读全量 +# 新模型:使用 DSH 插件 create_worktree 建树,然后用 bench-gate + merge 收口 +# (create_worktree 由 DSH 插件提供,不在本脚本内) + +# 台架验证后记录 bench-pass(lock release --bench-pass 自动写入 .bench-pass-log) +"$PY" "$TW" lock acquire bench --task pw:my-task +"$PY" "$TW" lock release bench --task pw:my-task --bench-pass + +# 合并前检查台架验收 +"$PY" "$TW" bench-gate task/my-task # 有记录返回 0,无记录返回 1 + +# 在主树执行合并(自动检查 bench-gate,--skip-bench 可跳过) +"$PY" "$TW" merge task/my-task # 必须在主工作树执行 ``` ## 读输出:管道与截断(两起误判的共同根因) @@ -130,14 +139,14 @@ git commit -F # 提交属 Ask first(AGENTS.md 三条不可跳过:① 逐文件内容**三桶归类**(与主线工作区/HEAD 比对;「很久没动」不是判据)② 动手前跑**只读 reparse 预扫**,拿到 `REPARSE: none` 才许 `git worktree remove`(铁律 5)③ 唯一未合并残留先备份再 `--force`,拆完复验 + 清悬挂台账。**绝不跑 `done`/`abort`**(铁律 9)。全文(第 1~6 步 + 两份探针脚本)→ [reference/reclaim-unregistered.md](reference/reclaim-unregistered.md)。 -### F. 与 DSH worktree 插件(`/worktree`、`worktree_create`)共存 +### F. 与现役 DSH worktree 插件(`create_worktree`)共存 | 联接 | 级别 | 依据 | 缺了会怎样 | 逃生门 | |---|---|---|---|---| | `simulator/SDL2` | **硬需求** | `simulator/CMakeLists.txt:12` 的 `set(SDL2_DIR …)` **非 `CACHE`**,include / `lib/x64` / `SDL2.dll` 全从它派生 | `build_sim.bat` 当场失败 | **无** | | `.venv` | 软需求 | `scripts/ci_local.py` 有 `DTU_PRECOMMIT_PYTHON` + 五级解释器候选 | **静默**落机器级解释器(缺 fontTools → `generated-fresh` 假红) | 环境变量 | -- `plugin adopt` 两条都补只是因为便宜,不代表两条都硬。插件树占锁记 `pw:`、**回收仍归 /worktree**(本 skill 不删插件树,插件也不删 `_wt/`)。「有插件为何还自持生命周期」=先读插件包(`npm root -g`)再 grep 三个概念名报命中数,插件侧零命中就是理由——取证姿势与答案边界 → [reference/dsh-plugin-bridge.md](reference/dsh-plugin-bridge.md) F-2。 +- `plugin adopt` 两条都补只是因为便宜,不代表两条都硬。插件树占锁记 `pw:`。现役插件只负责 create/restore,**保留目录与分支,不自动回收**;本 skill 不代删。确需回收先 `plugin unadopt ` 摘联接,再按 **E 节非登记树安全回收规程**处理。「有插件为何还自持生命周期」=先读插件包再 grep 三个概念名报命中数,插件侧零命中就是理由——取证姿势与答案边界 → [reference/dsh-plugin-bridge.md](reference/dsh-plugin-bridge.md) F-2。 ### G. 任务树内跑门禁:落点、`--all` 假红、消息禁词 @@ -157,7 +166,7 @@ git commit -F # 提交属 Ask first(AGENTS.md 4. **单写者文档**:`docs/任务规划/`(整个文件夹)、`AGENTS.md` 任务表块、`MDK-ARM/*.uvprojx`、`.pre-commit-config.yaml`、内存快照 —— 任务期**不在任务树里改**,留到 `done` 统一处理;用户显式豁免时(在任务树写、由 done 合回)要在交付报告里点名这条例外。 5. **清场必须先摘联接**:脚本先 `os.rmdir` 掉 `.venv` / `simulator/SDL2` 联接再 `git worktree remove`。手工拆树前必跑 **E 第 3 步**预扫拿到 `REPARSE: none`——否则 git 可能递归进联接把主树 `.venv` 一起删掉。 6. **活跃任务硬上限 8 = 脚本常量 `MAX_CONCURRENT`(`task_worktree.py:30`)——唯一事实源**:报错文案与 `new` 的插件树提示都插值它,AGENTS.md / 方案文档 / frontmatter / A 节的数字全是引用同步。改这个数字 → 全形态扫描走 `stale-doc-count-sweep` skill + 隔离小仓验证上限真生效;每棵磁盘代价按实测口径重述、别照抄旧数(细节 → 验证清单末条)。满了 exit 2 → A;悬挂台账 → E 第 6 步。 -7. **工作树放仓库同级 `_wt/`,绝不在仓库内部**(否则污染 `git status`、`--all-files` 与全库搜索)。唯一例外 = 插件写死的仓内 `.dsh-worktrees/`(改不了,已用预置 .gitignore + 三个全库扫描排除兜住 → F 节)。 +7. **工作树放仓库同级 `_wt/`,绝不在仓库内部**(否则污染 `git status`、`--all-files` 与全库搜索)。唯一例外 = 插件写死的仓内 `.agents/worktrees/`(现役)与 `.dsh-worktrees/`(旧版);现役目录池自写 `.gitignore`,旧路径由根 `.gitignore` 预置,两处均已纳入三个全库扫描排除面(→ F 节)。 8. **`done` 是合并点**:属主没提交完就别跑;合并提交进了主干就只用「主树补提交」收尾,**不用 `reset --hard`** 反悔 → C 节。 9. **非登记 / detached 的树绝不跑 `done` / `abort`**:两者都以台账为前置(无台账 exit 2),带残留台账时 `abort --force` 会绕过内容保全把别人在飞的改动连树一起删 → E 节。 @@ -170,4 +179,4 @@ git commit -F # 提交属 Ask first(AGENTS.md - 构建 = `keil-build`· 烧录/回读 = `stlink-flash` · 串口闭环 = `serial-boot-test`(真实 hex 路径以 `.serialtest/config.json` 的 hex 键为准——实测 `MDK-ARM/Objects/STM32F407-Demo.hex` 不存在,拷错 = 烧旧镜像) - 新 `.c` 双端注册 = `new-source-file` · RAM/.map 台账 = `mem-budget` · UI 回归 = `ui-verify` · 计数同步 = `stale-doc-count-sweep` · 卡死的技能台账行 = `mnemon-skill-ledger-stuck-repair` -- DSH worktree 插件(`/worktree` / `worktree_create`,仓内 `.dsh-worktrees/`)与本 skill 的分工、桥接命令、锁归属 → F 节 · 完整机制与目录分配 → `docs/环境与部署/并行任务工作树方案.md` +- 现役 DSH worktree 插件(`create_worktree`,仓内 `.agents/worktrees/`,兼容旧 `.dsh-worktrees/`)与本 skill 的分工、桥接命令、锁归属、非登记树安全回收 → F 节 · 完整机制与目录分配 → `docs/环境与部署/并行任务工作树方案.md` diff --git a/.dsh/skills/task-worktree/scripts/task_worktree.py b/.dsh/skills/task-worktree/scripts/task_worktree.py index 08571756..bb615c79 100644 --- a/.dsh/skills/task-worktree/scripts/task_worktree.py +++ b/.dsh/skills/task-worktree/scripts/task_worktree.py @@ -82,13 +82,14 @@ BASE_BRANCH = os.environ.get("DTU_TASK_BASE", "master") STATE_DIR = WT_ROOT / ".state" LOCKS_DIR = STATE_DIR / "locks" -# DSH 的 worktree 插件(包名 dsh-task-worktree;模型工具 worktree_create、人类命令 -# /worktree)把任务树建在**仓库内**这个命名空间下,与本 skill 的仓外 _wt/ 刻意分开。 -# .gitignore 里预置同名规则,正是为了让插件的 ensureIgnored() 见到就跳过、 -# 不在会话中途往这份受控文件里追加一行。 -# DSH worktree 插件实际使用两个位置(历史演进): -# 旧版 dsh-task-worktree → .dsh-worktrees/worktree/ -# 新版内置 create_worktree → .agents/worktrees/ +# bench-pass 记录文件(JSON lines,每行 {branch, timestamp, operator}) +# 放在 .agents/worktrees/ 下,因为 git notes 在 Windows worktree 间共享有坑 +BENCH_PASS_LOG = REPO_ROOT / ".agents" / "worktrees" / ".bench-pass-log" + +# 现役 DSH worktree 插件 = @deepseek-ai/dsh-experimental-tool-worktree 0.2.2-rc.1, +# 模型工具 create_worktree 默认在仓库内 .agents/worktrees// 建同名分支并切换会话 cwd。 +# 旧版 dsh-task-worktree 使用 .dsh-worktrees/worktree//;两处都与本 skill 的 +# 仓外 _wt/ 刻意分开。现役插件只负责 create/restore,不合并、不删树、不管锁或共享联接。 # plugin_trees() 同时扫描两处,list 统一展示。 PLUGIN_ROOT_LEGACY = REPO_ROOT / ".dsh-worktrees" PLUGIN_NAMESPACE_LEGACY = "worktree" @@ -336,6 +337,49 @@ def lock_release(name: str, task=None, force: bool = False): return True, "已释放锁 {}(原持有者 {})".format(name, info.get("task")) +# --------------------------------------------------------------------- bench-pass 记录 + + +def read_bench_pass_log() -> list: + """读取 bench-pass 日志文件,返回记录列表(每行一个 JSON 对象)""" + if not BENCH_PASS_LOG.is_file(): + return [] + records = [] + try: + for line in BENCH_PASS_LOG.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line: + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + continue + except OSError: + pass + return records + + +def find_bench_pass(branch: str) -> dict: + """查找指定分支的最近一次 bench-pass 记录,无记录返回 None""" + records = read_bench_pass_log() + # 从后往前找,返回最近的匹配 + for rec in reversed(records): + if rec.get("branch") == branch: + return rec + return None + + +def write_bench_pass(branch: str, operator: str = None) -> None: + """写入一条 bench-pass 记录""" + BENCH_PASS_LOG.parent.mkdir(parents=True, exist_ok=True) + record = { + "branch": branch, + "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), + "operator": operator or os.environ.get("USERNAME", os.environ.get("USER", "?")), + } + with open(BENCH_PASS_LOG, "a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + # --------------------------------------------------------------------- 目录联接 @@ -411,8 +455,9 @@ def bootstrap(wt: Path): # --------------------------------------------------------------------- DSH 插件工作树 # -# 插件建的树(.dsh-worktrees/worktree/)缺三样东西,下面的命令逐条补。 -# 回收**不在这里**:插件树由「/worktree remove|prune」收,done/abort 也不该拿去处理它。 +# 新旧插件建的树缺三样东西,下面的命令逐条补。 +# 回收**不在这里**:done/abort 不该处理插件树。现役插件只 create/restore、保留目录与分支; +# 要回收时先 unadopt 摘联接,再按本 skill 的 E 节非登记树安全规程处理。 # 1. 共享联接——没有 .venv 与 simulator 的 SDL2 SDK,插件树里「.venv\Scripts\python.exe」 # 直接找不到解释器:/keil-build、ci_local.py、所有 scripts/check_*.py 全部起跑失败; # 2. 可见性——插件树不在台账里,「list」看不见它,8 个活跃名额也只数登记任务; @@ -420,36 +465,65 @@ def bootstrap(wt: Path): # 插件树里算成 pw:),bench 锁仍要人占,占锁名得和「lock status」对得上。 +def _plugin_name_parts(name: str): + """把现役/旧版插件树名归一成同一组路径段;不静默吞掉 .. 等逃逸段。""" + raw = (name or "").strip().replace("\\", "/").strip("/") + if raw.startswith(".agents/worktrees/"): + raw = raw[len(".agents/worktrees/"):] + elif raw.startswith(".dsh-worktrees/"): + raw = raw[len(".dsh-worktrees/"):] + segs = [s for s in raw.split("/") if s] + if segs and segs[0] == PLUGIN_NAMESPACE_LEGACY: + segs = segs[1:] + return segs + + def plugin_lock_owner(name: str) -> str: """插件树在锁 payload 里的持有者名:pw:(多段名压平成 -)。 必须与 keil_build.py 的 default_lock_task() 对同一种树算出的名字一致——这样构建 自持自释留下的归属,和手工「lock acquire bench --task pw:x」才是同一个可 grep 的标识。 + 新旧路径写法与裸名都归一到同一个 owner,避免 lock status 出现两个持有者名。 """ - # 与 plugin_dir_for 同样容忍「worktree/」前缀,否则同一棵树会出现两个持有者名 - # (pw:refactor-logging vs pw:worktree-refactor-logging),lock status 就散了。 - segs = [s for s in name.replace(os.sep, "/").strip("/").split("/") if s] - if segs and segs[0] == PLUGIN_NAMESPACE: - segs = segs[1:] - return "{}:{}".format(PLUGIN_LOCK_PREFIX, "-".join(segs) or "?") + return "{}:{}".format(PLUGIN_LOCK_PREFIX, "-".join(_plugin_name_parts(name)) or "?") def plugin_dir_for(name: str) -> Path: - """插件 worktree 名 → 树目录;**只允许落在 .dsh-worktrees/worktree/ 里面**。 + """插件 worktree 名 → 树目录;现役 .agents/worktrees/ 与旧路径均可,禁止逃逸。 adopt/unadopt 会在解析出的目录上建/摘目录联接;不加这道闸,等于允许把主工作树或 - 任意 _wt 目录当插件树动手(联接会遮蔽真实内容,事后很难看出来)。 + 任意 _wt 目录当插件树动手(联接会遮蔽真实内容,事后很难看出来)。同名树两处都有时 + 拒绝猜测,要求传带根路径的全名。 """ - raw = (name or "").strip().replace(os.sep, "/").strip("/") + raw = (name or "").strip().replace("\\", "/").strip("/") if not raw: raise EnvError("缺插件 worktree 名(先跑「plugin list」看现有树)") - if raw == PLUGIN_NAMESPACE or raw.startswith(PLUGIN_NAMESPACE + "/"): - raw = raw[len(PLUGIN_NAMESPACE):].strip("/") - p = (PLUGIN_SUBDIR / raw).resolve() - base = PLUGIN_SUBDIR.resolve() - if p == base or base not in p.parents: - raise EnvError("拒绝 {}:只接受 {} 里面的树".format(p, PLUGIN_SUBDIR)) - return p + if raw in (".agents", ".agents/worktrees", ".dsh-worktrees", ".dsh-worktrees/worktree"): + raise EnvError("拒绝 {}:必须指定插件树名,不能只给根目录".format(raw)) + segs = _plugin_name_parts(raw) + if not segs or any(s in (".", "..") for s in segs): + raise EnvError("拒绝 {}:插件树名不能为空,也不能含 . 或 ..".format(name)) + + explicit = raw.startswith(".agents/worktrees/") or raw.startswith(".dsh-worktrees/") + bases = [PLUGIN_ROOT_AGENTS] if raw.startswith(".agents/worktrees/") else [] + if raw.startswith(".dsh-worktrees/"): + bases = [PLUGIN_SUBDIR_LEGACY] + if not explicit: + bases = [PLUGIN_ROOT_AGENTS, PLUGIN_SUBDIR_LEGACY] + + matches = [] + for base in bases: + p = (base.joinpath(*segs)).resolve() + resolved_base = base.resolve() + if p == resolved_base or resolved_base not in p.parents: + raise EnvError("拒绝 {}:只接受插件命名空间里面的树".format(p)) + if p.is_dir(): + matches.append(p) + if len(matches) > 1: + raise EnvError("插件树名 {} 在新旧两处都存在;请传 .agents/worktrees/{} 或 " + ".dsh-worktrees/worktree/{} 消歧".format(name, raw, raw)) + # 未创建/已删除的树也要能给出确定的候选路径,让上层报「目录不存在」而非 NameError。 + return matches[0] if matches else (bases[0].joinpath(*segs)).resolve() def junction_states(wt: Path): @@ -486,7 +560,7 @@ def plugin_trees(): 位置: - .dsh-worktrees/worktree/ (旧版 dsh-task-worktree 插件) - - .agents/worktrees/ (新版内置 create_worktree 工具) + - .agents/worktrees/ (现役 dsh-experimental-tool-worktree 的 create_worktree 工具) manifest.json 是插件私有格式(MANIFEST_VERSION 1,字段随版本变),而 git 的注册表 同时覆盖「插件正常建的树」和「manifest 丢了 / 树被手工删」的残留——桥接命令要看的 @@ -540,8 +614,9 @@ def print_plugin_trees() -> int: if not trees: return 0 print() - print("DSH 插件工作树({} 下;**不在本 skill 台账** → 不占 {} 个名额、done/abort " - "管不到,回收走 /worktree remove|prune):".format(PLUGIN_SUBDIR, MAX_CONCURRENT)) + print("DSH 插件工作树(.agents/worktrees/ 与旧 .dsh-worktrees/worktree/;**不在本 skill " + "台账** → 不占 {} 个名额、done/abort 管不到;现役插件保留目录与分支,回收先 " + "unadopt 再走 E 节安全规程):".format(MAX_CONCURRENT)) print("{:<26} {:<34} {:>4} {:>16}".format("名称", "分支", "脏", "联接")) for t in trees: print("{:<26} {:<34} {:>4} {:>16}".format( @@ -590,15 +665,16 @@ def adopt_junctions(wt: Path): def cmd_plugin(a) -> int: if a.action == "list": if print_plugin_trees() == 0: - print("({} 下没有插件工作树)".format(PLUGIN_SUBDIR)) - print(" 建法:新会话输入框选「Worktree模式」/ 让模型调 worktree_create / " - "/worktree create ") + print("(.agents/worktrees/ 与旧 .dsh-worktrees/worktree/ 下没有插件工作树)") + print(" 建法:让模型调现役插件的 create_worktree;默认建 .agents/worktrees// " + "及同名分支,并切换会话 cwd") print(" 建完回来「plugin adopt 」补共享联接,才能在里面跑 /keil-build 与门禁。") return EXIT_OK wt = plugin_dir_for(a.name) if not wt.is_dir(): - raise EnvError("目录不存在:{}(先用插件建树:/worktree create 或 worktree_create)".format(wt)) + raise EnvError("目录不存在:{}(先用现役插件的 create_worktree 建树;旧树也可放在 " + ".dsh-worktrees/worktree//)".format(wt)) if a.action == "adopt": if git("rev-parse", "--git-dir", cwd=wt, check=False).returncode != 0: @@ -619,7 +695,8 @@ def cmd_plugin(a) -> int: print("build 锁由 keil_build.py 自持自释,持有者名就是 {}".format(owner)) print("上台架前:$TW lock acquire bench --task {} --note <说明>".format(owner)) print("下台架后:$TW lock release bench --task {}".format(owner)) - print("回收仍归插件:/worktree remove {}(本 skill 不删插件树)".format(a.name)) + print("现役插件只 create/restore,不自动回收,会保留目录与分支;本 skill 不代删。") + print("确需回收:先 plugin unadopt {} 摘联接,再按 E 节非登记树安全规程处理。".format(a.name)) return EXIT_OK removed = [rel for rel, _spec in JUNCTION_SPECS if remove_junction(wt / rel)] @@ -628,6 +705,96 @@ def cmd_plugin(a) -> int: return EXIT_OK +# --------------------------------------------------------------------- bench-gate / merge(新统一模型) + + +def cmd_bench_gate(a) -> int: + """检查指定分支是否有 bench-pass 记录""" + branch = a.branch + rec = find_bench_pass(branch) + if rec is None: + print("分支 {} 无 bench-pass 记录,请先跑台架验证".format(branch), file=sys.stderr) + return EXIT_FAIL + print("[ok] 分支 {} 有 bench-pass 记录".format(branch)) + print(" 最近通过时间:{}".format(rec.get("timestamp", "?"))) + print(" 操作人:{}".format(rec.get("operator", "?"))) + return EXIT_OK + + +def cmd_merge(a) -> int: + """在主树执行编排式合并""" + branch = a.branch + main = main_worktree() + + # 检查当前是否在主树 + if Path.cwd().resolve() != main.resolve(): + raise EnvError("merge 必须在主工作树执行:{}".format(main)) + + # 检查主树是否干净 + if git_out("status", "--porcelain", cwd=main, check=False): + raise Fail("主工作树不干净——合并会把未提交改动卷进来") + + cur = git_out("rev-parse", "--abbrev-ref", "HEAD", cwd=main) + base = BASE_BRANCH + if cur != base: + raise Fail("主工作树当前在 {},不是集成分支 {};先切过去再 merge".format(cur, base)) + + # 检查分支是否存在 + if git("rev-parse", "--verify", "--quiet", "refs/heads/" + branch, check=False).returncode != 0: + raise EnvError("分支不存在:{}".format(branch)) + + # Step 1: bench-gate 检查(除非 --skip-bench) + if not a.skip_bench: + rec = find_bench_pass(branch) + if rec is None: + print("分支 {} 无 bench-pass 记录,请先跑台架验证".format(branch), file=sys.stderr) + print("(加 --skip-bench 可跳过此检查)", file=sys.stderr) + return EXIT_FAIL + print("[ok] bench-gate 通过({})".format(rec.get("timestamp", "?"))) + + # Step 2: 预演合并(--no-commit) + print("=== 合并预演(git merge --no-ff --no-commit)===") + r = git("merge", "--no-ff", "--no-commit", branch, cwd=main, check=False) + if r.returncode != 0: + # 中止合并 + git("merge", "--abort", cwd=main, check=False) + out = (r.stdout + r.stderr).strip() + print(out) + raise Fail("合并有冲突:请回到任务分支解决冲突后重试\n" + " cd <任务工作树> && git merge {} && ... && git commit".format(base)) + + # Step 3: 检查是否有实际改动(可能已经合入过了) + staged = git_out("diff", "--cached", "--name-only", cwd=main, check=False) + if not staged.strip(): + # 没有改动,说明分支已合入或无差异 + git("merge", "--abort", cwd=main, check=False) + print("[i] 分支 {} 已合入或与 {} 无差异,无需合并".format(branch, base)) + return EXIT_OK + + # Step 4: 完成合并提交 + print("=== 完成合并提交 ===") + title = "merge: {} 合入 {}".format(branch, base) + if len(title) > 100: + title = title[:97] + "..." + msg_lines = [title, "", "经 task-worktree merge 命令合入。"] + fd, msgpath = tempfile.mkstemp(prefix="taskwt-merge-", suffix=".txt") + os.close(fd) + Path(msgpath).write_text("\n".join(msg_lines) + "\n", encoding="utf-8", newline="\n") + try: + r = git("commit", "--no-edit", "-F", str(msgpath), cwd=main, check=False) + if r.returncode != 0: + raise Fail("合并提交失败:\n{}".format((r.stdout + r.stderr).strip())) + finally: + Path(msgpath).unlink(missing_ok=True) + + sha = git_out("rev-parse", "--short", "HEAD", cwd=main) + print("[ok] 合并完成({})".format(sha)) + print() + print("合并完成,请在主树跑全量门禁") + print("提示:任务分支和目录未自动删除,清场请走 plugin unadopt + 手动回收") + return EXIT_OK + + # --------------------------------------------------------------------- 子进程脚本 @@ -811,59 +978,6 @@ def teardown_worktree(st: dict, force: bool = False, base: str = None) -> None: # --------------------------------------------------------------------- 子命令 -def cmd_new(a) -> int: - if not ID_RE.match(a.task_id): - raise EnvError("任务 ID 非法:{}(字母数字开头,可用 . _ -)".format(a.task_id)) - if not SLUG_RE.match(a.slug): - raise EnvError("slug 非法:{}(小写字母数字与连字符)".format(a.slug)) - branch = "{}{}-{}".format(BRANCH_PREFIX, a.task_id, a.slug) - wt = WT_ROOT / "{}-{}".format(a.task_id, a.slug) - - if state_file(a.task_id).is_file(): - raise EnvError("任务 {} 已存在({})".format(a.task_id, state_file(a.task_id))) - if os.path.lexists(str(wt)): - raise EnvError("目录已存在:{}".format(wt)) - if git("rev-parse", "--verify", "--quiet", "refs/heads/" + branch, check=False).returncode == 0: - raise EnvError("分支已存在:{}".format(branch)) - if git("rev-parse", "--verify", "--quiet", a.base, check=False).returncode != 0: - raise EnvError("基线分支不存在:{}".format(a.base)) - - active = [s for s in all_states() if Path(s["path"]).is_dir()] - if len(active) >= MAX_CONCURRENT and not a.force: - raise EnvError("已有 {} 个活跃任务(上限 {});先 done/abort,或加 --force".format( - len(active), MAX_CONCURRENT)) - - n_plugin = len(plugin_trees()) - if n_plugin: - print("[i] 另有 {} 个 DSH 插件工作树(.dsh-worktrees/,不计入上限 {});" - "磁盘与 UV4 / 台架仍是共享资源——「plugin list」看清单".format(n_plugin, MAX_CONCURRENT)) - WT_ROOT.mkdir(parents=True, exist_ok=True) - print("[1/3] 建工作树(分支 {},基线 {}{})…".format( - branch, a.base, ",跳过 LFS smudge" if a.lean else "")) - git("worktree", "add", "-b", branch, str(wt), a.base, - env={"GIT_LFS_SKIP_SMUDGE": "1"} if a.lean else None) - - print("[2/3] 建立共享联接…") - junctions = [] if a.no_bootstrap else bootstrap(wt) - - st = { - "id": a.task_id, "slug": a.slug, "branch": branch, "path": str(wt), - "base": a.base, "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), - "junctions": junctions, "needs_bench": bool(a.needs_bench), - "bench_pass": False, "note": a.note or "", "lean": bool(a.lean), - } - save_state(st) - print("[3/3] 台账已写入 {}".format(state_file(a.task_id))) - print() - print("下一步:把新会话的工作目录切到") - print(" " + str(wt)) - print("在该目录里干活、跑 /keil-build 与 ci_local.py(并行安全);") - print("要动台架先 `lock acquire bench --task {}`,完事 `lock release bench --task {} --bench-pass`。".format( - a.task_id, a.task_id)) - print("回收用 `done {}`(**必须在主工作树执行**)。".format(a.task_id)) - return EXIT_OK - - def cmd_list(a) -> int: states = all_states() if not states: @@ -974,19 +1088,19 @@ def cmd_lock(a) -> int: if a.bench_pass: if not a.task: raise EnvError("--bench-pass 需要 --task ") - # 非登记持有者(lead 的验收窗、插件树 pw:*)没有台账。以前这里先 load_state - # 就抛「任务 X 不存在(缺台账)」,**根本走不到释放**:锁还挂着,而 done 的前置 - # 会因活锁判失败——一个名字没登记,就挡住所有任务的收口。现在降级为显式告警 + - # 照常释放:PASS 没进台账是事实,但不该拿「锁放不掉」当代价。 + # 写入统一的 bench-pass-log(新模型) + # 尝试从台账获取分支名,否则用 task ID 作为分支标识 + branch_name = a.task if state_file(a.task).is_file(): - st = load_state(a.task) - st["bench_pass"] = True - save_state(st) - print("[ok] 已为任务 {} 记录台架 PASS".format(a.task)) - else: - print("[warn] 任务 {} 无台账({})→ **跳过 PASS 记录,仅释放锁**;要把 PASS 留在" - "台账里就得用已「new」出来的任务 ID 占锁,或把验收结论写进主干提交说明/文档" - .format(a.task, state_file(a.task))) + try: + st = load_state(a.task) + st["bench_pass"] = True + save_state(st) + branch_name = st.get("branch", a.task) + except Exception: + pass + write_bench_pass(branch_name) + print("[ok] 已记录台架 PASS(分支 {})到 {}".format(branch_name, BENCH_PASS_LOG)) ok, msg = lock_release(a.name, a.task, force=a.force) print(msg) return EXIT_OK if ok else EXIT_FAIL @@ -1014,6 +1128,8 @@ def release_stale_locks() -> None: def cmd_done(a) -> int: + print("[DEPRECATED] done 已废弃,请用 merge 命令", file=sys.stderr) + print() st = load_state(a.task_id) wt = Path(st["path"]) branch = st["branch"] @@ -1280,25 +1396,6 @@ def cmd_postmerge(a) -> int: return EXIT_OK if ok else EXIT_FAIL -def cmd_abort(a) -> int: - st = load_state(a.task_id) - wt = Path(st["path"]) - if wt.is_dir(): - dirty = git_out("status", "--porcelain", cwd=wt, check=False).splitlines() - ahead = int(git_out("rev-list", "--count", - "{}..{}".format(st["base"], st["branch"]), cwd=wt, - check=False) or "0") - if (dirty or ahead) and not a.force: - print("任务 {} 有 {} 项未提交改动、{} 个未合并提交,abort 会**永久丢弃**:".format( - st["id"], len(dirty), ahead)) - for line in dirty[:20]: - print(" " + line) - raise Fail("确认丢弃请加 --force") - teardown_worktree(st, force=True) - print("\n已放弃任务 {}(工作树与分支均已删除)".format(st["id"])) - return EXIT_OK - - # --------------------------------------------------------------------- 入口 @@ -1308,18 +1405,16 @@ def build_parser(): description="多任务并行开发的空间管理(git worktree 生命周期)") sub = ap.add_subparsers(dest="cmd") - p = sub.add_parser("new", help="新建任务空间") - p.add_argument("task_id") - p.add_argument("slug") - p.add_argument("--base", default=BASE_BRANCH, help="基线分支(默认 %(default)s)") - p.add_argument("--lean", action="store_true", - help="跳过 Git LFS smudge(省约 38MB,参考图变成指针文件)") - p.add_argument("--no-bootstrap", action="store_true", help="不建共享联接") - p.add_argument("--needs-bench", action="store_true", - help="需台架验证;无 PASS 记录则 done 拒绝合并") - p.add_argument("--note", default="") - p.add_argument("--force", action="store_true", help="忽略并发上限") - p.set_defaults(func=cmd_new) + # bench-gate:检查分支是否有 bench-pass 记录 + p = sub.add_parser("bench-gate", help="检查指定分支是否有 bench-pass 记录") + p.add_argument("branch", help="要检查的分支名") + p.set_defaults(func=cmd_bench_gate) + + # merge:在主树执行编排式合并 + p = sub.add_parser("merge", help="在主树执行编排式合并(bench-gate → 预演 → 合并)") + p.add_argument("branch", help="要合并的分支名") + p.add_argument("--skip-bench", action="store_true", help="跳过 bench-gate 检查") + p.set_defaults(func=cmd_merge) p = sub.add_parser("list", help="列出所有任务空间") p.set_defaults(func=cmd_list) @@ -1346,13 +1441,13 @@ def build_parser(): help="release bench 时顺带为该任务记录台架 PASS") p.set_defaults(func=cmd_lock) - p = sub.add_parser("plugin", help="DSH worktree 插件的树(.dsh-worktrees/):list / adopt / unadopt") + p = sub.add_parser("plugin", help="DSH 插件树(.agents/worktrees/,兼容旧路径):list / adopt / unadopt") p.add_argument("action", choices=["list", "adopt", "unadopt"]) p.add_argument("name", nargs="?", default="", - help="插件 worktree 名(可带 worktree/ 前缀或多段斜杠)") + help="插件树名;可用裸名,或带 .agents/worktrees/ 与旧 .dsh-worktrees/worktree/ 前缀消歧") p.set_defaults(func=cmd_plugin) - p = sub.add_parser("done", help="回收:预演 → 合入主干 → 审计 → 重生成 → 门禁 → 清场") + p = sub.add_parser("done", help="[DEPRECATED] 已废弃,请用 merge 命令") p.add_argument("task_id") p.add_argument("--base", default=None) p.add_argument("--dry-run", action="store_true", help="只预演,不做任何改动") @@ -1361,11 +1456,6 @@ def build_parser(): p.add_argument("--force", action="store_true", help="清场时强制移除工作树") p.set_defaults(func=cmd_done) - p = sub.add_parser("abort", help="放弃任务(不合并)") - p.add_argument("task_id") - p.add_argument("--force", action="store_true") - p.set_defaults(func=cmd_abort) - p = sub.add_parser("precheck", help="树内自查(只读):字节体检 + docs 逐档门禁 + 可选消息检查") p.add_argument("--msgfile", default=None, help="附带检查提交信息文件(BOM/CRLF/行长/禁词)") p.add_argument("--ban", action="append", default=[], help="禁词,可重复") diff --git a/.gitignore b/.gitignore index 649f190b..63983515 100644 --- a/.gitignore +++ b/.gitignore @@ -173,12 +173,10 @@ ci-artifacts/ # 正文已经进了 commit message,落盘副本零信息量 → 根目录锚定挡掉这个命名。 /commitmsg*.txt -# DSH worktree 插件(dsh-task-worktree)把任务树建在**仓库内**这个命名空间下,与 -# @task skill 的仓外 _wt/ 两套并存。这一行必须**先由我们自己写好**:插件首次 create -# 会往 .gitignore 追加分毫不相同的字面量(ensureIgnored 见同名规则即跳过),但那等于 -# 在会话中途改这份受控文件——门禁 diff 里冒出一行"不是我加的"最坑人。 -# 该目录同时登记进了 check_doc_links / gen_refgraph / check_layer_deps 的排除面 -# (整份仓库副本不能进全库扫描);细节 → .dsh/skills/task-worktree/SKILL.md +# 旧 DSH worktree 插件把任务树建在仓内这个命名空间下;现役插件默认使用 +# .agents/worktrees/,并在该目录池内写入自己的 .gitignore(内容为 *),不靠根文件忽略。 +# 两处都登记进 check_doc_links / gen_refgraph / check_layer_deps 的全库扫描排除面, +# 防止整份仓库副本造成假红或生成物漂移;操作边界 → /task-worktree 的插件桥规程。 .dsh-worktrees/ .obsidian/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 288e3d25..7056534e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -145,12 +145,12 @@ repos: pass_filenames: false - id: no-mojibake name: Forbid U+FFFD mojibake - entry: python scripts/check_mojibake.py + entry: scripts/gate_python.py scripts/check_mojibake.py language: system types: [c] - id: coding-standard name: C coding standard (规范V1.0) - entry: python scripts/check_coding_standard.py + entry: scripts/gate_python.py scripts/check_coding_standard.py language: system types: [c] # 范围与 cppcheck 钩子一致;另排除 CubeMX 生成文件(Src/、Inc/,重新生成即覆盖)、 @@ -175,7 +175,7 @@ repos: # 章节:`§N.M` 必须能在目标文档的编号标题里找到(--sections-strict:自引用失效也拦) - id: doc-links name: 文档链接存在性 - entry: python scripts/check_doc_links.py --sections-strict + entry: scripts/gate_python.py scripts/check_doc_links.py --sections-strict language: system files: \.md$ # 外部资料(芯片手册转录)链接形态不受控;第三方目录整体排除 @@ -200,7 +200,7 @@ repos: # sequence alt-else-end 不误伤 / 基线吞得下且清空后同命中算新发)。 - id: mermaid-syntax name: "mermaid 围栏语法预检(图类型头 / 非法点线管道 / 块配对)" - entry: python scripts/check_mermaid.py + entry: scripts/gate_python.py scripts/check_mermaid.py language: system files: \.md$ exclude: | @@ -213,7 +213,7 @@ repos: # 数值:README/AGENTS 任务表须与 Src/freertos.c 一致 - id: task-table name: FreeRTOS 任务表一致性 - entry: python scripts/gen_task_table.py --check + entry: scripts/gate_python.py scripts/gen_task_table.py --check language: system files: ^(README\.md|AGENTS\.md|Src/freertos\.c)$ pass_filenames: false @@ -221,7 +221,7 @@ repos: # 只在内存文档或代码变动时触发;.map 未生成时脚本自身给出明确提示 - id: mem-numbers name: 内存/Flash 数字一致性(体积档) - entry: python scripts/check_mem_numbers.py --scan + entry: scripts/gate_python.py scripts/check_mem_numbers.py --scan language: system files: ^(docs/内存管理/.*\.md|scripts/check_mem_numbers\.py|App/.*\.c|App/.*\.h|Src/.*\.c|Inc/.*\.h)$ pass_filenames: false @@ -242,7 +242,7 @@ repos: # 实测对 local 钩子只告警并跳过——不能靠它兜底。) - id: lv-conf-sync name: 两份 lv_conf.h 特性子集一致性 - entry: python scripts/check_lv_conf_sync.py + entry: scripts/gate_python.py scripts/check_lv_conf_sync.py language: system files: ^(Lib/lv_conf\.h|simulator/lv_conf\.h|scripts/check_lv_conf_sync\.py)$ pass_filenames: false @@ -254,7 +254,7 @@ repos: # 范围:仅在这些易失文件变动时触发(改动 .ioc 或生成文件本身就值得复核)。 - id: cubemx-survival name: CubeMX 重生成存活审计 - entry: python scripts/check_cubemx_survival.py --check + entry: scripts/gate_python.py scripts/check_cubemx_survival.py --check language: system files: ^(STM32F407-Demo\.ioc|Src/.*\.(c|h)|Inc/.*\.h|App/util/task_watchdog\.(c|h)|MDK-ARM/.*\.uvprojx|scripts/check_cubemx_survival\.py)$ pass_filenames: false @@ -267,7 +267,7 @@ repos: # 详见 docs/测试说明/单元测试/看门狗单元测试说明.md - id: watchdog-unit-test name: 看门狗判定状态机单测(主机侧) - entry: python test/host/watchdog/run_unit_test.py + entry: scripts/gate_python.py test/host/watchdog/run_unit_test.py language: system files: ^(App/util/task_watchdog\.(c|h)|test/host/watchdog/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -281,7 +281,7 @@ repos: # files 含 sys_clock.c 依赖的 sd2506.h(时间结构/返回值定义)。 - id: sys-clock-unit-test name: 系统时钟换算单测(主机侧) - entry: python test/host/sys_clock/run_unit_test.py + entry: scripts/gate_python.py test/host/sys_clock/run_unit_test.py language: system files: ^(App/util/sys_clock\.(c|h)|Drivers/BSP/SD2506/sd2506\.h|test/host/sys_clock/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -297,7 +297,7 @@ repos: # 不落盘,且 net_cfg_save 仍报 OK)。详见测试说明文档。 - id: cfg-file-unit-test name: 配置引擎单测(主机侧) - entry: python test/host/cfg_file/run_unit_test.py + entry: scripts/gate_python.py test/host/cfg_file/run_unit_test.py language: system files: ^(App/cfg/cfg_file\.(c|h)|App/cfg/cfg_file_store\.h|App/log/sys_log\.h|test/host/cfg_file/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -311,7 +311,7 @@ repos: # files 含被测的两个源与其头,以及 harness 本身。 - id: phasor-unit-test name: 相量层单测(主机侧) - entry: python test/host/phasor/run_unit_test.py + entry: scripts/gate_python.py test/host/phasor/run_unit_test.py language: system files: ^(App/algo/(phasor|sample_ring)\.(c|h)|test/host/phasor/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -325,7 +325,7 @@ repos: # files 含被测源与其头、两个桩头所在目录,以及 harness 本身。 - id: adc-input-unit-test name: ADC 输入层单测(主机侧) - entry: python test/host/adc_input/run_unit_test.py + entry: scripts/gate_python.py test/host/adc_input/run_unit_test.py language: system files: ^(App/algo/adc_input\.(c|h)|test/host/adc_input/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -340,7 +340,7 @@ repos: # files 含被测源与其头,以及 harness 本身。 - id: vol-lock-unit-test name: 卷锁单测(主机侧) - entry: python test/host/vol_lock/run_unit_test.py + entry: scripts/gate_python.py test/host/vol_lock/run_unit_test.py language: system files: ^(App/fs/vol_lock\.(c|h)|test/host/vol_lock/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -355,7 +355,7 @@ repos: # 以及 harness 本身。 - id: vol-idle-sync-unit-test name: 卷空闲同步单测(主机侧) - entry: python test/host/vol_idle_sync/run_unit_test.py + entry: scripts/gate_python.py test/host/vol_idle_sync/run_unit_test.py language: system files: ^(App/fs/vol_idle_sync\.(c|h)|App/task/default_task\.c|test/host/vol_idle_sync/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -370,7 +370,7 @@ repos: # files 含被测源与其头、节拍契约头 time_sntp.h、被测的真实看门狗头,以及 harness 本身。 - id: time-sync-unit-test name: 时间同步单测(主机侧) - entry: python test/host/time_sync/run_unit_test.py + entry: scripts/gate_python.py test/host/time_sync/run_unit_test.py language: system files: ^(App/sync/time_sync\.(c|h)|App/sync/time_sntp\.h|App/util/task_watchdog\.h|test/host/time_sync/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -384,7 +384,7 @@ repos: # files 含被测源与其头、adc_task.c(告警编排的消费方),以及 harness 本身。 - id: adc-health-unit-test name: ADC 有效性校验单测(主机侧) - entry: python test/host/adc_health/run_unit_test.py + entry: scripts/gate_python.py test/host/adc_health/run_unit_test.py language: system files: ^(App/algo/adc_health\.(c|h)|App/task/adc_task\.c|test/host/adc_health/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -398,7 +398,7 @@ repos: # 以及 harness 本身。 - id: adc-cal-unit-test name: 通道标定层单测(主机侧) - entry: python test/host/adc_cal/run_unit_test.py + entry: scripts/gate_python.py test/host/adc_cal/run_unit_test.py language: system files: ^(App/algo/adc_cal\.(c|h)|App/algo/adc_health\.(c|h)|App/cfg/cfg_file\.(c|h)|App/task/adc_task\.c|test/host/adc_cal/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -409,7 +409,7 @@ repos: # 或量纲再错位,断言立刻翻转。files 覆盖链上全部生产源与两处编排。 - id: prot-dim-unit-test name: 量纲回归单测(主机侧) - entry: python test/host/prot_dim/run_unit_test.py + entry: scripts/gate_python.py test/host/prot_dim/run_unit_test.py language: system files: ^(App/algo/(adc_cal|prot_elm|prot_set|phasor|sample_ring|freq)\.(c|h)|App/task/(adc_task|protect_task)\.c|test/host/prot_dim/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -424,7 +424,7 @@ repos: # 同链核(phasor/sample_ring)、编排方 adc_task 与 harness 本身。 - id: meas-unit-test name: 遥测快照层单测(主机侧) - entry: python test/host/meas/run_unit_test.py + entry: scripts/gate_python.py test/host/meas/run_unit_test.py language: system files: ^(App/algo/meas\.(c|h)|App/algo/(phasor|sample_ring)\.(c|h)|App/task/adc_task\.c|test/host/meas/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -434,7 +434,7 @@ repos: # 分档量程守卫、两段式、系数直通的合法带与生效值回显必须离线钉死。 - id: run-par-unit-test name: 运行参数段单测(主机侧) - entry: python test/host/run_par/run_unit_test.py + entry: scripts/gate_python.py test/host/run_par/run_unit_test.py language: system files: ^(App/algo/run_par\.(c|h)|App/algo/adc_cal\.(c|h)|App/cfg/cfg_file(_store)?\.(c|h)|test/host/run_par/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -446,7 +446,7 @@ repos: # 任何元件接线之前钉死——板端无法穷举越界定值注入,主机侧毫秒级全跑。 - id: prot-set-unit-test name: 保护定值模型单测(主机侧) - entry: python test/host/prot_set/run_unit_test.py + entry: scripts/gate_python.py test/host/prot_set/run_unit_test.py language: system files: ^(App/algo/prot_set\.(c|h)|test/host/prot_set/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -457,7 +457,7 @@ repos: # 1.2×/0.9× 验收口径(规划 §8)。 - id: prot-elm-unit-test name: 保护元件框架单测(主机侧) - entry: python test/host/prot_elm/run_unit_test.py + entry: scripts/gate_python.py test/host/prot_elm/run_unit_test.py language: system files: ^(App/algo/prot_elm\.(c|h)|App/algo/prot_set\.(c|h)|App/algo/phasor\.(c|h)|App/algo/freq\.(c|h)|test/host/prot_elm/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -468,7 +468,7 @@ repos: # 可穷举的精度契约;板端无标准源照不出来(规划 §3.3 过频 / 低频)。 - id: freq-unit-test name: 频率测量单测(主机侧) - entry: python test/host/freq/run_unit_test.py + entry: scripts/gate_python.py test/host/freq/run_unit_test.py language: system files: ^(App/algo/freq\.(c|h)|App/algo/phasor\.(c|h)|App/algo/sample_ring\.(c|h)|test/host/freq/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -480,7 +480,7 @@ repos: # 在主机侧毫秒级钉死。零平台依赖,无桩。 - id: sha256-unit-test name: SHA-256 单测(主机侧) - entry: python test/host/sha256/run_unit_test.py + entry: scripts/gate_python.py test/host/sha256/run_unit_test.py language: system files: ^(App/util/sha256\.(c|h)|test/host/sha256/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -491,7 +491,7 @@ repos: # 设计(tick 参数注入)就是为了让这些分支主机侧毫秒级穷举。 - id: ftp-auth-unit-test name: FTP 鉴权状态机单测(主机侧) - entry: python test/host/ftp_auth/run_unit_test.py + entry: scripts/gate_python.py test/host/ftp_auth/run_unit_test.py language: system files: ^(Drivers/BSP/NET/lftpd/lftpd_auth\.(c|h)|App/util/sha256\.(c|h)|test/host/ftp_auth/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -504,7 +504,7 @@ repos: # 直接 #include 这些生产源,签名变了要立刻在这里暴露。 - id: iec104-unit-test name: IEC104 协议栈单测(主机侧) - entry: python test/host/iec104/run_unit_test.py + entry: scripts/gate_python.py test/host/iec104/run_unit_test.py language: system # 运行参数段落地起本 TU 链入真实 cfg_file/adc_cal/run_par(板端真源链),触发面同步扩 files: ^(App/protocol/iec104/iec104_(asdu|pts|link)\.(c|h)|App/algo/(prot_set|prot_elm|phasor|sample_ring|adc_input|freq|meas|adc_cal|run_par)\.(c|h)|App/cfg/cfg_file(_store)?\.(c|h)|test/host/iec104/.*|test/host/common/unit_test_harness\.py)$ @@ -517,7 +517,7 @@ repos: # 截断/坏校验/IV 位/seq 连续性(规划 §3.4 出口与记录)。 - id: soe-unit-test name: SOE 动作记录编解码单测(主机侧) - entry: python test/host/soe/run_unit_test.py + entry: scripts/gate_python.py test/host/soe/run_unit_test.py language: system files: ^(App/log/soe_rec\.(c|h)|App/log/soe_store\.(c|h)|test/host/soe/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -536,7 +536,7 @@ repos: # 触发面含 dhara 本体(测试一并 #include map.c/journal.c 跑真链路场景)。 - id: nand-port-unit-test name: dhara NAND 移植层单测(主机侧) - entry: python test/host/nand_port/run_unit_test.py + entry: scripts/gate_python.py test/host/nand_port/run_unit_test.py language: system files: ^(Drivers/BSP/GD5F2GQ5UE/nand_ftl\.(c|h)|Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue\.h|Lib/dhara/(map|journal|nand|error|bytes)\.(c|h)|test/host/nand_port/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -553,7 +553,7 @@ repos: # files 覆盖被测两件 + 编号真源四头 + 页面消费面(ui_content_page/ui_menu*)+ harness。 - id: ui-data-unit-test name: UI 数据快照层单测(主机侧) - entry: python test/host/ui_data/run_unit_test.py + entry: scripts/gate_python.py test/host/ui_data/run_unit_test.py language: system files: ^(Drivers/BSP/UI/(ui_data|ui_w_soe|ui_w_records|ui_content_page|ui_menu)\.(c|h)|App/app/ui_data_feed\.(c|h)|App/algo/(adc_input|prot_set|prot_elm)\.(c|h)|App/log/soe_rec\.(c|h)|test/host/ui_data/.*|test/host/common/unit_test_harness\.py)$ pass_filenames: false @@ -567,7 +567,7 @@ repos: # 判据与背景见 docs/门禁说明/test-suite-switch.md - id: test-suite-switch name: 测试套件开关=产品构建 - entry: python scripts/check_test_suite.py + entry: scripts/gate_python.py scripts/check_test_suite.py language: system files: ^test/board/test_config\.h$ pass_filenames: false @@ -585,7 +585,7 @@ repos: # 任意 .c + uvprojx + CMakeLists.txt + 脚本自身(脚本自身改动也值得复跑)。 - id: registration-check name: 双端注册反向校验 (uvprojx + CMakeLists) - entry: python scripts/check_registration.py + entry: scripts/gate_python.py scripts/check_registration.py language: system files: (\.c$|\.uvprojx$|CMakeLists\.txt$|^scripts/check_registration\.py$) pass_filenames: false @@ -599,7 +599,7 @@ repos: # 全部自研代码目录 + lv_conf.h + 测试板配置 + 脚本/tsv/台账自身 + 本配置文件。 - id: mem-budget name: RAM 预算 92% 阈值门禁 - entry: python scripts/ram_baseline.py --gate + entry: scripts/gate_python.py scripts/ram_baseline.py --gate language: system files: ^(App/|Drivers/BSP/|Src/|Inc/|Lib/lv_conf\.h|test/board/|scripts/ram_baseline\.|docs/内存管理/|\.pre-commit-config\.yaml$) pass_filenames: false @@ -619,7 +619,7 @@ repos: # UI 源码(含字体 .c 自身)+ ui_task.{c,h} + 字符集 txt + 两个生成器 + 本脚本。 - id: font-charset name: UI 字符集差集检查(防缺字白框) - entry: python scripts/check_font_charset.py + entry: scripts/gate_python.py scripts/check_font_charset.py language: system files: ^(Drivers/BSP/UI/|App/task/ui_task\.(c|h)$|ui_cn_chars\.txt$|tools/font_gen/|scripts/check_font_charset\.py$) pass_filenames: false @@ -634,7 +634,7 @@ repos: # 单独改任何一处都会在别人的提交里被拦下——名单散在三处而无人同步等于没有名单。 - id: bom-guard name: BOM 漂移检查(带 BOM 文件被剥掉即拦) - entry: python scripts/check_bom_drift.py + entry: scripts/gate_python.py scripts/check_bom_drift.py language: system files: ^(Drivers/BSP/UI/|App/task/ui_task\.c$|App/cfg/cfg_file\.c$|Lib/lv_conf\.h$) @@ -649,7 +649,7 @@ repos: # skip(skip 不是失败),合并后自动生效,不需要任何人再动这条配置。 - id: setview-check name: 定值查看行表与 prot_set 枚举/上下限一致性 - entry: python tools/setview_check/check_setview_limits.py + entry: scripts/gate_python.py tools/setview_check/check_setview_limits.py language: system files: ^(tools/setview_check/|Drivers/BSP/UI/ui_w_setview\.(c|h)$) pass_filenames: false @@ -673,7 +673,7 @@ repos: # 连续两次失败才当真(不许直接 --no-verify 绕过)。 - id: ui-golden name: UI 像素回归(菜单 8 状态 + 内容页 74 张 黄金参考) - entry: python scripts/check_ui_golden.py + entry: scripts/gate_python.py scripts/check_ui_golden.py language: system files: ^(Drivers/BSP/UI/|App/task/ui_task\.(c|h)$|simulator/(tests|sim|src)/|simulator/main\.c$|simulator/CMakeLists\.txt$|simulator/lv_conf\.h$|scripts/check_ui_golden\.py$|\.pre-commit-config\.yaml$) pass_filenames: false @@ -693,7 +693,7 @@ repos: # 把每个 UI 提交都拉去跑导航器,只会让人习惯等一个与本改动无关的钩子。 - id: uinav-selftest name: 板端 UI 导航器认屏自检(快检:黄金参考帧分类) - entry: python scripts/board_ui_nav.py --selftest-quick + entry: scripts/gate_python.py scripts/board_ui_nav.py --selftest-quick language: system files: ^(scripts/board_ui_nav\.py$|simulator/tests/ref/.*\.bin$) pass_filenames: false @@ -724,7 +724,7 @@ repos: # 扫描范围恒为全库(要跨文件建 App 头索引),故 pass_filenames: false。 - id: layer-deps name: 分层方向(BSP 不得反向 include App) - entry: python scripts/check_layer_deps.py + entry: scripts/gate_python.py scripts/check_layer_deps.py language: system files: ^Drivers/BSP/|^scripts/check_layer_deps\.py$|^\.pre-commit-config\.yaml$ pass_filenames: false @@ -740,7 +740,7 @@ repos: # 恒扫全库(要按 git 清单建公开头索引),故 pass_filenames: false。 - id: bsp-err name: BSP 错误码与超时命名约定 - entry: python scripts/check_bsp_err.py + entry: scripts/gate_python.py scripts/check_bsp_err.py language: system files: ^Drivers/BSP/|^scripts/check_bsp_err\.py$|^\.pre-commit-config\.yaml$ pass_filenames: false @@ -772,7 +772,7 @@ repos: - id: test-coverage name: 主机单测覆盖率(增量拦截:已覆盖行只增不减) stages: [manual] - entry: python scripts/check_test_coverage.py + entry: scripts/gate_python.py scripts/check_test_coverage.py language: system files: ^(App/|Drivers/BSP/|test/host/|scripts/check_test_coverage\.py|scripts/test_coverage_baseline\.json|\.pre-commit-config\.yaml$) pass_filenames: false @@ -808,7 +808,7 @@ repos: # 生成物(docs/images/*.svg + 两份注册表)、脚本自身与本配置文件(同 registration-check 口径)。 - id: generated-fresh name: 生成器幂等性(重跑到临时目录 → 与工作区逐字节一致) - entry: python scripts/check_generated_fresh.py + entry: scripts/gate_python.py scripts/check_generated_fresh.py language: system files: ^(tools/.+|Drivers/BSP/UI/.+|docs/images/.*\.svg|App/task/ui_task\.(c|h)|ui_menu_tree\.txt|ui_cn_chars\.txt|MDK-ARM/STM32F407-Demo\.uvprojx|simulator/CMakeLists\.txt|scripts/check_generated_fresh\.py|scripts/check_font_charset\.py|\.pre-commit-config\.yaml)$ pass_filenames: false @@ -834,7 +834,7 @@ repos: # 口径与自查命令见 docs/门禁说明/README.md §2 首条。 - id: nested-comment name: "块注释体内不得再出现 /*(ARMCC 9-D 嵌套注释)" - entry: python scripts/check_nested_comment.py + entry: scripts/gate_python.py scripts/check_nested_comment.py language: system types: [c] @@ -860,6 +860,6 @@ repos: # 详解与限制 → docs/门禁说明/skill-size.md。 - id: skill-size name: "SKILL.md 不超插件写侧上限 65536 且 reference/ 指针不悬空" - entry: python scripts/check_skill_size.py + entry: scripts/gate_python.py scripts/check_skill_size.py language: system files: ^\.dsh/skills/ diff --git a/AGENTS.md b/AGENTS.md index c76382b3..3a0c6490 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,9 @@ - **Teammate**:专注自己领域,跨域问题告知 Lead 转交。 - **角色绑定(自动注入)**:`spawn_teammate` 的 `prompt` **首行写 `@role:<角色id>`**(如 `@role:algo-specialist`),插件会在子会话首轮请求组装前自动把 `.dsh/roles/<角色id>.md` 的 persona 注入其系统提示词并应用工具白名单,**无需手动拼接**(缺首行标记 = 无角色,勿省略)。可用角色 → `.dsh/roles/README.md`;`subagent_role` 路径同为自动注入。 - **判断标准**:涉及两个以上模块、需要构建+测试+文档联动 → Agent Teams。单文件小改 → `subagent_role` 可接受。 +- **任务描述必须带验收口径**(借鉴 dsh-ai-team 的验收门禁,宿主无此闸故靠约定):派活时写清「怎么算做对了」——可验证的结果定义 + 逐条检查清单;teammate 回报必须附实测证据(命令输出、文件路径与函数名、计数),不采信自述结论。 +- **验收人 ≠ 执行人**:重要产出固定交给另一角色复核(如 test-engineer 的实现由 code-reviewer 验收)。 +- **返工不许只回「收到」**:被退回的任务须修正后重新提交并附新证据。 ## 边界(Ask first / Never) diff --git a/docs/README.md b/docs/README.md index 494d5470..b685b18e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -258,7 +258,7 @@ docs/ | 文档 | 内容 | |---|---| | [`项目部署环境要求.md`](环境与部署/项目部署环境要求.md) | 开发机软件环境、目标板资源预算、现场部署与验收步骤 | -| [`并行任务工作树方案.md`](环境与部署/并行任务工作树方案.md) | **多任务并行开发机制**:`git worktree` 隔离、目录与共享/独占分配(junction 规则)、`task-worktree` SKILL 命令面(`@task` 仅为文档别名,显式调用 `/task-worktree`)、单写者文档与合并残留审计、台架锁;**已落地并端到端实测**(见其 §11) | +| [`并行任务工作树方案.md`](环境与部署/并行任务工作树方案.md) | **多任务并行开发机制(单一模型)**:DSH 插件创建工作树,`task-worktree` 补共享环境、构建/台架锁、`bench-gate` 台架验收门禁与 `merge` 编排式合并;含目录联接、冲突规则与容量建议 | | [`子Agent角色自动注入实现说明.md`](环境与部署/子Agent角色自动注入实现说明.md) | **`spawn_teammate` 角色 persona 自动注入的实现参考 + 排障参照**(宿主 0.2.2-rc.1 + 2026-11-13 台架实证快照):事件面事实(两参回调/子会话时序/lead 侧接力)、工具白名单降级、兜底与去重、失败模式史、部署生效链(pnpm file: 拷贝陷阱)、诊断日志排障速查;使用侧在 `.dsh/roles/README.md` | | [`CI/README.md`](CI/README.md) | **远程 CI(Gitea Actions)唯一入口**:服务端开关 / runner 部署与保活 / workflow / 排障。**只涉及本机一台 runner `win-stm32-ci`,其他机器与项目都不涉及**;日常改代码用本地门禁 `scripts/ci_local.py`,不必读这里 | diff --git a/docs/任务规划/README.md b/docs/任务规划/README.md index 45975b78..20a6446f 100644 --- a/docs/任务规划/README.md +++ b/docs/任务规划/README.md @@ -35,7 +35,7 @@ ## 2. 全项目推进顺序 > 本节是全项目唯一的顺序权威。模块文档只写局部设计与重开条件,不另排一套路线图。 -> 写范围相交怎么排队、构建与台架怎么串行 → `docs/环境与部署/并行任务工作树方案.md` §7.3。 +> 写范围相交怎么排队、构建与台架怎么串行 → `docs/环境与部署/并行任务工作树方案.md` 的「冲突与单写者规则」与「从创建到交付的完整流程」两节。 ### 2.0 顺序表 diff --git a/docs/子系统设计/故障录波设计说明.md b/docs/子系统设计/故障录波设计说明.md index 665d37cb..1b4d50e9 100644 --- a/docs/子系统设计/故障录波设计说明.md +++ b/docs/子系统设计/故障录波设计说明.md @@ -353,7 +353,7 @@ flowchart TD PAGE -. 二期另立项 .-> NEXT[4 kHz 全速 / 突变量启动 / COMTRADE / 104 文件传输]; ``` -图内不写步骤号,节点名与 §10 各行语义逐字可对。并行度规则见 `docs/环境与部署/并行任务工作树方案.md` §7.3;**UI 道是单写者**——菜单树与中文字库都是整文件生成物,动叶子的那条道不许与别的改 UI 的道并行。 +图内不写步骤号,节点名与 §10 各行语义逐字可对。并行度规则见 `docs/环境与部署/并行任务工作树方案.md` 「冲突与单写者规则」一节;**UI 道是单写者**——菜单树与中文字库都是整文件生成物,动叶子的那条道不许与别的改 UI 的道并行。 ### 板级远程半轮记录(2026-10-31,无注入·无人值守) diff --git a/docs/文档规范.md b/docs/文档规范.md index 915cc426..cfaba6d3 100644 --- a/docs/文档规范.md +++ b/docs/文档规范.md @@ -185,7 +185,7 @@ > 同批实测的另两条工具侧事实:新路径**以 `/` 结尾**才是"挪进该目录并保留文件名"(见上例注释);`--apply` 收尾会自动跑 > `check_doc_links.py --all`,在带 junction 联接(`.venv`、`simulator/SDL2`)的任务工作树里会把联接目录扫成**假红**, -> 所以树内批量搬迁要加 `--no-verify`,全库 `--all` 复核留到主工作树跑(→ `docs/环境与部署/并行任务工作树方案.md`)。 +> 所以树内批量搬迁要加 `--no-verify`,全库 `--all` 复核留到主工作树跑(工作树分工与目录边界见 `docs/环境与部署/并行任务工作树方案.md`)。 > 另:脚本结尾的 `ℹ` 提示在 GBK 代码页会抛 `UnicodeEncodeError` 让进程**以退出码 1 收场**(此时 `git mv` 与改写**已经落盘**, > RC 与真实结果相反)→ 2026-10-30 已在 `scripts/rename_ref.py` 内把 stdout/stderr `reconfigure(encoding="utf-8")`, > 与 `check_ids.py` / `check_doc_links.py` / `check_test_traceability.py` 同法。 @@ -348,7 +348,7 @@ | **一级标题** | 文档首行为 `# 标题`,标题即主题,不写"文档""说明"等冗余后缀 | | **开篇引言** | 可选 `>` 引用块,写明**适用对象 / 阅读前提 / 相关文档** | | **章节编号** | 多章节文档用 `## 1.` `### 1.1` 编号,便于交叉引用(`§3.2`) | -| **变更记录** | **一律不设该节**(用户裁定 2026-10-28 起新文档、2026-10-29 起老文档也清完;标题变体 `修订记录` / 带编号的 `## 6. 变更记录` / `## 九、变更记录` 同规则)——文档只写现状、改了什么是正文说了算,改动过程查 `git log`。**例外只有 5 篇**(整表保留:表里是真台账、且被别处当唯一证据出处引用):`docs/子系统设计/看门狗设计说明.md`、`docs/子系统设计/配置文件设计说明.md`、`docs/内存管理/RAM预算台账.md`、`docs/UI设计/内容页架构.md`、`docs/环境与部署/本地工具路径环境变量方案.md`;`scripts/archive/` 下的归档文档整片都是历史,不受本条约束 | +| **变更记录** | **一律不设该节**(用户裁定 2026-10-28 起新文档、2026-10-29 起老文档也清完;标题变体 `修订记录` / 带编号的 `## 6. 变更记录` / `## 九、变更记录` 同规则)——文档只写现状、改了什么是正文说了算,改动过程查 `git log`。**例外只有 4 篇**(整表保留:表里是真台账、且被别处当唯一证据出处引用):`docs/子系统设计/看门狗设计说明.md`、`docs/子系统设计/配置文件设计说明.md`、`docs/内存管理/RAM预算台账.md`、`docs/UI设计/内容页架构.md`;`scripts/archive/` 下的归档文档整片都是历史,不受本条约束 | > 数"还剩几篇留着这节"的命令(**别只 grep 裸标题**——带编号的 `## 6. 变更记录`、中文序号的 `## 九、变更记录`、`## 修订记录` 三种写法都要算): > `grep -rn "^## " docs scripts` 再看命中的标题;2026-10-29 实测 **5 篇**真台账(上表那五篇)+ `scripts/archive/` 归档一篇。 diff --git a/docs/环境与部署/并行任务工作树方案.md b/docs/环境与部署/并行任务工作树方案.md index 84e7d903..05c2df7e 100644 --- a/docs/环境与部署/并行任务工作树方案.md +++ b/docs/环境与部署/并行任务工作树方案.md @@ -1,442 +1,187 @@ -# 并行任务工作树工作流(worktree + `task-worktree` skill) +# 并行任务工作树方案 -> **这个文件是什么**:多任务并行开发的**机制规格**——如何为每个任务开一个互不干扰的工作空间、 -> 干完如何合并回主干、哪些资源必须串行、冲突怎么治理。 +> **你要用它做什么**:把互不相干的开发任务放进独立目录,避免同时改代码、跑构建和上台架时互相干扰。 > -> **状态**:**已落地并端到端实测**(2026-10-12)——skill 在 `.dsh/skills/task-worktree/`, -> 实测记录与暴露并修复的缺陷见 §11。 +> **现行做法**:DSH 的 worktree 插件创建并进入工作树;本项目的 `task-worktree` skill +> 负责共享环境、硬件与构建排队、台架验收门禁及合并收尾。插件简化的是「开空间」, +> 不是「自动验证并合入主干」。 > -> **决策基线**(2026-10-12 用户确认):工作树放**仓库同级 `_wt/`**;集成分支**沿用 `v2-on-ui`**; -> `done` 回收**不自动 push**(沿用「push 需用户明确许可」)。 +> **现行基线**(2026-11-15):主干为 `master`;本机插件为 +> `@deepseek-ai/dsh-experimental-tool-worktree` 0.2.2-rc.1,模型工具为 +> `create_worktree`。本文规定本仓采用的分工;插件自身行为以其安装版本的说明和源码为准。 --- -## 1. 目的与范围 +## 1. 单一模型:插件树 + 项目验证层 -**要解决的问题**:多个任务同时推进时,若共用同一个工作目录,会互相踩——构建产物、日志目录、 -Python 环境、以及 `docs/任务规划/` 这类**全局唯一顺序权威**都会被并发写坏。 +所有任务统一走插件树生命周期,不再并存登记任务链。 -**做法**:每个任务一棵 `git worktree` + 一条任务分支,任务之间物理隔离;干完由固定命令合回主干。 +| 工作 | 入口 | 为什么 | +|---|---|---| +| 创建分支与工作树、切换当前会话目录 | DSH `create_worktree` | 一次调用完成,直接返回路径、分支和固定基线提交 | +| 接入共享 Python 环境与模拟器 SDK | `task-worktree plugin adopt` | 插件不复制本机未入库的依赖目录 | +| 查看工作树与共享联接状态 | `task-worktree plugin list` | 不依赖插件私有状态文件 | +| Keil 构建排队 | `/keil-build` 自动持有并释放 `build` 锁 | UV4 构建仍须串行 | +| 烧录与台架排队 | `task-worktree lock acquire/release bench` | 串口、ST-LINK 和设备各只有一份 | +| 台架验收门禁 | `task-worktree bench-gate ` | 检查该分支是否有 `bench-pass` 记录,合并前调用 | +| 编排式合并(预演→合并→重生成→门禁→清场) | `task-worktree merge ` | 复用原 `done` 逻辑,数据源改为 git metadata | +| 树内编码、静态检查与主机侧单测 | 各任务自己的目录 | 文件与构建产物互不覆盖;共享资源仍按锁串行 | +| 推送远端 | 用户明确许可后另行执行 | 任何本地收尾都不等于获得推送许可 | -**范围**:只解决「开发期隔离 + 回收期合并」的机械流程。**不改变**既有的分支纪律 -(push 需许可)、门禁口径(pre-commit 52 钩子 + `/keil-build` + `/serial-boot-test`)与文档约定。 +**`task-worktree` 没有重造 Git 工作树。** 底层隔离仍由 Git 提供;它补足的是本仓特有、插件不承担的约束。 ---- +## 2. 创建与接入 -## 2. 方案选型 - -| 方案 | 为什么不用 | -| ---------------------- | ----------------------------------------------------------------------------------- | -| 同一目录切分支 | 构建产物 / `.venv` / 日志目录 / `.workbuddy-ai` 全共享,两任务互相踩;且本仓库明令禁止用 `git checkout` 丢弃未提交工作 | -| 每任务 `git clone` | 对象库 234 MB × N 份、Git LFS 全量重复;克隆之间无法安全共享分支,推送关系也更绕 | -| `git stash` / 打补丁倒换 | 不具备并发安全性,冲突只能靠人工记忆 | -| **`git worktree`(采用)** | 唯一同时满足「共享对象库 + 独立工作目录 + 独立 HEAD/index」的机制;git 原生,本仓库已有先例 | - -**命名惯例来源**:`D:/Code/DTU Code/_v1pin-build`、`_v2oui-build` 曾是遗留 worktree(detached HEAD, -2026-09-19 建、已过期),本方案沿用其「仓库同级 + `_` 前缀」命名,但把散落的目录收敛到统一的 -`_wt/` 容器下。**两者已于 2026-10-12 清理**:`_v1pin-build` 干净直接移除;`_v2oui-build` 有 2 个 -未提交改动(uvprojx / freertos.c),已按字节留档于 `_wt/.archive/2026-10-12-legacy-worktrees/` -后才移除(两提交由两条 `wip/*` 保活分支持有,其一为 `wip/v2-pending-recv`;无提交丢失)。 - ---- - -## 3. 目录与资源分配 - -### 3.1 目录布局 +### 2.1 创建工作树 ```text -D:/Code/DTU Code/ -├── STM32F4-Base/ # 主工作树 = 集成分支 v2-on-ui(唯一检出主干的地方) -│ ├── .git/ # 共享对象库 + hooks(worktree 自动共享,无需任何操作) -│ ├── .venv/ # 唯一实体(Python 环境) -│ ├── .workbuddy-ai/ # 唯一实体(记忆;**不**共享给任务树,见 3.2) -│ └── simulator/SDL2/ # 唯一实体(模拟器 SDK) -└── _wt/ # ★ 并行任务容器(不在任何 git 仓库内) - ├── .state/.json # 任务台账:分支 / 联接 / 门禁与台架戳 - ├── .state/locks/ # 独占锁:build.lock(UV4)+ bench.lock(COM6/ST-LINK/设备 IP) - └── -/ # ← 一棵工作树(分支 task/-) - ├── .git # 文件(指向共享对象库,git 自动创建) - ├── .venv # → junction 到主工作树 - ├── simulator/SDL2 # → junction 到主工作树 - ├── .workbuddy-ai # 本任务自己的(清场前归并回主工作树) - └── MDK-ARM/… # 本任务独占的构建产物 +create_worktree({ name: "fix-serial-reconnect", from: "master" }) ``` -**工作树绝不能建在仓库内部**(`STM32F4-Base/` 里):那会变成未跟踪目录,污染 `git status`、 -`pre-commit --all-files` 与全库搜索。故统一放仓库同级的 `_wt/`。 +- `name` 同时作为新分支名和目录名,使用描述任务的短横线语义名;不要省略成难辨认的自动随机名。 +- `from` 必须是本地已有的提交、分支或标签;插件不会替你 `fetch`。通常显式写 `master`,避免从别的任务树继续叠基线。 +- 成功后保存返回的 `path`、`branch`、`baseCommit` 和 `repositoryRoot`。会话目录已经切到返回的 `path`,后续编辑与测试都在那里进行。 +- 暂存、未暂存、未跟踪及被忽略的源文件**不会**带进新树。若任务依赖主树未提交内容,先明确其归属与提交安排,不要假设插件会复制。 +- 离开只表示切换会话目录,**不删除目录和分支**;本版工具不负责合并或最终回收。 -### 3.2 共享 vs 独占 +### 2.2 补共享联接 -| 资源 | 归属 | 原因 | -| ------------------------------------------------------------------ | ---------------- | ------------------------------------------------------------- | -| `.git/` 对象库 + hooks | **共享**(git 机制自动) | 工作树共享 common dir,提交门禁无需在新空间重装 | -| `.venv/` | **共享**(junction) | 体积大、内容与任务无关;每任务重建一份纯属浪费 | -| `simulator/SDL2/` | **共享**(junction) | 下载型 SDK,与任务无关 | -| `.workbuddy-ai/` | **独占**(刻意不共享) | ① 避免并发写同一份日志丢更新;② 不给联接递归删除的机会——它是本项目最不可再生的目录。任务侧记忆由清场前归并回主工作树 | -| `MDK-ARM/` 构建产物、`simulator/build/` | **独占** | 正是需要隔离的部分:两个任务可各自构建互不干扰 | -| `.serialtest/config.json`、`.dsh/skills/`、`.pre-commit-config.yaml` | **独占但内容一致** | 受版本控制,随检出白拿,无需 bootstrap | -| `.mnemon/`、`ci-artifacts/` | **独占** | 本地运行态,gitignored | +插件只检出受控文件,因此新树没有本机的 `.venv/` 与 `simulator/SDL2/`。在主树执行: -### 3.3 junction 规则(**不能用 symlink**) +```bash +PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktree.py" +"$PY" "$TW" plugin list +"$PY" "$TW" plugin adopt fix-serial-reconnect +"$PY" "$TW" lock status +``` -本仓库 `core.symlinks=false`:git 里的符号链接会被检出成「内容是目标路径的普通文本文件」, -不是链接。因此**共享 `.venv` 只能靠 Windows 目录联接(junction)**,且 junction 必须创建在 -**版本控制之外**(`_wt/-/` 里的那几个路径都不在 git 索引内,安全)。 +`plugin adopt` 只补缺失的目录联接,不覆盖已有实体目录,不修改任务源码。命令返回后必须确认两项都不再缺失。 -创建方式用 `mklink /J`(**不需要管理员权限**,也不同于符号链接)。实测(2026-10-12): -用 ctypes 直调 `DeviceIoControl(FSCTL_SET_REPARSE_POINT)` 会被系统拒绝(Win32 4392), -缓冲区经 hexdump 逐字节核对无误仍失败,而 `mklink /J` 正常 → 采用后者;参数以**列表** -交给 subprocess,含空格的路径(本项目 `DTU Code`)由 subprocess 自动加引号。 - -**摘除联接必须用 `os.rmdir`**(= `RemoveDirectoryW`,只摘链不递归)。实测 `.venv` 条目数 -摘除前后一致(2809 → 2809)。**清场顺序铁律:先摘联接,再 `git worktree remove`**—— -否则 git 可能递归进联接,把主工作树的 `.venv` / SDL2 一起删掉。 - -> ⚠ 不要在仓库里提交任何指向 `../` 的 symlink 来实现共享——在本机就是坏文件。 -> ⚠ MSYS 的 `rmdir` 拒绝摘除联接(把它当符号链接),手工清场要用 Python 的 `os.rmdir` -> 或直接走本 skill 的 `abort` / `done`。 - -### 3.4 与 DSH worktree 插件的边界(2026-10-29) - -本仓现在有**两套**隔离空间并存,这是**有意接受**的现状,不是待清理的重复建设: - -| 维度 | 本方案(`task-worktree` skill) | DSH 插件 `dsh-task-worktree` | +| 联接 | 需求级别 | 缺失后果 | |---|---|---| -| 树的位置 | 仓库外 `../_wt/`(§3.1) | 仓库内 `.dsh-worktrees/worktree/`(插件写死,不可配置) | -| 面向的粒度 | 任务(周级、要合并回主干、要过门禁) | 会话(一次对话一个空间,零命令起步) | -| 生命周期属主 | 本 skill 的 `new`/`done`/`abort` + `_wt/.state/` 台账 | 插件自己的 `manifest.json` + `/worktree` 收尾命令 | -| 分支命名 | `task/-` | `worktree/` | +| `simulator/SDL2` | 构建模拟器的硬依赖 | 模拟器配置直接失败;该路径由构建文件固定,没有环境变量替代入口 | +| `.venv` | 有替代入口,但默认应共享 | 主机侧检查可能回退到机器级 Python,缺依赖后产生与代码无关的门禁失败 | -两条**并发红线由本方案单方守住**(插件不感知):`build`(UV4 单实例)与 `bench`(COM6 / ST-LINK / -设备 IP 唯一)这两把锁的位置与 TTL 口径见 §7.1 / §7.2——插件树里跑 `/keil-build` 时,`keil_build.py` -会照旧自持自释,持有者名从 cwd 反推为 `pw:`;`bench` 仍须手工占同一个名。共享联接(§3.3) -插件不会建,由本 skill 的 `plugin adopt ` 补(执行体命令行见 §8)。 +目录联接只共享这些与任务无关的大目录。源码、Git 索引、Keil/模拟器构建产物、日志和 `ci-artifacts/` 仍归各树独占。 +`.workbuddy-ai/` 与 `.mnemon/` 不做跨树共享,避免并发写同一份本机状态。 -**为什么允许它在仓内破例**(与 §10「绝不在仓库内部」的口径冲突处):路径根写死在插件里,且暴露给 -模型的工具只有 create/list/status 三个。兜法是把该目录登记进**全库扫描**的排除面(`check_doc_links` -的 `SKIP_PARTS`、`gen_refgraph` 的 `EXCLUDE_DIRNAMES`、`check_layer_deps` 的 `os.walk` 兜底)并在 -`.gitignore` 里**预置**同名规则(否则插件首次建树时会往 `.gitignore` 追加一行,等于会话中途改受控文件)。 -操作层的命令与判据 → `.dsh/skills/task-worktree/SKILL.md` F 节(本文只记边界,不重述步骤)。 +清场前必须先摘联接,再处理工作树目录;否则目录删除工具可能沿联接进入主树,误删共享环境。手工摘除只可用 +`os.rmdir` 这类「只删联接本身」的操作,禁止对联接目录做递归删除。 - ---- - -## 4. 分支模型 - -| 项 | 取值 | -| -------- | ---------------------------------------------------------------------------------------------------- | -| 集成分支(主干) | `v2-on-ui` | -| 任务分支 | `task/-`,从主干当前 HEAD 切出 | -| `` | **语义短语**(如 `wave-replay-fix`),对应 `docs/任务规划/README.md` §2 顺序表里的那一行;纯临时任务用 `<日期>-` | -| 合并方式 | `git merge --no-ff` 合入主干 | -| 并发上限 | **同时最多 8 棵登记工作树**(2026-10-29 由 3 上调,用户裁定;另有插件树不计入,见 §3.4) | - -**`` 用语义短语**是为了可追溯:任务分支名直接对应待办条目,回收时销项有据可查。 - -**并发上限的依据**(2026-10-29 由 3 上调到 8,用户裁定;脚本常量 `MAX_CONCURRENT`): -磁盘从来不是这条线的硬约束——实测一棵纯净检出 **84.0 MiB / 2857 个受控文件**(含 LFS smudge -后的真图;`docs/` 独占 46 MiB),而 `.venv` 与 `simulator/SDL2` 走目录联接共享、不随树复制;同日 D -盘余 1.4 TB,**8 棵全开的最坏估算也只有约 3.2 GB**(每棵再叠一次构建产物:Keil 中间件 289 MB + 模拟器 35 MB)。 -真正随并行数上涨的是**排队与合并**:`/keil-build` 与台架 `/serial-boot-test` 各被一把独占锁串行化(见 §5),`done` -的合并必须串行(§5 下方说明),而每多一棵树把 `sync` 追平与冲突面乘一遍。所以这条线现在是**管 -理半径闸门**而非磁盘闸门:调到 8 = 把「同时盯几棵」交回人,两把锁的排队代价用 `$TW lock status` 与 -§7 的台架台账观测;脑子里的名额够不够,看的是锁队列长度,不是磁盘。 - ---- - -## 5. 任务生命周期 - -| 阶段 | 动作 | 并行性 | -| ------ | -------------------------------------------- | ----------- | -| `new` | 建分支 + 工作树 + junction + 台账 | 可并行 | -| 编码 | 在新空间里改代码 | 可并行 | -| 单测产物 | `%TEMP%\dtu_host\<工作树哈希>\` 分家 + 每跑独占 `runs\-\` | **不需要锁,但必须按树分家**(旧固定共享名跨树互踩:A 树可能执行 B 树编出的同名 exe,summary 相同不可辨;build/bench 是真独占,继续用锁) | -| 门禁 | `scripts/ci_local.py`(静态门禁 + 主机侧纯逻辑单测,纯软件) | **可并行** | -| 构建 | `/keil-build`(UV4 增量,需持 `build` 锁) | **必须串行** | -| 台架 | `/serial-boot-test` / 板级套件(需先持台架锁,见 §7) | **必须串行** | -| 提交 | 本地 `git commit`(多行正文用 `-F `) | 可并行 | -| `sync` | `rebase` 追平主干,把冲突控制在任务分支上 | 可并行 | -| `done` | 预演 → 合入主干 → 残留审计 → 重生成产物 → 门禁 → 清理工作树 | **必须串行** | -| `push` | 推送主干/任务分支 | **需用户明确许可** | - -**为什么 `done` 必须串行**:合并动作会移动主干引用,两处同时合入会互相覆盖;且主干只在主工作树检出。 - ---- - -## 6. 冲突治理 - -### 6.1 单写者文档 - -并发失败通常不是代码冲突,而是**多个任务同时改同一个共享索引**。以下文件在任务期间 -**禁止在任务工作树里修改**,一律由 `done` 回收阶段在主工作树统一处理: - -| 文件 | 冲突原因 | 回收时的处理 | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `docs/任务规划/`(整个文件夹) | 全项目顺序唯一权威,任务销项时要改 | 由主工作树统一改,任务分支不碰 | -| `AGENTS.md`、`docs/README.md`(索引/登记类) | 每个任务都要在 AGENTS.md 的「按主题查哪份文档」表、`docs/README.md` §9.2 环境表、`docs/README.md` 的文档索引表里**各加一行** → 同一张表的新增行两任务必撞(2026-10-12 实测:主工作树里本任务的行与他任务的行已并排混在**同一 hunk**)。变更记录那一路**不再是冲突面**:2026-10-28 起新文档一律不设变更记录节(`docs/文档规范.md` §5.1),存量也只剩 5 篇台账/证据文档 | 由主工作树统一追加;提交时**只 `git add` 本任务的行**(`git apply --cached` 选择性入库),勿整文件 `git add` | -| `AGENTS.md` 的 `TASK_TABLE` 块 | 生成物,两个任务都抬栈就冲突 | 合并后重跑 `scripts/gen_task_table.py` **重新生成**,不手工并 | -| `MDK-ARM/STM32F407-Demo.uvprojx` | 新源文件注册 + UV4 自动改写 | 手工并 `` 条目 → 重跑 `registration-check` → `/keil-build` | -| `.pre-commit-config.yaml` | 新增主机单测要加钩子 | 并集处理 | -| `docs/内存管理/RAM使用分析与优化方案.md`(及其 `mem-numbers` 快照) | `mem-numbers` 快照 | **绝不手工合数字**,合并后 `scripts/check_mem_numbers.py --write` 重生成 | - -### 6.2 合并残留审计(**必做**) - -双方都改过的文件(尤其 `.gitignore`)会被 git 自动并成**并集**,可能留下: - -- **死规则**:指向已删目录的忽略项 -- **重复条目**:同一规则出现两次 - -判据 = 拿 base / 两个 tip **三方对比**(`git show :` 逐项 `grep -F`), -**不要用 `tail -N` 目测**(会漏看被合并在中间的行),也**不要把合并前就存在的历史重复算进来**。 - -### 6.3 冲突时的解决位置 - -`done` 先做**非破坏性预演**:`git merge-tree --write-tree v2-on-ui task/-`。 - -- `rc=0`(无冲突)→ 继续合并 -- `rc≠0` → **在任务分支上** `git merge v2-on-ui` 解决冲突并提交,再重跑 `done` - -在一次性分支上解冲突,**永不污染主干**。 - ---- - -## 7. 资源串行化 - -### 7.1 台架锁 - -以下物理资源**各只有一份**,故 `/serial-boot-test` / 板级套件 / 烧录必须串行: - -- 串口 `COM6` @115200 -- ST-LINK SN `0B250D12A218303030303032` -- 设备 IP `192.168.31.100` - -机制:`_wt/.state/locks/{build,bench}.lock` 记录持有者(任务 ID + pid + 时间戳 + 备注 + 自报 TTL)。 - -**锁不绑定进程存活**:`lock acquire` 本身是短命进程,而真正用资源的烧录/构建在另一个进程里跑。 -若按 pid 判活,acquire 一退出锁就被判为陈旧 → 锁形同虚设(实测踩到)。 -故判「陈旧」只看**时效**(该锁的 TTL)加显式 `--force`;pid 仅作排障记录。 -同任务重复 acquire 幂等;他人持锁时 acquire 拒绝(`--wait` 可轮询等待)。 - -**TTL 按锁分设**(2026-10-12):`LOCK_TTL_HOURS = {"build": 1.0, "bench": 6.0}`。 -口径 = 兜底窗口应与**操作真实时长**同量级:编译是秒~分钟级(`keil_build.py` 自持自释, -窗口 ≈ UV4 运行时长),1h 已足够宽松;bench 是**会话级**(flash→`/serial-boot-test`→探针),才配得上 6h。 -持有者还可用 `lock acquire ... --ttl <小时>` **自报**更精确的窗口—— -`keil_build.py` 即按其 `--timeout` 自报(`timeout/3600 + 15min`,下限 0.5h)。 - -`done` 前置检查:**陈旧锁自动接管释放**(不再硬失败),仅当锁仍被有效占用时才拒绝回收。 - -> ⚠ 台架相关的既有陷阱(`--require-selftest-pass` 的陈旧台账、回读校验会复位目标) -> 不因引入工作树而改变,仍按 `docs/测试说明/流程与规范/自动化测试流程.md` 执行。 - -### 7.2 构建 - -`/keil-build`(UV4 增量)在新空间里是**独占目录**,但 **UV4 不能两个实例同时 `-b`**(2026-10-12 用户确认, -无需实测)——因此构建**同样纳入串行锁**(`build`),与台架锁并列。 - -> 结论:任务期真正可并行的只有**编码与静态门禁**(`ci_local.py` 的静态门禁 / 主机侧纯逻辑单测,纯软件、零硬件); -> `/keil-build` 与烧录/台架都必须排队。 - -### 7.3 并行道与写范围规则(规划层 ↔ 执行层映射) - -规划层(全项目顺序在 `docs/任务规划/README.md` §2,设计类待办在各模块文档); -五字段 = **DoD / 硬依赖 / 资源 / 写范围 / 状态**) -按**资源属性**把任务切进三条道,本方案(工作树 + 锁)是它的执行层: - -| 道 | 资源 | 并行性 | 锁 / 机制 | -|---|---|---|---| -| **台架道** | COM6 / ST-LINK / 设备(§7.1) | **串行**——约束资源,**永不空转**(约束优先,TOC):代码道在跑时必须为下一个台架任务备料 | `bench.lock` | -| **代码道** | CPU / 源码 | **并行**——worktree 独立空间;前提 = 写范围互不相交 + 强门禁(`ci_local` 跑满声明的 52 根、含 manual 段,口径 → `docs/门禁说明/README.md` §2;同 Linux 内核子系统树并行而能合回的逻辑,见 `docs.kernel.org/process/2.Process.html` 的 subsystem tree 模型) | 无锁;分支 ≤2 天、绿即合(trunk-based 短分支) | -| **外部窗口** | 标准源 / 第三方主站等外部设备 | **Fixed Date 类**(Kanban 服务分类):窗口一到全道让路;窗口前 2 日特性冻结(只修稳定,Zephyr RC 语义 → `docs.zephyrproject.org/latest/project/release_process.html`) | `bench.lock` + 外部预约 | - -**四条规则**: - -1. **WIP ≤ 2**:每道最多 1 个进行中(典型 = 台架道 1 + 代码道 1),先完再开——进度需可见 - `⬜ 未开始 / 🟦 进行中` 两态,销项仍删行。 -2. **写范围相交 = 声明合并次序**:任务卡必须写写范围(目录级);相交不排队等待,而是**声明谁先合** - (后合者在主工作树 rebase 后继续),claim 时定。 -3. **分支存活 ≤2 天、绿即合**:合并走 `done` 预演(§6.3);单写者文档(§6.1)仍由主工作树统一处理。 -4. **里程碑不拖延、功能顺延下一轮**(时间驱动):里程碑的退出判据写在任务所属文档,判据不满足则该**功能** - 移入下一波次,里程碑本身按判据改期而非无限等待。 - -> 规划层唯一权威仍是 `docs/任务规划/README.md` §2;本节只定义执行层映射,**不在本文排任务顺序**。 - ---- - -## 8. SKILL 设计 - -**名称**:`task-worktree` **文档别名**:`@task`——**只是文档记号,没有任何执行机制**:shell 里敲 `@task` 直接 command not found,`dsh task` 会被当 profile 名报错;DSH 会话里的显式调用是 `/task-worktree`,可执行形态只有下面的执行体命令行。 - -**执行体**:`.dsh/skills/task-worktree/scripts/task_worktree.py`(纯标准库, -照 `keil_build.py` 的写法,整目录可拷到别的项目复用)。**已落地并端到端实测**(见 §11)。 - -| 子命令 | 作用 | -| ----------------------------------------------------- | ----------------------------------------------------------- | -| `new [--base B] [--lean] [--needs-bench]` | 建分支 `task/-` + `_wt/-/` 工作树 + 共享联接 + 台账 | -| `list` | 所有任务一览:分支 / 落后领先 / 脏文件数 / 台架状态 + 两个锁的归属 | -| `status ` | 单任务详情:未提交清单、相对基线漂移、台架记录、已建联接 | -| `sync ` | `rebase` 追平主干,把冲突控制在任务分支上 | -| `lock acquire\|release\|status ` | 独占资源锁(`--task` / `--wait` / `--force` / `--bench-pass`) | -| `done [--dry-run] [--skip-build] [--skip-gates]` | 预演 → 合入主干 → 残留审计 → 重生成产物 → 门禁 → 清场 | -| `abort [--force]` | 放弃任务(有未提交/未合并内容时不加 `--force` 会拒绝) | - -**环境变量**:`DTU_WT_ROOT`(任务容器位置)· `DTU_TASK_BASE`(默认基线)· -`DTU_INTEGRATION_WORKTREE`(集成点,默认主工作树;用于隔离演练/测试)。 - -**关键约束**: - -1. **`done` 只能在主工作树执行**——主干 `v2-on-ui` 检出在主工作树,git 不允许同一分支在两处检出。 -2. **`done` 前置检查主工作树干净**,脏则中止并提示(避免把未提交工作卷进合并)。 -3. **`done` 不含 `push`**:结束于本地合入 + 门禁全绿,打印提示等用户点头。 -4. `--needs-bench` 任务若没有台架 PASS 戳,`done` 拒绝合并。 -5. 门禁未全绿时 **`done` 保留工作树与分支**(只留下合并提交),便于修正或回退——不静默清场。 -6. 清场顺序:**先摘联接,再 `git worktree remove`**(见 3.3)。 - ---- - -## 9. 门禁与验收 - -| 层级 | 内容 | 时机 | -| ------- | ------------------------------------ | ------------------------------ | -| 任务期 | `scripts/ci_local.py`(静态门禁 + 主机侧纯逻辑单测,可并行) | 每个任务各自跑 | -| 任务期 | `/keil-build`(0 错 0 警,**持 `build` 锁串行**) | 每个任务各自跑 | -| **新空间首次** | `/keil-build` 一次,生成 `.map` | 见下表下注释:不先构建,体积类门禁必然红 | -| 任务期(可选) | `/serial-boot-test` / 板级套件(持 `bench` 锁) | 需真机验证的任务 | -| 回收期 | 重生成产物 → `ci_local.py` → `/keil-build` | `done` 内强制(`--skip-gates` 可跳过) | -| 人工 | push 许可 | 回收后 | - -> ⚠ **新开工作树后先 `/keil-build` 一次再谈门禁**(实测 2026-10-25):`.map` 是每棵树各自的构建产物、 -> 不入版本库,所以依赖它的事实源的门禁——`mem-numbers`、`mem-budget`、以及 `ci_local.py` 的 -> 体积门禁——在从未构建过的新空间里**必然红**。这不是代码缺陷,脚本此时也会把这句原因打出来 -> (`check_mem_numbers.py` / `ram_baseline.py` 的提示文案)。顺带一条同源提醒:`done` 的门禁 -> 排在 `/keil-build` 之前(见下文陷阱条目),同一族误报在回收期也会重现一次。 - -**`done` 的完成判据**:主干上 `git merge --no-ff` 成功 + `ci_local.py` 全绿 + `/keil-build` 0 错 0 警 - -- 残留审计无死规则/重复条目 + 工作树已移除。 - ---- - -## 10. 已知风险与待验证项 - -| 项 | 状态 | 处置 | -| -------------------------------------------- | --------------------- | ------------------------------------------- | -| UV4 能否并发构建 | **已确认不可**(2026-10-12) | `build` 与 `bench` 一并纳入串行锁 | -| 跳过 Git LFS smudge 是否影响门禁 | **已实测**(2026-10-12) | 见下表:只省磁盘、不省时间,两种方式工作区都干净 → 默认全量,`--lean` 备选 | -| 主工作树当前不干净 | 已知 | `done` 的前置检查会拦下 | -| 遗留 worktree(`_v1pin-build` / `_v2oui-build`) | 已知 | 落地时 `git worktree remove` 清理,避免新旧两套并存 | -| **「主工作树绿、干净工作树红」的隐藏依赖** | **已实证**(2026-10-12) | 见 10.2。属**原任务**漏提交,非本 skill 缺陷 | -| `git worktree remove` 中途被中断 → 残留「裸目录」 | **已实证**(2026-10-12) | 见下方 10.3 | - -### 10.1 LFS 实测数据(2026-10-12,2396 个受控文件 / 707 个 LFS `*.jpg`) - -| 方式 | 建树耗时 | 检出体积 | `git status` | 参考图 | -| --------------------------------- | ------ | ----- | ------------ | ---------- | -| 全量 smudge(默认) | 7.25 s | 83 MB | 干净 | 真实 JPEG,可看 | -| `--lean`(`GIT_LFS_SKIP_SMUDGE=1`) | 7.05 s | 45 MB | 干净 | 指针文本 | - -**结论**:LFS 对象已在本地,smudge 几乎不耗时,跳过**只省约 38 MB 磁盘**。 -故默认走全量(保留参考图可读性),磁盘紧张时才用 `--lean`。 -另外两种方式在全新工作树里跑 `pre-commit`(含 `doc-links`)都**正常通过**, -证明 hooks 经 common dir 共享、门禁不因工作树而失效。 - -### 10.2 工作树是「提交物自洽性」的照妖镜(2026-10-12 实证) - -演练中 `done` 的门禁在干净工作树里报出 2 处失效引用,而**主工作树里同一门禁是绿的**: +## 3. 目录与分支 ```text -docs\内存管理\RAM使用分析与优化方案.md:277 [路径引用] scripts/ram_baseline.tsv -docs\内存管理\RAM使用分析与优化方案.md:585 [路径引用] docs/内存管理/RAM预算台账.md +STM32F4-Base/ # 主工作树,master 的唯一集成点 +├── .agents/worktrees// # 插件树,目录内 .gitignore 自我忽略 +├── .dsh-worktrees/worktree// # 旧插件遗留位置;存在时继续受桥接与扫描排除保护 +├── .venv/ # 唯一 Python 实体环境 +├── simulator/SDL2/ # 唯一模拟器 SDK 实体 +└── MDK-ARM/ # 主树自己的构建产物 ``` -根因:该文档**已提交且无改动**,但它引用的这两个文件**从未 `git add`**(属另一在途任务), -只在作者的主工作树磁盘上存在 → `doc-links` 查的是**文件系统**,于是在主工作树里通过; -一旦换到干净工作树(本演练的 `_probe-base2`),文件不存在 → 必然 FAIL。 +| 项 | 说明 | +|---|---| +| 默认位置 | 仓内 `.agents/worktrees/` | +| 分支 | 与 `name` 同名 | +| 基线 | 调用时固定到本地提交 | +| 共享联接 | `plugin adopt` 补建 | +| 台架验收记录 | git notes / reflog,由 `bench-gate` 读取 | +| 合并 | `task-worktree merge ` 编排 | +| 活跃数量 | 不设上限;实际受磁盘、构建排队、台架时间与人的跟踪精力约束 | -**判据与处置**:这不是本 skill 的缺陷,而是被工作树**逼出来的真问题**—— -「一个提交是否自洽」不能靠「在我的工作目录里能过」来证明。修复归属**引用方任务** -(补 `git add`,或给引用加 `[计划]` 前缀 / 写明「已删除」状态词,见 `docs/文档规范.md`)。 +插件树位于仓内是受控例外,不代表可以任意在仓库里建副本。其目录池由插件写入自己的忽略文件;文档链接、引用关系图等全库扫描还必须排除这些目录,避免把整份仓库副本当成新内容。新增或升级插件后,要复核实际目录与扫描排除面是否仍一致。 -**副作用提醒**:这同时说明**共享主工作树里的绿门禁不可尽信**——只要有未提交文件躺在磁盘上, -门禁就可能对着「未提交的现状」放行。多任务并行期更该用工作树 + `done` 复核。 +## 4. 从创建到交付的完整流程 -### 10.3 `git worktree remove` 中断会留下「裸目录」(2026-10-12 实证) +### 4.1 创建与开发 -清理遗留 worktree 时踩到:`git worktree remove --force` 被中断后,`_v2oui-build` 的 `.git` **已被删除** -(于是 `git worktree list` 显示 `prunable`),但目录里**还躺着 2774 个条目**没删完。 +1. 确认任务基线是 `master` 的本地提交,并为任务写语义名。 +2. 用 `create_worktree` 创建并进入。 +3. 补两项共享联接;新树按需先建 `ci-artifacts/`,一次性输出都放这里。 +4. 只在任务树中编辑。提交前完成该任务应有的文档检查、主机侧单测、`/keil-build` 和台架验证。 +5. 提交仍须用户许可。属主完成应交付的提交后,才允许进入合并。 -**判据**:看到 `prunable` + 目录仍存在 = git 已删掉 gitfile、删除未完成。此时那棵树**已不再是 worktree** -(git 不认它了),`git worktree remove` 无法再用,只能按普通目录删除。 +### 4.2 共享资源排队 - -**处置**:`git worktree prune` 清台账 → 手工删目录。删之前必须**重新扫一遍 reparse**—— -理由它有 `.git` 时没有联接,不等于现在没有(尤其别让他人趁乱建了联接)。 -清除手段用 Python 自底向上删并顺带 `chmod +w`(MDK 产物有 2 个只读文件), -**切忌 `rm -rf`**(MSYS 会递归进联接,实测见 3.3)。 - -**已知不做**:不引入第二套并行机制(如 git submodule / 多克隆), -保持「同一能力只有一个实现」的收敛原则。 - ---- - -## 11. 落地清单与实测记录 - -| 动作 | 目标 | 状态 | +| 工作 | 并行规则 | 操作口径 | |---|---|---| -| 新建 | `.dsh/skills/task-worktree/SKILL.md`(含 `name` / `description` / `whenToUse` frontmatter) | ✅ | -| 新建 | `.dsh/skills/task-worktree/scripts/task_worktree.py`(纯标准库) | ✅ | -| 新建 | `.dsh/skills/task-worktree/.gitignore` | ✅ | -| 修改 | `AGENTS.md`「按主题查哪份文档」加一行指向本文档 | ✅ | -| 修改 | `docs/README.md` §9.2 登记本文档 | ✅ | -| 清理 | 移除遗留 worktree `_v1pin-build` / `_v2oui-build` | ✅ 2026-10-12 已清理(脏改动先留档,见 §2) | -| 验证 | §10 的两个待验证项 | ✅ 见 §10 / §10.1 | +| 编码、彼此独立的静态检查、主机侧单测 | 可并行 | 主机测试临时目录必须按工作树分家,不能共用固定构建目录 | +| `/keil-build` | 串行 | `keil_build.py` 自动占放 `build` 锁,调用者不要再手工包一层 | +| 烧录、串口启动回归、板级套件 | 串行 | 先占 `bench`,结束后用 `--bench-pass` 释放以记录验收 | +| 合并回 `master` | 串行且只在主树 | 主干引用和主树构建产物都是单点 | +| `push` | 不在自动流程内 | 必须另获用户许可 | -### 11.1 端到端实测记录(2026-10-12,隔离集成点演练) +插件树的锁持有者写作 `pw:`。上台架前后使用同一个名字: -用 `DTU_INTEGRATION_WORKTREE` 把集成点指到一次性工作树、基线用一次性分支, -**全程未触碰真实主线 `v2-on-ui`**: +```bash +"$PY" "$TW" lock acquire bench --task pw:fix-serial-reconnect --note "串口重连回归" +# 烧录与台架验证 +"$PY" "$TW" lock release bench --task pw:fix-serial-reconnect --bench-pass +``` -| 场景 | 结果 | +锁按有效期兜底,不以占锁命令的进程是否存活判定。不要用删锁文件替代正常释放;不要强行接管仍被他人有效占用的锁。 + +### 4.3 台架验收门禁 + +需要真机验收的任务,在合并前运行: + +```bash +"$PY" "$TW" bench-gate fix-serial-reconnect +``` + +该命令检查分支 `fix-serial-reconnect` 是否有对应的 `bench-pass` 记录(由 `lock release --bench-pass` 写入)。无记录则拒绝合并并提示补做台架验证。纯文档、纯主机侧测试等不需要台架验收的任务可跳过此步。 + +### 4.4 编排式合并 + +```bash +"$PY" "$TW" merge fix-serial-reconnect +``` + +该命令在主树执行完整收尾编排:非破坏性预演 → `git merge --no-ff` → 重生成受影响的生成物 → 全量门禁 → 安全清场(摘联接、删目录、删分支)。有冲突时中止并提示回任务分支解决,不在主树随手改造成另一份实现。 + +也可手动分步执行:先 `bench-gate`,再 `git merge --no-ff`,再跑门禁与清场。编排式合并只是把这些步骤串起来,不引入额外状态。 + +**合并落主干不等于验证结束。** 合并提交期的钩子可能只检查冲突文件;合并后仍要在主树跑全量静态检查、主机侧单测与 `/keil-build`,并重算确实受影响的生成物或基线。 + +### 4.5 回收 + +1. 确认任务分支已合并,或用户明确决定放弃;未提交与未跟踪内容必须逐项判定,不能只看最后一次提交。 +2. 先 `plugin unadopt ` 摘共享联接;再按 `task-worktree` 的「非登记工作树安全回收」规程处理目录与分支。 +3. 最后复核 `git worktree list` 和 `lock status`;以主干提交记录证明完成,不以目录消失代替合并证明。 + +## 5. 冲突与单写者规则 + +工作树隔离的是目录,不会消除两个任务改同一文件时的语义冲突。任务开始前先划分写范围;相交时明确谁先合,后合者追平主干并重新验证。 + +| 文件或目录 | 任务期规则 | 合并后处理 | +|---|---|---| +| `docs/任务规划/` | 默认只由主树写;任务树不并发销项 | 主树统一更新,完成即删对应待办行 | +| `AGENTS.md`、`docs/README.md` 的索引区 | 避免多个任务同时追加同一索引块 | 主树统一登记;只提交本任务应带的改动 | +| FreeRTOS 任务表、内存快照与体积凭证 | 不手工拼合两份生成结果 | 基于合并后的真实源码和最新构建重生成 | +| `MDK-ARM/*.uvprojx` | 新源文件按双端注册规程改;UV4 自动抖动不入库 | 检查注册并重新构建 | +| `.pre-commit-config.yaml` | 新增钩子或检查范围时声明合并次序 | 合并后跑全量门禁,不能只信任务树结果 | + +双方都改忽略规则或清单时,还要做三方残留审计:去掉失效规则与本次引入的重复项,不把历史既有重复误算成本次合并结果。 + +## 6. 容量与验证边界 + +活跃工作树不设数量上限。实际约束是磁盘、构建排队、台架时间与人的跟踪精力——这些才是真上限,数字只是表象。 + +每棵树的源码与构建产物独立;`.venv` 和 SDL2 共享。Git LFS 的实际文件不会因为「属于同一仓库」就自动出现在插件新树中:现役插件检出时跳过过滤器,可能留下指针对象。需要读取参考图或执行依赖真实二进制内容的检查时,先确认该树拿到的是实际内容,不要把「检出成功」当成「依赖齐全」。 + +新树首次运行内存与体积门禁前必须先构建,因为映射文件不是受控文件。树内全库扫描若误穿共享联接或仓内工作树副本,先修扫描边界并区分真问题,不能靠忽略输出收口。 + +## 7. 权威分工与外部参考 + +| 内容 | 唯一操作入口 | |---|---| -| `new` 建树 + 双联接(`.venv` / `simulator/SDL2`) | ✅ 透过联接跑解释器得 `Python 3.12.4` | -| 新工作树里跑 `pre-commit`(共享 hooks) | ✅ `doc-links` 等通过;gitlint 也正常拦下非法邮箱 | -| `list` / `status` | ✅ 落后领先、脏文件数、联接清单、锁归属均正确 | -| `lock` acquire / 冲突拒绝 / 幂等 / 错误持有者拒绝 release | ✅ 期望的 rc 全部命中 | -| `done --dry-run` 无冲突 | ✅ 输出预演结果树,零改动 | -| `done --dry-run` 有冲突 | ✅ 拒绝合并(rc=1),提示到任务分支上解冲突 | -| `done` 完整回收(合并 + 审计 + 重生成 + 清场) | ✅ 合并提交生成合规(标题 40 字符 ≤100、正文 ≤80) | -| 清场安全性 | ✅ 联接摘除前后 `.venv` 文件数 2809 → 2809,解释器完好 | -| `abort` 未加 `--force`(有未合并提交) | ✅ 拒绝(rc=1)并列出将丢弃的内容 | -| `abort --force` | ✅ 联接摘除、工作树移除、分支删除、`.venv` 2809 → 2809 | +| 锁、bench-gate、merge、非登记树安全回收 | `/task-worktree` 及其 `reference/` 深规程 | +| Keil 构建与 `build` 锁细节 | `/keil-build` | +| 烧录与串口启动回归 | `/stlink-flash`、`/serial-boot-test` | +| 文档检查与提交规范 | `docs/文档规范.md`、`docs/代码规范/pre-commit使用说明.md` | +| 任务推进顺序 | `docs/任务规划/README.md` | -**实测暴露并已修复的 4 个缺陷**: +现役插件行为核对来源: -1. **锁绑定进程存活 → 形同虚设**:`lock acquire` 是短命进程,一退出锁就被判陈旧、下次必被接管。 - 改为**时效判定**(当时全局 6h;2026-10-12 起改为按锁分设 TTL,见 §7.1)+ 显式 `--force`,pid 仅作记录。 -2. **`git branch -d` 按环境 HEAD 判「已合并」**:集成点被改走时误判、拒绝删除。 - 改为显式 `merge-base --is-ancestor ` 后再删。 -3. **直调 Win32 建联接被拒**(Win32 4392):改走 `mklink /J`(详见 3.3)。 -4. **`done` 门禁跑错工作树**(最隐蔽的一个):`run_script` 原用 `REPO_ROOT / script` 拼脚本路径, - 而 `REPO_ROOT` 由 `task_worktree.py` 自身位置反推 = **主工作树**。于是 `done` 本意是在集成 - 工作树里校验,实际却跑了主工作树里的同名脚本;该脚本再按它自己的 `__file__` 反推,又去校验 - **主工作树**——校验对象整体错位。 - 证据(回归前/后对照):直接在工作树里跑 `ci_local.py` 报「扫描 .c: 68 / PASS」,经 `done` 调用 - 却报「扫描 .c: 71 / FAIL」并附 `[GAP] App/protocol/iec104/*.c | 缺 uvprojx`(那 3 个文件只以 - **未跟踪**状态存在于主工作树)。修法 = 从传入的 `cwd`(= 集成工作树)解析脚本路径 - (`scripts/ci_local.py` L46 / `scripts/check_registration.py` L52 的 `__file__` 反推是既定约定, - 脚本必须由**目标树**里的那一份执行)。修复后同一条命令的 - `双端注册反向校验 (uvprojx + CMakeLists)` 由 FAIL 转 **Passed**。 - -> 缺陷 4 的教训具有普适性:**任何「按 `__file__` 反推仓库根」的项目脚本,都必须由目标工作树里 -> 的那一份来执行**,否则门禁会静默地校验另一棵树。本 skill 的 `run_script` 已统一按 `cwd` 解析。 - ---- - -## 12. 关联文档 - -| 主题 | 去处 | -|---|---| -| 提交门禁钩子逐项详解 | `docs/代码规范/pre-commit使用说明.md` | -| 自动化测试与台架流程 | `docs/测试说明/流程与规范/自动化测试流程.md` | -| 文档写作规范(编码/链接/登记) | `docs/文档规范.md` | -| 工具路径环境变量(换机) | `docs/环境与部署/本地工具路径环境变量方案.md` | -| 待办与排序 | `docs/任务规划/README.md` | +- [DSH 工具目录:create_worktree](https://deepseek-harness.github.io/deepseek-harness/en/reference/tool-catalog) +- 本机包说明:`@deepseek-ai/dsh-experimental-tool-worktree` 与 `@deepseek-ai/dsh-experimental-worktree` 0.2.2-rc.1 diff --git a/docs/环境与部署/本地工具路径环境变量方案.md b/docs/环境与部署/本地工具路径环境变量方案.md index b7c45f33..13c7acf6 100644 --- a/docs/环境与部署/本地工具路径环境变量方案.md +++ b/docs/环境与部署/本地工具路径环境变量方案.md @@ -1,7 +1,8 @@ # 本地工具路径环境变量方案 > 本文档描述项目的本机工具路径环境变量机制:变量清单、解析与失败语义、各级配置方法、验收判据 -> (2026-10-02 实施并生效;实施过程与后续变更见文末变更记录)。 +> (2026-10-02 实施并生效;2026-11-15 按当前代码实况同步:补齐控制类变量清单、修正 UV4/ST-LINK +> 解析链、更新钩子计数)。 > **约束(现行)**:只用进程/用户/CI 服务环境变量配置本机差异;不提交本机路径,不引入 `.env`/`python-dotenv`, > 不把个人工具路径写入共享 workflow;仓库内部路径继续相对脚本/仓库根解析。 @@ -9,8 +10,8 @@ | 变量 | 必需性 | 值的形态 | 使用者 | 默认/回退 | |---|---|---|---|---| -| `DTU_KEIL_UV4` | MCU 构建机必需 | UV4.exe 完整路径,例如 `D:\Tools\Keil\UV4\UV4.exe` | `keil_build.py`(`/keil-build`) | 不设即报错(exit 2);不从 C 盘旧路径猜测 | -| `DTU_STLINK_CLI` | 复位/烧录/回读机必需 | `ST-LINK_CLI.exe` 完整路径,例如 `C:\Program Files (x86)\STMicroelectronics\STM32 ST-LINK Utility\ST-LINK Utility\ST-LINK_CLI.exe` | `stlink_flash.py` | 未设时回退 PATH(`where ST-LINK_CLI.exe` 首个命中);已设但无效 = 硬报错 | +| `DTU_KEIL_UV4` | MCU 构建机必需 | UV4.exe 完整路径,例如 `D:\Tools\Keil\UV4\UV4.exe` | `keil_build.py`(`/keil-build`) | 解析链:`--uv4` > 项目档案 `.serialtest/config.json` 的 `uv4` > `$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH;全部落空即报错(exit 2),不从 C 盘旧路径猜测 | +| `DTU_STLINK_CLI` | 复位/烧录/回读机必需 | `ST-LINK_CLI.exe` 完整路径,例如 `C:\Program Files (x86)\STMicroelectronics\STM32 ST-LINK Utility\ST-LINK Utility\ST-LINK_CLI.exe` | `stlink_flash.py` | 解析链:`--cli` > 档案 `stlink_cli` > `$DSH_STLINK_CLI` > `$DTU_STLINK_CLI` > PATH;已设但无效 = 硬报错 | | `DTU_PRECOMMIT_PYTHON` | 可选(覆盖用) | 含 `pre_commit` 模块的 `python.exe` 完整路径 | `ci_local.py` 静态门禁、CI `static-gates` | 未设时按 §1.1 探测(当前解释器 → `.venv` → PATH);已设但无效 = 硬报错 | | `DTU_GRAPHVIZ_DOT` | 可选(覆盖用) | `dot.exe` 完整路径 | `gen_refgraph.py` | 未设时只查 PATH;无 Graphviz 时降级为"生成 .dot/.json、跳过渲染" | | `DTU_MSVC_CL` | 可选(覆盖用) | x64 `cl.exe` 完整路径(`bin\Hostx64\x64` 下) | `unit_test_harness.py` 主机侧纯逻辑单测、CI `host-unit-tests` | 未设时按 VS 安装根自动探测(2019/2022/2026 各 edition);已设但不符 `bin\Hostx64\x64\cl.exe` = 硬报错 | @@ -18,6 +19,26 @@ | `DTU_VSWHERE` | 可选(覆盖用) | `vswhere.exe` 完整路径 | `build_sim.ps1` | 未设时用 `%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe` 推导;已设但无效 = 硬报错 | | `DTU_CPPCHECK` | 可选(覆盖用) | `cppcheck.exe` 完整路径 | CI `static-gates` cppcheck 步骤 | 未设时用 PATH(`Get-Command`);已设但无效 = 硬报错。本地 `ci_local.py` 静态门禁不读该变量:cppcheck 钩子按 pre-commit `entry: cppcheck` 从 PATH 解析 | +### 1.2 控制与辅助变量(非「本机工具地址」,但同族登记) + +这些变量不指向可执行文件,而是给脚本传控制参数或路径覆盖;同样只在本机/CI 环境设置,不入库。 + +| 变量 | 使用者 | 语义 | +|---|---|---| +| `DTU_LOCK_HELD` | `keil_build.py` | 调用方已持 build/bench 锁时设它(值如 `build`),脚本跳过自持自释,避免自我死锁 | +| `DTU_TASK_ID` | `keil_build.py` | 显式声明 build 锁持有者名(原登记任务链的遗留入口,插件树时代一般不用) | +| `DTU_GEN_PYTHON` | `check_generated_fresh.py` | 生成器重跑用的解释器;优先级:显式变量 > 当前解释器 > 仓库 `.venv` > PATH,且必须探针校验 | +| `DTU_HOST_BUILD` / `DTU_HOST_BUILD_KEEP` | `unit_test_harness.py` | 主机单测构建目录覆盖 / 保留调试产物(默认每次随机临时目录) | +| `DTU_MS_COVERAGE` | `check_test_coverage.py` | `mscoverage.exe` 完整路径(设了就必须有效,不静默回退) | +| `DTU_REPO_ROOT` | `board_ui_nav.py` | 仓库根覆盖,便于从别的目录或工作树跑同一份取证脚本 | +| `DTU_WT_ROOT` / `DTU_TASK_BASE` / `DTU_INTEGRATION_WORKTREE` | `task_worktree.py` | 工作树根目录 / 默认基线分支 / 集成点覆盖(隔离演练用);随登记任务链退役仅剩兼容读取 | +| `DSH_SERIAL_CONFIG` | `keil_build.py`(经 serial-assistant) | 串口助手配置档案位置 | +| `DSH_UINAV_WALK` | `board_ui_nav.py` | UI 走查日志目录覆盖 | +| `DTU_BENCH` | (编译宏,非环境变量) | 板级 bench 固件的编译期开关;列在此处只为与同名易混的环境量区分——它**不是** shell 变量 | + +> CI workflow 内部还会向 run 块注入 `DTU_BASE_SHA` / `DTU_PR_BASE_SHA` / `DTU_HEAD_SHA` +> (`.gitea/workflows/ci.yml` 自己定义、自己消费,用于提交信息范围检查),属 CI 私有传递,不需要也不应在本机设置。 + > 变量值只存路径本身(不加引号);可执行文件变量存 `.exe` 完整路径,目录变量(Kits 根)存目录。 > 不设任何变量时全部走自动发现(PATH / 系统目录推导 / `.venv` / `vswhere`);变量只用于覆盖非标准安装位置。 > 字体系统目录(`simsun.ttc`)不设 DTU 变量:由 `%WINDIR%\Fonts\` 推导,`make_font.py` 的 `--font` 参数仍最高优先。 @@ -37,7 +58,7 @@ CI 侧同理:`DTU_PRECOMMIT_PYTHON` → runner PATH → workspace `.venv`。 1. **显式值校验**:变量已设置时,先校验其指向的文件/目录真实存在且符合形态(如 `cl.exe` 必须位于 `bin\Hostx64\x64\` 下);无效则**立即报错**,错误信息必须包含变量名与所给值,**不得静默回退**到自动发现。 2. **未设置才自动发现**:PATH / 系统目录推导 / `.venv` / `vswhere` 等;自动发现失败按各工具既有降级或报错语义处理。 -3. **BAT 实现约束**:`.bat` 中读取环境变量用 `%DTU_...%`;判空用 `if defined`;错误分支 `exit /b` 非零。BAT 内解析一律平铺 `goto` 标签风格,不用括号块(`echo )` 会提前闭合块)。 +3. **BAT 实现约束**:仓库内已无 `.bat` 入口(原 `MDK-ARM/build.bat` 2026-10-10 删除,构建走 `/keil-build`);若在别处写 BAT,读取环境变量用 `%DTU_...%`、判空用 `if defined`、错误分支 `exit /b` 非零,且一律平铺 `goto` 标签风格不用括号块(`echo )` 会提前闭合块)。 4. **PowerShell 实现约束**:`$env:DTU_...`;`build_sim.ps1` 的 CMake 配置不直接读环境变量(保持生成器无关)。 5. **workflow run 块**:`ci.yml` 的 run 块内用 `${env:DTU_...}`(未设置时为空串)与 PowerShell `$env:DTU_...` 判空,**不得**在 workflow 文件里写死任何本机绝对路径。 6. **Windows 命令行引号规则**:值含空格(如 `C:\Program Files (x86)\...`)时,调用端必须整体加引号(`"%DTU_KEIL_UV4%"` / `"& $env:DTU_..."`);变量值本身**不要**带引号。 @@ -57,11 +78,14 @@ $env:DTU_STLINK_CLI = 'D:\Tools\ST-LINK Utility\ST-LINK_CLI.exe' ### 3.2 用户级持久配置(日常开发机,推荐) ```powershell -# 当前用户、全部新进程可见;无需管理员 +# 当前用户、全部新进程可见;无需管理员(示例路径,按本机实际安装位置替换) [Environment]::SetEnvironmentVariable('DTU_KEIL_UV4', 'D:\Tools\Keil\UV4\UV4.exe', 'User') [Environment]::SetEnvironmentVariable('DTU_STLINK_CLI', 'D:\Tools\ST-LINK Utility\ST-LINK_CLI.exe', 'User') # 已存在同名值时直接覆盖;删除用 [Environment]::SetEnvironmentVariable('DTU_KEIL_UV4', $null, 'User') -# 设置后需重开终端/IDE 才对新进程生效 +# 设置后需重开终端/IDE 才对新进程生效。 +# ⚠ DSH agent 会话的 shell 由常驻服务派生,不继承服务启动之后设置的用户级变量: +# 设完后当前会话仍看不见,须把值注入单次调用(options.env),或重启宿主服务。 +# 这是 /keil-build 报 exit 2「UV4.exe not found」但注册表里明明有值的头号原因。 ``` ### 3.3 Gitea Windows runner(SYSTEM 服务) @@ -85,107 +109,75 @@ $env:DTU_STLINK_CLI = 'D:\Tools\ST-LINK Utility\ST-LINK_CLI.exe' --- -## 5. 三个解释器各能跑什么(2026-10-27 实测,道G task-33) +## 5. 门禁解释器环境与修复规程 -> 起因:有队友在 `.venv` 里 `import identify.tags` 失败,又用 `pip` 查不到 `pre-commit`,据此判定 -> 「`.venv` 里的 pre-commit 安装是残缺的」。**复测结论:环境不残缺,两条观测都成立但推错了因**—— -> 前者是版本本身没有那个模块(下表第三行),后者是查错了对象(下表第二行)。 -> 取证方法固定:同一个探针脚本(`sys.executable` + 逐个 `import` + `importlib.metadata.version`) -> 分别喂给三个解释器;脚本放仓外临时目录,不入库、不改任何环境。 +### 5.1 谁跑在哪个 Python 上 -| 解释器 | 身份 | 门禁相关模块 | 第三方包 | 结论 | -|---|---|---|---|---| -| `.venv\Scripts\python.exe` | 3.12.4(**基底是 Anaconda**,非干净 CPython) | `identify` 可导入、`pre_commit` 可导入;`identify.tags` **无此模块** | fontTools 4.51.0 / pillow 10.3.0 / pyserial 3.5 / pyyaml 6.0.3 / cfgv 3.5.0 全在,`pre-commit` 与 `identify` 的 dist-info 齐全(`pip show` 可查) | **就是钩子的执行环境**:`.git/hooks/pre-commit` 里 `INSTALL_PYTHON` 写死指向它 | -| `C:\Python314\python.exe` | 3.14.7,PATH 首位 | 三个全缺 | **一个都没装**(`pip` 元数据全 `PackageNotFoundError`) | 「查不到 pre-commit」的现场就是它;`language: system` 的 42 条 entry 的裸 `python` 落在这里 | -| `C:\Users\...\Python311\python.exe` | 3.11.9,PATH 次位 | 同上全缺 | 同上全缺 | 与门禁无关,列出来只为排除「装了但找不到」的猜测 | +pre-commit 不改 PATH,所以「钩子用哪个解释器」由 entry 怎么写决定。本仓的写法是 +**entry 只写 `scripts/gate_python.py` + 脚本名,解释器交给启动器在运行时找**: -**为什么"缺 `identify.tags`"不是故障**:`identify-2.6.19.dist-info/RECORD` 一共 24 行,列出的模块只有 -`__init__.py` / `cli.py` / `extensions.py` / `identify.py` / `interpreters.py` / `vendor/licenses.py`—— -**根本没有 `identify/tags.py` 这一条**,而磁盘上的文件与 RECORD 逐项一致。即"元数据完好、文件齐全、 -只是这个版本没有那个模块"。标签常量在这个版本里位于 `identify/identify.py`(`TEXT` / `BINARY` / -`EXECUTABLE` / `ALL_TAGS`),pre-commit 4.6.2 自己也是从那里取的(`clientlib.py` 里 -`from identify.identify import ALL_TAGS`),**全仓没有任何脚本 import `identify.tags`**(实测 grep 零命中)。 -⇒ 需要复算文件类型标签时,正确入口是 `identify.identify`;旧写法 `identify.tags` 在这个版本对上必然失败。 +| 谁 | 用哪个 Python | 依据 | +|---|---|---| +| pre-commit 本体 | 仓库 `.venv` | 安装时生成的钩子脚本把路径写死在 `INSTALL_PYTHON` 里;跨工作树共用同一份(`git rev-parse --git-common-dir` 指向主树的 `.git`),换树不必重装 | +| `language: system` 钩子(本仓 44 条) | 仓库 `.venv` | entry 是 `scripts/gate_python.py <脚本>`;启动器按候选链解析,见 §5.2 | +| 脚本内部再调 Python(生成器重跑等) | 启动器解析出的那一个 | 启动器用环境变量 `DTU_GATE_PYTHON` 把选中结果传给子进程,脚本内再调 Python 时自动与钩子一致 | -### 5.1 新增钩子 entry 的硬规则(今天没炸的真正原因) +`language: python` 类钩子跑在 pre-commit 自己的缓存环境里(可用 `DTU_PRECOMMIT_PYTHON` 钉住), +与 `.venv` 无关;本仓当前没有这类钩子。PATH 首位是哪个 Python 已不影响门禁。 -`language: system` 的 entry 不继承 `.venv`:pre-commit 不改 `PATH`,所以裸 `python` 命中 PATH 首位 -(本机 = 3.14.7,**零第三方包**)。实测本仓 45 条 system 钩子里 **43 条 entry 以裸 `python` 开头**, -今天能全绿的原因是(**2026-10-27 二次修正,改以机械口径为准**): +### 5.2 写新钩子:entry 走启动器,不写解释器 -1. 这些条目**没有任何一条在模块级 import 第三方包**(抽测 bom-drift / layer-deps / doc-links 与一套 - 带 harness 的主机单测,在 3.14.7 下 exit 0); -2. 真正会碰第三方的是**下游脚本里的惰性导入**——典型是字体链 `tools/font_gen/make_font.py` 在函数体内 - `from fontTools.ttLib import TTCollection`。它会不会被走到静态判不了,所以 - `scripts/ci_local.py` 的钩子解释器预检把这类只列成**候选**(实测列出 `font-charset` / `generated-fresh` - 两条经下游引入 fontTools),摊开给人看、不作判决;判决只留给「模块级 import + 裸 python + - 该解释器确实没装 + 脚本不自带解释器解析」四事齐备那种必然崩。 +```yaml +entry: scripts/gate_python.py scripts/check_新检查.py +``` -> ⚠ 本节 2026-10-27 的早先版本写作「42 条里唯一需要第三方包的那条自带解释器解析」。那是我拿一条宽松 -> 正则(连注释与字符串里的包名都算命中)审计出的**保守过头的结论**,条目数也因 `nested-comment` 钩子 -> 落地从 42 变 43。现在这条口径由钩子解释器预检每次跑门禁前重算,人不再需要记这个数。 +启动器按顺序找解释器,第一个可用的胜出: +`$DTU_GATE_PYTHON` → `<仓库>/.venv/Scripts/python.exe` → `<仓库>/.gate/python/python.exe`(可选便携解释器) +→ PATH 上的 `python`。一个都找不到时退出码 2,并把四个候选的检查结果打出来。 +排查「钩子到底跑在谁身上」:设 `DTU_GATE_PYTHON`,加 `DTU_GATE_VERBOSE=1` 可让它打印选中结果。 -⇒ 写新钩子时二选一,别开第三条路:要么脚本只用标准库,要么在脚本内部按 -「显式变量 > 当前解释器 > 仓库 `.venv` > PATH」自行解析并**探针校验**(先例与理由见 -`docs/门禁说明/generated-fresh.md` §4)。**不要**把 entry 写死成某台机器的绝对路径。 +**为什么不是别的写法**(三条实测边界,Windows + pre-commit 4.6.2): -### 5.2 动环境的失效顺序与回滚(含本笔机械化) +- pre-commit **不展开 `${VAR}`** —— `entry: '"${DTU_REPO_ROOT}/..."'` 直接报 + ``Executable `'${DTU_REPO_ROOT}'` not found``,所以不能用变量拼路径; +- `language: script` + `#!/usr/bin/env python` 看似最干净(配置里连解释器都不用写),但它把决定权交给 + **PATH 里的第一个 `python`**——正是本方案要摆脱的不确定源。本机实测它会落到 `C:\Python314`(没装任何依赖), + 只是现有钩子恰好只用标准库才没炸; +- `language: script` 的 shebang 只做**纯 PATH 改写**,`env -S python` 里的 `-S` 会被丢掉、 + `env pwsh` 这种「让 env 去找一个能写逻辑的解释器」的路子走不通。 -**先把"谁依赖谁"钉住,才知道动一下会红在哪一步**(下表每行都是本机实测或代码事实,不是推测): +另一个选项是把便携解释器放进仓库(`.gate/python/`),换来「换机零准备」,代价是仓库多几百 MB。 +当前**没有**提供它,所以新机器仍需先装 Python。 -| 被动什么 | 第一现场 | 症状 | 还能动什么 | -|---|---|---|---| -| 删 / 重建 `.venv` | `.git/hooks/pre-commit` 里的 `INSTALL_PYTHON` 指它 | `git commit` **当场起不来**:钩子脚本先判 `if [ -x "$INSTALL_PYTHON" ]`,不成立才回落到 PATH 上的 `pre-commit`——本机 `where.exe pre-commit` 报找不到,于是回落分支也是死路 | 重建 `.venv` 后必须 `pip install -r requirements.txt`(含 pre-commit),否则回落分支永远还是死路 | -| 只换 `.venv` 里的包(不删目录) | pre-commit 本体与 `language: python` 钩子各自独立的缓存环境(`~/.cache/pre-commit/repo*/py_env*/`)互不相干 | 只影响 pre-commit 本体;改写型钩子跑在缓存环境里,**看不见** `.venv` 的变动(实测那份缓存环境里连 `identify` 都没装) | 缓存环境坏了要 `pre-commit clean`——它会连别人正在跑的钩子环境一起重建,必须先要到静默窗口 | -| 换 PATH 首位解释器 | 那 43 条裸 python 条目 | 模块级第三方 import 一条都没有 ⇒ 普通情况下无感;但 3.12 → 3.14 这种版本跨度会改变标准库行为,是"靠巧合不炸"的敞口 | 设 `DTU_PRECOMMIT_PYTHON` 只钉得住 pre-commit 本体,**钉不住**裸 python 条目的落点——那只能靠脚本自解析 | -| 改 `requirements.txt` 的版本约束 | 别人机器上的一次 `pip install -r` | 本机无感,别人下次重建环境时才炸 | 这不属于"修本机环境",要改先报属主,别夹在诊断笔里 | +启动器本身必须**纯标准库、单文件、不 import 仓库内模块**:它运行在「解释器还不知道是谁」的时刻。 -**回滚按现场分条,别混用**: +### 5.3 出问题先看预检,别急着动环境 -- `.venv` 被删 / 建坏:`python -m venv .venv` → `pip install -r requirements.txt` → - `& '.venv\Scripts\python.exe' -m pre_commit --version` 通 → `ci_local.py --preflight-only` 通 → 再跑完整门禁。 -- 单包装坏:按修复前那次 `pip freeze`(放仓外,不入库)反向 `pip install <包>==<原版本>`; - **不要** `-U` 顺手把别人的依赖一起动。 -- 缓存环境坏:`pre-commit clean` 后跑一次 `ci_local.py` 让它重建;重建期间别提交(几十秒内会拿到半装好的环境)。 -- 只有全坏透才删 `.venv` 重来;删前记下 `pip freeze`,并记住本仓 `.venv` 基底是 Anaconda - (换机复制不出来,见上面 §5.2 依赖表与 §5.3 第 5 条)。 +`scripts/ci_local.py` 每次开头都跑一遍**钩子解释器预检**(`--preflight-only` 可单跑):报清 +"钩子跑在谁身上 / 裸 python 落到谁 / 那个解释器缺什么 / 哪些只是候选风险",确认会崩的直接 +exit 2 并指名到钩子。它不改环境、不装包、不写仓库,只保证断链在第一秒就报出来。 -**本笔把上面这些机械化了一半**:`scripts/ci_local.py` 新增 **钩子解释器预检**(`--preflight-only` -可单跑,也是 `ci_local.py` 每次的第一段),报清"钩子跑在谁身上 / 裸 python 落到谁 / 那个解释器缺什么 / -哪些只是候选",四事齐备即 exit 2 并指名到钩子。红-绿在临时假仓库上验过(同一份配置里 bad-hook 判 FAIL、 -good-hook 判 OK,整体 `rc = 2`)。**它不改环境、不装包、不写仓库**,也不替代上面的修复死顺序—— -它只保证一件事:这条链下次断掉时报错发生在第一秒,并且带着钩子的名字。 +预检报红之后,按现象对号入座: -### 5.3 什么情况下才需要动环境,以及动之前要走死顺序 +| 现象 | 原因 | 处置 | +|---|---|---| +| 删了或重建过 `.venv` 后 `git commit` 当场起不来 | 安装过的钩子脚本里写死了旧 `.venv` 的路径;找不到时回落到 PATH 上的 `pre-commit`——本机 PATH 上没有它,于是报错退出 | 重建 `.venv` 并 `pip install -r requirements.txt`(含 pre-commit),再重跑一次 `pre-commit install` 刷新钩子脚本里的路径 | +| 预检报「入口解释器不可用」 | `.venv` 被删或改名,或换机后 entry 里的旧绝对路径失效 | 确认 `.venv` 在位;换机则按新检出位置重写 44 条 entry 的首段(见 §5.2) | +| 钩子报 `No module named xxx` | 脚本要的包不在 `.venv` 里 | 加进 `requirements.txt` 再装(不要装到别的解释器);别用 `-U` 顺手升级别人的依赖 | +| 改了 `requirements.txt` 的版本约束 | 本机无感,别人下次重建环境时才炸 | 这不属于"修本机环境":要改先报属主,别夹在诊断笔里 | -默认动作是**不动**(本节存在即结论:没有需要重装的东西)。只有出现下列**具体现象**才执行修复, -且必须先要到静默窗口——`pre-commit` 与 `.venv` 是全队共享运行面,中途换包会把别人的一次钩子跑打断: +### 5.4 真要动环境:默认不动,动则走五步 -1. 停表:确认没有人在跑提交/门禁/台架(build 与 bench 两类锁都空闲,见 `task-worktree`); -2. 备份指针:记下 `& '.venv\Scripts\python.exe' -m pip freeze > 仓外文件`(不入库); -3. 只补不重建:优先 `pip install --force-reinstall --no-deps <包>==`, - **不要**顺手 `-U`(那会连带动别人的依赖);确实要整体重建才 `pip install -r requirements.txt`, - 且该文件必须保持纯 ASCII(Windows 下 pip 按代码页解码); -4. 自证(缺一不算修完):`& '.venv\Scripts\python.exe' -m pip show pre-commit` 有版本 → - `-m pre_commit --version` 通 → `scripts/check_generated_fresh.py` 与字体字符集钩子在 `.venv` - 与 PATH 两种解释器下都 exit 0 → 最后跑一次 `scripts/ci_local.py`; -5. 回滚:新装/升级出问题时按第 2 步的 freeze 反向 `pip install <包>==<原版本>`;**别删 `.venv` 重来**, - 删了就把「基底是 Anaconda」这条不可复制性也一起删掉(换机复现不了,见本文 §3 与 `docs/环境与部署/项目部署环境要求.md`)。 +`pre-commit` 与 `.venv` 是全队共享运行面,中途换包会把别人正在跑的钩子打断。只有出现具体故障 +才动手,且顺序固定: -### 5.4 仍开着的账(不在本次范围,登记给对应属主) - -- `DTU_PRECOMMIT_PYTHON` 本机当前**未设**,靠 §1.1 的优先级自动落到 `.venv`;如果哪天 PATH 首位换了 - 解释器,静态门禁档就会跟着换——把它显式设成 `.venv` 是一条零成本的加固(改的是本机环境,不动仓库)。 -- 42 条裸 `python` entry 与两个解释器的版本差(3.14 vs 3.12):今天没有可观测的行为差,但这是 - 「靠巧合不炸」。要不要把 entry 统一改成显式解释器解析,属门禁属主的决定(同一族的先例见 §5.1)。 -- 本机的 `.venv` 基底是 Anaconda 发行版,`python -m venv` 在别的机器上给出的不是同一个东西; - 这条差异目前无人记录在 `docs/环境与部署/项目部署环境要求.md` 里。 - - -## 变更记录 - -| 日期 | 变更 | -|---|---| -| 2026-10-27 | **新增 §5.2 动环境的失效顺序与回滚(原插成 §5.4,本笔按依赖表→死顺序→遗留账重排)+ §5.1 二次修正(道G task-33 修复分支)**:四种现场(删/换 `.venv`、只换包、换 PATH 首位、改 requirements 约束)逐行钉死"第一现场 / 症状 / 还能动什么",回滚按现场分条不混用;`scripts/ci_local.py` 同批加 **钩子解释器预检**(`--preflight-only` 单跑,也是每次门禁的第一段:报"钩子跑在谁身上 / 裸 python 落到谁 / 缺哪些包 / 哪些是惰性候选",四事齐备 exit 2 指名到钩子,红-绿在临时假仓库验过)。§5.1 那条「42 条里唯一需要第三方包的那条自带解释器解析」被更严的口径推翻:按**模块级 import** 判是 0 条,条目数也因 `nested-comment` 落地变 43;fontTools 属下游**惰性**导入,预检只列候选不判决。 | 2026-10-27 | **新增 §5 解释器实测矩阵(道G task-33,只读诊断,未动任何环境)**:三个解释器(仓库 `.venv` / PATH 首位 / PATH 次位)逐个探针实测,钉住三条事实——① `.venv` **不是残缺**:`pre-commit` 与 `identify` 的 dist-info 齐全(`pip show` 可查),`identify-2.6.19` 的 RECORD 里本就没有 `tags.py`,标签常量在该版本的 `identify.identify` 里,pre-commit 自己也是从那里取(旧写法 `identify.tags` 在这个版本对上必失败,全仓零引用);② 钩子执行环境被 `.git/hooks/pre-commit` 的 `INSTALL_PYTHON` 钉死为 `.venv`,而 `language: system` 的 42 条 entry 用裸 `python` → 落到 PATH 首位那个**零第三方包**的解释器,今天没炸是「42 条全只用标准库 + 唯一需要第三方包的那条自带解释器解析」两件事同时成立,据此写下新增 entry 的硬规则(§5.1);③ 「查不到 pre-commit」的现场是查了 PATH 上的解释器。另附 §5.2「默认不动环境;确需修复时的死顺序与回滚(只补不重建、freeze 备份放仓外、按 freeze 反向回滚、禁止删 `.venv` 重来)」与 §5.3 三条待属主的账。| 2026-10-02 | **文档改造**:删除规划阶段内容(设计结论、路径耦合盘点、代码改造映射、分阶段推广与验收、风险与边界、后续任务拆分),保留并重组为现行机制说明——§1 变量清单(含 Python 优先级特别说明)、§2 解析与失败语义、§3 本机配置方法(会话/用户/runner 三级)、§4 验收判据、变更记录;章节重编号,外部引用同步(`gen_refgraph.py` §3→§1、`ci_local.py` §3.1→§1.1、`Gitea_Runner部署指南.md` §5.3→§3.3) | -| 2026-10-02 | **实施完成**:阶段 A~D 落地。① 10 处调用点全部改为"`DTU_*` 变量优先 + 自动发现回退"(显式值无效 = 硬报错、不静默回退):`build/download/reset.bat`(goto 标签式解析,纯 ASCII)、`ci_local.py`(`DTU_PRECOMMIT_PYTHON` 严格校验)、`ci.yml`(`DTU_PRECOMMIT_PYTHON`/`DTU_CPPCHECK`/`DTU_MSVC_CL`/`DTU_WINDOWS_KITS_ROOT`,run 块纯 ASCII)、`gen_refgraph.py`(`DTU_GRAPHVIZ_DOT`→PATH,保留无渲染降级)、`unit_test_harness.py`(`DTU_MSVC_CL`/`DTU_WINDOWS_KITS_ROOT` + `%ProgramFiles*%` 推导取代静态盘符列表)、`build_sim.ps1`(`DTU_VSWHERE`→`%ProgramFiles(x86)%` 推导,去字面 C 盘回退)、`make_font.py`(`%WINDIR%` 推导);`_build.cmd` 确认零调用后删除。② 本机按 §3.2 配置用户级 `DTU_KEIL_UV4`/`DTU_STLINK_CLI`(其余工具本机均可自动发现,未设变量),验证:`@build` BUILD_OK 0/0、`ci_local.py` 静态门禁 15 钩子 + 主机侧纯逻辑单测 278/278 断言全绿、refgraph 重生成(含修复 `引用关系图.json` 末尾换行缺失导致的 end-of-file-fixer 抖动,生成器补 `f.write("\n")`)、`build_sim.ps1` vswhere 推导命中 VS2026(当时模拟器 CMake 配置期暴露**既有**问题:`App/cfg_file.c` 已移入 `App/cfg/` 但 `simulator/CMakeLists.txt` 未同步——与环境变量改造无关;2026-10-02 已修复:CMakeLists 同步 `App/cfg/` + `test/board/` 引用,模拟器全量构建通过)、`reset.bat list` 识别探针。③ 文档收口:`AGENTS.md`/`MDK-ARM/README.md`/`项目部署环境要求.md`(新增 §2.1.1)/`Gitea_Runner部署指南.md`(新增 §5.1.3 机器级变量)/`CI配置指南.md`/`pre-commit使用说明.md`/字体/测试流程/引用图说明/ST-LINK 说明/实机规划/`scripts/README.md` 共 12 篇同步为变量口径。④ `requirements.txt` 增 `pre-commit==4.6.2`(重建 `.venv` 即可跑静态门禁)。⑤ 全量 `pre-commit --all-files` + `ci_local.py` 通过。待办:新 workflow 推送远程后跑一次 CI 两 job 闭环验证;第二台不同安装路径机器的交叉验证(操作清单已固化入 `项目部署环境要求.md` §2.12) | -| 2026-10-02 | 初版规划:盘点 BAT/Python/PowerShell/Gitea workflow 中机器相关工具路径;定义 `DTU_*` 变量清单、优先级、用户级与 SYSTEM runner 配置边界;规划 4 阶段迁移及离线/实机验收范围。未改工具调用代码 | +1. **停表**:确认没人跑提交/门禁/台架(build 与 bench 锁都空闲,见 `task-worktree`); +2. **备份指针**:`& '.venv\Scripts\python.exe' -m pip freeze > 仓外文件`(不入库,回滚全靠它); +3. **只补不重建**:`pip install --force-reinstall --no-deps <包>==`; + **不要**顺手 `-U`(会连带动别人的依赖);确要整体重建才 `pip install -r requirements.txt` + (该文件必须纯 ASCII,Windows 下 pip 按代码页解码); +4. **自证**(缺一不算修完):`.venv` 里 `pip show pre-commit` 有版本 → `-m pre_commit --version` 通 → + `scripts/ci_local.py --preflight-only` 通过 → 最后完整跑一次 `ci_local.py`; +5. **回滚**:新装出问题按第 2 步的 freeze 反向 `pip install <包>==<原版本>`;**别删 `.venv` 重来**—— + 删掉就要重建并重装 `requirements.txt`,且 `pre-commit install` 必须重跑一次(钩子脚本里写死的 + 路径要跟着刷新,见 §5.3 第一行)。 diff --git a/scripts/check_layer_deps.py b/scripts/check_layer_deps.py index 6b18a5a4..5a5250be 100644 --- a/scripts/check_layer_deps.py +++ b/scripts/check_layer_deps.py @@ -66,7 +66,7 @@ def tracked_files(): files = [] for root, dirs, names in os.walk(REPO): dirs[:] = [d for d in dirs if d not in - (".git", ".venv", "build", ".dsh-worktrees")]; + (".git", ".venv", "build", ".dsh-worktrees", ".agents")]; for n in names: files.append(os.path.relpath(os.path.join(root, n), REPO).replace(os.sep, "/")) return files diff --git a/scripts/ci_local.py b/scripts/ci_local.py index ac60d4dc..22ea3f0c 100644 --- a/scripts/ci_local.py +++ b/scripts/ci_local.py @@ -61,6 +61,7 @@ """ import argparse +import importlib import os import re import shutil @@ -343,6 +344,103 @@ def _script_self_resolves(script_rel): r"re-?exec|ensure_python", text)) +def _hook_launcher_entries(): + """扫 .pre-commit-config.yaml,返回 [(hook_id, script_path)]。 + + 收 language: system 且 entry 以 scripts/gate_python.py 开头的条目——解耦之后本仓 44 条 + 都是这个形态:配置里不出现任何解释器路径,由启动器在运行时解析(见 + docs/环境与部署/本地工具路径环境变量方案.md §5.2)。 + """ + cfg = REPO_ROOT / ".pre-commit-config.yaml" + if not cfg.is_file(): + return [] + out = [] + cur_id = cur_entry = cur_lang = None + for raw in cfg.read_text(encoding="utf-8", errors="replace").splitlines(): + s = raw.strip() + if s.startswith("#"): + continue + m = re.match(r"^-\s+id:\s*(\S+)", s) + if m: + cur_id, cur_entry, cur_lang = m.group(1), None, None + continue + m = re.match(r"^entry:\s*(.+)$", s) + if m: + cur_entry = m.group(1).strip() + continue + m = re.match(r"^language:\s*(\S+)", s) + if m: + cur_lang = m.group(1) + if cur_lang == "system" and cur_entry and cur_id: + toks = cur_entry.split() + if len(toks) >= 2 and toks[0].replace("\\", "/").endswith("scripts/gate_python.py"): + out.append((cur_id, toks[1].strip("'\""))) + continue + return out + + +def _launcher_resolves(): + """让启动器自己报出它选中的解释器,返回 (说明, 路径);解析失败返回 (None, None)。 + + 直接 import 启动器模块调 candidates(),而不是跑一条子进程:启动器本来就要求纯标准库 + 单文件,import 是它最轻的用法,也让「预检看到的解释器」与「钩子实际用的解释器」共用一份逻辑。 + """ + launcher = REPO_ROOT / "scripts" / "gate_python.py" + if not launcher.is_file(): + return None, None + sys.path.insert(0, str(launcher.parent)) + try: + mod = importlib.import_module("gate_python") + picked = mod.pick(mod.repo_root()) + except Exception: + return None, None + finally: + sys.path.pop(0) + if not picked: + return None, None + return picked + + +def _hook_pinned_python_entries(): + """扫 .pre-commit-config.yaml,返回 [(hook_id, script_path, python_exe)]。 + + 收 language: system 且 entry 以显式 python 可执行文件开头的条目——统一到仓库 .venv + 之后本仓 44 条都是这个形态:入口解释器由 entry 写死,与 PATH 首位无关。 + entry 形如 '"D:\\...\\python.exe" scripts/check_x.py'(双引号包住含空格的路径), + 取不到脚本时只报入口本身的问题。扫描逻辑与 _hook_bare_python_entries 同源。 + """ + cfg = REPO_ROOT / ".pre-commit-config.yaml" + if not cfg.is_file(): + return [] + out = [] + cur_id = cur_entry = cur_lang = None + for raw in cfg.read_text(encoding="utf-8", errors="replace").splitlines(): + s = raw.strip() + if s.startswith("#"): + continue + m = re.match(r"^-\s+id:\s*(\S+)", s) + if m: + cur_id, cur_entry, cur_lang = m.group(1), None, None + continue + m = re.match(r"^entry:\s*(.+)$", s) + if m: + cur_entry = m.group(1).strip() + continue + m = re.match(r"^language:\s*(\S+)", s) + if m: + cur_lang = m.group(1) + if cur_lang == "system" and cur_entry and cur_id: + toks = cur_entry.split() + if not toks: + continue + exe = toks[0].strip("'\"") + if re.search(r"python(3)?(\.exe)?$", exe, re.I) and (os.sep in exe or "/" in exe): + script = toks[1].strip("'\"") if len(toks) > 1 else "" + out.append((cur_id, script, exe)) + continue + return out + + def _missing_modules(python_path, modules): """用指定解释器一次性试导入这批模块,返回它缺的那些(解释器本身跑不起来返回 None)。""" if not modules: @@ -396,9 +494,32 @@ def run_hook_preflight(): except (OSError, subprocess.TimeoutExpired): ver = "?" entries = _hook_bare_python_entries() + pinned = _hook_pinned_python_entries() + launched = _hook_launcher_entries() need_all = set() rows = [] lazy_all = {} + pinned_bad = [] + pin_kind = None + if pinned: + exes = sorted({os.path.normcase(e) for _h, _s, e in pinned}) + pin_kind = exes[0] if len(exes) == 1 else "<%d 个不同解释器>" % len(exes) + if len(exes) == 1 and exes[0] != os.path.normcase(venv): + print("[WARN] entry 里写死的解释器 = {}".format(exes[0])) + print(" 而仓库 .venv = {} —— 两者不一致:这批钩子跑的不是这个 .venv".format(venv)) + pinned_bad = [(h, s, e) for h, s, e in pinned if not os.path.isfile(e)] + for hook_id, script, exe in pinned: + if not script: + continue + forced, hinted, _lazy = _script_third_party(script) + need = set(forced.keys()) | set(hinted.keys()) + if not need: + continue + lack = _missing_modules(exe, need) + if lack is None: + pinned_bad.append((hook_id, script, "{} 探测失败".format(exe))) + elif lack: + pinned_bad.append((hook_id, script, "{} 缺 {}".format(exe, ", ".join(sorted(lack))))) for hook_id, script in entries: forced, hinted, lazy = _script_third_party(script) if forced or hinted: @@ -411,8 +532,39 @@ def run_hook_preflight(): if missing is None: print("[ERROR] 探测解释器失败:{}(跑不起来,预检无法完成)".format(path_first)) return 2 - print("[INFO] language: system 用裸 python 的条目:{} 条;其 entry 解释器 = {} {}(PATH 首位)".format( - len(entries), path_first, ver)) + if launched: + label, resolved = _launcher_resolves() + if resolved is None: + print("[WARN] scripts/gate_python.py 存在 {} 条钩子在用,但启动器解析不出可用解释器".format( + len(launched))) + else: + same = os.path.normcase(resolved) == os.path.normcase(venv) + print("[INFO] 经启动器找解释器的钩子:{} 条 -> {}({}){}".format( + len(launched), resolved, label, + "" if same else " ⚠ 与仓库 .venv 不一致")) + if not same: + print(" 这批钩子跑的不是仓库 .venv:先确认这是有意的(DTU_GATE_PYTHON 覆盖?)还是 .venv 缺失") + if pinned: + print("[INFO] entry 写死了解释器的钩子:{} 条,都指向 {}(入口解释器与 PATH 无关)".format( + len(pinned), pin_kind)) + if entries: + print("[INFO] language: system 用裸 python 的条目:{} 条;其 entry 解释器 = {} {}(PATH 首位)".format( + len(entries), path_first, ver)) + elif launched: + print("[INFO] language: system 用裸 python 的条目:0 条(全部经启动器或写死解释器)") + else: + print("[INFO] language: system 用裸 python 的条目:0 条(全部显式指定了解释器)") + if pinned_bad: + print("") + print("[ERROR] 以下钩子的入口解释器不可用,跑到它时必然失败:") + for hook_id, script, why in pinned_bad: + print(" - {}:{} -> {}".format(hook_id, script, why)) + print(" 修法:确认 .venv 存在(重建见 docs/环境与部署/本地工具路径环境变量方案.md §5.4)," + "或把该 entry 改回裸 python(前提见同文 §5.2)。") + return 2 + if not entries: + print("[OK] 预检通过:钩子入口解释器全部可用,且没有裸 python 条目") + return 0 if not launched or _launcher_resolves()[1] else 2 print("[INFO] 这些条目累计需要的第三方包:{};该解释器缺:{}".format( ", ".join(sorted(need_all)) or "(无,全是纯标准库)", ", ".join(sorted(missing)) or "(无)")) bad = [(h, s, f) for (h, s, f, _h2, resolved) in rows diff --git a/scripts/gate_python.py b/scripts/gate_python.py new file mode 100644 index 00000000..40874500 --- /dev/null +++ b/scripts/gate_python.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""门禁钩子的统一启动器:替钩子找到该用的解释器,再原样转交脚本。 + +被提交的 .pre-commit-config.yaml 只写 `entry: scripts/gate_python.py <脚本> [参数...]`, +不出现任何机器相关的解释器路径——换机、换克隆目录、换 PATH 首位都不必改配置。 + +解释器按下面的顺序找,第一个可用的胜出: + 1. $DTU_GATE_PYTHON 显式覆盖(临时切版本、排查「到底跑在谁身上」) + 2. <仓库>/.venv/Scripts/python.exe 约定的仓库内环境(requirements.txt 装在这里) + 3. <仓库>/.gate/python/python.exe 便携解释器(可选,本仓当前未提供) + 4. PATH 上的 python 最后兜底 + +约定纪律(改动前先读 docs/环境与部署/本地工具路径环境变量方案.md §5.1): + - 纯标准库、单文件、不 import 仓库内任何模块——它运行在「解释器还不知道是谁」的时刻; + - 解析出的解释器用环境变量 DTU_GATE_PYTHON 传给子进程,脚本内部再调 Python 时能与钩子保持一致, + 否则「门禁用的版本」和「生成器用的版本」会分叉。 + +退出码:0 表示被调脚本自己退出为 0;启动器自身失败一律 2(与 pre-commit「环境不可用」口径一致, +见 docs/草稿约定:2 = 配置/环境问题,1 = 检查结论不通过)。 +""" + +from __future__ import annotations + +import os +import sys + +LAUNCHER_NAME = "gate_python.py" + + +def repo_root() -> str: + """启动器所在仓库根(本文件位于 <仓库>/scripts/ 下)。""" + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def candidates(root: str) -> list[tuple[str, str]]: + """返回 [(说明, 解释器路径)],顺序即优先级。""" + items: list[tuple[str, str]] = [] + override = os.environ.get("DTU_GATE_PYTHON") + if override: + items.append(("$DTU_GATE_PYTHON", override)) + items.append(("仓库 .venv", os.path.join(root, ".venv", "Scripts", "python.exe"))) + items.append(("便携解释器 .gate", os.path.join(root, ".gate", "python", "python.exe"))) + path_python = path_lookup("python") + if path_python: + items.append(("PATH", path_python)) + return items + + +def path_lookup(name: str) -> str: + """按 Windows 语义在 PATH 里找可执行文件(含 PATHEXT 展开)。""" + exts = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(os.pathsep) + for directory in os.environ.get("PATH", "").split(os.pathsep): + if not directory: + continue + for ext in [""] + exts: + candidate = os.path.join(directory, name + ext.lower()) + if os.path.isfile(candidate): + return candidate + return "" + + +def pick(root: str) -> tuple[str, str] | None: + for label, exe in candidates(root): + if exe and os.path.isfile(exe): + return label, exe + return None + + +def fail(message: str, root: str) -> int: + print(f"[gate_python] {message}", file=sys.stderr) + print("[gate_python] 解释器候选检查结果:", file=sys.stderr) + for label, exe in candidates(root): + state = "可用" if exe and os.path.isfile(exe) else "不存在" + print(f" {label:<16} {exe or '<未设置>'} [{state}]", file=sys.stderr) + print( + "[gate_python] 修复:按下面任一方式让第 2 个候选就位——\n" + " python -m venv .venv && .venv/Scripts/python.exe -m pip install -r requirements.txt\n" + " 或临时指定:set DTU_GATE_PYTHON=<某个装了依赖的 python.exe>\n" + " 详细规程 → docs/环境与部署/本地工具路径环境变量方案.md §5", + file=sys.stderr, + ) + return 2 + + +def main(argv: list[str]) -> int: + root = repo_root() + if len(argv) < 2: + return fail("缺少目标脚本参数(用法:gate_python.py <脚本> [参数...])", root) + + target = argv[1] + if not os.path.isabs(target): + target = os.path.join(root, target) + if not os.path.isfile(target): + return fail(f"目标脚本不存在:{argv[1]}", root) + + chosen = pick(root) + if chosen is None: + return fail("找不到可用的解释器", root) + label, exe = chosen + + if os.environ.get("DTU_GATE_VERBOSE"): + print(f"[gate_python] {label} -> {exe}", file=sys.stderr) + + env = dict(os.environ) + env["DTU_GATE_PYTHON"] = exe + child_argv = [exe, target, *argv[2:]] + os.execve(exe, child_argv, env) + return 0 # 不会走到这里:execve 成功即替换进程 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/refgraph/gen_refgraph.py b/tools/refgraph/gen_refgraph.py index 77823e09..9e6b38eb 100644 --- a/tools/refgraph/gen_refgraph.py +++ b/tools/refgraph/gen_refgraph.py @@ -69,10 +69,10 @@ import sys # 文件索引时整体跳过的目录(按相对路径前缀,'/' 分隔) EXCLUDE_PREFIXES = ("MDK-ARM", "docs", "simulator/build") # 按目录名跳过(任意层级) -# .dsh-worktrees = DSH worktree 插件在仓库内建的任务树(整份仓库副本):不排掉的话 -# 每次有人建插件树,本生成器的输出就多出几十条重复节点/边——而 generated-fresh 门禁是 -# 逐字节比对,等于别人的一次「建个隔离空间」把配图 diff 砸在某位同事头上。 -EXCLUDE_DIRNAMES = (".git", ".venv", "node_modules", ".dsh-worktrees") +# .dsh-worktrees / .agents = DSH 新旧 worktree 插件在仓库内建的任务树(整份仓库副本): +# 不排掉的话,每次有人建插件树,本生成器的输出就多出几十条重复节点/边——而 +# generated-fresh 门禁逐字节比对,等于别人的一次「建个隔离空间」把配图 diff 砸给同事。 +EXCLUDE_DIRNAMES = (".git", ".venv", "node_modules", ".dsh-worktrees", ".agents") # simulator/CMakeLists.txt 的 include_directories(相对仓库根;保持同步) SIM_INCLUDE_DIRS = (