@jspg-ai/coding-bb 0.0.3-beta.36 → 0.0.3-beta.37

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.
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * worktree 模式 AI 配置 junction(ADR 0002,防线 B)。
4
4
  *
5
- * 用法:node junction-ai-config.js <ws_root> <wt_path>
5
+ * 用法:node junction-ai-config.js <ws_root> <wt_path> [--remove]
6
6
  *
7
7
  * 目的:让开发会话能开进 worktree(工作树内加载 AI 配置),同时保持
8
8
  * 「单层安装」实质——配置唯一物理副本在空间根,cbb update 一处生效。
@@ -18,6 +18,13 @@
18
18
  * 幂等:linkPath 已指向同一 target → skip;指向不同 → 重建;
19
19
  * 存在同名**物理目录**(非链接)→ warn 跳过(不覆盖用户内容)。
20
20
  *
21
+ * --remove(cbb-worktree-close 调用):
22
+ * - 拆除 worktree 内已建 junction 与复制件,**源文件零接触**:
23
+ * 只解除链接本身(fs.rmSync 对 reparse point 仅删链接、不穿透目标,
24
+ * 已由 Windows 实测验证),复制件为普通文件直接删除;
25
+ * - 拆除前校验链接目标位于空间根内——目标在空间根外 / readlink 失败
26
+ * → 跳过并警告(防误删外部目录);同名物理目录/文件 → 跳过不碰。
27
+ *
21
28
  * 退出码:0 成功(含全部 skip);1 参数错误;2 执行失败。
22
29
  */
23
30
 
@@ -26,9 +33,10 @@ const path = require('path');
26
33
 
27
34
  const wsRoot = process.argv[2];
28
35
  const wtPath = process.argv[3];
36
+ const removeMode = process.argv[4] === '--remove';
29
37
 
30
38
  if (!wsRoot || !wtPath) {
31
- console.error('Usage: node junction-ai-config.js <ws_root> <wt_path>');
39
+ console.error('Usage: node junction-ai-config.js <ws_root> <wt_path> [--remove]');
32
40
  process.exit(1);
33
41
  }
34
42
 
@@ -49,6 +57,63 @@ function normLinkTarget(p) {
49
57
  return path.resolve(String(p).replace(/^\\\\\?\\/, ''));
50
58
  }
51
59
 
