diff --git a/.dsh/skills/keil-build/scripts/keil_build.py b/.dsh/skills/keil-build/scripts/keil_build.py index a8ea4531..bde44308 100644 --- a/.dsh/skills/keil-build/scripts/keil_build.py +++ b/.dsh/skills/keil-build/scripts/keil_build.py @@ -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/)→ 持有者名 pw:。 + + 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/-/` 的 `` > "build-cli"。""" + """持有者名:$DTU_TASK_ID > cwd 反推 `_wt/-/` 的 `` > 插件树 `pw:` > "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): diff --git a/.dsh/skills/task-worktree/SKILL.md b/.dsh/skills/task-worktree/SKILL.md index e95b8862..afd7fa6d 100644 --- a/.dsh/skills/task-worktree/SKILL.md +++ b/.dsh/skills/task-worktree/SKILL.md @@ -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:`、回收仍归 /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 ` | 单任务详情(未提交清单、相对基线漂移、台架记录、已建联接) | | `sync ` | 在任务工作树里 `rebase` 追平主干,把冲突控制在任务分支上 | | `lock acquire|release|status ` | 独占资源锁 | +| `plugin list|adopt|unadopt [插件树名]` | 与 DSH worktree 插件的桥:看清单 / 补共享联接 / 摘联接(**不删树**)→ F 节 | | `done ` | 预演 → 合入主干 → 并集残留审计 → 重生成产物 → 门禁 → 清场 | | `abort ` | 放弃任务(工作树 + 分支一起删,**永久丢弃**) | @@ -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] 无冲突,预演结果树 `。dry-run 全文只有 5~8 行,直接读全量。 -## 分步操作(四条运行时机制) +## 分步操作(六条运行时机制) ### A. `new` 被拒「已有 3 个活跃任务(上限 3)」——怎么腾位 1. 读原文:`前置/环境错误:已有 3 个活跃任务(上限 3);先 done/abort,或加 --force`(exit 2)。 上限是脚本常量 `MAX_CONCURRENT = 3`;**「活跃」的判据 = `_wt/.state/.json` 台账存在,且台账里 - `path` 指向的目录还在**(手工删了工作树目录就不占名额,但会留下悬挂台账,见第 5 步)。 + `path` 指向的目录还在**(手工删了工作树目录就不占名额,但会留下悬挂台账,见第 5 步与 **E 第 6 步**)。 2. `$TW list` 看全景(分支 / 落后领先 / 脏文件数)决定谁让位:**已完成的先 `done`**(合并回收,一步不省)。 3. 没人认领、零提交、尚未 claim 的空闲树 → `abort ` 腾位(实测就是 abort 掉排在后面的空闲树换出名额)。 `abort` = 工作树 + 分支一起删、**永久丢弃**;工作树有未提交改动或已有提交时脚本会拒绝,确认可弃再加 `--force`。 4. 确实要同时开第 4 棵才 `new --force`:磁盘代价约 **83 MB/棵**(`--lean` 约 45 MB), 并行度上去了、**冲突面与台架排队也一起上去**——不是免费的。 -5. **别用手工 `git worktree remove` 腾位**:台账不会被删(下次 `new` 同 ID 会报「任务 已存在」), - 还绕过了「先摘联接」(铁律 5),有让 git 递归进联接把主工作树 `.venv` 删掉的风险。 +5. **别用手工 `git worktree remove` 给「登记任务」腾位**:台账不会被删(下次 `new` 同 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 `(不带 `--bench-pass`)就能释放。 -3. **需要 `--bench-pass` 而这个名字不是登记任务** → 手写**最小占位台账** `_wt/.state/.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 --task [--bench-pass]` 直接放;PASS 记不上会打印 warn,锁照放。 +3. **PASS 后面还要用**(`done` 只认台账里的 `bench_pass`)→ 从一开始就用已 `new` 出来的任务 ID 占锁, + 或把验收结论写进主干提交说明 / 文档。**插件树 `pw:` 天然没有台账**,别指望它留 PASS → F 节。 - 用写文件的工具落 UTF-8(无 BOM),别用 `echo` / `Set-Content`(中文乱码铁律)。 -4. `$TW lock release bench --task --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/.lock` 里的单行 JSON - (`name/task/pid/host/epoch/at/note/ttl`),`release` 的实现就是 unlink 它——手工删这个文件等价于 - 「不记 PASS 的强制释放」,只在前面几步都不可用时才用。 +最后手段(脚本本身都走不通时):锁就是 `_wt/.state/locks/.lock` 里的单行 JSON +(`name/task/pid/host/epoch/at/note/ttl`),`release` 的实现就是 unlink 它——手工删这个文件等价于 +「不记 PASS 的强制释放」,只在前面几步都不可用时才用。**历史遗留的占位台账**(早期为绕这个坑手写的 +`_wt/.state/.json`)现在已无必要:它活着时既占 `MAX_CONCURRENT = 3` 名额又被 `list` 当任务列出, +按 E 第 6 步备份后删掉即可。 ### C. `done ` 的正确用法(它是合并点,不是查看器) -1. **合并前确认任务属主已把全部改动提交完**。`$TW status ` 看未提交清单与领先提交数;跨会话任务 +1. **合并前确认任务属主已把全部改动提交完**。**`$TW status `** 看未提交清单与领先提交数;跨会话任务 再问一句属主(`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 # 提交属 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/.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/.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()`,无台账 → +`前置/环境错误:任务 不存在(缺台账 …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 -- 定性)" +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 -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/.json`;它会让 `$TW list` 打出一行怪条目、并让下次 + `new` 同 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 建一个)」),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/-/` | 仓库**内** `.dsh-worktrees/worktree//` | +| 谁建 | `new `(人来敲命令) | 会话选「Worktree模式」/ 模型调 `worktree_create`(零命令) | +| 台账 | `_wt/.state/.json`(本 skill) | `.dsh-worktrees/manifest.json`(插件私有格式,别去改) | +| 分支名 | `task/-` | `worktree/` | +| 共享联接 | `new` 自动建 | **建树时没有** → 要 `plugin adopt` 补 | +| 回收 | `done`(预演 / 合并 / 门禁 / 清场)或 `abort` | 用户敲 `/worktree finish\|bring-back\|remove\|prune`(模型碰不到收尾命令) | + +**为什么允许它把树建在仓内**(铁律 7 的唯一例外):路径写死在插件里(`WORKTREE_NAMESPACE = worktree` ++ `/.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 个插件工作树不计入上限」提醒并发代价)。 + +三步接上(`` 可带 `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:`(多段名压平成 + `-`),与 `plugin adopt` 打印的那个名字逐字相同(`keil_build.py: plugin_tree_owner()` ↔ + `task_worktree.py: plugin_lock_owner()`)→ `lock status` 不再一律落成 `build-cli`。 +- 上台架仍须手工占锁、用同一个名:`lock acquire bench --task pw: --note <说明>` → + `lock release bench --task pw: --bench-pass`。插件树没有台账 → 按 B 节只 warn、锁照常放。 +- **命名建议**:插件 `` 沿用 `@task` 的 `-` 形态(如 `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 ` → 烧录/`@test`/探针 → - `lock release bench --task --bench-pass`。非登记任务(lead 的验收窗)占了锁怎么放 → B 节。 + `lock release bench --task --bench-pass`。非登记持有者(lead 的验收窗、插件树 `pw:`)占了锁 + 怎么放 → 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 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 /.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/- 合入 ` 与其后的补基线提交; `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:` 与 `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` diff --git a/.dsh/skills/task-worktree/scripts/task_worktree.py b/.dsh/skills/task-worktree/scripts/task_worktree.py index 03834381..db060425 100644 --- a/.dsh/skills/task-worktree/scripts/task_worktree.py +++ b/.dsh/skills/task-worktree/scripts/task_worktree.py @@ -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/)缺三样东西,下面的命令逐条补。 +# 回收**不在这里**:插件树由「/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:),bench 锁仍要人占,占锁名得和「lock status」对得上。 + + +def plugin_lock_owner(name: str) -> str: + """插件树在锁 payload 里的持有者名:pw:(多段名压平成 -)。 + + 必须与 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 」只建共享联接,不碰树内容") + 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 ") + print(" 建完回来「plugin adopt 」补共享联接,才能在里面跑 @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 ` 建一个)") + 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 ") - 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) diff --git a/.gitignore b/.gitignore index 3fba8b8b..9087db8f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 3d4f98f8..f6ec8d7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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:`) | **`task-worktree` skill**(`@task`,用到自动加载;插件桥 → 其 F 节)、`docs/环境与部署/并行任务工作树方案.md` §3.4 | | 换机 / 工具路径环境变量 | `docs/环境与部署/` | | 远程 CI(Gitea Actions,**只跑本机一台 runner**) | [`docs/CI/README.md`](docs/CI/README.md) | diff --git a/docs/环境与部署/并行任务工作树方案.md b/docs/环境与部署/并行任务工作树方案.md index dcd9dfa1..7723dd67 100644 --- a/docs/环境与部署/并行任务工作树方案.md +++ b/docs/环境与部署/并行任务工作树方案.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/-` | `worktree/` | + +两条**并发红线由本方案单方守住**(插件不感知):`build`(UV4 单实例)与 `bench`(COM6 / ST-LINK / +设备 IP 唯一)这两把锁的位置与 TTL 口径见 §7.1 / §7.2——插件树里跑 `@build` 时,`keil_build.py` +会照旧自持自释,持有者名从 cwd 反推为 `pw:`;`bench` 仍须手工占同一个名。共享联接(§3.3) +插件不会建,由 `@task plugin adopt ` 补。 + +**为什么允许它在仓内破例**(与 §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. 分支模型 diff --git a/scripts/check_doc_links.py b/scripts/check_doc_links.py index 923593bb..e817e4bc 100644 --- a/scripts/check_doc_links.py +++ b/scripts/check_doc_links.py @@ -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)。目标须以「非空白、非右括号」构成, diff --git a/scripts/check_layer_deps.py b/scripts/check_layer_deps.py index 70661d56..6b18a5a4 100644 --- a/scripts/check_layer_deps.py +++ b/scripts/check_layer_deps.py @@ -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 diff --git a/tools/refgraph/gen_refgraph.py b/tools/refgraph/gen_refgraph.py index 1b7cc5a4..77823e09 100644 --- a/tools/refgraph/gen_refgraph.py +++ b/tools/refgraph/gen_refgraph.py @@ -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 = (