@minonl/dia 0.0.0-stage → 0.0.2-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +253 -2
  3. package/bin/dia.js +5 -0
  4. package/dist/cli.js +7408 -0
  5. package/docs/acp-integration.md +136 -0
  6. package/docs/architecture.md +166 -0
  7. package/docs/cli.md +117 -0
  8. package/docs/dependency-registry.md +105 -0
  9. package/docs/deps.jsonl +7 -0
  10. package/docs/ecosystem-integration.md +155 -0
  11. package/docs/ecosystem-positioning.md +95 -0
  12. package/docs/entrypoints.md +83 -0
  13. package/docs/evolution-process.md +298 -0
  14. package/docs/history/framework-bootstrap.md +109 -0
  15. package/docs/host-control-surface.md +213 -0
  16. package/docs/index.md +80 -0
  17. package/docs/kit-install.md +339 -0
  18. package/docs/migrations/kb-golden.md +32 -0
  19. package/docs/migrations/llm-migration.md +49 -0
  20. package/docs/migrations/no-python.md +52 -0
  21. package/docs/migrations/yan-agent-current.md +52 -0
  22. package/docs/migrations/yan-agent-next.md +168 -0
  23. package/docs/migrations/yan-kb-parity.md +159 -0
  24. package/docs/migrations/yan-kb.md +120 -0
  25. package/docs/model-baseline-sidecar.md +220 -0
  26. package/docs/model-behavior.md +229 -0
  27. package/docs/monorepo.md +83 -0
  28. package/docs/overview.md +126 -0
  29. package/docs/patching.md +91 -0
  30. package/docs/persona-install.md +264 -0
  31. package/docs/platforms/index.md +66 -0
  32. package/docs/platforms/registry.json +54 -0
  33. package/docs/pricing.md +43 -0
  34. package/docs/proposals.md +76 -0
  35. package/docs/research/benchmarks-survey.md +96 -0
  36. package/docs/research/collaboration-theory.md +85 -0
  37. package/docs/research/dsh-superagent.md +97 -0
  38. package/docs/research/frameworks-survey-2.md +75 -0
  39. package/docs/research/h0/report.md +135 -0
  40. package/docs/research/h0/sample.json +34 -0
  41. package/docs/research/model-boundary-patterns.md +99 -0
  42. package/docs/research/opencode-session-scoping.md +84 -0
  43. package/docs/review/reuse-review.md +105 -0
  44. package/docs/review/verification-2026-10-28.md +49 -0
  45. package/docs/security/audits/2026-11-04.json +44 -0
  46. package/docs/security/credentials.md +95 -0
  47. package/docs/security/public-release.md +108 -0
  48. package/docs/security-sandbox.md +157 -0
  49. package/docs/status.md +158 -0
  50. package/docs/team-automation.md +169 -0
  51. package/docs/team.md +112 -0
  52. package/docs/versioning.md +237 -0
  53. package/docs/vision.md +161 -0
  54. package/package.json +45 -4
  55. package/packages/job/README.md +98 -0
  56. package/packages/job/bin/dia-job +1283 -0
  57. package/packages/job/config.example +5 -0
  58. package/packages/job/install.sh +37 -0
  59. package/packages/job/lib/config.sh +18 -0
  60. package/packages/job/lib/json.mjs +130 -0
  61. package/packages/job/lib/policy.sh +103 -0
  62. package/packages/job/package.json +10 -0
  63. package/packages/job/shim/yan-task +24 -0
  64. package/packages/job/tests/bootstrap.test.sh +115 -0
  65. package/packages/job/tests/install.test.sh +30 -0
  66. package/packages/job/tests/linux.test.sh +130 -0
  67. package/packages/job/tests/p2.test.sh +46 -0
  68. package/packages/job/tests/policy.test.sh +38 -0
  69. package/packages/wt/bin/dia-wt +1148 -0
  70. package/packages/wt/tests/check.test.sh +126 -0
  71. package/packages/wt/tests/clean.test.sh +126 -0
  72. package/packages/wt/tests/prune-merged.test.sh +169 -0
  73. package/packages/wt/tests/route.test.sh +112 -0
  74. package/packages/wt/tests/scope.test.sh +69 -0
