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

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.
@@ -4,7 +4,7 @@ trigger: always_on
4
4
 
5
5
  # 业务空间开发纪律(双模式,用户选择制)
6
6
 
7
- **仅当工作目录是业务空间根(存在 `CBB-WORKSPACE.json` 且 `.git` 为目录)时生效。空间支持两种开发模式,能力对称(都能修改 workspace 内容与应用代码、都强制分支隔离、交付均有 MR 与直合并两条通道,ADR 0007/0008),由用户显式选择,agent 只给推荐不代决。**
7
+ **仅当处于业务空间体系(空间根、`.worktrees/` 工作树、及其中各应用检出)时生效。空间支持两种开发模式,能力对称(都能修改 workspace 内容与应用代码、都强制分支隔离、交付均有 MR 与直合并两条通道,ADR 0007/0008),由用户显式选择,agent 只给推荐不代决。**
8
8
 
9
9
  **模式一 · 本地模式(单线程快改,当前会话就地干):**
10
10
 
@@ -25,12 +25,14 @@ trigger: always_on
25
25
  - **收尾合并门禁(ADR 0007)**:两模式收尾均须每仓库"已合并"(`safety-check` / `merge-branches.js --check` 判定);未合并三选一——提 MR / 直合并 / 确认废弃(远程重命名 `<分支>-deprecated`);交付完成自动删本地+远程分支
26
26
  - **模式误判零代价**:本地模式中途要并行/隔离 → 重跑 `cbb-worktree-init`(分支已存在自动复用,提交随身带走)
27
27
 
28
- **三时刻对齐(agent 必须遵守):**
28
+ **五时刻对齐(agent 必须遵守):**
29
29
 
30
30
  1. **开工前——状态自检**(`cbb-local-mode` 的 `check-local-state.js`):干净态零交互直接开工;**遗留态(别的需求未收尾,任一仓库检出在非主干分支)必须让用户四选一**(继续 / 先收尾 / 升级 worktree / 丢弃),不得自行决定
31
31
  2. **开工时——布局确认**:向用户展示参与仓库与分支名,确认后执行
32
32
  3. **进行中——插单归属**:用户插入非本需求的改动,先确认归属(并入 / 收尾后处理 / 转 worktree),不得静默混入
33
+ 4. **打包前——主干对齐**(任何构建打包动作前):对产物所涉及的仓库(按代码来源判定:哪个仓库的代码被编进产物就同步哪个,与产物存放位置无关;多仓聚合产物则全部同步)先对齐再打包——① `git fetch <远端>`;② 在当前需求分支执行 `git merge <远端>/<主干>`(远端通常为 `origin`,主干名以远端 HEAD 为准,常见 main / master;在 main 检出上打包时改为 `git pull`;工作区未提交修改阻碍合并时,先提交或暂存再对齐);③ 冲突未解决时**禁止打包**,停下报告用户;fetch/merge 失败 → 报告并等待用户指示;④ 对齐完成后(由此产生的 merge 提交属正常)再执行打包命令(`mvn package` / `npm run build` / 前端 bundle / 生成部署包等凡产出构建产物者);产物目录须已被 `.gitignore` 忽略(未忽略先补 ignore——该修改属普通变更,随需求分支提交)。产物基线 = 主干最新,避免基于过时分支打包导致的集成返工;**不 rebase、不 force push**,merge 提交随下次 push 一并推送
34
+ 5. **打包后——产物收集与留存**:打包完成后把各仓库产物**复制**到 `<空间根>/.build/<需求名>/<应用名>/`(原件保持原位;需求名 = 分支名;workspace 仓库产物用 `workspace` 子目录;目标目录已存在时先向用户确认清理或覆盖;`.build/` 由 cbb 写入空间根 `.gitignore`)——产物按需求聚合留存,worktree 关闭后不丢失;打包时顺带报告 `.build/` 积压现状(需求目录数与总体积),建议清理已交付或已废弃需求的产物目录,经用户逐项确认后执行、**永不自动删除**
33
35
 
34
36
  **读基准代码约定:** 本地模式进行中,工作区是开发态而非基准——要读"main 现状"先确认检出分支,或直接 `git show main:<path>` / `git diff main`;不要默认工作区即基准。
