@sanqianx/project-knowledge 5.0.15 → 5.0.16-rc.2

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 (31) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +5 -0
  3. package/_modules/knowledge-engine/README.md +2 -2
  4. package/_modules/knowledge-engine/package.json +1 -1
  5. package/_modules/knowledge-engine/src/retrieval.js +119 -24
  6. package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +110 -8
  7. package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +8 -6
  8. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +105 -64
  9. package/_site/_test/agent-capture-mcp-ui-test.js +67 -0
  10. package/_site/_test/agent-capture-ui-test.js +2 -1
  11. package/_site/_test/integration-adapters-test.js +7 -1
  12. package/_site/_test/integration-runtime-binding-a2-test.js +566 -0
  13. package/_site/_test/integration-runtime-binding-a3-test.js +227 -0
  14. package/_site/_test/integration-runtime-binding-test.js +473 -0
  15. package/_site/_test/native-context-integrations-test.js +1 -1
  16. package/_site/_test/release-version-sync-test.js +12 -1
  17. package/_site/lib/integration-installer.js +325 -8
  18. package/_site/lib/module-bridge.js +6 -2
  19. package/_site/scripts/sync-product-content.js +1 -1
  20. package/_site/scripts/sync-release-version.js +9 -5
  21. package/_site/scripts/verify-product-runtime.js +1 -1
  22. package/docs/leader-glm-workbench-playbook.md +87 -0
  23. package/docs/release-5.0.16-acceptance.md +53 -0
  24. package/module-runtime-manifest.json +33 -32
  25. package/package.json +5 -3
  26. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  27. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  28. package/plugins/project-knowledge/.mcp.json +7 -6
  29. package/plugins/project-knowledge/scripts/mcp-launcher.cjs +78 -0
  30. package/ui/product.js +3 -1
  31. package/vendor-manifest.json +8 -8
@@ -2,13 +2,15 @@ const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
4
  const crypto = require('crypto');
5
- const { spawnSync } = require('child_process');
5
+ const { createRequire } = require('node:module');
6
+ const { spawn, spawnSync } = require('child_process');
6
7
  const crossSpawn = require('cross-spawn');
7
8
  const { applyEdits, modify, parse } = require('jsonc-parser');
8
9
  const packageInfo = require('../../package.json');
9
10
  const { resolveDataDirPath } = require('./data-dir');
10
11
  const { planNotify, writeNotify } = require('./codex-notify-config');
11
12
  const { planCaptureHooks, repairCaptureHooks, removeCaptureHooks } = require('./capture-hook-config');
13
+ const { writeJsonAtomic } = require('./atomic-file');
12
14
 
13
15
  // Fail-open module resolution: a missing/unloadable Bridge package must
14
16
  // degrade Development Capture status, never crash server startup.
