@jspg-ai/coding-bb 0.0.3-beta.32 → 0.0.3-beta.34

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 (41) hide show
  1. package/cbb/dev-standards/rules/cbb-ai-behavior.md +6 -5
  2. package/cbb/lib/utils/githook.js +6 -2
  3. package/cbb/worktrees/_shared/scripts/check-unarchived.js +77 -0
  4. package/cbb/worktrees/_shared/scripts/delete-branches.js +147 -0
  5. package/cbb/worktrees/_shared/scripts/deprecate-branches.js +119 -0
  6. package/cbb/worktrees/_shared/scripts/find-target-worktree.js +1 -1
  7. package/cbb/worktrees/_shared/scripts/find-workspace-root.js +1 -1
  8. package/cbb/worktrees/_shared/scripts/install-git-hook.js +1 -1
  9. package/cbb/worktrees/_shared/scripts/merge-branches.js +469 -0
  10. package/cbb/worktrees/_shared/scripts/push-core.js +1 -1
  11. package/cbb/worktrees/_shared/scripts/silence-popup.js +1 -1
  12. package/cbb/worktrees/skills/cbb-local-mode/SKILL.md +46 -10
  13. package/cbb/worktrees/skills/cbb-local-mode/scripts/delete-branches.js +147 -0
  14. package/cbb/worktrees/skills/cbb-local-mode/scripts/deprecate-branches.js +119 -0
  15. package/cbb/worktrees/skills/cbb-local-mode/scripts/install-git-hook.js +1 -1
  16. package/cbb/worktrees/skills/cbb-local-mode/scripts/merge-branches.js +469 -0
  17. package/cbb/worktrees/skills/cbb-local-mode/scripts/silence-popup.js +1 -1
  18. package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +88 -52
  19. package/cbb/worktrees/skills/cbb-worktree-close/scripts/check-env.js +1 -1
  20. package/cbb/worktrees/skills/cbb-worktree-close/scripts/check-unarchived.js +3 -1
  21. package/cbb/worktrees/skills/cbb-worktree-close/scripts/delete-branches.js +147 -136
  22. package/cbb/worktrees/skills/cbb-worktree-close/scripts/deprecate-branches.js +119 -0
  23. package/cbb/worktrees/skills/cbb-worktree-close/scripts/find-target-worktree.js +1 -1
  24. package/cbb/worktrees/skills/cbb-worktree-close/scripts/find-workspace-root.js +1 -1
  25. package/cbb/worktrees/skills/cbb-worktree-close/scripts/merge-branches.js +469 -0
  26. package/cbb/worktrees/skills/cbb-worktree-close/scripts/safety-check.js +4 -2
  27. package/cbb/worktrees/skills/cbb-worktree-close/scripts/silence-popup.js +1 -1
  28. package/cbb/worktrees/skills/cbb-worktree-init/SKILL.md +13 -10
  29. package/cbb/worktrees/skills/cbb-worktree-init/scripts/install-git-hook.js +1 -1
  30. package/cbb/worktrees/skills/cbb-worktree-init/scripts/push-core.js +1 -1
  31. package/cbb/worktrees/skills/cbb-worktree-init/scripts/silence-popup.js +1 -1
  32. package/cbb/worktrees/skills/cbb-worktree-push/SKILL.md +143 -20
  33. package/cbb/worktrees/skills/cbb-worktree-push/scripts/check-unarchived.js +77 -0
  34. package/cbb/worktrees/skills/cbb-worktree-push/scripts/find-target-worktree.js +1 -1
  35. package/cbb/worktrees/skills/cbb-worktree-push/scripts/find-workspace-root.js +1 -1
  36. package/cbb/worktrees/skills/cbb-worktree-push/scripts/merge-branches.js +469 -0
  37. package/cbb/worktrees/skills/cbb-worktree-push/scripts/push-core.js +1 -1
  38. package/cbb/worktrees/skills/cbb-worktree-push/scripts/silence-popup.js +1 -1
  39. package/config/openspec/schemas/spec-driven/schema.yaml +1 -0
  40. package/package.json +2 -2
  41. package/cbb/worktrees/skills/cbb-worktree-init/scripts/find-workspace-root.js +0 -67
