@sanqianx/project-knowledge 4.7.0 → 4.8.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 (192) hide show
  1. package/.agents/plugins/marketplace.json +17 -17
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/CHANGELOG.md +817 -799
  4. package/README.md +48 -36
  5. package/_modules/knowledge-engine/README.md +88 -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 +255 -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/migration.js +88 -0
  15. package/_modules/knowledge-engine/src/retrieval.js +116 -0
  16. package/_modules/knowledge-engine/src/runtime/README.md +7 -0
  17. package/_modules/knowledge-engine/src/runtime/atomic-file.js +204 -0
  18. package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -0
  19. package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +249 -0
  20. package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -0
  21. package/_modules/knowledge-engine/src/runtime/commit-prompt.js +202 -0
  22. package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +432 -0
  23. package/_modules/knowledge-engine/src/runtime/content-hash.js +4 -0
  24. package/_modules/knowledge-engine/src/runtime/contracts.js +279 -0
  25. package/_modules/knowledge-engine/src/runtime/conversation-exclusions.js +204 -0
  26. package/_modules/knowledge-engine/src/runtime/conversation-store.js +387 -0
  27. package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -0
  28. package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -0
  29. package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +444 -0
  30. package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +402 -0
  31. package/_modules/knowledge-engine/src/runtime/layout.js +44 -0
  32. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +230 -0
  33. package/_modules/knowledge-engine/src/runtime/scanner.js +298 -0
  34. package/_modules/knowledge-engine/src/server.js +155 -0
  35. package/_modules/knowledge-engine/src/store.js +239 -0
  36. package/_modules/knowledge-engine/ui/app.css +172 -0
  37. package/_modules/knowledge-engine/ui/app.js +297 -0
  38. package/_modules/knowledge-engine/ui/index.html +95 -0
  39. package/_site/README.md +30 -30
  40. package/_site/_test/ai-profile-resolver-test.js +137 -137
  41. package/_site/_test/automation-queue-test.js +14 -14
  42. package/_site/_test/automation-ui-test.js +40 -40
  43. package/_site/_test/background-task-registry-test.js +43 -43
  44. package/_site/_test/baseline-schema-test.js +91 -91
  45. package/_site/_test/chat-claudecodeui-match-test.js +92 -92
  46. package/_site/_test/claude-executable-discovery-test.js +62 -62
  47. package/_site/_test/claude-workbench-test.js +170 -170
  48. package/_site/_test/codex-conversation-projection-test.js +2 -1
  49. package/_site/_test/commit-conversation-binding-test.js +95 -95
  50. package/_site/_test/commit-evidence-test.js +110 -110
  51. package/_site/_test/conversation-api-test.js +109 -109
  52. package/_site/_test/conversation-store-test.js +136 -136
  53. package/_site/_test/data-dir-migration-test.js +57 -57
  54. package/_site/_test/desktop-browser-compat-test.js +28 -28
  55. package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
  56. package/_site/_test/explicit-commit-processor-test.js +37 -37
  57. package/_site/_test/fixtures/make-git-repos.js +12 -1
  58. package/_site/_test/folder-picker-output-test.js +19 -19
  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/legacy-forward-compat-test.js +257 -257
  75. package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -363
  76. package/_site/_test/logging-api-test.js +89 -89
  77. package/_site/_test/logging-sse-no-gap-test.js +88 -88
  78. package/_site/_test/logging-ui-test.js +106 -104
  79. package/_site/_test/markdown-delta-overlay-test.js +85 -85
  80. package/_site/_test/markdown-maintenance-api-test.js +75 -75
  81. package/_site/_test/mcp-server-test.js +149 -149
  82. package/_site/_test/module-artifact-integrity-test.js +27 -0
  83. package/_site/_test/module-boundary-test.js +30 -0
  84. package/_site/_test/module-bridge-eventbridge-test.js +7 -0
  85. package/_site/_test/module-model-configuration-test.js +43 -0
  86. package/_site/_test/module-process-lifecycle-test.js +24 -0
  87. package/_site/_test/module-stream-proxy-test.js +34 -0
  88. package/_site/_test/non-release-ci-test.js +34 -34
  89. package/_site/_test/offline-boundary-isolation-test.js +32 -32
  90. package/_site/_test/p0-data-migration-characterization-test.js +37 -37
  91. package/_site/_test/p0-e2e-gate-test.js +347 -347
  92. package/_site/_test/packaged-ui-smoke-test.js +11 -9
  93. package/_site/_test/path-consistency-test.js +151 -151
  94. package/_site/_test/pending-sweep-test.js +9 -9
  95. package/_site/_test/post-commit-automation-test.js +87 -87
  96. package/_site/_test/product-diagnostics-ui-test.js +75 -0
  97. package/_site/_test/product-import-ui-test.js +92 -0
  98. package/_site/_test/project-delete-recovery-test.js +64 -64
  99. package/_site/_test/project-goal-editor-test.js +144 -144
  100. package/_site/_test/project-layout-v2-migration-test.js +127 -127
  101. package/_site/_test/project-lifecycle-transaction-test.js +106 -106
  102. package/_site/_test/prompt-settings-test.js +115 -115
  103. package/_site/_test/protected-architecture-gate-test.js +122 -122
  104. package/_site/_test/refactor-characterization-test.js +36 -36
  105. package/_site/_test/release-version-sync-test.js +1 -1
  106. package/_site/_test/requirement-recorder-test.js +173 -173
  107. package/_site/_test/run-all-tests.js +159 -156
  108. package/_site/_test/server-runtime-migration-safety-test.js +27 -27
  109. package/_site/_test/shared-contracts-test.js +58 -58
  110. package/_site/_test/startup-analysis-disabled-test.js +10 -10
  111. package/_site/_test/storage-foundation-test.js +78 -78
  112. package/_site/_test/structured-logger-test.js +101 -101
  113. package/_site/_test/tracking-start-test.js +108 -108
  114. package/_site/_test/v4122-upgrade-data-contract-test.js +56 -56
  115. package/_site/_test/workbench-permission-test.js +117 -117
  116. package/_site/_test/workspace-ui-contract-test.js +28 -43
  117. package/_site/lib/ai-profile-resolver.js +78 -78
  118. package/_site/lib/ai-workspace.js +101 -101
  119. package/_site/lib/automation-config.js +140 -140
  120. package/_site/lib/bridge-adapter.js +1 -0
  121. package/_site/lib/bridge-consumer-service.js +402 -402
  122. package/_site/lib/claude-cli-runner.js +1510 -1510
  123. package/_site/lib/commit-conversation-binder.js +234 -234
  124. package/_site/lib/commit-processing-ledger.js +27 -27
  125. package/_site/lib/commit-prompt.js +202 -202
  126. package/_site/lib/commit-reconciler.js +406 -406
  127. package/_site/lib/contracts.js +284 -279
  128. package/_site/lib/conversation-query-service.js +144 -144
  129. package/_site/lib/conversation-store.js +385 -385
  130. package/_site/lib/data-dir.js +74 -74
  131. package/_site/lib/data-state-classifier.js +40 -40
  132. package/_site/lib/engine-cutover.js +97 -0
  133. package/_site/lib/evidence-bundle.js +228 -228
  134. package/_site/lib/folder-picker-output.js +21 -21
  135. package/_site/lib/git-runner.js +62 -62
  136. package/_site/lib/github-team-store.js +1172 -1172
  137. package/_site/lib/hook-manager.js +203 -202
  138. package/_site/lib/index-service.js +173 -173
  139. package/_site/lib/integration-installer.js +907 -907
  140. package/_site/lib/kb-framework.js +176 -176
  141. package/_site/lib/kb-validator.js +124 -124
  142. package/_site/lib/knowledge-promotion.js +435 -435
  143. package/_site/lib/knowledge-retrieval-service.js +401 -401
  144. package/_site/lib/knowledge-tool-runtime.js +359 -359
  145. package/_site/lib/legacy-data-manifest.js +26 -26
  146. package/_site/lib/llm-client.js +161 -161
  147. package/_site/lib/markdown-knowledge-indexer.js +230 -230
  148. package/_site/lib/migration-service.js +442 -442
  149. package/_site/lib/module-bridge.js +198 -33
  150. package/_site/lib/post-commit-automation.js +87 -87
  151. package/_site/lib/project-lifecycle-service.js +519 -497
  152. package/_site/lib/project-store.js +268 -262
  153. package/_site/lib/requirement-recorder.js +278 -278
  154. package/_site/lib/runtime-endpoint.js +155 -155
  155. package/_site/lib/scanner.js +298 -298
  156. package/_site/lib/server-app.js +1590 -1520
  157. package/_site/lib/settings-store.js +133 -113
  158. package/_site/lib/storage-layout.js +162 -162
  159. package/_site/lib/structured-logger.js +574 -574
  160. package/_site/scripts/folder-picker.ps1 +156 -156
  161. package/_site/scripts/hook-trigger.js +157 -156
  162. package/_site/scripts/install-module-candidates.js +69 -0
  163. package/_site/scripts/pack-module-candidates.js +72 -0
  164. package/_site/scripts/sync-release-version.js +2 -2
  165. package/_site/scripts/vendor-modules.js +47 -0
  166. package/_site/scripts/verify-module-runtime.js +45 -0
  167. package/_site/scripts/verify-product-runtime.js +34 -0
  168. package/bin/project-knowledge-mcp.js +194 -194
  169. package/docs/README.zh-CN.md +34 -25
  170. package/docs/project-registry-schema.md +22 -22
  171. package/docs/testing-strategy.md +33 -33
  172. package/module-runtime-manifest.json +220 -0
  173. package/package.json +13 -9
  174. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  175. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  176. package/plugins/project-knowledge/.mcp.json +1 -1
  177. package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
  178. package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
  179. package/templates/project-readme.md +34 -34
  180. package/ui/favicon.svg +38 -38
  181. package/ui/index.html +51 -149
  182. package/ui/product.css +9 -0
  183. package/ui/product.js +386 -0
  184. package/vendor-manifest.json +46 -0
  185. package/_site/_test/import-ui-flow-test.js +0 -171
  186. package/_site/_test/project-control-panel-task14-test.js +0 -63
  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/ui/app.css +0 -58
  191. package/ui/app.js +0 -607
  192. 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,22 @@ class ModuleBridge {
151
185
  return this.proxy(req, res, this._eventBridgeApi(pathname) + (search || ''));
152
186
  }