@@ -22,6 +24,43 @@ try {
22
24
  const INTEGRATION_NAME = 'project-knowledge';
23
25
  const MARKETPLACE_NAME = 'project-knowledge';
24
26
  const DEFAULT_MARKETPLACE_SOURCE = 'SanQianX/project-knowledge-base';
27
+ const INTEGRATION_RUNTIME_SCHEMA = 'project-knowledge/integration-runtime/v1';
28
+ const MCP_LAUNCHER_ERROR_SCHEMA = 'project-knowledge/mcp-launcher-error/v1';
29
+ const INTEGRATION_PACKAGE_NAME = '@sanqianx/project-knowledge';
30
+ const MCP_SERVER_NAME = 'project-knowledge';
31
+ const MCP_TOOL_NAMES = ['project_knowledge_resolve', 'project_knowledge_record_requirement', 'project_knowledge_search', 'project_knowledge_ask', 'project_knowledge_get', 'project_knowledge_history'];
32
+ const MCP_LAUNCHER_FILE = path.join('plugins', INTEGRATION_NAME, 'scripts', 'mcp-launcher.cjs');
33
+ const MCP_PROBE_TIMEOUT_MS = 8000;
34
+ const MCP_PROBE_STDERR_CAP_BYTES = 32768;
35
+ const MCP_PROBE_STDOUT_CAP_BYTES = 32768;
36
+ const MCP_PROBE_FAILURE_DETAILS = {
37
+ PROBE_SPAWN_FAILED: '无法启动本地 MCP 启动进程;请检查 Node 运行时。',
38
+ INITIALIZE_REJECTED: '本地 MCP 服务拒绝了 initialize 握手;未回显上游错误内容。',
39
+ SERVER_IDENTITY: 'MCP 服务身份不是 project-knowledge,判定未通过。',
40
+ VERSION_MISMATCH: 'MCP 服务版本与当前产品版本不一致。',
41
+ TOOLS_CONTRACT: 'MCP 服务未提供完整的六个知识工具。',
42
+ PROBE_OUTPUT_OVERFLOW: '探测输出超过大小上限,已立即失败并清理子进程。',
43
+ PROBE_TIMEOUT: 'MCP initialize 握手超时。',
44
+ PROBE_CRASH: '启动探测自身异常终止;已使用固定诊断,未回显异常内容。',
45
+ LAUNCHER_ERROR_UNKNOWN: '本地启动器返回了无法识别的错误代码;未回显其内容。',
46
+ BINDING_MISSING: '尚未写入本地运行时绑定;请先完成知识接入。',
47
+ BINDING_CORRUPT: '本地运行时绑定不可读;请修复知识接入。',
48
+ BINDING_PACKAGE_MISMATCH: '运行时绑定不是本产品包。',
49
+ BINDING_VERSION_MISMATCH: '运行时绑定版本不一致;请更新插件。',
50
+ };
51
+ // Launcher error codes the probe may adopt from stderr. Anything else is an
52
+ // untrusted value: it never becomes a result code and is never echoed.
53
+ const MCP_LAUNCHER_ERROR_CODE_ALLOWLIST = [
54
+ 'BINDING_MISSING',
55
+ 'BINDING_CORRUPT',
56
+ 'BINDING_SCHEMA_MISMATCH',
57
+ 'BINDING_PACKAGE_MISMATCH',
58
+ 'BINDING_VERSION_MISMATCH',
59
+ 'BINDING_INVALID_ROOT',
60
+ 'BINDING_ENTRY_MISSING',
61
+ 'BINDING_ENTRY_INVALID',
62
+ 'LAUNCH_FAILED',
63
+ ];
25
64
  const SUPPORTED_CLIENTS = ['claude', 'opencode', 'codex', 'zcode'];
26
65
  const DEFAULT_BRIDGE_CONSUMER = 'project-knowledge';
27
66
  const CLIENT_LABELS = {
@@ -185,9 +224,12 @@ class IntegrationManager {
185
224
  this.marketplaceSource = options.marketplaceSource || DEFAULT_MARKETPLACE_SOURCE;
186
225
  this.claudeMarketplaceSource = options.marketplaceSource || this.rootDir;
187
226
  this.explicitMarketplace = Boolean(options.marketplaceSource);
227
+ this.codexMarketplaceAddSource = options.codexMarketplaceSource || options.marketplaceSource || this.rootDir;
228
+ this.explicitCodexMarketplace = Boolean(options.codexMarketplaceSource || options.marketplaceSource);
188
229
  this.runner = options.runner || defaultRunner;
189
230
  this.commandResolver = options.commandResolver || defaultCommandResolver;
190
231
  this.codexHookProbe = options.codexHookProbe || require('./codex-context-status').readCodexHooks;
232
+ this.mcpProbe = options.mcpProbe || null;
191
233
  this.dryRun = Boolean(options.dryRun);
192
234
  this.logger = options.logger || null;
193
235
  this.operationId = options.operationId || '';
@@ -418,6 +460,162 @@ class IntegrationManager {
418
460
  try { const stat = fs.statSync(target); return client === 'opencode' ? stat.isDirectory() : stat.isFile(); } catch { return false; }
419
461
  }
420
462
 
463
+ // ---- Local MCP runtime binding and startup diagnostics ----
464
+
465
+ integrationRuntimeBindingPath() {
466
+ return path.join(resolveDataDirPath({ dataDir: this.env.KB_DATA_DIR || '', homeDir: this.homeDir }), 'integration-runtime.json');
467
+ }
468
+
469
+ ensureIntegrationRuntimeBinding(options = {}) {
470
+ const manifest = JSON.parse(fs.readFileSync(path.join(this.rootDir, 'package.json'), 'utf8'));
471
+ if (manifest.name !== INTEGRATION_PACKAGE_NAME) throw new Error('知识接入绑定的运行包不是本产品包,已拒绝写入绑定。');
472
+ const bin = manifest.bin && typeof manifest.bin === 'object' && !Array.isArray(manifest.bin) ? manifest.bin['project-knowledge-mcp'] : manifest.bin;
473
+ const packageRoot = path.resolve(this.rootDir);
474
+ const entry = typeof bin === 'string' && bin ? path.resolve(packageRoot, bin) : '';
475
+ const entryError = 'package.json must declare an existing project-knowledge-mcp bin entry inside the package before binding the local MCP runtime.';
476
+ const lexical = entry ? path.relative(packageRoot, entry) : '..';
477
+ if (!entry || !lexical || lexical === '..' || lexical.startsWith('..' + path.sep) || path.isAbsolute(lexical)) throw new Error(entryError);
478
+ // The same realpath containment the launcher enforces at start: a link
479
+ // inside the package that resolves outside the real root must never be
480
+ // bound, and the entry must be a regular file.
481
+ let rootReal = '';
482
+ let entryReal = '';
483
+ try {
484
+ rootReal = fs.realpathSync(packageRoot);
485
+ entryReal = fs.realpathSync(entry);
486
+ } catch (error) { throw new Error(entryError); }
487
+ const relativeReal = path.relative(rootReal, entryReal);
488
+ if (!relativeReal || relativeReal === '..' || relativeReal.startsWith('..' + path.sep) || path.isAbsolute(relativeReal)) throw new Error(entryError);
489
+ let entryStat = null;
490
+ try { entryStat = fs.statSync(entryReal); } catch (error) { throw new Error(entryError); }
491
+ if (!entryStat.isFile()) throw new Error(entryError);
492
+ const bindingPath = this.integrationRuntimeBindingPath();
493
+ const binding = { schema: INTEGRATION_RUNTIME_SCHEMA, packageName: manifest.name, packageRoot, version: manifest.version, updatedAt: new Date().toISOString() };
494
+ try {
495
+ const existing = JSON.parse(fs.readFileSync(bindingPath, 'utf8'));
496
+ // An injected beforeRename means the caller explicitly wants the write
497
+ // path exercised (failure surfacing); only the quiet repair path may
498
+ // short-circuit to keep the binding byte- and mtime-idempotent.
499
+ const verifyWrite = typeof options.beforeRename === 'function';
500
+ if (!verifyWrite && existing && typeof existing === 'object' && existing.schema === INTEGRATION_RUNTIME_SCHEMA && existing.packageName === binding.packageName && existing.packageRoot === binding.packageRoot && existing.version === binding.version) {
501
+ return { state: 'unchanged', path: bindingPath, version: manifest.version };
502
+ }
503
+ } catch (error) { /* missing or unreadable bindings are rewritten below */ }
504
+ if (this.dryRun) {
505
+ this.planned.push({ command: 'runtime-binding', args: ['write', bindingPath] });
506
+ return { state: 'planned', path: bindingPath };
507
+ }
508
+ writeJsonAtomic(bindingPath, binding, typeof options.beforeRename === 'function' ? { beforeRename: options.beforeRename } : {});
509
+ return { state: 'written', path: bindingPath, version: manifest.version };
510
+ }
511
+
512
+ async probeMcpStartup(probeOptions = {}) {
513
+ const launcherPath = probeOptions.launcherPath || path.join(this.rootDir, MCP_LAUNCHER_FILE);
514
+ const scope = probeOptions.scope || 'product-runtime';
515
+ const expectedVersion = typeof probeOptions.version === 'string' && probeOptions.version ? probeOptions.version : this.version;
516
+ const timeoutMs = Number.isFinite(probeOptions.timeoutMs) ? probeOptions.timeoutMs : MCP_PROBE_TIMEOUT_MS;
517
+ const stderrCapBytes = Number.isFinite(probeOptions.stderrCapBytes) ? probeOptions.stderrCapBytes : MCP_PROBE_STDERR_CAP_BYTES;
518
+ const stdoutCapBytes = Number.isFinite(probeOptions.stdoutCapBytes) ? probeOptions.stdoutCapBytes : MCP_PROBE_STDOUT_CAP_BYTES;
519
+ const checkedAt = new Date().toISOString();
520
+ let Client = null;
521
+ let StdioClientTransport = null;
522
+ try {
523
+ const requireFromPackage = createRequire(path.join(this.rootDir, 'package.json'));
524
+ Client = requireFromPackage('@modelcontextprotocol/sdk/client/index.js').Client;
525
+ StdioClientTransport = requireFromPackage('@modelcontextprotocol/sdk/client/stdio.js').StdioClientTransport;
526
+ } catch (error) {
527
+ this.log('error', 'integration.mcp_probe_sdk_unavailable', 'The MCP SDK is unavailable; the probe fails explicitly instead of guessing.', { error, context: { scope } });
528
+ return { state: 'failed', checkedAt, scope, expectedVersion, code: 'PROBE_SDK_UNAVAILABLE', detail: '安装包内缺少 MCP SDK,无法执行启动探测;未据此判定客户端未接入。' };
529
+ }
530
+ return await new Promise(resolve => {
531
+ let client = null;
532
+ let timer = null;
533
+ let settled = false;
534
+ let stderrTail = '';
535
+ let stderrBytes = 0;
536
+ let stdoutBytes = 0;
537
+ const failed = (code, extra = {}) => {
538
+ if (settled) return;
539
+ settled = true;
540
+ if (timer) clearTimeout(timer);
541
+ resolve({ state: 'failed', checkedAt, scope, expectedVersion, code, ...extra, detail: MCP_PROBE_FAILURE_DETAILS[code] || '本地 MCP 启动探测未通过;诊断不回显上游文本。' });
542
+ };
543
+ const cleanup = async () => {
544
+ const closing = client;
545
+ client = null;
546
+ if (!closing) return;
547
+ try { await closing.close(); } catch (error) { /* the SDK escalates SIGTERM/SIGKILL on its own child only */ }
548
+ };
549
+ // The probe must exercise the manager's own isolated data dir; a host
550
+ // KB_DATA_DIR (for example a Workbench session export) must not leak in.
551
+ const probeEnv = { ...process.env, ...this.env, KB_DATA_DIR: path.dirname(this.integrationRuntimeBindingPath()) };
552
+ const transport = new StdioClientTransport({ command: process.execPath, args: [launcherPath], env: probeEnv, stderr: 'pipe' });
553
+ transport.stderr.on('data', chunk => {
554
+ stderrBytes += chunk.length;
555
+ stderrTail = (stderrTail + String(chunk)).slice(-4096);
556
+ if (stderrBytes > stderrCapBytes) {
557
+ failed('PROBE_OUTPUT_OVERFLOW');
558
+ cleanup();
559
+ }
560
+ });
561
+ // The SDK exposes no public stdout tap, so count bytes on the child
562
+ // stream right after start(): a hostile single-line flood fails at the
563
+ // cap instead of growing the SDK read buffer for the whole timeout.
564
+ const startTransport = transport.start.bind(transport);
565
+ transport.start = async () => {
566
+ await startTransport();
567
+ const stream = transport._process && transport._process.stdout;
568
+ if (stream) stream.on('data', chunk => {
569
+ stdoutBytes += chunk.length;
570
+ if (stdoutBytes > stdoutCapBytes) {
571
+ failed('PROBE_OUTPUT_OVERFLOW');
572
+ cleanup();
573
+ }
574
+ });
575
+ };
576
+ client = new Client({ name: 'project-knowledge-integration-probe', version: '1' }, { capabilities: {} });
577
+ timer = setTimeout(() => {
578
+ failed('PROBE_TIMEOUT');
579
+ cleanup();
580
+ }, timeoutMs);
581
+ (async () => {
582
+ try {
583
+ await client.connect(transport);
584
+ if (settled) return;
585
+ const serverVersion = client.getServerVersion();
586
+ if (!serverVersion || serverVersion.name !== MCP_SERVER_NAME) return failed('SERVER_IDENTITY');
587
+ // A foreign serverVersion string is untrusted text; only the fixed
588
+ // mismatch code is reported, never the value itself.
589
+ if (String(serverVersion.version || '') !== expectedVersion) return failed('VERSION_MISMATCH');
590
+ let listed = null;
591
+ try { listed = await client.listTools(); } catch (error) { return failed('TOOLS_CONTRACT', { toolCount: 0 }); }
592
+ if (settled) return;
593
+ const names = (listed && Array.isArray(listed.tools) ? listed.tools : []).map(tool => String((tool && tool.name) || '')).filter(Boolean);
594
+ if (names.length !== MCP_TOOL_NAMES.length || !MCP_TOOL_NAMES.every(name => names.includes(name))) return failed('TOOLS_CONTRACT', { toolCount: names.length });
595
+ settled = true;
596
+ clearTimeout(timer);
597
+ resolve({ state: 'ready', checkedAt, scope, expectedVersion, actualVersion: String(serverVersion.version || ''), serverName: serverVersion.name, tools: names, detail: '本地 MCP 服务已完成 initialize,并通过身份、版本与六工具契约校验。' });
598
+ } catch (error) {
599
+ if (!settled) {
600
+ const lines = stderrTail.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
601
+ const last = lines[lines.length - 1] || '';
602
+ try {
603
+ const payload = JSON.parse(last);
604
+ if (payload && payload.schema === MCP_LAUNCHER_ERROR_SCHEMA && payload.code) {
605
+ const launcherCode = String(payload.code);
606
+ return failed(MCP_LAUNCHER_ERROR_CODE_ALLOWLIST.includes(launcherCode) ? launcherCode : 'LAUNCHER_ERROR_UNKNOWN');
607
+ }
608
+ } catch { /* plain-text launcher diagnostics fall through to fixed codes */ }
609
+ if (error && (error.code === 'ENOENT' || /spawn/i.test(String((error && error.message) || error)))) return failed('PROBE_SPAWN_FAILED');
610
+ return failed('INITIALIZE_REJECTED');
611
+ }
612
+ } finally {
613
+ await cleanup();
614
+ }
615
+ })();
616
+ });
617
+ }
618
+
421
619
  zcodeContextOptions() { return { configFile: this.zcodeConfigFile, rootDir: this.rootDir, dryRun: this.dryRun }; }
422
620
  statusZCode() { return require('./zcode-context-integration').status(this.zcodeContextOptions()); }
423
621
  installZCode() { return require('./zcode-context-integration').install(this.zcodeContextOptions()); }
@@ -479,8 +677,30 @@ class IntegrationManager {
479
677
  }
480
678
 
481
679
  codexMarketplaceExists() {
680
+ return this.codexMarketplaceRegisteredSource() !== null;
681
+ }
682
+
683
+ codexMarketplaceRegisteredSource() {
482
684
  const result = this.execute('codex', ['plugin', 'marketplace', 'list'], { allowFailure: true });
483
- return String(result.stdout || '').split(/\r?\n/).some(line => line.trim().startsWith(`${MARKETPLACE_NAME} `));
685
+ const line = String(result.stdout || '').split(/\r?\n/).map(item => item.trim()).find(item => item.startsWith(`${MARKETPLACE_NAME} `));
686
+ const source = line ? line.slice(MARKETPLACE_NAME.length).trim() : '';
687
+ return source || null;
688
+ }
689
+
690
+ isOwnCodexMarketplaceSource(source) {
691
+ const text = String(source || '').trim();
692
+ if (!text) return false;
693
+ if (text.toLowerCase() === DEFAULT_MARKETPLACE_SOURCE.toLowerCase()) return true;
694
+ if (/^(?:git\+)?https:\/\/github\.com\/SanQianX\/project-knowledge-base(?:\.git)?\/?$/i.test(text)) return true;
695
+ if (!path.isAbsolute(text)) return false;
696
+ const directory = path.resolve(text);
697
+ let stat = null;
698
+ try { stat = fs.statSync(directory); } catch { return true; }
699
+ if (!stat.isDirectory()) return false;
700
+ try {
701
+ const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'));
702
+ return Boolean(manifest) && manifest.name === INTEGRATION_PACKAGE_NAME;
703
+ } catch { return false; }
484
704
  }
485
705
 
486
706
  ensureClaudeMarketplace() {
@@ -499,11 +719,29 @@ class IntegrationManager {
499
719
  }
500
720
 
501
721
  ensureCodexMarketplace() {
502
- if (!this.dryRun && this.codexMarketplaceExists()) return;
503
- this.execute('codex', ['plugin', 'marketplace', 'add', this.marketplaceSource]);
722
+ const target = path.resolve(this.codexMarketplaceAddSource);
723
+ if (!this.dryRun) {
724
+ const existing = this.codexMarketplaceRegisteredSource();
725
+ if (existing !== null) {
726
+ if (this.explicitCodexMarketplace) return;
727
+ if (path.resolve(existing).toLowerCase() === target.toLowerCase()) return;
728
+ if (!this.isOwnCodexMarketplaceSource(existing)) throw new Error('同名 Codex 插件市场来源不是当前安装包,且无法确认属于本产品;未覆盖第三方来源。请检查 Codex 插件市场配置。');
729
+ this.execute('codex', ['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
730
+ const added = this.execute('codex', ['plugin', 'marketplace', 'add', target], { allowFailure: true });
731
+ if (added.status !== 0) {
732
+ const restored = this.execute('codex', ['plugin', 'marketplace', 'add', existing], { allowFailure: true });
733
+ throw new Error(restored.status === 0
734
+ ? 'Codex 插件市场更换为当前安装包失败,已恢复原市场来源;请重试接入。'
735
+ : 'Codex 插件市场更换为当前安装包失败,恢复原市场来源也未成功;请手动检查 codex 插件市场。');
736
+ }
737
+ return;
738
+ }
739
+ }
740
+ this.execute('codex', ['plugin', 'marketplace', 'add', target]);
504
741
  }
505
742
 
506
743
  installClaude(options = {}) {
744
+ this.ensureIntegrationRuntimeBinding();
507
745
  let plugin = this.dryRun ? null : this.claudePlugin();
508
746
  if (plugin?.enabled === false) throw new Error('知识库插件已被用户禁用,请在 Claude Code 中明确启用;未静默修改。');
509
747
  this.ensureClaudeMarketplace();
@@ -524,6 +762,7 @@ class IntegrationManager {
524
762
  }
525
763
 
526
764
  updateClaude(options = {}) {
765
+ this.ensureIntegrationRuntimeBinding();
527
766
  this.ensureClaudeMarketplace();
528
767
  this.execute('claude', ['plugin', 'marketplace', 'update', MARKETPLACE_NAME]);
529
768
  if (!this.dryRun && !this.claudePlugin()) return this.installClaude(options);
@@ -577,6 +816,7 @@ class IntegrationManager {
577
816
  }
578
817
 
579
818
  installCodex() {
819
+ this.ensureIntegrationRuntimeBinding();
580
820
  this.ensureCodexMarketplace();
581
821
  const plugin = this.dryRun ? null : this.codexPlugin();
582
822
  if (plugin?.enabled === false) throw new Error('知识库插件已被用户禁用,请在 Codex 中明确启用,未静默修改。');
@@ -590,6 +830,7 @@ class IntegrationManager {
590
830
  }
591
831
 
592
832
  updateCodex() {
833
+ this.ensureIntegrationRuntimeBinding();
593
834
  if (!this.dryRun && this.codexPlugin()?.enabled === false) throw new Error('知识库插件已被用户禁用,请在 Codex 中明确启用,未静默修改。');
594
835
  this.ensureCodexMarketplace();
595
836
  const refreshed = this.execute('codex', ['plugin', 'marketplace', 'upgrade', MARKETPLACE_NAME], { allowFailure: true });
@@ -750,9 +991,21 @@ class IntegrationManager {
750
991
 
751
992
  async operate(operation, clients, options = {}) {
752
993
  const results = [];
994
+ let bindingFailure = null;
995
+ if (operation === 'install' || operation === 'update') {
996
+ // The runtime binding is a precondition for knowledge installs: a
997
+ // binding failure must fail the knowledge result, never report success.
998
+ try {
999
+ this.ensureIntegrationRuntimeBinding();
1000
+ } catch (error) {
1001
+ bindingFailure = Object.assign(new Error('本地 MCP 运行时绑定校验失败,已阻止知识接入;采集能力不受影响。'), { code: 'INTEGRATION_RUNTIME_BINDING_FAILED' });
1002
+ this.log('error', 'integration.runtime_binding_failed', 'Failed to write the local MCP runtime binding.', { error, context: { operation } });
1003
+ }
1004
+ }
753
1005
  for (const rawClient of clients) {
754
1006
  const client = normalizeClient(rawClient);
755
1007
  try {
1008
+ if (bindingFailure) throw bindingFailure;
756
1009
  const suffix = CLIENT_METHOD_SUFFIX[client];
757
1010
  let result;
758
1011
  if (operation === 'status') result = this[`status${suffix}`]();
@@ -771,6 +1024,7 @@ class IntegrationManager {
771
1024
  ok: false,
772
1025
  state: 'failed',
773
1026
  detail: error.message,
1027
+ ...(error.code ? { code: error.code } : {}),
774
1028
  });
775
1029
  }
776
1030
  }
@@ -798,14 +1052,28 @@ class IntegrationManager {
798
1052
  } catch (error) {
799
1053
  summary.bridge = { ok: false, state: 'failed', detail: error.message };
800
1054
  }
1055
+ let bindingFailure = null;
1056
+ if (options.captureOnly !== true) {
1057
+ try {
1058
+ summary.runtimeBinding = { ok: true, ...this.ensureIntegrationRuntimeBinding() };
1059
+ } catch (error) {
1060
+ summary.runtimeBinding = { ok: false, state: 'failed', detail: error.message };
1061
+ bindingFailure = Object.assign(new Error('本地 MCP 运行时绑定校验失败,已阻止知识接入;采集能力不受影响。'), { code: 'INTEGRATION_RUNTIME_BINDING_FAILED' });
1062
+ this.log('error', 'integration.runtime_binding_failed', 'Failed to write the local MCP runtime binding.', { error, context: { operation: 'installAll' } });
1063
+ }
1064
+ }
801
1065
  for (const client of clients) {
802
1066
  const suffix = CLIENT_METHOD_SUFFIX[client];
803
1067
  const entry = { client, label: CLIENT_LABELS[client] };
804
1068
  if (options.captureOnly !== true) {
805
- try {
806
- entry.knowledgeIntegration = { ok: true, ...(await this[`install${suffix}`](options)) };
807
- } catch (error) {
808
- entry.knowledgeIntegration = { ok: false, state: 'failed', detail: error.message };
1069
+ if (bindingFailure) {
1070
+ entry.knowledgeIntegration = { ok: false, state: 'failed', code: bindingFailure.code, detail: bindingFailure.message };
1071
+ } else {
1072
+ try {
1073
+ entry.knowledgeIntegration = { ok: true, ...(await this[`install${suffix}`](options)) };
1074
+ } catch (error) {
1075
+ entry.knowledgeIntegration = { ok: false, state: 'failed', detail: error.message };
1076
+ }
809
1077
  }
810
1078
  }
811
1079
  if (options.knowledgeOnly !== true) {
@@ -816,6 +1084,32 @@ class IntegrationManager {
816
1084
  return summary;
817
1085
  }
818
1086
 
1087
+ installedMcpLauncherPaths(client) {
1088
+ const launchers = [];
1089
+ const seen = new Set();
1090
+ const consider = directory => {
1091
+ if (typeof directory !== 'string' || !path.isAbsolute(directory)) return;
1092
+ const launcher = path.join(directory, 'scripts', 'mcp-launcher.cjs');
1093
+ if (seen.has(launcher)) return;
1094
+ try { if (!fs.statSync(launcher).isFile()) return; } catch { return; }
1095
+ seen.add(launcher);
1096
+ launchers.push(launcher);
1097
+ };
1098
+ if (client === 'claude') {
1099
+ try {
1100
+ const index = JSON.parse(fs.readFileSync(path.join(this.homeDir, '.claude', 'plugins', 'installed_plugins.json'), 'utf8'));
1101
+ for (const record of (index.plugins?.[`${INTEGRATION_NAME}@${MARKETPLACE_NAME}`] || [])) {
1102
+ if (record && record.version === this.version) consider(record.installPath);
1103
+ }
1104
+ } catch { /* no native install index yet */ }
1105
+ consider(path.join(this.homeDir, '.claude', 'plugins', 'cache', MARKETPLACE_NAME, INTEGRATION_NAME, this.version));
1106
+ }
1107
+ if (client === 'codex' && this.executable('codex')) {
1108
+ try { consider(this.codexPlugin()?.source?.path); } catch { /* the client CLI failed to list plugins; there is no installed entry to probe */ }
1109
+ }
1110
+ return launchers;
1111
+ }
1112
+
819
1113
  async statusAll(options = {}) {
820
1114
  const clients = options.clients && options.clients.length ? options.clients.map(normalizeClient) : SUPPORTED_CLIENTS;
821
1115
  const summary = { clients: [], bridge: null };
@@ -824,6 +1118,23 @@ class IntegrationManager {
824
1118
  } catch (error) {
825
1119
  summary.bridge = { ok: false, state: 'failed', bridgeHealthy: false, detail: error.message };
826
1120
  }
1121
+ // Probes start in parallel so one hung launcher costs a single timeout.
1122
+ // Client probes exercise the entry that client would actually launch
1123
+ // (never the host source tree); identical entries share one host check,
1124
+ // and the shared product-runtime probe covers the shipping runtime.
1125
+ const runProbe = info => (this.mcpProbe ? this.mcpProbe(info) : this.probeMcpStartup(info)).catch(() => ({ state: 'failed', checkedAt: new Date().toISOString(), code: 'PROBE_CRASH', expectedVersion: this.version, detail: MCP_PROBE_FAILURE_DETAILS.PROBE_CRASH }));
1126
+ const clientProbes = new Map();
1127
+ const hostProbe = options.captureOnly ? null : runProbe({ client: 'host', scope: 'product-runtime', version: this.version });
1128
+ if (!options.captureOnly) {
1129
+ const sharedByLauncher = new Map();
1130
+ for (const client of clients) {
1131
+ if (client !== 'claude' && client !== 'codex') continue;
1132
+ const launcherPath = this.installedMcpLauncherPaths(client)[0];
1133
+ if (!launcherPath) continue;
1134
+ if (!sharedByLauncher.has(launcherPath)) sharedByLauncher.set(launcherPath, runProbe({ client, scope: `client:${client}`, launcherPath, version: this.version }));
1135
+ clientProbes.set(client, sharedByLauncher.get(launcherPath).then(result => ({ ...result, scope: `client:${client}` })));
1136
+ }
1137
+ }
827
1138
  for (const client of clients) {
828
1139
  const suffix = CLIENT_METHOD_SUFFIX[client];
829
1140
  const entry = { client, label: CLIENT_LABELS[client], available: Boolean(this.executable(client)), configFound: this.captureConfigFound(client) };
@@ -834,10 +1145,16 @@ class IntegrationManager {
834
1145
  entry.knowledgeIntegration = { ok: false, state: 'failed', detail: error.message };
835
1146
  }
836
1147
  entry.automaticContext = client === 'codex' ? await this.automaticCodexContextStatus(entry.knowledgeIntegration, options.cwd) : this.automaticContextStatus(client, entry.knowledgeIntegration);
1148
+ if (client === 'claude' || client === 'codex') {
1149
+ entry.mcpStartup = clientProbes.has(client)
1150
+ ? await clientProbes.get(client)
1151
+ : { state: 'failed', checkedAt: new Date().toISOString(), scope: `client:${client}`, code: 'CLIENT_PLUGIN_ENTRY_MISSING', expectedVersion: this.version, detail: '该客户端没有已安装的知识插件入口;未用宿主源码入口代替客户端实际启动入口做健康判定。' };
1152
+ }
837
1153
  }
838
1154
  entry.developmentCapture = await this.captureOperation('status', client);
839
1155
  summary.clients.push(entry);
840
1156
  }
1157
+ if (hostProbe) summary.mcpRuntime = await hostProbe;
841
1158
  return summary;
842
1159
  }
843
1160
 
@@ -604,8 +604,12 @@ class ModuleBridge {
604
604
 
605
605
  _supervisedSpecs() {
606
606
  const specs = [];
607
+ // C2 minimal wiring: the terminal service receives the non-secret product
608
+ // knowledge root via KB_KNOWLEDGE_DATA_DIR. Its own AGENT_TERMINAL_DATA_DIR
609
+ // stays the dedicated workbench subtree and is never widened.
610
+ const knowledgeDataDir = this.dataDir || require('./data-dir').getDataDir();
607
611
  if (process.env.KB_TERMINAL_COMMAND) {
608
- 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 } });
612
+ specs.push({ name: 'terminal', command: process.env.KB_TERMINAL_COMMAND, url: this.terminalUrl, env: { KB_KNOWLEDGE_DATA_DIR: knowledgeDataDir, KB_WORKBENCH_RUNTIME_TOKEN: this.runtimeToken, KB_KNOWLEDGE_ENGINE_URL: this.engineUrl, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl } });
609
613
  } else {
610
614
  const terminalEntry = resolveModuleEntry('terminal');
611
615
  if (terminalEntry) {
@@ -613,7 +617,7 @@ class ModuleBridge {
613
617
  name: 'terminal',
614
618
  command: `node ${JSON.stringify(terminalEntry)}`,
615
619
  url: this.terminalUrl,
616
- 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 },
620
+ env: { AGENT_TERMINAL_DATA_DIR: path.join(knowledgeDataDir, 'workbench'), KB_KNOWLEDGE_DATA_DIR: knowledgeDataDir, 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 },
617
621
  });
618
622
  } else {
619
623
  this.logger.warn('modules.supervisor', 'claude-ai-workbench runtime not installed; expecting an external service.', {});
@@ -20,6 +20,6 @@ for (const module of Object.values(manifest.modules)) {
20
20
  module.version = installed.version;
21
21
  for (const relative of Object.keys(module.files)) { if (!safeRelative(relative)) throw new Error('Unsafe content path'); const target = path.join(directory, relative); if (fs.lstatSync(target).isSymbolicLink()) throw new Error('Unsafe content symlink'); module.files[relative] = digest(target); }
22
22
  }
23
- for (const relative of ['ui/index.html', 'ui/product.js', 'ui/product.css', 'ui/favicon.svg', 'plugins/project-knowledge/hooks/claude.json', 'plugins/project-knowledge/hooks/codex.json', 'plugins/project-knowledge/scripts/context-hook.cjs', 'plugins/project-knowledge/.claude-plugin/plugin.json', 'plugins/project-knowledge/.codex-plugin/plugin.json', 'plugins/project-knowledge/.mcp.json']) manifest.productFiles[relative] = digest(path.join(root, relative));
23
+ for (const relative of ['ui/index.html', 'ui/product.js', 'ui/product.css', 'ui/favicon.svg', 'plugins/project-knowledge/hooks/claude.json', 'plugins/project-knowledge/hooks/codex.json', 'plugins/project-knowledge/scripts/context-hook.cjs', 'plugins/project-knowledge/scripts/mcp-launcher.cjs', 'plugins/project-knowledge/.claude-plugin/plugin.json', 'plugins/project-knowledge/.codex-plugin/plugin.json', 'plugins/project-knowledge/.mcp.json']) manifest.productFiles[relative] = digest(path.join(root, relative));
24
24
  AtomicFile.writeJsonAtomic(file, manifest);
25
25
  console.log('Updated content hashes from exact installed modules and reviewed product assets; this is not release acceptance.');
@@ -60,11 +60,15 @@ function releaseVersionTargets(rootDir, version, { webOnly = false } = {}) {
60
60
  {
61
61
  file: 'plugins/project-knowledge/.mcp.json',
62
62
  update(value) {
63
- const args = value && value.mcpServers && value.mcpServers['project-knowledge'] && value.mcpServers['project-knowledge'].args;
64
- if (!Array.isArray(args)) throw new Error('project-knowledge MCP server has no args array');
65
- const index = args.findIndex(arg => /^(?:@sanqianx\/)?project-knowledge@/.test(String(arg)));
66
- if (index < 0) throw new Error('project-knowledge MCP server has no pinned package argument');
67
- args[index] = `@sanqianx/project-knowledge@${version}`;
63
+ const server = value && value.mcpServers && value.mcpServers['project-knowledge'];
64
+ const serialized = JSON.stringify(server || {});
65
+ if (/npx/i.test(serialized) || /@sanqianx\/project-knowledge@/.test(serialized)) throw new Error('project-knowledge MCP server must use the bundled launcher; the npx/pinned package form is rejected');
66
+ const args = Array.isArray(server && server.args) ? server.args : [];
67
+ const bootstrap = String(args[1] || '');
68
+ if (!server || server.command !== 'node' || args[0] !== '-e' || !bootstrap.includes('mcp-launcher.cjs')) throw new Error('project-knowledge MCP server must launch the bundled mcp-launcher.cjs via node -e');
69
+ if (server.cwd !== undefined) throw new Error('project-knowledge MCP server must not pin a cwd; the plugin version is carried by env.KB_PLUGIN_VERSION');
70
+ if (bootstrap.includes('process.cwd')) throw new Error('the MCP bootstrap must never consult process.cwd');
71
+ server.env = { ...(server.env || {}), KB_PLUGIN_VERSION: version };
68
72
  },
69
73
  },
70
74
  ].filter(target => !webOnly || !target.file.startsWith('desktop/')).map(target => ({ ...target, path: path.join(rootDir, target.file) }));
@@ -7,7 +7,7 @@ const { safeRelative } = require('./pack-module-candidates');
7
7
  function verifyProductRuntime(coreRoot, resolveFrom = path.join(coreRoot, 'package.json')) {
8
8
  const manifest = JSON.parse(fs.readFileSync(path.join(coreRoot, 'module-runtime-manifest.json')));
9
9
  if (manifest.schema !== 'project-knowledge/module-content/v1' || Object.keys(manifest.modules).length !== 3) throw new Error('Incomplete module content manifest');
10
- for (const file of ['ui/index.html', 'ui/product.js', 'ui/product.css', 'ui/favicon.svg', 'plugins/project-knowledge/hooks/claude.json', 'plugins/project-knowledge/hooks/codex.json', 'plugins/project-knowledge/scripts/context-hook.cjs', 'plugins/project-knowledge/.claude-plugin/plugin.json', 'plugins/project-knowledge/.codex-plugin/plugin.json', 'plugins/project-knowledge/.mcp.json']) if (!manifest.productFiles?.[file]) throw new Error(`Product asset missing from content manifest: ${file}`);
10
+ for (const file of ['ui/index.html', 'ui/product.js', 'ui/product.css', 'ui/favicon.svg', 'plugins/project-knowledge/hooks/claude.json', 'plugins/project-knowledge/hooks/codex.json', 'plugins/project-knowledge/scripts/context-hook.cjs', 'plugins/project-knowledge/scripts/mcp-launcher.cjs', 'plugins/project-knowledge/.claude-plugin/plugin.json', 'plugins/project-knowledge/.codex-plugin/plugin.json', 'plugins/project-knowledge/.mcp.json']) if (!manifest.productFiles?.[file]) throw new Error(`Product asset missing from content manifest: ${file}`);
11
11
  const resolve = createRequire(resolveFrom);
12
12
  for (const [relative, expected] of Object.entries(manifest.productFiles || {})) {
13
13
  if (!safeRelative(relative) || crypto.createHash('sha256').update(fs.readFileSync(path.join(coreRoot, relative))).digest('hex') !== expected) throw new Error(`Product UI content mismatch: ${relative}`);
@@ -0,0 +1,87 @@
1
+ # Leader 与 GLM 的 Workbench 协作手册
2
+
3
+ ## 角色与边界
4
+
5
+ Leader 负责知识检索、接口决策、任务派发、代码审查、独立复测和发布判断。GLM-5.3 负责限定范围内的实现与测试。最多两条独立执行线;修改相同文件、用户配置、运行实例、npm 安装和固定端口验收必须串行。
6
+
7
+ 使用真实 Workbench,不以其他模型的原生子代理冒充 GLM。完全模式不是扩大授权:禁止自行清空数据、覆盖第三方配置、修改客户端信任记录或发布软件。
8
+
9
+ ## 创建与检查会话
10
+
11
+ 所有路径相对于运行中的知识库服务。服务地址应通过运行身份确认,不假设端口永远是 5757。
12
+
13
+ 1. 读取项目投影,取得 Workbench 的 `contextId`;它不等于知识引擎的 `projectId`。
14
+ 2. `POST /api/terminal/sessions`,明确设置以下参数;不要依赖 profile 的默认模型。
15
+
16
+ ```json
17
+ {
18
+ "context": {
19
+ "contextId": "<Workbench 项目 ID>",
20
+ "contextType": "project",
21
+ "displayName": "<项目名称>",
22
+ "workspaceRef": "<Workbench 项目 ID>"
23
+ },
24
+ "aiProfileId": "glm",
25
+ "model": "glm-5.3",
26
+ "agentId": "claude-code",
27
+ "permissionMode": "bypassPermissions"
28
+ }
29
+ ```
30
+
31
+ 3. 核对回执的 `sessionId`、项目、`model`、`selectedModel` 和权限模式。模型不匹配不能继续派发。
32
+ 4. 必要时使用 `PUT /api/terminal/sessions/<id>/permission-mode`,正文为 `{"mode":"bypassPermissions"}`;仅改变自己的任务会话。
33
+ 5. 用户在“开始工作”选择对应项目后,可查看真实会话及工具执行。
34
+
35
+ ## 任务合同
36
+
37
+ 每批任务必须包含:任务编号、目标、真实仓库、已有知识引用、可改文件、禁止操作、失败复现、验收条件和交付格式。
38
+
39
+ 可直接使用下面的短合同,替换具体路径与用例后派发:
40
+
41
+ ```text
42
+ 任务:修复一个已复现的问题,不扩展功能。
43
+ 仓库:绝对路径;已有知识:文件与 commit 引用。
44
+ 可改文件:明确清单。禁止:用户配置、真实数据、信任记录、发布和未授权提交。
45
+ 复现:输入、期望、实际;先补失败测试,再修复。
46
+ 编辑:先验证实际 apply_patch 入口;不可用时交付补丁正文。
47
+ 测试:具体命令、Node 运行时、仓库外临时目录、绝对证据目录。
48
+ 交付:根因、diff、首失败与复测结果、未完成项;完成后 idle 等待审查。
49
+ ```
50
+
51
+ 先解析实际 Git 根目录并检索已有知识,再对照当前源码。知识文件只读;不手写最终知识、staging 或引擎状态。源文件修改使用 `apply_patch`,不得混入用户已有改动。
52
+
53
+ 派发前验证 GLM 的执行环境能调用补丁工具,不只写一句要求。Windows 可通过 `Get-Command apply_patch` 找到已安装入口,核对包装器实际指向的程序;用空补丁验证。若 GLM 没有可用入口,应交付补丁正文由 Leader 应用,而不是改用 `Write`、`Edit` 或 shell 写文件。发现违反方式时保留已有成果,显式中止并在同一会话纠正续跑,说明中止原因,不伪称运行超时。
54
+
55
+ `POST /api/terminal/sessions/<id>/input` 的正文为 `{"text":"<任务合同>"}`。保存 accepted 回执及 sessionId。返回忙碌或网络异常时,先读快照确认是否已接受,不盲目重发。相同批次只有一次有效派发。
56
+
57
+ ## 跟踪、断线与长任务
58
+
59
+ - 执行中优先 `GET /api/terminal/sessions?contextId=<项目 ID>` 读取轻量状态,不反复加载数千条事件;终态或诊断时再用 `GET /api/terminal/sessions/<id>` 读取快照、权限与相关事件。
60
+ - `GET /api/terminal/sessions/<id>/events?after=<sequence>` 续接 SSE;保存已处理序号,重连不得重复消费。
61
+ - `GET /api/terminal/sessions/<id>/knowledge` 查看本次知识上下文。准备/输出日志不等于模型实际收到,原生送达仍需独立证据。
62
+ - 事件类型带驱动前缀,例如 `claude/tool-use`、`claude/result`、`claude/error`;不能只匹配无前缀的 `result`。
63
+ - 断线后先恢复快照,再续接事件;事件缺口不以空数组冒充完成。不得重复创建会话或发送已接受的任务。
64
+ - 不设置默认整轮工作期限。单次模型请求超时和后台维护的安全期限属于不同机制。
65
+ - 工作时间长不等于异常;检查进程、工具活动和错误。必要时显式 `POST /api/terminal/sessions/<id>/abort`,等待终态后再派发修订任务。不得因慢而自动停止健康工作。
66
+
67
+ Leader 只读取简洁检查点、相关 diff、测试结果和关键失败片段,不持续拉取完整思考流或整个事件历史。
68
+
69
+ 当前 `0.13.2-rc.10` 已复现长历史截断后的游标重建缺陷,相关修复仍待验收。此版本的快照序号和合成事件时间不能作为全会话游标或实际运行耗时证据;优先读取轻量状态、真实审计时间及操作收据,不因 UI 没有新消息就重复派发任务。
70
+
71
+ ## 审查与放行
72
+
73
+ GLM 交付根因、文件清单、先失败后成功的测试、Node 版本、证据路径和剩余风险,然后等待 Leader 审查。
74
+
75
+ `isError:false` 或会话 idle 只表示运行终态,不证明任务完成。若结果乱码、缺少可用 diff/测试/证据,先读实际文件和独立反例,标为无效交付;保留成果与原会话,在新 GLM-5.3 短上下文会话派发小批修正。不要据此提交、发布,也不能将乱码自行解释为成功。
76
+
77
+ Leader 独立复测关键链路。源码、npm 候选、正式安装和真实模型分别记录;端口在线、工具存在、替身测试或回答提到知识库都不能替代真实送达与索引证明。
78
+
79
+ 审查通过后,Leader 仅提交相关代码和回归测试。观察提交的真实 Hook、维护、晋升与索引,再让下一批任务实际检索本次新增知识与 commit 引用。
80
+
81
+ 安装、配置修复、重启和发布需单独的操作合同及受保护备份。候选只发布 `next`;正式包验收通过后才更新 `latest`。用户仍须亲自审查确实变化的原生 Hook,不写入或绕过信任。
82
+
83
+ 临时工作树不得通过目录联接共用需要保留的 `node_modules` 后直接递归清理。本轮清理带联接的工作树后,主源码依赖目录为空;因此清理前必须枚举链接、核对真实目标并先安全解除联接本身,不能让递归操作越出临时目录。依赖恢复按原锁文件执行,保留首失败,不以恢复后的结果覆盖环境事故。
84
+
85
+ ## 验收记录
86
+
87
+ 本轮的会话、提交、源码回归、npm 下载和真实维护结果见 [5.0.16 验收记录](release-5.0.16-acceptance.md)。操作手册与版本放行结果分开维护。