@@ -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}
@@ -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,11 +1,11 @@
1
1
  ---
2
2
  name: cbb-local-mode
3
- description: "业务空间本地模式开发:空间根与各涉及应用在 .codespace/<app>/ 就地建同名分支,当前会话直接开发(不建 worktree、不换目录)。触发:用户说'本地模式'/'就地开发'/'不走 worktree'/'在这个窗口开发',或双模式选择时用户选本地模式。覆盖开工(状态自检→布局确认→切分支)、开发、提交推送(转 cbb-worktree-push)、收尾(回 main + 删分支)、升级 worktree(重跑 cbb-worktree-init)。一次只承载一个需求。"
3
+ description: "业务空间本地模式开发:空间根与各涉及应用在 .codespace/<app>/ 就地建同名分支,当前会话直接开发(不建 worktree、不换目录)。触发:用户说'本地模式'/'就地开发'/'不走 worktree'/'在这个窗口开发',或双模式选择时用户选本地模式。覆盖开工(状态自检→布局确认→切分支)、开发、提交推送(转 cbb-worktree-push)、收尾(收尾合并门禁 ADR 0007:MR / 直合并 / 确认废弃后回 main + 删分支)、升级 worktree(重跑 cbb-worktree-init)。一次只承载一个需求。"
4
4
  ---
5
5
 
6
6
  # Local Mode(本地模式)
7
7
 
8
- 业务空间的**就地开发**模式(ADR 0002):需求涉及的所有仓库(空间根 + 各应用的标准本地检出 `.codespace/<app>/`)统一切到同名分支,当前会话在原地直接开发。与 worktree 模式**能力对称**(都能改 workspace 内容与应用代码、都强制分支隔离、交付都走 MR),差异仅是**无隔离目录、不可并行**——适合单线程快速推进的需求。
8
+ 业务空间的**就地开发**模式(ADR 0002):需求涉及的所有仓库(空间根 + 各应用的标准本地检出 `.codespace/<app>/`)统一切到同名分支,当前会话在原地直接开发。与 worktree 模式**能力对称**(都能改 workspace 内容与应用代码、都强制分支隔离、交付均有 MR 与直合并两条通道,ADR 0007/0008),差异仅是**无隔离目录、不可并行**——适合单线程快速推进的需求。
9
9
 
10
10
  **启动时声明:** "我正在使用 cbb-local-mode 技能进行本地模式开发。"
11
11
 
@@ -14,7 +14,8 @@ description: "业务空间本地模式开发:空间根与各涉及应用在 .c
14
14
  - **两模式由用户选择,agent 只给推荐**:需要并行 / 隔离 → cbb-worktree-init;单线程快改 → 本技能。涉及应用代码不再是 worktree 独占——本地模式同样支持(`.codespace/<app>/` 就地切分支)
15
15
  - **基准 = `origin/main`(唯一真相源);`.codespace/<app>/` 的检出 = 视图**:本地模式暂态占用视图合法;main 检出是基准快照,**严禁直接在 main 检出上提交**(pre-commit hook 会拦)
16
16
  - **一次只承载一个需求**:本地模式独占空间根与各 app 的本地检出;期间并行新需求只能走 worktree 模式(两模式互不干扰,worktree 分支从 `origin/main` 建,不依赖本地检出状态)
17
- - **提交防线在 hook**:分支感知 pre-commit hook 拦截一切 main 上的载荷提交(workspace 白名单外 / 应用全拦);被拦 = 大声失败,按指引回分支,**禁止自动 `--no-verify` 绕过**
17
+ - **提交防线在 hook**:分支感知 pre-commit hook 拦截一切 main 上的载荷提交(workspace 白名单外 / 应用全拦;merge 提交放行);被拦 = 大声失败,按指引回分支,**禁止自动 `--no-verify` 绕过**
18
+ - **收尾合并门禁(ADR 0007,与 worktree 模式对称)**:收尾前每个参与仓库必须为"已合并",未合并时三选一——**提 MR** / **直合并**(`merge-branches.js --allow-checkout`)/ **确认废弃**(远程重命名 `<需求名>-deprecated`)。**硬门禁:未合并且非废弃一律不允许收尾**
18
19
  - 所有原子操作封装为 `scripts/*.js`(跨平台 Node.js),调用方式与 worktree 三件套一致:`node "<skill_dir>/scripts/<name>.js" <参数>`,cwd 保持空间根
