@sanqianx/project-knowledge 4.8.0 → 5.0.0

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 (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +11 -6
  4. package/_modules/knowledge-engine/README.md +9 -6
  5. package/_modules/knowledge-engine/package.json +1 -1
  6. package/_modules/knowledge-engine/src/coordinator.js +11 -3
  7. package/_modules/knowledge-engine/src/server.js +9 -10
  8. package/_modules/knowledge-engine/src/store.js +9 -12
  9. package/_modules/knowledge-engine/ui/app.js +1 -1
  10. package/_site/_test/fresh-hook-reimport-test.js +53 -0
  11. package/_site/_test/fresh-product-data-test.js +42 -0
  12. package/_site/_test/module-model-configuration-test.js +2 -1
  13. package/_site/_test/tracking-start-test.js +1 -0
  14. package/_site/lib/data-dir.js +13 -81
  15. package/_site/lib/engine-connection.js +67 -0
  16. package/_site/lib/hook-manager.js +1 -1
  17. package/_site/lib/integration-installer.js +2 -1
  18. package/_site/lib/module-bridge.js +6 -16
  19. package/_site/lib/product-data.js +24 -0
  20. package/_site/lib/project-lifecycle-service.js +18 -2
  21. package/_site/lib/server-app.js +11 -22
  22. package/docs/README.zh-CN.md +8 -4
  23. package/docs/fresh-v5-start.md +28 -0
  24. package/module-runtime-manifest.json +2 -2
  25. package/package.json +2 -1
  26. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  27. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  28. package/plugins/project-knowledge/.mcp.json +1 -1
  29. package/ui/index.html +2 -1
  30. package/ui/product.js +41 -15
  31. package/vendor-manifest.json +7 -8
  32. package/_modules/knowledge-engine/src/migration.js +0 -88
  33. package/_site/_test/data-dir-migration-test.js +0 -77
  34. package/_site/_test/knowledge-migration-test.js +0 -75
  35. package/_site/_test/legacy-forward-compat-test.js +0 -257
  36. package/_site/_test/legacy-project-upgrade-e2e-test.js +0 -363
  37. package/_site/_test/p0-data-migration-characterization-test.js +0 -37
  38. package/_site/_test/p0-e2e-gate-test.js +0 -347
  39. package/_site/_test/project-layout-v2-migration-test.js +0 -127
  40. package/_site/_test/server-runtime-migration-safety-test.js +0 -27
  41. package/_site/_test/v4122-upgrade-data-contract-test.js +0 -56
  42. package/_site/lib/data-state-classifier.js +0 -40
  43. package/_site/lib/engine-cutover.js +0 -97
  44. package/_site/lib/legacy-data-manifest.js +0 -26
  45. package/_site/lib/migration-service.js +0 -442
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "project-knowledge",
12
- "version": "4.8.0",
12
+ "version": "5.0.0",
13
13
  "source": "./plugins/project-knowledge",
14
14
  "description": "Search durable project decisions and implementation history through MCP."
15
15
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.0.0] - 2026-10-10
4
+
5
+ - Fresh-data-only web product. Default state moves to `~/.project-knowledge-v5`;
6
+ no previous knowledge, project registry, unfinished claims or model profiles
7
+ are imported. Explicitly selected old data directories are rejected unchanged.
8
+ - Remove shell migration/cutover and engine `/api/migrate`. Engine 0.2.0 uses
9
+ schema version 3; validate its data generation and runtime authentication
10
+ before registering projects or enabling consumption. System status reports
11
+ safe connection stage/error codes instead of silently waiting for takeover.
12
+ - Isolate Workbench, bridge journals and vector configuration under the new
13
+ product directory. No automatic embedding-credential migration.
14
+ - Queued, unstarted tasks are cancelled on unbind; active knowledge/index write
15
+ leases still block removal. The visible confirmation dialog updates project
16
+ selection immediately and rejects stale registry responses without reloading.
17
+ - Reimport can explicitly replace a leftover knowledge-managed Git Hook. Failed
18
+ imports restore the prior Hook; third-party hooks and existing knowledge files
19
+ are never overwritten. Import tracks HEAD without analyzing historical commits.
20
+
21
+
3
22
  ## [4.8.0] - 2026-10-10
4
23
 
5
24
  - Unified brain web product: six real pages for work, retrieval, memories,
package/README.md CHANGED
@@ -32,9 +32,8 @@ project-knowledge
32
32
  It also registers the host-level `project-knowledge` Bridge consumer.
33
33
  No client UI needs to be opened; third-party hooks and configs are
34
34
  preserved, and a Codex notify conflict is reported instead of overwritten.
35
- 3. `project-knowledge` starts the local backend. Projects imported with older
36
- versions are upgraded automatically on the first drain (canonical workspace
37
- identity + conversation baseline — no manual migration).
35
+ 3. `project-knowledge` starts a fresh v5 local backend. Import projects again;
36
+ older registries, knowledge, sessions and unfinished tasks are not migrated.
38
37
 
39
38
  Notes:
40
39
 
@@ -62,7 +61,7 @@ Requires Node.js 22 or newer and Git on `PATH`.
62
61
 
63
62
  ### Windows desktop
64
63
 
65
- Version 4.8.0 delivers the web product through npm. A new Windows installer
64
+ Version 5.0.0 delivers the web product through npm. A new Windows installer
66
65
  and desktop auto-update are not part of this release.
67
66
 
68
67
  ## Runtime model
@@ -138,7 +137,13 @@ scopes are nested; unconfirmed memory candidates are not task preferences.
138
137
 
139
138
  ## Storage
140
139
 
141
- Default internal data directory: `~/.project-knowledge/`
140
+ Version 5 is a fresh start, not a data upgrade. Stop the previous process,
141
+ remove any old `KB_DATA_DIR` override, configure models and import projects into
142
+ new empty knowledge directories. Old files remain untouched but are not read.
143
+ See [fresh v5 setup](docs/fresh-v5-start.md). Queued tasks can be cancelled by
144
+ unbinding; only an active knowledge/index writer blocks removal.
145
+
146
+ Default internal data directory: `~/.project-knowledge-v5/`
142
147
 
143
148
  Override it with `KB_DATA_DIR`:
144
149
 
@@ -157,7 +162,7 @@ Settings, metadata, indexes, caches, runtime state, logs, and recovery assets
157
162
  remain in the internal data directory:
