@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
@@ -99,30 +99,37 @@ trigger: always_on
99
99
 
100
100
  **检验标准:** 用户从未因"AI 直接改了代码没打招呼"而感到意外。
101
101
 
102
- ## 8. 业务空间目录纪律
102
+ ## 8. 业务空间开发模式(双模式,用户选择制)
103
103
 
104
- **仅当工作目录是业务空间根(存在 `workspace-config.json`)时生效。空间根支持两种开发模式,接到需求先判定本次属于哪种,再动手。**
104
+ **仅当工作目录是业务空间根(存在 `workspace-config.json` 且 `.git` 为目录)时生效。空间支持两种开发模式,能力对称(都能修改 workspace 内容与应用代码、都强制分支隔离、交付都走 MR),由用户显式选择,agent 只给推荐不代决。**
105
105
 
106
- **模式一 · worktree 模式(默认;凡涉及应用代码改动必走此路):**
106
+ **模式一 · 本地模式(单线程快改,当前会话就地干):**
107
107
 
108
- 接到需求先建工作树(`cbb-worktree-init` skill,或斜杠命令 `/cbb-worktree-init`),之后:
108
+ - 就地切分支:根 + 各涉及应用 `.codespace/<app>/` 同名 `checkout -b`(基线 = `origin/<main>`,经 `cbb-local-mode` 技能执行)
109
+ - 当前会话原地开发,相对路径原样生效;**一次只承载一个需求**(独占本地检出;期间并行新需求走 worktree 模式,互不干扰)
110
+ - 收尾:MR 合并后各参与仓库 `checkout main && pull && 删分支`
109
111
 
110
- - 一切产生写操作的命令(git commit / openspec / 构建 / 测试 / 文件修改)必须以 `.worktrees/worktree-<需求名>/`(或其应用子目录)为工作目录:显式 `cd` 或绝对路径
111
- - 不在空间根主分支提交应用代码、不在空间根创建本模式的 openspec 变更产物
112
- - 会话开始时确认本次需求对应的工作树路径;存在多个未完成工作树时,先与用户确认目标,不要猜
112
+ **模式二 · worktree 模式(并行 / 隔离):**
113
113
 
114
- **模式二 · 轻量模式(仅限空间根仓库自身内容:openspec 产物、docs、空间配置):**
114
+ - `cbb-worktree-init` 建隔离 worktree(AI 配置内容子目录已 junction 就位),**开发会话开进 `.worktrees/worktree-<需求名>/`**,可多需求并行
115
+ - 根会话保留作控制面(worktree 生灭、`cbb update` 等空间级操作)
115
116
 
116
- 需求完全不触碰任何应用代码文件时,可直接在空间根开发:变更产物落空间根 `openspec/`,走完整 opsx 流程后提交 main 分支,**提交后即 push——main 就是交付线,无 worktree、无 MR,不得悬空本地提交**。
117
+ **基准与防线(两模式共同,ADR 0002):**
117
118
 
118
- **两模式共同红线(无例外):**
119
+ - **基准 = `origin/main`(唯一真相源);`.codespace/<app>/` 的检出 = 视图**——本地模式可暂态被需求分支占用,收尾恢复
120
+ - **严禁直接在任何 main 检出上提交载荷**:分支感知 pre-commit hook 机械拦截(workspace 仓库仅放行控制面白名单:`workspace-config.json` / `.gitignore` / `AGENTS.md` / `openspec/config.yaml` / `openspec/schemas/**`;应用仓库 main 全拦)——**被拦是防线生效,不是故障**;`--no-verify` 绕过仅限用户显式要求
121
+ - 远端推送唯一出口 = `cbb-worktree-push`(init 创建流程零远端操作,lazy push;空分支尸体不存在)
122
+ - **模式误判零代价**:本地模式中途要并行/隔离 → 重跑 `cbb-worktree-init`(分支已存在自动复用,提交随身带走)
119
123
 
120
- - `.codespace/` 是工具维护的基准代码,不是开发区——任何模式下都不许在其中改应用代码
121
- - 只读命令(git status / log、查看文件)在空间根执行无妨
124
+ **三时刻对齐(agent 必须遵守):**
122
125
 
