@sanqianx/project-knowledge 4.7.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 (196) hide show
  1. package/.agents/plugins/marketplace.json +17 -17
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/CHANGELOG.md +836 -799
  4. package/README.md +58 -41
  5. package/_modules/knowledge-engine/README.md +91 -0
  6. package/_modules/knowledge-engine/package.json +38 -0
  7. package/_modules/knowledge-engine/src/analysis-contract.js +25 -0
  8. package/_modules/knowledge-engine/src/analysis-worker.js +54 -0
  9. package/_modules/knowledge-engine/src/bin.js +52 -0
  10. package/_modules/knowledge-engine/src/bridge-client.js +24 -0
  11. package/_modules/knowledge-engine/src/coordinator.js +263 -0
  12. package/_modules/knowledge-engine/src/headless-analyzer.js +75 -0
  13. package/_modules/knowledge-engine/src/memories.js +85 -0
  14. package/_modules/knowledge-engine/src/retrieval.js +116 -0
  15. package/_modules/knowledge-engine/src/runtime/README.md +7 -0
  16. package/_modules/knowledge-engine/src/runtime/atomic-file.js +204 -0
  17. package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -0
  18. package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +249 -0
  19. package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -0
  20. package/_modules/knowledge-engine/src/runtime/commit-prompt.js +202 -0
  21. package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +432 -0
  22. package/_modules/knowledge-engine/src/runtime/content-hash.js +4 -0
  23. package/_modules/knowledge-engine/src/runtime/contracts.js +279 -0
  24. package/_modules/knowledge-engine/src/runtime/conversation-exclusions.js +204 -0
  25. package/_modules/knowledge-engine/src/runtime/conversation-store.js +387 -0
  26. package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -0
  27. package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -0
  28. package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +444 -0
  29. package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +402 -0
  30. package/_modules/knowledge-engine/src/runtime/layout.js +44 -0
  31. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +230 -0
  32. package/_modules/knowledge-engine/src/runtime/scanner.js +298 -0
  33. package/_modules/knowledge-engine/src/server.js +154 -0
  34. package/_modules/knowledge-engine/src/store.js +236 -0
  35. package/_modules/knowledge-engine/ui/app.css +172 -0
  36. package/_modules/knowledge-engine/ui/app.js +297 -0
  37. package/_modules/knowledge-engine/ui/index.html +95 -0
  38. package/_site/README.md +30 -30
  39. package/_site/_test/ai-profile-resolver-test.js +137 -137
  40. package/_site/_test/automation-queue-test.js +14 -14
  41. package/_site/_test/automation-ui-test.js +40 -40
  42. package/_site/_test/background-task-registry-test.js +43 -43
  43. package/_site/_test/baseline-schema-test.js +91 -91
  44. package/_site/_test/chat-claudecodeui-match-test.js +92 -92
  45. package/_site/_test/claude-executable-discovery-test.js +62 -62
  46. package/_site/_test/claude-workbench-test.js +170 -170
  47. package/_site/_test/codex-conversation-projection-test.js +2 -1
  48. package/_site/_test/commit-conversation-binding-test.js +95 -95
  49. package/_site/_test/commit-evidence-test.js +110 -110
  50. package/_site/_test/conversation-api-test.js +109 -109
  51. package/_site/_test/conversation-store-test.js +136 -136
  52. package/_site/_test/desktop-browser-compat-test.js +28 -28
  53. package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
  54. package/_site/_test/explicit-commit-processor-test.js +37 -37
  55. package/_site/_test/fixtures/make-git-repos.js +12 -1
  56. package/_site/_test/folder-picker-output-test.js +19 -19
  57. package/_site/_test/fresh-hook-reimport-test.js +53 -0
  58. package/_site/_test/fresh-product-data-test.js +42 -0
  59. package/_site/_test/full-integration-e2e-test.js +165 -165
  60. package/_site/_test/git-validation-test.js +2 -2
  61. package/_site/_test/hook-runtime-endpoint-test.js +88 -88
  62. package/_site/_test/hook-status-repair-api-test.js +152 -152
  63. package/_site/_test/hook-trigger-test.js +85 -85
  64. package/_site/_test/import-preflight-api-test.js +234 -233
  65. package/_site/_test/index-writer-concurrency-test.js +135 -135
  66. package/_site/_test/integration-adapters-test.js +171 -171
  67. package/_site/_test/integration-surface-coverage-test.js +164 -164
  68. package/_site/_test/knowledge-language-control-test.js +170 -170
  69. package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
  70. package/_site/_test/knowledge-query-test.js +54 -54
  71. package/_site/_test/knowledge-retrieval-service-test.js +77 -77
  72. package/_site/_test/knowledge-storage-startup-test.js +52 -52
  73. package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
  74. package/_site/_test/logging-api-test.js +89 -89
  75. package/_site/_test/logging-sse-no-gap-test.js +88 -88
  76. package/_site/_test/logging-ui-test.js +106 -104
  77. package/_site/_test/markdown-delta-overlay-test.js +85 -85
  78. package/_site/_test/markdown-maintenance-api-test.js +75 -75
  79. package/_site/_test/mcp-server-test.js +149 -149
  80. package/_site/_test/module-artifact-integrity-test.js +27 -0
  81. package/_site/_test/module-boundary-test.js +30 -0
  82. package/_site/_test/module-bridge-eventbridge-test.js +7 -0
  83. package/_site/_test/module-model-configuration-test.js +44 -0
  84. package/_site/_test/module-process-lifecycle-test.js +24 -0
  85. package/_site/_test/module-stream-proxy-test.js +34 -0
  86. package/_site/_test/non-release-ci-test.js +34 -34
  87. package/_site/_test/offline-boundary-isolation-test.js +32 -32
  88. package/_site/_test/packaged-ui-smoke-test.js +11 -9
  89. package/_site/_test/path-consistency-test.js +151 -151
  90. package/_site/_test/pending-sweep-test.js +9 -9
  91. package/_site/_test/post-commit-automation-test.js +87 -87
  92. package/_site/_test/product-diagnostics-ui-test.js +75 -0
  93. package/_site/_test/product-import-ui-test.js +92 -0
  94. package/_site/_test/project-delete-recovery-test.js +64 -64
  95. package/_site/_test/project-goal-editor-test.js +144 -144
  96. package/_site/_test/project-lifecycle-transaction-test.js +106 -106
  97. package/_site/_test/prompt-settings-test.js +115 -115
  98. package/_site/_test/protected-architecture-gate-test.js +122 -122
  99. package/_site/_test/refactor-characterization-test.js +36 -36
  100. package/_site/_test/release-version-sync-test.js +1 -1
  101. package/_site/_test/requirement-recorder-test.js +173 -173
  102. package/_site/_test/run-all-tests.js +159 -156
  103. package/_site/_test/shared-contracts-test.js +58 -58
  104. package/_site/_test/startup-analysis-disabled-test.js +10 -10
  105. package/_site/_test/storage-foundation-test.js +78 -78
  106. package/_site/_test/structured-logger-test.js +101 -101
  107. package/_site/_test/tracking-start-test.js +109 -108
  108. package/_site/_test/workbench-permission-test.js +117 -117
  109. package/_site/_test/workspace-ui-contract-test.js +28 -43
  110. package/_site/lib/ai-profile-resolver.js +78 -78
  111. package/_site/lib/ai-workspace.js +101 -101
  112. package/_site/lib/automation-config.js +140 -140
  113. package/_site/lib/bridge-adapter.js +1 -0
  114. package/_site/lib/bridge-consumer-service.js +402 -402
  115. package/_site/lib/claude-cli-runner.js +1510 -1510
  116. package/_site/lib/commit-conversation-binder.js +234 -234
  117. package/_site/lib/commit-processing-ledger.js +27 -27
  118. package/_site/lib/commit-prompt.js +202 -202
  119. package/_site/lib/commit-reconciler.js +406 -406
  120. package/_site/lib/contracts.js +284 -279
  121. package/_site/lib/conversation-query-service.js +144 -144
  122. package/_site/lib/conversation-store.js +385 -385
  123. package/_site/lib/data-dir.js +6 -74
  124. package/_site/lib/engine-connection.js +67 -0
  125. package/_site/lib/evidence-bundle.js +228 -228
  126. package/_site/lib/folder-picker-output.js +21 -21
  127. package/_site/lib/git-runner.js +62 -62
  128. package/_site/lib/github-team-store.js +1172 -1172
  129. package/_site/lib/hook-manager.js +203 -202
  130. package/_site/lib/index-service.js +173 -173
  131. package/_site/lib/integration-installer.js +908 -907
  132. package/_site/lib/kb-framework.js +176 -176
  133. package/_site/lib/kb-validator.js +124 -124
  134. package/_site/lib/knowledge-promotion.js +435 -435
  135. package/_site/lib/knowledge-retrieval-service.js +401 -401
  136. package/_site/lib/knowledge-tool-runtime.js +359 -359
  137. package/_site/lib/llm-client.js +161 -161
  138. package/_site/lib/markdown-knowledge-indexer.js +230 -230
  139. package/_site/lib/module-bridge.js +191 -36
  140. package/_site/lib/post-commit-automation.js +87 -87
  141. package/_site/lib/product-data.js +24 -0
  142. package/_site/lib/project-lifecycle-service.js +535 -497
  143. package/_site/lib/project-store.js +268 -262
  144. package/_site/lib/requirement-recorder.js +278 -278
  145. package/_site/lib/runtime-endpoint.js +155 -155
  146. package/_site/lib/scanner.js +298 -298
  147. package/_site/lib/server-app.js +1579 -1520
  148. package/_site/lib/settings-store.js +133 -113
  149. package/_site/lib/storage-layout.js +162 -162
  150. package/_site/lib/structured-logger.js +574 -574
  151. package/_site/scripts/folder-picker.ps1 +156 -156
  152. package/_site/scripts/hook-trigger.js +157 -156
  153. package/_site/scripts/install-module-candidates.js +69 -0
  154. package/_site/scripts/pack-module-candidates.js +72 -0
  155. package/_site/scripts/sync-release-version.js +2 -2
  156. package/_site/scripts/vendor-modules.js +47 -0
  157. package/_site/scripts/verify-module-runtime.js +45 -0
  158. package/_site/scripts/verify-product-runtime.js +34 -0
  159. package/bin/project-knowledge-mcp.js +194 -194
  160. package/docs/README.zh-CN.md +42 -29
  161. package/docs/fresh-v5-start.md +28 -0
  162. package/docs/project-registry-schema.md +22 -22
  163. package/docs/testing-strategy.md +33 -33
  164. package/module-runtime-manifest.json +220 -0
  165. package/package.json +14 -9
  166. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  167. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  168. package/plugins/project-knowledge/.mcp.json +1 -1
  169. package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
  170. package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
  171. package/templates/project-readme.md +34 -34
  172. package/ui/favicon.svg +38 -38
  173. package/ui/index.html +52 -149
  174. package/ui/product.css +9 -0
  175. package/ui/product.js +412 -0
  176. package/vendor-manifest.json +45 -0
  177. package/_site/_test/data-dir-migration-test.js +0 -77
  178. package/_site/_test/import-ui-flow-test.js +0 -171
  179. package/_site/_test/knowledge-migration-test.js +0 -75
  180. package/_site/_test/legacy-forward-compat-test.js +0 -257
  181. package/_site/_test/legacy-project-upgrade-e2e-test.js +0 -363
  182. package/_site/_test/p0-data-migration-characterization-test.js +0 -37
  183. package/_site/_test/p0-e2e-gate-test.js +0 -347
  184. package/_site/_test/project-control-panel-task14-test.js +0 -63
  185. package/_site/_test/project-layout-v2-migration-test.js +0 -127
  186. package/_site/_test/server-runtime-migration-safety-test.js +0 -27
  187. package/_site/_test/task15-20-ui-flow-test.js +0 -148
  188. package/_site/_test/ui-i18n-toggle-test.js +0 -114
  189. package/_site/_test/ui-smoke-test.js +0 -73
  190. package/_site/_test/v4122-upgrade-data-contract-test.js +0 -56
  191. package/_site/lib/data-state-classifier.js +0 -40
  192. package/_site/lib/legacy-data-manifest.js +0 -26
  193. package/_site/lib/migration-service.js +0 -442
  194. package/ui/app.css +0 -58
  195. package/ui/app.js +0 -607
  196. package/ui/i18n.js +0 -146
