@jspg-ai/coding-bb 0.0.3-beta.35 → 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.
- package/cbb/lib/install/init.js +1 -11
- package/cbb/lib/install/workspaces.js +0 -50
- package/cbb/worktrees/_shared/scripts/junction-ai-config.js +68 -3
- package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +8 -6
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/junction-ai-config.js +192 -0
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/remove-worktrees.js +31 -5
- package/cbb/worktrees/skills/cbb-worktree-init/SKILL.md +1 -1
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/junction-ai-config.js +68 -3
- package/package.json +1 -1
- package/config/workspace-agents.sample.md +0 -10
package/cbb/lib/install/init.js
CHANGED
|
@@ -1213,15 +1213,6 @@ async function init(targetDir, options = {}) {
|
|
|
1213
1213
|
setupOpenSpec(isUpgrade, managedPaths);
|
|
1214
1214
|
newPaths.add('openspec/config.yaml');
|
|
1215
1215
|
|
|
1216
|
-
// 业务空间根专属:空间导航 AGENTS.md(仅缺失时生成,永不覆盖)+ 旧版导航文案提示
|
|
1217
|
-
let agentsMdState = null;
|
|
1218
|
-
if (isWorkspaceRoot) {
|
|
1219
|
-
agentsMdState = workspaces.ensureWorkspaceAgentsMd(TARGET_DIR);
|
|
1220
|
-
if (workspaces.detectLegacyAgentsMd(TARGET_DIR)) {
|
|
1221
|
-
log('空间根 AGENTS.md 仍是旧版导航文案(单层安装改造前模板)。建议删除该文件后重跑 cbb update 重新生成(仅缺失时生成,不会覆盖你的增补内容)', 'warn');
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
1216
|
// AGENTS.md 规则导航块(声明 agentsNav 的适配器;MiniMax 唯一项目级规则注入通道)。
|
|
1226
1217
|
// 选中 → 注入/更新托管块(块外用户内容不动);未选中(本次 update 取消勾选)→ 精确摘除。
|
|
1227
1218
|
let agentsNavState = null;
|
|
@@ -1367,7 +1358,6 @@ async function init(targetDir, options = {}) {
|
|
|
1367
1358
|
if (hasCommands) extras.push('Commands');
|
|
1368
1359
|
if (hasHooks) extras.push('版本检查 hook');
|
|
1369
1360
|
if (hookSummary) extras.push('提交防线 pre-commit hook');
|
|
1370
|
-
if (agentsMdState === 'created') extras.push('AGENTS.md(空间导航)');
|
|
1371
1361
|
if (agentsNavState === 'created' || agentsNavState === 'updated') extras.push('AGENTS.md(规则导航)');
|
|
1372
1362
|
if (extras.length > 0) {
|
|
1373
1363
|
console.log(` ${extras.join(' / ')} 已配置`);
|
|
@@ -1984,7 +1974,7 @@ async function setupUserLevelComponents(targetAdapters = toolSelection.selectabl
|
|
|
1984
1974
|
* cbb update:就地升级当前目录内容 + 用户级组件。
|
|
1985
1975
|
*
|
|
1986
1976
|
* 单层安装:统一安装器按空间根判定(CBB-WORKSPACE.json + .git 目录)补齐
|
|
1987
|
-
* worktree
|
|
1977
|
+
* worktree 管理入口;旧子集清单升级时由清单差集机制自动补齐全量内容。
|
|
1988
1978
|
*/
|
|
1989
1979
|
async function updateInstalled() {
|
|
1990
1980
|
// 用户级组件沿用本目录的工具选择(.last-tools),无记录或为空才回退全量。
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* - 加载 + 校验
|
|
9
9
|
* - 归一化 remote URL(兼容 SSH / HTTPS 两种写法)
|
|
10
10
|
* - 检测当前目录是否已拉取指定 workspace 的仓库
|
|
11
|
-
* - 空间种子文件生成(AGENTS.md 空间导航,仅缺失时)
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
const fs = require('fs');
|
|
@@ -17,7 +16,6 @@ const { execSync } = require('child_process');
|
|
|
17
16
|
|
|
18
17
|
const DEFAULT_CONFIG = 'config/workspaces.json';
|
|
19
18
|
const SAMPLE_CONFIG = 'config/CBB-WORKSPACE.sample.json';
|
|
20
|
-
const SAMPLE_AGENTS_MD = 'config/workspace-agents.sample.md';
|
|
21
19
|
|
|
22
20
|
/**
|
|
23
21
|
* 归一化 git remote URL:提取 "namespace/repo" 形式(去掉 .git 后缀)。
|
|
@@ -168,51 +166,6 @@ function validateWorkspaceConfig(configPath) {
|
|
|
168
166
|
return { valid: true, config: raw };
|
|
169
167
|
}
|
|
170
168
|
|
|
171
|
-
/**
|
|
172
|
-
* 确保业务空间根目录存在 AGENTS.md(空间导航文件,讲清目录职责与协作约定)。
|
|
173
|
-
*
|
|
174
|
-
* 仅缺失时从包内模板复制;已存在(可能含团队自维护内容)一律不覆盖。
|
|
175
|
-
* 该文件不登记安装清单,升级 / 卸载均不触碰。
|
|
176
|
-
*
|
|
177
|
-
* @param {string} targetDir 业务空间根目录
|
|
178
|
-
* @returns {'exists' | 'created'}
|
|
179
|
-
*/
|
|
180
|
-
function ensureWorkspaceAgentsMd(targetDir) {
|
|
181
|
-
const agentsPath = path.join(targetDir, 'AGENTS.md');
|
|
182
|
-
if (fs.existsSync(agentsPath)) return 'exists';
|
|
183
|
-
|
|
184
|
-
const samplePath = path.resolve(__dirname, '..', '..', '..', SAMPLE_AGENTS_MD);
|
|
185
|
-
fs.copyFileSync(samplePath, agentsPath);
|
|
186
|
-
return 'created';
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// 旧版空间导航模板的特征语句(命中即提示用户删除后重跑 cbb update 重新生成;
|
|
190
|
-
// 该文件归用户所有、永不覆盖)。分两代:
|
|
191
|
-
// - 0.0.3 单层安装改造前:前两条
|
|
192
|
-
// - 双模式重构前(≤0.0.3-beta.21,worktree 默认 + 轻量模式):后两条
|
|
193
|
-
const LEGACY_AGENTS_MD_MARKERS = [
|
|
194
|
-
'故意不含编码规范',
|
|
195
|
-
'自动安装沙箱内的编码规范',
|
|
196
|
-
'轻量模式',
|
|
197
|
-
'AI 会话始终停在空间根',
|
|
198
|
-
];
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* 检测空间根 AGENTS.md 是否仍为旧版导航文案(命中任一特征语句即为旧版)。
|
|
202
|
-
* 仅用于安装时打印提示,不修改文件。
|
|
203
|
-
* @returns {boolean}
|
|
204
|
-
*/
|
|
205
|
-
function detectLegacyAgentsMd(targetDir) {
|
|
206
|
-
const agentsPath = path.join(targetDir, 'AGENTS.md');
|
|
207
|
-
if (!fs.existsSync(agentsPath)) return false;
|
|
208
|
-
try {
|
|
209
|
-
const content = fs.readFileSync(agentsPath, 'utf-8');
|
|
210
|
-
return LEGACY_AGENTS_MD_MARKERS.some(m => content.includes(m));
|
|
211
|
-
} catch (_) {
|
|
212
|
-
return false;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
169
|
/**
|
|
217
170
|
* 在指定目录执行 `git remote -v`,解析出 [{ name, url }] 列表。
|
|
218
171
|
* 任何异常静默返回空数组,便于上层用 state 机判定。
|
|
@@ -306,13 +259,10 @@ function findSampleApps(apps) {
|
|
|
306
259
|
module.exports = {
|
|
307
260
|
DEFAULT_CONFIG,
|
|
308
261
|
SAMPLE_CONFIG,
|
|
309
|
-
SAMPLE_AGENTS_MD,
|
|
310
262
|
normalizeRepoUrl,
|
|
311
263
|
matchesConfiguredRepo,
|
|
312
264
|
loadWorkspaces,
|
|
313
265
|
validateWorkspaceConfig,
|
|
314
|
-
ensureWorkspaceAgentsMd,
|
|
315
|
-
detectLegacyAgentsMd,
|
|
316
266
|
readGitRemotes,
|
|
317
267
|
detectRepo,
|
|
318
268
|
extractRepoBasename,
|
|
@@ -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
|
|
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
|
-
|
|
332
|
+
**移除流程**:`git worktree remove` 实际包含两个动作——①清理 git 元数据(`.git/worktrees/`)②删除物理目录。当 IDE 占用目录时 ② 会被 Windows 文件锁阻塞,但 ① 永远不受影响。脚本将这两个动作解耦,并在物理删除前**先显式拆除 AI 配置 junction**(`junction-ai-config.js --remove`:只解除链接、目标在空间根内校验、源文件零接触):
|
|
333
333
|
|
|
334
|
-
- **第 1 层(正常路径):** `git worktree remove` 一步完成元数据 +
|
|
335
|
-
- **第
|
|
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
|
|
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
|
|
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
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
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,10 +0,0 @@
|
|
|
1
|
-
# AGENTS.md
|
|
2
|
-
|
|
3
|
-
本空间由 `@jspg-ai/coding-bb` 初始化(升级用 `cbb update`)。
|
|
4
|
-
|
|
5
|
-
- **行为准则与开发纪律**(双模式、基准 = `origin/main`、提交防线、推送出口):见你所用 AI 工具规则目录(如 `.claude/rules/`、`.qoder/rules/`)下的全部规则文件——各工具自动加载;未适配的工具任选一处目录直接读。
|
|
6
|
-
- **空间事实**:关联应用清单(增减应用即编辑它)见 `CBB-WORKSPACE.json`;目录职责建议维护在你自己的 `README.md`。
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
本文件由 `@jspg-ai/coding-bb` 生成(仅缺失时写入,之后不会覆盖或删除,可自由增补团队约定)。
|