@jspg-ai/coding-bb 0.0.3-beta.8 → 0.0.4

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 (108) hide show
  1. package/cbb/dev-standards/rules/cbb-ai-behavior.md +0 -15
  2. package/cbb/dev-standards/rules/cbb-workspace-discipline.md +38 -0
  3. package/cbb/lib/external/index.js +228 -0
  4. package/cbb/lib/install/agents-nav.js +92 -0
  5. package/cbb/lib/install/claude-code.js +8 -1
  6. package/cbb/lib/install/command-skill.js +105 -0
  7. package/cbb/lib/install/init.js +399 -129
  8. package/cbb/lib/install/minimax.js +60 -0
  9. package/cbb/lib/install/rule-rewrite.js +1 -1
  10. package/cbb/lib/install/setup-decision.js +92 -0
  11. package/cbb/lib/install/tool-selection.js +23 -0
  12. package/cbb/lib/install/trae.js +18 -8
  13. package/cbb/lib/install/workbuddy.js +36 -0
  14. package/cbb/lib/install/workspaces.js +15 -61
  15. package/cbb/lib/utils/check-update.js +13 -5
  16. package/cbb/lib/utils/githook.js +230 -0
  17. package/cbb/lib/utils/gitignore.js +17 -4
  18. package/cbb/lib/utils/plugin-hook.js +92 -0
  19. package/cbb/worktrees/_shared/scripts/check-local-state.js +114 -0
  20. package/cbb/worktrees/_shared/scripts/check-unarchived.js +77 -0
  21. package/cbb/worktrees/_shared/scripts/delete-branches.js +147 -0
  22. package/cbb/worktrees/_shared/scripts/deprecate-branches.js +119 -0
  23. package/cbb/worktrees/_shared/scripts/find-target-worktree.js +148 -96
  24. package/cbb/worktrees/_shared/scripts/find-workspace-root.js +8 -8
  25. package/cbb/worktrees/_shared/scripts/install-git-hook.js +70 -0
  26. package/cbb/worktrees/_shared/scripts/junction-ai-config.js +192 -0
  27. package/cbb/worktrees/_shared/scripts/merge-branches.js +469 -0
  28. package/cbb/worktrees/_shared/scripts/push-core.js +1 -1
  29. package/cbb/worktrees/_shared/scripts/silence-popup.js +1 -1
  30. package/cbb/worktrees/_shared/scripts/start-local.js +180 -0
  31. package/cbb/worktrees/skills/cbb-local-mode/SKILL.md +194 -0
  32. package/cbb/worktrees/skills/cbb-local-mode/scripts/check-local-state.js +114 -0
  33. package/cbb/worktrees/skills/cbb-local-mode/scripts/delete-branches.js +147 -0
  34. package/cbb/worktrees/skills/cbb-local-mode/scripts/deprecate-branches.js +119 -0
  35. package/cbb/worktrees/skills/cbb-local-mode/scripts/install-git-hook.js +70 -0
  36. package/cbb/worktrees/skills/cbb-local-mode/scripts/merge-branches.js +469 -0
  37. package/cbb/worktrees/skills/cbb-local-mode/scripts/silence-popup.js +23 -0
  38. package/cbb/worktrees/skills/cbb-local-mode/scripts/start-local.js +180 -0
  39. package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +133 -81
  40. package/cbb/worktrees/skills/cbb-worktree-close/scripts/check-env.js +3 -3
  41. package/cbb/worktrees/skills/cbb-worktree-close/scripts/check-unarchived.js +77 -48
  42. package/cbb/worktrees/skills/cbb-worktree-close/scripts/delete-branches.js +147 -136
  43. package/cbb/worktrees/skills/cbb-worktree-close/scripts/deprecate-branches.js +119 -0
  44. package/cbb/worktrees/skills/cbb-worktree-close/scripts/discover-apps.js +14 -5
  45. package/cbb/worktrees/skills/cbb-worktree-close/scripts/find-target-worktree.js +148 -96
  46. package/cbb/worktrees/skills/cbb-worktree-close/scripts/find-workspace-root.js +8 -8
  47. package/cbb/worktrees/skills/cbb-worktree-close/scripts/junction-ai-config.js +192 -0
  48. package/cbb/worktrees/skills/cbb-worktree-close/scripts/merge-branches.js +469 -0
  49. package/cbb/worktrees/skills/cbb-worktree-close/scripts/remove-worktrees.js +44 -9
  50. package/cbb/worktrees/skills/cbb-worktree-close/scripts/safety-check.js +15 -4
  51. package/cbb/worktrees/skills/cbb-worktree-close/scripts/silence-popup.js +1 -1
  52. package/cbb/worktrees/skills/cbb-worktree-init/SKILL.md +136 -123
  53. package/cbb/worktrees/skills/cbb-worktree-init/scripts/check-env.js +1 -1
  54. package/cbb/worktrees/skills/cbb-worktree-init/scripts/check-repos.js +2 -2
  55. package/cbb/worktrees/skills/cbb-worktree-init/scripts/create-branches.js +33 -8
  56. package/cbb/worktrees/skills/cbb-worktree-init/scripts/create-worktrees.js +60 -46
  57. package/cbb/worktrees/skills/cbb-worktree-init/scripts/install-git-hook.js +70 -0
  58. package/cbb/worktrees/skills/cbb-worktree-init/scripts/junction-ai-config.js +192 -0
  59. package/cbb/worktrees/skills/cbb-worktree-init/scripts/parse-config.js +7 -7
  60. package/cbb/worktrees/skills/cbb-worktree-init/scripts/push-branches.js +3 -3
  61. package/cbb/worktrees/skills/cbb-worktree-init/scripts/push-core.js +1 -1
  62. package/cbb/worktrees/skills/cbb-worktree-init/scripts/silence-popup.js +1 -1
  63. package/cbb/worktrees/skills/cbb-worktree-init/scripts/sync-repos.js +11 -7
  64. package/cbb/worktrees/skills/cbb-worktree-init/scripts/update-gitignore.js +37 -9
  65. package/cbb/worktrees/skills/cbb-worktree-push/SKILL.md +212 -56
  66. package/cbb/worktrees/skills/cbb-worktree-push/scripts/check-unarchived.js +77 -0
  67. package/cbb/worktrees/skills/cbb-worktree-push/scripts/commit-worktrees.js +70 -12
  68. package/cbb/worktrees/skills/cbb-worktree-push/scripts/find-target-worktree.js +148 -96
  69. package/cbb/worktrees/skills/cbb-worktree-push/scripts/find-workspace-root.js +8 -8
  70. package/cbb/worktrees/skills/cbb-worktree-push/scripts/make-mr-urls.js +211 -0
  71. package/cbb/worktrees/skills/cbb-worktree-push/scripts/merge-branches.js +469 -0
  72. package/cbb/worktrees/skills/cbb-worktree-push/scripts/push-core.js +1 -1
  73. package/cbb/worktrees/skills/cbb-worktree-push/scripts/push-worktrees.js +67 -20
  74. package/cbb/worktrees/skills/cbb-worktree-push/scripts/silence-popup.js +1 -1
  75. package/config/{workspace-config.sample.json → CBB-WORKSPACE.sample.json} +1 -1
  76. package/config/openspec/schemas/spec-driven/schema.yaml +20 -0
  77. package/config/upstream-mirrors.json +5 -0
  78. package/external/frontend-design/.version +6 -0
  79. package/external/frontend-design/LICENSE.txt +177 -0
  80. package/external/frontend-design/SKILL.md +71 -0
  81. package/openspec/.version +2 -2
  82. package/openspec/commands/archive.md +15 -8
  83. package/openspec/commands/bulk-archive.md +13 -6
  84. package/openspec/commands/continue.md +0 -1
  85. package/openspec/commands/explore.md +1 -1
  86. package/openspec/commands/ff.md +1 -1
  87. package/openspec/commands/onboard.md +9 -4
  88. package/openspec/commands/update.md +11 -5
  89. package/openspec/commands/verify.md +57 -26
  90. package/openspec/skills/openspec-apply-change/SKILL.md +1 -1
  91. package/openspec/skills/openspec-archive-change/SKILL.md +16 -9
  92. package/openspec/skills/openspec-bulk-archive-change/SKILL.md +14 -7
  93. package/openspec/skills/openspec-continue-change/SKILL.md +1 -2
  94. package/openspec/skills/openspec-explore/SKILL.md +2 -2
  95. package/openspec/skills/openspec-ff-change/SKILL.md +2 -2
  96. package/openspec/skills/openspec-new-change/SKILL.md +1 -1
  97. package/openspec/skills/openspec-onboard/SKILL.md +10 -5
  98. package/openspec/skills/openspec-propose/SKILL.md +1 -1
  99. package/openspec/skills/openspec-sync-specs/SKILL.md +1 -1
  100. package/openspec/skills/openspec-update-change/SKILL.md +12 -6
  101. package/openspec/skills/openspec-verify-change/SKILL.md +59 -27
  102. package/package.json +3 -2
  103. package/superpowers/.version +3 -3
  104. package/superpowers/skills/writing-plans/SKILL.md +32 -20
  105. package/cbb/lib/install/codebuddy.js +0 -30
  106. package/cbb/worktrees/skills/cbb-worktree-init/scripts/find-workspace-root.js +0 -67
  107. package/config/workspace-agents.sample.md +0 -41
  108. package/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