60
+ /** 目标是否位于空间根内(防误删空间根外目录的守卫) */
61
+ function isWithin(p, root) {
62
+ const r = path.resolve(root) + path.sep;
63
+ return path.resolve(p).startsWith(r);
64
+ }
65
+
66
+ // ── remove 模式:显式拆除 junction 与复制件(源文件零接触)──
67
+ if (removeMode) {
68
+ const removed = [];
69
+ const guarded = [];
70
+
71
+ for (const root of CONFIG_ROOTS) {
72
+ for (const sub of CONTENT_SUBDIRS) {
73
+ const linkPath = path.join(wtPath, root, sub);
74
+ const label = root + '/' + sub;
75
+ let st;
76
+ try { st = fs.lstatSync(linkPath); } catch (e) { continue; } // 不存在 → 跳过(dangling 链接 lstat 仍成功,可拆)
77
+ if (!st.isSymbolicLink()) continue; // 物理目录/文件(工具状态空壳或用户内容)→ 不碰
78
+
79
+ const target = normLinkTarget(fs.readlinkSync(linkPath));
80
+ if (!isWithin(target, wsRoot)) {
81
+ guarded.push(label + ' → ' + target + '(目标不在空间根内,跳过)');
82
+ continue;
83
+ }
84
+
85
+ try {
86
+ // 只解除链接:Node 对 reparse point 仅删除链接本身,不递归、不触达目标内容
87
+ fs.rmSync(linkPath, { recursive: true, force: true });
88
+ removed.push(label);
89
+ } catch (e) {
90
+ console.error('❌ 拆除失败 ' + label + ' — ' + e.message);
91
+ hasFailure = true;
92
+ }
93
+ }
94
+
95
+ for (const file of (CONTENT_FILES[root] || [])) {
96
+ const linkPath = path.join(wtPath, root, file);
97
+ if (!fs.existsSync(linkPath)) continue;
98
+ try {
99
+ fs.unlinkSync(linkPath); // 复制件为普通文件,删除不影响源
100
+ removed.push(root + '/' + file);
101
+ } catch (e) {
102
+ console.error('❌ 删除失败 ' + root + '/' + file + ' — ' + e.message);
103
+ hasFailure = true;
104
+ }
105
+ }
106
+ }
107
+
108
+ if (removed.length) console.log('✅ 已拆除 junction/复制件:' + removed.join(', '));
109
+ if (guarded.length) console.log('⚠️ ' + guarded.join(';'));
110
+ if (removed.length === 0 && guarded.length === 0) console.log('ℹ️ 无待拆除项(幂等)');
111
+
112
+ console.log('---');
113
+ console.log('JUNCTION_REMOVE_RESULT', JSON.stringify({ removed, guarded, wt: wtPath }));
114
+ process.exit(hasFailure ? 2 : 0);
115
+ }
116
+
52
117
  function linkDir(target, linkPath, label) {
53
118
  if (fs.existsSync(linkPath)) {
54
119
  const st = fs.lstatSync(linkPath);
@@ -124,4 +189,4 @@ if (linked.length === 0 && copied.length === 0 && skipped.length === 0 && warned
124
189
 
125
190
  console.log('---');
126
191
  console.log('JUNCTION_RESULT', JSON.stringify({ linked, copied, skipped, warned, wt: wtPath }));
127
- process.exit(hasFailure ? 2 : 0);
192
+ process.exit(hasFailure ? 2 : 0);
@@ -105,7 +105,7 @@ node "<skill_dir>/scripts/discover-apps.js "<ws_root>" "<branch>"
105
105
  - `workspace` 字段:workspace worktree 绝对路径(AI agent 记录供后续命令使用)
106
106
  - `apps` 数组:实际存在的应用 worktree 列表(每项含 `name`, `repo`, `side`, `wt_path`, `app_path`)
107
107
 
108
- > 说明:app worktree 物理嵌套在 workspace worktree 的 `.codespace/` 容器内,移除 workspace worktree 时嵌套的子目录会一并消失,但各 app 仓库的 git worktree 元数据(`.git/worktrees/`)仍需通过 `git worktree remove` 逐个清理。AI 配置经 junction 链入 worktree(只读内容子目录指向空间根唯一物理副本),随 worktree 目录一并消失,无需特殊处理。
108
+ > 说明:app worktree 物理嵌套在 workspace worktree 的 `.codespace/` 容器内,移除 workspace worktree 时嵌套的子目录会一并消失,但各 app 仓库的 git worktree 元数据(`.git/worktrees/`)仍需通过 `git worktree remove` 逐个清理。AI 配置经 junction 链入 worktree(只读内容子目录指向空间根唯一物理副本),关闭时由 remove-worktrees.js 在物理删除前**显式拆除**(只解除链接、源文件零接触),见 4.2 与附录 `junction-ai-config.js`。
109
109
 
110
110
  ### 1.3 检查未归档 change
111
111
 
@@ -329,10 +329,11 @@ node "<skill_dir>/scripts/deprecate-branches.js" "<ws_root>" "<branch>" --apps "
329
329
  node "<skill_dir>/scripts/remove-worktrees.js "<ws_root>" "<branch>" "<force>"
330
330
  ```
331
331
 
332
- **三层降级策略**:`git worktree remove` 实际包含两个动作——①清理 git 元数据(`.git/worktrees/`)②删除物理目录。当 IDE 占用目录时 ② 会被 Windows 文件锁阻塞,但 ① 永远不受影响。脚本将这两个动作解耦:
332
+ **移除流程**:`git worktree remove` 实际包含两个动作——①清理 git 元数据(`.git/worktrees/`)②删除物理目录。当 IDE 占用目录时 ② 会被 Windows 文件锁阻塞,但 ① 永远不受影响。脚本将这两个动作解耦,并在物理删除前**先显式拆除 AI 配置 junction**(`junction-ai-config.js --remove`:只解除链接、目标在空间根内校验、源文件零接触):
333
333
 
334
- - **第 1 层(正常路径):** `git worktree remove` 一步完成元数据 + 物理目录清理
335
- - **第 2 层(解耦清理):** 当目录被锁时,先 `prune` 清理 git 元数据(始终成功),再 `rmdir`/`rm -rf` 尝试物理删除
334
+ - **第 1 层(正常路径):** junction 拆除 → `git worktree remove` 一步完成元数据 + 物理目录清理,并**验证物理目录已真消失**
335
+ - **第 1.5 层(git 静默失败兜底):** 实测 Windows 上 `git worktree remove` 遇 junction 时可能**返回成功但物理目录残留**(本次修复前 junction 遗留的根因)——检测目录仍存在即自动走第 2 层
336
+ - **第 2 层(解耦清理):** 先 `prune` 清理 git 元数据(始终成功),再 `fs.rmSync`(Node 原生,对 reparse point 只删链接不穿透)尝试物理删除
336
337
  - **第 3 层(延迟清理):** Windows 下 IDE 持有排他句柄时触发。元数据已清理保证分支删除不受影响;物理目录**不会被 init 自动删除**(0.0.3-beta.11 起 init 只 prune 元数据、绝不删目录,防误删活跃 worktree),需用户关闭 IDE 后手动删除,或重跑 close 补删
337
338
 
338
339
  **解读末行 `REMOVE_RESULT`**:
@@ -480,7 +481,7 @@ git -C "<ws_root>" worktree list
480
481
  - **合并判定依赖刷新后的远程引用,故 Step 2.0 先主动 `git fetch`**:feature 多由他人经 MR 合入远端,本地引用否则会陈旧误判
481
482
  - **并行块用临时文件收结果、不用共享变量**:原 bash 版本中 `&` 后台任务跑在独立子 shell;Node.js 同步执行无此问题
482
483
  - **`.gitignore` 无需清理**:创建时的 `.worktrees/` 条目提交在 feature 分支上,删分支时随分支消失(未合并)或无害保留(已合并)
483
- - **AI 配置在 worktree 内是 junction(链接)**:物理副本只在空间根(单层安装),worktree 删除时链接随目录树消失,无需清理动作
484
+ - **AI 配置在 worktree 内是 junction(链接)**:物理副本只在空间根(单层安装)。**不可依赖「随目录树消失」**——Windows 上 `git worktree remove` 遇 junction 会静默失败、残留整个目录树(实测复现)。关闭流程在物理删除前用 Node 显式拆除(只解除链接,目标在空间根内校验,源文件零接触)
484
485
  - **部分 `worktree remove` 失败**:已移除的无需回退,未移除的报告详情让用户处理后重跑
485
486
  - **Windows IDE 目录锁的三层降级**:`git worktree remove` 内部做两件事——①清元数据 ②删目录。Windows 文件锁只阻塞 ②,不阻塞 ①
486
487
 
@@ -496,7 +497,8 @@ git -C "<ws_root>" worktree list
496
497
  | `discover-apps.js` | 发现关联应用 worktree | ws_root, branch | 0/1/2 |
497
498
  | `check-unarchived.js` | 检查未归档 change(双扫空间根 + worktree) | ws_root, branch | 0/1 |
498
499
  | `safety-check.js` | 安全评估(合并判定 + 未提交 + 远程) | ws_root, branch | 0/1 |
499
- | `remove-worktrees.js` | 移除 worktree(三层降级) | ws_root, branch, force | 0/1 |
500
+ | `remove-worktrees.js` | 移除 worktree(先拆 junction,三层降级 + 残留验证) | ws_root, branch, force | 0/1 |
501
+ | `junction-ai-config.js` | AI 配置 junction 创建 / `--remove` 拆除(共享脚本,源仓库真源在 `_shared/scripts/`) | ws_root, wt_path, [--remove] | 0/1/2 |
500
502
  | `delete-branches.js` | 删除分支(按 mode;交付完成后由 Step 4.4 自动以 local_remote 调用) | ws_root, branch, mode | 0/1/2 |
501
503
  | `merge-branches.js` | 收尾合并门禁·直合并(预检 → merge --no-ff → push 主干,共享脚本,真源在 `_shared/scripts/`) | ws_root, branch, --apps / --target / --allow-checkout / --dry-run / --check | 0/1/2 |
502
504
  | `deprecate-branches.js` | 收尾合并门禁·废弃(远程分支重命名为 `<分支>-deprecated`,共享脚本) | ws_root, branch, --apps | 0/1/2 |
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * worktree 模式 AI 配置 junction(ADR 0002,防线 B)。
4
+ *
5
+ * 用法:node junction-ai-config.js <ws_root> <wt_path> [--remove]
6
+ *
7
+ * 目的:让开发会话能开进 worktree(工作树内加载 AI 配置),同时保持
8
+ * 「单层安装」实质——配置唯一物理副本在空间根,cbb update 一处生效。
9
+ *
10
+ * 策略(规避 ADR 0001 否决时的状态污染):
11
+ * - 只 junction **只读内容子目录**(rules / skills / commands):
12
+ * 工具向配置根写状态(如 settings.local.json)落在 worktree 自建的
13
+ * 物理(空壳)配置目录里,按项目隔离,不互相覆盖;
14
+ * - 配置根目录本身在 worktree 侧是物理目录(不是链接);
15
+ * - .opencode/opencode.json 为文件(junction 不支持文件):直接复制
16
+ * (cbb update 后需重跑本脚本同步;worktree 为瞬态,影响可控)。
17
+ *
18
+ * 幂等:linkPath 已指向同一 target → skip;指向不同 → 重建;
19
+ * 存在同名**物理目录**(非链接)→ warn 跳过(不覆盖用户内容)。
20
+ *
21
+ * --remove(cbb-worktree-close 调用):
22
+ * - 拆除 worktree 内已建 junction 与复制件,**源文件零接触**:
23
+ * 只解除链接本身(fs.rmSync 对 reparse point 仅删链接、不穿透目标,
24
+ * 已由 Windows 实测验证),复制件为普通文件直接删除;
25
+ * - 拆除前校验链接目标位于空间根内——目标在空间根外 / readlink 失败
26
+ * → 跳过并警告(防误删外部目录);同名物理目录/文件 → 跳过不碰。
27
+ *
28
+ * 退出码:0 成功(含全部 skip);1 参数错误;2 执行失败。
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+
34
+ const wsRoot = process.argv[2];
35
+ const wtPath = process.argv[3];
36
+ const removeMode = process.argv[4] === '--remove';
37
+
38
+ if (!wsRoot || !wtPath) {
39
+ console.error('Usage: node junction-ai-config.js <ws_root> <wt_path> [--remove]');
40
+ process.exit(1);
41
+ }
42
+
43
+ // 各工具的项目级配置根(存在才处理)与本包部署的内容子目录
44
+ const CONFIG_ROOTS = ['.claude', '.qoder', '.opencode', '.workbuddy', '.trae', '.minimax'];
45
+ const CONTENT_SUBDIRS = ['rules', 'skills', 'commands'];
46
+ // 文件级内容(复制而非 junction):opencode 的 instructions 登记
47
+ const CONTENT_FILES = { '.opencode': ['opencode.json'] };
48
+
49
+ const linked = [];
50
+ const copied = [];
51
+ const skipped = [];
52
+ const warned = [];
53
+ let hasFailure = false;
54
+
55
+ /** 归一化 junction 目标路径(Windows readlink 返回 \\?\ 前缀) */
56
+ function normLinkTarget(p) {
57
+ return path.resolve(String(p).replace(/^\\\\\?\\/, ''));
58
+ }
59
+
60
+ /** 目标是否位于空间根内(防误删空间根外目录的守卫) */
61
+ function isWithin(p, root) {
62
+ const r = path.resolve(root) + path.sep;
63
+ return path.resolve(p).startsWith(r);
64
+ }
65
+
66
+ // ── remove 模式:显式拆除 junction 与复制件(源文件零接触)──
67
+ if (removeMode) {
68
+ const removed = [];
69
+ const guarded = [];
70
+
71
+ for (const root of CONFIG_ROOTS) {
72
+ for (const sub of CONTENT_SUBDIRS) {
73
+ const linkPath = path.join(wtPath, root, sub);
74
+ const label = root + '/' + sub;
75
+ let st;
76
+ try { st = fs.lstatSync(linkPath); } catch (e) { continue; } // 不存在 → 跳过(dangling 链接 lstat 仍成功,可拆)
77
+ if (!st.isSymbolicLink()) continue; // 物理目录/文件(工具状态空壳或用户内容)→ 不碰
78
+
79
+ const target = normLinkTarget(fs.readlinkSync(linkPath));
80
+ if (!isWithin(target, wsRoot)) {
81
+ guarded.push(label + ' → ' + target + '(目标不在空间根内,跳过)');
82
+ continue;
83
+ }
84
+
85
+ try {
86
+ // 只解除链接:Node 对 reparse point 仅删除链接本身,不递归、不触达目标内容
87
+ fs.rmSync(linkPath, { recursive: true, force: true });
88
+ removed.push(label);
89
+ } catch (e) {
90
+ console.error('❌ 拆除失败 ' + label + ' — ' + e.message);
91
+ hasFailure = true;
92
+ }
93
+ }
94
+
95
+ for (const file of (CONTENT_FILES[root] || [])) {
96
+ const linkPath = path.join(wtPath, root, file);
97
+ if (!fs.existsSync(linkPath)) continue;
98
+ try {
99
+ fs.unlinkSync(linkPath); // 复制件为普通文件,删除不影响源
100
+ removed.push(root + '/' + file);
101
+ } catch (e) {
102
+ console.error('❌ 删除失败 ' + root + '/' + file + ' — ' + e.message);
103
+ hasFailure = true;
104
+ }
105
+ }
106
+ }
107
+
108
+ if (removed.length) console.log('✅ 已拆除 junction/复制件:' + removed.join(', '));
109
+ if (guarded.length) console.log('⚠️ ' + guarded.join(';'));
110
+ if (removed.length === 0 && guarded.length === 0) console.log('ℹ️ 无待拆除项(幂等)');
111
+
112
+ console.log('---');
113
+ console.log('JUNCTION_REMOVE_RESULT', JSON.stringify({ removed, guarded, wt: wtPath }));
114
+ process.exit(hasFailure ? 2 : 0);
115
+ }
116
+
117
+ function linkDir(target, linkPath, label) {
118
+ if (fs.existsSync(linkPath)) {
119
+ const st = fs.lstatSync(linkPath);
120
+ if (st.isSymbolicLink()) {
121
+ const existing = normLinkTarget(fs.readlinkSync(linkPath));
122
+ if (existing === path.resolve(target)) {
123
+ skipped.push(label);
124
+ return;
125
+ }
126
+ fs.unlinkSync(linkPath); // 指向不同 → 重建
127
+ } else if (st.isDirectory()) {
128
+ warned.push(label + '(存在同名物理目录,未覆盖)');
129
+ return;
130
+ } else {
131
+ fs.unlinkSync(linkPath); // 同名文件,罕见,重建
132
+ }
133
+ }
134
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
135
+ try {
136
+ if (process.platform === 'win32') {
137
+ fs.symlinkSync(path.resolve(target), linkPath, 'junction');
138
+ } else {
139
+ fs.symlinkSync(path.resolve(target), linkPath, 'dir');
140
+ }
141
+ linked.push(label);
142
+ } catch (e) {
143
+ console.error('❌ junction 失败 ' + label + ' — ' + e.message);
144
+ hasFailure = true;
145
+ }
146
+ }
147
+
148
+ function copyFile(target, dest, label) {
149
+ if (fs.existsSync(dest) && fs.existsSync(target) &&
150
+ fs.readFileSync(dest, 'utf-8') === fs.readFileSync(target, 'utf-8')) {
151
+ skipped.push(label);
152
+ return;
153
+ }
154
+ try {
155
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
156
+ fs.copyFileSync(target, dest);
157
+ copied.push(label);
158
+ } catch (e) {
159
+ console.error('❌ 复制失败 ' + label + ' — ' + e.message);
160
+ hasFailure = true;
161
+ }
162
+ }
163
+
164
+ for (const root of CONFIG_ROOTS) {
165
+ const srcRoot = path.join(wsRoot, root);
166
+ if (!fs.existsSync(srcRoot) || !fs.statSync(srcRoot).isDirectory()) continue;
167
+
168
+ for (const sub of CONTENT_SUBDIRS) {
169
+ const src = path.join(srcRoot, sub);
170
+ if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) continue;
171
+ linkDir(src, path.join(wtPath, root, sub), root + '/' + sub);
172
+ }
173
+
174
+ for (const file of (CONTENT_FILES[root] || [])) {
175
+ const src = path.join(srcRoot, file);
176
+ if (!fs.existsSync(src) || !fs.statSync(src).isFile()) continue;
177
+ copyFile(src, path.join(wtPath, root, file), root + '/' + file);
178
+ }
179
+ }
180
+
181
+ if (linked.length === 0 && copied.length === 0 && skipped.length === 0 && warned.length === 0) {
182
+ console.log('ℹ️ 空间根无 AI 配置目录,跳过(cbb setup 选择工具后再装)');
183
+ } else {
184
+ if (linked.length) console.log('✅ junction:' + linked.join(', '));
185
+ if (copied.length) console.log('✅ 复制:' + copied.join(', '));
186
+ if (skipped.length) console.log('⏭️ 已就绪:' + skipped.join(', '));
187
+ if (warned.length) console.log('⚠️ ' + warned.join(';'));
188
+ }
189
+
190
+ console.log('---');
191
+ console.log('JUNCTION_RESULT', JSON.stringify({ linked, copied, skipped, warned, wt: wtPath }));
192
+ process.exit(hasFailure ? 2 : 0);
@@ -7,6 +7,8 @@ require('./silence-popup');
7
7
  * force: "true" 启用 --force,"false" 不启用
8
8
  *
9
9
  * 行为:
10
+ * 0. 物理删除前先显式拆除 AI 配置 junction(源文件零接触,防 Windows
11
+ * 删除例程遗留/穿透——实测 git worktree remove 遇 junction 会静默残留)
10
12
  * 1. 先移除各应用 worktree
11
13
  * 2. 再移除 workspace worktree
12
14
  * 3. 三层降级:git worktree remove → prune+rm → 仅 prune
@@ -32,6 +34,8 @@ if (!wsRoot || !branch || !forceStr) {
32
34
 
33
35
  const force = forceStr === 'true';
34
36
  const wtDir = path.join(wsRoot, '.worktrees', 'worktree-' + branch);
37
+ // AI 配置 junction 拆除脚本(close 自包含副本;源真 _shared/scripts/junction-ai-config.js)
38
+ const junctionScript = path.join(__dirname, 'junction-ai-config.js');
35
39
 
36
40
  function run(cmd, cwd) {
37
41
  try {
@@ -42,11 +46,14 @@ function run(cmd, cwd) {
42
46
  }
43
47
 
44
48
  function removePhysicalDir(dir) {
45
- // Windows 用 rmdir /s /q,Unix 用 rm -rf
46
- if (process.platform === 'win32') {
47
- return run('rmdir /s /q "' + dir + '"');
49
+ // 统一 Node fs.rmSync:跨平台;对 reparse point(junction/symlink)只删链接不穿透目标。
50
+ // 不用 rmdir /s /q / rm -rf——两者对 junction 的行为因实现/版本而异(实测 git 会静默残留目录树)
51
+ try {
52
+ fs.rmSync(dir, { recursive: true, force: true });
53
+ return { ok: true, out: '' };
54
+ } catch (e) {
55
+ return { ok: false, err: e.message };
48
56
  }
49
- return run('rm -rf "' + dir + '"');
50
57
  }
51
58
 
52
59
  const lockedDirs = [];
@@ -58,14 +65,33 @@ function removeWt(repoPath, wtPath, name) {
58
65
  return;
59
66
  }
60
67
 
68
+ // 第 0 层:显式拆除 AI 配置 junction(只解除链接、源文件零接触;
69
+ // 实测 Windows 删除例程遇 junction 会静默失败、残留整个目录树)
70
+ const jr = run('node "' + junctionScript + '" "' + wsRoot + '" "' + wtPath + '" --remove');
71
+ if (jr.ok) {
72
+ const m = (jr.out || '').match(/JUNCTION_REMOVE_RESULT (.+)$/m);
73
+ if (m) {
74
+ try {
75
+ const data = JSON.parse(m[1]);
76
+ if (data.removed && data.removed.length) console.log('🔗 ' + name + ' AI 配置 junction 已拆除:' + data.removed.join(', '));
77
+ } catch (e) {}
78
+ }
79
+ } else {
80
+ console.log('⚠️ ' + name + ' junction 拆除失败(继续移除):' + (jr.err || ''));
81
+ }
82
+
61
83
  const forceFlag = force ? ' --force' : '';
62
84
  // 第 1 层:git worktree remove
63
85
  const r1 = run('git worktree remove' + forceFlag + ' "' + wtPath + '"', repoPath);
64
- if (r1.ok) {
86
+ if (r1.ok && !fs.existsSync(wtPath)) {
65
87
  console.log('✅ ' + name + ' worktree 已移除');
66
88
  results.push({ name, status: 'removed' });
67
89
  return;
68
90
  }
91
+ if (r1.ok && fs.existsSync(wtPath)) {
92
+ // git 报成功但物理目录残留(Windows + junction/系统差异的静默失败)→ 物理兜底
93
+ console.log('⚠️ ' + name + ':git 元数据已移除但物理目录残留,执行物理清理');
94
+ }
69
95
 
70
96
  // 第 2 层:prune + 物理目录清理(解耦)
71
97
  run('git worktree prune', repoPath);
@@ -307,7 +307,7 @@ WT_PATH d:\workspace\xxx\.worktrees\worktree-feature-login
307
307
  node "<skill_dir>/scripts/junction-ai-config.js" "<ws_root>" "<wt_path>"
308
308
  ```
309
309
 
310
- 脚本把根的 AI 配置**内容子目录**(`.claude/`、`.qoder/`、`.opencode/`、`.workbuddy/`、`.trae/`、`.minimax/` 下的 rules / skills / commands)junction 进工作树;`.opencode/opencode.json` 为文件则复制。
310
+ 脚本把根的 AI 配置**内容子目录**(`.claude/`、`.qoder/`、`.opencode/`、`.workbuddy/`、`.trae/`、`.minimax/` 下的 rules / skills / commands)junction 进工作树;`.opencode/opencode.json` 为文件则复制。关闭(`cbb-worktree-close`)时由同一脚本 `--remove` **显式拆除**——只解除链接、源文件零接触(防 Windows 删除例程遗留/穿透)。
311
311
 
312
312
  **解读末行 `JUNCTION_RESULT`**:
313
313
  - 退出码 0 → linked / copied / skipped(幂等)任一组合均成功
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * worktree 模式 AI 配置 junction(ADR 0002,防线 B)。
4
4
  *
5
- * 用法:node junction-ai-config.js <ws_root> <wt_path>
5
+ * 用法:node junction-ai-config.js <ws_root> <wt_path> [--remove]
6
6
  *
7
7
  * 目的:让开发会话能开进 worktree(工作树内加载 AI 配置),同时保持
8
8
  * 「单层安装」实质——配置唯一物理副本在空间根,cbb update 一处生效。
@@ -18,6 +18,13 @@
18
18
  * 幂等:linkPath 已指向同一 target → skip;指向不同 → 重建;
19
19
  * 存在同名**物理目录**(非链接)→ warn 跳过(不覆盖用户内容)。
20
20
  *
21
+ * --remove(cbb-worktree-close 调用):
22
+ * - 拆除 worktree 内已建 junction 与复制件,**源文件零接触**:
23
+ * 只解除链接本身(fs.rmSync 对 reparse point 仅删链接、不穿透目标,
24
+ * 已由 Windows 实测验证),复制件为普通文件直接删除;
25
+ * - 拆除前校验链接目标位于空间根内——目标在空间根外 / readlink 失败
26
+ * → 跳过并警告(防误删外部目录);同名物理目录/文件 → 跳过不碰。
27
+ *
21
28
  * 退出码:0 成功(含全部 skip);1 参数错误;2 执行失败。
22
29
  */
23
30
 
@@ -26,9 +33,10 @@ const path = require('path');
26
33
 
27
34
  const wsRoot = process.argv[2];
28
35
  const wtPath = process.argv[3];
36
+ const removeMode = process.argv[4] === '--remove';
29
37
 
30
38
  if (!wsRoot || !wtPath) {
31
- console.error('Usage: node junction-ai-config.js <ws_root> <wt_path>');
39
+ console.error('Usage: node junction-ai-config.js <ws_root> <wt_path> [--remove]');
32
40
  process.exit(1);
33
41
  }
34
42
 
@@ -49,6 +57,63 @@ function normLinkTarget(p) {
49
57
  return path.resolve(String(p).replace(/^\\\\\?\\/, ''));
50
58
  }
51
59
 
60
+ /** 目标是否位于空间根内(防误删空间根外目录的守卫) */
61
+ function isWithin(p, root) {
62
+ const r = path.resolve(root) + path.sep;
63
+ return path.resolve(p).startsWith(r);
64
+ }
65
+
66
+ // ── remove 模式:显式拆除 junction 与复制件(源文件零接触)──
67
+ if (removeMode) {
68
+ const removed = [];
69
+ const guarded = [];
70
+
71
+ for (const root of CONFIG_ROOTS) {
72
+ for (const sub of CONTENT_SUBDIRS) {
73
+ const linkPath = path.join(wtPath, root, sub);
74
+ const label = root + '/' + sub;
75
+ let st;
76
+ try { st = fs.lstatSync(linkPath); } catch (e) { continue; } // 不存在 → 跳过(dangling 链接 lstat 仍成功,可拆)
77
+ if (!st.isSymbolicLink()) continue; // 物理目录/文件(工具状态空壳或用户内容)→ 不碰
78
+
79
+ const target = normLinkTarget(fs.readlinkSync(linkPath));
80
+ if (!isWithin(target, wsRoot)) {
81
+ guarded.push(label + ' → ' + target + '(目标不在空间根内,跳过)');
82
+ continue;
83
+ }
84
+
85
+ try {
86
+ // 只解除链接:Node 对 reparse point 仅删除链接本身,不递归、不触达目标内容
87
+ fs.rmSync(linkPath, { recursive: true, force: true });
88
+ removed.push(label);
89
+ } catch (e) {
90
+ console.error('❌ 拆除失败 ' + label + ' — ' + e.message);
91
+ hasFailure = true;
92
+ }
93
+ }
94
+
95
+ for (const file of (CONTENT_FILES[root] || [])) {
96
+ const linkPath = path.join(wtPath, root, file);
97
+ if (!fs.existsSync(linkPath)) continue;
98
+ try {
99
+ fs.unlinkSync(linkPath); // 复制件为普通文件,删除不影响源
100
+ removed.push(root + '/' + file);
101
+ } catch (e) {
102
+ console.error('❌ 删除失败 ' + root + '/' + file + ' — ' + e.message);
103
+ hasFailure = true;
104
+ }
105
+ }
106
+ }
107
+
108
+ if (removed.length) console.log('✅ 已拆除 junction/复制件:' + removed.join(', '));
109
+ if (guarded.length) console.log('⚠️ ' + guarded.join(';'));
110
+ if (removed.length === 0 && guarded.length === 0) console.log('ℹ️ 无待拆除项(幂等)');
111
+
112
+ console.log('---');
113
+ console.log('JUNCTION_REMOVE_RESULT', JSON.stringify({ removed, guarded, wt: wtPath }));
114
+ process.exit(hasFailure ? 2 : 0);
115
+ }
116
+
52
117
  function linkDir(target, linkPath, label) {
53
118
  if (fs.existsSync(linkPath)) {
54
119
  const st = fs.lstatSync(linkPath);
@@ -124,4 +189,4 @@ if (linked.length === 0 && copied.length === 0 && skipped.length === 0 && warned
124
189
 
125
190
  console.log('---');
126
191
  console.log('JUNCTION_RESULT', JSON.stringify({ linked, copied, skipped, warned, wt: wtPath }));
127
- process.exit(hasFailure ? 2 : 0);
192
+ process.exit(hasFailure ? 2 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jspg-ai/coding-bb",
3
- "version": "0.0.3-beta.36",
3
+ "version": "0.0.3-beta.37",
4
4
  "description": "整合业界热门且高价值的工具、框架与技能,为 AI CODING AGENT 提供统一的行为准则与工作流,辅助开发者将需求高效落地为符合规范的代码",
5
5
  "main": "cbb/lib/install/init.js",
6
6
  "bin": {