158
163
 
159
164
  ```text
160
- ~/.project-knowledge/
165
+ ~/.project-knowledge-v5/
161
166
  ├── settings.json
162
167
  ├── projects.json # minimal project ID/order index
163
168
  ├── projects/<projectId>/
@@ -33,7 +33,9 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
33
33
 
34
34
  打开 `http://127.0.0.1:5790` 即调试台。默认绑定 loopback:运行历史包含 commit 证据与知识内容,不出本机。
35
35
 
36
- 首次启动为空,不创建演示项目。`--debug` 才开放手工运行、事件注入和模拟失败。独立运行默认只提供读取与诊断;设置 `KNOWLEDGE_ENGINE_CONSUME=1` 开始消费。外壳守护使用 `KB_ENGINE_MANAGED=1`,完成受保护迁移后再通过鉴权控制接口启用消费与执行。
36
+ 首次启动为空,不创建演示项目。`--debug` 才开放手工运行、事件注入和模拟失败。独立运行默认只提供读取与诊断;设置 `KNOWLEDGE_ENGINE_CONSUME=1` 开始消费。外壳守护使用 `KB_ENGINE_MANAGED=1`,确认服务身份、运行期鉴权、项目接线及配置后启用消费与执行;不再导入旧外壳任务。
37
+
38
+ 0.2.0 使用全新 schemaVersion 3,独立运行默认目录为 `./data-v3`。旧版状态目录明确拒绝读取,原文件保持不变;不备份后重置、不迁移、不创建 `upgrade-recovery`。同一新版数据目录内的崩溃恢复仍保留。
37
39
 
38
40
  本模块零新增 npm 依赖、CommonJS、Node ≥18;无头 Agent SDK 由宿主已安装的 Workbench/SDK 提供,缺失时明确失败,不退回假分析。完整产品的 Node 门槛为 ≥22。
39
41
 
@@ -41,9 +43,9 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
41
43
 
42
44
  | 方法 | 路径 | 说明 |
43
45
  |---|---|---|
44
- | GET | `/api/health` | 版本 / uptime / 计数 / journal 游标 |
46
+ | GET | `/api/health` | 版本 / dataSchemaVersion / uptime / 计数 / journal 游标 |
45
47
  | GET / POST | `/api/projects` | 项目列表 / 注册(按 projectId 幂等) |
46
- | DELETE | `/api/projects/:id` | 摘除登记 |
48
+ | DELETE | `/api/projects/:id` | 摘除登记,取消未开始任务;实际运行或写入租约未释放时返回 409;保留代码、知识和证据 |
47
49
  | GET | `/api/runs?projectId=` | 分析运行列表(新→旧) |
48
50
  | POST | `/api/runs` | 仅调试模式手工运行;必填完整 `commitSha` |
49
51
  | GET | `/api/runs/:id` | 运行详情(含 staging manifest) |
@@ -57,7 +59,8 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
57
59
  | GET / PATCH | `/api/memories`、`/api/memories/:id` | 候选和已确认记忆;编辑、确认、忽略持久化 |
58
60
  | POST | `/api/scopes` | 外壳投影智脑、领域共享目录;受运行期鉴权保护 |
59
61
  | POST | `/api/runtime-config` | 运行期模型配置,仅保留在内存;受鉴权保护 |
60
- | POST | `/api/migrate`、`/api/control` | 暂停状态迁移 / 迁移完成后启用;受鉴权保护 |
62
+ | GET | `/api/runtime-auth` | 本机服务确认运行期鉴权;受鉴权保护,不对浏览器反代 |
63
+ | POST | `/api/control` | 外壳完成接线后启用;受鉴权保护。旧 `/api/migrate` 已删除 |
61
64
 
62
65
  鉴权接口只接受无浏览器 Origin 的本机服务调用,使用 `KB_ENGINE_RUNTIME_TOKEN` 的 Bearer token。外壳不能把这些接口或解析后的凭据转发到浏览器。登记投影只接受白名单元数据,API key 不写入 EngineStore、任务记录或日志。
63
66
 
@@ -71,11 +74,11 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
71
74
  - 晋升与索引分别记录。索引失败保留已晋升知识并显示待修复,不重新调用模型。
72
75
  - 项目、领域、智脑检索使用包含范围。向量只提供排序,显示内容必须来自当前真实文件;索引不可用时降级为文件检索。
73
76
  - 候选必须有已晋升文件中的引用依据,未确认候选不能进入任务。确认记录原子持久化后写共享记忆文件,派生索引异步更新,索引失败不会撤销有效记忆。
74
- - 旧脚手架演示状态先备份,不显示成真实项目。损坏的正式状态不覆盖、不清空。
77
+ - 旧格式不读取、不自动迁移。损坏的正式状态不覆盖、不清空。
75
78
 
76
79
  ## 验证与尚未验收项
77
80
 
78
- `npm test` 覆盖真实 Git/Hook、真实分项目 journal、真实 vector-hub 文件索引、晋升、重复抑制、FIFO/并发、记忆、迁移及进程隔离。自动测试中的模型和 embedding 是可控替身。
81
+ `npm test` 覆盖真实 Git/Hook、真实分项目 journal、真实 vector-hub 文件索引、晋升、重复抑制、FIFO/并发、记忆、旧格式拒绝、新版恢复及进程隔离。自动测试中的模型和 embedding 是可控替身。
79
82
 
80
83
  `npm run test:web` 使用兄弟源码仓库启动隔离的五服务网页环境,只有模型与 embedding 是替身。单一 1920×1080 视口验证双目录 UI 导入、真实 Hook 到自动刷新、原生消息流与中止、快速项目切换/草稿/历史、迟到检索及文件响应、领域检索、记忆落盘、四 iframe 身份与主题、唯一进度连接、诊断关闭和保留数据解绑;不会在用户代码仓库提交。该脚本是开发验收工具,不属于引擎生产运行依赖。
81
84
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanqianx/ai-coding-knowledge-engine",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Knowledge engine module for project-knowledge: commit-boundary triggered knowledge analysis/generation (trigger plane) and retrieval for development assistants (retrieval plane).",
5
5
  "license": "Apache-2.0",
6
6
  "main": "src/server.js",
