@@ -1,7 +1,7 @@
---
name: task-worktree
description: 多任务并行开发的空间管理(git worktree 生命周期):new 开一棵工作树 + 任务分支、目录联接共享 .venv 与模拟器 SDK、活跃任务硬上限 3 与腾位、build/bench 两类独占锁(含非登记任务占锁后「缺台账」释放不掉的正解)、done 预演合并回主干、门禁未全绿的补基线收尾、并集合并残留审计,以及**把仓外 detached worktree 当净室测量仪**——门禁指标异常增长时一次只注入一份在飞文件、与基线差分做逐文件归因 。规格见 docs/环境与部署/并行任务工作树方案.md。
whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;要为新任务开一个隔离空间;要把任务合并回主干;或遇到 `new` 报「已有 3 个活跃任务(上限 3)」、`lock release` 报「任务 X 不存在(缺台账)」、`done` 报「门禁未全绿:工作树与分支保留,未清场」、两个任务抢 COM6 / UV4、"改了文档被覆盖"、并集合并残留时;或**门禁指标异常增长(如 id-check 的不可判定数从 1,162 涨到 1,178、+16)却查不出是哪个文件 / 哪条任务道造成的**、主工作树压着多条在飞改动而不许 stash / 提交 / 改门禁脚本、要按 HEAD 基线只读出像验收时。
description: 多任务并行开发的空间管理(git worktree 生命周期):new 开一棵工作树 + 任务分支、目录联接共享 .venv 与模拟器 SDK、活跃任务硬上限 3 与腾位、build/bench 两类独占锁(含非登记任务占锁后「缺台账」释放不掉的正解)、done 预演合并回主干、门禁未全绿的补基线收尾、并集合并残留审计、**非登记工作树(detached HEAD / 无台账 / 树内有在飞或未跟踪内容)的安全回收**(逐文件内容分桶 + 联接/reparse 预扫 + 唯一残留先备份再 --force + 悬挂台账清理复验),以及**把仓外 detached worktree 当净室测量仪**——门禁指标异常增长时一次只注入一份在飞文件、与基线差分做逐文件归因;**以及与 DSH worktree 插件( `/worktree` 、模型工具 `worktree_create` ,树在仓内 .dsh-worktrees/)的桥接**—— `plugin adopt` 补共享联接、锁归属 `pw:<name>` 、回收仍归 /worktree 。规格见 docs/环境与部署/并行任务工作树方案.md。
whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;要为新任务开一个隔离空间;要把任务合并回主干;或遇到 `new` 报「已有 3 个活跃任务(上限 3)」、`lock release` 报「任务 X 不存在(缺台账)」、`done` 报「门禁未全绿:工作树与分支保留,未清场」、两个任务抢 COM6 / UV4、"改了文档被覆盖"、并集合并残留时;或**门禁指标异常增长(如 id-check 的不可判定数从 1,162 涨到 1,178、+16)却查不出是哪个文件 / 哪条任务道造成的**、主工作树压着多条在飞改动而不许 stash / 提交 / 改门禁脚本、要按 HEAD 基线只读出像验收时;或**要回收 `@task list` 里根本看不到的工作树**( `git worktree list` 有它、台账没有 / detached HEAD / 树里疑似留着别的会话的在飞或未跟踪内容)、或**要清台账里 branch / path 都已不存在的悬挂条目**( `@task list` 输出发怪、 `new` 报「任务已存在」或白占 3 个名额之一);或**这棵隔离空间是 DSH worktree 插件开的**(会话选了「Worktree模式」/ `/worktree create` ,树在仓库内 `.dsh-worktrees/` )——进去发现 `.venv` 不在、 `@build` 与门禁全部起跑失败,或想看清谁在占 UV4 / 台架 → F 节 。
---
# task-worktree — 多任务并行开发空间(短命令 `@task`)
@@ -9,17 +9,17 @@ whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;
**执行体 = 本 skill 自带脚本 ** `.dsh/skills/task-worktree/scripts/task_worktree.py`
(纯标准库,任意 python3;拷整个 skill 目录到别的项目即可用)。
``` powershell
@T3 @powershell
# cwd = 仓库根(或任意子目录)
& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" <子命令> [参数]
```
@T3 @
bash( `ops_bash` )里固定两个变量,下文用 `$TW` 代指脚本:
``` bash
@T3 @bash
PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktree.py"
"$PY" "$TW" list
```
@T3 @
退出码:`0` =成功 / `1` =操作失败 / `2` =前置或环境错误 / `3` =用法。
* * ⚠ 这几个码只有「不接管道」时才读得到**——原因与对策见「读输出」一节。
@@ -33,6 +33,7 @@ PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktr
| `status <ID>` | 单任务详情(未提交清单、相对基线漂移、台架记录、已建联接) |
| `sync <ID>` | 在任务工作树里 `rebase` 追平主干,把冲突控制在任务分支上 |
| `lock acquire|release|status <build|bench>` | 独占资源锁 |
| `plugin list|adopt|unadopt [插件树名]` | 与 DSH worktree 插件的桥:看清单 / 补共享联接 / 摘联接(**不删树**)→ F 节 |
| `done <ID>` | 预演 → 合入主干 → 并集残留审计 → 重生成产物 → 门禁 → 清场 |
| `abort <ID>` | 放弃任务(工作树 + 分支一起删,**永久丢弃**) |
@@ -52,7 +53,7 @@ PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktr
## 典型流程
``` powershell
@T3 @powershell
& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" new N21 dhara-isfree --needs-bench
# → 把新会话的工作目录切到打印出来的 _wt/N21-dhara-isfree/,在那里干活
@@ -62,7 +63,7 @@ PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktr
# 回到主工作树执行回收
& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" done N21
```
@T3 @
## 读输出:管道与截断(两起误判的共同根因)
@@ -75,78 +76,56 @@ PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktr
`conflict` 字样的片段 → 据此误判「有冲突」。实测:`tail -2` 误读成 conflict,放宽到 `tail -10` 才看到
`[ok] 无冲突,预演结果树 <sha>` 。dry-run 全文只有 5~8 行,直接读全量。
## 分步操作(四 条运行时机制)
## 分步操作(六 条运行时机制)
### A. `new` 被拒「已有 3 个活跃任务(上限 3)」——怎么腾位
1. 读原文:`前置/环境错误:已有 3 个活跃任务(上限 3);先 done/abort,或加 --force` ( exit 2)。
上限是脚本常量 `MAX_CONCURRENT = 3` ; **「活跃」的判据 = `_wt/.state/<ID>.json` 台账存在,且台账里
`path` 指向的目录还在**(手工删了工作树目录就不占名额,但会留下悬挂台账,见第 5 步)。
`path` 指向的目录还在**(手工删了工作树目录就不占名额,但会留下悬挂台账,见第 5 步与 **E 第 6 步 ** )。
2. `$TW list` 看全景(分支 / 落后领先 / 脏文件数)决定谁让位:**已完成的先 `done` **(合并回收,一步不省)。
3. 没人认领、零提交、尚未 claim 的空闲树 → `abort <ID>` 腾位(实测就是 abort 掉排在后面的空闲树换出名额)。
`abort` = 工作树 + 分支一起删、**永久丢弃**;工作树有未提交改动或已有提交时脚本会拒绝,确认可弃再加 `--force` 。
4. 确实要同时开第 4 棵才 `new <ID> <slug> --force` :磁盘代价约 **83 MB/棵 ** ( `--lean` 约 45 MB),
并行度上去了、**冲突面与台架排队也一起上去**——不是免费的。
5. **别用手工 `git worktree remove` 腾位 ** :台账不会被删(下次 `new` 同 ID 会报「任务 <ID> 已存在」),
还绕过了「先摘联接」(铁律 5),有让 git 递归进联接把主工作树 `.venv` 删掉的风险。
5. **别用手工 `git worktree remove` 给「登记任务」 腾位 ** :台账不会被删(下次 `new` 同 ID 会报「任务 <ID> 已存在」),
还绕过了「先摘联接」(铁律 5),有让 git 递归进联接把主工作树 `.venv` 删掉的风险。要回收的若是**非登记树**
( `$TW list` 里根本没有它:`new` 出来又被手工删掉只剩悬挂台账的残骸 / D 节 `--detach` 净室 / 别的会话手工建的)
→ 腾位与回收一起走 **E 节 ** ,那里有「怎么证明这棵树零联接所以可以 remove」的可执行判据。
6. 腾位后复验:`$TW list` 的条目数与 `ls _wt` 的目录数一致,再 `new` 。
### B. 自己占的锁没有台账 : `lock release` 报「任务 X 不存在(缺台账)」
### B. 非登记持有者占的锁 : `lock release --bench-pass ` 曾 报「任务 X 不存在(缺台账)」
**症状 ** (lead 在主工作树跑台架验收窗,用临时名占锁: `lock acquire bench --task acceptwin` ):
**症状 ** (lead 在主工作树跑台架验收窗,用临时名占锁 `lock acquire bench --task acceptwin` ):
```
@T3 @bash
"$PY" "$TW" lock release bench --task acceptwin --bench-pass
前置/环境错误:任务 acceptwin 不存在(缺台账 _wt/.state/acceptwin.json)
"$PY" "$TW" lock release bench --task acceptwin --force --bench-pass # 同一句错误,锁仍在
```
前置/环境错误:任务 acceptwin 不存在(缺台账 _wt/.state/acceptwin.json) # 锁照旧挂着
"$PY" "$TW" lock release bench --task acceptwin --force --bench-pass # 同一句错误,锁仍在
@T3 @
**根因 ** (读脚本确认) : `release` 分支里** 只要带 `--bench-pass` 就先 `load_state(task)` ** 去给 台账写 PASS,
台账不在就抛「前置/环境错误」,**根本走不到释放那一步**; `--force` 只覆盖「 持有者与 `--task` 不符」和
acquire 的陈旧接管,管不到台账缺失。而 `acquire` 侧**不查台账**——这个不对称就是坑本身;报错文案里的
「任务不存在」也别误读成「我没占过锁」 。
**根因 ** : `release` 分支里只要带 `--bench-pass` 就先 `load_state(task)` 去台账写 PASS, 而 `acquire` 侧
**不查台账 ** ——这个不对称让「释放」被「记账」挡死( `--force` 只覆盖持有者不符与陈旧接管,管不到台账)。
后果比报错本身大:锁没放,`done` 的前置 `release_stale_locks()` 对未到 TTL 的活锁直接判失败 →
**一个名字没登记,就挡住所有任务的收口 ** 。
1. 先 `$TW lock status` 确认持有者 / pid / 持有时长 / TTL(如 `bench acceptwin 持有(pid=2784,
已 0.1 / TTL 6.0 小时)`)——**别反复重试 release**。
2. **不需要同步记 PASS** → 直接 ` $TW lock release bench --task <NAME>`(不带 ` --bench-pass`)就能释放。
3. **需要 ` --bench-pass` 而这个名字不是登记任务** → 手写**最小占位台账** ` _wt/.state/<NAME>.json`
(键与 ` new` 写出的完全一致,共 11 个):
**2026-10-29 起已修 ** ( `task_worktree.py` :无台账降级为 `[warn] …跳过 PASS 记录,仅释放锁` 并照常释放)。
现在的正解只有三步,**不再需要手写占位台账**:
` ``json
{
"id": "acceptwin",
"slug": "lead-window",
"branch": "(none: lead 验收窗占位台账)",
"path": "D:\\Code\\DTU Code\\STM32F4-Base",
"base": "v2-on-ui",
"created_at": "2026-10-26 17:00:00",
"junctions": [],
"needs_bench": true,
"bench_pass": false,
"note": "lead 验收窗(非工作树任务,为 lock 台账占位)",
"lean": true
}
` ``
1. `$TW lock status` 认现场(持有者 / pid / 已持有时长 / TTL)——别对同一句报错反复重试 release。
2. `$TW lock release <build|bench> --task <NAME> [--bench-pass]` 直接放;PASS 记不上会打印 warn,锁照放。
3. **PASS 后面还要用 ** ( `done` 只认台账里的 `bench_pass` )→ 从一开始就用已 `new` 出来的任务 ID 占锁,
或把验收结论写进主干提交说明 / 文档。**插件树 `pw:<name>` 天然没有台账**,别指望它留 PASS → F 节。
用写文件的工具落 UTF-8(无 BOM),别用 ` echo` / ` Set-Content`(中文乱码铁律)。
4. ` $TW lock release bench --task <NAME> --bench-pass` → ` [ok] 已为任务 … 记录台架 PASS` +
` 已释放锁 bench(原持有者 …)`。
5. **立刻删掉占位文件**:它活着期间会被 ` list` 当任务列出,并**占掉 3 个活跃名额之一**( ` path ` 指向的
目录存在就算活跃);而且**绝不对这个 ID 跑 ` done` / ` abort`**——清场会拿台账里的 ` path` / ` branch`
去 ` git worktree remove` 和删分支。
6. 复验 ` $TW lock status`: ` build 空闲(TTL 1.0h) ` / ` bench 空闲(TTL 6.0h) `。
7. 占位台账一删,那条 PASS 也随之消失。**这个 PASS 后面还要用的话,从一开始就用已登记的任务 ID 占锁**
( lead 的验收窗借某个已 ` new` 出来的 ID 最省事),或把验收结论写进主干提交说明/文档。
8. **为什么必须马上释放**: ` done` 的前置会跑 ` release_stale_locks()`——**未到 TTL 的活锁直接判失败**:
` 独占资源 bench 仍被 acceptwin 占用(已 0.1 / TTL 6.0 小时),先 release`。lead 挂着锁 = 挡住所有任务
的收口;只有超过 TTL 的锁才会被自动接管。
9. 最后手段(脚本本身都走不通时):锁就是 ` _wt/.state/locks/<name>.lock` 里的单行 JSON
( ` name/task/pid/host/epoch/at/note/ttl`),` release` 的实现就是 unlink 它——手工删这个文件等价于
「不记 PASS 的强制释放」,只在前面几步都不可用时才用。
最后手段(脚本本身都走不通时):锁就是 `_wt/.state/locks/<name>.lock` 里的单行 JSON
( `name/task/pid/host/epoch/at/note/ttl` ),`release` 的实现就是 unlink 它——手工删这个文件等价于
「不记 PASS 的强制释放」,只在前面几步都不可用时才用。**历史遗留的占位台账**(早期为绕这个坑手写的
`_wt/.state/<NAME>.json` )现在已无必要:它活着时既占 `MAX_CONCURRENT = 3` 名额又被 ` list` 当任务列出,
按 E 第 6 步备份后删掉即可。
### C. `done <ID>` 的正确用法(它是合并点,不是查看器)
1. **合并前确认任务属主已把全部改动提交完**。` $TW status <ID>` 看未提交清单与领先提交数;跨会话任务
1. **合并前确认任务属主已把全部改动提交完 ** 。** ` $TW status <ID>`** 看未提交清单与领先提交数;跨会话任务
再问一句属主(`send_message` )。实测教训:属主还剩「2/2」没提交就 `done` ,把只含 1/2 的分支合进了主干,
只能另开一棵树补后半并向对方说明(是自己的时序错,不是分支问题)。
2. **先 `--dry-run`,并读全量输出 ** (别 `tail -2` ,见「读输出」)。判据两行:
@@ -162,7 +141,7 @@ acquire 的陈旧接管,管不到台账缺失。而 `acquire` 侧**不查台
不同根因处置不同(见陷阱「`done` 的门禁 = `pre-commit run --all-files` 」)。最常态是 `.map` 滞后 → 第 5 步。
5. 补绿固定链(2026-10-25 实测;顺序即因果,全在**主工作树**跑,逐条不接管道看退出码):
` ` ` bash
@T3 @ bash
"$PY" scripts/check_mem_numbers.py --write # 台账受管区 ↔ 新 .map 对齐
"$PY" scripts/ram_baseline.py --write # RAM 快照 / 92% 预算台账
"$PY" scripts/ram_baseline.py --update # 趋势基线追加一行
@@ -170,7 +149,7 @@ acquire 的陈旧接管,管不到台账缺失。而 `acquire` 侧**不查台
"$PY" scripts/check_doc_links.py --all # 文档链接与章节引用
git add <上面几条刷出来的基线/快照文件> # 显式列文件,别 -A 把别人在飞的改动一起提交
git commit -F <UTF-8 无 BOM 的正文文件> # 提交属 Ask first( AGENTS.md 边界),按用户指示再做
` ``
@T3 @
为什么必然要补,见陷阱「`done` 的门禁跑在 `@build` 之前」。
6. **重跑 `done` 之前主工作树必须干净 ** ——否则前置检查直接
@@ -191,14 +170,14 @@ detached HEAD 的干净工作树当净室,一次只往里注入一份在飞文
1. **建净室 + 先落基线数 ** (路径必须**在仓库目录之外**;铁律 7 对临时树同样成立——放仓内就污染 `git status` /
`pre-commit --all-files` / 全库搜索):
` ` ` bash
@T3 @ bash
MAIN="D:/Code/DTU Code/STM32F4-Base" # 主工作树绝对路径(含空格 → 变量整体加引号)
WT="$MAIN/../_wt/und-attr" # 仓外同级 _wt/ 下;一次测量固定用同一个名字
rm -rf "$WT" 2>/dev/null
git -C "$MAIN" worktree add --detach "$WT" HEAD >/dev/null 2>&1 || { echo WT_FAIL; exit 9; }
git -C "$WT" status --porcelain # 应为空
"$PY" "$WT/scripts/archive/id_gate/check_ids.py" 2>&1 | tail -25 # ← 口径:id-check 钩子已于 2026-10-28 退役(脚本移入 scripts/archive/id_gate/),按该脚本自身默认参数跑;基线与注入用**同一条命令**
` ``
@T3 @
- `--detach` ( **不要 `-b` **):净室没有分支、不写 `_wt/.state/<ID>.json` 台账 → **不占 `MAX_CONCURRENT = 3`
名额、不出现在 `$TW list` **,也没人会被误指去对它跑 `done` (建与拆都不走 `$TW new` / `$TW done` )。
@@ -208,14 +187,14 @@ git -C "$WT" status --porcelain # 应为空
看清会删什么,只挪走多出来的那些再测;语料被污染出来的数字与 CI 不可比。
2. **一次只注入一份在飞文件 → 跑同一个扫描器 → 与基线差分 ** (得到的是每个文件的**独立**贡献,不是合计):
` ` ` bash
@T3 @ bash
for f in "App/log/sys_log_mcu.c" "test/host/sys_log_mcu/sys_log_mcu_test.c" "docs/子系统设计/运行与自检记录设计说明.md"; do
mkdir -p "$WT/$(dirname "$f")"
cp "$MAIN/$f" "$WT/$f" # 拷进净室;主树那份只读、一字节不动
echo "== $f =="; "$PY" "$WT/scripts/archive/id_gate/check_ids.py" 2>&1 | tail -25 # ← 与第 1 步同一条命令
git -C "$WT" checkout -- "$f" # 只还原净室里自己刚拷进来的那份
done
` ``
@T3 @
实测:`run_par.c` / `board_ui_nav.py` / `check_nested_comment.py` / keil `SKILL.md` **各 +0 ** ,
而 log 道那三个文件 * * +6 / +8 / +2**,合计**正好 +16**。**Σ(逐文件增量) ≠ 观察到的涨幅 → 还有没被注入的
@@ -226,18 +205,274 @@ done
全部落进 `skip_bare_unmanaged` —— 到这一步结论才配发给属主。
4. **闭合 + 复验主树一个字节没被污染 ** :
` ` ` bash
@T3 @ bash
git -C "$MAIN" worktree remove --force "$WT" # 净室里只有自己的拷贝、没有原件,--force 安全
git -C "$MAIN" worktree list # 不该再有 und-attr
git -C "$MAIN" status --porcelain | sort # 与测量前**逐行一致**(测量前先存一份基线清单)
` ``
@T3 @
「增量 + 字面量类别 + 属主」三者齐了才算归因完成。**同一条道自己「我自查过不是我们」的结论别直接采信**
→ 陷阱「队友的自查结论要复核」。
⚠ **上面第一行的前提是有条件的 ** :树里混进了别人的在飞 / 未跟踪内容时「只有自己的拷贝」就不成立了,
`--force` 直接拆等于替别人把唯一版本删掉 → 先走 **E 节 ** (全景 → 逐文件分桶 → 联接预扫 → 唯一残留先备份),
再回来执行本步;复验按 **E 第 5 步**做(拆前/拆后主树 `status --porcelain -uall` 行数必须相同)。
**同一棵净室树的第二种用法 ** :按指定 commit **只读**构建出像 / 烧录验收——不搬动任何在飞改动,也不为台架
提前抢发一笔提交;构建与烧录都在这棵树里做(不在主树),仍受 `bench` / `build` 锁串行(铁律 3)。
### E. 回收**非登记**工作树(`$TW list` 为空 / detached HEAD / 树里有在飞或未跟踪内容)
**这类树的判定特征 ** (命中任意一条就适用本节):`git worktree list` 里有它、但 `_wt/.state/<ID>.json` 台账不存在;
HEAD 是 detached( `git rev-parse --abbrev-ref HEAD` 打 `HEAD` );或树里的改动不属于本会话(别的会话留下的未提交 /
未跟踪文件)。典型来源:D 节的 `--detach` 净室、`new` 之后被手工删目录只剩台账的残骸、别人手工 `git worktree add` 建的分析树
( 2026-10-28 实测一次回收 3 棵:`verify-task16` / `verify-batch` / `und-attr` ,各 89 MB,全部无台账 + detached)。
**为什么 `done` / `abort` 在这里整体不适用 ** (铁律 9):两条子命令的前置都是 `load_state()` ,无台账 →
`前置/环境错误:任务 <ID> 不存在(缺台账 …json) ` (exit 2)当场停住、什么也不会发生;而**假如有**一份残留台账,
`abort --force` 会拿台账的 `path` 直接 `git worktree remove` —— **绕过下面第 1~4 步的全部内容保全 ** ,把别的会话的唯一版本一次删光;
`done` 则会去找那条(多半已不存在的)`task/` 分支做合并。另:本路径没有「合并」这一步,所以 `done` 的并集合并残留审计
在这里不发生,也没有三方对比可依据 —— **唯一性只由第 2 步的内容分桶给出 ** 。回收只能手工走本节第 1~5 步 + 第 6 步清台账。
1. **建全景(全程只读) ** :先看 `git worktree list` 的全集,再**逐棵**记三件事(脏行数含未跟踪 / 树顶提交 / 有无分支)
加一条台账判定,同时存下主树的拆前基线清单:
@T3 @bash
MAIN="D:/Code/DTU Code/STM32F4-Base"; WTROOT="$MAIN/../_wt"
git -c core.quotePath=false worktree list # 全集:登记树 + 非登记树都在这份里
MAIN_SHA=$(git -C "$MAIN" rev-parse --short HEAD) # 下面所有比对都以这一版为主线尺子
mkdir -p ci-artifacts
git -C "$MAIN" status --porcelain -uall > ci-artifacts/wt-main-before.txt # 拆前主树基线(第 5 步逐行比)
wc -l < ci-artifacts/wt-main-before.txt # 记下行数(实测 0)
for t in "$WTROOT"/*/; do
echo "--- $t"
git -c core.quotePath=false -C "$t" status --porcelain -uall | wc -l # 脏 + 未跟踪行数
git -c core.quotePath=false -C "$t" log --oneline -1 # 树顶提交
git -C "$t" rev-parse --abbrev-ref HEAD # 打 HEAD = detached,压根没分支可 done
ls "$WTROOT/.state/$(basename "$t").json" 2>/dev/null || echo "NO-LEDGER" # 台账位置随 DTU_WT_ROOT
done
@T3 @
- **`-uall` 不是可选**:默认 `status --porcelain` 会把未跟踪目录折叠成一行 `dir/` , `-uall` 才展开到文件级;少了它
第 2 步的分桶就漏项(实测 `und-attr` 唯一那份就是未跟踪 `?? scripts/check_nested_comment.py` )。
- 路径含空格(本项目 `DTU Code` )→ 变量整体加引号;`core.quotePath=false` 让中文路径原样输出,便于逐行核对。
- 判定「是不是登记树」看两样:**台账在不在 + HEAD 是否 detached**。两者都指「非登记」→ 从这里开始**禁止**
`done` / `abort` (见本节开头),继续第 2 步。
2. **逐文件与主线 HEAD 比内容并分三桶 ** : `IN-MAINLINE` (内容等价,拆 = 零损失)/ `UNIQUE-UNMERGED` (主线没有这份
内容,拆 = 丢)/ `OLDER-DRAFT` (同路径主线已有更新版本)。落成独立只读探针跑(别用 heredoc 现写现改,
见陷阱「别用 heredoc 打补丁造探针」):
@T3 @python
# ci-artifacts/wt_bucket_scan.py — 只读:逐文件与主线工作区/HEAD 比对,三桶归类
import os, subprocess, tempfile
MAIN = r"D:\Code\DTU Code\STM32F4-Base"
WTROOT = os.path.join(os.path.dirname(MAIN), "_wt")
TREES = ["verify-task16", "verify-batch", "und-attr"] # ← 第 1 步全景里挑出的非登记树
SKIP_TOP = {".venv", "MDK-ARM", ".git", "ci-artifacts"} # 联接 / 构建产物 / 忽略区,按项目调
def git(*a):
return subprocess.run(("git", "-c", "core.quotePath=false", "-C", MAIN) + a,
capture_output=True, text=True, errors="replace")
def bucket(rel, tree):
p = os.path.join(tree, rel)
if not os.path.exists(p):
return "OTHER", "工作树里已不存在(多为已 staged 删除)"
tf = tempfile.NamedTemporaryFile(delete=False)
tf.write(open(p, "rb").read()); tf.close()
# 1) 与主线工作区(= 当前 HEAD 的检出内容)逐字节比
if git("diff", "--no-index", "--quiet", tf.name, rel).returncode == 0:
os.unlink(tf.name); return "IN-MAINLINE", "与主线 HEAD 逐字节相同"
# 2) 与主线历史各版比:内容等价于某个旧 commit → 只是主线又往前走了
hist = [x.strip() for x in git("log", "--format=%H", "HEAD", "--", rel).stdout.splitlines()]
for sha in hist[1:]:
old = git("show", f"{sha}:{rel}")
if old.returncode == 0 and old.stdout == open(p, encoding="utf-8", errors="replace").read():
os.unlink(tf.name); return "IN-MAINLINE", f"内容等价于主线历史版 {sha[:8]}"
# 3) 主线从没这条路径 → 唯一;否则同路径主线有更新版 → 更旧草稿(再用 blob diff 定性方向)
os.unlink(tf.name)
if not hist:
return "UNIQUE-UNMERGED", "主线 HEAD 与历史都没有此路径"
return "OLDER-DRAFT", "同路径主线有更新版本(用 diff --stat <HEAD> -- <f> 定性)"
for name in TREES:
tree = os.path.join(WTROOT, name)
lines = subprocess.run(("git", "-c", "core.quotePath=false", "-C", tree,
"status", "--porcelain", "-uall"),
capture_output=True, text=True, errors="replace").stdout.splitlines()
tally = {}
for line in lines:
rel = line[3:].split(" -> ")[-1].strip('"') # 兼容 ?? / M / A / R 的箭头形态
if rel.split("/")[0] in SKIP_TOP:
continue
b, why = bucket(rel, tree)
tally.setdefault(b, []).append((rel, why))
g = lambda k: len(tally.get(k, []))
print(f"== {name}: IN-MAINLINE={g('IN-MAINLINE')} UNIQUE-UNMERGED={g('UNIQUE-UNMERGED')} "
f"OLDER-DRAFT={g('OLDER-DRAFT')} OTHER={g('OTHER')}")
for b in ("UNIQUE-UNMERGED", "OLDER-DRAFT", "OTHER"):
for rel, why in tally.get(b, []):
print(f" {b}: {rel} # {why}")
@T3 @
报告里必须**逐桶给计数 + 判定证据**(实测:`verify-task16` IN-MAINLINE=6 / UNIQUE=0; `verify-batch` IN-MAINLINE=35 /
UNIQUE=1; `und-attr` IN-MAINLINE=0 / UNIQUE=1)。两个非空桶再各钉一条命令把证据坐实:
@T3 @bash
# 「唯一未合并」是真更新还是更旧草稿?blob 级 diff + 逐行看方向
git -c core.quotePath=false -C "$MAIN" diff --stat "$MAIN_SHA" -- simulator/tests/README.md
diff <(git -C "$MAIN" show "$MAIN_SHA":simulator/tests/README.md) "$WTROOT/verify-batch/simulator/tests/README.md"
# 「树顶提交要不要保」只看一件事:是否已并入 HEAD。别拿「目录很久没动」当判据
for c in 77779d49 1d4b6a17; do
git -C "$MAIN" merge-base --is-ancestor "$c" HEAD && echo "$c MERGED" || echo "$c NOT-merged"
done
@T3 @
实测:那份 `simulator/tests/README.md` 树内写「27 页 × 3200 B」、主线 HEAD 已是「60 张 × 3200 B」→ **更旧草稿 ** (被取代的
早先版本),不是未合并成果;`bench-21-77779d49` / `bench-23-1d4b6a17` 两棵 `merge-base --is-ancestor` 判 **MERGED ** 且脏数 = 0
→ **保留不动 ** (不属本节,等属主自己收,报告里点名说清为什么留)。
**回收判据不是「看着旧」 ** :唯一内容如果没被提交过,`git log` 里根本看不见它;反过来「提交已 MERGED」也不等于可以拆——
还得看未跟踪那一层。`UNIQUE-UNMERGED` 非空又确实要留 → 它进第 4 步的备份,**不许靠 `--force` 顺手带走**。
3. **拆前扫联接 / reparse point(物理安全性 = 铁律 5 的可执行判据) ** 。存成 `ci-artifacts/wt_junction_scan.py` 再跑:
@T3 @python
# 只读预扫:一个路径是不是 reparse point( Windows 目录联接在这里现形);发现即停止深入,绝不 walk 进联接
import os, ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
GA = ctypes.windll.kernel32.GetFileAttributesW
def is_reparse(p):
a = GA(p)
return a != -1 and bool(a & FILE_ATTRIBUTE_REPARSE_POINT)
for name in ["verify-task16", "verify-batch", "und-attr", "bench-21-77779d49", "bench-23-1d4b6a17"]:
top = os.path.join(r"D:\Code\DTU Code\_wt", name)
hits, entries = [], 0
for root, ds, fs in os.walk(top):
bad = [d for d in ds if is_reparse(os.path.join(root, d))]
if bad:
hits += [os.path.join(root, d) + "/" for d in bad]
ds[:] = [] # 有联接就不再深入:防转进主树 .venv 把别人整棵树数一遍
hits += [os.path.join(root, f) for f in fs if is_reparse(os.path.join(root, f))]
entries += len(ds) + len(fs)
print(f"== {name} scanned entries={entries}")
print(" REPARSE: none → 可直接 git worktree remove --force"
if not hits else " REPARSE:\n " + "
".join(hits))
@T3 @
实测输出 `== verify-task16 ... REPARSE: none` (每棵的体积 / 条目数 + 零联接就是证据),而 `bench-*` 两棵各挂一个 `.venv`
联接 → 判 **KEEP ** (本节不拆它们;将来真要拆,先 `rmdir` 摘掉联接再 remove)。
**判据是「扫出来一个都没有」,不是「我记得没建过联接」 ** : `new --no-bootstrap` 的树、别的手工 `mklink /J` 的树都可能带着指向主树的联接。
摘联接用 `rmdir` (只删联接本身;**不是** `rd /s` ,更不是 `rm -rf` ——那会顺着联接删目标)。
非 Windows 环境改用 `os.path.islink` / `find <tree> -type l` (本项目是 Windows + Keil 工具链,按上面这条走)。
**到这里才算「拆前唯一性自检 + 物理安全预扫」齐 ** : `--force` 的安全前提 = 第 2 步 `UNIQUE-UNMERGED` 为 0
(或剩下的已全部进第 4 步备份)**且**这里 `REPARSE: none` 。
4. **唯一内容先备份,再 `--force` 拆 ** 。`UNIQUE-UNMERGED` 与需要留档的 `OLDER-DRAFT` 一律先备份进主树
`ci-artifacts/wt-leftover-backup/` ( `ci-artifacts/` 已被 `.gitignore` 忽略 → **备份不会改变主树脏行数 ** ;仓库根不放一次性产物),
**文件名 + 字节数必须打进输出**当证据:
@T3 @bash
BK="$MAIN/ci-artifacts/wt-leftover-backup"; mkdir -p "$BK"
# 命名约定:<树名>__<原路径里的 / 换成 _>,反向可定位;台账备份统一后缀 .state-bak
cp "$WTROOT/verify-batch/simulator/tests/README.md" "$BK/verify-batch__simulator_tests_README.md"
cp "$WTROOT/und-attr/scripts/check_nested_comment.py" "$BK/und-attr__scripts_check_nested_comment.py"
ls -l "$BK" # 实测:verify-batch__simulator_tests_README.md 1769 bytes / und-attr__scripts_check_nested_comment.py 14827 bytes
for t in verify-task16 verify-batch und-attr; do
git -C "$MAIN" worktree remove --force "$WTROOT/$t"; echo "$t remove_rc=$?" # 不接管道才读得到码(见「读输出」)
done
git -C "$MAIN" worktree prune # 顺带清 .git/worktrees 里的残籍
@T3 @
自检:**备份条目数 = 第 2 步里判「要留档」的条目数**,且每个都带字节数。这一步就是本节相对 D 第 4 步的增量——
D 的前提是「净室里只有自己的拷贝」,本节的树里混着别人在飞的东西,`--force` 是一次不可逆删除,**先备份才谈得上安全**。
5. **复验:主树一个字节没动 + 树确实没了 ** :
@T3 @bash
git -C "$MAIN" status --porcelain -uall > ci-artifacts/wt-main-after.txt
wc -l < ci-artifacts/wt-main-before.txt; wc -l < ci-artifacts/wt-main-after.txt # 两个数必须相同(实测 0 → 0)
diff ci-artifacts/wt-main-before.txt ci-artifacts/wt-main-after.txt # 空输出 = 逐行一致
git -c core.quotePath=false -C "$MAIN" worktree list # 已拆路径逐条消失;主树与判「保留」的树原样在列
@T3 @
行数不同 = 备份或摘除碰到了主线(多半是备份写进了未被忽略的目录,或漏摘联接)→ 立刻定位,别报「回收完成」。
第 2 步判「保留不动」的树还在 `worktree list` 里是**预期结果**,在报告里点名解释为什么留(附 `MERGED` 判据)。
6. **顺手清悬挂台账 ** ( branch / path 都已不存在的 `_wt/.state/<ID>.json` ;它会让 `$TW list` 打出一行怪条目、并让下次
`new` 同 ID 报「任务 <ID> 已存在」)。先给证据、后动手:
@T3 @bash
for j in "$WTROOT"/.state/*.json; do # 台账位置随 DTU_WT_ROOT
id=$(basename "$j" .json)
br=$("$PY" -c "import json,sys;print(json.load(open(sys.argv[1],encoding='utf-8')).get('branch',''))" "$j")
pp=$("$PY" -c "import json,sys;print(json.load(open(sys.argv[1],encoding='utf-8')).get('path',''))" "$j")
git -C "$MAIN" rev-parse --verify "$br" >/dev/null 2>&1 && b=branch-OK || b=branch-MISSING
[ -d "$pp" ] && p=path-OK || p=path-MISSING
echo "$id $b $p <- $j"
done
@T3 @
实测这条判据打出的正是 `v5tool2 branch-MISSING path-MISSING` : `git rev-parse task/v5tool2-v11` 报
`fatal: ambiguous argument 'task/v5tool2-v11': unknown revision ...` ,台账 `path` 指的目录已无,`git worktree list` 里也没有这棵树。
**三条齐(rev-parse fatal + path 目录不存在 + worktree list 无此树)才叫悬挂 ** ;缺任何一条都回到第 1~2 步查是不是真有东西要保。
然后**备份 → 删 → 复跑两条 list 复验**:
@T3 @bash
cp "$WTROOT/.state/v5tool2.json" "$BK/v5tool2.json.state-bak" && rm "$WTROOT/.state/v5tool2.json"
"$PY" "$TW" list # 该条目消失(全空时打「(没有任务空间。用 new <ID> <slug> 建一个)」),echo RC=$? 应为 0
"$PY" "$TW" lock status # build 空闲(TTL 1.0h) / bench 空闲(TTL 6.0h)—— 证明清台账没牵连两把锁
ls "$WTROOT"/.state/locks/ 2>/dev/null # 若该 ID 名下还有 .lock,先按 B 节释放,再删台账
@T3 @
- **必须 `cp` 先备份再 `rm` **:台账里存着 base / junctions / needs_bench / bench_pass,是真出纠纷时唯一的现场记录。
- 悬挂条目按定义 `path` 已不存在 → 通常**不占** `MAX_CONCURRENT = 3` 名额(A 第 1 步的「活跃」判据要求 path 目录还在),
但它把 `$TW list` 搅成怪输出、并永久挡住同 ID 复用;**path 还在的占位/残留台账才占名额**(早期为绕 B 节那个坑手写的占位台账就是这种)。两种都要清。
- **仍不对这个 ID 跑 `done` / `abort` **:清场会拿台账里的死 `path` / 死 `branch` 去做 `git worktree remove` 和删分支
(这正是 A 第 5 步说的「手工删目录留下的尾巴」,正解就是本步)。历史遗留的占位台账同理——活着时也别
`done` / `abort` ,用完直接删文件(来历与现状见 B 末条)。
### F. 与 DSH worktree 插件(`/worktree`、模型工具 `worktree_create`)共存
本仓现在有**两套**隔离空间。别混用,也别互相回收:
| | `@task` (本 skill) | DSH worktree 插件(`dsh-task-worktree` ) |
|---|---|---|
| 树在哪 | 仓库**外** `../_wt/<ID>-<slug>/` | 仓库**内** `.dsh-worktrees/worktree/<name>/` |
| 谁建 | `new <ID> <slug>` (人来敲命令) | 会话选「Worktree模式」/ 模型调 `worktree_create` (零命令) |
| 台账 | `_wt/.state/<ID>.json` (本 skill) | `.dsh-worktrees/manifest.json` (插件私有格式,别去改) |
| 分支名 | `task/<ID>-<slug>` | `worktree/<name>` |
| 共享联接 | `new` 自动建 | **建树时没有 ** → 要 `plugin adopt` 补 |
| 回收 | `done` (预演 / 合并 / 门禁 / 清场)或 `abort` | 用户敲 `/worktree finish\|bring-back\|remove\|prune` (模型碰不到收尾命令) |
**为什么允许它把树建在仓内 ** (铁律 7 的唯一例外):路径写死在插件里(`WORKTREE_NAMESPACE = worktree`
+ `<repo>/.dsh-worktrees/` ),而暴露给模型的工具只有 `worktree_create` / `worktree_list` /
`worktree_status` 三个,配置面改不动。兜法三件(均已落地):
1. `.gitignore` **预置 ** `.dsh-worktrees/` —— 插件首次 `create` 会往 `.gitignore` 追加一模一样的字面量,
`ensureIgnored()` 见到同名规则即跳过;不预置就等于让插件在会话中途改这份受控文件(diff 里冒出
一行"不是我加的")。
2. 全库扫描的排除面登记了它:`scripts/check_doc_links.py` 的 `SKIP_PARTS` 、
`tools/refgraph/gen_refgraph.py` 的 `EXCLUDE_DIRNAMES` 、`scripts/check_layer_deps.py` 的 `os.walk` 兜底。
一份仓库副本被全库扫描吞进去 = 文档链接假红 + 配图生成物逐字节漂移砸在某位同事头上。
3. 联接与锁归属由本节命令接上(下面三步)。
**这棵树是不是插件的 ** : `git worktree list` 里路径含 `.dsh-worktrees/` 就是。`$TW list` 现在会在台账段
下面另起一段列出它们,并标 `已联 / 未联` 。**插件树不占 `MAX_CONCURRENT = 3` 名额**——回收不归本 skill,
硬计入只会让别人那棵关不掉的树把 `new` 挡死(`new` 会打印一行「另有 N 个插件工作树不计入上限」提醒并发代价)。
三步接上(`<name>` 可带 `worktree/` 前缀,也可多段如 `refactor/logging` ):
@T3 @bash
"$PY" "$TW" plugin list # 一览:分支 / 脏文件数 / 联接已联·未联
"$PY" "$TW" plugin adopt N21-dhara-isfree # 只补缺失的 .venv 与 simulator/SDL2 联接
"$PY" "$TW" plugin unadopt N21-dhara-isfree # 只摘联接(os.rmdir),绝不动目标内容与树里文件
@T3 @
- **adopt 之前那棵树跑不了 Python**: `.venv` 不入库,新检出的树里没有它 → `.venv/Scripts/python.exe`
找不到解释器,`@build` 、`ci_local.py` 、每个 `scripts/check_*.py` 全部起跑失败。这是插件树最常见的
「莫名失败」,先 `plugin adopt` 再谈别的。
- **adopt 只碰联接**:已联的跳过;`simulator/SDL2` 若已是实体目录只告警不动;`.dsh-worktrees/` 之外的路径
一律 exit 2(实测 `plugin adopt ".."` 被拒)——这道闸防的是把主树或 `_wt` 目录当插件树遮蔽掉真实内容。
- **锁归属现在认得出来**: `@build` 在插件树里自持自释时,持有者名从 cwd 反推成 `pw:<name>` (多段名压平成
`-` ),与 `plugin adopt` 打印的那个名字逐字相同(`keil_build.py: plugin_tree_owner()` ↔
`task_worktree.py: plugin_lock_owner()` )→ `lock status` 不再一律落成 `build-cli` 。
- 上台架仍须手工占锁、用同一个名:`lock acquire bench --task pw:<name> --note <说明>` →
`lock release bench --task pw:<name> --bench-pass` 。插件树没有台账 → 按 B 节只 warn、锁照常放。
- **命名建议**:插件 `<name>` 沿用 `@task` 的 `<ID>-<slug>` 形态(如 `N21-dhara-isfree` ),两套视图同名好对账;
但分支前缀必然不同(`worktree/` vs `task/` ),别把两者说成同一个分支。
- **收尾不在这**: `done` / `abort` 拿插件树名下手只会 exit 2(这是保护,别绕过去手工 `git worktree remove` ;
要拆先 `plugin unadopt` 摘联接,再由**用户** `/worktree remove` )。`/worktree bring-back` 要求主工作树干净,
本仓常有在飞改动 → 提交或 stash 由用户定。反过来插件也不会碰 `_wt/` (路径根不同,谁也删不掉谁)。
## 铁律
1. * * `done` 只能在主工作树执行**——主干分支检出在主工作树,git 不允许同一分支在两处检出。
@@ -246,29 +481,50 @@ git -C "$MAIN" status --porcelain | sort # 与测量前**逐行一致**(
设备 IP `192.168.1.100` 各只有一份。**构建与烧录都不可并发。**
- `build` 锁由 * * `@build` 自持自释**,调用方无需手工 acquire/release;
- `bench` 锁**必须手工**: `lock acquire bench --task <ID>` → 烧录/`@test` /探针 →
` lock release bench --task <ID> --bench-pass`。非登记任务 ( lead 的验收窗)占了锁怎么放 → B 节。
`lock release bench --task <ID> --bench-pass` 。非登记持有者 ( lead 的验收窗、插件树 `pw:<name>` )占了锁
怎么放 → B 节(2026-10-29 起无台账也只 warn、照常释放);插件树整体口径 → F 节。
4. **单写者文档 ** : `docs/任务规划/` (整个文件夹)、`AGENTS.md` 的任务表块、`MDK-ARM/*.uvprojx` 、
`.pre-commit-config.yaml` 、内存快照 —— 任务期**不在任务工作树里改**,留到 `done` 统一处理。
5. **清场必须先摘联接 ** :脚本会先 `os.rmdir` 掉 `.venv` / `simulator/SDL2` 联接再
`git worktree remove` 。**手工清场时千万别直接 `git worktree remove` **——git 可能递归进联接,
把主工作树的 ` .venv` 一起删掉。
把主工作树的 `.venv` 一起删掉。手工拆任何一棵树之前先跑 **E 第 3 步**的预扫,拿到 `REPARSE: none` 才动手。
6. **活跃任务硬上限 3 ** (脚本常量 `MAX_CONCURRENT = 3` ,每棵约 83 MB 检出)。满了 `new` 直接 exit 2,
腾位顺序与 ` --force` 的代价 → A 节。
7. ** 工作树放仓库同级 ` _wt/`,绝不在仓库内部**——否则污染 ` git status`、 ` pre-commit --all-files` 与全库搜索。
腾位顺序与 `--force` 的代价 → A 节;悬挂台账的清理与复验 → **E 第 6 步 ** 。
7. * * `@task` 的 工作树放仓库同级 `_wt/` ,绝不在仓库内部**——否则污染 `git status` 、 ` pre-commit --all-files`
与全库搜索。唯一例外 = DSH worktree 插件写死的仓内 `.dsh-worktrees/` (改不了;已用「预置 .gitignore +
三个全库扫描门禁排除」兜住,见 **F 节 ** )。自己新建的树一律走 `_wt/` 。
8. * * `done` 是合并点**:属主没提交完就别 `done` ;合并提交一旦进了主干,只用「主工作树补提交」收尾,
**不用 `reset --hard` 反悔 ** → C 节。
9. **非登记 / detached 的树绝不跑 `done` / `abort` ** :两者都以台账为前置,无台账直接 exit 2;带着残留台账时
`abort --force` 会绕过内容保全把别人的在飞改动连树一起删。回收走 **E 节 ** (全景 → 分桶 → 联接预扫 → 备份再拆 →
复验 + 清悬挂台账)。
## 陷阱(实测)
- **`--force` 不是万能钥匙**:它只覆盖四类检查——并发上限、持有者与 `--task` 不符、陈旧锁接管、
清场时强制移除工作树。**台账缺失这类前置/环境错误(exit 2)跟它无关**(实测 `lock release
--force --bench-pass` 报的是同一句话,锁照旧没放)。
--force --bench-pass` 报的是同一句话,锁照旧没放)。同理 `abort --force` 也只覆盖「树里有东西」这一条拒绝,
**不会**替你做 E 节的内容保全——它只会删得更快。
- **未跟踪文件在 `git log` 、提交门禁与 `done` 的合并里都不留痕**(2026-10-29 回收现场):钩子按 HEAD tree 的
diff-index 取文件,未跟踪文件从没进过任何钩子的视野;`git log` 也照不出「唯一未合并」——它压根没被提交过。
所以「这棵树没有遗留改动」这种结论**只能**由 E 第 2 步的逐文件内容比对给出,`log --oneline -1` + 脏计数都不够。
- **`git status --porcelain` 不带 `-uall` 会折叠未跟踪目录**(E 第 1 步):「全景」少一项、分桶少一个文件,
最后拆掉的是别人唯一的草稿。回收路径上**一律 `-uall` **。
- **`$TW list` 空 ≠ 没有工作树**: `list` 只遍历台账(A 第 1 步),用户说的「那 3 棵 done 掉」很可能在 `$TW list` 里
一个都不存在(实测就是)→ 别照 `$TW list` 的空结果回「没有任务」,先 `git worktree list` 拿全集再逐棵定性(E 节)。
2026-10-29 起 `list` 还会另起一段列出插件树(`.dsh-worktrees/` , F 节)——**"看见"不等于"归 `done` 管"**。
- **「这棵树很久没动」不是回收判据**:实测 `bench-*` 两棵既没台账也没脏文件、mtime 很旧,但真正决定去留的是
`git merge-base --is-ancestor <c> HEAD` = MERGED(提交已进主线)+ 未跟踪层为空;而另一棵 mtime 更新的树反而
全是已在主线的副本。**判据是内容与祖先关系,不是时间戳。**
- **「我记得没建过联接」不是安全判据**:判据只有扫出来的 `REPARSE: none` ( E 第 3 步实测 `bench-*` 各挂一个 `.venv`
联接,正是「不是自己建的」那类)。
- **联接不是符号链接**:本仓库 `core.symlinks=false` , git 里的 symlink 会被检出成
「内容是目标路径的文本文件」。共享 `.venv` 只能用 Windows **目录联接 ** ( `mklink /J` ,
不需要管理员权限),且联接必须建在版本控制之外。
实测(2026-10-12):用 ctypes 直调 `DeviceIoControl(FSCTL_SET_REPARSE_POINT)`
会被系统拒绝(Win32 4392),`mklink /J` 正常——所以脚本走后者,参数以列表交给
subprocess,含空格的路径(本项目 `DTU Code` )由 subprocess 自动加引号。
但**读属性**用 ctypes 没问题:`GetFileAttributesW` + `FILE_ATTRIBUTE_REPARSE_POINT` 就是 E 第 3 步的预扫手段。
- **联接建失败不中止 `new` **:只告警并打印手工补建命令,避免留下半成品任务空间;
但该任务里跑不了 `.venv\Scripts\python.exe` , `@build` / `ci_local.py` 会找不到解释器。
- **`done` 前主工作树必须干净**:否则未提交改动会被卷进合并(补完基线再重跑 `done` 时最容易忘,见 C 第 6 步)。
@@ -308,10 +564,11 @@ git -C "$MAIN" status --porcelain | sort # 与测量前**逐行一致**(
别用 `tail -N` 目测。
- **⚠ 别用 heredoc 打补丁改门禁脚本造探针**(2026-10-27 实测三连败,白烧一整轮):heredoc 里的 python
把门禁脚本改坏——`patched 3` 之后 `PROBE_RC=1` 、`SyntaxError: unterminated string literal (detected at
line 882)`,而它**正是被测对象**,改完的数不可信;另一处 ` replace('\\ ', '/')`(想把 Windows 反斜杠换成正斜杠)里的反斜杠被多层引号吞掉
line 882)` ,而它**正是被测对象**,改完的数不可信;另一处 `replace('\', '/')` (想把 Windows 反斜杠换成正斜杠)里的反斜杠被多层引号吞掉
→ `SyntaxError (detected at line 16)` 。**正解**:探针落成**独立 `.py` 文件**(写文件的工具,UTF-8 无 BOM)
再 `"$PY" probe.py` ;非用 heredoc 不可就用单引号定界 `<<'PY'` (防变量展开),且**路径一律 `as_posix()` 、
一个反斜杠都不写**——反斜杠字面量要穿「JS 模板串 → bash → shell 引号 → python」四层,必被吞。
E 节那两段扫描脚本同理:**落成 `ci-artifacts/*.py` 再跑**(该目录已被忽略,不会污染第 5 步复验用的脏行数)。
- **取门禁数字:import 门禁模块,别猜输出、别打补丁**( 2026-10-27; grep 源码 / 手抄正则 / 解析 stdout 三种姿势
都拿不到可复核的数,换成这句立刻拿到):先读门禁脚本的 `main()` (直读末尾那几十行)看它怎么装配检查器,
把**那几行照抄**进探针——`sys.path.insert(0, 'scripts')` → `import check_ids as ci` →
@@ -329,7 +586,8 @@ git -C "$MAIN" status --porcelain | sort # 与测量前**逐行一致**(
定责用 D 节:逐文件注入 + HEAD/在飞两版同一计数器相减 + 对新增行 grep;闭合后 +16 = 16 处裸形 「字母 T 累进数字 4」,
**推翻**了自查结论。口头自查与 `--note` 都不是证据,**可复核的差分数字才是 ** 。
- **`.workbuddy-ai/` 刻意不共享**:记忆不随任务分叉,也不给联接递归删除的机会。
任务工作树自己的记忆由 ` done` / ` abort` 在清场前归并回主工作树。
任务工作树自己的记忆由 `done` / `abort` 在清场前归并回主工作树。⚠ **手工回收(E 节)没有这一步归并 ** :
拆之前顺手 `ls <tree>/.workbuddy-ai/` 看一眼,有内容就一并备份进 `ci-artifacts/wt-leftover-backup/` 。
- **`--lean` 只省磁盘不省时间**:实测全量 smudge 7.25 s / 83 MB,跳过 smudge 7.05 s / 45 MB,
两者工作区都干净。默认走全量(参考图可用),磁盘紧张时才用 `--lean` 。
- **锁不绑定进程存活**: `lock acquire` 本身是短命进程,而真正用资源的烧录/构建在别的进程里跑。
@@ -349,11 +607,21 @@ git -C "$MAIN" status --porcelain | sort # 与测量前**逐行一致**(
## 验证
- ` $TW list`: 条目数 = ` ls _wt` 的目录数且 ≤ 3;刚回收的任务与 B 节的 占位条目都不再出现。
- ` $TW list` :台账段 条目数 = `ls _wt` 的目录数且 ≤ 3;刚回收的任务与历史 占位条目都不再出现
(插件树另起一段,不参与这条计数)。
- `$TW lock status` : `build 空闲(TTL 1.0h) ` + `bench 空闲(TTL 6.0h) ` ——没有本会话留下的持有者。
- `git log --oneline -3` :能看到 `merge: task/<ID>-<slug> 合入 <base>` 与其后的补基线提交;
`git status --porcelain` 为空(补链改动已提交,才谈得上重跑 `done` )。
- `ls _wt/.state/` :该 ID 的台账已无(清场最后一步 unlink);重复 `done` 报「任务不存在」不必再追。
- **E 节回收**: `git worktree list` 只剩主树 + 判「保留不动」的树(已拆路径逐条消失);**拆前/拆后主树
`git status --porcelain -uall` 行数相同且 `diff` 为空**(实测 0 → 0);`ci-artifacts/wt-leftover-backup/` 里每个留档件都打印过
文件名与字节数、条目数 = 分桶判「要留档」的条目数;每棵被拆的树都留有 `REPARSE: none` 预扫记录;全景输出里
`UNIQUE-UNMERGED` 归零(或全部已备份);保留树在报告里点名给出判据(`merge-base --is-ancestor` = MERGED)。
- **E 第 6 步清悬挂台账**:该 json 已从 `_wt/.state/` 消失(`.state-bak` 在备份目录里)、`$TW list` 不再打出它、
`$TW lock status` 两把锁仍空闲;全程未对该 ID 跑 `done` / `abort` 。
- **F 节桥接**: `plugin list` 的「联接」列在 `adopt` 后变 `已联` 、`unadopt` 后回 `未联` ; **adopt/unadopt 前后
主树 `.venv` 与树内文件都还在**( `test -f .venv/Scripts/python.txt` 恒真);`plugin adopt ".."` 必须 exit 2;
`plugin adopt` 打印的 `pw:<name>` 与 `keil_build.py` 从 cwd 反推的名字逐字相同;`done <插件树名>` 只 exit 2。
- D 节归因:测量前后 `git -C "$MAIN" status --porcelain` **逐行一致 ** (主树全程只读);
`git worktree list` 里净室路径已消失;逐文件增量之和 **等于**观察到的涨幅(实测 +6+8+2 = +16),且每处增量
都指到具体字面量类别(裸形 「字母 T 累进数字 4」)与属主;取数走 `import check_ids` 读 `ck.stats` ,且门禁脚本自身 `git diff` 为空。
@@ -366,5 +634,7 @@ git -C "$MAIN" status --porcelain | sort # 与测量前**逐行一致**(
- 烧录产物的真实 hex 路径以 `.serialtest/config.json` 的 hex 键为准(实测
`MDK-ARM/Objects/STM32F407-Demo.hex` 这个路径并不存在),拷错等于烧了旧镜像 → `serial-boot-test` skill
- 新 `.c` 文件 = `new-source-file` skill( `uvprojx` 与 `CMakeLists.txt` 双端注册)
- **DSH worktree 插件**(人类命令 `/worktree` 、模型工具 `worktree_create` ,树在仓内 `.dsh-worktrees/` )
与本 skill 的分工、桥接命令与锁归属 → F 节(本 skill 不删插件树,插件也不删 `_wt/` )
- `.map` 口径 / RAM 台账与趋势 → `mem-budget` skill; UI 回归 → `ui-verify` skill
- 完整机制与目录分配 → `docs/环境与部署/并行任务工作树方案.md`