153
187
 
188
+ proxyEngine(req, res, pathname, search) {
189
+ return this.proxy(req, res, `${this.engineUrl}/api/${pathname.replace(/^\/api\/engine\/?/, '')}${search || ''}`);
190
+ }
191
+
192
+ proxyModuleUi(req, res, module, rest, search) {
193
+ const base = { terminal: this.terminalUrl, vectorhub: this.vectorHubUrl, eventbridge: this.eventBridgeUrl, engine: this.engineUrl }[module];
194
+ let decoded;
195
+ try { decoded = decodeURIComponent(rest); } catch { decoded = null; }
196
+ // Reject encoded separators/dot segments before URL normalization. Static
197
+ // mounts must never become a second route to a module's private API.
198
+ 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; }
199
+ const target = new URL(`${base}/${rest}${search || ''}`);
200
+ if (target.origin !== new URL(base).origin) { res.statusCode = 404; res.end('Not found'); return; }
201
+ return this.proxy(req, res, target.href);
202
+ }
203
+
154
204
  async _listTerminalProjects() {
155
205
  try {
156
206
  const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects`);
@@ -179,12 +229,22 @@ class ModuleBridge {
179
229
  } catch { return []; }
180
230
  }
181
231
 
232
+ async _listEngineProjects() {
233
+ try {
234
+ const response = await this._fetch(`${this.engineUrl}/api/projects`);
235
+ if (!response.ok) return [];
236
+ const payload = await response.json();
237
+ return Array.isArray(payload.projects) ? payload.projects : [];
238
+ } catch { return []; }
239
+ }
240
+
182
241
  /** Aggregated projection for the shell sidebar: pkb registry is the spine. */
183
242
  async aggregatedProjects() {
184
- const [terminalProjects, vectorHubProjects, eventBridgeProjects] = await Promise.all([
243
+ const [terminalProjects, vectorHubProjects, eventBridgeProjects, engineProjects] = await Promise.all([
185
244
  this._listTerminalProjects(),
186
245
  this._listVectorHubProjects(),
187
246
  this._listEventBridgeProjects(),
247
+ this._listEngineProjects(),
188
248
  ]);
189
249
  const ids = this.registryStore ? this.registryStore.listIds() : [];
190
250
  const byWorkspace = new Map(terminalProjects.map(p => [String(p.rootPath || '').toLowerCase(), p]));
@@ -209,10 +269,13 @@ class ModuleBridge {
209
269
  name: config.displayName || projectId,
210
270
  workspacePath,
211
271
  knowledgePath,
272
+ brainId: config.brainId || 'brain-default',
273
+ domainId: config.domainId || null,
212
274
  modules: {
213
275
  terminal: terminal ? { registered: true, projectId: terminal.id, name: terminal.name } : { registered: false, status: wiring.terminal && wiring.terminal.status },
214
276
  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
277
  eventBridge: eventBridge ? { registered: true, projectId: eventBridge.id, turns: eventBridge.turns, auto: !!eventBridge.auto } : { registered: false, status: wiring.eventBridge && wiring.eventBridge.status },
278
+ engine: engineProjects.some(p => p.projectId === projectId) ? { registered: true, projectId } : { registered: false, status: wiring.engine && wiring.engine.status },
216
279
  },
217
280
  };
218
281
  });
@@ -221,26 +284,78 @@ class ModuleBridge {
221
284
  terminalAvailable: terminalProjects.length > 0 || await this._probe(this.terminalUrl),
222
285
  vectorHubAvailable: vectorHubProjects.length > 0 || await this._probe(this.vectorHubUrl),
223
286
  eventBridgeAvailable: eventBridgeProjects.length > 0 || await this._probe(this.eventBridgeUrl),
287
+ engineAvailable: engineProjects.length > 0 || await this._probe(this.engineUrl),
224
288
  };
225
289
  }
226
290
 
227
291
  async _probe(base) {
228
- try { await this._fetch(base, { timeoutMs: 2000 }); return true; } catch { return false; }
292
+ const route = base === this.terminalUrl ? '/api/claude-workbench/v1/health' : '/api/health';
293
+ try { const response = await this._fetch(`${base}${route}`, { timeoutMs: 2000 }); return response.ok; } catch { return false; }
229
294
  }
230
295
 
231
296
  async modulesHealth() {
232
- const [terminal, vectorHub, eventBridge] = await Promise.all([
297
+ const [terminal, vectorHub, eventBridge, engine] = await Promise.all([
233
298
  this._probe(this.terminalUrl),
234
299
  this._probe(this.vectorHubUrl),
235
300
  this._probe(this.eventBridgeUrl),
301
+ this._probe(this.engineUrl),
236
302
  ]);
237
303
  return { ok: true, modules: {
238
304
  terminal: { url: this.terminalUrl, up: terminal },
239
305
  vectorHub: { url: this.vectorHubUrl, up: vectorHub },
240
306
  eventBridge: { url: this.eventBridgeUrl, up: eventBridge },
307
+ engine: { url: this.engineUrl, up: engine },
241
308
  } };
242
309
  }
243
310
 
311
+ async syncEngineConfiguration() {
312
+ if (this.syncingConfig) return this.configStatus || { scopes: false, model: false };
313
+ this.syncingConfig = true;
314
+ const status = { scopes: false, model: false, embedding: false };
315
+ const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${this.runtimeToken}` };
316
+ try {
317
+ if (this.settingsStore) {
318
+ const settings = this.settingsStore.read();
319
+ if (settings.knowledge.rootPath) {
320
+ const brain = settings.brain;
321
+ const root = path.join(settings.knowledge.rootPath, '_shared');
322
+ 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) }))];
323
+ status.scopes = (await this._fetch(`${this.engineUrl}/api/scopes`, { method: 'POST', headers, body: JSON.stringify({ scopes }) })).ok;
324
+ }
325
+ }
326
+ } catch { /* A scope failure must not prevent revoking a stale model key. */ }
327
+ try {
328
+ let profile = null;
329
+ try {
330
+ const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/maintenance-config`, { method: 'POST', headers, body: '{}' });
331
+ if (response.ok) profile = await response.json();
332
+ } catch { /* Missing configuration must clear a previously resolved key. */ }
333
+ const response = await this._fetch(`${this.engineUrl}/api/runtime-config`, { method: 'POST', headers, body: JSON.stringify({ projectId: 'default', profile }) });
334
+ status.model = response.ok && !!profile;
335
+ } catch { /* Status projection only: never log resolved profiles or raw errors. */ }
336
+ try {
337
+ const legacyResponse = await this._fetch(`${this.vectorHubUrl}/api/internal/embedding-config`, { method: 'POST', headers, body: '{}' });
338
+ if (legacyResponse.ok) {
339
+ const legacy = await legacyResponse.json();
340
+ if (legacy.settings && /^embedding-[a-f0-9]{64}$/.test(legacy.migrationId || '')) {
341
+ const imported = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/import-embedding`, { method: 'POST', headers, body: JSON.stringify(legacy) });
342
+ if (imported.ok) await this._fetch(`${this.vectorHubUrl}/api/internal/retire-embedding`, { method: 'POST', headers, body: JSON.stringify({ migrationId: legacy.migrationId }) });
343
+ }
344
+ }
345
+ } catch { /* Failed migration retains the module's protected configuration. */ }
346
+ try {
347
+ let settings = null;
348
+ try {
349
+ const resolved = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/embedding-config`, { method: 'POST', headers, body: '{}' });
350
+ if (resolved.ok) settings = await resolved.json();
351
+ } catch { /* Revocation wins over a previously cached runtime key. */ }
352
+ const applied = await this._fetch(`${this.vectorHubUrl}/api/internal/runtime-config`, { method: 'POST', headers, body: JSON.stringify({ settings }) });
353
+ status.embedding = applied.ok && !!settings;
354
+ } catch { /* Public status contains no resolved configuration. */ }
355
+ this.syncingConfig = false; this.configStatus = status;
356
+ return status;
357
+ }
358
+
244
359
  /**
245
360
  * Register one pkb project into both modules and persist the wiring table.
246
361
  * Best-effort per module: a module that is down is recorded as 'pending'
@@ -248,11 +363,12 @@ class ModuleBridge {
248
363
  * KB_MODULES_REGISTER=0 disables fan-out entirely (test isolation).
249
364
  */
250
365
  async registerProjectLinks({ projectId, name, workspacePath, knowledgePath }) {
251
- const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false } };
366
+ const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
252
367
  if (String(process.env.KB_MODULES_REGISTER || '') === '0') {
253
368
  result.terminal = { ok: false, status: 'disabled' };
254
369
  result.vectorHub = { ok: false, status: 'disabled' };
255
370
  result.eventBridge = { ok: false, status: 'disabled' };
371
+ result.engine = { ok: false, status: 'disabled' };
256
372
  return result;
257
373
  }
258
374
  // 幂等登记:终端已有同 rootPath 的项目时复用,绝不重复创建
@@ -273,12 +389,14 @@ class ModuleBridge {
273
389
  result.terminal = { ok: false, status: 'pending' };
274
390
  }
275
391
  }
276
- try {
392
+ const existingVector = (await this._listVectorHubProjects()).find(p => p.name === projectId && String(p.sourceDir || '').toLowerCase() === String(knowledgePath || '').toLowerCase());
393
+ if (existingVector) result.vectorHub = { ok: true, projectName: projectId, status: 'registered', reused: true };
394
+ else try {
277
395
  const response = await this._fetch(`${this.vectorHubUrl}/api/import`, {
278
396
  method: 'POST',
279
397
  headers: { 'Content-Type': 'application/json' },
280
398
  timeoutMs: REGISTER_TIMEOUT_MS,
281
- body: JSON.stringify({ sourceDir: knowledgePath, projectName: projectId, watch: true }),
399
+ body: JSON.stringify({ sourceDir: knowledgePath, projectName: projectId, watch: false }),
282
400
  });
283
401
  result.vectorHub = { ok: response.ok, projectName: projectId, status: response.ok ? 'registered' : 'error' };
284
402
  } catch {
@@ -307,10 +425,26 @@ class ModuleBridge {
307
425
  result.eventBridge = { ok: false, status: 'pending' };
308
426
  }
309
427
  }
428
+ const config = this.projectStore ? this.projectStore.readConfig(projectId) : {};
429
+ const state = this.projectStore ? this.projectStore.readState(projectId) : {};
430
+ try {
431
+ const baselines = { ...(state.conversationBaselineCursors || {}), global: state.conversationBaselineCursor ?? 0 };
432
+ const journalId = result.eventBridge.projectId || state.modules?.eventBridge?.projectId;
433
+ if (journalId && baselines[journalId] === undefined) {
434
+ const watermark = result.eventBridge.reused && await this._fetch(`${this.eventBridgeUrl}/api/journal/high-watermark?journalId=${encodeURIComponent(journalId)}`);
435
+ const current = result.eventBridge.reused ? watermark.ok && await watermark.json() : { sequence: 0 };
436
+ if (!current || !Number.isSafeInteger(current.sequence)) throw new Error('JOURNAL_BASELINE_UNAVAILABLE');
437
+ baselines[journalId] = current.sequence;
438
+ if (this.projectStore) await this.projectStore.updateState(projectId, draft => { draft.conversationBaselineCursors = baselines; });
439
+ }
440
+ 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 }) });
441
+ result.engine = { ok: response.ok, projectId, status: response.ok ? 'registered' : 'error' };
442
+ } catch { result.engine = { ok: false, status: 'pending' }; }
310
443
  await this._writeWiring(projectId, wiring => {
311
444
  wiring.terminal = { ...wiring.terminal, ...result.terminal, name: name || projectId, rootPath: workspacePath, updatedAt: new Date().toISOString() };
312
445
  wiring.vectorHub = { ...wiring.vectorHub, ...result.vectorHub, sourceDir: knowledgePath, updatedAt: new Date().toISOString() };
313
446
  wiring.eventBridge = { ...wiring.eventBridge, ...result.eventBridge, path: workspacePath, store: knowledgePath ? path.join(knowledgePath, 'dev-conversations') : '', updatedAt: new Date().toISOString() };
447
+ wiring.engine = { ...wiring.engine, ...result.engine, updatedAt: new Date().toISOString() };
314
448
  });
315
449
  this.logger.info('modules.register', 'Module registration finished.', { context: { projectId, terminal: result.terminal.status, vectorHub: result.vectorHub.status, eventBridge: result.eventBridge.status } });
316
450
  return result;
@@ -320,9 +454,19 @@ class ModuleBridge {
320
454
  * only drops its derived index (source untouched); terminal removes the project
321
455
  * with keepSessions=1 so session records survive; the event-bridge console only
322
456
  * unregisters the project — journals and sealed commit documents stay on disk. */
323
- async removeProjectLinks({ projectId }) {
457
+ async removeProjectLinks({ projectId, requireEngine = true }) {
324
458
  const wiring = (this.projectStore ? this.projectStore.readState(projectId).modules : null) || {};
325
- const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false } };
459
+ const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
460
+ // Stop the writer first, before removing hooks/index registrations.
461
+ try {
462
+ if (!requireEngine) result.engine = { ok: true, status: 'not-owner' };
463
+ else {
464
+ const response = await this._fetch(`${this.engineUrl}/api/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
465
+ if (response.status === 409) throw new DomainError('PROJECT_BUSY', '知识维护仍在运行,不能解绑。', { status: 409, retryable: true });
466
+ result.engine = { ok: response.ok || response.status === 404, status: response.ok || response.status === 404 ? 'removed' : 'pending' };
467
+ }
468
+ } catch (error) { if (error.code === 'PROJECT_BUSY') throw error; result.engine = { ok: false, status: 'pending' }; }
469
+ if (!result.engine.ok) throw new DomainError('PROJECT_BUSY', '知识引擎暂时不可用,无法安全停止写入;请恢复后再解绑。', { status: 409, retryable: true });
326
470
  let terminalId = wiring.terminal && wiring.terminal.projectId;
