chore(gate): 门禁钩子改走 gate_python 启动器,worktree 统一插件模型

门禁解释器解耦
  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/<name> 并返回 pw:<name>。
  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 全通过。
This commit is contained in:
2026-11-15 19:39:56 +08:00
parent 9f68b7bc03
commit 96a3d15efc
17 changed files with 826 additions and 720 deletions
+2 -2
View File
@@ -96,7 +96,7 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被
| 开关 | 作用 |
|---|---|
| `--lock-task <ID>` | 持有者名。默认 `$DTU_TASK_ID` → 从 cwd 反推:`_wt/<ID>-<slug>/` → `<ID>`(`@task` 那批);`.dsh-worktrees/worktree/<name>/` → `pw:<name>`(DSH worktree **插件**建的树,`plugin_tree_owner()`)→ 都没有才落 `build-cli` |
| `--lock-task <ID>` | 持有者名。默认 `$DTU_TASK_ID` → 从 cwd 反推:`_wt/<ID>-<slug>/` → `<ID>`(`@task` 那批);`.agents/worktrees/<name>/` 或旧 `.dsh-worktrees/worktree/<name>/` → `pw:<name>`(DSH worktree **插件**建的树,`plugin_tree_owner()`)→ 都没有才落 `build-cli` |
| `--lock-wait <N>` | 锁被占时等待 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/<ID>-<slug>/` 反推持有者名,意味着"手工 `lock acquire build --task <ID>`
后再跑 `/keil-build`"仍**幂等**(同名即视为自己持有),不会自我死锁。
- **退出码 4 的持有者名带 `pw:`(如 `pw:refactor-logging`)= 插件在 `.dsh-worktrees/worktree/<name>/` 里跑的构建占着 UV4,不是某个 `@task` 任务 ID**:
- **退出码 4 的持有者名带 `pw:`(如 `pw:refactor-logging`)= 插件在 `.agents/worktrees/<name>/`(或旧 `.dsh-worktrees/worktree/<name>/`)里跑的构建占着 UV4,不是某个 `@task` 任务 ID**:
插件树**没有台账**,别拿它去 `lock release <ID>`(释放不掉别人的锁)、更别去杀 UV4 进程。正解 =
等那次构建自己 `finally` 释放(自持自释;崩溃则按自报 TTL 兜底),或本次加 `--lock-wait <N>` 排队;
想知道是哪棵树的谁:`"$PY" "$TW" plugin list`(缺联接再 `plugin adopt <name>`)→ 细节归
+16 -12
View File
@@ -274,20 +274,24 @@ def find_lock_tool():
def plugin_tree_owner(cwd: Path):
"""DSH worktree 插件的树(仓库内 .dsh-worktrees/worktree/<name>)→ 持有者名 pw:<name>。
"""现役/旧版 DSH 插件树 → 同一个持有者名 pw:<name>(多段名压平成 -)。
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/<name>/;旧版路径是 .dsh-worktrees/worktree/<name>/。
只认这两个完整目录形态,避免把偶然同名目录或路径之外的普通目录算成插件树。
与 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():
+23 -14
View File
@@ -38,30 +38,39 @@ PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktr
| 命令 | 作用 |
|---|---|
| `new <ID> <slug>` | 建分支 `task/<ID>-<slug>` + 工作树 + 共享联接 + 台账,打印新空间路径 |
| `bench-gate <branch>` | 检查指定分支是否有 bench-pass 记录(存储于 `.agents/worktrees/.bench-pass-log`) |
| `merge <branch>` | 在主树执行编排式合并:bench-gate → 预演 → 合并(`--skip-bench` 跳过台架检查) |
| `list` | 分支 / 落后领先 / 脏文件数 / 台架状态 + 两把锁归属。**只遍历台账** → 非登记树看不见(E 节) |
| `status <ID>` | 未提交清单、相对基线漂移、台架记录、已建联接 |
| `sync <ID>` | 在任务树里 `rebase` 追平主干,把冲突留在任务分支上 |
| `lock acquire|release|status <build\|bench>` | 独占资源锁(→ 铁律 3、B 节) |
| `plugin list|adopt|unadopt [插件树名]` | 插件桥:看清单 / 补共享联接 / 摘联接(**不删树**)→ F 节 |
| `done <ID>` | 预演 → 合入主干 → 并集残留审计 → 重生成产物 → 门禁 → 清场 |
| `abort <ID>` | 工作树 + 分支一起删,**永久丢弃** |
| `done <ID>` | **[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 <UTF-8 无 BOM 的正文文件> # 提交属 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:<name>`、**回收仍归 /worktree**(本 skill 不删插件树,插件也不删 `_wt/`)。「有插件为何还自持生命周期」=先读插件包(`npm root -g`)再 grep 三个概念名报命中数,插件侧零命中就是理由——取证姿势与答案边界 → [reference/dsh-plugin-bridge.md](reference/dsh-plugin-bridge.md) F-2。
- `plugin adopt` 两条都补只是因为便宜,不代表两条都硬。插件树占锁记 `pw:<name>`。现役插件只负责 create/restore,**保留目录与分支,不自动回收**;本 skill 不代删。确需回收先 `plugin unadopt <name>` 摘联接,再按 **E 节非登记树安全回收规程**处理。「有插件为何还自持生命周期」=先读插件包再 grep 三个概念名报命中数,插件侧零命中就是理由——取证姿势与答案边界 → [reference/dsh-plugin-bridge.md](reference/dsh-plugin-bridge.md) F-2。
### G. 任务树内跑门禁:落点、`--all` 假红、消息禁词
@@ -157,7 +166,7 @@ git commit -F <UTF-8 无 BOM 的正文文件> # 提交属 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 <UTF-8 无 BOM 的正文文件> # 提交属 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`
+227 -137
View File
@@ -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/<name>/ 建同名分支并切换会话 cwd。
# 旧版 dsh-task-worktree 使用 .dsh-worktrees/worktree/<name>/;两处都与本 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/<name>)缺三样东西,下面的命令逐条补。
# 回收**不在这里**:插件树由「/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:<name>),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:<name>(多段名压平成 -)。
必须与 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 <name>")
print("(.agents/worktrees/ 与旧 .dsh-worktrees/worktree/ 下没有插件工作树)")
print(" 建法:让模型调现役插件的 create_worktree;默认建 .agents/worktrees/<name>/ "
"及同名分支,并切换会话 cwd")
print(" 建完回来「plugin adopt <name>」补共享联接,才能在里面跑 /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/<name>/)".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 <ID>")
# 非登记持有者(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="禁词,可重复")
+4 -6
View File
@@ -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/
+44 -44
View File
@@ -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/
+3
View File
@@ -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)
+1 -1
View File
@@ -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`,不必读这里 |
+1 -1
View File
@@ -35,7 +35,7 @@
## 2. 全项目推进顺序
> 本节是全项目唯一的顺序权威。模块文档只写局部设计与重开条件,不另排一套路线图。
> 写范围相交怎么排队、构建与台架怎么串行 → `docs/环境与部署/并行任务工作树方案.md` §7.3。
> 写范围相交怎么排队、构建与台架怎么串行 → `docs/环境与部署/并行任务工作树方案.md` 的「冲突与单写者规则」与「从创建到交付的完整流程」两节。
### 2.0 顺序表
@@ -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,无注入·无人值守)
+2 -2
View File
@@ -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/` 归档一篇。
@@ -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 <branch>` | 检查该分支是否有 `bench-pass` 记录,合并前调用 |
| 编排式合并(预演→合并→重生成→门禁→清场) | `task-worktree merge <branch>` | 复用原 `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/<ID>.json # 任务台账:分支 / 联接 / 门禁与台架戳
├── .state/locks/ # 独占锁:build.lock(UV4)+ bench.lock(COM6/ST-LINK/设备 IP)
└── <ID>-<slug>/ # ← 一棵工作树(分支 task/<ID>-<slug>)
├── .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/<ID>-<slug>/` 里的那几个路径都不在 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/<ID>-<slug>` | `worktree/<name>` |
| `simulator/SDL2` | 构建模拟器的硬依赖 | 模拟器配置直接失败;该路径由构建文件固定,没有环境变量替代入口 |
| `.venv` | 有替代入口,但默认应共享 | 主机侧检查可能回退到机器级 Python,缺依赖后产生与代码无关的门禁失败 |
两条**并发红线由本方案单方守住**(插件不感知):`build`(UV4 单实例)与 `bench`(COM6 / ST-LINK /
设备 IP 唯一)这两把锁的位置与 TTL 口径见 §7.1 / §7.2——插件树里跑 `/keil-build` 时,`keil_build.py`
会照旧自持自释,持有者名从 cwd 反推为 `pw:<name>`;`bench` 仍须手工占同一个名。共享联接(§3.3)
插件不会建,由本 skill 的 `plugin adopt <name>` 补(执行体命令行见 §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/<ID>-<slug>`,从主干当前 HEAD 切出 |
| `<ID>` | **语义短语**(如 `wave-replay-fix`),对应 `docs/任务规划/README.md` §2 顺序表里的那一行;纯临时任务用 `<日期>-<slug>` |
| 合并方式 | `git merge --no-ff` 合入主干 |
| 并发上限 | **同时最多 8 棵登记工作树**(2026-10-29 由 3 上调,用户裁定;另有插件树不计入,见 §3.4) |
**`<ID>` 用语义短语**是为了可追溯:任务分支名直接对应待办条目,回收时销项有据可查。
**并发上限的依据**(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\<pid>-<ns>\` | **不需要锁,但必须按树分家**(旧固定共享名跨树互踩:A 树可能执行 B 树编出的同名 exe,summary 相同不可辨;build/bench 是真独占,继续用锁) |
| 门禁 | `scripts/ci_local.py`(静态门禁 + 主机侧纯逻辑单测,纯软件) | **可并行** |
| 构建 | `/keil-build`(UV4 增量,需持 `build` 锁) | **必须串行** |
| 台架 | `/serial-boot-test` / 板级套件(需先持台架锁,见 §7) | **必须串行** |
| 提交 | 本地 `git commit`(多行正文用 `-F <UTF-8 无 BOM 文件>`) | 可并行 |
| `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 自动改写 | 手工并 `<File>` 条目 → 重跑 `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 <ref>:<file>` 逐项 `grep -F`),
**不要用 `tail -N` 目测**(会漏看被合并在中间的行),也**不要把合并前就存在的历史重复算进来**。
### 6.3 冲突时的解决位置
`done` 先做**非破坏性预演**:`git merge-tree --write-tree v2-on-ui task/<ID>-<slug>`。
- `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 <ID> <slug> [--base B] [--lean] [--needs-bench]` | 建分支 `task/<ID>-<slug>` + `_wt/<ID>-<slug>/` 工作树 + 共享联接 + 台账 |
| `list` | 所有任务一览:分支 / 落后领先 / 脏文件数 / 台架状态 + 两个锁的归属 |
| `status <ID>` | 单任务详情:未提交清单、相对基线漂移、台架记录、已建联接 |
| `sync <ID>` | `rebase` 追平主干,把冲突控制在任务分支上 |
| `lock acquire\|release\|status <build\|bench>` | 独占资源锁(`--task` / `--wait` / `--force` / `--bench-pass`) |
| `done <ID> [--dry-run] [--skip-build] [--skip-gates]` | 预演 → 合入主干 → 残留审计 → 重生成产物 → 门禁 → 清场 |
| `abort <ID> [--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/<name>/ # 插件树,目录内 .gitignore 自我忽略
├── .dsh-worktrees/worktree/<name>/ # 旧插件遗留位置;存在时继续受桥接与扫描排除保护
├── .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 <branch>` 编排 |
| 活跃数量 | 不设上限;实际受磁盘、构建排队、台架时间与人的跟踪精力约束 |
**判据与处置**:这不是本 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:<name>`。上台架前后使用同一个名字:
用 `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 <name>` 摘共享联接;再按 `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 <branch> <base>` 后再删。
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
@@ -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 <包>==<requirements.txt 里的版本>`,
**不要**顺手 `-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 <包>==<requirements.txt 里的版本>`;
**不要**顺手 `-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 第一行)。
+1 -1
View File
@@ -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
+154 -2
View File
@@ -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
+113
View File
@@ -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))
+4 -4
View File
@@ -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 = (