35
37
 
36
- **检验标准:** 任何 main 检出上无载荷提交(hook 兜底,git log 可机械审计;merge 提交除外);本地模式收尾后全部仓库回主干且 clean;worktree 模式的变更产物只出现在需求工作树内;需求分支只经 push 技能产生;**未合并且非废弃的分支无法通过收尾(门禁兜底)**。
38
+ **检验标准:** 任何 main 检出上无载荷提交(hook 兜底,git log 可机械审计;merge 提交除外);本地模式收尾后全部仓库回主干且 clean;worktree 模式的**变更(代码改动)**只出现在需求工作树内(打包产物按第 5 条收集到空间根 `.build/`,不在此限);需求分支只经 push 技能产生;**未合并且非废弃的分支无法通过收尾(门禁兜底)**。
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * 安装时:若 .gitignore 已含某目录(认可带/不带尾斜杠两种形式),则不重复添加;
7
7
  * 否则新增无尾斜杠形式(.claude / .qoder,更通用)。.cbb/ / .codespace/ /
8
- * .worktrees/ 这类纯目录路径维持带斜杠。
8
+ * .worktrees/ / .build/ 这类纯目录路径维持带斜杠。
9
9
  * 卸载时:移除本包曾写入的所有形式(新旧版本均覆盖)。
10
10
  *
11
11
  * 注意:.gitignore 条目无 _managedBy 之类的标记,卸载按"形式匹配"移除,
@@ -32,6 +32,8 @@ const GITIGNORE_RULES = [
32
32
  { add: '.trae', accept: ['.trae', '.trae/'] },
33
33
  // .minimax:MiniMax Code 项目级配置根(rules / skills)
34
34
  { add: '.minimax', accept: ['.minimax', '.minimax/'] },
35
+ // .build/:0.0.3-beta.38 新增 —— 打包产物收集目录(空间纪律第 5 条:产物按需求聚合到 .build/<需求名>/)
36
+ { add: '.build/', accept: ['.build/', '.build'] },
35
37
  ];
36
38
 
37
39
  /** 将 .gitignore 文本拆为行数组。 */
@@ -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`**:
@@ -412,6 +413,8 @@ git -C "<ws_root>" worktree list
412
413
 
413
414
  目标 worktree 应已不在列表中。
414
415
 
416
+ **产物留存检查(打包产物 · 不自动删除)**:检查 `<ws_root>/.build/<branch>/` 是否存在(`test -d`),并统计 `.build/` 全景(需求目录数与总体积,Node 或 `du -sh` 等价实现)。**关闭流程绝不自动删除 `.build/` 内容**(打包产物是交付后的取件来源,删错不可逆);用户明确表示不需要时,才按其指定范围清理。
417
+
415
418
  **工作区回根交接(必须执行)**:若用户的开发会话工作在 worktree 内(当前会话工作目录 = 已删除的 `<wt_path>`),此刻目录已悬空——**OpenCode 等支持会话搬移的工具由 agent 自动 `session_move` 切回 `<ws_root>`,不列入用户待办**;无该能力的工具才提示用户把工作区切回 `<ws_root>`(打开文件夹 / Open Folder / 切换工作目录),并在最终报告中给出该绝对路径。
416
419
 
417
420
  ---
@@ -435,6 +438,9 @@ git -C "<ws_root>" worktree list
435
438
  - 交付完成 → 已删除本地 + 远程分支:feature-xxx(逐仓库列出)
436
439
  - 已废弃 → 远程已重命名为 feature-xxx-deprecated(本地分支保留原名)
437
440
  剩余工作空间 worktree:<列出仍存在的,或 "无" >
441
+ 产物留存:.build/<branch>/(已保留;不需要可告诉我清理)
442
+ .build 全景:共 N 个需求目录 / ~X MB(可顺带清理已交付或已废弃需求)
443
+ (产物留支行仅在该目录存在时显示)
438
444
  👉 仅当所用工具不支持自动切根、且你的窗口仍停在已删除的 worktree 时,请手动切回工作空间根:<ws_root 绝对路径>
439
445
  Workspace cleaned up.
440
446
  ```
