@sanqianx/project-knowledge 4.8.0 → 5.0.1

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 (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +32 -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/git-path-compatibility-test.js +80 -0
  13. package/_site/_test/module-model-configuration-test.js +2 -1
  14. package/_site/_test/product-diagnostics-ui-test.js +17 -3
  15. package/_site/_test/product-import-ui-test.js +12 -0
  16. package/_site/_test/tracking-start-test.js +1 -0
  17. package/_site/lib/data-dir.js +13 -81
  18. package/_site/lib/engine-connection.js +67 -0
  19. package/_site/lib/git-path.js +14 -0
  20. package/_site/lib/hook-manager.js +5 -5
  21. package/_site/lib/integration-installer.js +2 -1
  22. package/_site/lib/module-bridge.js +6 -16
  23. package/_site/lib/post-commit-automation.js +1 -1
  24. package/_site/lib/product-data.js +24 -0
  25. package/_site/lib/project-lifecycle-service.js +29 -8
  26. package/_site/lib/requirement-recorder.js +2 -2
  27. package/_site/lib/server-app.js +13 -24
  28. package/_site/scripts/hook-trigger.js +3 -2
  29. package/docs/README.zh-CN.md +8 -4
  30. package/docs/fresh-v5-start.md +34 -0
  31. package/module-runtime-manifest.json +5 -5
  32. package/package.json +3 -2
  33. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  34. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  35. package/plugins/project-knowledge/.mcp.json +1 -1
  36. package/ui/index.html +3 -2
  37. package/ui/product.js +57 -22
  38. package/vendor-manifest.json +7 -8
  39. package/_modules/knowledge-engine/src/migration.js +0 -88
  40. package/_site/_test/data-dir-migration-test.js +0 -77
  41. package/_site/_test/knowledge-migration-test.js +0 -75
  42. package/_site/_test/legacy-forward-compat-test.js +0 -257
  43. package/_site/_test/legacy-project-upgrade-e2e-test.js +0 -363
  44. package/_site/_test/p0-data-migration-characterization-test.js +0 -37
  45. package/_site/_test/p0-e2e-gate-test.js +0 -347
  46. package/_site/_test/project-layout-v2-migration-test.js +0 -127
  47. package/_site/_test/server-runtime-migration-safety-test.js +0 -27
  48. package/_site/_test/v4122-upgrade-data-contract-test.js +0 -56
  49. package/_site/lib/data-state-classifier.js +0 -40
  50. package/_site/lib/engine-cutover.js +0 -97
  51. package/_site/lib/legacy-data-manifest.js +0 -26
  52. 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.1",
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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.0.1] - 2026-10-10
4
+
5
+ - Fix project import on Git versions before 2.31: query hooks and common Git
6
+ directories without `--path-format`, then resolve relative paths against the
7
+ Git command's working directory. Reject empty, multi-line or echoed-option
8
+ responses before writing hooks or repository identities.
9
+ - Apply the same correction to commit capture and pin event-bridge console
10
+ 0.7.1 so importing a project and capturing its next commit use consistent
11
+ workspace metadata. No previous data is migrated.
12
+ - Keep import failure feedback visible with its error code and operation ID.
13
+ Persist the actual failed transaction stage; system diagnostics default to
14
+ all projects so failures of not-yet-registered imports are visible/exportable.
15
+
16
+ ## [5.0.0] - 2026-10-10
17
+
18
+ - Fresh-data-only web product. Default state moves to `~/.project-knowledge-v5`;
19
+ no previous knowledge, project registry, unfinished claims or model profiles
20
+ are imported. Explicitly selected old data directories are rejected unchanged.
21
+ - Remove shell migration/cutover and engine `/api/migrate`. Engine 0.2.0 uses
22
+ schema version 3; validate its data generation and runtime authentication
23
+ before registering projects or enabling consumption. System status reports
24
+ safe connection stage/error codes instead of silently waiting for takeover.
25
+ - Isolate Workbench, bridge journals and vector configuration under the new
26
+ product directory. No automatic embedding-credential migration.
27
+ - Queued, unstarted tasks are cancelled on unbind; active knowledge/index write
28
+ leases still block removal. The visible confirmation dialog updates project
29
+ selection immediately and rejects stale registry responses without reloading.
30
+ - Reimport can explicitly replace a leftover knowledge-managed Git Hook. Failed
31
+ imports restore the prior Hook; third-party hooks and existing knowledge files
32
+ are never overwritten. Import tracks HEAD without analyzing historical commits.
33
+
34
+
3
35
  ## [4.8.0] - 2026-10-10