19
20
 
20
21
  ---
@@ -41,7 +42,7 @@ node "<skill_dir>/scripts/check-local-state.js" "<ws_root>"
41
42
 
42
43
  ## Step 1:需求名与布局确认
43
44
 
44
- 1. 对话中询问需求名(kebab-case,同时作分支名;禁 `..` / 路径分隔符 / 空格)
45
+ 1. 对话中询问需求名(kebab-case,同时作分支名;禁 `..` / 路径分隔符 / 空格):直接给名,或输入需求描述由 agent 推荐 3–5 个英文候选(`AskUserQuestion` 选定;不满意可直接回复自己的名字)
45
46
  2. 从需求语境提取**涉及的应用子集**(改哪些 `.codespace/<app>`);纯 workspace 内容则仅根参与。展示布局向用户确认:
46
47
 
47
48
  ```
@@ -73,18 +74,49 @@ node "<skill_dir>/scripts/start-local.js" "<ws_root>" "<branch>" --apps "<app1>,
73
74
 
74
75
  ## Step 4:提交推送(转 cbb-worktree-push)
75
76
 
76
- 对话中说"提交并推送"即触发 `cbb-worktree-push`——其仓库发现已支持**本地布局**(根 + 持有同名分支的 `.codespace/<app>/`),dry-run 预览(含 agent 生成的 commit 信息)→ 批量 commit → push → MR 预填链接,全流程与 worktree 模式一致。
77
+ 对话中说"提交并推送"即触发 `cbb-worktree-push`——其仓库发现已支持**本地布局**(根 + 持有同名分支的 `.codespace/<app>/`),dry-run 预览(含 agent 生成的 commit 信息)→ 批量 commit → push → Step 7 交付选择(提 MR 预填链接 / 直接合并 / 暂不处理,ADR 0008),全流程与 worktree 模式一致。
77
78
 
78
- ## Step 5:收尾(MR 合并后)
79
+ ## Step 5:收尾(收尾合并门禁 · ADR 0007)
79
80
 
80
- MR 合并、验收通过后回到本会话收尾(各参与仓库):
81
+ MR 合并、验收通过后回到本会话收尾。**与 worktree 模式同门禁**:每个参与仓库(空间根 + 各参与 `.codespace/<app>/`)必须为"已合并"。
82
+
83
+ **5.1 交付前置**:未提交改动 → 先提交;未归档 change → 先归档并提交(**合并前必须已归档 + 已提交**)。
84
+
85
+ **5.2 判定是否已合并**(复用共享判定,算法与 close 的 `safety-check` 同源):
86
+
87
+ ```bash
88
+ node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<需求名>" --apps "<app1,app2>" --check
89
+ ```
90
+
91
+ - 解读末行 `MERGE_CHECK_RESULT`:每仓库 `merged`(含 `origin/main` / `main-via-release-*`)/ `unmerged` / `no-branch`
92
+ - 全部 `merged` / `no-branch`(退出码 0)→ 直接进 5.4
93
+ - 存在 `unmerged`(退出码 2)→ 进 5.3
94
+
95
+ **5.3 门禁三选一**(`AskUserQuestion`,同 close Step 3.4):
96
+
97
+ - **提 MR** → **中止收尾**,引导用户运行 `cbb-worktree-push`(说"提交并推送")生成 MR 预填链接(其 Step 7:目标分支候选交互 + `make-mr-urls.js`)→ 合并完重跑本步
98
+ - **直合并** → `merge-branches.js`(本地检出被需求分支占用,**必须加 `--allow-checkout`** 切回主干):
99
+
100
+ ```bash
101
+ node "<skill_dir>/scripts/merge-branches.js" "<ws_root>" "<需求名>" --apps "<app1,app2>" --allow-checkout
102
+ ```
103
+
104
+ `need-mr`(远端主干受保护)→ 改用提 MR;`failed` → 输出明细、修复后重跑
105
+ - **确认废弃** → `deprecate-branches.js`(远程重命名 `<需求名>-deprecated`;本地分支保留原名)
106
+
107
+ **5.4 交付完成 → 各仓库回主干并删分支**:
81
108
 
82
109
  ```bash