@@ -461,6 +467,7 @@ git -C "<ws_root>" worktree list
461
467
  - 操作 `.worktrees/worktree-<需求名>` 以外的任何 worktree(如 `~/.qoder/worktree/...` 会话 worktree)
462
468
  - 移除/删除前跳过 Step 2 安全评估
463
469
  - 删除工作空间根目录的 AI 配置目录(`.claude/`、`.qoder/`、`.opencode/` 等)
470
+ - 未经用户确认删除 `.build/` 内的打包产物(留存目录的清理须用户逐项确认;关闭流程绝不自动删)
464
471
  - 用 `AskUserQuestion` 收集自由文本(分支名等直接对话提问)
465
472
  - 手动改任何仓库的 `.gitignore`(关闭流程不碰它)
466
473
  - 运行环境 node 不可用时强行继续 → 改为 fail-fast 提示用户
@@ -480,7 +487,7 @@ git -C "<ws_root>" worktree list
480
487
  - **合并判定依赖刷新后的远程引用,故 Step 2.0 先主动 `git fetch`**:feature 多由他人经 MR 合入远端,本地引用否则会陈旧误判
481
488
  - **并行块用临时文件收结果、不用共享变量**:原 bash 版本中 `&` 后台任务跑在独立子 shell;Node.js 同步执行无此问题
482
489
  - **`.gitignore` 无需清理**:创建时的 `.worktrees/` 条目提交在 feature 分支上,删分支时随分支消失(未合并)或无害保留(已合并)
483
- - **AI 配置在 worktree 内是 junction(链接)**:物理副本只在空间根(单层安装),worktree 删除时链接随目录树消失,无需清理动作
490
+ - **AI 配置在 worktree 内是 junction(链接)**:物理副本只在空间根(单层安装)。**不可依赖「随目录树消失」**——Windows 上 `git worktree remove` 遇 junction 会静默失败、残留整个目录树(实测复现)。关闭流程在物理删除前用 Node 显式拆除(只解除链接,目标在空间根内校验,源文件零接触)
484
491
  - **部分 `worktree remove` 失败**:已移除的无需回退,未移除的报告详情让用户处理后重跑
485
492
  - **Windows IDE 目录锁的三层降级**:`git worktree remove` 内部做两件事——①清元数据 ②删目录。Windows 文件锁只阻塞 ②,不阻塞 ①
486
493
 
@@ -496,7 +503,8 @@ git -C "<ws_root>" worktree list
496
503
  | `discover-apps.js` | 发现关联应用 worktree | ws_root, branch | 0/1/2 |
497
504
  | `check-unarchived.js` | 检查未归档 change(双扫空间根 + worktree) | ws_root, branch | 0/1 |
498
505
  | `safety-check.js` | 安全评估(合并判定 + 未提交 + 远程) | ws_root, branch | 0/1 |
499
- | `remove-worktrees.js` | 移除 worktree(三层降级) | ws_root, branch, force | 0/1 |
506
+ | `remove-worktrees.js` | 移除 worktree(先拆 junction,三层降级 + 残留验证) | ws_root, branch, force | 0/1 |
507
+ | `junction-ai-config.js` | AI 配置 junction 创建 / `--remove` 拆除(共享脚本,源仓库真源在 `_shared/scripts/`) | ws_root, wt_path, [--remove] | 0/1/2 |
500
508
  | `delete-branches.js` | 删除分支(按 mode;交付完成后由 Step 4.4 自动以 local_remote 调用) | ws_root, branch, mode | 0/1/2 |
501
509
  | `merge-branches.js` | 收尾合并门禁·直合并(预检 → merge --no-ff → push 主干,共享脚本,真源在 `_shared/scripts/`) | ws_root, branch, --apps / --target / --allow-checkout / --dry-run / --check | 0/1/2 |
502
510
  | `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.38",
4
4
  "description": "整合业界热门且高价值的工具、框架与技能,为 AI CODING AGENT 提供统一的行为准则与工作流,辅助开发者将需求高效落地为符合规范的代码",
5
5
  "main": "cbb/lib/install/init.js",
6
6
  "bin": {