feat(task-worktree): 桥接 DSH worktree 插件——补共享联接、锁归属 pw:<name>、修释放不掉

诉求最初是升级 dsh-task-worktree:实测已装 0.4.6 = npm dist-tags.latest,无需升级;
真正的缺口是本仓 skill 看不见插件建的树(.dsh-worktrees/),故按用户追加指令把两套
系统接起来。回收方向刻意不打通:本 skill 永不删插件的树。

脚本 task_worktree.py:新增 plugin 子命令 list/adopt/unadopt,事实源是 git worktree
list(不是插件 manifest,免得被绕过);adopt 只补缺失的 .venv 与 simulator/SDL2
联接,已有联接跳过、实体目录绝不碰;锁归属用 pw:<name> 前缀;new 顺带提示另有 N 棵
插件树、不占活跃上限 3。
一并修一处真 bug:lock release --bench-pass 在任务无台账时先 EnvError 退出、锁释放
不掉,导致每个任务的 done 收口连坐失败;现在改为警告后仍然释放。

keil-build:default_lock_task() 增加 .dsh-worktrees/ 反推分支,插件树内的构建归属
pw:<name> 而不是兜底的 build-cli(主树行为未变,本次 @build 实测仍走 build-cli)。

.gitignore 预先忽略 .dsh-worktrees/,让插件的 ensureIgnored() 找到同一字面量、不再
改写这份受控文件。check_doc_links / check_layer_deps / gen_refgraph 三处扫描面同步
排除:仓里再放一份仓会让 doc-links 假红、并让生成的配图字节漂移到 generated-fresh。

文档:并行任务工作树方案新增 §3.4 两套系统边界对照;SKILL.md 加 §F 共存一节,同时把
§B 从 53 行压到 29 行(净 +39,用户此前在飞的改写已 rebase 保留、未被覆盖)。