4
36
 
5
37
  - 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; });
@@ -0,0 +1,80 @@
1
+ const assert = require('assert/strict');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const { resolveGitPath } = require('../lib/git-path');
7
+ const { StorageLayout } = require('../lib/storage-layout');
8
+ const { SettingsStore } = require('../lib/settings-store');
9
+ const { ProjectRegistryStore } = require('../lib/project-registry-store');
10
+ const { ProjectStore } = require('../lib/project-store');
11
+ const { ProjectLifecycleService } = require('../lib/project-lifecycle-service');
12
+ const { BridgeAdapter } = require('../lib/bridge-adapter');
13
+ const { Logger, LogRepository } = require('../lib/structured-logger');
14
+ const hooks = require('../lib/hook-manager');
15
+
16
+ (async () => {
17
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-git-path-'));
18
+ const repo = path.join(root, '中文 空格项目'); fs.mkdirSync(repo);
19
+ const git = (args, cwd = repo) => {
20
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
21
+ assert.equal(result.status, 0, result.stderr); return result.stdout.trim();
22
+ };
23
+ for (const invalid of ['', '--path-format=absolute\n.git/hooks', '.git/hooks\nother', '--unsupported', '\0']) {
24
+ assert.throws(() => resolveGitPath(repo, invalid), { code: 'INVALID_ARGUMENT' });
25
+ }
26
+ assert.equal(resolveGitPath(repo, '.git/hooks\r\n'), path.join(repo, '.git', 'hooks'));
27
+ git(['init', '-q']); git(['config', 'user.name', 'Git Compatibility']); git(['config', 'user.email', 'git-compat@example.invalid']);
28
+ fs.writeFileSync(path.join(repo, 'README.md'), '# baseline\n'); git(['add', '.']); git(['commit', '-qm', 'baseline']);
29
+ const layout = new StorageLayout({ dataDir: path.join(root, 'data') });
30
+ const settingsStore = new SettingsStore({ layout });
31
+ const registryStore = new ProjectRegistryStore({ layout });
32
+ const projectStore = new ProjectStore({ layout });
33
+ await settingsStore.initialize({ knowledge: { rootPath: path.join(root, 'knowledge') } });
34
+ await registryStore.initialize();
35
+ const bridgeAdapter = new BridgeAdapter({ dataDir: layout.getDataDir(), bridgeHomeDir: path.join(root, 'bridge') });
36
+ assert.ok(bridgeAdapter.isAvailable());
37
+ const logger = new Logger({ layout });
38
+ const triggerScriptPath = path.resolve(__dirname, '../scripts/hook-trigger.js');
39
+ const options = { layout, settingsStore, registryStore, projectStore, bridgeAdapter, logger, triggerScriptPath };
40
+ const lifecycle = new ProjectLifecycleService(options);
41
+ const input = { localPath: repo, knowledgePath: path.join(root, 'knowledge', 'fresh'), projectId: 'project-git-compat' };
42
+ const preflight = await lifecycle.preflightImport(input);
43
+ assert.equal(preflight.ready, true);
44
+ assert.equal(preflight.checks.git.commonDir, path.join(repo, '.git'));
45
+ const imported = await lifecycle.importProject(input);
46
+ assert.equal(imported.hook.hookPath, path.join(repo, '.git', 'hooks', 'post-commit'));
47
+ assert.equal(imported.config.repoIdentity.commonDir, path.join(repo, '.git'));
48
+ const nested = path.join(repo, 'src'); fs.mkdirSync(nested);
49
+ assert.equal((await bridgeAdapter.resolveRepoIdentity(nested)).repoIdentity.commonDir, imported.config.repoIdentity.commonDir);
50
+ fs.appendFileSync(path.join(repo, 'README.md'), 'commit after import\n'); git(['add', '.']); git(['commit', '-qm', 'captured update']);
51
+ const sha = git(['rev-parse', 'HEAD']);
52
+ const journal = await bridgeAdapter.readEvents({ after: 0, limit: 100 });
53
+ const boundary = journal.events.find(event => event.commitSha === sha && event.schema === 'git-commit-boundary/v1');
54
+ assert.ok(boundary, 'real Git post-commit Hook must append a durable boundary');
55
+ assert.equal(boundary.repoIdentity.commonDir, path.join(repo, '.git'));
56
+ assert.equal(boundary.projectId, input.projectId);
57
+ await lifecycle.deleteProject(input.projectId);
58
+ const brokenRegistry = Object.create(registryStore);
59
+ brokenRegistry.add = async () => { throw new Error('diagnostic registry failure'); };
60
+ await assert.rejects(new ProjectLifecycleService({ ...options, registryStore: brokenRegistry }).importProject(input), /Project import failed/);
61
+ const failed = new LogRepository({ layout }).query({ event: 'project.import.failed' }).entries[0];
62
+ assert.equal(failed.error.message, 'diagnostic registry failure');
63
+ assert.equal(failed.phase, 'hook-verified');
64
+ const transaction = JSON.parse(fs.readFileSync(lifecycle.transactionPath(failed.operationId), 'utf8'));
65
+ assert.equal(transaction.phase, 'failed'); assert.equal(transaction.failurePhase, 'hook-verified');
66
+ assert.equal(hooks.readHookStatus({ repoPath: repo }).installed, false);
67
+ const worktree = path.join(root, '关联 工作树'); git(['worktree', 'add', '-qb', 'linked', worktree]);
68
+ const sharedHook = hooks.installHook({ repoPath: worktree, projectId: 'project-worktree', triggerScriptPath });
69
+ assert.equal(path.dirname(sharedHook.hookPath), path.join(repo, '.git', 'hooks'));
70
+ hooks.uninstallHook({ repoPath: worktree, projectId: 'project-worktree' });
71
+ for (const custom of ['.custom hooks', path.join(root, '绝对 hooks')]) {
72
+ git(['config', 'core.hooksPath', custom], worktree);
73
+ const installed = hooks.installHook({ repoPath: worktree, projectId: 'project-worktree', triggerScriptPath });
74
+ assert.equal(path.dirname(installed.hookPath), path.resolve(worktree, custom));
75
+ assert.equal(hooks.readHookStatus({ repoPath: worktree }).installed, true);
76
+ hooks.uninstallHook({ repoPath: worktree, projectId: 'project-worktree' });
77
+ }
78
+ await logger.close();
79
+ console.log(`git-path-compatibility-test PASS (${git(['--version'])}; import → real Hook → journal; worktrees/custom hooks; diagnostic phase)`);
80
+ })().catch(error => { console.error(error.stack || error.message); 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);
@@ -8,10 +8,17 @@ const { spawnServer } = require('./helpers/spawn-server');
8
8
  const { findChrome } = require('./helpers/find-chrome');
9
9
  const { launchCdpBrowser, waitFor } = require('./helpers/cdp-browser');
10
10
  const { createLoggingUiFixture } = require('./helpers/logging-ui-fixture');
11
+ const { Logger } = require('../lib/structured-logger');
12
+ const { StorageLayout } = require('../lib/storage-layout');
11
13
  const ROOT = path.resolve(__dirname, '../..');
12
14
 
13
15
  async function main() {
14
- const fixture = await createLoggingUiFixture({ totalLogs: 240 });
16
+ const fixture = await createLoggingUiFixture({ totalLogs: 240, freshProduct: true });
17
+ const logger = new Logger({ layout: new StorageLayout({ dataDir: fixture.dataDir }) });
18
+ await logger.error('project.import.failed', 'Import failed for an unregistered project', {
19
+ projectId: 'project-import-not-registered', operationId: 'op-import-diagnostic',
20
+ error: Object.assign(new Error('Git Hook path resolution failed'), { code: 'ENOENT' })
21
+ });
15
22
  const offline = http.createServer((req, res) => { res.writeHead(503, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: { message: '验收中的离线模块' } })); });
