@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.
Files changed (30) hide show
  1. package/cbb/dev-standards/rules/cbb-ai-behavior.md +21 -14
  2. package/cbb/lib/install/init.js +29 -1
  3. package/cbb/lib/utils/githook.js +224 -0
  4. package/cbb/lib/utils/gitignore.js +5 -1
  5. package/cbb/worktrees/_shared/scripts/check-local-state.js +114 -0
  6. package/cbb/worktrees/_shared/scripts/find-target-worktree.js +148 -96
  7. package/cbb/worktrees/_shared/scripts/install-git-hook.js +70 -0
  8. package/cbb/worktrees/_shared/scripts/junction-ai-config.js +127 -0
  9. package/cbb/worktrees/_shared/scripts/start-local.js +180 -0
  10. package/cbb/worktrees/skills/cbb-local-mode/SKILL.md +158 -0
  11. package/cbb/worktrees/skills/cbb-local-mode/scripts/check-local-state.js +114 -0
  12. package/cbb/worktrees/skills/cbb-local-mode/scripts/install-git-hook.js +70 -0
  13. package/cbb/worktrees/skills/cbb-local-mode/scripts/silence-popup.js +23 -0
  14. package/cbb/worktrees/skills/cbb-local-mode/scripts/start-local.js +180 -0
  15. package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +9 -6
  16. package/cbb/worktrees/skills/cbb-worktree-close/scripts/check-unarchived.js +75 -48
  17. package/cbb/worktrees/skills/cbb-worktree-close/scripts/find-target-worktree.js +148 -96
  18. package/cbb/worktrees/skills/cbb-worktree-init/SKILL.md +85 -91
  19. package/cbb/worktrees/skills/cbb-worktree-init/scripts/create-branches.js +31 -6
  20. package/cbb/worktrees/skills/cbb-worktree-init/scripts/create-worktrees.js +23 -3
  21. package/cbb/worktrees/skills/cbb-worktree-init/scripts/install-git-hook.js +70 -0
  22. package/cbb/worktrees/skills/cbb-worktree-init/scripts/junction-ai-config.js +127 -0
  23. package/cbb/worktrees/skills/cbb-worktree-init/scripts/sync-repos.js +5 -1
  24. package/cbb/worktrees/skills/cbb-worktree-init/scripts/update-gitignore.js +17 -5
  25. package/cbb/worktrees/skills/cbb-worktree-push/SKILL.md +5 -5
  26. package/cbb/worktrees/skills/cbb-worktree-push/scripts/commit-worktrees.js +48 -12
  27. package/cbb/worktrees/skills/cbb-worktree-push/scripts/find-target-worktree.js +148 -96
  28. package/cbb/worktrees/skills/cbb-worktree-push/scripts/push-worktrees.js +45 -20
  29. package/config/workspace-agents.sample.md +50 -45
  30. package/package.json +2 -2
@@ -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);
@@ -19,6 +19,7 @@ require('./silence-popup');
19
19
  const { execSync } = require('child_process');
20
20
  const fs = require('fs');
21
21
  const path = require('path');
22
+ const { installTo: installGitHook } = require('./install-git-hook');
22
23
 
23
24
  const wsRoot = process.argv[2];
24
25
 
