@jspg-ai/coding-bb 0.0.3-beta.21 → 0.0.3-beta.22
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/dev-standards/rules/cbb-ai-behavior.md +21 -14
- package/cbb/lib/install/init.js +29 -1
- package/cbb/lib/utils/githook.js +224 -0
- package/cbb/lib/utils/gitignore.js +5 -1
- package/cbb/worktrees/_shared/scripts/check-local-state.js +114 -0
- package/cbb/worktrees/_shared/scripts/find-target-worktree.js +148 -96
- package/cbb/worktrees/_shared/scripts/install-git-hook.js +70 -0
- package/cbb/worktrees/_shared/scripts/junction-ai-config.js +127 -0
- package/cbb/worktrees/_shared/scripts/start-local.js +180 -0
- package/cbb/worktrees/skills/cbb-local-mode/SKILL.md +158 -0
- package/cbb/worktrees/skills/cbb-local-mode/scripts/check-local-state.js +114 -0
- package/cbb/worktrees/skills/cbb-local-mode/scripts/install-git-hook.js +70 -0
- package/cbb/worktrees/skills/cbb-local-mode/scripts/silence-popup.js +23 -0
- package/cbb/worktrees/skills/cbb-local-mode/scripts/start-local.js +180 -0
- package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +9 -6
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/check-unarchived.js +75 -48
- package/cbb/worktrees/skills/cbb-worktree-close/scripts/find-target-worktree.js +148 -96
- package/cbb/worktrees/skills/cbb-worktree-init/SKILL.md +85 -91
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/create-branches.js +31 -6
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/create-worktrees.js +23 -3
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/install-git-hook.js +70 -0
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/junction-ai-config.js +127 -0
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/sync-repos.js +5 -1
- package/cbb/worktrees/skills/cbb-worktree-init/scripts/update-gitignore.js +17 -5
- package/cbb/worktrees/skills/cbb-worktree-push/SKILL.md +5 -5
- package/cbb/worktrees/skills/cbb-worktree-push/scripts/commit-worktrees.js +48 -12
- package/cbb/worktrees/skills/cbb-worktree-push/scripts/find-target-worktree.js +148 -96
- package/cbb/worktrees/skills/cbb-worktree-push/scripts/push-worktrees.js +45 -20
- package/config/workspace-agents.sample.md +50 -45
- package/package.json +2 -2
|
@@ -1,96 +1,148 @@
|
|
|
1
|
-
require('./silence-popup');
|
|
2
|
-
/**
|
|
3
|
-
* openspec-shared 目标需求名识别
|
|
4
|
-
*
|
|
5
|
-
* 唯一真源:cbb/worktrees/_shared/scripts/find-target-worktree.js
|
|
6
|
-
* 由 scripts/sync-shared.js 展开到各 skill 的 scripts/ 下(extend / close / push)。
|
|
7
|
-
*
|
|
8
|
-
* 用法:node find-target-worktree.js <ws_root>
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* 1. cwd 在 .worktrees/worktree-xxx/ 内 → 自动从路径反推
|
|
12
|
-
* 2.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
console.
|
|
68
|
-
console.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
1
|
+
require('./silence-popup');
|
|
2
|
+
/**
|
|
3
|
+
* openspec-shared 目标需求名识别
|
|
4
|
+
*
|
|
5
|
+
* 唯一真源:cbb/worktrees/_shared/scripts/find-target-worktree.js
|
|
6
|
+
* 由 scripts/sync-shared.js 展开到各 skill 的 scripts/ 下(extend / close / push)。
|
|
7
|
+
*
|
|
8
|
+
* 用法:node find-target-worktree.js <ws_root>
|
|
9
|
+
*
|
|
10
|
+
* 优先级(ADR 0002 双布局):
|
|
11
|
+
* 1. cwd 在 .worktrees/worktree-xxx/ 内 → 自动从路径反推
|
|
12
|
+
* 2. 收集候选:worktree 目录(.worktrees/worktree-xxx)
|
|
13
|
+
* + 本地布局(根 / .codespace/<app>/ 检出在非主干分支,本地模式进行中)
|
|
14
|
+
* 同一需求名两种布局共存时合并为一项(标 both)
|
|
15
|
+
* 3. 唯一候选 → 自动选用;多个 → 按 mtime 倒序列出(本地布局用分支检出时间)
|
|
16
|
+
*
|
|
17
|
+
* 输出:
|
|
18
|
+
* 末行输出需求名(用于 AI agent 解析)
|
|
19
|
+
* 多个时按 [1] xxx ⭐ / [2] yyy 列出
|
|
20
|
+
*
|
|
21
|
+
* 退出码:
|
|
22
|
+
* 0 - 唯一识别(自动或唯一)
|
|
23
|
+
* 1 - 多个候选,需用户输入
|
|
24
|
+
* 2 - 无候选(无 worktree 且无本地布局检出)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const { execSync } = require('child_process');
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const wsRoot = process.argv[2];
|
|
32
|
+
|
|
33
|
+
if (!wsRoot) {
|
|
34
|
+
console.error('❌ 用法:node find-target-worktree.js <ws_root>');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function run(cmd, cwd) {
|
|
39
|
+
try {
|
|
40
|
+
return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
41
|
+
} catch (e) { return null; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function currentBranch(cwd) {
|
|
45
|
+
return run('git branch --show-current', cwd) || '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 主干名(探测失败返回 null → 该仓库任何非空检出都视为候选) */
|
|
49
|
+
function detectMain(cwd) {
|
|
50
|
+
const remotes = run('git remote', cwd);
|
|
51
|
+
if (remotes) {
|
|
52
|
+
const r = remotes.split(/\r?\n/)[0];
|
|
53
|
+
const sym = run('git symbolic-ref "refs/remotes/' + r + '/HEAD"', cwd);
|
|
54
|
+
if (sym) return sym.replace('refs/remotes/' + r + '/', '');
|
|
55
|
+
}
|
|
56
|
+
if (run('git rev-parse --verify --quiet refs/heads/main', cwd)) return 'main';
|
|
57
|
+
if (run('git rev-parse --verify --quiet refs/heads/master', cwd)) return 'master';
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 优先级 1:cwd 反推
|
|
62
|
+
const cwd = process.cwd();
|
|
63
|
+
const cwdMatch = cwd.match(/\.worktrees[\\\/]worktree-([^\\\/]+)/);
|
|
64
|
+
if (cwdMatch) {
|
|
65
|
+
const branch = cwdMatch[1];
|
|
66
|
+
console.log('✅ 从 cwd 反推需求名:' + branch);
|
|
67
|
+
console.log('---');
|
|
68
|
+
console.log(branch);
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 优先级 2a:worktree 目录候选
|
|
73
|
+
const candidates = new Map(); // name → { layouts: Set, mtime }
|
|
74
|
+
|
|
75
|
+
const worktreesDir = path.join(wsRoot, '.worktrees');
|
|
76
|
+
if (fs.existsSync(worktreesDir)) {
|
|
77
|
+
const entries = fs.readdirSync(worktreesDir, { withFileTypes: true })
|
|
78
|
+
.filter(e => e.isDirectory() && e.name.startsWith('worktree-'));
|
|
79
|
+
for (const e of entries) {
|
|
80
|
+
const name = e.name.substring('worktree-'.length);
|
|
81
|
+
const full = path.join(worktreesDir, e.name);
|
|
82
|
+
const item = candidates.get(name) || { layouts: new Set(), mtime: 0 };
|
|
83
|
+
item.layouts.add('worktree');
|
|
84
|
+
item.mtime = Math.max(item.mtime, fs.statSync(full).mtime.getTime());
|
|
85
|
+
candidates.set(name, item);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 优先级 2b:本地布局候选(根 + .codespace/<app>/ 检出在非主干分支)
|
|
90
|
+
function addLocalCandidate(name, cwdPath) {
|
|
91
|
+
if (!name) return;
|
|
92
|
+
const item = candidates.get(name) || { layouts: new Set(), mtime: 0 };
|
|
93
|
+
item.layouts.add('local');
|
|
94
|
+
try {
|
|
95
|
+
item.mtime = Math.max(item.mtime, fs.statSync(path.join(cwdPath, '.git')).mtime.getTime());
|
|
96
|
+
} catch (_) { /* .git 状态时间不可读则保持 */ }
|
|
97
|
+
candidates.set(name, item);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rootMain = detectMain(wsRoot);
|
|
101
|
+
const rootBranch = currentBranch(wsRoot);
|
|
102
|
+
if (rootBranch && (!rootMain || rootBranch !== rootMain)) {
|
|
103
|
+
addLocalCandidate(rootBranch, wsRoot);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const codeDir = path.join(wsRoot, '.codespace');
|
|
107
|
+
if (fs.existsSync(codeDir)) {
|
|
108
|
+
for (const app of fs.readdirSync(codeDir).sort()) {
|
|
109
|
+
const appDir = path.join(codeDir, app);
|
|
110
|
+
if (!fs.existsSync(path.join(appDir, '.git'))) continue;
|
|
111
|
+
const appMain = detectMain(appDir);
|
|
112
|
+
const appBranch = currentBranch(appDir);
|
|
113
|
+
if (appBranch && (!appMain || appBranch !== appMain)) {
|
|
114
|
+
addLocalCandidate(appBranch, appDir);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (candidates.size === 0) {
|
|
120
|
+
console.error('❌ 未找到任何进行中的需求(无 worktree,且无仓库检出在非主干分支)');
|
|
121
|
+
console.error(' worktree 模式请先执行 cbb-worktree-init;本地模式请先执行 cbb-local-mode');
|
|
122
|
+
console.log('---');
|
|
123
|
+
console.log('NO_WORKTREE');
|
|
124
|
+
process.exit(2);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 唯一候选 → 自动选用
|
|
128
|
+
if (candidates.size === 1) {
|
|
129
|
+
const [name] = [...candidates.keys()];
|
|
130
|
+
const layouts = [...candidates.get(name).layouts].join('+');
|
|
131
|
+
console.log('✅ 自动选用唯一进行中需求:' + name + '(' + layouts + ')');
|
|
132
|
+
console.log('---');
|
|
133
|
+
console.log(name);
|
|
134
|
+
process.exit(0);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 多候选 → 按 mtime 倒序列出
|
|
138
|
+
const sorted = [...candidates.entries()].sort((a, b) => b[1].mtime - a[1].mtime);
|
|
139
|
+
console.log('⚠️ 检测到 ' + sorted.length + ' 个进行中的需求(按最近活动排序):');
|
|
140
|
+
sorted.forEach(([name, item], i) => {
|
|
141
|
+
const layouts = [...item.layouts].join('+');
|
|
142
|
+
const tag = i === 0 ? ' ⭐ (推荐:最近活动)' : '';
|
|
143
|
+
console.log(' [' + (i + 1) + '] ' + name + '(' + layouts + ')' + tag);
|
|
144
|
+
});
|
|
145
|
+
console.log('请在对话中输入序号(1/2/3)或完整分支名:');
|
|
146
|
+
console.log('---');
|
|
147
|
+
console.log('MULTIPLE_WORKTREEES');
|
|
148
|
+
process.exit(1);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 向指定 git 仓库安装分支感知 pre-commit hook(ADR 0002)。
|
|
4
|
+
*
|
|
5
|
+
* 真源:cbb/lib/utils/githook.js 的 PRE_COMMIT_SOURCE(本文件为部署到业务空间的自包含副本,
|
|
6
|
+
* 改动必须两处同步——本仓库内改真源后同步本文件,勿只改其一)。
|
|
7
|
+
*
|
|
8
|
+
* 用法(二选一):
|
|
9
|
+
* CLI:node install-git-hook.js <repoPath...> —— 对每个仓库安装,输出 HOOK_RESULT
|
|
10
|
+
* require:installTo(repoPath) → 'installed' | 'updated' | 'skipped-not-git'
|
|
11
|
+
*
|
|
12
|
+
* 供 cbb-worktree-init 的 sync-repos.js 兜底 clone 后调用(cbb setup 已装过,此处幂等补齐)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const HOOK_MARKER = 'managed-by-cbb';
|
|
19
|
+
const HOOK_FILE = 'pre-commit';
|
|
20
|
+
const BACKUP_SUFFIX = '.cbb-backup';
|
|
21
|
+
|
|
22
|
+
// 与 cbb/lib/utils/githook.js 的 buildPreCommitSource() 输出完全一致(构建时注入)
|
|
23
|
+
const PRE_COMMIT_SOURCE = "#!/usr/bin/env node\n// managed-by-cbb v1 —— 分支感知提交防线(ADR 0002)\n// main 上:workspace 仓库只放行控制面白名单,应用仓库全拦;非 main 一律放行。\n// 绕过逃生口仅限用户显式 git commit --no-verify。\nconst { execSync } = require('child_process');\nconst fs = require('fs');\nfunction run(cmd) {\n try { return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }\n catch (e) { return null; }\n}\nconst branch = run('git branch --show-current') || '';\n// 主干名:优先远端 HEAD 指向;探测失败按本地 main/master 存在性回退\nlet main = null;\nconst remotes = run('git remote');\nif (remotes) {\n const r = remotes.split(/\\r?\\n/)[0];\n const sym = run('git symbolic-ref \"refs/remotes/' + r + '/HEAD\"');\n if (sym) main = sym.replace('refs/remotes/' + r + '/', '');\n}\nif (!main) {\n if (run('git rev-parse --verify --quiet refs/heads/main')) main = 'main';\n else if (run('git rev-parse --verify --quiet refs/heads/master')) main = 'master';\n}\n// 非主干(含 detached / 探测失败)→ 放行\nif (!main || branch !== main) process.exit(0);\n\n// 主干上的提交:收集 staged 变更(空提交放行)\nconst staged = run('git diff --cached --name-only') || '';\nconst files = staged.split(/\\r?\\n/).filter(Boolean);\nif (files.length === 0) process.exit(0);\n\n// 仓库类型:workspace 仓库 = 检出含 workspace-config.json\nconst isWorkspace = fs.existsSync('workspace-config.json');\n\nfunction deny(bad) {\n console.error('[cbb-guard] 主干上拦截载荷提交(ADR 0002):');\n bad.forEach(f => console.error(' ' + f));\n console.error('需求载荷须在需求分支上提交:本地模式先 git checkout -b <需求名>;worktree 模式在 .worktrees/ 工作树内提交。');\n console.error('控制面白名单:workspace-config.json / .gitignore / AGENTS.md / openspec/config.yaml / openspec/schemas/** / .cbb/**');\n console.error('如确需在主干提交,须由用户显式 git commit --no-verify。');\n process.exit(1);\n}\n\nif (isWorkspace) {\n const EXACT = new Set(['workspace-config.json', '.gitignore', 'AGENTS.md', 'openspec/config.yaml']);\n const PREFIXES = ['openspec/schemas/', '.cbb/'];\n const bad = files.filter(f => !EXACT.has(f) && !PREFIXES.some(p => f.indexOf(p) === 0));\n if (bad.length) deny(bad);\n} else {\n deny(files);\n}\nprocess.exit(0);\n";
|
|
24
|
+
|
|
25
|
+
function isCbbHookContent(content) {
|
|
26
|
+
return typeof content === 'string' && content.includes(HOOK_MARKER);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveHooksDir(repoPath) {
|
|
30
|
+
const gitDir = path.join(repoPath, '.git');
|
|
31
|
+
if (!fs.existsSync(gitDir)) return null;
|
|
32
|
+
const st = fs.statSync(gitDir);
|
|
33
|
+
if (!st.isDirectory()) return null; // worktree 检出(.git 为文件):hook 由主仓库承担
|
|
34
|
+
return path.join(gitDir, 'hooks');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function installTo(repoPath) {
|
|
38
|
+
const dir = resolveHooksDir(repoPath);
|
|
39
|
+
if (!dir) return 'skipped-not-git';
|
|
40
|
+
|
|
41
|
+
const hookPath = path.join(dir, HOOK_FILE);
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
|
|
44
|
+
if (fs.existsSync(hookPath)) {
|
|
45
|
+
const prev = fs.readFileSync(hookPath, 'utf-8');
|
|
46
|
+
if (isCbbHookContent(prev)) {
|
|
47
|
+
if (prev !== PRE_COMMIT_SOURCE) fs.writeFileSync(hookPath, PRE_COMMIT_SOURCE);
|
|
48
|
+
return 'updated';
|
|
49
|
+
}
|
|
50
|
+
fs.writeFileSync(hookPath + BACKUP_SUFFIX, prev); // 用户自有 hook:备份后接管
|
|
51
|
+
}
|
|
52
|
+
fs.writeFileSync(hookPath, PRE_COMMIT_SOURCE);
|
|
53
|
+
try { fs.chmodSync(hookPath, 0o755); } catch (_) { /* Windows 无压力 */ }
|
|
54
|
+
return 'installed';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { installTo, isCbbHookContent, PRE_COMMIT_SOURCE };
|
|
58
|
+
|
|
59
|
+
// CLI 形式:node install-git-hook.js <repoPath...>
|
|
60
|
+
if (require.main === module) {
|
|
61
|
+
const targets = process.argv.slice(2);
|
|
62
|
+
if (targets.length === 0) {
|
|
63
|
+
console.error('Usage: node install-git-hook.js <repoPath...>');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
const results = targets.map(p => ({ path: p, status: installTo(p) }));
|
|
67
|
+
results.forEach(r => console.log((r.status === 'skipped-not-git' ? '⚠️ ' : '✅ ') + r.path + ' → ' + r.status));
|
|
68
|
+
console.log('HOOK_RESULT', JSON.stringify(results));
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* worktree 模式 AI 配置 junction(ADR 0002,防线 B)。
|
|
4
|
+
*
|
|
5
|
+
* 用法:node junction-ai-config.js <ws_root> <wt_path>
|
|
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
|
+
* 退出码:0 成功(含全部 skip);1 参数错误;2 执行失败。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
const wsRoot = process.argv[2];
|
|
28
|
+
const wtPath = process.argv[3];
|
|
29
|
+
|
|
30
|
+
if (!wsRoot || !wtPath) {
|
|
31
|
+
console.error('Usage: node junction-ai-config.js <ws_root> <wt_path>');
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 各工具的项目级配置根(存在才处理)与本包部署的内容子目录
|
|
36
|
+
const CONFIG_ROOTS = ['.claude', '.qoder', '.opencode', '.workbuddy', '.trae'];
|
|
37
|
+
const CONTENT_SUBDIRS = ['rules', 'skills', 'commands'];
|
|
38
|
+
// 文件级内容(复制而非 junction):opencode 的 instructions 登记
|
|
39
|
+
const CONTENT_FILES = { '.opencode': ['opencode.json'] };
|
|
40
|
+
|
|
41
|
+
const linked = [];
|
|
42
|
+
const copied = [];
|
|
43
|
+
const skipped = [];
|
|
44
|
+
const warned = [];
|
|
45
|
+
let hasFailure = false;
|
|
46
|
+
|
|
47
|
+
/** 归一化 junction 目标路径(Windows readlink 返回 \\?\ 前缀) */
|
|
48
|
+
function normLinkTarget(p) {
|
|
49
|
+
return path.resolve(String(p).replace(/^\\\\\?\\/, ''));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function linkDir(target, linkPath, label) {
|
|
53
|
+
if (fs.existsSync(linkPath)) {
|
|
54
|
+
const st = fs.lstatSync(linkPath);
|
|
55
|
+
if (st.isSymbolicLink()) {
|
|
56
|
+
const existing = normLinkTarget(fs.readlinkSync(linkPath));
|
|
57
|
+
if (existing === path.resolve(target)) {
|
|
58
|
+
skipped.push(label);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
fs.unlinkSync(linkPath); // 指向不同 → 重建
|
|
62
|
+
} else if (st.isDirectory()) {
|
|
63
|
+
warned.push(label + '(存在同名物理目录,未覆盖)');
|
|
64
|
+
return;
|
|
65
|
+
} else {
|
|
66
|
+
fs.unlinkSync(linkPath); // 同名文件,罕见,重建
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
70
|
+
try {
|
|
71
|
+
if (process.platform === 'win32') {
|
|
72
|
+
fs.symlinkSync(path.resolve(target), linkPath, 'junction');
|
|
73
|
+
} else {
|
|
74
|
+
fs.symlinkSync(path.resolve(target), linkPath, 'dir');
|
|
75
|
+
}
|
|
76
|
+
linked.push(label);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error('❌ junction 失败 ' + label + ' — ' + e.message);
|
|
79
|
+
hasFailure = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function copyFile(target, dest, label) {
|
|
84
|
+
if (fs.existsSync(dest) && fs.existsSync(target) &&
|
|
85
|
+
fs.readFileSync(dest, 'utf-8') === fs.readFileSync(target, 'utf-8')) {
|
|
86
|
+
skipped.push(label);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
91
|
+
fs.copyFileSync(target, dest);
|
|
92
|
+
copied.push(label);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error('❌ 复制失败 ' + label + ' — ' + e.message);
|
|
95
|
+
hasFailure = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const root of CONFIG_ROOTS) {
|
|
100
|
+
const srcRoot = path.join(wsRoot, root);
|
|
101
|
+
if (!fs.existsSync(srcRoot) || !fs.statSync(srcRoot).isDirectory()) continue;
|
|
102
|
+
|
|
103
|
+
for (const sub of CONTENT_SUBDIRS) {
|
|
104
|
+
const src = path.join(srcRoot, sub);
|
|
105
|
+
if (!fs.existsSync(src) || !fs.statSync(src).isDirectory()) continue;
|
|
106
|
+
linkDir(src, path.join(wtPath, root, sub), root + '/' + sub);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const file of (CONTENT_FILES[root] || [])) {
|
|
110
|
+
const src = path.join(srcRoot, file);
|
|
111
|
+
if (!fs.existsSync(src) || !fs.statSync(src).isFile()) continue;
|
|
112
|
+
copyFile(src, path.join(wtPath, root, file), root + '/' + file);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (linked.length === 0 && copied.length === 0 && skipped.length === 0 && warned.length === 0) {
|
|
117
|
+
console.log('ℹ️ 空间根无 AI 配置目录,跳过(cbb setup 选择工具后再装)');
|
|
118
|
+
} else {
|
|
119
|
+
if (linked.length) console.log('✅ junction:' + linked.join(', '));
|
|
120
|
+
if (copied.length) console.log('✅ 复制:' + copied.join(', '));
|
|
121
|
+
if (skipped.length) console.log('⏭️ 已就绪:' + skipped.join(', '));
|
|
122
|
+
if (warned.length) console.log('⚠️ ' + warned.join(';'));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log('---');
|
|
126
|
+
console.log('JUNCTION_RESULT', JSON.stringify({ linked, copied, skipped, warned, wt: wtPath }));
|
|
127
|
+
process.exit(hasFailure ? 2 : 0);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 本地模式开工(ADR 0002):根 + 指定应用就地切需求分支。
|
|
4
|
+
*
|
|
5
|
+
* 用法:node start-local.js <ws_root> <branch> [--apps app1,app2] [--all-apps]
|
|
6
|
+
*
|
|
7
|
+
* 行为:
|
|
8
|
+
* 1. 前置自检:根与所有 .codespace/<app>/ 若任一检出在非主干分支 → 遗留态,
|
|
9
|
+
* 退出码 2(agent 应升级为用户决策:继续 / 收尾 / 升级 / 丢弃,不在本脚本内处理)
|
|
10
|
+
* 2. 参与仓库 = workspace + 应用子集:
|
|
11
|
+
* - --apps a,b → 指定应用(本地模式推荐:从需求语境提取涉及应用)
|
|
12
|
+
* - --all-apps → workspace-config.json 全量应用
|
|
13
|
+
* - 缺省 → 仅 workspace(后续重跑幂等补齐,与 worktree-init 增补语义一致)
|
|
14
|
+
* 3. 每个仓库:
|
|
15
|
+
* - 当前已在 <branch> → reused
|
|
16
|
+
* - 分支存在(未检出)→ git checkout
|
|
17
|
+
* - 否则 → git checkout -b <branch> <remote>/<main>(基线不取 HEAD,ADR 0002;
|
|
18
|
+
* remote 不可用回退本地 main/master,再回退 HEAD)
|
|
19
|
+
* - dirty 仓库切换失败 → 报错退出(不 stash 兜底,交给用户决策)
|
|
20
|
+
* 4. 参与的应用仓库幂等补装提交防线 hook(cbb setup 已装,此处兜底)
|
|
21
|
+
*
|
|
22
|
+
* 幂等:重跑同一 branch 安全(reused / checkout 跳过)。
|
|
23
|
+
*
|
|
24
|
+
* 退出码:
|
|
25
|
+
* 0 - 全部切换成功
|
|
26
|
+
* 1 - 参数错误
|
|
27
|
+
* 2 - 遗留态 / 部分切换失败
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { execSync } = require('child_process');
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const { installTo: installGitHook } = require('./install-git-hook');
|
|
34
|
+
|
|
35
|
+
const wsRoot = process.argv[2];
|
|
36
|
+
const branch = process.argv[3];
|
|
37
|
+
|
|
38
|
+
if (!wsRoot || !branch) {
|
|
39
|
+
console.error('Usage: node start-local.js <ws_root> <branch> [--apps a,b] [--all-apps]');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function run(cmd, cwd) {
|
|
44
|
+
try {
|
|
45
|
+
return { ok: true, out: execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim() };
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return { ok: false, err: (e.stderr ? e.stderr.toString() : e.message || String(e)).trim() };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 主干名:symbolic-ref 远端 HEAD → 本地 main/master 回退 */
|
|
52
|
+
function detectMain(cwd) {
|
|
53
|
+
const remotes = run('git remote', cwd);
|
|
54
|
+
if (remotes.ok && remotes.out) {
|
|
55
|
+
const r = remotes.out.split(/\r?\n/)[0];
|
|
56
|
+
const sym = run('git symbolic-ref "refs/remotes/' + r + '/HEAD"', cwd);
|
|
57
|
+
if (sym.ok && sym.out) return { remote: r, main: sym.out.replace('refs/remotes/' + r + '/', '') };
|
|
58
|
+
}
|
|
59
|
+
if (run('git rev-parse --verify --quiet refs/heads/main', cwd).ok) return { remote: null, main: 'main' };
|
|
60
|
+
if (run('git rev-parse --verify --quiet refs/heads/master', cwd).ok) return { remote: null, main: 'master' };
|
|
61
|
+
return { remote: null, main: null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function currentBranch(cwd) {
|
|
65
|
+
const r = run('git branch --show-current', cwd);
|
|
66
|
+
return r.ok ? r.out : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── 1. 前置自检:遗留态检测 ───────────────────────────────
|
|
70
|
+
// 「非主干」且「不是本次 branch」才是遗留(别的需求没收尾);
|
|
71
|
+
// 已检出在本次 branch 上 = 幂等补齐场景,放行(reused)。
|
|
72
|
+
|
|
73
|
+
function isOnMain(cwd) {
|
|
74
|
+
const info = detectMain(cwd);
|
|
75
|
+
return info.main ? currentBranch(cwd) === info.main : false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isOnTarget(cwd) {
|
|
79
|
+
return currentBranch(cwd) === branch || isOnMain(cwd);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const leftovers = [];
|
|
83
|
+
if (!isOnTarget(wsRoot)) leftovers.push({ name: 'workspace', branch: currentBranch(wsRoot) });
|
|
84
|
+
const codeDir = path.join(wsRoot, '.codespace');
|
|
85
|
+
if (fs.existsSync(codeDir)) {
|
|
86
|
+
for (const name of fs.readdirSync(codeDir).sort()) {
|
|
87
|
+
const appDir = path.join(codeDir, name);
|
|
88
|
+
if (fs.existsSync(path.join(appDir, '.git')) && !isOnTarget(appDir)) {
|
|
89
|
+
leftovers.push({ name, branch: currentBranch(appDir) });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (leftovers.length > 0) {
|
|
94
|
+
console.error('❌ 遗留态:以下仓库检出在非主干分支,先由用户决策处理(继续/收尾/升级/丢弃):');
|
|
95
|
+
leftovers.forEach(l => console.error(' - ' + l.name + ': ' + l.branch));
|
|
96
|
+
console.log('---');
|
|
97
|
+
console.log('LOCAL_START_FAILED', JSON.stringify({ reason: 'leftover', leftovers }));
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── 2. 参与仓库集合 ───────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
let appNames = [];
|
|
104
|
+
const argApps = process.argv.indexOf('--apps');
|
|
105
|
+
const argAll = process.argv.includes('--all-apps');
|
|
106
|
+
if (argApps >= 0) {
|
|
107
|
+
const raw = process.argv[argApps + 1] || '';
|
|
108
|
+
appNames = raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
109
|
+
} else if (argAll) {
|
|
110
|
+
try {
|
|
111
|
+
const config = JSON.parse(fs.readFileSync(path.join(wsRoot, 'workspace-config.json'), 'utf-8'));
|
|
112
|
+
appNames = (config.apps || []).map(a => a.name);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
console.error('❌ 读取 workspace-config.json 失败:' + e.message);
|
|
115
|
+
process.exit(2);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const targets = [{ name: 'workspace', dir: wsRoot }];
|
|
120
|
+
for (const name of appNames) {
|
|
121
|
+
const appDir = path.join(wsRoot, '.codespace', name);
|
|
122
|
+
if (!fs.existsSync(path.join(appDir, '.git'))) {
|
|
123
|
+
console.error('❌ 应用仓库不存在:.codespace/' + name + '(请检查 workspace-config.json 或先跑 cbb setup)');
|
|
124
|
+
process.exit(2);
|
|
125
|
+
}
|
|
126
|
+
targets.push({ name, dir: appDir });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 3. 逐仓库切换 ─────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
const results = [];
|
|
132
|
+
let hasFailure = false;
|
|
133
|
+
|
|
134
|
+
for (const t of targets) {
|
|
135
|
+
const cur = currentBranch(t.dir);
|
|
136
|
+
if (cur === branch) {
|
|
137
|
+
console.log('⏭️ ' + t.name + ': 已在 ' + branch + '(reused)');
|
|
138
|
+
results.push({ name: t.name, status: 'reused' });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const exists = run('git rev-parse --verify --quiet refs/heads/' + branch, t.dir).ok;
|
|
143
|
+
const info = detectMain(t.dir);
|
|
144
|
+
let cmd;
|
|
145
|
+
let base = 'HEAD';
|
|
146
|
+
if (exists) {
|
|
147
|
+
cmd = 'git checkout "' + branch + '"';
|
|
148
|
+
base = '(existing)';
|
|
149
|
+
} else {
|
|
150
|
+
if (info.remote && info.main) base = info.remote + '/' + info.main;
|
|
151
|
+
else if (info.main) base = info.main;
|
|
152
|
+
cmd = 'git checkout -b "' + branch + '" "' + base + '"';
|
|
153
|
+
}
|
|
154
|
+
const r = run(cmd, t.dir);
|
|
155
|
+
if (r.ok) {
|
|
156
|
+
console.log('✅ ' + t.name + ': ' + (exists ? 'checkout' : 'created from ' + base) + ' → ' + branch);
|
|
157
|
+
results.push({ name: t.name, status: exists ? 'switched' : 'created', base });
|
|
158
|
+
} else {
|
|
159
|
+
console.error('❌ ' + t.name + ': 切换失败 — ' + r.err);
|
|
160
|
+
results.push({ name: t.name, status: 'failed', error: r.err });
|
|
161
|
+
hasFailure = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── 4. 参与应用补装提交防线 hook(幂等)────────────────────
|
|
166
|
+
|
|
167
|
+
const hookResults = [];
|
|
168
|
+
for (const t of targets) {
|
|
169
|
+
if (t.name === 'workspace') continue; // 根仓库 hook 由 cbb setup 安装
|
|
170
|
+
hookResults.push({ name: t.name, status: installGitHook(t.dir) });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log('---');
|
|
174
|
+
console.log('LOCAL_START_RESULT', JSON.stringify({
|
|
175
|
+
branch,
|
|
176
|
+
results,
|
|
177
|
+
hooks: hookResults,
|
|
178
|
+
worktreeHint: '如需升级隔离:重跑 cbb-worktree-init(分支已存在自动复用,worktree 从分支创建,提交随身带走)',
|
|
179
|
+
}));
|
|
180
|
+
process.exit(hasFailure ? 2 : 0);
|