16
23
  await new Promise(resolve => offline.listen(0, '127.0.0.1', resolve));
17
24
  const offlineUrl = `http://127.0.0.1:${offline.address().port}`;
@@ -35,6 +42,13 @@ async function main() {
35
42
  assert.ok(await run("document.getElementById('activityUpdates').textContent.includes('离线')"), 'offline cannot masquerade as empty/success');
36
43
  await click('#openSystem'); await click('#openLogs');
37
44
  await waitFor(() => run("document.querySelectorAll('#logsContent .log-entry').length === 100"), 'bounded diagnostic rendering');
45
+ assert.equal(await run("document.getElementById('logsScope').value"), 'all');
46
+ await run("document.getElementById('logsSearch').value='op-import-diagnostic'"); await click('#loadLogs');
47
+ await waitFor(() => run("document.getElementById('logsContent').textContent.includes('ENOENT')"), 'unregistered import visible despite active project');
48
+ await run("document.getElementById('logsScope').value='project'; document.getElementById('logsScope').dispatchEvent(new Event('change'))");
49
+ await waitFor(() => run("document.querySelectorAll('#logsContent .log-entry').length === 0"), 'current project excludes failed new import');
50
+ await run("document.getElementById('logsScope').value='all'; document.getElementById('logsScope').dispatchEvent(new Event('change'))");
51
+ await waitFor(() => run("document.getElementById('logsContent').textContent.includes('op-import-diagnostic')"), 'global diagnostic scope restored');
38
52
  assert.equal(await run("document.querySelectorAll('#logsDialog img, #logsDialog script').length"), 0);
39
53
  await run("document.getElementById('logsSearch').value='<img src=x onerror=window.__xss=1>'"); await click('#loadLogs');
40
54
  await waitFor(() => run("document.querySelectorAll('#logsContent .log-entry').length === 1"), 'real server query filter');
@@ -59,8 +73,8 @@ async function main() {
59
73
  // The registry does not own knowledge bytes. Use its actual double-directory binding.
60
74
  const project = (await (await fetch(base + '/api/projects/aggregated')).json()).projects[0];
61
75
  const retained = path.join(project.knowledgePath, 'keep.md'); fs.writeFileSync(retained, '# 保留知识\n');
62
- await run('window.confirm = () => true'); await click('[data-action="unbind"]');
63
- await waitFor(() => run("document.getElementById('toast').textContent.includes('无法安全停止写入')"), 'offline writer prevents unsafe unbind');
76
+ await click('[data-action="unbind"]'); await click('#confirmUnbind');
77
+ await waitFor(() => run("document.getElementById('unbindFeedback').textContent.includes('无法安全停止写入')"), 'offline writer prevents unsafe unbind');
64
78
  assert.equal((await (await fetch(base + '/api/projects/aggregated')).json()).projects.length, 1);
65
79
  assert.deepEqual(fs.readdirSync(fixture.repo.path), codeFiles); assert.ok(fs.existsSync(retained));
66
80
  assert.equal(browser.exceptions.length, 0, JSON.stringify(browser.exceptions));
@@ -66,6 +66,18 @@ async function main() {
66
66
  await waitFor(() => run("document.getElementById('importDialog').open && !document.getElementById('confirmImport').disabled"), 'setup returns to ready import');
67
67
  const values = await run("Object.fromEntries(['importCode','importKnowledge','importName','importDomain'].map(id=>[id,document.getElementById(id).value]))");
68
68
  assert.deepEqual(values, { importCode: repo, importKnowledge: knowledge, importName: '回归项目', importDomain: domainId });
69
+ const racedHook = path.join(repo, '.git', 'hooks', 'post-commit');
70
+ fs.writeFileSync(racedHook, '#!/bin/sh\necho third-party fixture\n');
71
+ await click('#confirmImport');
72
+ await waitFor(() => run("document.getElementById('preflightStatus').textContent.includes('HOOK_CONFLICT') && document.getElementById('preflightStatus').textContent.includes('操作 ID:op-')"), 'durable import failure feedback');
73
+ await new Promise(resolve => setTimeout(resolve, 750));
74
+ assert.ok(await run("document.getElementById('preflightStatus').textContent.includes('HOOK_CONFLICT')"), 'failed import must not turn back into a passed preflight');
75
+ assert.equal((await api('/api/projects/aggregated')).projects.length, 0);
76
+ assert.equal(fs.readFileSync(racedHook, 'utf8'), '#!/bin/sh\necho third-party fixture\n');
77
+ const failureLogs = (await api('/api/logs?event=project.import.failed')).entries;
78
+ assert.equal(failureLogs.length, 1); assert.equal(failureLogs[0].error.code, 'HOOK_CONFLICT');
79
+ assert.ok(await run(`document.getElementById('preflightStatus').textContent.includes(${JSON.stringify(failureLogs[0].operationId)})`), 'UI operation ID resolves to the persisted failure');
80
+ fs.unlinkSync(racedHook);
69
81
  await click('#confirmImport');
70
82
  await waitFor(() => run("!document.getElementById('importDialog').open && document.getElementById('projectDetail').textContent.includes('回归项目')"), 'import feedback', 30000);
71
83
  const selected = await run("document.getElementById('projectSelect').value");
@@ -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
  },