@@ -77,7 +78,10 @@ for (const app of apps) {
77
78
  // 不存在:clone
78
79
  try {
79
80
  execSync('git clone -q "' + app.repo + '" "' + target + '"', { stdio: 'pipe' });
80
- console.log('✅ ' + app.name + ': 代码已拉取(clone)');
81
+ // 兜底 clone 出的应用同步装分支感知 pre-commit hook(ADR 0002;
82
+ // cbb setup 首次 clone 已装,此处幂等补齐)
83
+ installGitHook(target);
84
+ console.log('✅ ' + app.name + ': 代码已拉取(clone + 提交防线 hook)');
81
85
  results.push({ name: app.name, status: 'cloned' });
82
86
  } catch (e) {
83
87
  console.error('❌ ' + app.name + ': clone 失败 — ' + app.repo);
@@ -7,8 +7,8 @@ require('./silence-popup');
7
7
  *
8
8
  * 行为:
9
9
  * 1. stash 当前未提交修改(仅 untracked 时不产生 stash)
10
- * 2. 目标分支不存在则从 HEAD 创建(首跑时分支尚未由 create-branches.js 建立,
11
- * 顺序无关自愈;create-branches.js 后续会报 reused)
10
+ * 2. 目标分支不存在则从 <remote>/<main> 创建(首跑时分支尚未由 create-branches.js 建立,
11
+ * 顺序无关自愈;create-branches.js 后续会报 reused;基线不取 HEAD,见 ADR 0002)
12
12
  * 3. 切换到目标分支
13
13
  * 4. 检查 .worktrees/ 和 .codespace/ 是否在 .gitignore,缺失则添加并提交
14
14
  * 5. 切回原分支
@@ -81,16 +81,28 @@ if (status.ok && status.out) {
81
81
  console.log('ℹ️ 无未提交修改,跳过 stash');
82
82
  }
83
83
 
84
- // 2.1 目标分支不存在则从 HEAD 创建(Step 3.2 先于 Step 3.3 执行时分支尚未建立)
84
+ // 2.1 目标分支不存在则创建(Step 3.2 先于 Step 3.3 执行时分支尚未建立)
85
+ // 基线取 <remote>/<main>(ADR 0002,与 create-branches.js 一致):本地模式进行中根可能检出在
86
+ // feature 分支,从 HEAD 建会把未合并的开发态带进新分支;无 remote 时回退 HEAD
85
87
  const branchList = run('git branch --list "' + branch + '"', wsRoot);
86
88
  if (branchList.ok && !branchList.out) {
87
- const create = run('git branch "' + branch + '"', wsRoot);
89
+ const remoteOut = run('git remote', wsRoot);
90
+ let base = 'HEAD';
91
+ if (remoteOut.ok && remoteOut.out) {
92
+ const r = remoteOut.out.split(/\r?\n/)[0];
93
+ const symbolic = run('git symbolic-ref "refs/remotes/' + r + '/HEAD"', wsRoot);
94
+ const main = (symbolic.ok && symbolic.out)
95
+ ? symbolic.out.replace('refs/remotes/' + r + '/', '')
96
+ : 'main';
97
+ base = r + '/' + main;
98
+ }
99
+ const create = run('git branch "' + branch + '" "' + base + '"', wsRoot);
88
100
  if (!create.ok) {
89
101
  console.error('❌ 创建分支 ' + branch + ' 失败:' + create.err);
90
102
  if (stashed) run('git stash pop', wsRoot);
91
103
  process.exit(2);
92
104
  }
93
- console.log('✅ 分支 ' + branch + ' 不存在,已从 HEAD 创建');
105
+ console.log('✅ 分支 ' + branch + ' 不存在,已从 ' + base + ' 创建');
94
106
  }
95
107
 
96
108
  // 3. 切换到目标分支
@@ -3,9 +3,9 @@ name: cbb-worktree-push
3
3
  description: "提交并推送当前需求的所有关联仓库(跨 worktree 批量),推送后提案生成 MR(合并请求)预填链接。触发:用户说'提交'/'commit'/'push'/'推送'/'提交并push'/'推到远端'/'提MR'/'提合并请求'/'merge request';用户在 .worktrees/worktree-<需求名>/ 下工作要求批量推送;apply 完成后准备推送所有 worktree;close-worktree 前需要先推送未提交改动。注意:本技能会先识别场景——不在 cbb 工作空间时降级为普通单仓库提交推送。"
4
4
  ---
5
5
 
6
- # Push Worktrees(跨平台脚本化版本 v1.1)
6
+ # Push Worktrees(跨平台脚本化版本 v2.0)
7
7
 
8
- 提交并推送当前需求的所有关联仓库(跨 worktree 批量)。专门解决"开发完成后批量推送"和"用户口语化提交并push"两类场景,弥补 [cbb-worktree-init Step 3.4](file:///d:/workspace/va-ai/coding-bb/cbb/worktrees/skills/cbb-worktree-init/SKILL.md) 仅推送初始空 commit 的缺口。
8
+ 提交并推送当前需求的所有关联仓库(跨仓库批量,ADR 0002 双布局:worktree 布局或本地模式布局自动发现)。专门解决"开发完成后批量推送"和"用户口语化提交并push"两类场景——本技能是**唯一的远端推送出口**(cbb-worktree-init 自 0.0.3-beta.22 起 lazy push,创建流程零远端副作用;首次 push 自动建立 upstream)。
9
9
 
10
10
  > **本技能描述含宽泛触发词("提交"/"push"),允许被误触发**。因此第一步永远是 **Step 0 场景识别**:在 cbb 工作空间内才走跨 worktree 批量流程;在普通 git 仓库降级为单仓库提交推送;非 git 目录则退出。
11
11
 
@@ -91,9 +91,9 @@ node "<skill_dir>/scripts/find-target-worktree.js" "<ws_root>"
91
91
  ```
92
92
 
93
93
  **解读末行**:
94
- - 退出码 0 + 末行(需求名) → 唯一识别,自动用之
95
- - 退出码 1 + 末行 `MULTIPLE_WORKTREEES` → 多个 worktree,AI agent 在对话中让用户输入序号或分支名
96
- - 退出码 2 + 末行 `NO_WORKTREE` → 无 worktree,提示用户先用 `cbb-worktree-init`
94
+ - 退出码 0 + 末行(需求名) → 唯一识别,自动用之(脚本自动合并 worktree 布局与本地模式布局候选,`(local)` 标本地模式进行中)
95
+ - 退出码 1 + 末行 `MULTIPLE_WORKTREEES` → 多个进行中需求,AI agent 在对话中让用户输入序号或分支名
96
+ - 退出码 2 + 末行 `NO_WORKTREE` → 无进行中需求,提示用户先执行 `cbb-worktree-init`(worktree 模式)或 `cbb-local-mode`(本地模式)
97
97
 
98
98
  ---
99
99
 
@@ -7,7 +7,9 @@ require('./silence-popup');
7
7
  * node commit-worktrees.js <wt_path> --message "msg" [options]
8
8
  *
9
9
  * 行为:
10
- * 1. 扫描 <ws_root>/.worktrees/worktree-<branch>/ 下所有 git 仓库
10
+ * 1. 按需求名发现参与仓库(ADR 0002 双布局):
11
+ * - worktree 布局:.worktrees/worktree-<branch>/ 下所有 git 仓库
12
+ * - 本地布局:根 / .codespace/<app>/ 检出恰在 <branch> 分支上
11
13
  * 2. 对每个仓库:
12
14
  * a. git status --porcelain 检测 dirty
13
15
  * b. 若 clean 或已有 ahead commit → 跳过
@@ -95,23 +97,57 @@ function isGitRepo(p) {
95
97
  return fs.existsSync(path.join(p, '.git'));
96
98
  }
97
99
 
100
+ function currentBranch(p) {
101
+ try {
102
+ return execSync('git branch --show-current', { cwd: p, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
103
+ } catch (e) { return ''; }
104
+ }
105
+
106
+ /**
107
+ * 按需求名发现参与仓库(ADR 0002 双布局):
108
+ * 布局一 worktree:.worktrees/worktree-<branch>/ 下 workspace + 嵌套 app worktree
109
+ * 布局二 本地模式:根 / .codespace/<app>/ 的检出恰在 <branch> 分支上(同名仓库以 worktree 布局优先)
110
+ * 两者皆空 → error。
111
+ */
98
112
  function scanWorktreeRepos(wsRoot, branch) {
99
113
  const wtRoot = path.join(wsRoot, '.worktrees', 'worktree-' + branch);
100
114
  const repos = [];
101
- if (!fs.existsSync(wtRoot)) {
102
- return { wtRoot, repos, error: 'worktree 目录不存在:' + wtRoot };
115
+
116
+ // 布局一:worktree
117
+ if (fs.existsSync(wtRoot)) {
118
+ if (isGitRepo(wtRoot)) {
119
+ repos.push({ name: 'workspace', wt: wtRoot, repo: wsRoot, layout: 'worktree' });
120
+ }
121
+ const entries = fs.readdirSync(wtRoot, { withFileTypes: true });
122
+ for (const e of entries) {
123
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
124
+ const appWt = path.join(wtRoot, e.name);
125
+ if (!isGitRepo(appWt)) continue;
126
+ repos.push({ name: e.name, wt: appWt, repo: path.join(wsRoot, '.codespace', e.name), layout: 'worktree' });
127
+ }
103
128
  }
104
- if (isGitRepo(wtRoot)) {
105
- repos.push({ name: 'workspace', wt: wtRoot, repo: wsRoot });
129
+
130
+ // 布局二:本地模式(检出在需求分支上)
131
+ const names = new Set(repos.map(r => r.name));
132
+ if (!names.has('workspace') && isGitRepo(wsRoot) && currentBranch(wsRoot) === branch) {
133
+ repos.push({ name: 'workspace', wt: wsRoot, repo: wsRoot, layout: 'local' });
106
134
  }
107
- const entries = fs.readdirSync(wtRoot, { withFileTypes: true });
108
- for (const e of entries) {
109
- if (!e.isDirectory() || e.name.startsWith('.')) continue;
110
- const appWt = path.join(wtRoot, e.name);
111
- if (!isGitRepo(appWt)) continue;
112
- repos.push({ name: e.name, wt: appWt, repo: path.join(wsRoot, '.codespace', e.name) });
135
+ const codeDir = path.join(wsRoot, '.codespace');
136
+ if (fs.existsSync(codeDir)) {
137
+ for (const name of fs.readdirSync(codeDir).sort()) {
138
+ if (names.has(name)) continue;
139
+ const appDir = path.join(codeDir, name);
140
+ if (!isGitRepo(appDir)) continue;
141
+ if (currentBranch(appDir) === branch) {
142
+ repos.push({ name, wt: appDir, repo: appDir, layout: 'local' });
143
+ }
144
+ }
113
145
  }
114
- return { wtRoot, repos, error: null };
146
+
147
+ const error = repos.length === 0
148
+ ? ('未发现需求 ' + branch + ' 的参与仓库(worktree 不存在,且无仓库检出在 ' + branch + ' 分支上)')
149
+ : null;
150
+ return { wtRoot, repos, error };
115
151
  }
116
152
 
117
153
  function run(cmd, cwd) {
@@ -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. .worktrees/ 下只有一个 worktree → 自动选用
13
- * 3. 多个 worktree → 按 mtime 倒序,第一个为推荐
14
- *
15
- * 输出:
16
- * 末行输出需求名(用于 AI agent 解析)
17
- * 多个时按 [1] xxx ⭐ / [2] yyy 列出
18
- *
19
- * 退出码:
20
- * 0 - 唯一识别(自动或唯一)
21
- * 1 - 多个 worktree,需用户输入
22
- * 2 - 无 worktree
23
- */
24
-
25
- const { execSync } = require('child_process');
26
- const fs = require('fs');
27
- const path = require('path');
28
-
29
- const wsRoot = process.argv[2];
30
-
31
- if (!wsRoot) {
32
- console.error('❌ 用法:node find-target-worktree.js <ws_root>');
33
- process.exit(1);
34
- }
35
-
36
- // 优先级 1:cwd 反推
37
- const cwd = process.cwd();
38
- const cwdMatch = cwd.match(/\.worktrees[\\\/]worktree-([^\\\/]+)/);
39
- if (cwdMatch) {
40
- const branch = cwdMatch[1];
41
- console.log('✅ 从 cwd 反推需求名:' + branch);
42
- console.log('---');
43
- console.log(branch);
44
- process.exit(0);
45
- }
46
-
47
- const worktreesDir = path.join(wsRoot, '.worktrees');
48
- if (!fs.existsSync(worktreesDir)) {
49
- console.error('❌ .worktrees 目录不存在:' + worktreesDir);
50
- console.error(' 请先执行 cbb-worktree-init 创建 worktree');
51
- console.log('---');
52
- console.log('NO_WORKTREE');
53
- process.exit(2);
54
- }
55
-
56
- // 列出所有 worktree-xxx 目录
57
- const entries = fs.readdirSync(worktreesDir, { withFileTypes: true })
58
- .filter(e => e.isDirectory() && e.name.startsWith('worktree-'))
59
- .map(e => ({
60
- name: e.name.substring('worktree-'.length),
61
- fullName: e.name,
62
- path: path.join(worktreesDir, e.name),
63
- mtime: fs.statSync(path.join(worktreesDir, e.name)).mtime.getTime(),
64
- }));
65
-
66
- if (entries.length === 0) {
67
- console.error('❌ 未找到任何 worktree');
68
- console.error(' 请先执行 cbb-worktree-init 创建 worktree');
69
- console.log('---');
70
- console.log('NO_WORKTREE');
71
- process.exit(2);
72
- }
73
-
74
- // 优先级 2:唯一
75
- if (entries.length === 1) {
76
- console.log('✅ 自动选用唯一 worktree:' + entries[0].name);
77
- console.log('---');
78
- console.log(entries[0].name);
79
- process.exit(0);
80
- }
81
-
82
- // 优先级 3:多个 → 按 mtime 倒序
83
- entries.sort((a, b) => b.mtime - a.mtime);
84
-
85
- console.log('⚠️ 检测到 ' + entries.length + ' 个 worktree(按最近修改排序):');
86
- entries.forEach((e, i) => {
87
- if (i === 0) {
88
- console.log(' [' + (i + 1) + '] ' + e.name + ' ⭐ (推荐:最近修改)');
89
- } else {
90
- console.log(' [' + (i + 1) + '] ' + e.name);
91
- }
92
- });
93
- console.log('请在对话中输入序号(1/2/3)或完整分支名:');
94
- console.log('---');
95
- console.log('MULTIPLE_WORKTREEES');
96
- process.exit(1);
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);
@@ -7,7 +7,9 @@ require('./silence-popup');
7
7
  * node push-worktrees.js <wt_path> # wt_path 必须是 <ws_root>/.worktrees/worktree-<branch> 形态
8
8
  *
9
9
  * 行为:
10
- * 1. 扫描 <ws_root>/.worktrees/worktree-<branch>/ 下所有 git 仓库(workspace + 各 app 子 worktree)
10
+ * 1. 按需求名发现参与仓库(ADR 0002 双布局):
11
+ * - worktree 布局:.worktrees/worktree-<branch>/ 下所有 git 仓库(workspace + 各 app 子 worktree)
12
+ * - 本地布局:根 / .codespace/<app>/ 检出恰在 <branch> 分支上
11
13
  * 2. 对每个仓库调 push-core.pushSingle(已建立 upstream 时直接 push -u;否则建立 upstream)
12
14
  * 3. 逐个报告状态,输出末行 PUSH_RESULT
13
15
  *
@@ -28,6 +30,7 @@ require('./silence-popup');
28
30
  const { pushSingle } = require('./push-core');
29
31
  const fs = require('fs');
30
32
  const path = require('path');
33
+ const { execSync } = require('child_process');
31
34
 
32
35
  function resolveInputs(argv) {
33
36
  // argv[2] 单独:wt_path 形式
@@ -56,35 +59,57 @@ function isGitRepo(p) {
56
59
  }
57
60
  }
58
61
 
62
+ function currentBranch(p) {
63
+ try {
64
+ return execSync('git branch --show-current', { cwd: p, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
65
+ } catch (e) { return ''; }
66
+ }
67
+
59
68
  /**
60
- * 扫描 worktree 下的所有 git 仓库
61
- * - workspace worktree:<ws_root>/.worktrees/worktree-<branch>/ 本身
62
- * - app worktree:<ws_root>/.worktrees/worktree-<branch>/<app_name>/(嵌套子目录)
69
+ * 按需求名发现参与仓库(ADR 0002 双布局):
70
+ * 布局一 worktree:.worktrees/worktree-<branch>/ 下 workspace + 嵌套 app worktree
71
+ * 布局二 本地模式:根 / .codespace/<app>/ 的检出恰在 <branch> 分支上(同名仓库以 worktree 布局优先)
72
+ * 两者皆空 → error。
63
73
  */
64
74
  function scanWorktreeRepos(wsRoot, branch) {
65
75
  const wtRoot = path.join(wsRoot, '.worktrees', 'worktree-' + branch);
66
76
  const repos = [];
67
77
 
68
- if (!fs.existsSync(wtRoot)) {
69
- return { wtRoot, repos, error: 'worktree 目录不存在:' + wtRoot };
78
+ // 布局一:worktree
79
+ if (fs.existsSync(wtRoot)) {
80
+ if (isGitRepo(wtRoot)) {
81
+ repos.push({ name: 'workspace', wt: wtRoot, repo: wsRoot, layout: 'worktree' });
82
+ }
83
+ const entries = fs.readdirSync(wtRoot, { withFileTypes: true });
84
+ for (const e of entries) {
85
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
86
+ const appWt = path.join(wtRoot, e.name);
87
+ if (!isGitRepo(appWt)) continue;
88
+ repos.push({ name: e.name, wt: appWt, repo: path.join(wsRoot, '.codespace', e.name), layout: 'worktree' });
89
+ }
70
90
  }
71
91
 
72
- // workspace worktree(自身是 git worktree)
73
- if (isGitRepo(wtRoot)) {
74
- repos.push({ name: 'workspace', wt: wtRoot, repo: wsRoot });
92
+ // 布局二:本地模式(检出在需求分支上)
93
+ const names = new Set(repos.map(r => r.name));
94
+ if (!names.has('workspace') && isGitRepo(wsRoot) && currentBranch(wsRoot) === branch) {
95
+ repos.push({ name: 'workspace', wt: wsRoot, repo: wsRoot, layout: 'local' });
75
96
  }
76
-
77
- // app worktree(嵌套子目录)
78
- const entries = fs.readdirSync(wtRoot, { withFileTypes: true });
79
- for (const e of entries) {
80
- if (!e.isDirectory()) continue;
81
- // 跳过 . 开头的隐藏目录(如 .va、.claude、.qoder)
82
- if (e.name.startsWith('.')) continue;
83
- const appWt = path.join(wtRoot, e.name);
84
- if (!isGitRepo(appWt)) continue;
85
- repos.push({ name: e.name, wt: appWt, repo: path.join(wsRoot, '.codespace', e.name) });
97
+ const codeDir = path.join(wsRoot, '.codespace');
98
+ if (fs.existsSync(codeDir)) {
99
+ for (const name of fs.readdirSync(codeDir).sort()) {
100
+ if (names.has(name)) continue;
101
+ const appDir = path.join(codeDir, name);
102
+ if (!isGitRepo(appDir)) continue;
103
+ if (currentBranch(appDir) === branch) {
104
+ repos.push({ name, wt: appDir, repo: appDir, layout: 'local' });
105
+ }
106
+ }
86
107
  }
87
- return { wtRoot, repos, error: null };
108
+
109
+ const error = repos.length === 0
110
+ ? ('未发现需求 ' + branch + ' 的参与仓库(worktree 不存在,且无仓库检出在 ' + branch + ' 分支上)')
111
+ : null;
112
+ return { wtRoot, repos, error };
88
113
  }
89
114
 
90
115
  /**