123
- **会话与工作目录是两回事:** AI 会话窗口始终停在**空间根**(worktree 内不装 AI 配置,无工作流命令),`/opsx:*` 等命令仍在空间根会话发起;worktree 模式下**产生写操作的工作目录**指向需求工作树(显式 `cd` 或绝对路径)。不要把会话切到 worktree 内。
126
+ 1. **开工前——状态自检**(`cbb-local-mode` 的 `check-local-state.js`):干净态零交互直接开工;**遗留态(别的需求未收尾,任一仓库检出在非主干分支)必须让用户四选一**(继续 / 先收尾 / 升级 worktree / 丢弃),不得自行决定
127
+ 2. **开工时——布局确认**:向用户展示参与仓库与分支名,确认后执行
128
+ 3. **进行中——插单归属**:用户插入非本需求的改动,先确认归属(并入 / 收尾后处理 / 转 worktree),不得静默混入
124
129
 
125
- **检验标准:** worktree 模式的变更产物只出现在需求工作树内;空间根 main 的 `git status` 干净(除工具维护的配置文件外),且轻量模式产生的 main 提交都已 push(本地 main 不长期领先 origin/main)。
130
+ **读基准代码约定:** 本地模式进行中,工作区是开发态而非基准——要读"main 现状"先确认检出分支,或直接 `git show main:<path>` / `git diff main`;不要默认工作区即基准。
131
+
132
+ **检验标准:** 任何 main 检出上无载荷提交(hook 兜底,git log 可机械审计);本地模式收尾后全部仓库回主干且 clean;worktree 模式的变更产物只出现在需求工作树内;远端分支只经 push 技能产生。
126
133
 
127
134
  ---
128
135
 