验证:@build 0 错误 0 警告;scripts/ci_local.py 48 根提交期钩子与 gitlint 全绿;
check_doc_links --all --sections-strict 通过(2596 章节引用、无法判定 0);id-check-new
通过;改动文件字节体检无 BOM、CRLF 与 U+FFFD。桥接行为在临时 scratch 仓端到端跑过
(adopt 建两个 reparse、unadopt 只摘联接、pw 锁取放、缺台账也能释放、越界 name 退 2)。
This commit is contained in:
2026-10-29 07:50:05 +08:00
parent 5f69f38dfd
commit 21c68521ff
9 changed files with 593 additions and 85 deletions
+19 -2
View File
@@ -273,8 +273,25 @@ def find_lock_tool():
return cand if cand.is_file() else None
def plugin_tree_owner(cwd: Path):
"""DSH worktree 插件的树(仓库内 .dsh-worktrees/worktree/<name>)→ 持有者名 pw:<name>。
2026-10-29 与 task-worktree skill 的 `plugin` 子命令对齐:插件建的树不在 _wt/ 约定里,
以前在这里跑构建,持有者名一律落成 "build-cli"——`lock status` 看不出是谁在占 UV4,
多个插件树 / 主树并发时就只剩猜。名字里的多段斜杠压平成 -,与
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:?"
def default_lock_task():
"""持有者名:$DTU_TASK_ID > cwd 反推 `_wt/<ID>-<slug>/` 的 `<ID>` > "build-cli"。"""
"""持有者名:$DTU_TASK_ID > cwd 反推 `_wt/<ID>-<slug>/` 的 `<ID>` > 插件树 `pw:<name>` > "build-cli"。"""
v = os.environ.get("DTU_TASK_ID")
if v:
return v
@@ -285,7 +302,7 @@ def default_lock_task():
for p in (cwd, *cwd.parents):
if p.parent.name == "_wt":
return p.name.split("-", 1)[0] or p.name
return "build-cli"
return plugin_tree_owner(cwd) or "build-cli"
def build_lock_ttl_hours(timeout_s):
+346 -76
View File
@@ -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`
@@ -82,6 +82,15 @@ BASE_BRANCH = os.environ.get("DTU_TASK_BASE", "v2-on-ui")
STATE_DIR = WT_ROOT / ".state"
LOCKS_DIR = STATE_DIR / "locks"
# DSH 的 worktree 插件(包名 dsh-task-worktree;模型工具 worktree_create、人类命令
# /worktree)把任务树建在**仓库内**这个命名空间下,与本 skill 的仓外 _wt/ 刻意分开。
# .gitignore 里预置同名规则,正是为了让插件的 ensureIgnored() 见到就跳过、
# 不在会话中途往这份受控文件里追加一行。
PLUGIN_ROOT = REPO_ROOT / ".dsh-worktrees"
PLUGIN_NAMESPACE = "worktree" # 插件侧 WORKTREE_NAMESPACE:name 即分支尾段
PLUGIN_SUBDIR = PLUGIN_ROOT / PLUGIN_NAMESPACE
PLUGIN_LOCK_PREFIX = "pw" # 插件树在锁 payload 里的持有者前缀
# --------------------------------------------------------------------- 基础工具
@@ -390,9 +399,163 @@ def bootstrap(wt: Path):
return made
# --------------------------------------------------------------------- DSH 插件工作树
#
# 插件建的树(.dsh-worktrees/worktree/<name>)缺三样东西,下面的命令逐条补。
# 回收**不在这里**:插件树由「/worktree remove|prune」收,done/abort 也不该拿去处理它。
# 1. 共享联接——没有 .venv 与 simulator 的 SDL2 SDK,插件树里「.venv\Scripts\python.exe」
# 直接找不到解释器:@build、ci_local.py、所有 scripts/check_*.py 全部起跑失败;
# 2. 可见性——插件树不在台账里,「list」看不见它,3 个活跃名额也只数登记任务;
# 3. 独占资源归属——build 锁由 keil_build.py 自持自释(按 cwd 反推持有者名,
# 插件树里算成 pw:<name>),bench 锁仍要人占,占锁名得和「lock status」对得上。
def plugin_lock_owner(name: str) -> str:
"""插件树在锁 payload 里的持有者名:pw:<name>(多段名压平成 -)。
必须与 keil_build.py 的 default_lock_task() 对同一种树算出的名字一致——这样构建
自持自释留下的归属,和手工「lock acquire bench --task pw:x」才是同一个可 grep 的标识。
"""
# 与 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 "?")
def plugin_dir_for(name: str) -> Path:
"""插件 worktree 名 → 树目录;**只允许落在 .dsh-worktrees/worktree/ 里面**。
adopt/unadopt 会在解析出的目录上建/摘目录联接;不加这道闸,等于允许把主工作树或
任意 _wt 目录当插件树动手(联接会遮蔽真实内容,事后很难看出来)。
"""
raw = (name or "").strip().replace(os.sep, "/").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
def plugin_trees():
""".dsh-worktrees/worktree/ 下的工作树(事实源 = git worktree list,不读插件 manifest)。
manifest.json 是插件私有格式(MANIFEST_VERSION 1,字段随版本变),而 git 的注册表
同时覆盖「插件正常建的树」和「manifest 丢了 / 树被手工删」的残留——桥接命令要看的
是真实存在的东西。
"""
try:
base = PLUGIN_SUBDIR.resolve()
except OSError:
return []
trees = []
for ent in worktree_entries():
p = ent["path"]
try:
rp = p.resolve()
except OSError:
continue
if rp == base or base not in rp.parents:
continue
dirty = (len(git_out("status", "--porcelain", cwd=p, check=False).splitlines())
if p.is_dir() else -1)
trees.append({
"name": rp.relative_to(base).as_posix(),
"path": str(p),
"branch": ent.get("branch") or "(detached)",
"dirty": dirty,
"adopted": is_reparse(p / JUNCTION_SPECS[0][0]),
})
return trees
def print_plugin_trees() -> int:
"""打印插件树一览(无树则静默),返回数量——「list」与「new」共用同一事实。"""
trees = plugin_trees()
if not trees:
return 0
print()
print("DSH 插件工作树({} 下;**不在本 skill 台账** → 不占 {} 个名额、done/abort "
"管不到,回收走 /worktree remove|prune):".format(PLUGIN_SUBDIR, MAX_CONCURRENT))
print("{:<26} {:<34} {:>4} {:>6}".format("名称", "分支", "脏", "联接"))
for t in trees:
print("{:<26} {:<34} {:>4} {:>6}".format(
t["name"], t["branch"], "-" if t["dirty"] < 0 else str(t["dirty"]),
"已联" if t["adopted"] else "未联"))
if any(not t["adopted"] for t in trees):
print("未联的进去跑不了 Python:「plugin adopt <name>」只建共享联接,不碰树内容")
return len(trees)
def adopt_junctions(wt: Path):
"""只补**缺失**的共享联接(已联的跳过、实体目录绝不碰),返回 (已有, 本次新建)。"""
already, made = [], []
for rel, desc in JUNCTION_SPECS:
link = wt / rel
if is_reparse(link):
already.append(rel)
continue
if os.path.lexists(str(link)):
print("[warn] {} 已是实体目录/文件(不是联接)——不动它".format(rel))
already.append(rel)
continue
target = REPO_ROOT / rel
if not target.exists():
print("[skip] {}({})主线里不存在,跳过".format(rel, desc))
continue
try:
make_junction(link, target)
except (Fail, EnvError) as err:
print("[WARN] {} 联接失败:{}".format(rel, err))
continue
made.append(rel)
print("[ok] 联接 {} -> {}".format(rel, target))
return already, made
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(" 建完回来「plugin adopt <name>」补共享联接,才能在里面跑 @build 与门禁。")
return EXIT_OK
wt = plugin_dir_for(a.name)
if not wt.is_dir():
raise EnvError("目录不存在:{}(先用插件建树:/worktree create 或 worktree_create)".format(wt))
if a.action == "adopt":
if git("rev-parse", "--git-dir", cwd=wt, check=False).returncode != 0:
raise EnvError("{} 不是 git 工作树(别拿普通目录当插件树)".format(wt))
print("为插件树补共享联接:{}".format(wt))
already, made = adopt_junctions(wt)
owner = plugin_lock_owner(a.name)
print()
print("[ok] 联接:已有 {} 个、本次新建 {} 个".format(len(already), len(made)))
print("该目录现在可以跑 @build / ci_local.py / @test;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))
return EXIT_OK
removed = [rel for rel, _spec in JUNCTION_SPECS if remove_junction(wt / rel)]
print("[ok] 摘掉联接:{}(只摘联接本身,绝不动目标内容与树里文件)".format(
"、".join(removed) if removed else "无(该树没有联接着)"))
return EXIT_OK
# --------------------------------------------------------------------- 子进程脚本
def regen(cwd: Path) -> None:
run_script("scripts/gen_task_table.py", ["--write"], cwd, "重生成 FreeRTOS 任务表")
if list((Path(cwd) / "MDK-ARM").glob("**/*.map")):
@@ -557,6 +720,10 @@ def cmd_new(a) -> int:
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 ""))
@@ -588,6 +755,7 @@ def cmd_list(a) -> int:
states = all_states()
if not states:
print("(没有任务空间。用 `new <ID> <slug>` 建一个)")
print_plugin_trees()
return EXIT_OK
print("{:<12} {:<32} {:>5} {:>5} {:>4} {}".format(
"任务", "分支", "落后", "领先", "脏", "台架"))
@@ -612,6 +780,7 @@ def cmd_list(a) -> int:
else:
print("锁 {:<6} {} 持有(已 {:.1f} 小时,于 {})".format(
n, info.get("task"), lock_age_hours(info), info.get("at")))
print_plugin_trees()
return EXIT_OK
@@ -692,10 +861,19 @@ def cmd_lock(a) -> int:
if a.bench_pass:
if not a.task:
raise EnvError("--bench-pass 需要 --task <ID>")
st = load_state(a.task)
st["bench_pass"] = True
save_state(st)
print("[ok] 已为任务 {} 记录台架 PASS".format(a.task))
# 非登记持有者(lead 的验收窗、插件树 pw:*)没有台账。以前这里先 load_state
# 就抛「任务 X 不存在(缺台账)」,**根本走不到释放**:锁还挂着,而 done 的前置
# 会因活锁判失败——一个名字没登记,就挡住所有任务的收口。现在降级为显式告警 +
# 照常释放:PASS 没进台账是事实,但不该拿「锁放不掉」当代价。
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)))
ok, msg = lock_release(a.name, a.task, force=a.force)
print(msg)
return EXIT_OK if ok else EXIT_FAIL
@@ -874,6 +1052,12 @@ 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.add_argument("action", choices=["list", "adopt", "unadopt"])
p.add_argument("name", nargs="?", default="",
help="插件 worktree 名(可带 worktree/ 前缀或多段斜杠)")
p.set_defaults(func=cmd_plugin)
p = sub.add_parser("done", help="回收:预演 → 合入主干 → 审计 → 重生成 → 门禁 → 清场")
p.add_argument("task_id")
p.add_argument("--base", default=None)
+8
View File
@@ -171,3 +171,11 @@ ci-artifacts/
# 但 `git commit -F` 那份文件随手落在仓库根就会以 ?? 出现,被某次 add -A 带走):
# 正文已经进了 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-worktrees/
+1 -1
View File
@@ -184,6 +184,6 @@
| 串口助手 | **`serial-assistant` skill**(命令/判据/陷阱,用到自动加载)、`docs/测试说明/串口助手使用指南.md`、`串口助手设计说明.md` |
| UI 架构 / 模拟器 / UI 自动化回归 | **`lvgl-simulator` skill**(构建/自检/报错速查,用到自动加载)、`docs/UI设计/UI_设计架构.md`、`LVGL模拟器部署与使用说明.md`、`UI_自动化测试流程.md`;UI 改动回归走 **`ui-verify` skill** |
| 待办与审计发现登记簿 | `docs/任务规划/README.md` |
| 多任务并行开发(开空间 / 合并回收 / 台架与构建排队) | **`task-worktree` skill**(`@task`,用到自动加载)、`docs/环境与部署/并行任务工作树方案.md` |
| 多任务并行开发(开空间 / 合并回收 / 台架与构建排队);**DSH worktree 插件的仓内树怎么接上**(`.dsh-worktrees/` 缺 `.venv`、`plugin adopt`、锁归属 `pw:<name>`) | **`task-worktree` skill**(`@task`,用到自动加载;插件桥 → 其 F 节)、`docs/环境与部署/并行任务工作树方案.md` §3.4 |
| 换机 / 工具路径环境变量 | `docs/环境与部署/` |
| 远程 CI(Gitea Actions,**只跑本机一台 runner**) | [`docs/CI/README.md`](docs/CI/README.md) |
@@ -96,6 +96,29 @@ D:/Code/DTU Code/
> ⚠ MSYS 的 `rmdir` 拒绝摘除联接(把它当符号链接),手工清场要用 Python 的 `os.rmdir`
> 或直接走本 skill 的 `abort` / `done`。
### 3.4 与 DSH worktree 插件的边界(2026-10-29)
本仓现在有**两套**隔离空间并存,这是**有意接受**的现状,不是待清理的重复建设:
| 维度 | 本方案(`@task` 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>` |
两条**并发红线由本方案单方守住**(插件不感知):`build`(UV4 单实例)与 `bench`(COM6 / ST-LINK /
设备 IP 唯一)这两把锁的位置与 TTL 口径见 §7.1 / §7.2——插件树里跑 `@build` 时,`keil_build.py`
会照旧自持自释,持有者名从 cwd 反推为 `pw:<name>`;`bench` 仍须手工占同一个名。共享联接(§3.3)
插件不会建,由 `@task plugin adopt <name>` 补。
**为什么允许它在仓内破例**(与 §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 节(本文只记边界,不重述步骤)。
---
## 4. 分支模型
+2
View File
@@ -51,6 +51,8 @@ SKIP_PARTS = ("node_modules", ".venv", ".git", "build", "obj", "__pycache__",
".workbuddy-ai", # 本地记忆/会话数据,非项目文档
".mnemon", # 同上:记忆工具的版本快照目录,已 .gitignore
"ci-artifacts", # 本地临时/草稿目录,已 .gitignore,不是项目文档
".dsh-worktrees", # DSH worktree 插件在**仓库内**建的任务树(整份仓库的副本,
# 扫它等于把同一篇文档按别人的任务状态判一遍——假红 + 白跑)
"docs/芯片手册") # 外部数据手册转录,非项目自有文档,链接形态不受控
# Markdown 链接:[text](target)。目标须以「非空白、非右括号」构成,
+2 -1
View File
@@ -65,7 +65,8 @@ def tracked_files():
except Exception: # noqa: BLE001
files = []
for root, dirs, names in os.walk(REPO):
dirs[:] = [d for d in dirs if d not in (".git", ".venv", "build")];
dirs[:] = [d for d in dirs if d not in
(".git", ".venv", "build", ".dsh-worktrees")];
for n in names:
files.append(os.path.relpath(os.path.join(root, n), REPO).replace(os.sep, "/"))
return files
+4 -1
View File
@@ -69,7 +69,10 @@ import sys
# 文件索引时整体跳过的目录(按相对路径前缀,'/' 分隔)
EXCLUDE_PREFIXES = ("MDK-ARM", "docs", "simulator/build")
# 按目录名跳过(任意层级)
EXCLUDE_DIRNAMES = (".git", ".venv", "node_modules")
# .dsh-worktrees = DSH worktree 插件在仓库内建的任务树(整份仓库副本):不排掉的话
# 每次有人建插件树,本生成器的输出就多出几十条重复节点/边——而 generated-fresh 门禁是
# 逐字节比对,等于别人的一次「建个隔离空间」把配图 diff 砸在某位同事头上。
EXCLUDE_DIRNAMES = (".git", ".venv", "node_modules", ".dsh-worktrees")
# simulator/CMakeLists.txt 的 include_directories(相对仓库根;保持同步)
SIM_INCLUDE_DIRS = (