@@ -0,0 +1,469 @@
1
+ #!/usr/bin/env node
2
+ require('./silence-popup');
3
+ /**
4
+ * 收尾 / 交付合并门禁 —— 直合并通道 + 合并判定(ADR 0007 / 0008)
5
+ *
6
+ * 把需求分支合并进目标分支并推送远端:
7
+ * - 供 cbb-worktree-close / cbb-local-mode 的收尾门禁调用(默认目标 = 主干)
8
+ * - 供 cbb-worktree-push Step 7 的"直接合并"出口调用(--target 可指定测试分支)
9
+ * 默认交付走 MR(人工在浏览器合入);本脚本是用户在门禁处**显式确认**后的
10
+ * "由 agent 直接完成合并"通道。
11
+ *
12
+ * 用法:
13
+ * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] [--allow-checkout] [--dry-run]
14
+ * node merge-branches.js <ws_root> <branch> [--apps a,b] [--target <分支>] --check # 仅判定,不合并
15
+ *
16
+ * 目标分支:
17
+ * 缺省 / --target 等于该仓库探测主干 → 主干快路径(本地主干检出上 merge --no-ff → push)
18
+ * --target 指定非主干分支 → 临时 worktree(基于 origin/<target>,不动用户检出)
19
+ *
20
+ * 模式:
21
+ * --check 逐仓库判定是否已合并到目标(主干含 release-* 中转 / squash / cherry-pick 兜底,
22
+ * 算法与 cbb-worktree-close 的 safety-check.js 同源);不修改任何状态
23
+ * 默认 两阶段(ADR 0007):
24
+ * 1. 预检:逐仓库校验(目标可解析 / 检出干净 / 分支存在)
25
+ * 任一不过 → 退出 2,不执行任何合并
26
+ * 2. 执行:merge --no-ff → push;执行中失败 → 停止并报告已完成清单(不回滚;重跑幂等)
27
+ * --dry-run 只做预检,不执行
28
+ *
29
+ * 退出码:0 全部成功(含 skipped / merged)/ 1 参数错误 / 2 预检或执行失败(含 need-mr / unmerged)
30
+ * 末行:MERGE_RESULT [...] 或 MERGE_CHECK_RESULT [...]
31
+ */
32
+
33
+ const { execSync } = require('child_process');
34
+ const fs = require('fs');
35
+ const os = require('os');
36
+ const path = require('path');
37
+
38
+ function run(cmd, cwd) {
39
+ try {
40
+ return {
41
+ ok: true,
42
+ out: execSync(cmd, {
43
+ cwd,
44
+ encoding: 'utf-8',
45
+ stdio: ['ignore', 'pipe', 'pipe'],
46
+ timeout: 120000,
47
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
48
+ }).trim(),
49
+ };
50
+ } catch (e) {
51
+ return { ok: false, err: (e.stderr || e.message || String(e)).trim() };
52
+ }
53
+ }
54
+
55
+ function detectMain(repoPath) {
56
+ const remote = run('git remote', repoPath);
57
+ if (!remote.ok || !remote.out) return '';
58
+ const r = remote.out.split(/\r?\n/)[0];
59
+ const symbolic = run('git symbolic-ref "refs/remotes/' + r + '/HEAD"', repoPath);
60
+ if (symbolic.ok && symbolic.out) return symbolic.out.replace('refs/remotes/' + r + '/', '');
61
+ return '';
62
+ }
63
+
64
+ // ── 合并判定(与 cbb-worktree-close/scripts/safety-check.js 同源) ──
65
+ // 0=merged, 1=unmerged, 2=to-ref-missing
66
+ function mergedTo(repoPath, from, to) {
67
+ if (!run('git rev-parse --verify --quiet "' + from + '"', repoPath).ok) return 1;
68
+ if (!run('git rev-parse --verify --quiet "' + to + '"', repoPath).ok) return 2;
69
+ if (run('git merge-base --is-ancestor "' + from + '" "' + to + '" && echo Y', repoPath).out === 'Y') return 0;
70
+ // 跨平台:不用 grep(Windows 无 grep),解析 git cherry 输出(+ 前缀 = 未合并提交)
71
+ const cherry = run('git cherry "' + to + '" "' + from + '"', repoPath);
72
+ if (!cherry.ok) return 1;
73
+ if (cherry.out.split(/\r?\n/).filter(Boolean).some(l => l.indexOf('+') === 0)) return 1;
74
+ return 0;
75
+ }
76
+
77
+ function mergedToMain(repoPath, branchName, main) {
78
+ if (mergedTo(repoPath, branchName, main) === 0) return 'main';
79
+ if (mergedTo(repoPath, branchName, 'origin/' + main) === 0) return 'origin/' + main;
80
+ const releaseList = run('git for-each-ref --format="%(refname:short)" "refs/heads/release-*" "refs/remotes/origin/release-*"', repoPath);
81
+ if (releaseList.ok && releaseList.out) {
82
+ const releases = releaseList.out.split(/\r?\n/).filter(Boolean).sort();
83
+ for (const rel of releases) {
84
+ if (mergedTo(repoPath, branchName, rel) === 0) {
85
+ if (mergedTo(repoPath, rel, main) === 0) return 'main-via-' + rel;
86
+ if (mergedTo(repoPath, rel, 'origin/' + main) === 0) return 'origin/' + main + '-via-' + rel;
87
+ }
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /** 目标为具体分支时的已合并判定(含 origin 镜像,不做 release 中转) */
94
+ function mergedToTarget(repoPath, branchName, target) {
95
+ if (mergedTo(repoPath, branchName, target) === 0) return target;
96
+ if (mergedTo(repoPath, branchName, 'origin/' + target) === 0) return 'origin/' + target;
97
+ return null;
98
+ }
99
+
100
+ function classifyPushError(stderr) {
101
+ const s = (stderr || '').toLowerCase();
102
+ if (s.includes('protected branch') || s.includes('deny updating')) return 'branch-protected';
103
+ if (s.includes('non-fast-forward') || s.includes('fetch first') || s.includes('rejected')) return 'non-fast-forward';
104
+ if (s.includes('permission denied (publickey)')) return 'ssh-auth-failed';
105
+ if (s.includes('authentication failed')) return 'https-auth-failed';
106
+ if (s.includes('could not resolve host') || s.includes('connection refused') || s.includes('hung up')) return 'network-unreachable';
107
+ return 'unknown';
108
+ }
109
+
110
+ function loadApps(wsRoot) {
111
+ try {
112
+ const config = JSON.parse(fs.readFileSync(path.join(wsRoot, 'CBB-WORKSPACE.json'), 'utf-8'));
113
+ return Array.isArray(config.apps) ? config.apps : [];
114
+ } catch (e) {
115
+ return [];
116
+ }
117
+ }
118
+
119
+ /** 参与仓库:workspace(根 = 主干检出)+ 各 app(.codespace/<app> = 主干检出) */
120
+ function buildRepos(wsRoot, appFilter) {
121
+ const repos = [{ name: 'workspace', repo: wsRoot, work: wsRoot }];
122
+ for (const app of loadApps(wsRoot)) {
123
+ if (appFilter && !appFilter.has(app.name)) continue;
124
+ const dir = path.join(wsRoot, '.codespace', app.name);
125
+ repos.push({ name: app.name, repo: dir, work: dir });
126
+ }
127
+ return repos;
128
+ }
129
+
130
+ function hasRef(repoPath, ref) {
131
+ return run('git rev-parse --verify --quiet "' + ref + '"', repoPath).ok;
132
+ }
133
+
134
+ /** 清理临时 worktree(失败仅警告,不阻断) */
135
+ function cleanupTempWorktree(repoPath, tmpDir) {
136
+ if (!tmpDir) return;
137
+ const removed = run('git worktree remove --force "' + tmpDir + '"', repoPath);
138
+ if (!removed.ok && fs.existsSync(tmpDir)) {
139
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (e) { /* Windows 锁:留给用户 */ }
140
+ }
141
+ run('git worktree prune', repoPath);
142
+ }
143
+
144
+ function main() {
145
+ const args = process.argv.slice(2);
146
+ const wsRoot = args[0];
147
+ const branch = args[1];
148
+
149
+ if (!wsRoot || !branch || branch.indexOf('--') === 0) {
150
+ console.error('Usage: node merge-branches.js <ws_root> <branch> [--apps a,b] [--target b] [--allow-checkout] [--dry-run] [--check]');
151
+ process.exit(1);
152
+ }
153
+
154
+ const appsIdx = args.indexOf('--apps');
155
+ const appFilter = appsIdx >= 0
156
+ ? new Set((args[appsIdx + 1] || '').split(',').map(s => s.trim()).filter(Boolean))
157
+ : null;
158
+ const targetIdx = args.indexOf('--target');
159
+ const targetArg = targetIdx >= 0 ? (args[targetIdx + 1] || '').trim() : '';
160
+ if (targetIdx >= 0 && !targetArg) {
161
+ console.error('--target 需要提供分支名');
162
+ process.exit(1);
163
+ }
164
+ const allowCheckout = args.includes('--allow-checkout');
165
+ const dryRun = args.includes('--dry-run');
166
+ const checkOnly = args.includes('--check');
167
+
168
+ const repos = buildRepos(wsRoot, appFilter);
169
+
170
+ // ── --check:仅判定每仓库是否已合并到目标 ─────────────────
171
+ if (checkOnly) {
172
+ const results = [];
173
+ for (const rp of repos) {
174
+ const rec = { name: rp.name, status: 'unmerged', merged: null, main: null, target: null };
175
+ if (!fs.existsSync(path.join(rp.repo, '.git'))) {
176
+ rec.status = 'no-repo';
177
+ results.push(rec);
178
+ continue;
179
+ }
180
+ run('git fetch --quiet origin', rp.repo);
181
+ const main = detectMain(rp.repo);
182
+ if (!main) {
183
+ rec.status = 'failed';
184
+ results.push(rec);
185
+ continue;
186
+ }
187
+ rec.main = main;
188
+ const target = targetArg || main;
189
+ rec.target = target;
190
+ if (!hasRef(rp.repo, 'refs/heads/' + branch)) {
191
+ rec.status = 'no-branch';
192
+ results.push(rec);
193
+ continue;
194
+ }
195
+ const merged = (!targetArg || target === main)
196
+ ? mergedToMain(rp.repo, branch, main)
197
+ : mergedToTarget(rp.repo, branch, target);
198
+ rec.merged = merged;
199
+ rec.status = merged ? 'merged' : 'unmerged';
200
+ results.push(rec);
201
+ }
202
+ results.forEach(r => console.log(r.status.toUpperCase() + ' ' + r.name + (r.merged ? ' → ' + r.merged : '') + (r.target ? ' [target: ' + r.target + ']' : '')));
203
+ const ok = results.every(r => r.status !== 'unmerged' && r.status !== 'failed');
204
+ console.log('---');
205
+ console.log('MERGE_CHECK_RESULT', JSON.stringify({ branch, target: targetArg || null, results }));
206
+ process.exit(ok ? 0 : 2);
207
+ }
208
+
209
+ // ── 阶段 1:预检 ──────────────────────────────────────────
210
+ const prechecks = [];
211
+ for (const rp of repos) {
212
+ const rec = { name: rp.name, status: 'ok', main: null, target: null, temp: false, reason: null };
213
+ if (!fs.existsSync(path.join(rp.repo, '.git'))) {
214
+ rec.status = 'skipped';
215
+ rec.reason = 'no-repo';
216
+ prechecks.push(rec);
217
+ continue;
218
+ }
219
+ const fetch = run('git fetch --quiet origin', rp.repo);
220
+ if (!fetch.ok) {
221
+ rec.status = 'failed';
222
+ rec.reason = 'fetch-failed';
223
+ prechecks.push(rec);
224
+ continue;
225
+ }
226
+ const main = detectMain(rp.repo);
227
+ if (!main) {
228
+ rec.status = 'failed';
229
+ rec.reason = 'cannot-detect-main';
230
+ prechecks.push(rec);
231
+ continue;
232
+ }
233
+ rec.main = main;
234
+ const target = targetArg || main;
235
+ rec.target = target;
236
+
237
+ if (!hasRef(rp.repo, 'refs/heads/' + branch)) {
238
+ rec.status = 'skipped';
239
+ rec.reason = 'no-branch';
240
+ prechecks.push(rec);
241
+ continue;
242
+ }
243
+ const targetLocal = hasRef(rp.repo, 'refs/heads/' + target);
244
+ const targetRemote = hasRef(rp.repo, 'refs/remotes/origin/' + target);
245
+ if (!targetLocal && !targetRemote) {
246
+ rec.status = 'failed';
247
+ rec.reason = 'target-not-found';
248
+ prechecks.push(rec);
249
+ continue;
250
+ }
251
+
252
+ if (target === main) {
253
+ // 主干路径(快路径,与 ADR 0007 收尾门禁一致)
254
+ if (mergedToMain(rp.repo, branch, main)) {
255
+ rec.status = 'skipped';
256
+ rec.reason = 'already-merged';
257
+ prechecks.push(rec);
258
+ continue;
259
+ }
260
+ const status = run('git status --porcelain', rp.work);
261
+ if (!status.ok || status.out) {
262
+ rec.status = 'failed';
263
+ rec.reason = 'dirty-worktree';
264
+ prechecks.push(rec);
265
+ continue;
266
+ }
267
+ const current = run('git branch --show-current', rp.work);
268
+ if (current.out && current.out !== main && !allowCheckout) {
269
+ rec.status = 'failed';
270
+ rec.reason = 'not-on-main';
271
+ prechecks.push(rec);
272
+ continue;
273
+ }
274
+ const ahead = run('git rev-list --count "origin/' + main + '..' + main + '"', rp.repo);
275
+ if (ahead.ok && ahead.out && parseInt(ahead.out, 10) > 0) {
276
+ rec.status = 'failed';
277
+ rec.reason = 'main-ahead-of-origin';
278
+ prechecks.push(rec);
279
+ continue;
280
+ }
281
+ } else {
282
+ // 指定分支路径:已合并则跳过;当前检出即目标且干净 → 就地;否则临时 worktree
283
+ if (mergedToTarget(rp.repo, branch, target)) {
284
+ rec.status = 'skipped';
285
+ rec.reason = 'already-merged';
286
+ prechecks.push(rec);
287
+ continue;
288
+ }
289
+ const current = run('git branch --show-current', rp.work);
290
+ if (current.out === target) {
291
+ const status = run('git status --porcelain', rp.work);
292
+ if (!status.ok || status.out) {
293
+ rec.status = 'failed';
294
+ rec.reason = 'dirty-worktree';
295
+ prechecks.push(rec);
296
+ continue;
297
+ }
298
+ } else {
299
+ rec.temp = true; // 执行阶段创建临时 worktree
300
+ }
301
+ }
302
+ prechecks.push(rec);
303
+ }
304
+
305
+ const hasPrecheckFailure = prechecks.some(p => p.status === 'failed');
306
+ prechecks.forEach(p => {
307
+ if (p.status === 'failed') console.log('FAIL ' + p.name + ': ' + p.reason);
308
+ else if (p.status === 'skipped') console.log('SKIP ' + p.name + ': ' + p.reason);
309
+ else console.log('OK ' + p.name + ' → 合并至 ' + p.target + (p.temp ? '(临时 worktree)' : ''));
310
+ });
311
+
312
+ if (hasPrecheckFailure) {
313
+ console.log('---');
314
+ console.log('MERGE_RESULT', JSON.stringify({ phase: 'precheck', results: prechecks }));
315
+ process.exit(2);
316
+ }
317
+
318
+ if (dryRun) {
319
+ console.log('---');
320
+ console.log('MERGE_RESULT', JSON.stringify({ phase: 'precheck', dry_run: true, results: prechecks }));
321
+ process.exit(0);
322
+ }
323
+
324
+ // ── 阶段 2:执行(停止式,不回滚) ────────────────────────
325
+ const results = prechecks.map(p => ({ ...p, status: p.status, detail: null }));
326
+ let stopped = false;
327
+
328
+ for (let i = 0; i < prechecks.length; i += 1) {
329
+ const p = prechecks[i];
330
+ const rp = repos[i];
331
+ if (p.status !== 'ok') continue;
332
+ if (stopped) {
333
+ results[i].status = 'not-attempted';
334
+ continue;
335
+ }
336
+
337
+ const target = p.target;
338
+ const mergeMsg = "Merge branch '" + branch + "'";
339
+
340
+ if (target === p.main) {
341
+ // ── 主干路径(快路径) ──
342
+ const current = run('git branch --show-current', rp.work);
343
+ if (current.out !== target) {
344
+ const co = run('git checkout --quiet "' + target + '"', rp.work);
345
+ if (!co.ok) {
346
+ results[i].status = 'failed';
347
+ results[i].detail = 'checkout-failed: ' + co.err.split(/\r?\n/)[0];
348
+ stopped = true;
349
+ continue;
350
+ }
351
+ }
352
+ const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
353
+ if (!pull.ok) {
354
+ results[i].status = 'failed';
355
+ results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
356
+ stopped = true;
357
+ continue;
358
+ }
359
+ const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', rp.repo);
360
+ if (!merge.ok) {
361
+ run('git merge --abort', rp.repo);
362
+ results[i].status = 'failed';
363
+ results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
364
+ stopped = true;
365
+ continue;
366
+ }
367
+ let push = run('git push origin "' + target + '"', rp.repo);
368
+ if (!push.ok) {
369
+ const code = classifyPushError(push.err);
370
+ if (code === 'non-fast-forward') {
371
+ const retryPull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
372
+ push = retryPull.ok ? run('git push origin "' + target + '"', rp.repo) : retryPull;
373
+ }
374
+ if (!push.ok) {
375
+ const code2 = classifyPushError(push.err);
376
+ if (code2 === 'branch-protected') {
377
+ results[i].status = 'need-mr';
378
+ results[i].detail = 'branch-protected(目标分支受保护,请改用 MR 出口)';
379
+ } else {
380
+ results[i].status = 'failed';
381
+ results[i].detail = 'push-failed: ' + (push.err || '').split(/\r?\n/)[0];
382
+ }
383
+ stopped = true;
384
+ continue;
385
+ }
386
+ }
387
+ results[i].status = 'merged';
388
+ results[i].detail = 'merged+pushed origin/' + target;
389
+ continue;
390
+ }
391
+
392
+ // ── 指定分支路径(当前检出即目标 → 就地;否则临时 worktree) ──
393
+ let workDir = rp.work;
394
+ let tmpDir = null;
395
+ const current = run('git branch --show-current', rp.work);
396
+ if (current.out !== target) {
397
+ tmpDir = path.join(os.tmpdir(), 'cbb-merge-' + process.pid + '-' + i);
398
+ const base = hasRef(rp.repo, 'refs/remotes/origin/' + target) ? 'origin/' + target : target;
399
+ const add = run('git worktree add --detach "' + tmpDir + '" "' + base + '"', rp.repo);
400
+ if (!add.ok) {
401
+ results[i].status = 'failed';
402
+ results[i].detail = 'worktree-add-failed: ' + add.err.split(/\r?\n/)[0];
403
+ stopped = true;
404
+ continue;
405
+ }
406
+ workDir = tmpDir;
407
+ } else {
408
+ const pull = run('git pull --ff-only --quiet origin "' + target + '"', rp.repo);
409
+ if (!pull.ok) {
410
+ results[i].status = 'failed';
411
+ results[i].detail = 'pull-failed: ' + pull.err.split(/\r?\n/)[0];
412
+ stopped = true;
413
+ continue;
414
+ }
415
+ }
416
+
417
+ const merge = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', workDir);
418
+ if (!merge.ok) {
419
+ run('git merge --abort', workDir);
420
+ cleanupTempWorktree(rp.repo, tmpDir);
421
+ results[i].status = 'failed';
422
+ results[i].detail = 'merge-failed: ' + merge.err.split(/\r?\n/)[0];
423
+ stopped = true;
424
+ continue;
425
+ }
426
+
427
+ let push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
428
+ if (!push.ok) {
429
+ const code = classifyPushError(push.err);
430
+ if (code === 'non-fast-forward') {
431
+ // 远端已前进:在合并工作区 ff 同步后重试一次
432
+ const retry = run('git fetch --quiet origin "' + target + '" && git reset --hard FETCH_HEAD', workDir);
433
+ if (retry.ok) {
434
+ const rem = run('git merge --no-ff -m "' + mergeMsg + '" "' + branch + '"', workDir);
435
+ if (rem.ok) push = run('git push origin "HEAD:refs/heads/' + target + '"', workDir);
436
+ }
437
+ }
438
+ if (!push.ok) {
439
+ const code2 = classifyPushError(push.err);
440
+ cleanupTempWorktree(rp.repo, tmpDir);
441
+ if (code2 === 'branch-protected') {
442
+ results[i].status = 'need-mr';
443
+ results[i].detail = 'branch-protected(目标分支受保护,请改用 MR 出口)';
444
+ } else {
445
+ results[i].status = 'failed';
446
+ results[i].detail = 'push-failed: ' + (push.err || '').split(/\r?\n/)[0];
447
+ }
448
+ stopped = true;
449
+ continue;
450
+ }
451
+ }
452
+ cleanupTempWorktree(rp.repo, tmpDir);
453
+ results[i].status = 'merged';
454
+ results[i].detail = 'merged+pushed origin/' + target + (tmpDir ? '(临时 worktree)' : '');
455
+ }
456
+
457
+ results.forEach(r => {
458
+ console.log(r.status.toUpperCase() + ' ' + r.name + (r.detail ? ' — ' + r.detail : ''));
459
+ });
460
+
461
+ const ok = results.every(r => r.status === 'merged' || r.status === 'skipped');
462
+ console.log('---');
463
+ console.log('MERGE_RESULT', JSON.stringify({ phase: 'execute', target: targetArg || null, results }));
464
+ process.exit(ok ? 0 : 2);
465
+ }
466
+
467
+ if (require.main === module) main();
468
+
469
+ module.exports = { run, detectMain, mergedTo, mergedToMain, mergedToTarget, classifyPushError, loadApps, buildRepos };
@@ -3,7 +3,7 @@ require('./silence-popup');
3
3
  * openspec-shared 推送核心模块
4
4
  *
5
5
  * 唯一真源:cbb/worktrees/_shared/scripts/push-core.js
6
- * 由 scripts/sync-shared.js 展开到各 skill 的 scripts/ 下(init-worktree / extend-worktree / push-worktrees)。
6
+ * 由 scripts/sync-shared.js 展开到各 skill 的 scripts/ 下(init / push)。
7
7
  *
8
8
  * 公开 API:
9
9
  * - tryExec(cmd, cwd, execImpl) 包装 execSync,统一返回 {ok, out|err}
@@ -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>/ 的 workspace + 其 .codespace/<app>/ 容器内各 app worktree(镜像主布局,ADR 0003)
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,79 @@ function isGitRepo(p) {
56
59
  }
57
60
  }
58
61
 
62
+ /** 读取 CBB-WORKSPACE.json 的 app 名列表(旧布局兼容判定用;缺失返回 []) */
63
+ function loadAppNames(wsRoot) {
64
+ try {
65
+ const cfg = JSON.parse(fs.readFileSync(path.join(wsRoot, 'CBB-WORKSPACE.json'), 'utf-8'));
66
+ return Array.isArray(cfg.apps) ? cfg.apps.map(a => a.name).filter(Boolean) : [];
67
+ } catch (e) {
68
+ return [];
69
+ }
70
+ }
71
+
72
+ function currentBranch(p) {
73
+ try {
74
+ return execSync('git branch --show-current', { cwd: p, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
75
+ } catch (e) { return ''; }
76
+ }
77
+
59
78
  /**
60
- * 扫描 worktree 下的所有 git 仓库
61
- * - workspace worktree:<ws_root>/.worktrees/worktree-<branch>/ 本身
62
- * - app worktree:<ws_root>/.worktrees/worktree-<branch>/<app_name>/(嵌套子目录)
79
+ * 按需求名发现参与仓库(ADR 0002 双布局):
80
+ * 布局一 worktree:.worktrees/worktree-<branch>/ 的 workspace + 其 .codespace/<app>/ 容器内 app worktree(ADR 0003 镜像主布局;≤beta.23 旧平铺顶层布局按配置 app 名兼容)
81
+ * 布局二 本地模式:根 / .codespace/<app>/ 的检出恰在 <branch> 分支上(同名仓库以 worktree 布局优先)
82
+ * 两者皆空 → error。
63
83
  */
64
84
  function scanWorktreeRepos(wsRoot, branch) {
65
85
  const wtRoot = path.join(wsRoot, '.worktrees', 'worktree-' + branch);
66
86
  const repos = [];
67
87
 
68
- if (!fs.existsSync(wtRoot)) {
69
- return { wtRoot, repos, error: 'worktree 目录不存在:' + wtRoot };
88
+ // 布局一:worktree
89
+ if (fs.existsSync(wtRoot)) {
90
+ if (isGitRepo(wtRoot)) {
91
+ repos.push({ name: 'workspace', wt: wtRoot, repo: wsRoot, layout: 'worktree' });
92
+ }
93
+ // app worktree 收拢在 .codespace/ 容器内(镜像主布局,ADR 0003)
94
+ const appContainer = path.join(wtRoot, '.codespace');
95
+ if (fs.existsSync(appContainer)) {
96
+ for (const e of fs.readdirSync(appContainer, { withFileTypes: true })) {
97
+ if (!e.isDirectory()) continue;
98
+ const appWt = path.join(appContainer, e.name);
99
+ if (!isGitRepo(appWt)) continue;
100
+ repos.push({ name: e.name, wt: appWt, repo: path.join(wsRoot, '.codespace', e.name), layout: 'worktree' });
101
+ }
102
+ }
103
+ // 兼容 ≤beta.23 旧平铺布局:按配置 app 名精确匹配(避免把根仓库自带的嵌套 git 目录误判为 app)
104
+ for (const name of loadAppNames(wsRoot)) {
105
+ if (repos.some(r => r.name === name)) continue;
106
+ const legacyWt = path.join(wtRoot, name);
107
+ if (fs.existsSync(path.join(legacyWt, '.git'))) {
108
+ console.error('⚠️ ' + name + ' 仍是旧平铺布局(' + legacyWt + '),建议重跑 cbb-worktree-init 迁移到 .codespace/ 容器');
109
+ repos.push({ name, wt: legacyWt, repo: path.join(wsRoot, '.codespace', name), layout: 'worktree' });
110
+ }
111
+ }
70
112
  }
71
113
 
72
- // workspace worktree(自身是 git worktree)
73
- if (isGitRepo(wtRoot)) {
74
- repos.push({ name: 'workspace', wt: wtRoot, repo: wsRoot });
114
+ // 布局二:本地模式(检出在需求分支上)
115
+ const names = new Set(repos.map(r => r.name));
116
+ if (!names.has('workspace') && isGitRepo(wsRoot) && currentBranch(wsRoot) === branch) {
117
+ repos.push({ name: 'workspace', wt: wsRoot, repo: wsRoot, layout: 'local' });
75
118
  }
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) });
119
+ const codeDir = path.join(wsRoot, '.codespace');
120
+ if (fs.existsSync(codeDir)) {
121
+ for (const name of fs.readdirSync(codeDir).sort()) {
122
+ if (names.has(name)) continue;
123
+ const appDir = path.join(codeDir, name);
124
+ if (!isGitRepo(appDir)) continue;
125
+ if (currentBranch(appDir) === branch) {
126
+ repos.push({ name, wt: appDir, repo: appDir, layout: 'local' });
127
+ }
128
+ }
86
129
  }
87
- return { wtRoot, repos, error: null };
130
+
131
+ const error = repos.length === 0
132
+ ? ('未发现需求 ' + branch + ' 的参与仓库(worktree 不存在,且无仓库检出在 ' + branch + ' 分支上)')
133
+ : null;
134
+ return { wtRoot, repos, error };
88
135
  }
89
136
 
90
137
  /**
@@ -4,7 +4,7 @@
4
4
  // koffi is resolved from cbb global install (no local node_modules needed).
5
5
  //
6
6
  // 唯一真源:cbb/worktrees/_shared/scripts/silence-popup.js
7
- // 由 scripts/sync-shared.js 展开到各 skill 的 scripts/ 下(init / extend / close / push)。
7
+ // 由 scripts/sync-shared.js 展开到各 skill 的 scripts/ 下(init / close / push / local-mode)。
8
8
  // skill 内同目录引用:脚本第一行 `require('./silence-popup')`。
9
9
  if (process.platform === 'win32') {
10
10
  try {
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "AI 开发工作空间配置样例(请勿修改本文件,复制为工作空间根目录的 workspace-config.json 后编辑使用)。apps 数组中每个对象需包含:name(应用名,同时作为 .codespace/ 子目录名)、repo(Git 仓库地址,支持 SSH 或 HTTPS)、side(仓库类型,'front' 表示前端工程,'back' 表示后端工程,'mixed' 表示前后端混合工程);可选 desc(该应用的职责描述,便于人与 AI 理解应用分工)。",
2
+ "_comment": "AI 开发工作空间配置样例(请勿修改本文件,复制为工作空间根目录的 CBB-WORKSPACE.json 后编辑使用)。apps 数组中每个对象需包含:name(应用名,同时作为 .codespace/ 子目录名)、repo(Git 仓库地址,支持 SSH 或 HTTPS)、side(仓库类型,'front' 表示前端工程,'back' 表示后端工程,'mixed' 表示前后端混合工程);可选 desc(该应用的职责描述,便于人与 AI 理解应用分工)。",
3
3
  "apps":
4
4
  [
5
5
  {
@@ -224,6 +224,14 @@ apply:
224
224
 
225
225
  `plan.md` 跟 change 同住一个目录,archive 阶段会被一起带走。
226
226
 
227
+ **收尾(全部任务翻成 `- [x]` 后执行;本段为项目级硬约束,覆盖官方 apply 模板自带的完成提示语):**
228
+
229
+ 1. **未决项自检**(逐项确认,任何一项未过都不得宣布完成):
230
+ - change 产物(proposal.md / design.md / specs/** / tasks.md / plan.md)已提交到需求分支(轻量模式:空间根 main 分支);未提交时先询问用户是否现在提交(可随 cbb-worktree-push 一并推送;轻量模式提交后即 push)
231
+ - 本会话产生的临时文件与环境改动(备份目录、未回写远端的本地提交、cherry-pick 等)已列清并交用户决策
232
+ 2. **输出顺序纪律**:存在未决项时,本轮只列未决项并等待用户决策;**禁止同轮输出"下一步建议"**,禁止出现"规划与实施均已完成""可运行 archive"之类表述(产物未提交时归档会丢规划产物)
233
+ 3. **全部未决项关闭后,唯一可建议的下一步是 `/opsx:verify`**:本项目工作流为 propose → apply → verify → archive,verify(强制 code-review + 产出 cr.md)是 archive 的前置阶段;**禁止直接建议 archive**,官方模板的 "You can archive this change" 提示语以本段为准
234
+
227
235
  verify:
228
236
  tracks: cr.md
229
237
  requires: [tasks]
@@ -236,3 +244,15 @@ verify:
236
244
  即便 AI 自认能 review,仍必须调 skill。
237
245
  2. **MANDATORY 产出 `cr.md`**:把完整 Verification Report 写到 `openspec/changes/<name>/cr.md`,
238
246
  缺则视为 verify 未完成。报告必须包含五段:Summary / CRITICAL / WARNING / SUGGESTION / Code Review。
247
+
248
+ **收尾(cr.md 产出且无 CRITICAL 遗留后执行;本段为项目级硬约束,覆盖官方 verify 模板自带的完成提示语):**
249
+
250
+ verify 通过后,用 AskUserQuestion(固定选项)向用户提供两条路径并等待选择,禁止替用户决定、禁止单一路径直接建议归档:
251
+
252
+ - **路径 A · 先验收后归档(推荐)**:执行 `cbb-worktree-push` 推送 feature 分支并提 MR 到测试分支 → 测试环境部署、用户验收 → 验收要改则在 worktree 内修复后再次 push(小改动无需重跑 verify)→ 验收通过后才 `/opsx:archive`
253
+ - **路径 B · 先归档后推送**:立即 `/opsx:archive` 落定变更产物(归档移动与 spec 同步发生在 feature 分支上)→ 再执行 `cbb-worktree-push` 一并推送 → 提 MR 到测试分支验收。注意:验收若发现问题,产物已归档,修复需走新 change 或将归档移回后继续;适合改动小、验收把握大的场景
254
+ - **Step 7 交付选择(ADR 0008)**:两条路径的 `cbb-worktree-push` 推送完成后,除「提 MR」外均可选「直接合并」——合测试分支不要求归档,合主干要求已归档 + 强提醒确认;合并成功不删分支(删除归 `cbb-worktree-close` 收尾门禁)
255
+
256
+ 用户选定后按该路径执行;官方模板的单一 "suggest archive" 提示语以本段为准。
257
+
258
+ **轻量模式变体**(本次 change 完全不涉及应用代码,产物在空间根仓库):无 worktree push / 测试分支 MR 环节,两条路径替换为——A(推荐)"先 push main 交付、观察无回归后再 archive" / B"立即 archive 落定产物,再 push main";main 提交不得悬空本地。
@@ -8,5 +8,10 @@
8
8
  "openspec": [
9
9
  "https://registry.npmmirror.com",
10
10
  "https://registry.npmjs.org"
11
+ ],
12
+ "external": [
13
+ "https://gh-proxy.com/",
14
+ "https://ghfast.top/",
15
+ ""
11
16
  ]
12
17
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "repo": "anthropics/skills",
3
+ "ref": "41bbe19d1a1a7eaab5e7bb9050a417e5c6cffc8f",
4
+ "path": "skills/frontend-design",
5
+ "updatedAt": "2026-09-28T07:10:02.694Z"
6
+ }