@@ -0,0 +1,1148 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # dia wt —— worktree 目录形式选型 + 创建(**唯一实现**,2026-10-12)
4
+ #
5
+ # 为什么需要:
6
+ # R20 要求「任何仓库的改动一律 worktree 隔离」,但目录形式选型
7
+ # (平级 `<repo>-worktree--<feat>` vs 仓库内 `<repo>/.worktrees/<feat>`)
8
+ # 此前**靠人判断**(`workflows/worktree-forms.md`)→ 2026-10-12 实测:
9
+ # agent 在 yan-agent 上**忘了建 worktree**、直接在 main 工作区提交,
10
+ # 还用 `git add -A` 抓走了并行会话的未提交改动。
11
+ # ⇒ 把「选型 + 创建」做成脚本,**固定死**,agent 只需调用它(②③ 脚本化)。
12
+ #
13
+ # 选型规则(**默认平级,白名单才隐藏**;用户 2026-10-12 拍板「拿不准一律平级」):
14
+ # 平级(默认):<parent>/<repo>-worktree--<feature>
15
+ # 隐藏(白名单):<repo>/.worktrees/<feature>
16
+ # ⚠️ 白名单**不硬编码仓名**,而是**按条件检测**(worktree-forms.md §2):
17
+ # ① 无 node_modules ② 无仓库级扫描工具 ③ .gitignore 含 .worktrees/
18
+ # 三条**同时**满足才用隐藏;任一不满足或**检测不确定** → **平级**(兜底)。
19
+ #
20
+ # 用法:
21
+ # bash scripts/dia wt create <repo-path> <feature> [--base <ref>] [--dry-run] [--install]
22
+ # bash scripts/dia wt form <repo-path> # 只看选型结论(不创建)
23
+ # bash scripts/dia wt list [repo-path] # 列 worktree(含形式标注)
24
+ # bash scripts/dia wt check [<repo>...] [--all] [--deep] [--quiet] # ★ node_modules 软链体检(R20)
25
+ # bash scripts/dia wt clean <repo-path> <feature> # 按 R25 清理(合并后)
26
+ #
27
+ # 退出码:0 成功/体检干净;1 失败/体检有违规;2 未知子命令
28
+ #
29
+ # ── 依赖策略(R20)与 GVS 试点记录(2026-10-16)────────────────────────────────
30
+ # 【规则】worktree 内一律**真装**(`pnpm install --frozen-lockfile --prefer-offline`,实测 12~58s);
31
+ # **禁止**把别的 checkout 的 `node_modules` 软链过来 —— pnpm 会按 realpath **改写对方**的顶层链,
32
+ # 删 worktree 后对方整片悬空(2026-09-30 主仓 198 条 / 10-10 两次 99+100 条 / 10-15 一次)。
33
+ # ⚠️ 判据精确版 = 禁「**顶层 `node_modules` 是软链 / 跨 checkout 共享**」,**不是**禁 node_modules 内部有软链:
34
+ # pnpm `isolated` linker 的顶层包链、`link:` 依赖、dsh profile 的 `node_modules/@example/*`、
35
+ # GVS 的 `<storeDir>/links/**` 都是**合法形态**(`check` 已把 store links 列入白名单)。
36
+ # ✅ 落地 = `check`(判据 A/B/C/D + 白名单)+ `create` 提示真装/`--install` + `clean` 拒绝软链 worktree;
37
+ # 巡检任务 `com.leyan.yan-nm-check` 每日 14:10 跑 `check --all --quiet`。
38
+ # 【GVS 试点结论:暂停启用(用户 2026-10-16 拍板)】pnpm 官方的多 worktree 共享方案
39
+ # `virtualStoreType: global`(≥11.23.0;老拼写 `enableGlobalVirtualStore`)在 example-repo 实测:
40
+ # **收益** 新 worktree 安装 **2.9s**(对照 17.5~20.8s)、`node_modules` **1.9M**(对照名义 1.3G)、lockfile 零变更;
41
+ # **硬阻** GVS 把「每个包的依赖图目录」实体化 ⇒ `@types/react` 出现 6 个不同 realpath 副本 ⇒ TS 类型身份分裂
42
+ # ⇒ **CI 同款 `nx typecheck web-hub` 40 错**(单变量对照:同 pnpm 版本关掉 GVS 即通过;pnpm 12.11.2 同样错)。
43
+ # 绕法(tsconfig `paths` 钉 `react`)能救 TS,但被 example-repo 的 `resolve.tsconfigPaths: true` 带到**运行时** ⇒ vitest 挂,故不可用。
44
+ # ⇒ **不改任何仓**,维持「真装 + 禁软链」。完整数据/复现命令/失败尝试见 `knowledge/tools/pnpm.md`「试点结论」;
45
+ # 流程语境见 `knowledge/workflows/worktree-forms.md` §3。
46
+ # ============================================================================
47
+ set -uo pipefail
48
+
49
+ # 自身路径(`check` 的修复指引里要能回指本脚本;被 `bash <path>` 调用时 $0 已是路径)
50
+ # ── 解析真实脚本路径(symlink 安全;macOS readlink 无 -f)────────────────
51
+ _SRC="${BASH_SOURCE[0]:-$0}"
52
+ while [ -L "$_SRC" ]; do
53
+ _D="$(cd -P "$(dirname "$_SRC")" >/dev/null 2>&1 && pwd)"
54
+ _SRC="$(readlink "$_SRC")"
55
+ case "$_SRC" in /*) ;; *) _SRC="$_D/$_SRC" ;; esac
56
+ done
57
+ SELF_DIR="$(cd -P "$(dirname "$_SRC")" >/dev/null 2>&1 && pwd)"
58
+ SELF="$_SRC"
59
+
60
+ # ── SIGPIPE 加固(T8,2026-10-13 实测)─────────────────────────────────────────
61
+ # 现象:`dia wt status | head -3` / `| grep -q …` 会以 **141**(128+SIGPIPE)退出,
62
+ # 且**输出被截断** —— 因为下游提前退出关闭管道后,本脚本仍在 `printf`。
63
+ # 影响面:**用户真实用法**(`| head`、`| less`、`| grep -q`),不只是 e2e 断言。
64
+ # e2e 3b 段 `T4 status 未标出未提交改动` 的失败即由此而来(grep -q 提前退出)。
65
+ # 修法:默认忽略 SIGPIPE ⇒ 写失败不再杀进程;管道下游截断属**预期行为**,退出码归 0。
66
+ # 注:`trap` 只影响本脚本自身,不改变各子命令(git 等)的语义。
67
+ trap '' PIPE
68
+
69
+ say() { printf '\033[1;36m[dia-wt]\033[0m %s\n' "$*"; }
70
+ warn() { printf '\033[1;33m[dia-wt]\033[0m %s\n' "$*" >&2; }
71
+ die() { printf '\033[1;31m[dia-wt]\033[0m %s\n' "$*" >&2; exit 1; }
72
+
73
+ # ── 仓库级扫描工具特征(出现任一 → 平级)────────────────────────────────────────
74
+ # 依据 worktree-forms.md §2 条件②:nx / eslint / tsc / vite / CRA 全仓扫描。
75
+ has_repo_scan_tools() {
76
+ local repo="$1"
77
+ local pats=(nx.json eslint.config.js eslint.config.mjs eslint.config.cjs .eslintrc .eslintrc.js .eslintrc.cjs
78
+ tsconfig.json vite.config.ts vite.config.js webpack.config.js)
79
+ local f
80
+ for f in "${pats[@]}"; do
81
+ [ -e "${repo}/${f}" ] && return 0
82
+ done
83
+ if [ -f "${repo}/package.json" ]; then
84
+ grep -qE '"(nx|eslint|typescript|vite|react-scripts)"' "${repo}/package.json" 2>/dev/null && return 0
85
+ fi
86
+ return 1
87
+ }
88
+
89
+ # ── 选型:输出 flat | hidden ────────────────────────────────────────────────────
90
+ pick_form() {
91
+ local repo="$1"
92
+ [ -d "${repo}/.git" ] || { printf 'flat'; return 0; }
93
+
94
+ if has_repo_scan_tools "$repo"; then printf 'flat'; return 0; fi
95
+ if [ -d "${repo}/node_modules" ]; then printf 'flat'; return 0; fi
96
+ if [ -f "${repo}/.gitignore" ] && grep -qE '^/?\.worktrees/?$' "${repo}/.gitignore" 2>/dev/null; then
97
+ printf 'hidden'; return 0
98
+ fi
99
+ printf 'flat'
100
+ }
101
+
102
+ form_reason() {
103
+ local repo="$1"
104
+ if has_repo_scan_tools "$repo"; then echo "有仓库级扫描工具(nx/eslint/tsc/vite…)→ 平级"; return; fi
105
+ if [ -d "${repo}/node_modules" ]; then echo "有 node_modules(需真装依赖)→ 平级"; return; fi
106
+ if [ -f "${repo}/.gitignore" ] && grep -qE '^/?\.worktrees/?$' "${repo}/.gitignore" 2>/dev/null; then
107
+ echo "纯文档/脚本仓(无依赖、无扫描工具、gitignore 已备)→ 隐藏子目录可用"; return; fi
108
+ echo "条件不足或不确定 → 平级(兜底,用户 2026-10-12 拍板)"
109
+ }
110
+
111
+ # ── 数据根(与 core/AGENTS.md 原则 9 一致;YAN_DATA_ROOT 优先)──────────────────
112
+ dia_wt_data_root() {
113
+ if [ -n "${DIA_WT_DATA_ROOT:-}" ]; then printf '%s' "${DIA_WT_DATA_ROOT%/}"; return; fi
114
+ if [ -n "${DIA_DATA_ROOT_SCOPE:-}" ]; then printf '%s' "${DIA_DATA_ROOT_SCOPE%/}"; return; fi
115
+ # 默认与 yan 数据根同构:~/.yan-agent(persona 用 data_root 声明覆盖)
116
+ printf '%s' "$HOME/.yan-agent"
117
+ }
118
+
119
+ # ── 授权推导的**输入**:产出 scope.json(设计 §4.5 D0–D6;消费方 = dsh 引擎 / P3 兜底插件)──
120
+ # 为什么在这里产出:迭代入口天然知道"本次任务仓库是哪个",而权限层不知道(这是越权写的根因之一)。
121
+ # ⚠️ 关键判定:**是否 git 仓库** —— 非 git 仓库(如 ~/repos/leyan 根)**不派生** worktree 同级目录,
122
+ # 否则会把兄弟目录(repos/local 等)误算进作用域(用户 2026-10-15 明确)。
123
+ write_scope() { # write_scope <repo-or-worktree-path> [feature] [session]
124
+ local target="$1" feature="${2:-}" session="${3:-}"
125
+ [ -n "$target" ] || die "write_scope 需要路径"
126
+ [ -d "$target" ] || die "路径不存在:$target"
127
+ local abs; abs="$(cd "$target" && pwd)"
128
+
129
+ # 若给的是 worktree(<name>-worktree--<slug>)⇒ 归到主仓;否则按主仓处理
130
+ local base main="$abs" main_checkout=false active="$abs"
131
+ base="$(basename "$abs")"
132
+ if case "$base" in *-worktree--*) true ;; *) false ;; esac; then
133
+ local parent; parent="$(dirname "$abs")"
134
+ local m="$parent/${base%%-worktree--*}"
135
+ if [ -d "$m" ]; then main="$m"; fi
136
+ else
137
+ [ -d "$abs/.git" ] && main_checkout=true # 主 checkout:.git 是目录(worktree 的 .git 是文件)
138
+ [ -f "$abs/.git" ] && main_checkout=false
139
+ fi
140
+
141
+ local git_repo=false
142
+ git -C "$main" rev-parse --git-dir >/dev/null 2>&1 && git_repo=true
143
+
144
+ # ⚠️ 2026-10-16 修:此前写**单份** `tasks/scope.json` ⇒ 多会话并发互相覆盖(实测:后 create 的会话
145
+ # 把前一个的 active 覆盖掉 ⇒ 权限层会用**别人的作用域**判定越权)。改为**按 worktree 分文件**:
146
+ # `<数据根>/tasks/scopes/<active 基名>.json`(worktree 目录名唯一 ⇒ 天然隔离;同一 worktree 内重复写=同一任务,覆盖正确)
147
+ local scopes_dir; scopes_dir="$(dia_wt_data_root)/tasks/scopes"
148
+ mkdir -p "$scopes_dir"
149
+ local out="$scopes_dir/$(basename "$active").json"
150
+ node - "$out" "$main" "$git_repo" "$main_checkout" "$active" "$feature" "$session" <<'JS'
151
+ const [out, repo, gitRepo, mainCheckout, active, feature, session] = process.argv.slice(2);
152
+ const path = require('node:path');
153
+ const fs = require('node:fs');
154
+ const parent = path.dirname(repo), name = path.basename(repo);
155
+ const writable = [repo];
156
+ let sibling = null;
157
+ if (gitRepo === 'true') {
158
+ sibling = `${parent}/${name}-worktree--*`;
159
+ writable.push(sibling);
160
+ }
161
+ const pad = (n) => String(n).padStart(2, '0');
162
+ const d = new Date();
163
+ const since = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
164
+ const doc = {
165
+ task: feature || name, repo, active,
166
+ gitRepo: gitRepo === 'true', mainCheckout: mainCheckout === 'true',
167
+ writable, siblingWorktreeGlob: sibling, session: session || null, since,
168
+ producedBy: 'dia wt scope',
169
+ };
170
+ const tmp = out + '.tmp';
171
+ fs.writeFileSync(tmp, JSON.stringify(doc, null, 1) + '\n');
172
+ fs.renameSync(tmp, out); // 原子替换
173
+ console.log(JSON.stringify(doc, null, 1));
174
+ JS
175
+ }
176
+
177
+ cmd_scope() { # scope <repo-or-worktree> [--feature <slug>] [--session <id>] | scope --show
178
+ local target="" feature="" session="" show=false list=false
179
+ while [ $# -gt 0 ]; do case "$1" in
180
+ --feature) feature="${2:-}"; shift 2 ;;
181
+ --session) session="${2:-}"; shift 2 ;;
182
+ --show) show=true; shift ;;
183
+ --list) list=true; shift ;;
184
+ *) target="$1"; shift ;;
185
+ esac; done
186
+ local scopes; scopes="$(dia_wt_data_root)/tasks/scopes"
187
+ if [ "$list" = true ]; then
188
+ ls -1 "$scopes"/*.json 2>/dev/null | while read -r f; do
189
+ printf '%s\n' "$(basename "$f")"
190
+ done
191
+ [ -d "$scopes" ] && [ -n "$(ls -1 "$scopes"/*.json 2>/dev/null)" ] || warn "尚无作用域(先跑:dia wt scope <repo>)"
192
+ return 0
193
+ fi
194
+ if [ "$show" = true ] || [ -z "$target" ]; then
195
+ # 按 **cwd** 选用:优先匹配 active 前缀最长者;否则回退"当前目录所属 worktree"的 scope 文件
196
+ local cur; cur="$(pwd)"
197
+ local best="" best_len=0 f active
198
+ for f in "$scopes"/*.json; do
199
+ [ -f "$f" ] || continue
200
+ active="$(node -e 'try{console.log(require(require("node:path").resolve(process.argv[1])).active||"")}catch{}' "$f" 2>/dev/null)"
201
+ case "$cur" in "$active"|"$active"/*) [ ${#active} -gt "$best_len" ] && { best="$f"; best_len=${#active}; } ;; esac
202
+ done
203
+ [ -n "$best" ] && { cat "$best"; return 0; }
204
+ [ -f "$(dia_wt_data_root)/tasks/scope.json" ] && { warn "(回退兼容:旧的单份 scope.json)"; cat "$(dia_wt_data_root)/tasks/scope.json"; return 0; }
205
+ warn "当前目录无适用作用域(先跑:dia wt scope <repo>)"; return 1
206
+ fi
207
+ write_scope "$target" "$feature" "$session" >/dev/null
208
+ local written
209
+ written="$(ls -1t "$scopes"/*.json 2>/dev/null | head -1)"
210
+ say "✅ 已写作用域声明:$written"
211
+ node - "$written" <<'JS'
212
+ const d = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
213
+ console.log(` repo=${d.repo}| gitRepo=${d.gitRepo}| writable=` + (d.writable || []).join(', '));
214
+ JS
215
+ }
216
+
217
+ wt_path() {
218
+ local repo="$1" feature="$2" form="$3"
219
+ local parent; parent="$(cd "$(dirname "$repo")" && pwd)"
220
+ local base; base="$(basename "$repo")"
221
+ if [ "$form" = hidden ]; then printf '%s/.worktrees/%s' "$repo" "$feature"
222
+ else printf '%s/%s-worktree--%s' "$parent" "$base" "$feature"; fi
223
+ }
224
+
225
+ # ── create ─────────────────────────────────────────────────────────────────────
226
+ cmd_create() {
227
+ local repo="${1:-}" feature="${2:-}"; shift 2 2>/dev/null || true
228
+ [ -n "$repo" ] && [ -n "$feature" ] || die "用法:create <repo-path> <feature> [--base <ref>] [--dry-run]"
229
+ local base="origin/main" dry=0 install=0
230
+ while [ $# -gt 0 ]; do case "$1" in
231
+ --base) base="$2"; shift 2 ;;
232
+ --dry-run) dry=1; shift ;;
233
+ --install) install=1; shift ;;
234
+ *) shift ;;
235
+ esac; done
236
+
237
+ repo="$(cd "$repo" 2>/dev/null && pwd)" || die "仓库不存在:$repo"
238
+ [ -d "${repo}/.git" ] || die "不是 git 仓库:$repo"
239
+
240
+ local form; form="$(pick_form "$repo")"
241
+ local dest; dest="$(wt_path "$repo" "$feature" "$form")"
242
+ local branch="feat/${feature}"
243
+
244
+ say "仓库 : $repo"
245
+ say "形式 : ${form}($(form_reason "$repo"))"
246
+ say "目标 : $dest"
247
+ say "分支 : ${branch}(基于 ${base})"
248
+
249
+ if [ -e "$dest" ]; then
250
+ warn "目标已存在:$dest"
251
+ warn " → 复用请直接 cd;重建请先 dia wt clean '$repo' '$feature'"
252
+ return 1
253
+ fi
254
+ if [ "$dry" = 1 ]; then say "(dry-run)未创建"; return 0; fi
255
+
256
+ git -C "$repo" fetch origin --prune >/dev/null 2>&1 || warn "fetch 失败(继续,用本地 ${base})"
257
+ if ! git -C "$repo" rev-parse --verify "$base" >/dev/null 2>&1; then
258
+ warn "$base 不存在,回退 origin/master"; base="origin/master"
259
+ fi
260
+
261
+ if [ "$form" = hidden ]; then
262
+ if ! grep -qE '^/?\.worktrees/?$' "${repo}/.gitignore" 2>/dev/null; then
263
+ say " 补 .gitignore:.worktrees/"
264
+ printf '\n# worktree 隐藏目录(dia wt)\n.worktrees/\n' >> "${repo}/.gitignore"
265
+ fi
266
+ mkdir -p "${repo}/.worktrees"
267
+ fi
268
+
269
+ git -C "$repo" worktree add "$dest" -b "$branch" "$base" || die "worktree 创建失败"
270
+ say "✅ 已创建:$dest"
271
+ local gd cd_
272
+ gd="$(git -C "$dest" rev-parse --git-dir 2>/dev/null)"
273
+ cd_="$(git -C "$dest" rev-parse --git-common-dir 2>/dev/null)"
274
+ if [ "$gd" = "$cd_" ]; then
275
+ warn "⚠️ 隔离校验异常:git-dir == common-dir(${gd})"
276
+ else
277
+ say "隔离校验 ✓(git-dir ≠ common-dir)"
278
+ fi
279
+ # 迭代入口自动产出作用域声明(供权限层消费;失败不影响 create)
280
+ write_scope "$dest" "$feature" "${DIA_SESSION:-}" >/dev/null 2>&1 && say "作用域声明已写:$(basename "$(dia_wt_data_root)")/tasks/scope.json"
281
+
282
+ # ── 依赖:真装,**不许软链**(R20;2026-10-15 加机械提示 + 可选 --install)──
283
+ # 为什么在创建时就提示:软链省下的 13~34s,换来的是对方 checkout 被 pnpm 改写
284
+ # (10-10 实证 99/100 条)+ 删 worktree 后对方整片悬空(09-30 实证 198 条)。
285
+ # 把「真装」变成创建流程里的默认下一步,而不是靠记性。
286
+ if [ -f "$dest/package.json" ]; then
287
+ local dep_cmd=""
288
+ if [ -f "$dest/pnpm-lock.yaml" ]; then dep_cmd="pnpm install --frozen-lockfile --prefer-offline"
289
+ elif [ -f "$dest/package-lock.json" ]; then dep_cmd="npm ci"
290
+ else dep_cmd="pnpm install"; fi
291
+ if [ -L "$dest/node_modules" ]; then
292
+ warn "依赖 : ⚠️ node_modules 是软链(异常)→ rm -f '$dest/node_modules' 后真装"
293
+ elif [ -d "$dest/node_modules" ]; then
294
+ say "依赖 : node_modules 已存在(真目录)✓"
295
+ elif [ "$install" = 1 ]; then
296
+ say "真装依赖 : $dep_cmd"
297
+ ( cd "$dest" && eval "$dep_cmd" ) && say "✅ 依赖就绪" \
298
+ || warn "依赖安装失败(重跑:cd '$dest' && ${dep_cmd})"
299
+ else
300
+ say "依赖 : 尚未安装 —— 真装:cd '$dest' && $dep_cmd"
301
+ say " (R20:**禁止**软链其他 checkout 的 node_modules;要提速请用 pnpm 官方"
302
+ say " global virtual store,见 knowledge/workflows/worktree-forms.md §3)"
303
+ fi
304
+ [ -L "$repo/node_modules" ] && \
305
+ warn "主仓 node_modules 是软链 → bash '$SELF' check '$repo' 确认是否需要真装重建"
306
+ fi
307
+ say "下一步:cd '$dest' 开发;收尾 dia wt clean '$repo' '$feature'(R25)"
308
+ }
309
+
310
+ cmd_form() {
311
+ local repo="${1:-}"; [ -n "$repo" ] || die "用法:form <repo-path>"
312
+ repo="$(cd "$repo" 2>/dev/null && pwd)" || die "仓库不存在:$repo"
313
+ echo " 仓库: $repo"
314
+ echo " 形式: $(pick_form "$repo")"
315
+ echo " 理由: $(form_reason "$repo")"
316
+ }
317
+
318
+ # ── status:跨 worktree 可见性(T4,INV-8)──────────────────────────────────────
319
+ # 为什么需要(2026-10-13 实测,设计 §1.4):
320
+ # `main..HEAD = 0` 且 `git status` 相对 HEAD 干净 —— **两个常规信号都指向"可安全清理"**,
321
+ # 但 worktree 可能持有**唯一工作副本**(未提交改动)。`git worktree remove` 会连它一起删。
322
+ # ⇒ 需要「这个 worktree 能不能清」的**统一判据**。
323
+ #
324
+ # 判据(设计 D6,任一不满足即不可直接清理):
325
+ # ① 未并入默认分支:`<def>..HEAD` > 0 → 有未合并提交
326
+ # ② 未推送:`origin/<branch>..HEAD` > 0 → 提交只在本地
327
+ # ③ 未提交改动 ≠ 空 → **唯一工作副本**
328
+ #
329
+ # ⚠️ **本命令只读**:不写、不删、不切分支(R21 精神)。
330
+ status_of() { # 输出该 worktree 的判定行;置全局 ST_UNSAFE=1 表示不可直接清理
331
+ local p="$1" def="$2"
332
+ local br ahead unpushed dirty_files dirty_n uniq="—"
333
+ ST_UNSAFE=0; ST_FILES=""
334
+ br="$(git -C "$p" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')"
335
+ [ "$br" = "HEAD" ] && br="detached" # E15:detached 明确标注
336
+ ahead="$(git -C "$p" rev-list --count "$def..HEAD" 2>/dev/null || echo 0)"
337
+ if [ "$br" = "detached" ]; then unpushed="?"
338
+ else unpushed="$(git -C "$p" rev-list --count "origin/$br..HEAD" 2>/dev/null || echo 0)"; fi
339
+ dirty_files="$(git -C "$p" status --porcelain 2>/dev/null | sed 's/^...//')"
340
+ dirty_n="$(printf '%s' "$dirty_files" | grep -c . 2>/dev/null)"; [ -n "$dirty_n" ] || dirty_n=0
341
+ # 顺序:未提交 > 未推送 > 未并入(按"先抢救什么"的紧迫度)
342
+ if [ "$dirty_n" -gt 0 ]; then uniq="⚠️ 是(未提交)"; ST_UNSAFE=1; ST_FILES="$dirty_files"
343
+ elif [ "$unpushed" != "0" ] && [ "$unpushed" != "?" ]; then uniq="⚠️ 是(未推送)"; ST_UNSAFE=1
344
+ elif [ "$ahead" != "0" ]; then uniq="⚠️ 是(未并入)"; ST_UNSAFE=1
345
+ fi
346
+ printf ' %-50s %-20s %6s %8s %s\n' "${p/#$HOME/~}" "$br" "$ahead" "${dirty_n}文件" "$uniq"
347
+ }
348
+
349
+ cmd_status() {
350
+ local repo="${1:-.}"
351
+ repo="$(cd "$repo" 2>/dev/null && pwd)" || die "仓库不存在:$repo"
352
+ [ -d "${repo}/.git" ] || die "不是 git 仓库:$repo"
353
+ local def; def="$(git -C "$repo" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
354
+ def="${def#origin/}"; [ -n "$def" ] || def="main"
355
+
356
+ echo "══ dia wt status(仓库: $repo · 默认分支: ${def})══"
357
+ printf ' %-50s %-20s %6s %8s %s\n' "WORKTREE" "分支" "未并入" "改动" "唯一工作副本"
358
+ printf ' %s\n' "──────────────────────────────────────────────────────────────────────────────────"
359
+
360
+ local total=0 unsafe=0 dirty_map=""
361
+ while IFS= read -r p; do
362
+ [ -n "$p" ] || continue
363
+ [ -d "$p" ] || continue
364
+ total=$((total+1))
365
+ status_of "$p" "$def"
366
+ if [ "$ST_UNSAFE" = 1 ]; then
367
+ unsafe=$((unsafe+1))
368
+ [ -n "$ST_FILES" ] && printf '%s\n' "$ST_FILES" | sed 's/^/ · /'
369
+ [ -n "$ST_FILES" ] && dirty_map="${dirty_map}${p}"$'\t'"$(printf '%s' "$ST_FILES" | tr '\n' ',')"$'\n'
370
+ fi
371
+ done < <(git -C "$repo" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
372
+
373
+ # 重叠检测(设计 D6):**同一仓内**两个以上 worktree 改同一**仓内相对路径**。
374
+ # 不跨仓比较 —— 不同仓的同名相对路径属巧合,报出来是噪音。
375
+ local overlaps
376
+ overlaps="$(printf '%s' "$dirty_map" | awk -F'\t' '
377
+ NF<2 {next}
378
+ { n=split($2, a, ","); for (i=1;i<=n;i++) if (a[i]!="") { cnt[a[i]]++; if (who[a[i]]=="") who[a[i]]=1; else who[a[i]]++ } }
379
+ END { for (f in cnt) if (cnt[f]>1) printf " · %s(%d 个 worktree 同时改)\n", f, cnt[f] }
380
+ ' | sort)"
381
+ if [ -n "$overlaps" ]; then
382
+ echo " ⚠️ **改动重叠**(同一文件被多个 worktree 同时修改 → 合并必冲突):"
383
+ printf '%s\n' "$overlaps"
384
+ fi
385
+
386
+ echo " ── $total 个 worktree · $unsafe 个**不可直接清理** ──"
387
+ [ "$unsafe" = 0 ] || echo " (抢救:cd <worktree> && git add -A && git commit -m '…',或 push 分支后再 clean)"
388
+ return 0
389
+ }
390
+
391
+ cmd_list() {
392
+ local repo="${1:-.}"
393
+ repo="$(cd "$repo" 2>/dev/null && pwd)" || die "仓库不存在:$repo"
394
+ echo " 仓库: $repo"
395
+ git -C "$repo" worktree list | while read -r p rest; do
396
+ local mark="平级"
397
+ case "$p" in */.worktrees/*) mark="隐藏";; esac
398
+ printf ' [%s] %s %s\n' "$mark" "$p" "$rest"
399
+ done
400
+ }
401
+
402
+ # ═══════════════════════════════════════════════════════════════════════════
403
+ # prune-merged —— 扫出「已并入 main 的本地分支」并(可选)清理(R25 的定期闸口)
404
+ #
405
+ # 为什么需要(2026-10-19,用户拍板):
406
+ # R25 要求「合并后当场清理分支」,但**没有兜底** —— `clean` 只在有人手动调用时执行。
407
+ # 实证:2026-10-19 一次清出 **16 个**已并入 main 的历史分支(最早 09-30,最晚 10-16),
408
+ # 其中 `fix/dws-hub-*` 是并行会话留下的、挂了 3 天没人管。
409
+ # ⇒ 与 `check`(node_modules 软链)同理:**「写进规则」不够,要有定期扫描的机械闸口**。
410
+ #
411
+ # 安全性(这是**破坏性**操作,比 check 严格):
412
+ # · 只删 **已并入 `--base`(默认 origin/main)** 的分支(`git branch -d` 语义,不是 `-D`);
413
+ # · **跳过**:当前分支、被任何 worktree checkout 的分支、`--protect` 名单(默认 main/master/develop/dev);
414
+ # · 默认 **dry-run**(只列不删);真删要显式 `--apply`;
415
+ # · 删前逐个打印「分支名 + 最后提交日期 + 标题」,可回溯。
416
+ #
417
+ # 用法:prune-merged [<repo>] [--all] [--apply] [--base <ref>] [--protect a,b] [--quiet]
418
+ # --all 扫已知仓根(数据根 repos/、$DIA_WT_REPOS_ROOT 默认 ~/repos/leyan)下的主 checkout
419
+ # 退出码:0 = 无待清理 / 已全部清理;1 = 仍有残留(dry-run 发现待清理时也是 1)
420
+ # ═══════════════════════════════════════════════════════════════════════════
421
+ cmd_prune_merged() {
422
+ local repo="" apply=0 base="origin/main" quiet=0 all=0
423
+ local protect_csv="main,master,develop,dev"
424
+ while [ $# -gt 0 ]; do case "$1" in
425
+ --apply) apply=1; shift ;;
426
+ --all) all=1; shift ;;
427
+ --base) base="$2"; shift 2 ;;
428
+ --protect) protect_csv="$2"; shift 2 ;;
429
+ --quiet) quiet=1; shift ;;
430
+ -*) die "未知参数:$1(支持 --all / --apply / --base <ref> / --protect a,b / --quiet)" ;;
431
+ *) repo="$1"; shift ;;
432
+ esac; done
433
+
434
+ # 仓库集合:--all 复用与 `check --all` 相同的发现逻辑(含跳过 worktree 目录)
435
+ local -a repos=()
436
+ if [ "$all" = 1 ]; then
437
+ local dr rr
438
+ dr="$(dia_wt_data_root 2>/dev/null || true)"
439
+ rr="${DIA_WT_REPOS_ROOT:-$HOME/repos/leyan}"
440
+ local -a roots=()
441
+ [ -n "$dr" ] && [ -d "$dr/repos" ] && roots+=("$dr/repos")
442
+ [ -d "$rr" ] && roots+=("$rr")
443
+ [ ${#roots[@]} -gt 0 ] || die "--all:未找到仓根(数据根 repos/ 与 ${rr})"
444
+ local root sub
445
+ for root in "${roots[@]}"; do
446
+ for sub in "$root"/*; do
447
+ [ -d "$sub" ] || continue
448
+ case "$(basename "$sub")" in *-worktree--*) continue ;; esac
449
+ [ -d "$sub/.git" ] && repos+=("$sub")
450
+ done
451
+ done
452
+ else
453
+ repos=("${repo:-.}")
454
+ fi
455
+
456
+ local total=0 rc_all=0
457
+ local r
458
+ for r in "${repos[@]}"; do
459
+ prune_one "$r" "$apply" "$base" "$protect_csv" "$quiet" "$all" || rc_all=1
460
+ total=$((total + 1))
461
+ done
462
+ return "$rc_all"
463
+ }
464
+
465
+ # 单仓处理(被 cmd_prune_merged 调用;返回 0=干净/已清完,1=仍有残留)
466
+ prune_one() {
467
+ local repo="$1" apply="$2" base="$3" protect_csv="$4" quiet="$5" all="$6"
468
+ repo="$(cd "$repo" 2>/dev/null && pwd)" || { warn "跳过(非目录):$repo"; return 0; }
469
+
470
+ if ! git -C "$repo" rev-parse --verify --quiet "$base" >/dev/null; then
471
+ # ⚠️ 两种语境语义不同:
472
+ # · 单仓(显式指定)→ **报错**(用户以为能扫,实际基线没 fetch ⇒ 静默空转最坏)
473
+ # · --all(批量巡检)→ **跳过**(很多仓没配 origin/main,不能因此中断整轮)
474
+ if [ "$all" = 1 ]; then
475
+ [ "$quiet" = 1 ] || warn "跳过(无基线 ${base},先 git fetch origin):$repo"
476
+ return 0
477
+ fi
478
+ warn "基线不存在:${base}(先 git fetch origin)—— $repo"
479
+ return 1
480
+ fi
481
+
482
+ # 受保护集合:名单 + 当前分支 + 所有 worktree checkout 的分支
483
+ local -A prot=()
484
+ local IFS=','
485
+ for b in $protect_csv; do [ -n "$b" ] && prot["$b"]=1; done
486
+ unset IFS
487
+ local cur; cur="$(git -C "$repo" branch --show-current 2>/dev/null)"
488
+ [ -n "$cur" ] && prot["$cur"]=1
489
+ while IFS= read -r wtpath; do
490
+ local wb; wb="$(git -C "$wtpath" branch --show-current 2>/dev/null)"
491
+ [ -n "$wb" ] && prot["$wb"]=1
492
+ done < <(git -C "$repo" worktree list --porcelain | sed -n 's/^worktree //p')
493
+
494
+ # 候选:已并入 base 的本地分支,去掉受保护项
495
+ local -a todo=()
496
+ while IFS= read -r b; do
497
+ [ -n "$b" ] || continue
498
+ [ -n "${prot[$b]:-}" ] && continue
499
+ todo+=("$b")
500
+ done < <(git -C "$repo" branch --merged "$base" --format='%(refname:short)')
501
+
502
+ if [ "${#todo[@]}" -eq 0 ]; then
503
+ [ "$quiet" = 1 ] || say "✅ 无已合并的残留分支(基线 ${base})"
504
+ return 0
505
+ fi
506
+
507
+ [ "$quiet" = 1 ] || say "仓库:${repo}(基线 ${base})—— 已并入但未清理的分支 ${#todo[@]} 个:"
508
+ local b
509
+ for b in "${todo[@]}"; do
510
+ if [ "$quiet" = 1 ]; then
511
+ printf '%s\n' "$b"
512
+ else
513
+ local when subj
514
+ when="$(git -C "$repo" log -1 --format='%ad' --date=short "$b" 2>/dev/null)"
515
+ subj="$(git -C "$repo" log -1 --format='%s' "$b" 2>/dev/null | cut -c1-60)"
516
+ printf ' · %-46s %s %s\n' "$b" "$when" "$subj"
517
+ fi
518
+ done
519
+
520
+ if [ "$apply" != 1 ]; then
521
+ [ "$quiet" = 1 ] || {
522
+ printf '\n'
523
+ warn "以上为 **dry-run**(未删除)。确认后执行:"
524
+ warn " bash '$SELF' prune-merged '$repo' --apply"
525
+ }
526
+ return 1
527
+ fi
528
+
529
+ local deleted=0 failed=0
530
+ for b in "${todo[@]}"; do
531
+ # ⚠️⚠️ **必须先临时 unset upstream,否则 `-d` 会误拒**(2026-10-19 查清根因):
532
+ # `git branch -d` 的判据是「**已并入 upstream(若配置了)**」,不是「已并入 HEAD/main」。
533
+ # 当分支配置了 `branch.<b>.remote/merge`(`push -u` 常见),而远端那份**落后于本地**
534
+ # (推送后又提交、或 MR 合并后远端未同步)⇒ git 报:
535
+ # 「not deleting branch 'x' that is not yet merged to 'refs/remotes/origin/x',
536
+ # even though it is merged to HEAD」⇒ **拒绝删除**。
537
+ # 实证(example-repo 2026-10-19):`fix/format-superbook-cli` 的 4 个提交**全部在 origin/main 上**,
538
+ # 仍被 `-d` 拒绝 —— 正是这个原因(当时误判为「squash 合并」)。
539
+ # 修法:本函数**已独立判定过「已并入 ${base}」**(候选集就是 `branch --merged $base`),
540
+ # 故可安全地临时清掉 upstream 让 `-d` 退回 HEAD 判据;判完**原样恢复**(不改用户配置)。
541
+ # ⚠️ 不用 `-D`:`-d` 在 upstream 清空后仍会拒绝「未并入 HEAD」的分支 —— 安全网保留。
542
+ local _bk_remote _bk_merge _had_up=0
543
+ _bk_remote="$(git -C "$repo" config --get "branch.$b.remote" 2>/dev/null || true)"
544
+ _bk_merge="$(git -C "$repo" config --get "branch.$b.merge" 2>/dev/null || true)"
545
+ if [ -n "$_bk_remote" ] || [ -n "$_bk_merge" ]; then
546
+ _had_up=1
547
+ git -C "$repo" config --unset "branch.$b.remote" 2>/dev/null || true
548
+ git -C "$repo" config --unset "branch.$b.merge" 2>/dev/null || true
549
+ fi
550
+
551
+ if git -C "$repo" branch -d "$b" >/dev/null 2>&1; then
552
+ deleted=$((deleted + 1))
553
+ [ "$quiet" = 1 ] || say "✅ 已删 $b"
554
+ else
555
+ failed=$((failed + 1))
556
+ # 恢复 upstream 配置(删失败时用户可能还要用这个分支)
557
+ if [ "$_had_up" = 1 ]; then
558
+ [ -n "$_bk_remote" ] && git -C "$repo" config "branch.$b.remote" "$_bk_remote" 2>/dev/null || true
559
+ [ -n "$_bk_merge" ] && git -C "$repo" config "branch.$b.merge" "$_bk_merge" 2>/dev/null || true
560
+ fi
561
+ warn "⚠️ 未能删除 ${b}(已并入 $base 但 git 仍拒绝 ⇒ 可能 worktree 占用或分支正被 checkout)"
562
+ warn " 人工确认后可强删:git -C '$repo' branch -D $b"
563
+ fi
564
+ done
565
+ [ "$quiet" = 1 ] || say "完成:删除 $deleted 个,未删 $failed 个"
566
+ [ "$failed" -eq 0 ] && return 0 || return 1
567
+ }
568
+
569
+ # ═══════════════════════════════════════════════════════════════════════════
570
+ # check —— node_modules 软链体检(R20 的**机械闸口**)
571
+ #
572
+ # 为什么需要(2026-09-30 起四犯,2026-10-15 用户拍板落地):
573
+ # R20 明令「worktree 内一律真装依赖、禁止软链其他 checkout 的 node_modules」,
574
+ # 但违规反复发生(09-30 主仓 198 条悬空 / 10-10 一天两次被改写 99+100 条 /
575
+ # 10-12 侥幸未损坏 / 10-15 在软链 worktree 里跑 `pnpm nx` 触发整仓重装)——
576
+ # **「强化文字」已被证明无效** ⇒ 把判据做成命令(同 `route` / `status` 的思路)。
577
+ #
578
+ # 判据(4 类 + 1 条白名单;**必须与 pnpm 官方共享方案对齐**,否则一启用就全体误报):
579
+ # A 顶层 `node_modules` 本身是软链 —— 跨 checkout 共享的典型形态(最危险:pnpm 会 realpath 改写对方)
580
+ # B 顶层链解析后**逃出本 checkout** —— 指向别的 checkout(被改写后的现场)
581
+ # C 顶层链**悬空**(指向已删路径) —— 对方被清理后的现场
582
+ # D 深层链逃逸(仅 `--deep`) —— `node_modules/**` 内部的链
583
+ # ✅ 白名单:pnpm **global virtual store** 的共享链(`virtualStoreType: global` ⇒ 顶层链指向
584
+ # `<storeDir>/links/@/…`)。⚠️ 官方共享方案与"软链别人的 checkout"**是两回事**:
585
+ # 前者指向共享 store(删 worktree 不连累他人,实测删后对方链仍可解析),后者指向短命 checkout。
586
+ # 判据不区分 → 一旦启用 GVS 全仓误报;判据不查 → 违规静默破坏。
587
+ #
588
+ # 用法:check [<repo>...] [--all] [--deep] [--quiet]
589
+ # --all 扫已知仓根(`$YAN_DATA_ROOT/repos`、`$DIA_WT_REPOS_ROOT`(默认 `~/repos/leyan`))下的主 checkout
590
+ # (跳过 `*-worktree--*`:它们的 worktree 由主仓 `worktree list` 覆盖)+ 仓根杂散 node_modules 检测
591
+ # --deep 递归扫 node_modules 全树(默认只扫顶层;大仓全树需数秒)
592
+ # --quiet 只输出有问题的 checkout
593
+ # 退出码:0 干净;1 有违规;2 用法错误
594
+ # ═══════════════════════════════════════════════════════════════════════════
595
+
596
+ # ── 合法外部目标前缀(GVS 共享链):<checkout> → 每行一个 ──────────────────────
597
+ nm_allow_prefixes() {
598
+ local p="$1" nm="$p/node_modules" sd="" vsd=""
599
+ if [ -f "$nm/.modules.yaml" ]; then
600
+ local pair; pair="$(node - "$nm/.modules.yaml" <<'JS' 2>/dev/null
601
+ const fs = require('node:fs');
602
+ let txt = '';
603
+ try { txt = fs.readFileSync(process.argv[2], 'utf8'); } catch { console.log('- -'); process.exit(0); }
604
+ const get = (key) => {
605
+ const m = new RegExp('"' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '"\\s*:\\s*"([^"]*)"').exec(txt);
606
+ return m ? m[1] : '';
607
+ };
608
+ console.log((get('storeDir') || '-') + ' ' + (get('virtualStoreDir') || '-'));
609
+ JS
610
+ )"
611
+ read -r sd vsd <<<"$pair"
612
+ fi
613
+ [ "$sd" = "-" ] && sd=""
614
+ [ "$vsd" = "-" ] && vsd=""
615
+ [ -n "$sd" ] || sd="$(pnpm store path 2>/dev/null | head -1 || true)"
616
+ [ -n "$sd" ] && [ -d "$sd/links" ] && printf '%s\n' "$sd/links"
617
+ # virtualStoreDir 若被显式设成绝对路径(GVS 自定义位置)也算合法外部目标
618
+ case "$vsd" in /*) [ -d "$vsd" ] && printf '%s\n' "$vsd" ;; esac
619
+ return 0
620
+ }
621
+
622
+ # ── 解析 python 体检输出 ──────────────────────────────────────────────────────
623
+ chk_cnt() { awk -F'\t' -v k="$2" '$1=="COUNT" && $2==k {print $3}' <<<"$1" | head -1; }
624
+ chk_top() { awk -F'\t' '$1=="TOP" {print $2}' <<<"$1" | head -1; }
625
+ chk_items() { awk -F'\t' -v k="$2" '$1=="ITEM" && $2==k {print $3"\t"$4}' <<<"$1"; }
626
+
627
+ # ── 单个 checkout 扫描:<checkout> <deep> <is-main 0|1> → 置 CHK_VIOL / CHK_BLOCK ──
628
+ scan_checkout() {
629
+ local p="$1" deep="$2" is_main="$3"
630
+ local nm="$p/node_modules"
631
+ local _ln=""
632
+ CHK_VIOL=0; CHK_BLOCK=""
633
+ { [ -d "$nm" ] || [ -L "$nm" ]; } || return 0
634
+ local allow=() l
635
+ while IFS= read -r l; do [ -n "$l" ] && allow+=("$l"); done < <(nm_allow_prefixes "$p")
636
+
637
+ local raw; raw="$(node - "$p" "$deep" ${allow[@]+"${allow[@]}"} 2>/dev/null <<'JS'
638
+ const fs = require('node:fs');
639
+ const path = require('node:path');
640
+ const [target, deepFlag, ...allowRaw] = process.argv.slice(2);
641
+ const deep = deepFlag === '1';
642
+ const allow = allowRaw.filter(Boolean).map((a) => fs.realpathSync(a));
643
+ const troot = fs.realpathSync(target);
644
+ const nm = path.join(target, 'node_modules');
645
+ const under = (p, root) => p === root || p.startsWith(root + path.sep);
646
+
647
+ function classify(link) {
648
+ let tgt;
649
+ try { tgt = fs.readlinkSync(link); } catch { return null; }
650
+ const abs = path.isAbsolute(tgt) ? tgt : path.normalize(path.join(path.dirname(link), tgt));
651
+ if (!fs.existsSync(abs)) return ['C', abs];
652
+ const rp = fs.realpathSync(abs);
653
+ if (under(rp, troot) || allow.some((a) => under(rp, a))) return null;
654
+ return ['B', rp];
655
+ }
656
+
657
+ const res = { A: [], B: [], C: [], D: [] };
658
+ let topLinks = 0, capped = false;
659
+ let st = null;
660
+ try { st = fs.lstatSync(nm); } catch { st = null; }
661
+ if (st && st.isSymbolicLink()) {
662
+ res.A.push(['node_modules', fs.realpathSync(nm)]);
663
+ } else if (st && st.isDirectory()) {
664
+ for (const name of fs.readdirSync(nm).sort()) {
665
+ const p = path.join(nm, name);
666
+ let ls; try { ls = fs.lstatSync(p); } catch { continue; }
667
+ if (ls.isSymbolicLink()) {
668
+ topLinks++;
669
+ const c = classify(p);
670
+ if (c) res[c[0]].push([name, c[1]]);
671
+ }
672
+ }
673
+ if (deep) {
674
+ let seen = 0;
675
+ const walk = (dir) => {
676
+ let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
677
+ for (const e of entries) {
678
+ const p = path.join(dir, e.name);
679
+ seen++;
680
+ if (seen > 400000) { capped = true; return; }
681
+ let ls; try { ls = fs.lstatSync(p); } catch { continue; }
682
+ if (ls.isSymbolicLink()) {
683
+ const c = classify(p);
684
+ if (c) res.D.push([path.relative(target, p), c[1]]);
685
+ } else if (ls.isDirectory()) {
686
+ walk(p);
687
+ if (capped) return;
688
+ }
689
+ }
690
+ };
691
+ walk(nm);
692
+ console.log('DEEP\t' + seen);
693
+ }
694
+ }
695
+ console.log('TOP\t' + topLinks);
696
+ for (const k of ['A', 'B', 'C', 'D']) console.log(`COUNT\t${k}\t${res[k].length}`);
697
+ for (const k of ['A', 'B', 'C', 'D']) for (const [name, tgt] of res[k].slice(0, 5)) console.log(`ITEM\t${k}\t${name}\t${tgt}`);
698
+ console.log(capped ? 'CAPPED\t1' : 'CAPPED\t0');
699
+ JS
700
+ )"
701
+ local A B C D tot
702
+ A="$(chk_cnt "$raw" A)"; B="$(chk_cnt "$raw" B)"; C="$(chk_cnt "$raw" C)"; D="$(chk_cnt "$raw" D)"
703
+ [ -n "$A" ] || A=0; [ -n "$B" ] || B=0; [ -n "$C" ] || C=0; [ -n "$D" ] || D=0
704
+ tot=$((A + B + C + D)); CHK_VIOL=$tot
705
+
706
+ local label="$p" mark=""
707
+ [ "$is_main" = 1 ] && { mark=" · 主仓"; }
708
+ if [ "$tot" = 0 ]; then
709
+ # ⚠️ 用 `printf -v` 而不是 `$(printf …)`:命令替换会**吃掉结尾换行** ⇒ 多个 checkout
710
+ # 的输出会挤成一行(2026-10-15 实测)。
711
+ printf -v CHK_BLOCK ' \033[32m✓\033[0m %s%s 0 违规(顶层链 %s 条)\n' "${p/#$HOME/~}" "$mark" "$(chk_top "$raw")"
712
+ return 0
713
+ fi
714
+
715
+ printf -v CHK_BLOCK ' \033[31m✗\033[0m %s%s 违规 %s\n' "${p/#$HOME/~}" "$mark" "$tot"
716
+ if [ "$A" != 0 ]; then
717
+ printf -v _ln ' A 顶层 node_modules 是软链 → %s\n' "$(chk_items "$raw" A | head -1 | cut -f2)"
718
+ CHK_BLOCK+="$_ln"
719
+ CHK_BLOCK+=" (跨 checkout 共享:pnpm 会按 realpath 把对方的链改写指向本 worktree)"$'\n'
720
+ fi
721
+ local k n
722
+ for k in B C D; do
723
+ eval "n=\$$k"
724
+ [ "$n" = 0 ] && continue
725
+ case "$k" in
726
+ B) printf -v _ln ' B 顶层链指向其他 checkout(%s 条,示例):\n' "$n" ;;
727
+ C) printf -v _ln ' C 顶层链悬空(%s 条,示例):\n' "$n" ;;
728
+ D) printf -v _ln ' D 深层链逃逸(%s 条,示例):\n' "$n" ;;
729
+ esac
730
+ CHK_BLOCK+="$_ln"
731
+ while IFS=$'\t' read -r rel tgt; do
732
+ [ -n "$rel" ] || continue
733
+ printf -v _ln ' · %s → %s\n' "$rel" "$tgt"
734
+ CHK_BLOCK+="$_ln"
735
+ done < <(chk_items "$raw" "$k" | head -3)
736
+ done
737
+ [ "$(awk -F'\t' '$1=="CAPPED" {print $2}' <<<"$raw")" = 1 ] && \
738
+ CHK_BLOCK+=" (深层扫描在 40 万条目处截断)"$'\n'
739
+ return 0
740
+ }
741
+
742
+ cmd_check() {
743
+ local repos=() all=0 deep=0 quiet=0 roots=()
744
+ while [ $# -gt 0 ]; do case "$1" in
745
+ --all) all=1; shift ;;
746
+ --deep) deep=1; shift ;;
747
+ --quiet|-q) quiet=1; shift ;;
748
+ -h|--help)
749
+ printf '用法:check [<repo>...] [--all] [--deep] [--quiet]\n'
750
+ return 0 ;;
751
+ -*) die "未知参数:$1(用法:check [<repo>...] [--all] [--deep] [--quiet])" ;;
752
+ *) repos+=("$1"); shift ;;
753
+ esac; done
754
+
755
+ if [ "$all" = 1 ]; then
756
+ local dr rr
757
+ dr="$(dia_wt_data_root 2>/dev/null || true)"
758
+ rr="${DIA_WT_REPOS_ROOT:-$HOME/repos/leyan}"
759
+ [ -n "$dr" ] && [ -d "$dr/repos" ] && roots+=("$dr/repos")
760
+ [ -d "$rr" ] && roots+=("$rr")
761
+ [ ${#roots[@]} -gt 0 ] || die "--all:未找到仓根(数据根 repos/ 与 ${rr})"
762
+ local root sub
763
+ for root in "${roots[@]}"; do
764
+ for sub in "$root"/*; do
765
+ [ -d "$sub" ] || continue
766
+ case "$(basename "$sub")" in *-worktree--*) continue ;; esac # worktree 由其主仓覆盖
767
+ repos+=("$sub")
768
+ done
769
+ done
770
+ fi
771
+ [ ${#repos[@]} -gt 0 ] || repos=("$PWD")
772
+
773
+ printf '══ dia wt check(node_modules 软链体检 · R20)══\n'
774
+ [ "$deep" = 1 ] && printf ' 模式:--deep(递归扫 node_modules 全树)\n'
775
+ printf ' 判据:A 顶层 node_modules 是软链 · B 顶层链逃出 checkout · C 悬空链 · D 深层链逃逸;\n'
776
+ printf ' 白名单:pnpm global virtual store 的 <storeDir>/links(官方共享方案,不算违规)\n'
777
+
778
+ local R total=0 viol=0 bad=0 strays=0
779
+ for R in "${repos[@]}"; do
780
+ if [ ! -e "$R" ]; then warn "跳过(不存在):$R"; continue; fi
781
+ R="$(cd "$R" 2>/dev/null && pwd)" || { warn "跳过(无法进入):$R"; continue; }
782
+ local checkouts=() p buf="" vsum=0 vcheckouts=0
783
+ if [ -d "$R/.git" ] || [ -f "$R/.git" ]; then
784
+ while IFS= read -r p; do [ -d "$p" ] && checkouts+=("$p"); done \
785
+ < <(git -C "$R" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
786
+ fi
787
+ [ ${#checkouts[@]} -gt 0 ] || checkouts=("$R")
788
+ for p in "${checkouts[@]}"; do
789
+ # ⚠️ 用 `pwd -P` 比较:`git worktree list` 给出的是**登记时**的路径,可能含符号链接
790
+ # (实测 macOS `$TMPDIR` 下登记 `/var/…`,而 `cd` 后是 `/private/var/…`)⇒
791
+ # 直接字符串比较会把主仓误判成普通 worktree(丢「· 主仓」标记)。
792
+ local is_main=0
793
+ [ "$(cd "$p" 2>/dev/null && pwd -P)" = "$(cd "$R" 2>/dev/null && pwd -P)" ] && is_main=1
794
+ scan_checkout "$p" "$deep" "$is_main"
795
+ vsum=$((vsum + CHK_VIOL))
796
+ [ "$CHK_VIOL" != 0 ] && vcheckouts=$((vcheckouts + 1))
797
+ buf+="$CHK_BLOCK"
798
+ done
799
+ total=$((total + ${#checkouts[@]}))
800
+ if [ "$vsum" != 0 ]; then
801
+ viol=$((viol + vsum)); bad=$((bad + vcheckouts))
802
+ printf '\n── %s ──\n%s' "${R/#$HOME/~}" "$buf"
803
+ elif [ "$quiet" != 1 ]; then
804
+ printf '\n── %s ──\n%s' "${R/#$HOME/~}" "$buf"
805
+ fi
806
+ done
807
+
808
+ # ── 仓根杂散 node_modules(--all):非 git 仓的目录里躺着 node_modules → 干扰解析 ──
809
+ local root
810
+ for root in "${roots[@]+"${roots[@]}"}"; do
811
+ [ -e "$root/node_modules" ] || continue
812
+ strays=$((strays + 1)); viol=$((viol + 1)); bad=$((bad + 1))
813
+ printf '\n \033[31m✗\033[0m %s/node_modules 杂散(该目录不是仓库)\n' "${root/#$HOME/~}"
814
+ printf ' → 从该目录运行工具(vite/vitest/nx…)会把缓存/解析落到这里,且不属任何 checkout\n'
815
+ printf ' → 清理:rm -rf %s/node_modules(确认无项目在用;本机实证为跑错 cwd 的残留)\n' "${root/#$HOME/~}"
816
+ done
817
+
818
+ printf '\n── %s 个 checkout · 违规 %s 处 · 涉及 %s 个 checkout%s ──\n' \
819
+ "$total" "$viol" "$bad" "$([ "$strays" != 0 ] && printf '(含杂散 %s)' "$strays")"
820
+ if [ "$viol" = 0 ]; then
821
+ printf ' \033[32m全部干净 ✅\033[0m(A/B/C/D 四类判据均 0)\n'
822
+ return 0
823
+ fi
824
+ cat <<EOF
825
+ 修复(对每个违规 checkout 执行;若主仓被改写,就在主仓执行):
826
+ cd <checkout> && rm -f node_modules && pnpm install --frozen-lockfile --prefer-offline
827
+ 说明:真装实测 13.8s~57.5s 即可全量重建(0 悬空);成因/现场特征/前三犯实证见
828
+ core/knowledge/rules/worktree.md R20 与 workflows/worktree-forms.md §3
829
+ EOF
830
+ return 1
831
+ }
832
+
833
+ cmd_clean() {
834
+ local repo="${1:-}" feature="${2:-}"
835
+ [ -n "$repo" ] && [ -n "$feature" ] || die "用法:clean <repo-path> <feature>"
836
+ repo="$(cd "$repo" 2>/dev/null && pwd)" || die "仓库不存在:$repo"
837
+ local candidates=(
838
+ "$(wt_path "$repo" "$feature" flat)"
839
+ "$(wt_path "$repo" "$feature" hidden)"
840
+ )
841
+ local target=""
842
+ for c in "${candidates[@]}"; do [ -d "$c" ] && { target="$c"; break; }; done
843
+ [ -n "$target" ] || die "未找到该 feature 的 worktree(试过:${candidates[*]})"
844
+
845
+ # ── R20 守卫:软链 node_modules 的 worktree **不得直接清理** ────────────────
846
+ # 为什么拦:这种 worktree 借用了别的 checkout 的依赖,pnpm 早已可能**把对方的链
847
+ # 改写指向本 worktree**(2026-10-10 实证 99/100 条)——直接 remove 会把唯一的证据
848
+ # (这条软链)连同现场一起删掉,事后无法判断主仓是否已被改写。
849
+ # 所以:先让 check 判定主仓健康度,再手动删链、再清理。
850
+ if [ -L "$target/node_modules" ]; then
851
+ warn "⛔ 拒绝清理:该 worktree 的 node_modules 是软链(违反 R20):"
852
+ warn " $target/node_modules → $(readlink "$target/node_modules")"
853
+ warn " ⚠️ 曾软链共享 ⇒ pnpm 很可能已把**对方 checkout** 的 node_modules 改写/重装"
854
+ warn " → 先体检:bash '$SELF' check '$repo' (0 违规才算安全)"
855
+ warn " → 再清理:rm -f '$target/node_modules' && bash '$SELF' clean '$repo' '$feature'"
856
+ return 1
857
+ fi
858
+
859
+ local dirty; dirty="$(git -C "$target" status --porcelain 2>/dev/null)"
860
+ if [ -n "$dirty" ]; then
861
+ # T4(设计 E11):拒绝行为**原本已有**,真实增量是**列出文件名 + 给抢救命令** ——
862
+ # 原来只说"有未提交改动",用户不知道丢的是什么、也不知道怎么救。
863
+ warn "worktree 有未提交改动,拒绝清理:$target"
864
+ warn " ⚠️ 这些改动是**唯一工作副本**(删掉即永久丢失):"
865
+ printf '%s\n' "$dirty" | sed 's/^/ /' >&2
866
+ warn " → 抢救:cd \"$target\" && git add -A && git commit -m '…'"
867
+ warn " → 或确认弃用后强删:git worktree remove --force \"$target\""
868
+ return 1
869
+ fi
870
+
871
+ # ── 分支名:**反查 worktree 的真实 HEAD**,不靠猜前缀(2026-10-19 修,P0 级静默缺陷)──
872
+ # ⚠️ 原实现写死 `br="feat/${feature}"` —— 而 `create` 接受**任意分支名**
873
+ # (本会话实证:`review/head-gate-and-scope`、`review/cred-from-env`、`fix/wt-clean-branch`)。
874
+ # 名字对不上时 `rev-parse --verify` 失败 ⇒ **整个删分支分支被跳过**,且**不告警**,
875
+ # 最后照样打印「✅ 清理完成」+ 退出码 0 ⇒ **谎报**(R25 要求的「worktree + 分支」只做了一半)。
876
+ # ⇒ 从 worktree 自身取分支名(对称:创建时叫什么,清理时就删什么)。
877
+ local br; br="$(git -C "$target" branch --show-current 2>/dev/null)"
878
+ if [ -z "$br" ]; then
879
+ # detached HEAD(无分支)→ 无可删;但必须**说清楚**,不静默
880
+ warn "worktree 处于 detached HEAD(无关联分支)→ 跳过删分支"
881
+ br=""
882
+ fi
883
+
884
+ say "清理:$target${br:+(分支 ${br})}"
885
+ git -C "$repo" worktree remove "$target" || die "worktree remove 失败"
886
+
887
+ if [ -n "$br" ]; then
888
+ if ! git -C "$repo" rev-parse --verify --quiet "refs/heads/$br" >/dev/null; then
889
+ say "分支 $br 已不存在(可能已删)"
890
+ elif git -C "$repo" branch -d "$br" >/dev/null 2>&1; then
891
+ say "✅ 已删分支 ${br}(已合并)"
892
+ else
893
+ # ⚠️ **未合并 ⇒ 保留分支 + 非 0 退出**(2026-10-19):
894
+ # 以前这里是 `warn` 后照样返回 0 ⇒ 调用方(agent)看到「✅ 清理完成」就以为收工了。
895
+ # R25 的清理是「worktree + 分支」两件,只完成一半**不算成功**。
896
+ warn "⚠️ 分支 $br **未完全合并**,已保留(worktree 已删)"
897
+ warn " 如需强删:git branch -D $br"
898
+ warn " 若要保留(等 MR 合并后自动可删)→ 本次清理**未完成**,退出码 1"
899
+ return 1
900
+ fi
901
+ fi
902
+
903
+ # ── 收尾自检:worktree 必须真的没了(防「说完成但其实还在」)───────────────
904
+ if [ -d "$target" ]; then
905
+ warn "⚠️ worktree 目录仍存在:${target}(清理未生效)"
906
+ return 1
907
+ fi
908
+
909
+ # ── 远端分支提醒(R25 要求「本地 + 远端」都清)──────────────────────────────
910
+ # ⚠️ **不自动删**:`push origin --delete` 是破坏性且影响他人(若该分支已推给同事/正在评审)。
911
+ # 但**必须说出来** —— 否则又是「只做一半 + 说完成」(本函数刚修掉的那类缺陷)。
912
+ if [ -n "$br" ] && git -C "$repo" rev-parse --verify --quiet "refs/remotes/origin/$br" >/dev/null; then
913
+ warn "⚠️ 远端分支仍在:origin/$br"
914
+ warn " 如需清理:git -C '$repo' push origin --delete $br && git -C '$repo' fetch --prune"
915
+ fi
916
+
917
+ say "✅ 清理完成(worktree 已删${br:+,本地分支 $br 已删})"
918
+ }
919
+
920
+ # ═══════════════════════════════════════════════════════════════════════════
921
+ # route —— 【开工前必查】这次改动该走 MR 还是直推 main?(R1.7)
922
+ #
923
+ # 为什么需要(2026-10-12 复发教训):R20 管「别污染 main checkout」(隔离);R1.7 管「要不要评审」——
924
+ # **两件事独立**。agent 做对了 A(隔离),却错误推导出 B(要 MR):在内部仓走 MR 全套打扰同事;
925
+ # 随后写规范优化时**又直接污染 main 主 checkout**。⇒ 做成脚本,把「靠记性」升级为「运行一次」。
926
+ #
927
+ # 为什么改成**配置驱动**(2026-10-16 用户指正):
928
+ # 原先把仓名硬编码在本脚本里(`yan-agent|personal-agent` 内部仓、`example-repo|*-qa|…` 业务仓)⇒
929
+ # ① 「哪些仓是我的/业务仓」是**环境事实**,换个人就全错;② 它含「**我是谁**」成分 ——
930
+ # `fed/yan-agent` 对本人是自持(直推)、对同事是别人的仓(该 MR)⇒ **同一仓在不同人机器上结论不同**;
931
+ # ③ 每加一个仓都要改共享配方库 ⇒ 漂移源。⇒ **规则留在本脚本,数据只落本地**。
932
+ #
933
+ # 判定顺序(**每步都记录来源**、输出可审计;先否决后放行):
934
+ # ① 本地配置 `mr` 名单命中 → 走 MR(显式否决,优先级最高)
935
+ # ② 本地配置 `direct` 名单命中 → 直推
936
+ # ③ remote 命名空间 ∈ `my_namespaces` → 直推("这是我的仓" = 可推导事实,不靠猜仓名)
937
+ # ④ 其余 / 信息不足 → **走 MR**(安全侧兜底)
938
+ #
939
+ # 配置读取(**数据根为准**,可与个人仓基线合并;三个名单键取**并集**:本地只能增、不能删):
940
+ # $YAN_ROUTE_CONFIG 显式指定(最高优先,一般不用)
941
+ # $YAN_DATA_ROOT/route.json 本机主入口 —— **可以是软链**,指向个人仓里的版本化文件
942
+ # $PERSONAL_AGENT_DIR/config/yan-route.json 个人仓基线(跨设备可版本化,默认 ~/repos/leyan/personal-agent)
943
+ # 格式:{ "my_namespaces": ["example-user"], "direct": ["yan-agent"], "mr": [] }
944
+ # ⚠️ `my_namespaces` 缺失时**绝不瞎猜**(不拿 git author 的 email 前缀自动当命名空间):
945
+ # 只在输出里给「推测 + 如何确认」,由人核对(glab auth status)后写进配置。
946
+ #
947
+ # 用法:dia wt route <repo-path>
948
+ # 退出码:0 = 直推;1 = 走 MR(含默认兜底);2 = 信息不足(按 MR 处理,需补配置/remote)
949
+ # ═══════════════════════════════════════════════════════════════════════════
950
+
951
+ # ── remote 命名空间:`<host>/<ns>/<repo>` 里的 `<ns>`(无 remote → 空)────────
952
+ route_remote_ns() {
953
+ local repo="$1" url=""
954
+ url="$(git -C "$repo" config --get remote.origin.url 2>/dev/null || true)"
955
+ [ -n "$url" ] || return 0
956
+ url="${url#ssh://}"; url="${url#https://}"; url="${url#http://}"; url="${url#git://}"
957
+ url="${url#*@}" # 去 user@
958
+ case "$url" in *:*) url="${url#*:}" ;; esac # scp 形式:git@host:ns/repo.git
959
+ case "$url" in */*/*) url="${url#*/}" ;; esac # https 形式:host/ns/repo.git
960
+ url="${url%.git}"
961
+ printf '%s' "${url%%/*}"
962
+ }
963
+
964
+ # ── 合并配置(个人仓基线 → 数据根主入口;名单并集)→ JSON ─────────────────────
965
+ # 用法:route_config_json <data-root> (data root 由调用方用 `yan_data_root()` 解析后传入,
966
+ # 别在 python 里读 env —— 脚本内部是**算**出来的,环境变量往往不存在 ⇒ 静默读不到配置)
967
+ route_config_json() {
968
+ node - "$1" <<'JS'
969
+ const fs = require('node:fs');
970
+ const os = require('node:os');
971
+ const path = require('node:path');
972
+ const dr = process.argv[2] || '';
973
+ const explicit = process.env.DIA_WT_ROUTE_CONFIG || process.env.YAN_ROUTE_CONFIG;
974
+ const pa = process.env.DIA_WT_PERSONAL_DIR || process.env.PERSONAL_AGENT_DIR || path.join(os.homedir(), 'repos/leyan/personal-agent');
975
+ const cands = explicit ? [explicit] : [path.join(pa, 'config', 'yan-route.json'), path.join(dr, 'route.json')];
976
+ const merged = { my_namespaces: [], direct: [], mr: [] };
977
+ const srcs = [], errs = [];
978
+ for (const p of cands) {
979
+ let raw;
980
+ try { raw = fs.readFileSync(p, 'utf8'); }
981
+ catch (e) { if (e.code !== 'ENOENT') errs.push(`${p}: ${e.message}`); continue; }
982
+ let d;
983
+ try { d = JSON.parse(raw); } catch (e) { errs.push(`${p}: JSON 解析失败(${e.message})`); continue; }
984
+ srcs.push(p);
985
+ if (!d || typeof d !== 'object' || Array.isArray(d)) continue;
986
+ for (const k of Object.keys(merged)) {
987
+ const v = d[k];
988
+ const vals = Array.isArray(v) ? v.map(String) : (typeof v === 'string' && v ? [v] : []);
989
+ for (const x of vals) if (!merged[k].includes(x)) merged[k].push(x);
990
+ }
991
+ }
992
+ console.log(JSON.stringify({ config: merged, sources: srcs, errors: errs }));
993
+ JS
994
+ }
995
+
996
+ cmd_route() {
997
+ local repo="${1:-}"
998
+ [ -n "$repo" ] || die "用法:dia wt route <repo-path>"
999
+
1000
+ # ── 取仓名(关键:worktree 里 show-toplevel 给的是 worktree 路径,不是仓名)──
1001
+ # 用 --git-common-dir 反查主仓 → 那才是仓的身份。
1002
+ local name="" is_git=0
1003
+ if [ -e "$repo" ]; then
1004
+ local common
1005
+ common="$(cd "$repo" 2>/dev/null && git rev-parse --git-common-dir 2>/dev/null)"
1006
+ if [ -n "$common" ]; then
1007
+ is_git=1
1008
+ if [ "${common#/}" = "$common" ]; then
1009
+ common="$(cd "$repo" 2>/dev/null && cd "$(dirname "$common")" 2>/dev/null && pwd)/$(basename "$common")"
1010
+ fi
1011
+ name="$(basename "$(dirname "$common")")"
1012
+ else
1013
+ name="$(basename "$(cd "$repo" 2>/dev/null && pwd)")"
1014
+ fi
1015
+ else
1016
+ name="$(basename "$repo")"
1017
+ fi
1018
+ case "$name" in *-worktree--*) name="${name%%-worktree--*}"; repo="$(dirname "$repo")/$name" ;; esac
1019
+
1020
+ # ── 配置驱动判定(**不再硬编码仓名**;判定来源会被打印出来,可审计)──
1021
+ local cfg ns=""
1022
+ cfg="$(route_config_json "$(dia_wt_data_root 2>/dev/null || true)")"
1023
+ [ "$is_git" = 1 ] && ns="$(route_remote_ns "$repo")"
1024
+
1025
+ # 取三个名单(**必须显式判空**:`grep -qx ""` 会匹配空行 ⇒ 空列表被误判成命中)
1026
+ local _cfg_ns _cfg_direct _cfg_mr
1027
+ _cfg_ns="$(printf '%s' "$cfg" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).config.my_namespaces.join("\n"))}catch{}})' )"
1028
+ _cfg_direct="$(printf '%s' "$cfg" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).config.direct.join("\n"))}catch{}})' )"
1029
+ _cfg_mr="$(printf '%s' "$cfg" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).config.mr.join("\n"))}catch{}})' )"
1030
+ cfg_has() { local list="$1" v="$2"; [ -n "$list" ] && printf '%s\n' "$list" | grep -qx -- "$v"; }
1031
+
1032
+ local verdict="" why=""
1033
+ if cfg_has "$_cfg_mr" "$name"; then
1034
+ verdict=mr; why="配置 mr 名单显式否决"
1035
+ elif cfg_has "$_cfg_direct" "$name"; then
1036
+ verdict=direct; why="配置 direct 名单命中"
1037
+ elif [ -n "$ns" ] && cfg_has "$_cfg_ns" "$ns"; then
1038
+ verdict=direct; why="remote 命名空间「${ns}」∈ my_namespaces(这是我的仓)"
1039
+ elif [ "$is_git" = 0 ]; then
1040
+ verdict=unknown; why="不是 git 仓:无法从命名空间推导"
1041
+ elif [ -z "$ns" ]; then
1042
+ verdict=unknown; why="无 origin remote:无法从命名空间推导"
1043
+ else
1044
+ verdict=mr; why="命名空间「${ns}」不在 my_namespaces(安全侧默认 MR)"
1045
+ fi
1046
+
1047
+ echo "── 仓库路由判定(R1.7 · 配置驱动,不再硬编码仓名)──"
1048
+ echo " 仓库 : $name${ns:+(命名空间 ${ns})}"
1049
+ printf ' 判定来源 : %s\n' "$(printf '%s' "$cfg" | node -e 'let s="";process.stdin.on("data",d=>{s+=d}).on("end",()=>{const d=JSON.parse(s);const srcs=(d.sources&&d.sources.length)?d.sources:["(无配置文件)"];console.log(srcs.join("、")+(d.errors.length?" ⚠️ "+d.errors.join(";"):""))})')"
1050
+
1051
+ case "$verdict" in
1052
+ direct)
1053
+ cat <<EOS
1054
+ 结论 : ✅ **直推 main**(不建 MR)|依据:$why
1055
+ 正确姿势:
1056
+ git worktree add <平级路径> -b <branch> origin/main
1057
+ # ... 开发 + 自测 ...
1058
+ git push origin HEAD:main # 快进推 main,不建 MR
1059
+ ⚠️ 「worktree 隔离」≠「要评审」——两者独立:
1060
+ · worktree = 别污染 main checkout(R20)
1061
+ · 免 MR = 我自持的仓、自己主导,review 为辅(R1.7)
1062
+ ⚠️ yan-agent main 拒绝 force push(pre-receive hook)→ 先 rebase 成快进
1063
+ EOS
1064
+ exit 0 ;;
1065
+ mr)
1066
+ cat <<EOS
1067
+ 结论 : ✅ **走 MR**(+ LGTM + @bot mergeme)|依据:$why
1068
+ 正确姿势:
1069
+ git worktree add <平级路径> -b <分支前缀>/<feat> origin/main
1070
+ # ... 开发 + 自测 + rebase(落后必须为 0)...
1071
+ glab mr create ... && glab mr update <iid> --squash-before-merge
1072
+ # 等 LGTM → @bot mergeme
1073
+ 提示 : example-repo 的 docs/** 纯文档改动,用户说「直推」时可直接特权合并(R1.7 常态授权)
1074
+ EOS
1075
+ exit 1 ;;
1076
+ *)
1077
+ cat <<EOS
1078
+ 结论 : ⚠️ **信息不足,按 MR 处理**(安全侧)|依据:$why
1079
+ 补齐判定(任选):
1080
+ 1) 让仓有 origin remote ⇒ 命名空间可推导;
1081
+ 2) 写配置(数据根为主入口,可与个人仓基线合并;名单取并集):
1082
+ \$YAN_DATA_ROOT/route.json ← 本机;**可以是软链**(指向个人仓版本化文件)
1083
+ \$PERSONAL_AGENT_DIR/config/yan-route.json ← 个人仓基线(跨设备)
1084
+ { "my_namespaces": ["<你的 gitlab 账号>"], "direct": ["<我自持的仓名>"], "mr": [] }
1085
+ EOS
1086
+ # 未配置命名空间时给「推测 + 如何确认」,但**绝不自动采用**(不瞎猜"我是谁")
1087
+ if [ -n "$ns" ] && [ -z "$_cfg_ns" ]; then
1088
+ local guess; guess="$(git -C "$repo" config --get user.email 2>/dev/null | sed 's/@.*//' || true)"
1089
+ printf ' 💡 推测(**未采用**):origin 命名空间 =「%s」;%s\n' "$ns" "${guess:+你的 git user.email 前缀 =「${guess}」}"
1090
+ printf ' 两者一致且确属你本人(用 glab auth status 核对)⇒ 写进配置 my_namespaces 后即可自动判定\n'
1091
+ fi
1092
+ exit 2 ;;
1093
+ esac
1094
+ }
1095
+
1096
+ case "${1:-}" in
1097
+ create) shift; cmd_create "$@" ;;
1098
+ route) shift; cmd_route "$@" ;;
1099
+ scope) shift; cmd_scope "$@" ;;
1100
+ form) shift; cmd_form "$@" ;;
1101
+ list) shift; cmd_list "$@" ;;
1102
+ status) shift; cmd_status "$@" ;;
1103
+ check) shift; cmd_check "$@" ;;
1104
+ clean) shift; cmd_clean "$@" ;;
1105
+ prune-merged) shift; cmd_prune_merged "$@" ;;
1106
+ ""|help|-h|--help)
1107
+ cat <<'EOF'
1108
+ dia wt —— worktree 目录形式选型 + 创建(唯一实现)
1109
+
1110
+ 用法:
1111
+ dia wt create <repo-path> <feature> [--base <ref>] [--dry-run] [--install]
1112
+ dia wt form <repo-path> # 只看选型结论
1113
+ dia wt list [repo-path]
1114
+ dia wt status [repo-path] # ★ 清理前必查:唯一工作副本 + 改动重叠(只读)
1115
+ dia wt check [<repo>...] [--all] [--deep] [--quiet]
1116
+ # ★ 依赖体检(R20):软链/逃逸/悬空;--all 扫全部仓
1117
+ dia wt route <repo-path> # ★ 开工前必查:走 MR 还是直推(R1.7,配置驱动)
1118
+ dia wt scope <repo|worktree> [--feature <slug>] [--session <id>] # ★ 产出作用域声明(设计 §4.5)
1119
+ dia wt scope --show # 看与当前目录匹配的作用域声明
1120
+ dia wt scope --list # 列出全部作用域文件(按 worktree 分文件)
1121
+ dia wt clean <repo-path> <feature> # R25 清理(要求工作区干净 + 非软链 node_modules)
1122
+ # 删 worktree + **其 HEAD 指向的分支**(任意前缀);
1123
+ # 分支未合并/未删净 → 退出码 1(不谎报完成)
1124
+ dia wt prune-merged [<repo>] [--apply] [--base <ref>] [--protect a,b] [--quiet]
1125
+ # ★ R25 定期闸口:扫出「已并入 main 的本地分支」
1126
+ # 默认 dry-run(只列);--apply 才删(用 -d,非 -D)
1127
+ # 跳过当前分支 / 被 worktree 占用 / 保护名单
1128
+
1129
+ 选型(默认平级,白名单才隐藏):
1130
+ 平级 <parent>/<repo>-worktree--<feature>
1131
+ 隐藏 <repo>/.worktrees/<feature> ← 仅当:无 node_modules + 无扫描工具 + gitignore 已备
1132
+ 拿不准 → 平级(兜底)
1133
+
1134
+ 依赖(R20):
1135
+ worktree 内**一律真装**(create --install 或 cd 后 pnpm install --frozen-lockfile);
1136
+ **禁止**把别的 checkout 的 node_modules 软链过来 —— pnpm 会按 realpath 改写对方,
1137
+ 删 worktree 后对方整片悬空。开工前/收工前跑 `check` 判定(0 违规才算干净)。
1138
+ 判据 = 禁「顶层 node_modules 是软链 / 跨 checkout 共享」(pnpm isolated / link: / dsh profile /
1139
+ GVS 的 <storeDir>/links 等**内部软链**都是合法的,check 已白名单)。
1140
+ ⛔ pnpm 官方共享方案(virtualStoreType: global)**试点结论 = 暂停启用**(2026-10-16,example-repo 实测:
1141
+ 安装 2.9s / 1.9M 很香,但打破 TS 类型单例 ⇒ nx typecheck 40 错)→ 详见脚本头部与本脚本 docs 指针。
1142
+ EOF
1143
+ ;;
1144
+ # 未知子命令 → **退出码 2**(与文件头第 26 行声明一致;2026-10-13 修:此前误用 die ⇒ 返回 1)。
1145
+ # ⚠️ 与「参数/前置不满足」的 `die`(1) **刻意区分** —— 调用方据码可分辨「我参数写错了」
1146
+ # (1,可重试/改参数) 与「这脚本压根没这个子命令」(2,多为版本不匹配/拼错)。
1147
+ *) printf '\033[1;31m[dia-wt]\033[0m 未知子命令:%s(用 --help)\n' "$1" >&2; exit 2 ;;
1148
+ esac