327
471
  if (!terminalId) {
328
472
  const workspacePath = wiring.terminal && wiring.terminal.rootPath;
@@ -363,6 +507,7 @@ class ModuleBridge {
363
507
  if (result.terminal.ok) wiring.terminal = { status: 'removed', updatedAt: new Date().toISOString() };
364
508
  if (result.vectorHub.ok) wiring.vectorHub = { status: 'removed', updatedAt: new Date().toISOString() };
365
509
  if (result.eventBridge.ok) wiring.eventBridge = { status: 'removed', updatedAt: new Date().toISOString() };
510
+ if (result.engine.ok) wiring.engine = { status: 'removed', updatedAt: new Date().toISOString() };
366
511
  });
367
512
  this.logger.info('modules.remove', 'Module de-registration finished.', { context: { projectId, terminal: result.terminal, vectorHub: result.vectorHub, eventBridge: result.eventBridge } });
368
513
  return result;
@@ -396,7 +541,7 @@ class ModuleBridge {
396
541
  _supervisedSpecs() {
397
542
  const specs = [];
398
543
  if (process.env.KB_TERMINAL_COMMAND) {
399
- specs.push({ name: 'terminal', command: process.env.KB_TERMINAL_COMMAND, url: this.terminalUrl, env: {} });
544
+ 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
545
  } else {
401
546
  const terminalEntry = resolveModuleEntry('terminal');
402
547
  if (terminalEntry) {
@@ -404,7 +549,7 @@ class ModuleBridge {
404
549
  name: 'terminal',
405
550
  command: `node ${JSON.stringify(terminalEntry)}`,
406
551
  url: this.terminalUrl,
407
- env: { AGENT_TERMINAL_PORT: urlPort(this.terminalUrl, 5760), AGENT_TERMINAL_HOST: urlHost(this.terminalUrl) },
552
+ env: { 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
553
  });
409
554
  } else {
410
555
  this.logger.warn('modules.supervisor', 'claude-ai-workbench runtime not installed; expecting an external service.', {});
@@ -413,7 +558,7 @@ class ModuleBridge {
413
558
  // vector-hub declares engines >= 22; spawning it on older runtimes would
414
559
  // crash-loop the supervisor for nothing.
415
560
  if (process.env.KB_VECTORHUB_COMMAND) {
416
- specs.push({ name: 'vector-hub', command: process.env.KB_VECTORHUB_COMMAND, url: this.vectorHubUrl, env: {} });
561
+ 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
562
  } else if (parseInt(process.versions.node, 10) < 22) {
418
563
  this.logger.warn('modules.supervisor', 'vector-hub needs Node >= 22; not spawning the vendored runtime.', { context: { node: process.versions.node } });
419
564
  } else {
@@ -425,9 +570,9 @@ class ModuleBridge {
425
570
  const root = path.join(dataDir, 'vectorhub');
426
571
  specs.push({
427
572
  name: 'vector-hub',
428
- command: `node ${JSON.stringify(vectorHubEntry)} serve --port ${urlPort(this.vectorHubUrl, 8787)} --root ${JSON.stringify(root)}`,
573
+ command: `node ${JSON.stringify(vectorHubEntry)} serve --host ${urlHost(this.vectorHubUrl)} --port ${urlPort(this.vectorHubUrl, 8787)} --root ${JSON.stringify(root)}`,
429
574
  url: this.vectorHubUrl,
430
- env: { VECTOR_HUB_ROOT: root },
575
+ env: { VECTOR_HUB_ROOT: root, VECTOR_HUB_CONFIG: process.env.VECTOR_HUB_CONFIG || path.join(root, 'config.json'), VECTOR_HUB_LEGACY_CONFIG: process.env.VECTOR_HUB_LEGACY_CONFIG || path.join(require('os').homedir(), '.vector-hub', 'config.json'), KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken },
431
576
  });
432
577
  }
433
578
  }
@@ -438,7 +583,7 @@ class ModuleBridge {
438
583
  // folder-picker warm-up is an interactive-only feature the shell never
439
584
  // routes here (import uses the terminal/vector-hub pickers) — skip it.
440
585
  if (process.env.KB_EVENTBRIDGE_COMMAND) {
441
- specs.push({ name: 'event-bridge', command: process.env.KB_EVENTBRIDGE_COMMAND, url: this.eventBridgeUrl, env: {} });
586
+ specs.push({ name: 'event-bridge', command: process.env.KB_EVENTBRIDGE_COMMAND, url: this.eventBridgeUrl, env: { KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken } });
442
587
  } else {
443
588
  const eventBridgeEntry = resolveModuleEntry('event-bridge');
444
589
  if (!eventBridgeEntry) {
@@ -451,28 +596,37 @@ class ModuleBridge {
451
596
  name: 'event-bridge',
452
597
  command: `node ${JSON.stringify(eventBridgeEntry)} serve --host ${urlHost(this.eventBridgeUrl)} --port ${urlPort(this.eventBridgeUrl, 8790)}${homeArgs}`,
453
598
  url: this.eventBridgeUrl,
454
- env: { BRIDGE_CONSOLE_PICK_FOLDER: '0' },
599
+ env: { BRIDGE_CONSOLE_PICK_FOLDER: '0', KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken },
455
600
  });
456
601
  }
457
602
  }
603
+ const engineEntry = resolveModuleEntry('knowledge-engine');
604
+ 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
605
  return specs;
459
606
  }
460
607
 
461
608
  _spawnSupervised(spec) {
609
+ if (this.stopped) return;
462
610
  // Strip the quoting: spawn takes argv, not a shell line — a literal
463
611
  // `"path"` argument makes node load a file whose name contains quotes.
464
612
  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), {
613
+ .map(part => { if (!part.startsWith('"') || !part.endsWith('"')) return part; try { return JSON.parse(part); } catch { return part.slice(1, -1); } });
614
+ const nodeProcess = parts[0] === 'node' || parts[0] === process.execPath;
615
+ const child = spawn(nodeProcess ? process.execPath : parts[0], parts.slice(1), {
467
616
  // Module banners (ports, data roots) are diagnostics — inherit stdout
468
617
  // so they land on the console in --fg mode and in launcher.log when the
469
618
  // shell itself runs detached.
470
- stdio: ['ignore', 'inherit', 'inherit'],
619
+ stdio: nodeProcess ? ['ignore', 'inherit', 'inherit', 'ipc'] : ['ignore', 'inherit', 'inherit'],
471
620
  windowsHide: true,
472
621
  env: { ...process.env, ...(spec.env || {}) },
473
622
  });
474
- let attempts = 0;
623
+ let attempts = spec._attempts || 0;
624
+ child.on('error', error => {
625
+ this.supervised = this.supervised.filter(existing => existing !== child);
626
+ this.logger.warn('modules.supervisor', 'Module process could not start.', { context: { name: spec.name, code: error.code || 'SPAWN_FAILED' } });
627
+ });
475
628
  child.on('exit', code => {
629
+ this.supervised = this.supervised.filter(existing => existing !== child);
476
630
  attempts += 1;
477
631
  if (attempts > 5 || this.stopped) {
478
632
  this.logger.warn('modules.supervisor', 'Module service stopped.', { context: { name: spec.name, code, attempts } });
@@ -480,17 +634,28 @@ class ModuleBridge {
480
634
  }
481
635
  const delay = Math.min(1000 * 2 ** attempts, 15000);
482
636
  this.logger.warn('modules.supervisor', 'Module service exited; restarting.', { context: { name: spec.name, code, retryInMs: delay } });
483
- setTimeout(() => this._spawnSupervised({ ...spec, _attempts: attempts }), delay);
637
+ const timer = setTimeout(() => { this.restartTimers.delete(timer); this._spawnSupervised({ ...spec, _attempts: attempts }); }, delay);
638
+ this.restartTimers = this.restartTimers || new Set(); this.restartTimers.add(timer);
484
639
  });
485
640
  this.supervised.push(child);
486
641
  this.logger.info('modules.supervisor', 'Module service spawned.', { context: { name: spec.name, pid: child.pid } });
487
642
  }
488
643
 
489
- stopSupervised() {
644
+ async stopSupervised() {
490
645
  this.stopped = true;
491
- for (const child of this.supervised) { try { child.kill(); } catch { /* already gone */ } }
646
+ for (const timer of this.restartTimers || []) clearTimeout(timer);
647
+ await Promise.all(this.supervised.map(child => new Promise(resolve => {
648
+ if (child.exitCode !== null || !child.pid) return resolve();
649
+ const timer = setTimeout(() => {
650
+ if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
651
+ else try { child.kill('SIGKILL'); } catch {}
652
+ resolve();
653
+ }, 5000);
654
+ child.once('exit', () => { clearTimeout(timer); resolve(); });
655
+ try { if (child.connected) child.send({ type: 'kb:shutdown' }, error => { if (error) try { child.kill(); } catch {} }); else child.kill(); } catch { try { child.kill(); } catch {} }
656
+ })));
492
657
  this.supervised = [];
493
658
  }
494
659
  }
495
660
 
496
- module.exports = { ModuleBridge, defaultTerminalUrl, defaultVectorHubUrl, defaultEventBridgeUrl };
661
+ 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