@@ -22,9 +22,12 @@
22
22
  * KB_EVENTBRIDGE_COMMAND explicit node command line for the event-bridge console
23
23
  */
24
24
 
25
- const { spawn } = require('child_process');
25
+ const { spawn, spawnSync } = require('child_process');
26
26
  const path = require('path');
27
27
  const fs = require('fs');
28
+ const crypto = require('crypto');
29
+ const { once } = require('events');
30
+ const { DomainError } = require('./contracts');
28
31
 
29
32
  const PROXY_TIMEOUT_MS = 10000;
30
33
  // Registration drives a full embedding pass in vector-hub (remote API); a
@@ -47,6 +50,10 @@ const MODULE_ENTRIES = {
47
50
  };
48
51
 
49
52
  function resolveModuleEntry(name) {
53
+ if (name === 'knowledge-engine') {
54
+ const runtime = path.resolve(__dirname, '..', '..', '_modules', 'knowledge-engine', 'src', 'bin.js');
55
+ return fs.existsSync(runtime) ? runtime : null;
56
+ }
50
57
  try { return require.resolve(MODULE_ENTRIES[name]); } catch { return null; }
51
58
  }
52
59
 
@@ -70,14 +77,19 @@ function defaultEventBridgeUrl() {
70
77
  return String(process.env.KB_EVENTBRIDGE_URL || 'http://127.0.0.1:8790').replace(/\/+$/, '');
71
78
  }
72
79
 