83
110
  git -C "<仓库>" checkout main && git -C "<仓库>" pull && git -C "<仓库>" branch -d "<需求名>"
84
111
  ```
85
112
 
113
+ - 各仓库本地分支删除后,远程分支删除由共享脚本完成(删除前输出将删清单):
114
+
115
+ ```bash
116
+ node "<skill_dir>/scripts/delete-branches.js" "<ws_root>" "<需求名>" local_remote
117
+ ```
86
118
  - `checkout` 被 hook 拦(说明 main 落后或本地 main 有分叉)→ 先 `pull --ff-only`,再删分支
87
- - openspec change 归档(`/opsx:archive`)在合并前按 worktree 模式同款纪律执行(产物在分支上随 MR 合入)
119
+ - openspec change 归档在合并前执行(产物在分支上随交付合入)
88
120
  - 全部仓库回 main 后向用户报告"本地模式已收尾,空间回到干净态"
89
121
 
90
122
  ## 升级 worktree(中途要并行 / 要隔离)
@@ -98,13 +130,13 @@ git -C "<仓库>" checkout main && git -C "<仓库>" pull && git -C "<仓库>" b
98
130
  | 场景 | 处理方式 |
99
131
  |------|----------|
100
132
  | 任何触发 | 先跑 Step 0 状态自检(clean 零交互 / active 用户四选一) |
101
- | 需求名 | 对话自由文本输入(禁 AskUserQuestion 收集自由文本) |
133
+ | 需求名 | 对话输入:直接给名,或给需求描述(agent 推荐 3–5 个英文候选供选);自由文本本身禁走 AskUserQuestion |
102
134
  | 涉及应用 | 需求语境提取子集,`--apps` 传入;中途补齐重跑(幂等) |
103
135
  | 切分支失败(dirty 冲突) | 报告详情,用户处理;禁止自动 stash |
104
136
  | commit 被 hook 拦(main 载荷) | 大声报错是预期行为:回分支重做;禁止自动 --no-verify |
105
137
  | 误在 main 检出上写了文件 | `git checkout -b <需求名>` 后改动随分支走;已 staged 则 unstage 后再切 |
106
138
  | 提交推送 | 说"提交并推送" → cbb-worktree-push(本地布局自动发现) |
107
- | 收尾 | 各仓库 checkout main + pull + 删分支;openspec 归档随 MR 合入 |
139
+ | 收尾 | 收尾合并门禁(ADR 0007):`merge-branches.js --check` 判已合并 → 全合并则各仓库 checkout main + pull + 删分支;否则三选一(MR / 直合并 `--allow-checkout` / 确认废弃) |
108
140
  | 要并行新需求 | 转 worktree 模式(init 分支从 origin/main 建,不占本地检出);本地需求继续占用根 |
109
141
  | 升级隔离 | 重跑 cbb-worktree-init(提交随身带走);先回 main 腾检出 |
110
142
 
@@ -148,11 +180,15 @@ git -C "<仓库>" checkout main && git -C "<仓库>" pull && git -C "<仓库>" b
148
180
  - 自动 stash 兜底切换失败(用户决策)
149
181
  - 同时承载第二个本地需求(并行走 worktree)
150
182
  - 把 `.codespace/<app>/` 的 main 检出当开发区(基准快照,本地模式只在分支上开发)
183
+ - **未合并且未确认为废弃时收尾**(违反 ADR 0007 硬门禁)
184
+ - 未经二次确认执行废弃重命名(远程重命名不可逆)
151
185
 
152
186
  **必须执行**
153
187
 
154
188
  - Step 0 自检 → clean 才开工
155
189
  - 布局展示 + 用户确认后才切分支
156
190
  - 切分支统一走 `start-local.js`(基线 = `<remote>/<main>`,不取 HEAD)
191
+ - 收尾先过门禁判定(`merge-branches.js --check`);未合并必须三选一后收尾
192
+ - 直合并走 `merge-branches.js --allow-checkout`(本地检出需从需求分支切回主干)
157
193
  - 收尾三连(checkout main / pull / 删分支)完成后报告干净态
158
194
  - 所有写操作前的分支确认(当前检出 = 需求分支)