@@ -97,6 +97,14 @@ class EngineCoordinator extends EventEmitter {
97
97
  this.ready.then(() => this.pump());
98
98
  return run;
99
99
  }
100
+ async removeProject(projectId) {
101
+ await this.ready;
102
+ // An indexing job can still hold a write lease even after its state changes.
103
+ if (this.active.has(projectId)) throw Object.assign(new Error('此项目正在执行知识维护,请等待写入结束后再解绑。'), { status: 409, code: 'PROJECT_BUSY' });
104
+ const result = this.store.removeProject(projectId);
105
+ if (result.removed) this.emit('progress', this.store.addEvent({ type: 'project.unregister', projectId, summary: `已解绑项目,取消 ${result.cancelledRuns} 个未开始任务;知识与提交证据保留。` }));
106
+ return result;
107
+ }
100
108
  drain() {
101
109
  if (this.drainJob) return this.drainJob;
102
110
  if (this.closed) return Promise.resolve();
@@ -115,8 +123,8 @@ class EngineCoordinator extends EventEmitter {
115
123
  let through = cursor;
116
124
  for (const event of events) {
117
125
  if (this.closed) break;
118
- const p = this.store.listProjects().find(p => (journal.journalId !== 'global' && p.journalId === journal.journalId)
119
- || ((journal.journalId === 'global' || !p.journalId) && event.repoIdentity?.workspaceRoot && this.layout.pathsEqual(p.repoPath, event.repoIdentity.workspaceRoot)));
126
+ const p = this.store.listProjects().find(p => p.enabled !== false && ((journal.journalId !== 'global' && p.journalId === journal.journalId)
127
+ || ((journal.journalId === 'global' || !p.journalId) && event.repoIdentity?.workspaceRoot && this.layout.pathsEqual(p.repoPath, event.repoIdentity.workspaceRoot))));
120
128
  if (p) {
121
129
  if (event.schema === 'ai-coding-event/v1' && ['user_prompt', 'assistant_response'].includes(event.eventType)) await this.conversations.appendBridgeEvent(p.projectId, { ...event, journalId: journal.journalId });
122
130
  if (event.schema === 'git-commit-boundary/v1' && /^[a-f0-9]{7,64}$/i.test(event.commitSha || '')) {
@@ -124,7 +132,7 @@ class EngineCoordinator extends EventEmitter {
124
132
  const baseline = p.runtime.lastAnalyzedCommit || p.trackingStartCommit;
125
133
  const completed = this.reconciler.processingLedger.read(p.projectId, event.commitSha);
126
134
  const historical = baseline && (baseline === event.commitSha || (await execGit(p.repoPath, ['merge-base', '--is-ancestor', event.commitSha, baseline])).ok);
127
- if (!completed && !historical) this.enqueue({ projectId: p.projectId, commitSha: event.commitSha, trigger: 'git-hook', journalId: journal.journalId, journalSequence: event.sequence });
135
+ if (!completed && !historical && this.store.getProject(p.projectId)?.enabled) this.enqueue({ projectId: p.projectId, commitSha: event.commitSha, trigger: 'git-hook', journalId: journal.journalId, journalSequence: event.sequence });
128
136
  }
129
137
  }
130
138
  through = event.sequence;
@@ -5,7 +5,6 @@ const fs = require('node:fs');
5
5
  const crypto = require('node:crypto');
6
6
  const { EngineStore } = require('./store');
7
7
  const { EngineCoordinator } = require('./coordinator');
8
- const { importLegacyState } = require('./migration');
9
8
  const AtomicFile = require('./runtime/atomic-file');
10
9
 
11
10
  const VERSION = require('../package.json').version;
@@ -13,7 +12,7 @@ const UI_DIR = path.resolve(__dirname, '..', 'ui');
13
12
  const STATIC_FILES = new Map([['/', ['index.html', 'text/html']], ['/index.html', ['index.html', 'text/html']], ['/app.js', ['app.js', 'text/javascript']], ['/app.css', ['app.css', 'text/css']]]);
14
13
 
15
14
  function createEngineServer(options = {}) {
16
- const dataDir = options.dataDir || path.join(process.cwd(), 'data');
15
+ const dataDir = options.dataDir || path.join(process.cwd(), 'data-v3');
17
16
  const store = options.store || new EngineStore(dataDir, { deferLoad: true });
18
17
  let releaseLease;
19
18
  const coordinator = new EngineCoordinator(store, { ...options, initializeHook: async () => {
@@ -46,14 +45,13 @@ function createEngineServer(options = {}) {
46
45
  }
47
46
  if (pathname.startsWith('/api/')) {
48
47
  const body = await readBody(req);
49
- if (pathname === '/api/migrate' && req.method === 'POST') {
50
- if (!trusted(req)) throw httpError(403, 'RUNTIME_AUTH_REQUIRED', '仅允许受信任外壳迁移运行状态。');
51
- await coordinator.ready;
52
- send(res, 200, { ok: true, ...await importLegacyState(coordinator, body) }); return;
48
+ if (pathname === '/api/runtime-auth' && req.method === 'GET') {
49
+ if (!trusted(req)) throw httpError(403, 'RUNTIME_AUTH_REQUIRED', '仅允许受信任外壳检查连接。');
50
+ send(res, 200, { ok: true, dataSchemaVersion: store.state.schemaVersion }); return;
53
51
  }
54
52
  if (pathname === '/api/control' && req.method === 'POST') {
55
53
  if (!trusted(req)) throw httpError(403, 'RUNTIME_AUTH_REQUIRED', '仅允许受信任外壳切换运行所有权。');
56
- if (body.enabled !== true) throw httpError(400, 'INVALID_ARGUMENT', '只支持完成迁移后的启用操作。');
54
+ if (body.enabled !== true) throw httpError(400, 'INVALID_ARGUMENT', '只支持外壳完成接线后的启用操作。');
57
55
  await coordinator.activate(); send(res, 200, { ok: true }); return;
58
56
  }
59
57
  if (pathname === '/api/runtime-config' && req.method === 'POST') {
@@ -102,7 +100,7 @@ function createEngineServer(options = {}) {
102
100
  }
103
101
 
104
102
  async function api(method, pathname, search, body, store, coordinator, ctx) {
105
- if (method === 'GET' && pathname === '/api/health') return { name: 'knowledge-engine', version: VERSION, debug: ctx.debug, executionEnabled: coordinator.executionEnabled, uptimeSec: Math.floor((Date.now() - ctx.startedAt) / 1000), dataDir: ctx.dataDir, counts: { projects: store.state.projects.length, runs: store.state.runs.length, running: coordinator.active.size }, journal: { cursor: store.state.cursor, cursors: store.state.journalCursors, consumer: 'knowledge-engine', enabled: coordinator.consume, error: coordinator.journalError || null }, concurrency: coordinator.concurrency };
103
+ if (method === 'GET' && pathname === '/api/health') return { name: 'knowledge-engine', version: VERSION, dataSchemaVersion: store.state.schemaVersion, debug: ctx.debug, executionEnabled: coordinator.executionEnabled, uptimeSec: Math.floor((Date.now() - ctx.startedAt) / 1000), dataDir: ctx.dataDir, counts: { projects: store.state.projects.length, runs: store.state.runs.length, running: coordinator.active.size }, journal: { cursor: store.state.cursor, cursors: store.state.journalCursors, consumer: 'knowledge-engine', enabled: coordinator.consume, error: coordinator.journalError || null }, concurrency: coordinator.concurrency };
106
104
  if (method === 'GET' && pathname === '/api/projects') return { projects: store.listProjects() };
107
105
  if (method === 'GET' && pathname === '/api/memories') return { memories: coordinator.memories.list(Object.fromEntries(search)) };
108
106
  const memoryMatch = pathname.match(/^\/api\/memories\/([^/]+)$/);
@@ -118,8 +116,9 @@ async function api(method, pathname, search, body, store, coordinator, ctx) {
118
116
  }
119
117
  const projectMatch = pathname.match(/^\/api\/projects\/([^/]+)$/);
120
118
  if (method === 'DELETE' && projectMatch) {
121
- if (!store.removeProject(decodeURIComponent(projectMatch[1]))) throw httpError(404, 'NOT_FOUND', '项目未登记。');
122
- return { removed: true, keptData: true };
119
+ const result = await coordinator.removeProject(decodeURIComponent(projectMatch[1]));
120
+ if (!result.removed) throw httpError(404, 'NOT_FOUND', '项目未登记。');
121
+ return { ...result, keptData: true };
123
122
  }
124
123
  if (method === 'GET' && pathname === '/api/runs') return { runs: store.listRuns(search.get('projectId')) };
125
124
  const runMatch = pathname.match(/^\/api\/runs\/([^/]+)(\/retry)?$/);
@@ -14,21 +14,14 @@ class EngineStore {
14
14
  constructor(dataDir, options = {}) {
15
15
  this.dataDir = dataDir;
16
16
  this.file = path.join(dataDir, 'engine-state.json');
17
- this.state = { schemaVersion: 2, projects: [], runs: [], events: [], cursor: 0, journalCursors: {}, memories: [], sharedScopes: [] };
17
+ this.state = { schemaVersion: 3, projects: [], runs: [], events: [], cursor: 0, journalCursors: {}, memories: [], sharedScopes: [] };
18
18
  if (!options.deferLoad) this.load();
19
19
  }
20
20
 
21
21
  load() {
22
22
  try {
23
23
  const raw = JSON.parse(fs.readFileSync(this.file, 'utf8'));
24
- if (raw && (raw.schemaVersion === undefined || raw.schemaVersion === 1)) {
25
- const backup = path.join(this.dataDir, `legacy-debug-state-${crypto.randomUUID()}.json`);
26
- require('./runtime/atomic-file').writeJsonAtomic(backup, raw);
27
- this.state.legacyStateBackup = backup;
28
- this.save();
29
- return;
30
- }
31
- if (raw?.schemaVersion !== 2) throw new Error('Unsupported engine state');
24
+ if (raw?.schemaVersion !== 3) throw Object.assign(new Error('不再读取旧版引擎数据,请使用新的空数据目录重新导入项目。'), { code: 'SCHEMA_UNSUPPORTED' });
32
25
  if (!raw || !Array.isArray(raw.projects) || !Array.isArray(raw.runs) || !Array.isArray(raw.events) || !Number.isSafeInteger(raw.cursor) || raw.cursor < 0) throw new Error('Invalid engine state');
33
26
  if (raw.memories && !Array.isArray(raw.memories) || raw.sharedScopes && !Array.isArray(raw.sharedScopes) || raw.journalCursors && (typeof raw.journalCursors !== 'object' || Array.isArray(raw.journalCursors))) throw new Error('Invalid engine projections');
34
27
  const ids = new Set();
@@ -40,7 +33,7 @@ class EngineStore {
40
33
  const runIds = new Set();
41
34
  for (const run of raw.runs) {
42
35
  validateProjectId(run.projectId);
43
- if (!/^run-[a-f0-9-]{36}$/.test(run.runId || '') || runIds.has(run.runId) || !/^[a-f0-9]{40}$/.test(run.commitSha || '') || !['queued', 'running', 'failed', 'succeeded', 'index-pending'].includes(run.state) || !Number.isSafeInteger(run.sequence) || run.sequence < 1) throw new Error('Invalid queued run');
36
+ if (!/^run-[a-f0-9-]{36}$/.test(run.runId || '') || runIds.has(run.runId) || !/^[a-f0-9]{40}$/.test(run.commitSha || '') || !['queued', 'running', 'failed', 'succeeded', 'index-pending', 'cancelled'].includes(run.state) || !Number.isSafeInteger(run.sequence) || run.sequence < 1) throw new Error('Invalid queued run');
44
37
  runIds.add(run.runId);
45
38
  }
46
39
  for (const [journalId, sequence] of Object.entries(raw.journalCursors || {})) if (!/^(global|sha256:[a-f0-9]{64})$/.test(journalId) || !Number.isSafeInteger(sequence) || sequence < 0) throw new Error('Invalid journal cursor');
@@ -48,6 +41,7 @@ class EngineStore {
48
41
  for (const memory of raw.memories || []) if (!memory || !['candidate', 'confirmed', 'ignored'].includes(memory.status) || typeof memory.text !== 'string' || !memory.evidence?.projectId || !/^[a-f0-9]{40}$/.test(memory.evidence.commitSha || '')) throw new Error('Invalid memory record');
49
42
  this.state = { ...this.state, ...raw };
50
43
  } catch (error) {
44
+ if (error.code === 'SCHEMA_UNSUPPORTED') throw error;
51
45
  if (error.code !== 'ENOENT') throw Object.assign(new Error('引擎状态损坏,已保留原文件;请从备份恢复。'), { code: 'DATA_CORRUPT', cause: error });
52
46
  }
53
47
  }
@@ -129,11 +123,14 @@ class EngineStore {
129
123
  }
130
124
 
131
125
  removeProject(projectId) {
132
- if (this.state.runs.some(r => r.projectId === projectId && ['queued', 'running'].includes(r.state))) throw Object.assign(new Error('请等待当前维护运行结束后再解绑。'), { status: 409, code: 'PROJECT_BUSY' });
126
+ if (this.state.runs.some(r => r.projectId === projectId && r.state === 'running')) throw Object.assign(new Error('此项目正在执行知识维护,请等待写入结束后再解绑。'), { status: 409, code: 'PROJECT_BUSY' });
133
127
  const before = this.state.projects.length;
128
+ const now = new Date().toISOString();
129
+ const cancelled = this.state.runs.filter(r => r.projectId === projectId && r.state === 'queued');
130
+ for (const run of cancelled) Object.assign(run, { state: 'cancelled', phase: 'cancelled', finishedAt: now, cancellationReason: 'project-unbound' });
134
131
  this.state.projects = this.state.projects.filter(p => p.projectId !== projectId);
135
132
  this.save();
136
- return this.state.projects.length < before;
133
+ return { removed: this.state.projects.length < before, cancelledRuns: cancelled.length };
137
134
  }
138
135
 
139
136
  listRuns(projectId) {
@@ -50,7 +50,7 @@ function fmtTime(iso) {
50
50
  }
51
51
  function fmtDuration(ms) { return ms == null ? '–' : ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; }
52
52
  function shortSha(sha) { return sha ? sha.slice(0, 7) : '–'; }
53
- function stateBadge(state) { return `<span class="badge ${escapeHtml(state)}">${escapeHtml(({ queued: '排队', running: '运行中', succeeded: '可检索', failed: '失败', 'index-pending': '索引待修复' })[state] || state)}</span>`; }
53
+ function stateBadge(state) { return `<span class="badge ${escapeHtml(state)}">${escapeHtml(({ queued: '排队', running: '运行中', succeeded: '可检索', failed: '失败', 'index-pending': '索引待修复', cancelled: '已取消' })[state] || state)}</span>`; }
54
54
  function typeBadge(type) { return `<span class="badge type">${escapeHtml(type)}</span>`; }
55
55
  function scopedPath(path) { return scopedProjectId ? `${path}${path.includes('?') ? '&' : '?'}projectId=${encodeURIComponent(scopedProjectId)}` : path; }
56
56
  function successfulRun(runs) { return runs.find(run => run.promotedAt) || null; }
@@ -0,0 +1,53 @@
1
+ const assert = require('assert/strict');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+ const { StorageLayout } = require('../lib/storage-layout');
7
+ const { SettingsStore } = require('../lib/settings-store');
8
+ const { ProjectRegistryStore } = require('../lib/project-registry-store');
9
+ const { ProjectStore } = require('../lib/project-store');
10
+ const { ProjectLifecycleService } = require('../lib/project-lifecycle-service');
11
+ const hooks = require('../lib/hook-manager');
12
+
13
+ (async () => {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-fresh-hook-'));
15
+ const layout = new StorageLayout({ dataDir: path.join(root, 'fresh') });
16
+ const settingsStore = new SettingsStore({ layout });
17
+ const registryStore = new ProjectRegistryStore({ layout });
18
+ const projectStore = new ProjectStore({ layout });
19
+ const triggerScriptPath = path.resolve(__dirname, '../scripts/hook-trigger.js');
20
+ await settingsStore.initialize({ knowledge: { rootPath: path.join(root, 'knowledge') } });
21
+ await registryStore.initialize();
22
+ const options = { layout, settingsStore, registryStore, projectStore, triggerScriptPath };
23
+ const lifecycle = new ProjectLifecycleService(options);
24
+ const repo = path.join(root, '中文 项目'); fs.mkdirSync(repo);
25
+ const git = args => execFileSync('git', args, { cwd: repo, windowsHide: true, encoding: 'utf8' }).trim();
26
+ git(['init', '-q']); git(['config', 'user.name', 'Fresh Test']); git(['config', 'user.email', 'fresh@example.invalid']);
27
+ fs.writeFileSync(path.join(repo, 'user-code.txt'), 'unchanged'); git(['add', '.']); git(['commit', '-qm', 'baseline']);
28
+ const old = hooks.installHook({ repoPath: repo, projectId: 'project-old', triggerScriptPath, dataDir: path.join(root, 'old') });
29
+ const oldBytes = fs.readFileSync(old.hookPath, 'utf8');
30
+ const input = { localPath: repo, projectId: 'project-new', storageName: 'new' };
31
+ const preflight = await lifecycle.preflightImport(input);
32
+ assert.equal(preflight.checks.hook.replacementProjectId, 'project-old');
33
+ for (const confirmation of [undefined, 'project-wrong']) {
34
+ await assert.rejects(lifecycle.importProject({ ...input, replaceManagedHookProjectId: confirmation }), { code: 'HOOK_CONFLICT' });
35
+ assert.equal(fs.readFileSync(old.hookPath, 'utf8'), oldBytes);
36
+ assert.equal(registryStore.listIds().length, 0);
37
+ }
38
+ const brokenRegistry = Object.create(registryStore);
39
+ brokenRegistry.add = async () => { throw new Error('registry write failed'); };
40
+ await assert.rejects(new ProjectLifecycleService({ ...options, registryStore: brokenRegistry }).importProject({ ...input, replaceManagedHookProjectId: 'project-old' }));
41
+ assert.equal(fs.readFileSync(old.hookPath, 'utf8'), oldBytes, 'failed import restores the old managed hook');
42
+ const result = await lifecycle.importProject({ ...input, replaceManagedHookProjectId: 'project-old' });
43
+ assert.equal(hooks.readHookStatus({ repoPath: repo }).projectId, 'project-new');
44
+ assert.equal(result.state.lastAnalyzedCommit, null);
45
+ assert.equal(result.state.trackingStartCommit, git(['rev-parse', 'HEAD']));
46
+ assert.deepEqual(fs.readdirSync(result.config.knowledgePath), []);
47
+ assert.equal(fs.readFileSync(path.join(repo, 'user-code.txt'), 'utf8'), 'unchanged');
48
+ await lifecycle.deleteProject('project-new');
49
+ const thirdParty = '#!/bin/sh\necho third-party\n'; fs.writeFileSync(old.hookPath, thirdParty);
50
+ await assert.rejects(lifecycle.importProject({ ...input, replaceManagedHookProjectId: 'project-old' }), { code: 'HOOK_CONFLICT' });
51
+ assert.equal(fs.readFileSync(old.hookPath, 'utf8'), thirdParty);
52
+ console.log('PASS explicit fresh Hook replacement, rollback, baseline, third-party protection');
53
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,42 @@
1
+ const assert = require('assert/strict');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { initializeProductData, DATA_SCHEMA } = require('../lib/product-data');
6
+ const { resolveDataDirPath } = require('../lib/data-dir');
7
+ const { connectEngine } = require('../lib/engine-connection');
8
+
9
+ (async () => {
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-fresh-product-'));
11
+ assert.equal(resolveDataDirPath({ dataDir: '', homeDir: root }), path.join(root, '.project-knowledge-v5'));
12
+ const old = path.join(root, 'old'); fs.mkdirSync(old);
13
+ const bytes = JSON.stringify({ projects: ['old-project'], activeClaim: 'old-claim' });
14
+ fs.writeFileSync(path.join(old, 'projects.json'), bytes);
15
+ assert.throws(() => initializeProductData(old), { code: 'SCHEMA_UNSUPPORTED' });
16
+ assert.equal(fs.readFileSync(path.join(old, 'projects.json'), 'utf8'), bytes);
17
+ const fresh = path.join(root, 'fresh'); initializeProductData(fresh); initializeProductData(fresh);
18
+ assert.equal(JSON.parse(fs.readFileSync(path.join(fresh, 'product-data.json'))).schema, DATA_SCHEMA);
19
+ const paths = [];
20
+ let activationStatus = 403;
21
+ const logs = [];
22
+ const runtime = {
23
+ analysisOwner: 'engine', registryStore: { listIds: () => [] }, logger: { warn: async (...args) => logs.push(args) },
24
+ moduleBridge: { engineUrl: 'http://engine.invalid', runtimeToken: 'private-local-token', syncEngineConfiguration: async () => {}, _fetch: async url => {
25
+ paths.push(url);
26
+ if (url.endsWith('/api/health')) return { ok: true, status: 200, json: async () => ({ name: 'knowledge-engine', version: '0.2.0', dataSchemaVersion: 3 }) };
27
+ if (url.endsWith('/api/runtime-auth')) return { ok: true, status: 200 };
28
+ return { ok: activationStatus === 200, status: activationStatus };
29
+ } },
30
+ };
31
+ await connectEngine(runtime);
32
+ assert.equal(runtime.ownership.ready, false);
33
+ assert.equal(runtime.ownership.error.code, 'RUNTIME_AUTH_REQUIRED');
34
+ assert.equal(runtime.ownership.error.step, 'engine-activation');
35
+ assert.ok(!JSON.stringify(logs).includes('private-local-token'));
36
+ assert.ok(paths.every(url => !url.includes('migrate')));
37
+ await connectEngine(runtime);
38
+ assert.equal(logs.length, 1, 'identical connection failures are not repeatedly logged');
39
+ activationStatus = 200; await connectEngine(runtime);
40
+ assert.deepEqual(runtime.ownership, { owner: 'engine', ready: true });
41
+ console.log('PASS fresh data isolation, legacy rejection, precise connection failure, recovery without migration');
42
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -33,7 +33,8 @@ const { ModuleBridge } = require('../lib/module-bridge');
33
33
  assert.equal(status.model, true); assert.equal(status.embedding, true);
34
34
  assert.equal(appliedModel.mainModel, 'durable-default'); assert.equal(appliedVector.model, 'embedding-model');
35
35
  assert.ok(!JSON.stringify(status).includes('key'));
36
- assert.ok(hits.indexOf('/api/claude-workbench/v1/internal/import-embedding') < hits.indexOf('/api/internal/retire-embedding'));
36
+ assert.ok(!hits.some(route => /import-embedding|retire-embedding/.test(route)), 'fresh product must not import old credentials');
37
+ assert.ok(!hits.includes('/api/internal/embedding-config'));
37
38
  profile = null; settings = null;
38
39
  const revoked = await bridge.syncEngineConfiguration();
39
40
  assert.equal(revoked.model, false); assert.equal(revoked.embedding, false);
@@ -51,6 +51,7 @@ function commit(target, name) {
51
51
  settingsStore: { read: () => ({ knowledge: { rootPath: knowledgeRoot }, ai: { schema: 'ai-profiles/v1', profiles: [{ id: 'test-profile', enabled: true, vendor: 'anthropic', model: 'm' }], defaultProfileId: null } }) },
52
52
  triggerScriptPath: path.join(temp, 'hook-trigger.js'),
53
53
  hookManager: {
54
+ readHookStatus: () => ({ kbManaged: false }),
54
55
  installHook: options => { installed.push(options.projectId); return { ok: true, managedVersion: 2 }; },
55
56
  uninstallHook: () => ({ ok: true }),
56
57
  },
@@ -1,81 +1,13 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const os = require('os');
4
- const crypto = require('crypto');
5
- const { LEGACY_ASSETS, assetPath } = require('./legacy-data-manifest');
6
- const { isEmptyJson } = require('./data-state-classifier');
7
-
8
- let _resolved = null;
9
-
10
- function resolveDataDirPath(options = {}) {
11
- const fromEnv = options.dataDir === undefined ? process.env.KB_DATA_DIR : options.dataDir;
12
- const homeDir = options.homeDir || os.homedir();
13
- return path.resolve(fromEnv || path.join(homeDir, '.project-knowledge'));
14
- }
15
- function ensureDataDir(dataDir = resolveDataDirPath()) { fs.mkdirSync(dataDir, { recursive: true }); return path.resolve(dataDir); }
16
- function getDataDir() { if (!_resolved) _resolved = resolveDataDirPath(); return _resolved; }
17
- function _resetCache() { _resolved = null; }
18
- function fileDigest(filePath) { return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); }
19
- function directoryDigest(root) {
20
- const hash = crypto.createHash('sha256');
21
- const walk = current => {
22
- for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
23
- const filePath = path.join(current, entry.name);
24
- const relative = path.relative(root, filePath).replace(/\\/g, '/');
25
- hash.update(`${entry.isDirectory() ? 'd' : 'f'}:${relative}\0`);
26
- if (entry.isDirectory()) walk(filePath); else if (entry.isFile()) hash.update(fs.readFileSync(filePath));
27
- }
28
- };
29
- walk(root); return hash.digest('hex');
30
- }
31
- function isEmptyAsset(filePath, asset) {
32
- if (!fs.existsSync(filePath)) return true;
33
- if (asset.kind === 'dir') return fs.readdirSync(filePath).length === 0;
34
- if (fs.statSync(filePath).size === 0) return true;
35
- if (!filePath.endsWith('.json')) return false;
36
- try { const raw = fs.readFileSync(filePath, 'utf8'); return isEmptyJson(JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw)); } catch { return false; }
37
- }
38
- function assetsEqual(source, target, asset) { return asset.kind === 'dir' ? directoryDigest(source) === directoryDigest(target) : fileDigest(source) === fileDigest(target); }
39
- function copyDir(source, target) {
40
- fs.mkdirSync(target, { recursive: true });
41
- for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
42
- const from = path.join(source, entry.name); const to = path.join(target, entry.name);
43
- if (entry.isDirectory()) copyDir(from, to); else if (entry.isFile()) fs.copyFileSync(from, to);
44
- }
45
- }
46
-
47
- function migrateFromLegacy({ legacyRoot, logger } = {}) {
48
- if (process.env.KB_SKIP_MIGRATION === '1') return { ok: true, migrated: false, reason: 'skipped via KB_SKIP_MIGRATION=1' };
49
- if (!legacyRoot || !fs.existsSync(legacyRoot)) return { ok: true, migrated: false, reason: legacyRoot ? 'legacy root does not exist' : 'no legacy root provided' };
50
- const dataDir = getDataDir();
51
- if (path.resolve(legacyRoot) === dataDir) return { ok: true, migrated: false, reason: 'legacy root equals data dir' };
52
- // Packaged application roots may include empty scaffolding such as
53
- // `projects/`. It is not user data and must never conflict with populated
54
- // runtime data during startup relocation.
55
- const sources = LEGACY_ASSETS.filter(asset => {
56
- const source = assetPath(legacyRoot, asset);
57
- return fs.existsSync(source) && !isEmptyAsset(source, asset);
58
- });
59
- if (!sources.length) return { ok: true, migrated: false, reason: 'no legacy assets found' };
60
- const conflicts = [];
61
- for (const asset of sources) {
62
- const source = assetPath(legacyRoot, asset); const target = assetPath(dataDir, asset, 'target');
63
- if (fs.existsSync(target) && !isEmptyAsset(target, asset) && !assetsEqual(source, target, asset)) conflicts.push(asset.target);
64
- }
65
- if (conflicts.length) return { ok: false, migrated: false, requiresManualRecovery: true, reason: 'legacy-data-conflict', conflicts, source: legacyRoot, target: dataDir };
66
- ensureDataDir(dataDir);
67
- const result = { ok: true, migrated: false, files: 0, dirs: 0, source: legacyRoot, target: dataDir };
68
- try {
69
- for (const asset of sources) {
70
- const source = assetPath(legacyRoot, asset); const target = assetPath(dataDir, asset, 'target');
71
- if (fs.existsSync(target) && assetsEqual(source, target, asset)) continue;
72
- if (asset.kind === 'dir') { if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true }); copyDir(source, target); result.dirs++; }
73
- else { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(source, target); result.files++; }
74
- }
75
- result.migrated = result.files > 0 || result.dirs > 0;
76
- if (result.migrated && logger) logger(`migrated runtime data to ${dataDir} (${result.files} files, ${result.dirs} dirs from ${legacyRoot})`);
77
- return result;
78
- } catch (error) { return { ...result, ok: false, error: `failed to relocate legacy runtime data: ${error.message}` }; }
79
- }
80
-
81
- module.exports = { resolveDataDirPath, ensureDataDir, getDataDir, migrateFromLegacy, LEGACY_ASSETS, _resetCache };
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ let resolved = null;
6
+ function resolveDataDirPath(options = {}) {
7
+ const configured = options.dataDir === undefined ? process.env.KB_DATA_DIR : options.dataDir;
8
+ return path.resolve(configured || path.join(options.homeDir || os.homedir(), '.project-knowledge-v5'));
9
+ }
10
+ function ensureDataDir(dataDir = resolveDataDirPath()) { fs.mkdirSync(dataDir, { recursive: true }); return path.resolve(dataDir); }
11
+ function getDataDir() { if (!resolved) resolved = resolveDataDirPath(); return resolved; }
12
+ function _resetCache() { resolved = null; }
13
+ module.exports = { resolveDataDirPath, ensureDataDir, getDataDir, _resetCache };
@@ -0,0 +1,67 @@
1
+ const { DomainError } = require('./contracts');
2
+ const { execGit } = require('./git-runner');
3
+
4
+ async function connectEngine(runtime) {
5
+ if (runtime.analysisOwner === 'legacy' || runtime.engineConnecting) return;
6
+ runtime.engineConnecting = true;
7
+ let step = 'engine-health';
8
+ let projectId = '';
9
+ let httpStatus = 0;
10
+ try {
11
+ const bridge = runtime.moduleBridge;
12
+ if (bridge.engineCompatible === undefined) bridge.engineCompatible = false;
13
+ const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${bridge.runtimeToken}` };
14
+ const health = await bridge._fetch(`${bridge.engineUrl}/api/health`);
15
+ httpStatus = health.status;
16
+ if (!health.ok) throw new Error('ENGINE_UNAVAILABLE');
17
+ const engine = await health.json();
18
+ if (engine.name !== 'knowledge-engine' || engine.dataSchemaVersion !== 3) throw new Error('ENGINE_VERSION_MISMATCH');
19
+ step = 'runtime-authentication';
20
+ const auth = await bridge._fetch(`${bridge.engineUrl}/api/runtime-auth`, { headers });
21
+ httpStatus = auth.status;
22
+ if (!auth.ok) throw new Error('RUNTIME_AUTH_REQUIRED');
23
+ bridge.engineCompatible = true;
24
+ step = 'project-wiring';
25
+ for (const id of runtime.registryStore.listIds()) {
26
+ projectId = id;
27
+ const config = runtime.projectStore.readConfig(id);
28
+ const modules = await bridge.registerProjectLinks({ projectId: id, name: config.displayName, workspacePath: config.repoPath, knowledgePath: config.knowledgePath });
29
+ if (!modules.engine.ok || !modules.eventBridge.ok) throw new Error('PROJECT_WIRING_PENDING');
30
+ }
31
+ projectId = '';
32
+ step = 'runtime-configuration';
33
+ await bridge.syncEngineConfiguration();
34
+ step = 'engine-activation';
35
+ const activated = await bridge._fetch(`${bridge.engineUrl}/api/control`, { method: 'POST', headers, body: JSON.stringify({ enabled: true }) });
36
+ httpStatus = activated.status;
37
+ if (!activated.ok) throw new Error(activated.status === 403 ? 'RUNTIME_AUTH_REQUIRED' : 'ENGINE_ACTIVATION_FAILED');
38
+ runtime.ownership = { owner: 'engine', ready: true };
39
+ } catch (error) {
40
+ const codes = ['ENGINE_UNAVAILABLE', 'ENGINE_VERSION_MISMATCH', 'PROJECT_WIRING_PENDING', 'RUNTIME_AUTH_REQUIRED', 'ENGINE_ACTIVATION_FAILED'];
41
+ const code = codes.includes(error.message) ? error.message : 'ENGINE_CONNECTION_FAILED';
42
+ if (['ENGINE_VERSION_MISMATCH', 'RUNTIME_AUTH_REQUIRED'].includes(code)) runtime.moduleBridge.engineCompatible = false;
43
+ const messages = {
44
+ ENGINE_VERSION_MISMATCH: '检测到旧版知识引擎,请停止旧版服务后重新启动新版。',
45
+ RUNTIME_AUTH_REQUIRED: '引擎服务与外壳鉴权不一致,请停止旧版服务后重新启动新版。',
46
+ PROJECT_WIRING_PENDING: '项目接线未完成,请检查模块连接。',
47
+ };
48
+ const message = messages[code] || '知识引擎连接未完成,服务恢复后自动重试。';
49
+ const previous = runtime.ownership?.error;
50
+ runtime.ownership = { owner: 'engine', ready: false, message, error: { code, step, httpStatus, ...(projectId ? { projectId } : {}) } };
51
+ if (previous?.code !== code || previous?.step !== step || (previous?.projectId || '') !== projectId) await runtime.logger.warn('engine.connection_pending', message, { component: 'engine-connection', phase: step, projectId, context: { code, httpStatus } });
52
+ } finally { runtime.engineConnecting = false; }
53
+ }
54
+
55
+ async function forwardCommitToBridge(runtime, event) {
56
+ const config = runtime.projectStore.readConfig(event.projectId);
57
+ if (!runtime.layout.pathsEqual(config.repoPath, event.repoRoot) || !/^[a-f0-9]{40}$/i.test(event.head || '')) throw new DomainError('INVALID_ARGUMENT', '提交通知与项目绑定不匹配。');
58
+ const exists = await execGit(config.repoPath, ['cat-file', '-e', `${event.head}^{commit}`]);
59
+ if (!exists.ok) throw new DomainError('INVALID_ARGUMENT', '提交不属于当前仓库。');
60
+ const identity = await runtime.bridgeAdapter.resolveRepoIdentity(config.repoPath);
61
+ const result = await runtime.bridgeAdapter.appendCommitBoundary({ projectId: event.projectId, repoIdentity: identity.repoIdentity, commitSha: event.head, branch: event.branch || null, operationId: event.operationId });
62
+ if (result.status !== 'captured') throw new DomainError('MODULE_UNREACHABLE', '事件记录失败,请恢复事件模块后重试。', { status: 503 });
63
+ runtime.moduleBridge._fetch(`${runtime.moduleBridge.engineUrl}/api/bridge/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).catch(() => {});
64
+ return { journalSequence: result.boundary?.journalSequence || result.boundary?.sequence };
65
+ }
66
+
67
+ module.exports = { connectEngine, forwardCommitToBridge };
@@ -111,7 +111,7 @@ function installHook(options = {}) {
111
111
  // refusing — otherwise import promises a migration the installer
112
112
  // rejects for every repo hooked by an older KB generation.
113
113
  }
114
- if (marker && marker.projectId !== projectId) {
114
+ if (marker && marker.projectId !== projectId && options.replaceManagedHookProjectId !== marker.projectId) {
115
115
  throw new DomainError('HOOK_CONFLICT', 'The managed hook belongs to another project.', { status: 409 });
116
116
  }
117
117
  if (existing === body) return { ok: true, installed: true, updated: false, hookPath, projectId, managedVersion: MANAGED_VERSION };
@@ -6,6 +6,7 @@ const { spawnSync } = require('child_process');
6
6
  const crossSpawn = require('cross-spawn');
7
7
  const { applyEdits, modify, parse } = require('jsonc-parser');
8
8
  const packageInfo = require('../../package.json');
9
+ const { resolveDataDirPath } = require('./data-dir');
9
10
 
10
11
  // Fail-open module resolution: a missing/unloadable Bridge package must
11
12
  // degrade Development Capture status, never crash server startup.
@@ -327,7 +328,7 @@ class IntegrationManager {
327
328
  this.bridgeConsumerName = options.bridgeConsumerName || DEFAULT_BRIDGE_CONSUMER;
328
329
  this.bridgeHomeDir = options.bridgeHomeDir
329
330
  || this.env.AI_CODING_EVENT_BRIDGE_HOME
330
- || path.join(this.homeDir, '.ai-coding-event-bridge');
331
+ || path.join(resolveDataDirPath({ dataDir: this.env.KB_DATA_DIR || '', homeDir: this.homeDir }), 'events');
331
332
  this.claudeSettingsFile = options.claudeSettingsFile || '';
332
333
  this.codexConfigFile = options.codexConfigFile || '';
333
334
  this.openCodePluginsDir = options.openCodePluginsDir || '';