80
+ function defaultEngineUrl() { return String(process.env.KB_KNOWLEDGE_ENGINE_URL || 'http://127.0.0.1:5790').replace(/\/+$/, ''); }
81
+
73
82
  class ModuleBridge {
74
83
  constructor(options = {}) {
75
84
  this.logger = options.logger || { info: () => {}, warn: () => {}, error: () => {} };
76
85
  this.terminalUrl = options.terminalUrl || defaultTerminalUrl();
77
86
  this.vectorHubUrl = options.vectorHubUrl || defaultVectorHubUrl();
78
87
  this.eventBridgeUrl = options.eventBridgeUrl || defaultEventBridgeUrl();
88
+ this.engineUrl = options.engineUrl || defaultEngineUrl();
89
+ this.runtimeToken = options.runtimeToken || crypto.randomBytes(32).toString('hex');
79
90
  this.registryStore = options.registryStore || null;
80
91
  this.projectStore = options.projectStore || null;
92
+ this.settingsStore = options.settingsStore || null;
81
93
  this.dataDir = options.dataDir || null;
82
94
  this.supervised = [];
83
95
  }
@@ -110,7 +122,7 @@ class ModuleBridge {
110
122
  }
111
123
  }
112
124
 
113
- /** Same-origin reverse proxy for one browser request. Streams JSON bodies buffered. */
125
+ /** Same-origin proxy. SSE has a headers deadline, never a whole-turn deadline. */
114
126
  async proxy(req, res, target) {
115
127
  const chunks = [];
116
128
  for await (const chunk of req) chunks.push(chunk);
@@ -120,23 +132,45 @@ class ModuleBridge {
120
132
  const isPicker = /\/(system\/)?pick-folder/.test(target);
121
133
  const timeoutMs = isPicker ? PICKER_TIMEOUT_MS : PROXY_TIMEOUT_MS;
122
134
  let upstream;
135
+ const controller = new AbortController();
136
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
137
+ const disconnected = () => { if (!res.writableEnded) controller.abort(); };
138
+ res.once?.('close', disconnected);
123
139
  try {
124
- upstream = await this._fetch(target, {
140
+ upstream = await fetch(target, {
125
141
  method: req.method,
126
- timeoutMs,
127
- headers: body ? { 'Content-Type': req.headers['content-type'] || 'application/json' } : {},
142
+ signal: controller.signal,
143
+ headers: { ...(body ? { 'Content-Type': req.headers['content-type'] || 'application/json' } : {}), ...(req.headers['last-event-id'] ? { 'Last-Event-ID': req.headers['last-event-id'] } : {}) },
128
144
  body: ['GET', 'HEAD'].includes(req.method) ? undefined : body,
129
145
  });
130
146
  } catch (error) {
147
+ clearTimeout(timer);
148
+ res.off?.('close', disconnected);
149
+ if (res.destroyed) return;
131
150
  res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
132
151
  res.end(JSON.stringify({ ok: false, error: 'MODULE_UNREACHABLE', target }));
133
152
  return;
134
153
  }
135
- const buf = Buffer.from(await upstream.arrayBuffer());
136
- res.writeHead(upstream.status, {
137
- 'Content-Type': upstream.headers.get('content-type') || 'application/json; charset=utf-8',
138
- });
139
- res.end(buf);
154
+ const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8';
155
+ if (contentType.includes('text/event-stream')) {
156
+ clearTimeout(timer);
157
+ res.writeHead(upstream.status, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
158
+ res.flushHeaders?.();
159
+ try {
160
+ for await (const chunk of upstream.body) {
161
+ if (res.destroyed) break;
162
+ if (!res.write(Buffer.from(chunk))) await once(res, 'drain', { signal: controller.signal });
163
+ }
164
+ if (!res.destroyed) res.end();
165
+ } catch { if (!res.destroyed) res.destroy(); }
166
+ finally { controller.abort(); res.off?.('close', disconnected); }
167
+ } else {
168
+ try {
169
+ const buf = Buffer.from(await upstream.arrayBuffer());
170
+ res.writeHead(upstream.status, { 'Content-Type': contentType });
171
+ res.end(buf);
172
+ } finally { clearTimeout(timer); res.off?.('close', disconnected); }
173
+ }
140
174
  }
141
175
 
142
176
  proxyTerminal(req, res, pathname, search) {
@@ -151,6 +185,23 @@ class ModuleBridge {
151
185
  return this.proxy(req, res, this._eventBridgeApi(pathname) + (search || ''));
152
186
  }
153
187
 
188
+ proxyEngine(req, res, pathname, search) {
189
+ if (this.engineCompatible === false) throw new DomainError('ENGINE_CONNECTION_PENDING', '知识引擎连接尚未确认,请查看系统状态;不会读取旧服务中的知识。', { status: 503 });
190
+ return this.proxy(req, res, `${this.engineUrl}/api/${pathname.replace(/^\/api\/engine\/?/, '')}${search || ''}`);
191
+ }
192
+
193
+ proxyModuleUi(req, res, module, rest, search) {
194
+ const base = { terminal: this.terminalUrl, vectorhub: this.vectorHubUrl, eventbridge: this.eventBridgeUrl, engine: this.engineUrl }[module];
195
+ let decoded;
196
+ try { decoded = decodeURIComponent(rest); } catch { decoded = null; }
197
+ // Reject encoded separators/dot segments before URL normalization. Static
198
+ // mounts must never become a second route to a module's private API.
199
+ if (!base || req.method !== 'GET' || decoded === null || /[\\\x00-\x1f]/.test(decoded) || decoded.split('/').some(part => ['.', '..', 'api', 'internal'].includes(part.toLowerCase())) || /%[0-9a-f]{2}/i.test(decoded)) { res.statusCode = 404; res.end('Not found'); return; }
200
+ const target = new URL(`${base}/${rest}${search || ''}`);
201
+ if (target.origin !== new URL(base).origin) { res.statusCode = 404; res.end('Not found'); return; }
202
+ return this.proxy(req, res, target.href);
203
+ }
204
+
154
205
  async _listTerminalProjects() {
155
206
  try {
156
207
  const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects`);
@@ -179,12 +230,23 @@ class ModuleBridge {
179
230
  } catch { return []; }
180
231
  }
181
232
 
233
+ async _listEngineProjects() {
234
+ if (this.engineCompatible === false) return [];
235
+ try {
236
+ const response = await this._fetch(`${this.engineUrl}/api/projects`);
237
+ if (!response.ok) return [];
238
+ const payload = await response.json();
239
+ return Array.isArray(payload.projects) ? payload.projects : [];
240
+ } catch { return []; }
241
+ }
242
+
182
243
  /** Aggregated projection for the shell sidebar: pkb registry is the spine. */
183
244
  async aggregatedProjects() {
184
- const [terminalProjects, vectorHubProjects, eventBridgeProjects] = await Promise.all([
245
+ const [terminalProjects, vectorHubProjects, eventBridgeProjects, engineProjects] = await Promise.all([
185
246
  this._listTerminalProjects(),
186
247
  this._listVectorHubProjects(),
187
248
  this._listEventBridgeProjects(),
249
+ this._listEngineProjects(),
188
250
  ]);
189
251
  const ids = this.registryStore ? this.registryStore.listIds() : [];
190
252
  const byWorkspace = new Map(terminalProjects.map(p => [String(p.rootPath || '').toLowerCase(), p]));
@@ -209,10 +271,13 @@ class ModuleBridge {
209
271
  name: config.displayName || projectId,
210
272
  workspacePath,
211
273
  knowledgePath,
274
+ brainId: config.brainId || 'brain-default',
275
+ domainId: config.domainId || null,
212
276
  modules: {
213
277
  terminal: terminal ? { registered: true, projectId: terminal.id, name: terminal.name } : { registered: false, status: wiring.terminal && wiring.terminal.status },
214
278
  vectorHub: vectorHub ? { registered: true, projectName: vectorHub.name, docCount: vectorHub.docCount, chunkCount: vectorHub.chunkCount, watching: !!vectorHub.watching } : { registered: false, status: wiring.vectorHub && wiring.vectorHub.status },
215
279
  eventBridge: eventBridge ? { registered: true, projectId: eventBridge.id, turns: eventBridge.turns, auto: !!eventBridge.auto } : { registered: false, status: wiring.eventBridge && wiring.eventBridge.status },
280
+ engine: engineProjects.some(p => p.projectId === projectId) ? { registered: true, projectId } : { registered: false, status: wiring.engine && wiring.engine.status },
216
281
  },
217
282
  };
218
283
  });
@@ -221,26 +286,68 @@ class ModuleBridge {
221
286
  terminalAvailable: terminalProjects.length > 0 || await this._probe(this.terminalUrl),
222
287
  vectorHubAvailable: vectorHubProjects.length > 0 || await this._probe(this.vectorHubUrl),
223
288
  eventBridgeAvailable: eventBridgeProjects.length > 0 || await this._probe(this.eventBridgeUrl),
289
+ engineAvailable: engineProjects.length > 0 || await this._probe(this.engineUrl),
224
290
  };
225
291
  }
226
292
 
227
293
  async _probe(base) {
228
- try { await this._fetch(base, { timeoutMs: 2000 }); return true; } catch { return false; }
294
+ const route = base === this.terminalUrl ? '/api/claude-workbench/v1/health' : '/api/health';
295
+ try { const response = await this._fetch(`${base}${route}`, { timeoutMs: 2000 }); return response.ok; } catch { return false; }
229
296
  }
230
297
 
231
298
  async modulesHealth() {
232
- const [terminal, vectorHub, eventBridge] = await Promise.all([
299
+ const [terminal, vectorHub, eventBridge, engine] = await Promise.all([
233
300
  this._probe(this.terminalUrl),
234
301
  this._probe(this.vectorHubUrl),
235
302
  this._probe(this.eventBridgeUrl),
303
+ this._probe(this.engineUrl),
236
304
  ]);
237
305
  return { ok: true, modules: {
238
306
  terminal: { url: this.terminalUrl, up: terminal },
239
307
  vectorHub: { url: this.vectorHubUrl, up: vectorHub },
240
308
  eventBridge: { url: this.eventBridgeUrl, up: eventBridge },
309
+ engine: { url: this.engineUrl, up: engine },
241
310
  } };
242
311
  }
243
312
 
313
+ async syncEngineConfiguration() {
314
+ if (this.syncingConfig) return this.configStatus || { scopes: false, model: false };
315
+ this.syncingConfig = true;
316
+ const status = { scopes: false, model: false, embedding: false };
317
+ const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${this.runtimeToken}` };
318
+ try {
319
+ if (this.settingsStore) {
320
+ const settings = this.settingsStore.read();
321
+ if (settings.knowledge.rootPath) {
322
+ const brain = settings.brain;
323
+ const root = path.join(settings.knowledge.rootPath, '_shared');
324
+ const scopes = [{ id: brain.id, kind: 'brain', brainId: brain.id, name: brain.name, knowledgePath: path.join(root, brain.id) }, ...brain.domains.map(d => ({ id: d.id, kind: 'domain', brainId: brain.id, domainId: d.id, name: d.name, knowledgePath: path.join(root, d.id) }))];
325
+ status.scopes = (await this._fetch(`${this.engineUrl}/api/scopes`, { method: 'POST', headers, body: JSON.stringify({ scopes }) })).ok;
326
+ }
327
+ }
328
+ } catch { /* A scope failure must not prevent revoking a stale model key. */ }
329
+ try {
330
+ let profile = null;
331
+ try {
332
+ const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/maintenance-config`, { method: 'POST', headers, body: '{}' });
333
+ if (response.ok) profile = await response.json();
334
+ } catch { /* Missing configuration must clear a previously resolved key. */ }
335
+ const response = await this._fetch(`${this.engineUrl}/api/runtime-config`, { method: 'POST', headers, body: JSON.stringify({ projectId: 'default', profile }) });
336
+ status.model = response.ok && !!profile;
337
+ } catch { /* Status projection only: never log resolved profiles or raw errors. */ }
338
+ try {
339
+ let settings = null;
340
+ try {
341
+ const resolved = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/embedding-config`, { method: 'POST', headers, body: '{}' });
342
+ if (resolved.ok) settings = await resolved.json();
343
+ } catch { /* Revocation wins over a previously cached runtime key. */ }
344
+ const applied = await this._fetch(`${this.vectorHubUrl}/api/internal/runtime-config`, { method: 'POST', headers, body: JSON.stringify({ settings }) });
345
+ status.embedding = applied.ok && !!settings;
346
+ } catch { /* Public status contains no resolved configuration. */ }
347
+ this.syncingConfig = false; this.configStatus = status;
348
+ return status;
349
+ }
350
+
244
351
  /**
245
352
  * Register one pkb project into both modules and persist the wiring table.
246
353
  * Best-effort per module: a module that is down is recorded as 'pending'
@@ -248,11 +355,12 @@ class ModuleBridge {
248
355
  * KB_MODULES_REGISTER=0 disables fan-out entirely (test isolation).
249
356
  */
250
357
  async registerProjectLinks({ projectId, name, workspacePath, knowledgePath }) {
251
- const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false } };
358
+ const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
252
359
  if (String(process.env.KB_MODULES_REGISTER || '') === '0') {
253
360
  result.terminal = { ok: false, status: 'disabled' };
254
361
  result.vectorHub = { ok: false, status: 'disabled' };
255
362
  result.eventBridge = { ok: false, status: 'disabled' };
363
+ result.engine = { ok: false, status: 'disabled' };
256
364
  return result;
257
365
  }
258
366
  // 幂等登记:终端已有同 rootPath 的项目时复用,绝不重复创建
@@ -273,12 +381,14 @@ class ModuleBridge {
273
381
  result.terminal = { ok: false, status: 'pending' };
274
382
  }
275
383
  }
276
- try {
384
+ const existingVector = (await this._listVectorHubProjects()).find(p => p.name === projectId && String(p.sourceDir || '').toLowerCase() === String(knowledgePath || '').toLowerCase());
385
+ if (existingVector) result.vectorHub = { ok: true, projectName: projectId, status: 'registered', reused: true };
386
+ else try {
277
387
  const response = await this._fetch(`${this.vectorHubUrl}/api/import`, {
278
388
  method: 'POST',
279
389
  headers: { 'Content-Type': 'application/json' },
280
390
  timeoutMs: REGISTER_TIMEOUT_MS,
281
- body: JSON.stringify({ sourceDir: knowledgePath, projectName: projectId, watch: true }),
391
+ body: JSON.stringify({ sourceDir: knowledgePath, projectName: projectId, watch: false }),
282
392
  });
283
393
  result.vectorHub = { ok: response.ok, projectName: projectId, status: response.ok ? 'registered' : 'error' };
284
394
  } catch {
@@ -307,10 +417,26 @@ class ModuleBridge {
307
417
  result.eventBridge = { ok: false, status: 'pending' };
308
418
  }
309
419
  }
420
+ const config = this.projectStore ? this.projectStore.readConfig(projectId) : {};
421
+ const state = this.projectStore ? this.projectStore.readState(projectId) : {};
422
+ try {
423
+ const baselines = { ...(state.conversationBaselineCursors || {}), global: state.conversationBaselineCursor ?? 0 };
424
+ const journalId = result.eventBridge.projectId || state.modules?.eventBridge?.projectId;
425
+ if (journalId && baselines[journalId] === undefined) {
426
+ const watermark = result.eventBridge.reused && await this._fetch(`${this.eventBridgeUrl}/api/journal/high-watermark?journalId=${encodeURIComponent(journalId)}`);
427
+ const current = result.eventBridge.reused ? watermark.ok && await watermark.json() : { sequence: 0 };
428
+ if (!current || !Number.isSafeInteger(current.sequence)) throw new Error('JOURNAL_BASELINE_UNAVAILABLE');
429
+ baselines[journalId] = current.sequence;
430
+ if (this.projectStore) await this.projectStore.updateState(projectId, draft => { draft.conversationBaselineCursors = baselines; });
431
+ }
432
+ const response = await this._fetch(`${this.engineUrl}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId, name: name || projectId, repoPath: workspacePath, knowledgePath, knowledgeLanguage: config.knowledgeLanguage || 'zh-CN', brainId: config.brainId || 'brain-default', domainId: config.domainId || null, repoIdentity: config.repoIdentity, journalId: journalId || null, vectorProjectId: projectId, trackingStartCommit: state.trackingStartCommit || state.lastAnalyzedCommit || null, conversationBaselineCursor: state.conversationBaselineCursor ?? 0, conversationBaselineCursors: baselines, profileId: config.aiProfileId || null }) });
433
+ result.engine = { ok: response.ok, projectId, status: response.ok ? 'registered' : 'error' };
434
+ } catch { result.engine = { ok: false, status: 'pending' }; }
310
435
  await this._writeWiring(projectId, wiring => {
311
436
  wiring.terminal = { ...wiring.terminal, ...result.terminal, name: name || projectId, rootPath: workspacePath, updatedAt: new Date().toISOString() };
312
437
  wiring.vectorHub = { ...wiring.vectorHub, ...result.vectorHub, sourceDir: knowledgePath, updatedAt: new Date().toISOString() };
313
438
  wiring.eventBridge = { ...wiring.eventBridge, ...result.eventBridge, path: workspacePath, store: knowledgePath ? path.join(knowledgePath, 'dev-conversations') : '', updatedAt: new Date().toISOString() };
439
+ wiring.engine = { ...wiring.engine, ...result.engine, updatedAt: new Date().toISOString() };
314
440
  });
315
441
  this.logger.info('modules.register', 'Module registration finished.', { context: { projectId, terminal: result.terminal.status, vectorHub: result.vectorHub.status, eventBridge: result.eventBridge.status } });
316
442
  return result;
@@ -320,9 +446,19 @@ class ModuleBridge {
320
446
  * only drops its derived index (source untouched); terminal removes the project
321
447
  * with keepSessions=1 so session records survive; the event-bridge console only
322
448
  * unregisters the project — journals and sealed commit documents stay on disk. */
323
- async removeProjectLinks({ projectId }) {
449
+ async removeProjectLinks({ projectId, requireEngine = true }) {
324
450
  const wiring = (this.projectStore ? this.projectStore.readState(projectId).modules : null) || {};
325
- const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false } };
451
+ const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
452
+ // Stop the writer first, before removing hooks/index registrations.
453
+ try {
454
+ if (!requireEngine) result.engine = { ok: true, status: 'not-owner' };
455
+ else {
456
+ const response = await this._fetch(`${this.engineUrl}/api/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
457
+ if (response.status === 409) throw new DomainError('PROJECT_BUSY', '此项目正在执行知识维护,请等待写入结束后再解绑。未开始的排队任务会随解绑取消。', { status: 409, retryable: true });
458
+ result.engine = { ok: response.ok || response.status === 404, status: response.ok || response.status === 404 ? 'removed' : 'pending' };
459
+ }
460
+ } catch (error) { if (error.code === 'PROJECT_BUSY') throw error; result.engine = { ok: false, status: 'pending' }; }
461
+ if (!result.engine.ok) throw new DomainError('PROJECT_BUSY', '知识引擎暂时不可用,无法安全停止写入;请恢复后再解绑。', { status: 409, retryable: true });
326
462
  let terminalId = wiring.terminal && wiring.terminal.projectId;
327
463
  if (!terminalId) {
328
464
  const workspacePath = wiring.terminal && wiring.terminal.rootPath;
@@ -363,6 +499,7 @@ class ModuleBridge {
363
499
  if (result.terminal.ok) wiring.terminal = { status: 'removed', updatedAt: new Date().toISOString() };
364
500
  if (result.vectorHub.ok) wiring.vectorHub = { status: 'removed', updatedAt: new Date().toISOString() };
365
501
  if (result.eventBridge.ok) wiring.eventBridge = { status: 'removed', updatedAt: new Date().toISOString() };
502
+ if (result.engine.ok) wiring.engine = { status: 'removed', updatedAt: new Date().toISOString() };
366
503
  });
367
504
  this.logger.info('modules.remove', 'Module de-registration finished.', { context: { projectId, terminal: result.terminal, vectorHub: result.vectorHub, eventBridge: result.eventBridge } });
368
505
  return result;
@@ -396,7 +533,7 @@ class ModuleBridge {
396
533
  _supervisedSpecs() {
397
534
  const specs = [];
398
535
  if (process.env.KB_TERMINAL_COMMAND) {
399
- specs.push({ name: 'terminal', command: process.env.KB_TERMINAL_COMMAND, url: this.terminalUrl, env: {} });
536
+ specs.push({ name: 'terminal', command: process.env.KB_TERMINAL_COMMAND, url: this.terminalUrl, env: { KB_WORKBENCH_RUNTIME_TOKEN: this.runtimeToken, KB_KNOWLEDGE_ENGINE_URL: this.engineUrl, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl } });
400
537
  } else {
401
538
  const terminalEntry = resolveModuleEntry('terminal');
402
539
  if (terminalEntry) {
@@ -404,7 +541,7 @@ class ModuleBridge {
404
541
  name: 'terminal',
405
542
  command: `node ${JSON.stringify(terminalEntry)}`,
406
543
  url: this.terminalUrl,
407
- env: { AGENT_TERMINAL_PORT: urlPort(this.terminalUrl, 5760), AGENT_TERMINAL_HOST: urlHost(this.terminalUrl) },
544
+ env: { AGENT_TERMINAL_DATA_DIR: path.join(this.dataDir || require('./data-dir').getDataDir(), 'workbench'), AGENT_TERMINAL_PORT: urlPort(this.terminalUrl, 5760), AGENT_TERMINAL_HOST: urlHost(this.terminalUrl), KB_WORKBENCH_RUNTIME_TOKEN: this.runtimeToken, KB_KNOWLEDGE_ENGINE_URL: this.engineUrl, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl },
408
545
  });
409
546
  } else {
410
547
  this.logger.warn('modules.supervisor', 'claude-ai-workbench runtime not installed; expecting an external service.', {});
@@ -413,7 +550,7 @@ class ModuleBridge {
413
550
  // vector-hub declares engines >= 22; spawning it on older runtimes would
414
551
  // crash-loop the supervisor for nothing.
415
552
  if (process.env.KB_VECTORHUB_COMMAND) {
416
- specs.push({ name: 'vector-hub', command: process.env.KB_VECTORHUB_COMMAND, url: this.vectorHubUrl, env: {} });
553
+ specs.push({ name: 'vector-hub', command: process.env.KB_VECTORHUB_COMMAND, url: this.vectorHubUrl, env: { KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken } });
417
554
  } else if (parseInt(process.versions.node, 10) < 22) {
418
555
  this.logger.warn('modules.supervisor', 'vector-hub needs Node >= 22; not spawning the vendored runtime.', { context: { node: process.versions.node } });
419
556
  } else {
@@ -425,9 +562,9 @@ class ModuleBridge {
425
562
  const root = path.join(dataDir, 'vectorhub');
426
563
  specs.push({
427
564
  name: 'vector-hub',
428
- command: `node ${JSON.stringify(vectorHubEntry)} serve --port ${urlPort(this.vectorHubUrl, 8787)} --root ${JSON.stringify(root)}`,
565
+ command: `node ${JSON.stringify(vectorHubEntry)} serve --host ${urlHost(this.vectorHubUrl)} --port ${urlPort(this.vectorHubUrl, 8787)} --root ${JSON.stringify(root)}`,
429
566
  url: this.vectorHubUrl,
430
- env: { VECTOR_HUB_ROOT: root },
567
+ env: { VECTOR_HUB_ROOT: root, VECTOR_HUB_CONFIG: path.join(root, 'config.json'), VECTOR_HUB_LEGACY_CONFIG: path.join(root, 'no-legacy-config.json'), KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken },
431
568
  });
432
569
  }
433
570
  }
@@ -438,41 +575,48 @@ class ModuleBridge {
438
575
  // folder-picker warm-up is an interactive-only feature the shell never
439
576
  // routes here (import uses the terminal/vector-hub pickers) — skip it.
440
577
  if (process.env.KB_EVENTBRIDGE_COMMAND) {
441
- specs.push({ name: 'event-bridge', command: process.env.KB_EVENTBRIDGE_COMMAND, url: this.eventBridgeUrl, env: {} });
578
+ specs.push({ name: 'event-bridge', command: process.env.KB_EVENTBRIDGE_COMMAND, url: this.eventBridgeUrl, env: { KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken } });
442
579
  } else {
443
580
  const eventBridgeEntry = resolveModuleEntry('event-bridge');
444
581
  if (!eventBridgeEntry) {
445
582
  this.logger.warn('modules.supervisor', '@sanqianx/ai-coding-event-bridge-console runtime not installed; expecting an external service.', {});
446
583
  } else {
447
- const homeArgs = process.env.AI_CODING_EVENT_BRIDGE_HOME
448
- ? ` --home ${JSON.stringify(process.env.AI_CODING_EVENT_BRIDGE_HOME)}`
449
- : '';
584
+ const homeArgs = ` --home ${JSON.stringify(process.env.AI_CODING_EVENT_BRIDGE_HOME || path.join(this.dataDir || require('./data-dir').getDataDir(), 'events'))}`;
450
585
  specs.push({
451
586
  name: 'event-bridge',
452
587
  command: `node ${JSON.stringify(eventBridgeEntry)} serve --host ${urlHost(this.eventBridgeUrl)} --port ${urlPort(this.eventBridgeUrl, 8790)}${homeArgs}`,
453
588
  url: this.eventBridgeUrl,
454
- env: { BRIDGE_CONSOLE_PICK_FOLDER: '0' },
589
+ env: { BRIDGE_CONSOLE_PICK_FOLDER: '0', KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken },
455
590
  });
456
591
  }
457
592
  }
593
+ const engineEntry = resolveModuleEntry('knowledge-engine');
594
+ if (process.env.KB_KNOWLEDGE_ENGINE_COMMAND || engineEntry) specs.push({ name: 'knowledge-engine', command: process.env.KB_KNOWLEDGE_ENGINE_COMMAND || `node ${JSON.stringify(engineEntry)} serve --port ${urlPort(this.engineUrl, 5790)} --host ${urlHost(this.engineUrl)}`, url: this.engineUrl, env: { KNOWLEDGE_ENGINE_DATA: path.join(this.dataDir || require('./data-dir').getDataDir(), 'knowledge-engine'), KB_ENGINE_MANAGED: '1', KNOWLEDGE_ENGINE_CONSUME: '0', KB_ENGINE_RUNTIME_TOKEN: this.runtimeToken, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl, KB_VECTOR_HUB_URL: this.vectorHubUrl, NODE_PATH: path.resolve(__dirname, '..', '..', 'node_modules') } });
458
595
  return specs;
459
596
  }
460
597
 
461
598
  _spawnSupervised(spec) {
599
+ if (this.stopped) return;
462
600
  // Strip the quoting: spawn takes argv, not a shell line — a literal
463
601
  // `"path"` argument makes node load a file whose name contains quotes.
464
602
  const parts = (spec.command.match(/"[^"]+"|\S+/g) || [])
465
- .map(part => (part.startsWith('"') && part.endsWith('"') ? part.slice(1, -1) : part));
466
- const child = spawn(parts[0], parts.slice(1), {
603
+ .map(part => { if (!part.startsWith('"') || !part.endsWith('"')) return part; try { return JSON.parse(part); } catch { return part.slice(1, -1); } });
604
+ const nodeProcess = parts[0] === 'node' || parts[0] === process.execPath;
605
+ const child = spawn(nodeProcess ? process.execPath : parts[0], parts.slice(1), {
467
606
  // Module banners (ports, data roots) are diagnostics — inherit stdout
468
607
  // so they land on the console in --fg mode and in launcher.log when the
469
608
  // shell itself runs detached.
470
- stdio: ['ignore', 'inherit', 'inherit'],
609
+ stdio: nodeProcess ? ['ignore', 'inherit', 'inherit', 'ipc'] : ['ignore', 'inherit', 'inherit'],
471
610
  windowsHide: true,
472
611
  env: { ...process.env, ...(spec.env || {}) },
473
612
  });
474
- let attempts = 0;
613
+ let attempts = spec._attempts || 0;
614
+ child.on('error', error => {
615
+ this.supervised = this.supervised.filter(existing => existing !== child);
616
+ this.logger.warn('modules.supervisor', 'Module process could not start.', { context: { name: spec.name, code: error.code || 'SPAWN_FAILED' } });
617
+ });
475
618
  child.on('exit', code => {
619
+ this.supervised = this.supervised.filter(existing => existing !== child);
476
620
  attempts += 1;
477
621
  if (attempts > 5 || this.stopped) {
478
622
  this.logger.warn('modules.supervisor', 'Module service stopped.', { context: { name: spec.name, code, attempts } });
@@ -480,17 +624,28 @@ class ModuleBridge {
480
624
  }
481
625
  const delay = Math.min(1000 * 2 ** attempts, 15000);
482
626
  this.logger.warn('modules.supervisor', 'Module service exited; restarting.', { context: { name: spec.name, code, retryInMs: delay } });
483
- setTimeout(() => this._spawnSupervised({ ...spec, _attempts: attempts }), delay);
627
+ const timer = setTimeout(() => { this.restartTimers.delete(timer); this._spawnSupervised({ ...spec, _attempts: attempts }); }, delay);
628
+ this.restartTimers = this.restartTimers || new Set(); this.restartTimers.add(timer);
484
629
  });
485
630
  this.supervised.push(child);
486
631
  this.logger.info('modules.supervisor', 'Module service spawned.', { context: { name: spec.name, pid: child.pid } });
487
632
  }
488
633
 
489
- stopSupervised() {
634
+ async stopSupervised() {
490
635
  this.stopped = true;
491
- for (const child of this.supervised) { try { child.kill(); } catch { /* already gone */ } }
636
+ for (const timer of this.restartTimers || []) clearTimeout(timer);
637
+ await Promise.all(this.supervised.map(child => new Promise(resolve => {
638
+ if (child.exitCode !== null || !child.pid) return resolve();
639
+ const timer = setTimeout(() => {
640
+ if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
641
+ else try { child.kill('SIGKILL'); } catch {}
642
+ resolve();
643
+ }, 5000);
644
+ child.once('exit', () => { clearTimeout(timer); resolve(); });
645
+ try { if (child.connected) child.send({ type: 'kb:shutdown' }, error => { if (error) try { child.kill(); } catch {} }); else child.kill(); } catch { try { child.kill(); } catch {} }
646
+ })));
492
647
  this.supervised = [];
493
648
  }
494
649
  }
495
650
 
496
- module.exports = { ModuleBridge, defaultTerminalUrl, defaultVectorHubUrl, defaultEventBridgeUrl };
651
+ module.exports = { ModuleBridge, defaultTerminalUrl, defaultVectorHubUrl, defaultEventBridgeUrl, defaultEngineUrl };
@@ -1,91 +1,91 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
- const { SCHEMAS, DomainError, validateProjectId } = require('./contracts');
4
- const { StorageLayout } = require('./storage-layout');
5
- const { ProjectRegistryStore } = require('./project-registry-store');
6
- const { ProjectStore } = require('./project-store');
7
- const { runGit } = require('./project-lifecycle-service');
8
- const { CommitReconciler, reconcileProjectCommits } = require('./commit-reconciler');
9
- const { validateSha } = require('./scanner');
10
- const { renderCommitPrompt } = require('./commit-prompt');
11
-
12
- function stores(deps = {}) {
13
- const layout = deps.layout || new StorageLayout(deps);
14
- return {
15
- layout,
16
- registryStore: deps.registryStore || new ProjectRegistryStore({ layout }),
17
- projectStore: deps.projectStore || new ProjectStore({ layout }),
18
- };
19
- }
20
-
21
- async function handlePostCommitEvent(event = {}, deps = {}) {
22
- if (event.schema !== SCHEMAS.hookEvent) throw new DomainError('SCHEMA_UNSUPPORTED', 'Hook event must use hook-event/v2.', { status: 409 });
23
- const projectId = validateProjectId(event.projectId);
24
- const commitSha = validateSha(event.head, 'Hook event head');
25
- if (!event.repoRoot) throw new DomainError('INVALID_ARGUMENT', 'Hook event repoRoot is required.');
26
- const resolved = stores(deps);
27
- if (!resolved.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Hook projectId is not registered.', { status: 404 });
28
- const config = resolved.projectStore.readConfig(projectId);
29
- const runtimeRoot = path.resolve(runGit(path.resolve(event.repoRoot), ['rev-parse', '--show-toplevel']).stdout);
30
- const commonDir = path.resolve(runGit(runtimeRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir']).stdout);
31
- if (config.repoIdentity && config.repoIdentity.commonDir
32
- && !resolved.layout.pathsEqual(config.repoIdentity.commonDir, commonDir)) {
33
- if (fs.existsSync(config.repoPath)) {
34
- throw new DomainError('PROJECT_NOT_FOUND', 'Hook Git identity does not match projectId.', { status: 404 });
35
- }
36
- const state = resolved.projectStore.readState(projectId);
37
- const baseline = state.lastAnalyzedCommit || state.trackingStartCommit;
38
- if (baseline) {
39
- const reachable = runGit(runtimeRoot, ['merge-base', '--is-ancestor', baseline, commitSha], { allowFailure: true });
40
- if (!reachable.ok) throw new DomainError('PROJECT_NOT_FOUND', 'Moved Hook repository does not contain the project baseline.', { status: 404 });
41
- }
42
- }
43
- if (!resolved.layout.pathsEqual(config.repoPath, runtimeRoot)) {
44
- // Only the versioned Hook path reaches this update, after projectId and Git identity checks above.
45
- await resolved.projectStore.updateConfig(projectId, { repoPath: runtimeRoot }, { allowRepoPath: true });
46
- }
47
- if (event.boundary && event.boundary.status === 'captured' && event.boundary.boundary) {
48
- const boundary = event.boundary.boundary;
49
- if (boundary.projectId !== projectId || boundary.commitSha !== commitSha) {
50
- throw new DomainError('INVALID_ARGUMENT', 'Hook boundary identity does not match the Hook event.');
51
- }
52
- const commitExists = runGit(runtimeRoot, ['cat-file', '-e', `${boundary.commitSha}^{commit}`], { allowFailure: true });
53
- if (!commitExists.ok) throw new DomainError('INVALID_ARGUMENT', 'Hook boundary commit does not exist in the repository.');
54
- // Ordering (T13): the boundary was appended before the Hook notified us;
55
- // drain the journal THROUGH the boundary sequence before any snapshot is
56
- // bound, so every same-workspace event <= boundaryEndCursor is already in
57
- // the ConversationStore (or deterministically handled) at freeze time.
58
- if (deps.bridgeConsumerService && typeof deps.bridgeConsumerService.drainThrough === 'function') {
59
- await deps.bridgeConsumerService.drainThrough(Number(boundary.journalSequence), 'commit-boundary');
60
- }
61
- if (deps.conversationStore && typeof deps.conversationStore.writeBoundary === 'function') {
62
- deps.conversationStore.writeBoundary(projectId, boundary);
63
- }
64
- } else if (event.boundary && event.boundary.status === 'unavailable' && deps.logger && typeof deps.logger.warn === 'function') {
65
- await deps.logger.warn('conversation.boundary_gap', 'Conversation boundary capture gap was reported by the Git Hook.', {
66
- projectId,
67
- operationId: deps.operationId || event.operationId || '',
68
- commitSha: event.head || '',
69
- phase: 'boundary',
70
- context: {
71
- gapId: event.boundary.gap && event.boundary.gap.gapId || '',
72
- reason: event.boundary.gap && event.boundary.gap.reason || 'unavailable',
73
- },
74
- });
75
- }
76
- const reconciler = deps.reconciler instanceof CommitReconciler ? deps.reconciler : new CommitReconciler({ ...deps, ...resolved });
77
- return reconciler.processCommitEvent({ projectId, commitSha, branch: event.branch || '', operationId: deps.operationId || event.operationId || '' });
78
- }
79
-
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { SCHEMAS, DomainError, validateProjectId } = require('./contracts');
4
+ const { StorageLayout } = require('./storage-layout');
5
+ const { ProjectRegistryStore } = require('./project-registry-store');
6
+ const { ProjectStore } = require('./project-store');
7
+ const { runGit } = require('./project-lifecycle-service');
8
+ const { CommitReconciler, reconcileProjectCommits } = require('./commit-reconciler');
9
+ const { validateSha } = require('./scanner');
10
+ const { renderCommitPrompt } = require('./commit-prompt');
11
+
12
+ function stores(deps = {}) {
13
+ const layout = deps.layout || new StorageLayout(deps);
14
+ return {
15
+ layout,
16
+ registryStore: deps.registryStore || new ProjectRegistryStore({ layout }),
17
+ projectStore: deps.projectStore || new ProjectStore({ layout }),
18
+ };
19
+ }
20
+
21
+ async function handlePostCommitEvent(event = {}, deps = {}) {
22
+ if (event.schema !== SCHEMAS.hookEvent) throw new DomainError('SCHEMA_UNSUPPORTED', 'Hook event must use hook-event/v2.', { status: 409 });
23
+ const projectId = validateProjectId(event.projectId);
24
+ const commitSha = validateSha(event.head, 'Hook event head');
25
+ if (!event.repoRoot) throw new DomainError('INVALID_ARGUMENT', 'Hook event repoRoot is required.');
26
+ const resolved = stores(deps);
27
+ if (!resolved.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Hook projectId is not registered.', { status: 404 });
28
+ const config = resolved.projectStore.readConfig(projectId);
29
+ const runtimeRoot = path.resolve(runGit(path.resolve(event.repoRoot), ['rev-parse', '--show-toplevel']).stdout);
30
+ const commonDir = path.resolve(runGit(runtimeRoot, ['rev-parse', '--path-format=absolute', '--git-common-dir']).stdout);
31
+ if (config.repoIdentity && config.repoIdentity.commonDir
32
+ && !resolved.layout.pathsEqual(config.repoIdentity.commonDir, commonDir)) {
33
+ if (fs.existsSync(config.repoPath)) {
34
+ throw new DomainError('PROJECT_NOT_FOUND', 'Hook Git identity does not match projectId.', { status: 404 });
35
+ }
36
+ const state = resolved.projectStore.readState(projectId);
37
+ const baseline = state.lastAnalyzedCommit || state.trackingStartCommit;
38
+ if (baseline) {
39
+ const reachable = runGit(runtimeRoot, ['merge-base', '--is-ancestor', baseline, commitSha], { allowFailure: true });
40
+ if (!reachable.ok) throw new DomainError('PROJECT_NOT_FOUND', 'Moved Hook repository does not contain the project baseline.', { status: 404 });
41
+ }
42
+ }
43
+ if (!resolved.layout.pathsEqual(config.repoPath, runtimeRoot)) {
44
+ // Only the versioned Hook path reaches this update, after projectId and Git identity checks above.
45
+ await resolved.projectStore.updateConfig(projectId, { repoPath: runtimeRoot }, { allowRepoPath: true });
46
+ }
47
+ if (event.boundary && event.boundary.status === 'captured' && event.boundary.boundary) {
48
+ const boundary = event.boundary.boundary;
49
+ if (boundary.projectId !== projectId || boundary.commitSha !== commitSha) {
50
+ throw new DomainError('INVALID_ARGUMENT', 'Hook boundary identity does not match the Hook event.');
51
+ }
52
+ const commitExists = runGit(runtimeRoot, ['cat-file', '-e', `${boundary.commitSha}^{commit}`], { allowFailure: true });
53
+ if (!commitExists.ok) throw new DomainError('INVALID_ARGUMENT', 'Hook boundary commit does not exist in the repository.');
54
+ // Ordering (T13): the boundary was appended before the Hook notified us;
55
+ // drain the journal THROUGH the boundary sequence before any snapshot is
56
+ // bound, so every same-workspace event <= boundaryEndCursor is already in
57
+ // the ConversationStore (or deterministically handled) at freeze time.
58
+ if (deps.bridgeConsumerService && typeof deps.bridgeConsumerService.drainThrough === 'function') {
59
+ await deps.bridgeConsumerService.drainThrough(Number(boundary.journalSequence), 'commit-boundary');
60
+ }
61
+ if (deps.conversationStore && typeof deps.conversationStore.writeBoundary === 'function') {
62
+ deps.conversationStore.writeBoundary(projectId, boundary);
63
+ }
64
+ } else if (event.boundary && event.boundary.status === 'unavailable' && deps.logger && typeof deps.logger.warn === 'function') {
65
+ await deps.logger.warn('conversation.boundary_gap', 'Conversation boundary capture gap was reported by the Git Hook.', {
66
+ projectId,
67
+ operationId: deps.operationId || event.operationId || '',
68
+ commitSha: event.head || '',
69
+ phase: 'boundary',
70
+ context: {
71
+ gapId: event.boundary.gap && event.boundary.gap.gapId || '',
72
+ reason: event.boundary.gap && event.boundary.gap.reason || 'unavailable',
73
+ },
74
+ });
75
+ }
76
+ const reconciler = deps.reconciler instanceof CommitReconciler ? deps.reconciler : new CommitReconciler({ ...deps, ...resolved });
77
+ return reconciler.processCommitEvent({ projectId, commitSha, branch: event.branch || '', operationId: deps.operationId || event.operationId || '' });
78
+ }
79
+
80
80
  function cleanupOrphanedRuns(_projects, deps = {}) {
81
- if (!deps.projectStore && !deps.registryStore && !deps.layout) {
82
- return { activeClaims: 0, recoveredFromFrozenClaims: 0, legacyStoreSkipped: true };
83
- }
84
- const resolved = stores(deps);
85
- let activeClaims = 0;
86
- for (const projectId of resolved.registryStore.listIds()) {
87
- if (resolved.projectStore.readState(projectId).analysis.activeClaim) activeClaims += 1;
88
- }
81
+ if (!deps.projectStore && !deps.registryStore && !deps.layout) {
82
+ return { activeClaims: 0, recoveredFromFrozenClaims: 0, legacyStoreSkipped: true };
83
+ }
84
+ const resolved = stores(deps);
85
+ let activeClaims = 0;
86
+ for (const projectId of resolved.registryStore.listIds()) {
87
+ if (resolved.projectStore.readState(projectId).analysis.activeClaim) activeClaims += 1;
88
+ }
89
89
  return { activeClaims, recoveredFromFrozenClaims: activeClaims };
90
90
  }
91
91