@jspg-ai/coding-bb 0.0.3-beta.31 → 0.0.3-beta.33

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 (25) hide show
  1. package/cbb/dev-standards/rules/cbb-ai-behavior.md +5 -4
  2. package/cbb/lib/install/claude-code.js +3 -3
  3. package/cbb/lib/install/init.js +55 -23
  4. package/cbb/lib/install/minimax.js +2 -2
  5. package/cbb/lib/install/tool-selection.js +8 -30
  6. package/cbb/lib/utils/githook.js +6 -2
  7. package/cbb/worktrees/_shared/scripts/check-unarchived.js +77 -0
  8. package/cbb/worktrees/_shared/scripts/deprecate-branches.js +119 -0
  9. package/cbb/worktrees/_shared/scripts/install-git-hook.js +1 -1
  10. package/cbb/worktrees/_shared/scripts/merge-branches.js +469 -0
  11. package/cbb/worktrees/skills/cbb-local-mode/SKILL.md +38 -6
  12. package/cbb/worktrees/skills/cbb-local-mode/scripts/deprecate-branches.js +119 -0
  13. package/cbb/worktrees/skills/cbb-local-mode/scripts/install-git-hook.js +1 -1
  14. package/cbb/worktrees/skills/cbb-local-mode/scripts/merge-branches.js +469 -0
  15. package/cbb/worktrees/skills/cbb-worktree-close/SKILL.md +86 -51
  16. package/cbb/worktrees/skills/cbb-worktree-close/scripts/check-unarchived.js +3 -1
  17. package/cbb/worktrees/skills/cbb-worktree-close/scripts/delete-branches.js +4 -2
  18. package/cbb/worktrees/skills/cbb-worktree-close/scripts/deprecate-branches.js +119 -0
  19. package/cbb/worktrees/skills/cbb-worktree-close/scripts/merge-branches.js +469 -0
  20. package/cbb/worktrees/skills/cbb-worktree-close/scripts/safety-check.js +4 -2
  21. package/cbb/worktrees/skills/cbb-worktree-init/scripts/install-git-hook.js +1 -1
  22. package/cbb/worktrees/skills/cbb-worktree-push/SKILL.md +143 -20
  23. package/cbb/worktrees/skills/cbb-worktree-push/scripts/check-unarchived.js +77 -0
  24. package/config/openspec/schemas/spec-driven/schema.yaml +1 -0
  25. package/package.json +2 -2
@@ -107,7 +107,7 @@ trigger: always_on
107
107
 
108
108
  - 就地切分支:根 + 各涉及应用 `.codespace/<app>/` 同名 `checkout -b`(基线 = `origin/<main>`,经 `cbb-local-mode` 技能执行)
109
109
  - 当前会话原地开发,相对路径原样生效;**一次只承载一个需求**(独占本地检出;期间并行新需求走 worktree 模式,互不干扰)
110
- - 收尾:MR 合并后各参与仓库 `checkout main && pull && 删分支`
110
+ - 收尾(收尾合并门禁,ADR 0007):`merge-branches.js --check` 判定已合并;未合并须三选一(提 MR / 直合并 `--allow-checkout` / 确认废弃)后方可收尾;各参与仓库 `checkout main && pull && 删分支`
111
111
 
112
112
  **模式二 · worktree 模式(并行 / 隔离):**
113
113
 
@@ -117,8 +117,9 @@ trigger: always_on
117
117
  **基准与防线(两模式共同,ADR 0002):**
118
118
 
119
119
  - **基准 = `origin/main`(唯一真相源);`.codespace/<app>/` 的检出 = 视图**——本地模式可暂态被需求分支占用,收尾恢复