@@ -76,7 +76,7 @@ const SUPERPOWERS_WHITELIST = [
76
76
  // worktree 管理 skill 清单:init / close / push 三件套(SKILL.md + scripts)。
77
77
  // 按 skill 形态部署到各工具的 skillsDir,使这三个能力可被 AI 在对话中自动触发
78
78
  // (description 驱动),同时工具也会把它们暴露为斜杠命令供用户手动指定。
79
- const WORKTREE_SKILLS = ['cbb-worktree-init', 'cbb-worktree-close', 'cbb-worktree-push'];
79
+ const WORKTREE_SKILLS = ['cbb-worktree-init', 'cbb-worktree-close', 'cbb-worktree-push', 'cbb-local-mode'];
80
80
 
81
81
  // 装到空间的 tools skills:当前不分发。
82
82
  // cbb-design-to-wiki(Wiki 发布)与 cbb-wiki-ops 保留在源仓库,需要时加入本清单即恢复分发;Java 工具不装。
@@ -124,6 +124,9 @@ const { applyRuleRewrites } = require('./rule-rewrite');
124
124
  // .gitignore 条目决策(纯函数:检测/写入/卸载移除)
125
125
  const gitignore = require('../utils/gitignore');
126
126
 
127
+ // git pre-commit hook:分支感知提交防线(ADR 0002,install/uninstall 调用)
128
+ const githook = require('../utils/githook');
129
+
127
130
  // 业务空间配置(cbb setup 命令专用)
128
131
  const workspaces = require('./workspaces');
129
132
 
@@ -1232,6 +1235,14 @@ async function init(targetDir, options = {}) {
1232
1235
  // 更新 .gitignore
1233
1236
  updateGitignore();
1234
1237
 
1238
+ // 安装分支感知提交防线(ADR 0002,仅业务空间根:空间根仓库 + .codespace/ 各应用)
1239
+ let hookSummary = null;
1240
+ if (isWorkspaceRoot) {
1241
+ hookSummary = githook.installGitHooksForWorkspace(TARGET_DIR);
1242
+ const appDone = hookSummary.apps.length;
1243
+ log(`提交防线 pre-commit hook → 空间根 + ${appDone} 个应用仓库(${hookSummary.root})`, 'success');
1244
+ }
1245
+
1235
1246
  // 完成摘要
1236
1247
  console.log(`\n ✅ ${action}完成!\n`);
1237
1248
 
@@ -1266,6 +1277,7 @@ async function init(targetDir, options = {}) {
1266
1277
  if (hasSkills) extras.push('Skills');
1267
1278
  if (hasCommands) extras.push('Commands');
1268
1279
  if (hasHooks) extras.push('版本检查 hook');
1280
+ if (hookSummary) extras.push('提交防线 pre-commit hook');
1269
1281
  if (agentsMdState === 'created') extras.push('AGENTS.md(空间导航)');
1270
1282
  if (extras.length > 0) {
1271
1283
  console.log(` ${extras.join(' / ')} 已配置`);
@@ -2012,6 +2024,22 @@ async function uninstall() {
2012
2024
  // 清理 .gitignore
2013
2025
  cleanupGitignore();
2014
2026
 
2027
+ // 移除分支感知提交防线(ADR 0002):空间根 + .codespace/ 各应用(业务空间根专属)
2028
+ if (detectWorkspaceRoot()) {
2029
+ const results = [githook.uninstallGitHook(TARGET_DIR)];
2030
+ const codeDir = path.join(TARGET_DIR, '.codespace');
2031
+ if (fs.existsSync(codeDir)) {
2032
+ for (const name of fs.readdirSync(codeDir)) {
2033
+ const appDir = path.join(codeDir, name);
2034
+ if (fs.existsSync(path.join(appDir, '.git'))) {
2035
+ results.push(githook.uninstallGitHook(appDir));
2036
+ }
2037
+ }
2038
+ }
2039
+ const removedHooks = results.filter(r => r !== 'noop').length;
2040
+ if (removedHooks > 0) log(`移除提交防线 pre-commit hook(${removedHooks} 个仓库)`, 'success');
2041
+ }
2042
+
2015
2043
  // 卸载用户级组件(如已确认)
2016
2044
  if (removeUserLevel) {
2017
2045
  console.log('');
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * git pre-commit hook:分支感知提交防线(ADR 0002)。
5
+ *
6
+ * 策略(对当前仓库生效,worktree 与主仓库共享 .git/hooks 故一并覆盖):
7
+ * - 当前分支非主干(含 detached HEAD)→ 一律放行(两模式开发态都在需求分支上)
8
+ * - workspace 仓库(检出含 workspace-config.json)在主干上 → 只放行控制面白名单路径:
9
+ * workspace-config.json / .gitignore / AGENTS.md / openspec/config.yaml /
10
+ * openspec/schemas/** / .cbb/**(配置变更与 cbb update 部署物,直改即推)
11
+ * - 应用仓库在主干上 → 全拦(交付只走 MR)
12
+ * - 逃生口:用户显式 git commit --no-verify(AI 侧纪律禁止自动绕过)
13
+ *
14
+ * 本模块是 hook 源码的唯一真源;cbb/worktrees/_shared/scripts/install-git-hook.js
15
+ * 部署到业务空间后独立运行,内嵌同一份源码副本(改动须两处同步)。
16
+ *
17
+ * 提供纯函数(isCbbHookContent / buildPreCommitSource)与 I/O 包装
18
+ * (installGitHook / uninstallGitHook),供 init.js 的 setup / update / uninstall 调用。
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { execSync } = require('child_process');
24
+
25
+ const HOOK_MARKER = 'managed-by-cbb';
26
+ const HOOK_FILE = 'pre-commit';
27
+ const BACKUP_SUFFIX = '.cbb-backup';
28
+
29
+ // 部署到仓库后的 hook 源码(独立 Node 脚本,无外部依赖)。
30
+ // 注意:源码内不使用反引号,便于本模块以模板字符串承载。
31
+ const PRE_COMMIT_SOURCE = [
32
+ '#!/usr/bin/env node',
33
+ '// managed-by-cbb v1 —— 分支感知提交防线(ADR 0002)',
34
+ '// main 上:workspace 仓库只放行控制面白名单,应用仓库全拦;非 main 一律放行。',
35
+ '// 绕过逃生口仅限用户显式 git commit --no-verify。',
36
+ "const { execSync } = require('child_process');",
37
+ "const fs = require('fs');",
38
+ 'function run(cmd) {',
39
+ " try { return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }",
40
+ ' catch (e) { return null; }',
41
+ '}',
42
+ "const branch = run('git branch --show-current') || '';",
43
+ '// 主干名:优先远端 HEAD 指向;探测失败按本地 main/master 存在性回退',
44
+ 'let main = null;',
45
+ "const remotes = run('git remote');",
46
+ 'if (remotes) {',
47
+ " const r = remotes.split(/\\r?\\n/)[0];",
48
+ " const sym = run('git symbolic-ref \"refs/remotes/' + r + '/HEAD\"');",
49
+ " if (sym) main = sym.replace('refs/remotes/' + r + '/', '');",
50
+ '}',
51
+ "if (!main) {",
52
+ " if (run('git rev-parse --verify --quiet refs/heads/main')) main = 'main';",
53
+ " else if (run('git rev-parse --verify --quiet refs/heads/master')) main = 'master';",
54
+ '}',
55
+ '// 非主干(含 detached / 探测失败)→ 放行',
56
+ 'if (!main || branch !== main) process.exit(0);',
57
+ '',
58
+ "// 主干上的提交:收集 staged 变更(空提交放行)",
59
+ "const staged = run('git diff --cached --name-only') || '';",
60
+ "const files = staged.split(/\\r?\\n/).filter(Boolean);",
61
+ 'if (files.length === 0) process.exit(0);',
62
+ '',
63
+ '// 仓库类型:workspace 仓库 = 检出含 workspace-config.json',
64
+ "const isWorkspace = fs.existsSync('workspace-config.json');",
65
+ '',
66
+ 'function deny(bad) {',
67
+ " console.error('[cbb-guard] 主干上拦截载荷提交(ADR 0002):');",
68
+ " bad.forEach(f => console.error(' ' + f));",
69
+ " console.error('需求载荷须在需求分支上提交:本地模式先 git checkout -b <需求名>;worktree 模式在 .worktrees/ 工作树内提交。');",
70
+ " console.error('控制面白名单:workspace-config.json / .gitignore / AGENTS.md / openspec/config.yaml / openspec/schemas/** / .cbb/**');",
71
+ " console.error('如确需在主干提交,须由用户显式 git commit --no-verify。');",
72
+ ' process.exit(1);',
73
+ '}',
74
+ '',
75
+ 'if (isWorkspace) {',
76
+ " const EXACT = new Set(['workspace-config.json', '.gitignore', 'AGENTS.md', 'openspec/config.yaml']);",
77
+ " const PREFIXES = ['openspec/schemas/', '.cbb/'];",
78
+ ' const bad = files.filter(f => !EXACT.has(f) && !PREFIXES.some(p => f.indexOf(p) === 0));',
79
+ ' if (bad.length) deny(bad);',
80
+ '} else {',
81
+ ' deny(files);',
82
+ '}',
83
+ 'process.exit(0);',
84
+ ].join('\n');
85
+
86
+ /**
87
+ * 内容是否为本包管理的 hook(含标记行)。
88
+ */
89
+ function isCbbHookContent(content) {
90
+ return typeof content === 'string' && content.includes(HOOK_MARKER);
91
+ }
92
+
93
+ /**
94
+ * hook 源码(纯函数,便于单测与未来按仓库定制)。
95
+ */
96
+ function buildPreCommitSource() {
97
+ return PRE_COMMIT_SOURCE + '\n';
98
+ }
99
+
100
+ /**
101
+ * 解析仓库的 hooks 目录。
102
+ * 优先 core.hooksPath 配置(相对路径基于仓库根);未配置或读取失败回退 .git/hooks。
103
+ * @returns {string|null} 绝对路径;目录不可用时返回 null(调用方跳过安装)
104
+ */
105
+ function resolveHooksDir(repoPath) {
106
+ let configured = null;
107
+ try {
108
+ configured = execSync('git config core.hooksPath', {
109
+ cwd: repoPath,
110
+ encoding: 'utf-8',
111
+ stdio: ['ignore', 'pipe', 'ignore'],
112
+ timeout: 5000,
113
+ }).trim();
114
+ } catch (_) { /* 未配置 */ }
115
+
116
+ let dir;
117
+ if (configured) {
118
+ dir = path.isAbsolute(configured) ? configured : path.join(repoPath, configured);
119
+ } else {
120
+ const gitDir = path.join(repoPath, '.git');
121
+ if (!fs.existsSync(gitDir)) return null; // 非 git 仓库(或 .git 为 worktree 文件,见下)
122
+ // worktree 检出的 .git 是文件(内容 "gitdir: ...");hook 安装统一走主仓库,
123
+ // 调用方应传主仓库路径。此处对文件形态直接放弃,避免误建目录。
124
+ const st = fs.statSync(gitDir);
125
+ if (!st.isDirectory()) return null;
126
+ dir = path.join(gitDir, 'hooks');
127
+ }
128
+ return dir;
129
+ }
130
+
131
+ /**
132
+ * 安装(幂等,可用于升级覆盖):
133
+ * - 无既有 hook → 写入
134
+ * - 既有本包 hook → 覆盖(升级)
135
+ * - 既有用户 hook → 备份为 pre-commit.cbb-backup 后写入;卸载时恢复
136
+ * @param {string} repoPath 仓库根(主仓库;worktree 共享 hooks 无需重复安装)
137
+ * @returns {'installed' | 'updated' | 'skipped-not-git' | 'skipped-no-dir'}
138
+ */
139
+ function installGitHook(repoPath) {
140
+ const dir = resolveHooksDir(repoPath);
141
+ if (!dir) return 'skipped-not-git';
142
+
143
+ const hookPath = path.join(dir, HOOK_FILE);
144
+ fs.mkdirSync(dir, { recursive: true });
145
+
146
+ let prev = null;
147
+ if (fs.existsSync(hookPath)) {
148
+ prev = fs.readFileSync(hookPath, 'utf-8');
149
+ if (isCbbHookContent(prev)) {
150
+ if (prev === buildPreCommitSource()) return 'updated'; // 幂等:同版本
151
+ fs.writeFileSync(hookPath, buildPreCommitSource());
152
+ return 'updated';
153
+ }
154
+ // 用户自有 hook:备份后接管
155
+ fs.writeFileSync(hookPath + BACKUP_SUFFIX, prev);
156
+ }
157
+ fs.writeFileSync(hookPath, buildPreCommitSource());
158
+ try { fs.chmodSync(hookPath, 0o755); } catch (_) { /* Windows 无压力 */ }
159
+ return 'installed';
160
+ }
161
+
162
+ /**
163
+ * 卸载:
164
+ * - 当前 hook 是本包的 → 删除;有备份则恢复
165
+ * - 当前 hook 非本包(用户已接管)→ 不动,仅清掉残留备份
166
+ * @returns {'removed' | 'restored-backup' | 'noop'}
167
+ */
168
+ function uninstallGitHook(repoPath) {
169
+ const dir = resolveHooksDir(repoPath);
170
+ if (!dir) return 'noop';
171
+
172
+ const hookPath = path.join(dir, HOOK_FILE);
173
+ const backupPath = hookPath + BACKUP_SUFFIX;
174
+ let result = 'noop';
175
+
176
+ if (fs.existsSync(hookPath)) {
177
+ const content = fs.readFileSync(hookPath, 'utf-8');
178
+ if (isCbbHookContent(content)) {
179
+ fs.unlinkSync(hookPath);
180
+ result = 'removed';
181
+ if (fs.existsSync(backupPath)) {
182
+ fs.renameSync(backupPath, hookPath);
183
+ result = 'restored-backup';
184
+ }
185
+ }
186
+ } else if (fs.existsSync(backupPath)) {
187
+ // hook 已被用户删除但备份残留:不擅自恢复,只清备份
188
+ fs.unlinkSync(backupPath);
189
+ }
190
+ return result;
191
+ }
192
+
193
+ /**
194
+ * 对业务空间批量安装:空间根仓库 + .codespace/ 下每个应用仓库。
195
+ * @param {string} wsRoot 业务空间根
196
+ * @returns {{ root: string, apps: {name: string, status: string}[] }}
197
+ */
198
+ function installGitHooksForWorkspace(wsRoot) {
199
+ const root = installGitHook(wsRoot);
200
+ const apps = [];
201
+ const codeDir = path.join(wsRoot, '.codespace');
202
+ if (fs.existsSync(codeDir)) {
203
+ for (const name of fs.readdirSync(codeDir)) {
204
+ const appDir = path.join(codeDir, name);
205
+ if (fs.existsSync(path.join(appDir, '.git'))) {
206
+ apps.push({ name, status: installGitHook(appDir) });
207
+ }
208
+ }
209
+ }
210
+ return { root, apps };
211
+ }
212
+
213
+ module.exports = {
214
+ HOOK_MARKER,
215
+ HOOK_FILE,
216
+ BACKUP_SUFFIX,
217
+ PRE_COMMIT_SOURCE,
218
+ isCbbHookContent,
219
+ buildPreCommitSource,
220
+ resolveHooksDir,
221
+ installGitHook,
222
+ uninstallGitHook,
223
+ installGitHooksForWorkspace,
224
+ };
@@ -4,7 +4,8 @@
4
4
  * .gitignore 管理的纯函数工具(无副作用,便于单测;由 init.js 的 I/O 层调用)。
5
5
  *
6
6
  * 安装时:若 .gitignore 已含某目录(认可带/不带尾斜杠两种形式),则不重复添加;
7
- * 否则新增无尾斜杠形式(.claude / .qoder,更通用)。.cbb/ 维持带斜杠。
7
+ * 否则新增无尾斜杠形式(.claude / .qoder,更通用)。.cbb/ / .codespace/ /
8
+ * .worktrees/ 这类纯目录路径维持带斜杠。
8
9
  * 卸载时:移除本包曾写入的所有形式(新旧版本均覆盖)。
9
10
  *
10
11
  * 注意:.gitignore 条目无 _managedBy 之类的标记,卸载按"形式匹配"移除,
@@ -13,11 +14,14 @@
13
14
 
14
15
  // 各目录:add = 写入形式;accept = 视为"已存在 / 需卸载移除"的可接受形式(含尾斜杠变体)。
15
16
  // .claude / .qoder 写无尾斜杠形式,但检测/卸载时同时认可带斜杠变体,避免重复添加、覆盖旧版本。
17
+ // .worktrees/ 自 0.0.3-beta.22 起随 setup 写入(ADR 0002:本地模式下 main 工作区恒净,
18
+ // 不再依赖 worktree-init 把该条目提交到 feature 分支——那会导致首个分支合并前 main 恒脏)。
16
19
  const GITIGNORE_RULES = [
17
20
  { add: '.claude', accept: ['.claude', '.claude/'] },
18
21
  { add: '.qoder', accept: ['.qoder', '.qoder/'] },
19
22
  { add: '.cbb/', accept: ['.cbb/'] },
20
23
  { add: '.codespace/', accept: ['.codespace/', '.codespace'] },
24
+ { add: '.worktrees/', accept: ['.worktrees/', '.worktrees'] },
21
25
  { add: '.opencode', accept: ['.opencode', '.opencode/'] },
22
26
  // .codebuddy:beta.15 及以前写入的旧目录,不再新增(add: null),但卸载/清理时仍移除其行
23
27
  { add: null, accept: ['.codebuddy', '.codebuddy/'] },
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 本地模式状态自检(ADR 0002,改动清单 #12)。
4
+ *
5
+ * 用法:node check-local-state.js <ws_root>
6
+ *
7
+ * 扫描空间根 + 所有 .codespace/<app>/ 的 { 当前分支, dirty },
8
+ * 判定「干净态」(全部在主干且 clean)或「遗留态」(任一仓库检出在非主干分支)。
9
+ * 干净态 → 零交互直接进入模式选择;遗留态 → agent 升级为用户决策
10
+ * (继续 / 收尾 / 升级 worktree / 丢弃)。同名分支集合 = 遗留需求的仓库布局。
11
+ *
12
+ * 输出:
13
+ * 人类可读报告(逐仓库状态 + 态判定 + 推断的需求名)
14
+ * 末行 LOCAL_STATE { state, branches[], repos[{name,branch,dirty}] }
15
+ * - state: 'clean' | 'active'(active = 存在非主干检出)
16
+ * - branches: 非主干分支名集合(通常为 1 个 = 遗留需求名;多个不同名 → 多头遗留,需用户逐个处理)
17
+ *
18
+ * 退出码:
19
+ * 0 - 扫描完成(无论何种状态;态判定交给 agent 解读)
20
+ * 1 - 参数错误
21
+ */
22
+
23
+ const { execSync } = require('child_process');
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const wsRoot = process.argv[2];
28
+
29
+ if (!wsRoot) {
30
+ console.error('Usage: node check-local-state.js <ws_root>');
31
+ process.exit(1);
32
+ }
33
+
34
+ function run(cmd, cwd) {
35
+ try {
36
+ return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
37
+ } catch (e) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /** 主干名:symbolic-ref 远端 HEAD → 本地 main/master 存在性回退;探测失败 null */
43
+ function detectMain(cwd) {
44
+ const remotes = run('git remote', cwd);
45
+ if (remotes) {
46
+ const r = remotes.split(/\r?\n/)[0];
47
+ const sym = run('git symbolic-ref "refs/remotes/' + r + '/HEAD"', cwd);
48
+ if (sym) return sym.replace('refs/remotes/' + r + '/', '');
49
+ }
50
+ if (run('git rev-parse --verify --quiet refs/heads/main', cwd)) return 'main';
51
+ if (run('git rev-parse --verify --quiet refs/heads/master', cwd)) return 'master';
52
+ return null;
53
+ }
54
+
55
+ function scanRepo(cwd, name) {
56
+ const branch = run('git branch --show-current', cwd) || '(detached)';
57
+ const status = run('git status --porcelain', cwd);
58
+ const dirty = !!(status && status.length);
59
+ const main = detectMain(cwd);
60
+ const repo = { name, branch, dirty };
61
+ if (main) repo.main = main;
62
+ return repo;
63
+ }
64
+
65
+ // ── 扫描空间根 ─────────────────────────────────────────────
66
+
67
+ if (!fs.existsSync(path.join(wsRoot, '.git'))) {
68
+ console.error('❌ ' + wsRoot + ' 不是 git 仓库');
69
+ process.exit(1);
70
+ }
71
+
72
+ const repos = [scanRepo(wsRoot, 'workspace')];
73
+
74
+ // ── 扫描 .codespace/ 各应用 ───────────────────────────────
75
+
76
+ const codeDir = path.join(wsRoot, '.codespace');
77
+ if (fs.existsSync(codeDir)) {
78
+ for (const name of fs.readdirSync(codeDir).sort()) {
79
+ const appDir = path.join(codeDir, name);
80
+ if (fs.existsSync(path.join(appDir, '.git'))) {
81
+ repos.push(scanRepo(appDir, name));
82
+ }
83
+ }
84
+ }
85
+
86
+ // ── 态判定 ─────────────────────────────────────────────────
87
+
88
+ for (const r of repos) {
89
+ const icon = (r.main && r.branch === r.main) ? '📄' : '🌿';
90
+ const dirtyTag = r.dirty ? '(有未提交修改)' : '';
91
+ const baseTag = (r.main && r.branch !== r.main) ? ' ← 主干 ' + r.main : '';
92
+ console.log(icon + ' ' + r.name + ': ' + r.branch + dirtyTag + baseTag);
93
+ }
94
+
95
+ const active = repos.filter(r => !(r.main && r.branch === r.main));
96
+ const branches = [...new Set(active.map(r => r.branch))];
97
+
98
+ let state;
99
+ if (active.length === 0) {
100
+ state = 'clean';
101
+ console.log('✅ 干净态:全部仓库在主干且可开工(模式选择不受限)');
102
+ } else {
103
+ state = 'active';
104
+ console.log('⚠️ 遗留态:检测到本地需求进行中(分支:' + branches.join(', ') + ')');
105
+ console.log(' 参与仓库:' + active.map(r => r.name).join(', '));
106
+ if (branches.length > 1) {
107
+ console.log(' ⚠️ 存在多个不同分支名——多头遗留,需用户逐个处理');
108
+ }
109
+ console.log(' 处理选项:继续该需求 / 先收尾 / 升级 worktree(git worktree add)/ 丢弃');
110
+ }
111
+
112
+ console.log('---');
113
+ console.log('LOCAL_STATE', JSON.stringify({ state, branches, repos }));
114
+ process.exit(0);