120
- - **严禁直接在任何 main 检出上提交载荷**:分支感知 pre-commit hook 机械拦截(workspace 仓库仅放行控制面白名单:`CBB-WORKSPACE.json` / `.gitignore` / `AGENTS.md` / `openspec/config.yaml` / `openspec/schemas/**`;应用仓库 main 全拦)——**被拦是防线生效,不是故障**;`--no-verify` 绕过仅限用户显式要求
121
- - 远端推送唯一出口 = `cbb-worktree-push`(init 创建流程零远端操作,lazy push;空分支尸体不存在)
120
+ - **严禁直接在任何 main 检出上提交载荷**:分支感知 pre-commit hook 机械拦截(workspace 仓库仅放行控制面白名单:`CBB-WORKSPACE.json` / `.gitignore` / `AGENTS.md` / `openspec/config.yaml` / `openspec/schemas/**`;应用仓库 main 全拦;**merge 提交(`MERGE_HEAD` 存在)放行**,见 ADR 0007)——**被拦是防线生效,不是故障**;`--no-verify` 绕过仅限用户显式要求
121
+ - 需求分支推送唯一出口 = `cbb-worktree-push`(init 创建流程零远端操作,lazy push);推送后可选提 MR 或**直接合并**(`merge-branches.js`,主干 / 测试分支,ADR 0008);收尾合并门禁同源(ADR 0007)
122
+ - **收尾合并门禁(ADR 0007)**:两模式收尾均须每仓库"已合并"(`safety-check` / `merge-branches.js --check` 判定);未合并三选一——提 MR / 直合并 / 确认废弃(远程重命名 `<分支>-deprecated`);交付完成自动删本地+远程分支
122
123
  - **模式误判零代价**:本地模式中途要并行/隔离 → 重跑 `cbb-worktree-init`(分支已存在自动复用,提交随身带走)
123
124
 
124
125
  **三时刻对齐(agent 必须遵守):**
@@ -129,7 +130,7 @@ trigger: always_on
129
130
 
130
131
  **读基准代码约定:** 本地模式进行中,工作区是开发态而非基准——要读"main 现状"先确认检出分支,或直接 `git show main:<path>` / `git diff main`;不要默认工作区即基准。
131
132
 
132
- **检验标准:** 任何 main 检出上无载荷提交(hook 兜底,git log 可机械审计);本地模式收尾后全部仓库回主干且 clean;worktree 模式的变更产物只出现在需求工作树内;远端分支只经 push 技能产生。
133
+ **检验标准:** 任何 main 检出上无载荷提交(hook 兜底,git log 可机械审计;merge 提交除外);本地模式收尾后全部仓库回主干且 clean;worktree 模式的变更产物只出现在需求工作树内;需求分支只经 push 技能产生;**未合并且非废弃的分支无法通过收尾(门禁兜底)**。
133
134
 
134
135
  ---
135
136
 
@@ -4,8 +4,8 @@
4
4
  * 将编码规范分发到 .claude/rules/ 目录
5
5
  *
6
6
  * 退市(selectable: false):团队当前不使用 Claude Code,不在安装面板提供。
7
- * 适配器代码与全部能力保留(含用户级 settings hook);先前已安装的空间按
8
- * .last-tools 记录**粘性维护**(见 tool-selection.js),内容不被清理。
7
+ * 适配器代码与全部能力保留(含用户级 settings hook);旧记录中的遗留内容
8
+ * 不在勾选范围时随清单差集与用户级差集清理移除(ADR 0006)。
9
9
  * 恢复入口:删掉 selectable 即可重新出现在面板(决策:不留参数开关)。
10
10
  */
11
11
 
@@ -22,6 +22,6 @@ module.exports = {
22
22
  settingsFile: path.join(os.homedir(), '.claude', 'settings.json'),
23
23
  settingsFileIsUserLevel: true,
24
24
  hookEvents: ['UserPromptSubmit'],
25
- // 退市:不在安装面板提供(代码保留,存量空间粘性维护)
25
+ // 退市:不在安装面板提供(代码保留;旧记录遗留随清单/用户级差集清理,ADR 0006)
26
26
  selectable: false,
27
27
  };
@@ -105,7 +105,7 @@ const agentsNav = require('./agents-nav');
105
105
  // 命令 → skill 形态转换(commandsAsSkills 适配器,当前仅 MiniMax Code)
106
106
  const commandSkill = require('./command-skill');
107
107
 
108
- // 工具选择策略(退市工具不在面板提供 + 存量粘性保留)
108
+ // 工具选择策略(退市工具不在面板提供;勾选范围即权威,ADR 0006)
109
109
  const toolSelection = require('./tool-selection');
110
110
 
111
111
  /** 适配器的 hook 方言参数(Trae 需顶层 version 字段等差异) */
@@ -384,10 +384,7 @@ async function selectTools(isUpgrade) {
384
384
  .map(i => selectable[i]);
385
385
  }
386
386
 
387
- // 退市但该空间旧记录里有的工具:粘性保留(继续维护,不因退市清删其内容)
388
- result = toolSelection.carryOverRetired(adapters, result, lastTools);
389
-
390
- // 保存本次选择(含粘性保留项,避免记录丢失导致下次被清理),供下次运行时预选
387
+ // 保存本次选择(勾选范围即权威,供下次运行时预选)
391
388
  saveLastTools(result);
392
389
  return result;
393
390
  }
@@ -1054,7 +1051,8 @@ async function init(targetDir, options = {}) {
1054
1051
  fromRecord = true;
1055
1052
  } else if (isNonInteractive) {
1056
1053
  const lastTools = readLastTools();
1057
- const reused = lastTools ? adapters.filter(a => lastTools.has(a.name)) : [];
1054
+ // 旧记录只用于预选可选项——历史遗留的退市工具不并回(ADR 0006:勾选范围即权威)
1055
+ const reused = lastTools ? toolSelection.selectableAdapters(adapters).filter(a => lastTools.has(a.name)) : [];
1058
1056
  selectedAdapters = reused.length > 0 ? reused : toolSelection.selectableAdapters(adapters);
1059
1057
  fromRecord = reused.length > 0;
1060
1058
  if (yes && process.stdin.isTTY) {
@@ -1066,19 +1064,10 @@ async function init(targetDir, options = {}) {
1066
1064
  selectedAdapters = await selectTools(isUpgrade);
1067
1065
  }
1068
1066
 
1069
- // 退市工具粘性保留(面板不出现,但本空间旧记录里有 → 继续维护,内容不被清理)。
1070
- // 覆盖面板外入口:setup 预设(presetAdapters)与非交互记录沿用;交互面板已在 selectTools 内并过。
1071
- const lastToolsForDir = readLastTools();
1072
- const withRetired = toolSelection.carryOverRetired(adapters, selectedAdapters, lastToolsForDir);
1073
- const retiredKept = toolSelection.retiredNames(withRetired);
1074
- if (retiredKept.length > 0) {
1075
- log(`保留上次选择的退市工具:${retiredKept.join('、')}(当前不在安装面板提供,内容继续维护)`, 'dim');
1076
- }
1077
- selectedAdapters = withRetired;
1067
+ // 退市工具不粘性保留(ADR 0006):选择集即权威,旧记录里的退市工具随清单差集清理
1078
1068
 
1079
- // 业务空间根记录本次选择,供 cbb update 等非交互场景沿用;
1080
- // 沿用旧记录时同样落盘,使历史工具名(codebuddy → workbuddy)归一化自愈
1081
- if (isWorkspaceRoot && fromRecord) {
1069
+ // 本次选择落盘(沿用旧记录时同样落盘:归一化历史工具名 + 剔除退市遗留记录)
1070
+ if (fromRecord) {
1082
1071
  saveLastTools(selectedAdapters);
1083
1072
  }
1084
1073
 
@@ -1871,8 +1860,10 @@ function printSetupSummary(selected, selectedAdapters, parentDir) {
1871
1860
  /**
1872
1861
  * 安装用户级组件(与项目无关的内容,一次安装全局可用,幂等)。
1873
1862
  *
1874
- * - 清单:~/.cbb/.user-components 记录本包管理的用户级路径
1863
+ * - 清单:~/.cbb/.user-components 只记录**本次实装**的用户级路径
1875
1864
  * - 内容:check-update hook(settings 由 settingsManager 幂等管理)
1865
+ * - 差集清理(ADR 0006):不在本次目标集合内的适配器,其清单遗留路径、
1866
+ * 本包版本检查 hook、本地插件一并移除(仅删本包标记项,不动用户自装内容)
1876
1867
  */
1877
1868
  const USER_MANIFEST_FILE = path.join(os.homedir(), '.cbb', '.user-components');
1878
1869
  // 装到用户级的工具 skills(与具体项目无关,任何会话都可能用到)。
@@ -1884,15 +1875,18 @@ async function setupUserLevelComponents(targetAdapters = toolSelection.selectabl
1884
1875
  const srcDir = path.join(SHARED_STANDARDS_DIR, 'cbb', 'tools');
1885
1876
  if (!fs.existsSync(srcDir)) return;
1886
1877
 
1887
- const managed = new Set();
1878
+ // 旧清单仅作差集清理基准,不再并入 managed(ADR 0006:勾选范围即权威,
1879
+ // 本次未实装的路径由下方差集移除,而非累积保留)
1880
+ let oldPaths = [];
1888
1881
  const manifestDir = path.dirname(USER_MANIFEST_FILE);
1889
1882
  fs.mkdirSync(manifestDir, { recursive: true });
1890
1883
  if (fs.existsSync(USER_MANIFEST_FILE)) {
1891
1884
  try {
1892
1885
  const data = JSON.parse(fs.readFileSync(USER_MANIFEST_FILE, 'utf-8'));
1893
- (data.paths || []).forEach(p => managed.add(p));
1886
+ oldPaths = data.paths || [];
1894
1887
  } catch (_) { /* ignore parse errors */ }
1895
1888
  }
1889
+ const managed = new Set();
1896
1890
 
1897
1891
  const summaryParts = [];
1898
1892
 
@@ -1939,6 +1933,43 @@ async function setupUserLevelComponents(targetAdapters = toolSelection.selectabl
1939
1933
  summaryParts.push(`版本检查插件 → ${pluginHookAdapters.map(a => a.displayName).join('、')}`);
1940
1934
  }
1941
1935
 
1936
+ // ── 用户级差集清理(ADR 0006:不在本次勾选范围内的工具,用户级组件一并移除)──
1937
+ const targetNames = new Set(targetAdapters.map(a => a.name));
1938
+
1939
+ // 1) 清单路径差集:旧有新无 → 删除(仅本包管理路径,不碰用户自装内容)
1940
+ const removedPaths = [];
1941
+ oldPaths.forEach(relPath => {
1942
+ if (!managed.has(relPath)) {
1943
+ const target = path.join(home, relPath);
1944
+ if (fs.existsSync(target)) {
1945
+ removePath(target);
1946
+ removedPaths.push(relPath);
1947
+ }
1948
+ }
1949
+ });
1950
+ if (removedPaths.length > 0) {
1951
+ summaryParts.push(`移除清单遗留 ×${removedPaths.length}`);
1952
+ }
1953
+
1954
+ // 2) 非目标适配器:摘除本包版本检查 hook(仅删 _managedBy: cbb 标记项,保留用户配置)
1955
+ // + 本地插件目录
1956
+ const removedNames = [];
1957
+ adapters.filter(a => !targetNames.has(a.name)).forEach(adapter => {
1958
+ let touched = false;
1959
+ if (adapter.settingsFile && adapter.hookEvents) {
1960
+ if (settingsManager.removeHooks(adapter.settingsFile, adapter.hookEvents, hookOpts(adapter))) touched = true;
1961
+ if (cleanLegacySettings(adapter)) touched = true;
1962
+ }
1963
+ if (adapter.pluginHook &&
1964
+ pluginHookManager.remove({ dataDir: adapter.resolveDataDir(), ...adapter.pluginHook })) {
1965
+ touched = true;
1966
+ }
1967
+ if (touched) removedNames.push(adapter.displayName);
1968
+ });
1969
+ if (removedNames.length > 0) {
1970
+ summaryParts.push(`清理 ${removedNames.join('、')} 的用户级组件`);
1971
+ }
1972
+
1942
1973
  if (summaryParts.length > 0) step('✓', '用户级组件', summaryParts.join(' · '));
1943
1974
 
1944
1975
  const pkg = require(path.join(SHARED_STANDARDS_DIR, 'package.json'));
@@ -1955,9 +1986,10 @@ async function setupUserLevelComponents(targetAdapters = toolSelection.selectabl
1955
1986
  * worktree 管理入口与空间导航;旧子集清单升级时由清单差集机制自动补齐全量内容。
1956
1987
  */
1957
1988
  async function updateInstalled() {
1958
- // 用户级组件沿用本目录的工具选择(.last-tools),无记录或为空才回退全量
1989
+ // 用户级组件沿用本目录的工具选择(.last-tools),无记录或为空才回退全量。
1990
+ // 旧记录只预选可选项——历史遗留的退市工具不并回(ADR 0006)
1959
1991
  const lastTools = readLastTools();
1960
- const reused = lastTools ? adapters.filter(a => lastTools.has(a.name)) : [];
1992
+ const reused = lastTools ? toolSelection.selectableAdapters(adapters).filter(a => lastTools.has(a.name)) : [];
1961
1993
  await setupUserLevelComponents(reused.length > 0 ? reused : toolSelection.selectableAdapters(adapters));
1962
1994
 
1963
1995
  const manifest = readManifest();
@@ -19,7 +19,7 @@
19
19
  * 退市(selectable: false):实测 MiniMax Code 不支持目录级 skill 与 command
20
20
  * (无项目级 commands 目录,skill 亦需插件形态承载),当前分发形态不可用。
21
21
  * 适配器代码与全部能力保留(备将来改走插件形态),但不在安装面板提供;
22
- * 已安装过(beta.29)的空间按 .last-tools 记录**粘性维护**,内容不被清理。
22
+ * 旧记录中的遗留内容不在勾选范围时随清单差集与用户级差集清理移除(ADR 0006)。
23
23
  */
24
24
 
25
25
  const path = require('path');
@@ -55,6 +55,6 @@ module.exports = {
55
55
  timeout: 10,
56
56
  },
57
57
  resolveDataDir,
58
- // 退市:不在安装面板提供(代码保留,存量空间粘性维护)
58
+ // 退市:不在安装面板提供(代码保留;旧记录遗留随清单/用户级差集清理,ADR 0006)
59
59
  selectable: false,
60
60
  };
@@ -1,20 +1,18 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * 工具选择策略:安装面板的可见集合 + 退市工具的粘性保留。
4
+ * 工具选择策略:安装面板的可见集合(未退市适配器)。
5
5
  *
6
6
  * 背景(2026-09-28 决策):MiniMax Code 实测不支持目录级 skill 与 command、
7
7
  * Claude Code 团队当前未用,两者**退市**——适配器与全部能力保留(见各适配器
8
8
  * 文件头),仅不在 `cbb setup` / `cbb update` 的安装面板提供。
9
9
  *
10
- * 两条规则:
11
- * 1. **可选项**(selectableAdapters):面板项、非交互全选兜底、setup 默认预选、
12
- * 用户级组件新装,一律只用未退市适配器(`selectable !== false`);
13
- * 2. **粘性保留**(carryOverRetired):某空间的 `.last-tools` 记录过退市工具
14
- * (即确曾安装),则继续纳入本次安装/升级集合——面板不出现,内容不因退市
15
- * 被清单差集清删;`.last-tools` 一并保持该记录,避免记录丢失导致下次清理。
16
- *
17
- * 恢复入口:删掉适配器的 `selectable: false` 即回到面板(决策:不留参数开关)。
10
+ * 退市工具的取舍(ADR 0006,反转 beta.30 的粘性保留决策):
11
+ * - 退市工具**不进**选择集:面板、非交互沿用、setup 预设、用户级组件,
12
+ * 一律只用未退市适配器(`selectable !== false`);
13
+ * - 勾选范围即权威:`.last-tools` 只如实记录本次实际选择,不在范围内的
14
+ * 工具内容(含用户级 hook / 插件)由清单差集与用户级差集清理移除;
15
+ * - 恢复入口:删掉适配器的 `selectable: false` 即回到面板。
18
16
  */
19
17
 
20
18
  /** 面板/默认可选适配器(未标 selectable: false 的全部) */
@@ -22,24 +20,4 @@ function selectableAdapters(adapters) {
22
20
  return adapters.filter(a => a.selectable !== false);
23
21
  }
24
22
 
25
- /**
26
- * 把「本空间旧记录中的退市工具」并入选择结果(按注册表顺序追加,去重)。
27
- * @param {Array} adapters 注册表全量
28
- * @param {Array} selected 本次选择(面板 / 预设 / 记录沿用)
29
- * @param {Set<string>|null} lastTools 该空间的旧工具记录(.last-tools)
30
- * @returns {Array} 并入后的有序数组(无退市工具需保留时原样返回)
31
- */
32
- function carryOverRetired(adapters, selected, lastTools) {
33
- if (!lastTools || lastTools.size === 0) return selected;
34
- const selectedNames = new Set(selected.map(a => a.name));
35
- const carried = adapters.filter(a =>
36
- a.selectable === false && lastTools.has(a.name) && !selectedNames.has(a.name));
37
- return carried.length > 0 ? selected.concat(carried) : selected;
38
- }
39
-
40
- /** 结果集中退市工具的显示名(供安装输出提示,如「保留上次选择的 Claude Code」) */
41
- function retiredNames(selected) {
42
- return selected.filter(a => a.selectable === false).map(a => a.displayName);
43
- }
44
-
45
- module.exports = { selectableAdapters, carryOverRetired, retiredNames };
23
+ module.exports = { selectableAdapters };
@@ -9,7 +9,8 @@
9
9
  * CBB-WORKSPACE.json / .gitignore / AGENTS.md / openspec/config.yaml /
10
10
  * openspec/schemas/** / .cbb/**(配置变更与 cbb update 部署物,直改即推);
11
11
  * 另放行旧名 workspace-config.json 的删除条目(仅用于一次性更名迁移)
12
- * - 应用仓库在主干上 → 全拦(交付只走 MR)
12
+ * - 主干上处于 merge 进行中(MERGE_HEAD 存在)→ 放行(ADR 0007 合并门禁的直合并通道)
13
+ * - 应用仓库在主干上 → 全拦(交付只走 MR;merge 进行中的合并提交除外)
13
14
  * - 逃生口:用户显式 git commit --no-verify(AI 侧纪律禁止自动绕过)
14
15
  *
15
16
  * 本模块是 hook 源码的唯一真源;cbb/worktrees/_shared/scripts/install-git-hook.js
@@ -32,7 +33,7 @@ const BACKUP_SUFFIX = '.cbb-backup';
32
33
  const PRE_COMMIT_SOURCE = [
33
34
  '#!/usr/bin/env node',
34
35
  '// managed-by-cbb v1 —— 分支感知提交防线(ADR 0002)',
35
- '// main 上:workspace 仓库只放行控制面白名单,应用仓库全拦;非 main 一律放行。',
36
+ '// main 上:workspace 只放行控制面白名单,应用全拦(merge 提交除外);非 main 一律放行。',
36
37
  '// 绕过逃生口仅限用户显式 git commit --no-verify。',
37
38
  "const { execSync } = require('child_process');",
38
39
  "const fs = require('fs');",
@@ -56,6 +57,9 @@ const PRE_COMMIT_SOURCE = [
56
57
  '// 非主干(含 detached / 探测失败)→ 放行',
57
58
  'if (!main || branch !== main) process.exit(0);',
58
59
  '',
60
+ '// merge 进行中(MERGE_HEAD 存在)→ 显式人工合并提交,放行(ADR 0007 合并门禁)',
61
+ "if (run('git rev-parse --verify --quiet MERGE_HEAD')) process.exit(0);",
62
+ '',
59
63
  "// 主干上的提交:收集 staged 变更(空提交放行)",
60
64
  "const staged = run('git diff --cached --name-only') || '';",
61
65
  "const files = staged.split(/\\r?\\n/).filter(Boolean);",
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ require('./silence-popup');
3
+ /**
4
+ * 未归档 change 检查(共享脚本,真源:cbb/worktrees/_shared/scripts/)
5
+ *
6
+ * 供 cbb-worktree-close(收尾安全评估)与 cbb-worktree-push(Step 7 直合并前置,ADR 0008)调用。
7
+ *
8
+ * 用法:node check-unarchived.js <ws_root> [branch]
9
+ *
10
+ * 双扫(ADR 0002):
11
+ * - <ws_root>/openspec/changes/ → 空间根当前检出(本地模式产物 / 空间根遗留)
12
+ * - <ws_root>/.worktrees/worktree-<branch>/openspec/changes/ → worktree 模式产物
13
+ * (branch 未传时跳过 worktree 扫描,保持旧调用兼容)
14
+ *
15
+ * 输出:changes 为两处合集(去重);detail 标注每个 change 的来源
16
+ * ('root' / 'worktree',同名两处共存时为 'both')。
17
+ * 本检查为信息收集性质,不阻断流程。
18
+ *
19
+ * 退出码:
20
+ * 0 - 检查完成(无论是否有未归档 change)
21
+ * 1 - 参数错误
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const wsRoot = process.argv[2];
28
+ const branch = process.argv[3];
29
+
30
+ if (!wsRoot) {
31
+ console.error('Usage: node check-unarchived.js <ws_root> [branch]');
32
+ process.exit(1);
33
+ }
34
+
35
+ /**
36
+ * 扫描单个 changes 目录,返回未归档 change 名数组(目录不存在返回 [])。
37
+ */
38
+ function scanDir(changesDir) {
39
+ if (!fs.existsSync(changesDir)) return [];
40
+ let entries;
41
+ try {
42
+ entries = fs.readdirSync(changesDir, { withFileTypes: true });
43
+ } catch (e) {
44
+ console.error('Failed to read ' + changesDir + ':', e.message);
45
+ return [];
46
+ }
47
+ return entries
48
+ .filter(e => e.isDirectory() && e.name !== 'archive')
49
+ .map(e => e.name);
50
+ }
51
+
52
+ const rootChanges = scanDir(path.join(wsRoot, 'openspec', 'changes'));
53
+
54
+ const worktreeDir = branch
55
+ ? path.join(wsRoot, '.worktrees', 'worktree-' + branch, 'openspec', 'changes')
56
+ : null;
57
+ const wtChanges = worktreeDir ? scanDir(worktreeDir) : [];
58
+
59
+ const detail = {};
60
+ for (const name of rootChanges) detail[name] = 'root';
61
+ for (const name of wtChanges) {
62
+ detail[name] = detail[name] === 'root' ? 'both' : 'worktree';
63
+ }
64
+
65
+ const changes = Object.keys(detail).sort();
66
+
67
+ if (rootChanges.length === 0 && wtChanges.length === 0) {
68
+ console.log('ℹ️ 未发现未归档 change(根与 worktree' + (worktreeDir ? '' : '(未指定 branch,跳过)') + '均无)');
69
+ } else {
70
+ console.log('ℹ️ 未归档 change:' + (changes.join(', ') || '无'));
71
+ for (const name of changes) {
72
+ console.log(' - ' + name + '(来源:' + detail[name] + ')');
73
+ }
74
+ }
75
+
76
+ console.log('UNARCHIVED_RESULT', JSON.stringify({ changes, detail }));
77
+ process.exit(0);
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ require('./silence-popup');
3
+ /**
4
+ * 收尾合并门禁 —— 废弃分支出口(ADR 0007)
5
+ *
6
+ * 用户在门禁处确认"分支废弃、不再交付"后,把各参与仓库远端的需求分支重命名为
7
+ * <branch>-deprecated(保留历史、去掉占用中的旧名),**不删除任何提交**。
8
+ * 本地分支保持原名不动;未推送的仓库(远端无该分支)跳过。
9
+ *
10
+ * 用法:
11
+ * node deprecate-branches.js <ws_root> <branch> [--apps a,b]
12
+ *
13
+ * 退出码:0 全部成功(含 skipped)/ 1 参数错误 / 2 存在失败
14
+ * 末行:DEPRECATE_RESULT [...]
15
+ */
16
+
17
+ const { execSync } = require('child_process');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ function run(cmd, cwd) {
22
+ try {
23
+ return {
24
+ ok: true,
25
+ out: execSync(cmd, {
26
+ cwd,
27
+ encoding: 'utf-8',
28
+ stdio: ['ignore', 'pipe', 'pipe'],
29
+ timeout: 120000,
30
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
31
+ }).trim(),
32
+ };
33
+ } catch (e) {
34
+ return { ok: false, err: (e.stderr || e.message || String(e)).trim() };
35
+ }
36
+ }
37
+
38
+ function loadApps(wsRoot) {
39
+ try {
40
+ const config = JSON.parse(fs.readFileSync(path.join(wsRoot, 'CBB-WORKSPACE.json'), 'utf-8'));
41
+ return Array.isArray(config.apps) ? config.apps : [];
42
+ } catch (e) {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ /** 对单个仓库执行废弃重命名 */
48
+ function deprecateRepo(name, repo, branch) {
49
+ const newName = branch + '-deprecated';
50
+ const rec = { name, status: 'skipped', detail: null };
51
+ if (!fs.existsSync(path.join(repo, '.git'))) {
52
+ rec.detail = 'no-repo';
53
+ return rec;
54
+ }
55
+ const remote = run('git remote', repo);
56
+ if (!remote.ok || !remote.out) {
57
+ rec.detail = 'no-remote';
58
+ return rec;
59
+ }
60
+ const r = remote.out.split(/\r?\n/)[0];
61
+ // 仅重命名远端确实存在的分支
62
+ const exists = run('git ls-remote --heads "' + r + '" "refs/heads/' + branch + '"', repo);
63
+ if (!exists.ok || !exists.out) {
64
+ rec.detail = 'remote-has-no-branch';
65
+ return rec;
66
+ }
67
+ const pushNew = run('git push "' + r + '" "refs/heads/' + branch + ':refs/heads/' + newName + '"', repo);
68
+ if (!pushNew.ok) {
69
+ rec.status = 'failed';
70
+ rec.detail = 'push-new-failed: ' + (pushNew.err || '').split(/\r?\n/)[0];
71
+ return rec;
72
+ }
73
+ const delOld = run('git push "' + r + '" --delete "' + branch + '"', repo);
74
+ if (!delOld.ok) {
75
+ rec.status = 'failed';
76
+ rec.detail = 'delete-old-failed: ' + (delOld.err || '').split(/\r?\n/)[0];
77
+ return rec;
78
+ }
79
+ rec.status = 'deprecated';
80
+ rec.detail = 'remote ' + branch + ' → ' + newName;
81
+ return rec;
82
+ }
83
+
84
+ function main() {
85
+ const args = process.argv.slice(2);
86
+ const wsRoot = args[0];
87
+ const branch = args[1];
88
+
89
+ if (!wsRoot || !branch || branch.indexOf('--') === 0) {
90
+ console.error('Usage: node deprecate-branches.js <ws_root> <branch> [--apps a,b]');
91
+ process.exit(1);
92
+ }
93
+
94
+ const appsIdx = args.indexOf('--apps');
95
+ const appFilter = appsIdx >= 0
96
+ ? new Set((args[appsIdx + 1] || '').split(',').map(s => s.trim()).filter(Boolean))
97
+ : null;
98
+
99
+ const repos = [{ name: 'workspace', repo: wsRoot }];
100
+ for (const app of loadApps(wsRoot)) {
101
+ if (appFilter && !appFilter.has(app.name)) continue;
102
+ repos.push({ name: app.name, repo: path.join(wsRoot, '.codespace', app.name) });
103
+ }
104
+
105
+ const results = repos.map(rp => deprecateRepo(rp.name, rp.repo, branch));
106
+
107
+ results.forEach(r => {
108
+ console.log(r.status.toUpperCase() + ' ' + r.name + (r.detail ? ' — ' + r.detail : ''));
109
+ });
110
+
111
+ const ok = results.every(r => r.status !== 'failed');
112
+ console.log('---');
113
+ console.log('DEPRECATE_RESULT', JSON.stringify({ branch, new_name: branch + '-deprecated', results }));
114
+ process.exit(ok ? 0 : 2);
115
+ }
116
+
117
+ if (require.main === module) main();
118
+
119
+ module.exports = { run, loadApps, deprecateRepo };
@@ -20,7 +20,7 @@ const HOOK_FILE = 'pre-commit';
20
20
  const BACKUP_SUFFIX = '.cbb-backup';
21
21
 
22
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 仓库 = 检出含 CBB-WORKSPACE.json(或旧名,改名过渡期识别)\nconst isWorkspace = fs.existsSync('CBB-WORKSPACE.json') || 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('控制面白名单:CBB-WORKSPACE.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 // 旧名仅放行删除条目:更名迁移提交(git mv)会产生旧名删除 + 新名新增两条\n const EXACT = new Set(['CBB-WORKSPACE.json', '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";
23
+ const PRE_COMMIT_SOURCE = "#!/usr/bin/env node\n// managed-by-cbb v1 —— 分支感知提交防线(ADR 0002)\n// main 上:workspace 只放行控制面白名单,应用全拦(merge 提交除外);非 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// merge 进行中(MERGE_HEAD 存在)→ 显式人工合并提交,放行(ADR 0007 合并门禁)\nif (run('git rev-parse --verify --quiet MERGE_HEAD')) 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 仓库 = 检出含 CBB-WORKSPACE.json(或旧名,改名过渡期识别)\nconst isWorkspace = fs.existsSync('CBB-WORKSPACE.json') || 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('控制面白名单:CBB-WORKSPACE.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 // 旧名仅放行删除条目:更名迁移提交(git mv)会产生旧名删除 + 新名新增两条\n const EXACT = new Set(['CBB-WORKSPACE.json', '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
24
 
25
25
  function isCbbHookContent(content) {
26
26
  return typeof content === 'string' && content.includes(HOOK_MARKER);