@sanqianx/project-knowledge 5.0.12 → 5.0.13

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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "project-knowledge",
12
- "version": "5.0.12",
12
+ "version": "5.0.13",
13
13
  "source": "./plugins/project-knowledge",
14
14
  "description": "Search durable project decisions and implementation history through MCP."
15
15
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.0.13] - 2026-10-13
4
+
5
+ - 修复独立 bridge 与产品 bridge 同时接入导致同一项目生成两个知识目录:接入/修复只替换明确的旧自有入口,并去除当前入口的重复项。
6
+ - Claude Code、ZCode 重复采集不再显示为正常;保留混合分组中的第三方 Hook、知识读取、模型配置和历史日志。
7
+ - Codex 通知链识别默认旧 bridge,包括已知通知分支中嵌套的旧入口;保留第三方通知,不恢复退役的重复采集。
8
+
3
9
  ## [5.0.12] - 2026-10-12
4
10
 
5
11
  - 冻结 rc.3 已验收的三客户端开发前知识读取:Claude Code、Codex、ZCode 获取统一事实及适用的已确认长期记忆。
package/README.md CHANGED
@@ -56,6 +56,11 @@ Notes:
56
56
  `project-knowledge-integrations install --ide codex --capture-only`.
57
57
  Repeating setup does not add notification wrappers. Removing capture restores
58
58
  the preserved notification chain and leaves captured records on disk.
59
+ - Capture repair replaces the known old standalone Bridge entry with the
60
+ current product Bridge and keeps one capture hook per event. Claude Code
61
+ and ZCode mixed-group third-party hooks and knowledge-context hooks remain
62
+ intact. Duplicate or stale entries are reported as needing repair rather
63
+ than healthy; existing journals are not moved or deleted.
59
64
 
60
65
  The local service listens on `127.0.0.1:5757` by default. The CLI can select a
61
66
  nearby free port and records the active loopback endpoint so managed Git Hooks
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawnSync } = require('child_process');
8
+ const { IntegrationManager } = require(process.env.PK_TEST_INTEGRATION_ROOT
9
+ ? path.join(process.env.PK_TEST_INTEGRATION_ROOT, '_site/lib/integration-installer') : '../lib/integration-installer');
10
+ const { bridgeBinding, planCaptureHooks, repairCaptureHooks } = require('../lib/capture-hook-config');
11
+ const bridge = require('@sanqianx/ai-coding-event-bridge-console/src/bridge');
12
+ const ROOT = path.resolve(__dirname, '../..');
13
+ const TEMP = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-capture-dedup-'));
14
+ const HOME = path.join(TEMP, '用户 空格');
15
+ const OLD = path.join(HOME, '.ai-coding-event-bridge');
16
+ const CURRENT = path.join(HOME, '.project-knowledge-v5/events');
17
+ const options = { homeDir: HOME, bridgeHomeDir: CURRENT };
18
+ const shim = home => path.join(home, 'bin/bridge-hook.cjs');
19
+ const cmd = home => ({ type: 'command', command: `node "${shim(home)}"` });
20
+ const processHook = home => ({ type: 'process', command: 'node', args: [shim(home), '--source', 'zcode'], timeoutMs: 15000 });
21
+ const events = ['UserPromptSubmit', 'Stop'];
22
+ const read = file => JSON.parse(fs.readFileSync(file, 'utf8'));
23
+ function write(file, object) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(object, null, 2) + '\n'); }
24
+ function manager(extra = {}) { return new IntegrationManager({ rootDir: ROOT, env: {}, ...options, ...extra }); }
25
+
26
+ async function main() {
27
+ const claude = path.join(HOME, '.claude/settings.json');
28
+ const third = { type: 'command', command: 'node "notify-user.cjs"', timeout: 10 };
29
+ const context = { type: 'command', command: 'node "context-hook.cjs" --client claude' };
30
+ const lookalike = { type: 'command', command: `node "${shim(OLD)}" && echo keep-user-wrapper` };
31
+ const customHome = path.join(HOME, 'independent-third-party-bridge');
32
+ const unrelated = { model: 'user-model', env: { PRIVATE_TEST_TOKEN: 'do-not-back-up-unrelated-secret' } };
33
+ write(claude, { ...unrelated, hooks: Object.fromEntries(events.map(event => [event, [
34
+ { matcher: '*', hooks: [cmd(OLD), third, cmd(CURRENT)] },
35
+ { matcher: '*', hooks: [cmd(OLD), cmd(CURRENT), context, lookalike, cmd(customHome)] },
36
+ ]])) });
37
+ const before = fs.readFileSync(claude, 'utf8');
38
+ const logs = [];
39
+ const item = manager({ logger: { info: (...args) => logs.push(args), error: (...args) => logs.push(args) } });
40
+ assert.equal((await item.statusCaptureClaude()).state, 'needs-repair', 'duplicate capture cannot be reported healthy');
41
+ assert.equal(fs.readFileSync(claude, 'utf8'), before, 'status is read-only');
42
+ const installed = await item.captureOperation('install', 'claude');
43
+ assert.equal(installed.removedLegacy, 4);
44
+ assert.equal(installed.removedDuplicates, 2);
45
+ assert(logs.some(row => row[2]?.context?.removedLegacy === 4 && row[2]?.context?.removedDuplicates === 2), 'repair counts are persisted without raw configuration');
46
+ assert(!JSON.stringify(logs).includes('do-not-back-up-unrelated-secret'));
47
+ let saved = read(claude);
48
+ assert.equal(saved.model, unrelated.model); assert.deepEqual(saved.env, unrelated.env);
49
+ for (const event of events) {
50
+ const hooks = saved.hooks[event].flatMap(group => group.hooks);
51
+ assert.equal(hooks.filter(hook => hook.command === cmd(CURRENT).command).length, 1);
52
+ assert.equal(hooks.filter(hook => hook.command === cmd(OLD).command).length, 0);
53
+ for (const keep of [third, context, lookalike, cmd(customHome)]) assert(hooks.some(hook => JSON.stringify(hook) === JSON.stringify(keep)), 'mixed-group third-party hooks survive');
54
+ }
55
+ assert(!fs.readFileSync(installed.backupFile, 'utf8').includes('do-not-back-up-unrelated-secret'), 'backup contains only removed owned invocations');
56
+ assert.equal((await item.statusCaptureClaude()).state, 'installed');
57
+ const after = fs.readFileSync(claude, 'utf8');
58
+ assert.equal((await item.installCaptureClaude()).changed, false);
59
+ assert.equal(fs.readFileSync(claude, 'utf8'), after, 'repeat repair is byte-idempotent');
60
+ await item.uninstallCaptureClaude();
61
+ saved = read(claude);
62
+ for (const event of events) assert(saved.hooks[event].flatMap(group => group.hooks).some(hook => hook.command === third.command), 'uninstall keeps other hooks in the same group');
63
+
64
+ const zcode = path.join(HOME, '.zcode/cli/config.json');
65
+ const zContext = { type: 'process', command: 'node', args: ['context-hook.cjs', '--client', 'zcode'] };
66
+ write(zcode, { ...unrelated, hooks: { enabled: true, events: Object.fromEntries(events.map(event => [event, [{ hooks: [processHook(OLD), third, processHook(CURRENT), processHook(CURRENT), zContext] }]])) } });
67
+ assert.equal((await item.statusCaptureZCode()).state, 'needs-repair');
68
+ const zInstalled = await item.installCaptureZCode();
69
+ assert.equal(zInstalled.removedLegacy, 2); assert.equal(zInstalled.removedDuplicates, 2);
70
+ const zBeforeRepeat = fs.readFileSync(zcode, 'utf8');
71
+ await item.installCaptureZCode();
72
+ assert.equal(fs.readFileSync(zcode, 'utf8'), zBeforeRepeat);
73
+ await item.uninstallCaptureZCode();
74
+ assert.deepEqual(read(zcode).env, unrelated.env);
75
+ assert.deepEqual(read(zcode).hooks.events.UserPromptSubmit[0].hooks, [third, zContext]);
76
+
77
+ write(claude, { hooks: Object.fromEntries(events.map(event => [event, [{ hooks: [cmd(OLD)] }]])) });
78
+ const onlyLegacy = fs.readFileSync(claude, 'utf8');
79
+ assert.throws(() => repairCaptureHooks(claude, 'claude', options), /入口未完整/);
80
+ const failed = manager({ bridgeModule: { installers: { claudeCode: { installClaudeCodeHook: async () => { throw new Error('runtime materialization failed'); } } } } });
81
+ await assert.rejects(failed.installCaptureClaude(), /materialization failed/);
82
+ assert.equal(fs.readFileSync(claude, 'utf8'), onlyLegacy, 'failed runtime install cannot retire working old capture');
83
+ const conflict = manager({ bridgeModule: { installers: { claudeCode: { installClaudeCodeHook: async () => ({ added: [], runtime: { conflict: true } }) } } } });
84
+ await assert.rejects(conflict.installCaptureClaude(), /版本冲突/);
85
+ assert.equal(fs.readFileSync(claude, 'utf8'), onlyLegacy, 'a runtime conflict cannot retire old capture');
86
+ const dry = manager({ dryRun: true });
87
+ await dry.installCaptureClaude(); await dry.uninstallCaptureClaude();
88
+ assert.equal(fs.readFileSync(claude, 'utf8'), onlyLegacy);
89
+ fs.writeFileSync(claude, '{invalid JSON');
90
+ assert.throws(() => planCaptureHooks(claude, 'claude', options), /JSON/);
91
+ assert.equal(fs.readFileSync(claude, 'utf8'), '{invalid JSON');
92
+ assert.equal(bridgeBinding(['node', `${shim(OLD)}.user-script`], options), '');
93
+ assert.equal(bridgeBinding(['node', shim(OLD), '--custom-argument'], options), '');
94
+ assert.equal(bridgeBinding([['node'], shim(OLD)], options), '');
95
+ write(claude, { hooks: { UserPromptSubmit: [null, { hooks: [cmd(OLD), cmd(CURRENT)] }], Stop: [{ hooks: [cmd(CURRENT)] }] } });
96
+ repairCaptureHooks(claude, 'claude', options);
97
+ assert.equal(read(claude).hooks.UserPromptSubmit[0], null, 'repair does not silently drop unrelated malformed groups');
98
+ write(claude, { hooks: Object.fromEntries(events.map(event => [event, [{ hooks: [cmd(OLD), cmd(CURRENT)] }]])) });
99
+ const originalRead = fs.readFileSync;
100
+ let reads = 0;
101
+ fs.readFileSync = function(file, ...args) {
102
+ const value = originalRead.call(this, file, ...args);
103
+ if (file === claude && ++reads === 2) write(claude, { ...JSON.parse(value), userConcurrentEdit: 'kept' });
104
+ return value;
105
+ };
106
+ try { assert.throws(() => repairCaptureHooks(claude, 'claude', options), error => error.code === 'CONFIG_CHANGED'); }
107
+ finally { fs.readFileSync = originalRead; }
108
+ assert.equal(read(claude).userConcurrentEdit, 'kept', 'a concurrent user edit is never overwritten');
109
+
110
+ // Real Git + real installed shims: the stale store is registered, but no
111
+ // repaired client invocation recreates it or sends duplicate events.
112
+ const repo = path.join(TEMP, '代码 中文 project');
113
+ fs.mkdirSync(repo, { recursive: true });
114
+ const git = spawnSync('git', ['init', '-q'], { cwd: repo, encoding: 'utf8', windowsHide: true });
115
+ assert.equal(git.status, 0, git.stderr);
116
+ const obsoleteStore = path.join(TEMP, 'knowledge/old-slug/dev-conversations');
117
+ const canonicalStore = path.join(TEMP, 'knowledge/Canonical-abcdef/dev-conversations');
118
+ await bridge.ensureRuntimeHome({ homeDir: OLD });
119
+ await bridge.projectRegistry.addProject({ home: OLD, path: repo, store: obsoleteStore });
120
+ await bridge.projectRegistry.addProject({ home: CURRENT, path: repo, store: canonicalStore });
121
+ fs.rmSync(path.join(TEMP, 'knowledge/old-slug'), { recursive: true, force: true });
122
+ write(claude, { hooks: Object.fromEntries(events.map(event => [event, [{ hooks: [cmd(OLD), cmd(CURRENT), cmd(CURRENT)] }]])) });
123
+ await item.installCaptureClaude();
124
+ const env = { ...process.env }; delete env.AI_CODING_EVENT_BRIDGE_HOME;
125
+ for (const [event, payload] of [
126
+ ['UserPromptSubmit', { prompt: 'isolated dedup test request' }],
127
+ ['Stop', { last_assistant_message: 'isolated dedup test answer' }],
128
+ ]) {
129
+ const hooks = read(claude).hooks[event].flatMap(group => group.hooks);
130
+ assert.equal(hooks.length, 1);
131
+ const call = spawnSync(process.execPath, [shim(CURRENT)], { cwd: repo, env, input: JSON.stringify({ hook_event_name: event, cwd: repo, session_id: 'dedup-test', ...payload }), encoding: 'utf8', windowsHide: true, timeout: 10000 });
132
+ assert.equal(call.status, 0, call.stderr);
133
+ }
134
+ const journal = path.join(canonicalStore, 'journal/events.jsonl');
135
+ const captured = fs.readFileSync(journal, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line));
136
+ assert.deepEqual(captured.map(row => row.eventType), ['user_prompt', 'assistant_response']);
137
+ assert(!fs.existsSync(obsoleteStore), 'the stale second directory is not recreated');
138
+ write(zcode, { hooks: { enabled: true, events: Object.fromEntries(events.map(event => [event, [{ hooks: [processHook(OLD), processHook(CURRENT), processHook(CURRENT)] }]])) } });
139
+ await item.installCaptureZCode();
140
+ for (const [event, payload] of [
141
+ ['UserPromptSubmit', { prompt: 'isolated zcode dedup request' }],
142
+ ['Stop', { last_assistant_message: 'isolated zcode dedup answer' }],
143
+ ]) {
144
+ const hooks = read(zcode).hooks.events[event].flatMap(group => group.hooks);
145
+ assert.equal(hooks.length, 1);
146
+ const call = spawnSync(process.execPath, hooks[0].args, { cwd: repo, env, input: JSON.stringify({ hook_event_name: event, cwd: repo, session_id: 'zcode-dedup-test', ...payload }), encoding: 'utf8', windowsHide: true, timeout: 10000 });
147
+ assert.equal(call.status, 0, call.stderr);
148
+ }
149
+ const afterZCode = fs.readFileSync(journal, 'utf8').trim().split(/\r?\n/).map(line => JSON.parse(line));
150
+ assert.deepEqual(afterZCode.map(row => row.source), ['claude-code', 'claude-code', 'zcode', 'zcode']);
151
+ assert(!fs.existsSync(obsoleteStore), 'ZCode also cannot recreate the old knowledge folder');
152
+ console.log('capture-hook-dedup-test PASS (duplicate status, exact ownership, mixed-group preservation, idempotency, runtime failure and real journal capture)');
153
+ }
154
+
155
+ main().catch(error => { console.error(error.stack); process.exitCode = 1; }).finally(() => {
156
+ assert(path.resolve(TEMP).startsWith(path.resolve(os.tmpdir()) + path.sep));
157
+ fs.rmSync(TEMP, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
158
+ });
@@ -73,10 +73,12 @@ async function main() {
73
73
  assert(captured.every(event => event.repoIdentity.workspaceId === registered.id && path.resolve(event.projectPath).toLowerCase() === path.resolve(repoPath).toLowerCase()), 'workspace comes from rollout metadata, not the notification or server cwd');
74
74
  assert(!JSON.stringify(captured).includes('NOT-EVIDENCE'), 'notification summaries are never captured as conversation facts');
75
75
 
76
- const beforeRepeat = fs.statSync(cursorFile).mtimeMs;
76
+ const beforeRepeat = JSON.parse(fs.readFileSync(cursorFile, 'utf8')).updatedAt;
77
77
  await new Promise(resolve => setTimeout(resolve, 25));
78
78
  invokeNotify(notify, payload);
79
- await waitFor(() => fs.statSync(cursorFile).mtimeMs !== beforeRepeat, 'second notification reaches the real cursor reader');
79
+ // Filesystems may coalesce mtime; the durable cursor timestamp proves that
80
+ // the reader completed another pass rather than merely touching a file.
81
+ await waitFor(() => JSON.parse(fs.readFileSync(cursorFile, 'utf8')).updatedAt !== beforeRepeat, 'second notification reaches the real cursor reader');
80
82
  assert.strictEqual(readEvents(eventsFile).length, 2, 'duplicate notify cannot duplicate captured evidence');
81
83
 
82
84
  invokeNotify(notify, JSON.stringify({ type: 'agent-turn-complete', 'thread-id': 'missing-session-e2e', 'last-assistant-message': 'must not be fabricated' }));
@@ -165,6 +165,22 @@ async function main() {
165
165
  assert.strictEqual(readNotify(fanoutOnly.configFile), null, 'uninstall removes the notify entry only if no third-party notifier remains');
166
166
  assertUnrelatedSettings(fanoutOnly);
167
167
 
168
+ const defaultLegacy = name => ['node', path.join(TEMP, name, 'home', '.ai-coding-event-bridge', 'bin', 'bridge-hook.cjs')];
169
+ const rawLegacy = fixture('legacy-default-home', defaultLegacy('legacy-default-home'));
170
+ await assertInstallIdempotent(rawLegacy);
171
+ assert(!descendants(readNotify(rawLegacy.configFile)).some(command => command[1] === defaultLegacy('legacy-default-home')[1]), 'replace the known old standalone Bridge instead of chaining it as a third party');
172
+ await rawLegacy.manager.uninstallCaptureCodex();
173
+ assert.strictEqual(readNotify(rawLegacy.configFile), null, 'uninstall must not resurrect a retired Bridge home');
174
+
175
+ const nestedLegacyCommand = defaultLegacy('nested-legacy-default-home');
176
+ const legacyRadar = [...radar, '--previous-notify', JSON.stringify(nestedLegacyCommand)];
177
+ const nestedLegacy = fixture('nested-legacy-default-home', oldFanout('old-with-legacy-next', legacyRadar, staleBridge));
178
+ await assertInstallIdempotent(nestedLegacy);
179
+ assert(!descendants(readNotify(nestedLegacy.configFile)).some(command => command[1] === nestedLegacyCommand[1]), 'remove known stale Bridge capture hidden in a compatible notification branch');
180
+ assert(descendants(readNotify(nestedLegacy.configFile)).some(command => JSON.stringify(command) === JSON.stringify(radar)), 'preserve Radar and its non-notify arguments');
181
+ await nestedLegacy.manager.uninstallCaptureCodex();
182
+ assert.deepStrictEqual(readNotify(nestedLegacy.configFile), radar);
183
+
168
184
  // Missing files alone never authorize deleting a third-party notifier.
169
185
  // Neither a matching script basename nor the word devtask-radar is ownership.
170
186
  for (const [name, command] of [
@@ -79,7 +79,7 @@ const { runCli } = require('../../bin/project-knowledge-integrations');
79
79
  assert(failed.clients[0].developmentCapture.error.code);
80
80
  await logger.flush();
81
81
  const errors = new LogRepository({ layout }).query({ event: 'integration.capture.failed' }).entries;
82
- assert(errors.some(entry => entry.operationId === failed.operationId && entry.context.client === 'zcode' && entry.error.message.includes('valid JSON')), 'actual failed phase and operation ID are persisted');
82
+ assert(errors.some(entry => entry.operationId === failed.operationId && entry.context.client === 'zcode' && entry.error.message.includes('JSON')), 'actual failed phase and operation ID are persisted');
83
83
  fs.writeFileSync(configFile, JSON.stringify(twice));
84
84
  const removed = await request('/api/integrations/uninstall', { clients: ['zcode'], captureOnly: true });
85
85
  assert.equal(removed.complete, true);
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const { writeFileAtomic } = require('./atomic-file');
8
+
9
+ const EVENTS = ['UserPromptSubmit', 'Stop'];
10
+ function normalized(file) {
11
+ const value = path.resolve(file).replace(/\\/g, '/');
12
+ return process.platform === 'win32' ? value.toLowerCase() : value;
13
+ }
14
+ function nodeCommand(command) { return /(?:^|[\\/])node(?:\.exe)?$/i.test(command || ''); }
15
+
16
+ // Only exact, known Bridge invocations are ours. A shell wrapper, extra
17
+ // argument or a lookalike filename never authorizes removing a user hook.
18
+ function bridgeBinding(args, options) {
19
+ if (!Array.isArray(args) || typeof args[0] !== 'string' || !nodeCommand(args[0]) || typeof args[1] !== 'string') return '';
20
+ if (args.length !== 2 && !(args.length === 4 && args[2] === '--source' && ['claude-code', 'claude', 'zcode', 'codex', 'opencode'].includes(args[3]))) return '';
21
+ const actual = normalized(args[1]);
22
+ const current = normalized(path.join(options.bridgeHomeDir, 'bin', 'bridge-hook.cjs'));
23
+ const legacy = normalized(path.join(options.homeDir || os.homedir(), '.ai-coding-event-bridge', 'bin', 'bridge-hook.cjs'));
24
+ return actual === current ? 'current' : actual === legacy ? 'legacy' : '';
25
+ }
26
+
27
+ function hookArgs(hook) {
28
+ if (!hook || typeof hook !== 'object') return null;
29
+ if (hook.type === 'process' && Array.isArray(hook.args)) return [hook.command, ...hook.args];
30
+ if (hook.type !== 'command' || typeof hook.command !== 'string') return null;
31
+ const match = hook.command.trim().match(/^(?:"([^"]+)"|'([^']+)'|(\S+))\s+(?:"([^"]+)"|'([^']+)'|(\S+))(?:\s+--source\s+(claude-code|claude|zcode|codex|opencode))?$/);
32
+ return match ? [match[1] || match[2] || match[3], match[4] || match[5] || match[6], ...(match[7] ? ['--source', match[7]] : [])] : null;
33
+ }
34
+
35
+ function configuration(file) {
36
+ const source = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
37
+ let config;
38
+ try { config = source ? JSON.parse(source) : {}; } catch { throw new Error('Agent 配置不是有效 JSON,未修改。'); }
39
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Agent 配置必须是 JSON 对象,未修改。');
40
+ return { source, config };
41
+ }
42
+
43
+ function planCaptureHooks(file, client, options, removeCurrent = false) {
44
+ if (!['claude', 'zcode'].includes(client)) throw new Error('不支持的采集配置类型。');
45
+ const { source, config } = configuration(file);
46
+ const hooks = config.hooks;
47
+ if (hooks != null && (typeof hooks !== 'object' || Array.isArray(hooks))) throw new Error('Agent hooks 配置格式异常,未修改。');
48
+ const events = client === 'zcode' ? hooks?.events : hooks;
49
+ if (events != null && (typeof events !== 'object' || Array.isArray(events))) throw new Error('Agent 事件配置格式异常,未修改。');
50
+ const removed = [], counts = {};
51
+ for (const event of EVENTS) {
52
+ const groups = events?.[event];
53
+ if (groups == null) { counts[event] = { current: 0, legacy: 0 }; continue; }
54
+ if (!Array.isArray(groups)) throw new Error('Agent 事件列表格式异常,未修改。');
55
+ const count = { current: 0, legacy: 0 };
56
+ for (const group of groups) {
57
+ if (group?.hooks != null && !Array.isArray(group.hooks)) throw new Error('Agent Hook 列表格式异常,未修改。');
58
+ for (const hook of group?.hooks || []) { const binding = bridgeBinding(hookArgs(hook), options); if (binding) count[binding]++; }
59
+ }
60
+ counts[event] = count;
61
+ // Materialize the replacement first; never remove the only working old
62
+ // capture when installing the new runtime has failed.
63
+ if (!count.current) continue;
64
+ let keptCurrent = false;
65
+ const removedGroup = Symbol('empty-owned-group');
66
+ events[event] = groups.map((group, groupIndex) => {
67
+ if (!Array.isArray(group?.hooks)) return group;
68
+ const kept = group.hooks.filter((hook, hookIndex) => {
69
+ const binding = bridgeBinding(hookArgs(hook), options);
70
+ if (!binding) return true;
71
+ if (removeCurrent && binding === 'legacy') return true;
72
+ if (!removeCurrent && binding === 'current' && !keptCurrent) { keptCurrent = true; return true; }
73
+ // Back up only owned invocation fields, never unrelated config/env.
74
+ const saved = Object.fromEntries(['type', 'command', 'args', 'timeout', 'timeoutMs'].filter(key => hook[key] !== undefined).map(key => [key, hook[key]]));
75
+ removed.push({ event, groupIndex, hookIndex, hook: saved, binding });
76
+ return false;
77
+ });
78
+ return kept.length === group.hooks.length ? group : kept.length ? { ...group, hooks: kept } : removedGroup;
79
+ }).filter(group => group !== removedGroup);
80
+ }
81
+ return { source, config, counts, removed, repairNeeded: Object.values(counts).some(count => count.legacy > 0 || count.current > 1) };
82
+ }
83
+
84
+ function writePlan(file, client, plan) {
85
+ if (!plan.removed.length) return { changed: false, removedLegacy: 0, removedDuplicates: 0 };
86
+ const assertUnchanged = () => {
87
+ if (fs.readFileSync(file, 'utf8') !== plan.source) throw Object.assign(new Error('Agent 配置已被其他程序修改,请重试接入。'), { code: 'CONFIG_CHANGED' });
88
+ };
89
+ assertUnchanged();
90
+ const backupFile = `${file}.project-knowledge-capture-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.bak`;
91
+ writeFileAtomic(backupFile, JSON.stringify({ schema: 'project-knowledge/capture-backup/v1', client, removed: plan.removed }, null, 2) + '\n');
92
+ const eol = plan.source.includes('\r\n') ? '\r\n' : '\n';
93
+ writeFileAtomic(file, (JSON.stringify(plan.config, null, 2) + '\n').replace(/\n/g, eol), { beforeRename: assertUnchanged });
94
+ return { changed: true, backupFile, removedLegacy: plan.removed.filter(row => row.binding === 'legacy').length, removedDuplicates: plan.removed.filter(row => row.binding === 'current').length };
95
+ }
96
+
97
+ function repairCaptureHooks(file, client, options) {
98
+ const plan = planCaptureHooks(file, client, options);
99
+ if (EVENTS.some(event => !plan.counts[event].current)) throw new Error('当前 Bridge 入口未完整建立,保留旧采集接线。');
100
+ if (!fs.existsSync(path.join(options.bridgeHomeDir, 'bin', 'bridge-hook.cjs'))) throw new Error('当前 Bridge 运行入口缺失,保留旧采集接线。');
101
+ return writePlan(file, client, plan);
102
+ }
103
+
104
+ function removeCaptureHooks(file, client, options) {
105
+ return writePlan(file, client, planCaptureHooks(file, client, options, true));
106
+ }
107
+
108
+ module.exports = { bridgeBinding, planCaptureHooks, repairCaptureHooks, removeCaptureHooks };
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
+ const { bridgeBinding } = require('./capture-hook-config');
6
7
 
7
8
  function readNotify(configFile) {
8
9
  const source = fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf8') : '';
@@ -79,7 +80,7 @@ function inspectTree(args, options, state, depth = 0) {
79
80
  || args.length !== (next ? 6 : 4)) throw Object.assign(new Error('托管通知链格式异常,未覆盖现有配置。'), { code: 'CAPTURE_CONFLICT' });
80
81
  state.owned = true;
81
82
  inspectTree(next && next.args, options, state, depth + 1);
82
- } else if (sameCommand(args, bridgeCommand(options.bridgeHomeDir))) {
83
+ } else if (bridgeBinding(args, options)) {
83
84
  state.owned = true;
84
85
  } else if (desktopCommand(args)) {
85
86
  const previous = nested(args, '--previous-notify');
@@ -114,7 +115,7 @@ function rewriteTree(args, options, state, canAnchor = true) {
114
115
  const cleaned = rewriteTree(next && next.args, options, state, false);
115
116
  return anchor ? fanout(cleaned, options) : cleaned;
116
117
  }
117
- if (sameCommand(args, bridgeCommand(options.bridgeHomeDir))) return null;
118
+ if (bridgeBinding(args, options)) return null;
118
119
  if (desktopCommand(args)) {
119
120
  const previous = nested(args, '--previous-notify');
120
121
  const cleaned = rewriteTree(previous && previous.args, options, state, canAnchor);
@@ -8,6 +8,7 @@ const { applyEdits, modify, parse } = require('jsonc-parser');
8
8
  const packageInfo = require('../../package.json');
9
9
  const { resolveDataDirPath } = require('./data-dir');
10
10
  const { planNotify, writeNotify } = require('./codex-notify-config');
11
+ const { planCaptureHooks, repairCaptureHooks, removeCaptureHooks } = require('./capture-hook-config');
11
12
 
12
13
  // Fail-open module resolution: a missing/unloadable Bridge package must
13
14
  // degrade Development Capture status, never crash server startup.
@@ -253,7 +254,8 @@ class IntegrationManager {
253
254
  try {
254
255
  const result = await this[`${operation}Capture${CLIENT_METHOD_SUFFIX[client]}`](options);
255
256
  const ok = !result.conflict && result.state !== 'failed';
256
- if (operation !== 'status' || !ok) this.log(ok ? 'info' : 'error', `integration.capture.${ok ? 'completed' : 'failed'}`, ok ? 'Agent 采集接线操作完成。' : 'Agent 采集接线未完成。', { operationId: this.operationId, durationMs: Date.now() - startedAt, error: ok ? undefined : Object.assign(new Error(result.detail), { code: 'CAPTURE_CONFLICT' }), context });
257
+ const repairContext = { ...context, ...(typeof result.changed === 'boolean' ? { changed: result.changed } : {}), ...(Number.isInteger(result.removedLegacy) ? { removedLegacy: result.removedLegacy, removedDuplicates: result.removedDuplicates } : {}) };
258
+ if (operation !== 'status' || !ok) this.log(ok ? 'info' : 'error', `integration.capture.${ok ? 'completed' : 'failed'}`, ok ? 'Agent 采集接线操作完成。' : 'Agent 采集接线未完成。', { operationId: this.operationId, durationMs: Date.now() - startedAt, error: ok ? undefined : Object.assign(new Error(result.detail), { code: 'CAPTURE_CONFLICT' }), context: repairContext });
257
259
  return { ok, ...result, ...(!ok ? { error: { code: 'CAPTURE_CONFLICT', message: result.detail } } : {}) };
258
260
  } catch (error) {
259
261
  this.log('error', 'integration.capture.failed', 'Agent 采集接线操作失败。', { operationId: this.operationId, durationMs: Date.now() - startedAt, error, context });
@@ -266,19 +268,24 @@ class IntegrationManager {
266
268
  this.planned.push({ command: 'bridge', args: ['installZcodeHook', this.bridgeHomeDir] });
267
269
  return { state: 'planned', installed: false, detail: '计划接入 ZCode 会话采集,未修改配置。' };
268
270
  }
271
+ planCaptureHooks(this.zcodeConfigFile, 'zcode', this);
269
272
  await this.bridge.installers.zcode.installZcodeHook(this._captureInstallerOptions('zcode'));
273
+ const repaired = repairCaptureHooks(this.zcodeConfigFile, 'zcode', this);
270
274
  const status = await this.statusCaptureZCode();
271
275
  if (!status.installed) throw new Error('ZCode Hook 写入后校验失败,请检查 hooks.enabled 与事件配置。');
272
- return status;
276
+ return { ...status, ...repaired };
273
277
  }
274
278
 
275
279
  async statusCaptureZCode() {
276
280
  const status = await this.bridge.installers.zcode.statusZcodeHook(this._captureInstallerOptions('zcode'));
281
+ const plan = planCaptureHooks(this.zcodeConfigFile, 'zcode', this);
282
+ if (plan.repairNeeded || status.installed && !fs.existsSync(path.join(this.bridgeHomeDir, 'bin', 'bridge-hook.cjs'))) return { state: 'needs-repair', installed: false, conflict: false, repairNeeded: true, detail: '检测到重复、旧接线或缺失的 Bridge 入口,请执行接入/修复。' };
277
283
  return { state: status.installed ? 'installed' : 'not-installed', installed: status.installed, conflict: false, detail: status.installed ? '已配置用户输入与回答采集;请重新打开 ZCode 会话。' : 'ZCode 会话采集尚未接入。' };
278
284
  }
279
285
 
280
286
  async uninstallCaptureZCode() {
281
287
  if (this.dryRun) return { state: 'planned', detail: '计划移除 ZCode 采集,未修改配置。' };
288
+ removeCaptureHooks(this.zcodeConfigFile, 'zcode', this);
282
289
  await this.bridge.installers.zcode.uninstallZcodeHook(this._captureInstallerOptions('zcode'));
283
290
  return { state: 'removed', installed: false, detail: '已移除 ZCode 托管采集,保留第三方配置与已有记录。' };
284
291
  }
@@ -288,12 +295,17 @@ class IntegrationManager {
288
295
  this.planned.push({ command: 'bridge', args: ['installClaudeCodeHook', this.bridgeHomeDir] });
289
296
  return { state: 'planned', installed: false, conflict: false, detail: 'managed Bridge hooks (dry-run)' };
290
297
  }
298
+ planCaptureHooks(this.claudeSettingsFile, 'claude', this);
291
299
  const result = await this.bridge.installers.claudeCode.installClaudeCodeHook(this._captureInstallerOptions());
292
- return { state: 'installed', installed: true, conflict: false, detail: `managed hooks: ${result.added.join(', ') || 'already present'}` };
300
+ if (result.runtime?.conflict) throw new Error('Bridge 运行版本冲突,保留旧采集接线。');
301
+ const repaired = repairCaptureHooks(this.claudeSettingsFile, 'claude', this);
302
+ return { state: 'installed', installed: true, conflict: false, detail: `managed hooks: ${result.added.join(', ') || 'already present'}`, ...repaired };
293
303
  }
294
304
 
295
305
  async statusCaptureClaude() {
296
306
  const status = await this.bridge.installers.claudeCode.statusClaudeCodeHook(this._captureInstallerOptions());
307
+ const plan = planCaptureHooks(this.claudeSettingsFile, 'claude', this);
308
+ if (plan.repairNeeded || status.installed && !fs.existsSync(path.join(this.bridgeHomeDir, 'bin', 'bridge-hook.cjs'))) return { state: 'needs-repair', installed: false, conflict: false, repairNeeded: true, detail: '检测到重复、旧接线或缺失的 Bridge 入口,请执行接入/修复。' };
297
309
  return {
298
310
  state: status.installed ? 'installed' : 'not-installed',
299
311
  installed: status.installed,
@@ -303,6 +315,8 @@ class IntegrationManager {
303
315
  }
304
316
 
305
317
  async uninstallCaptureClaude(options = {}) {
318
+ if (this.dryRun) return { state: 'planned', detail: '计划移除 Claude Code 采集,未修改配置。' };
319
+ removeCaptureHooks(this.claudeSettingsFile, 'claude', this);
306
320
  await this.bridge.installers.claudeCode.uninstallClaudeCodeHook({
307
321
  ...this._captureInstallerOptions(),
308
322
  unregisterConsumer: options.unregisterConsumer === true,
@@ -7,10 +7,10 @@
7
7
  "ui/favicon.svg": "c48be946b224112ebb0238b44263f7a61bda31e531afa895c2e9801c0ff24f32",
8
8
  "plugins/project-knowledge/hooks/claude.json": "d61510327e3e1facbb4c645228e9f50cad05a88b33d80c4c22e5ba9f8b9f5359",
9
9
  "plugins/project-knowledge/scripts/context-hook.cjs": "b138fab6d8fcb35f8101ccfda565ccec694acb888f94347c5864c804ebb5e1a0",
10
- "plugins/project-knowledge/.claude-plugin/plugin.json": "2322a6ab161d407736647abf836cb9669b8fe628c644884627d91d330975992e",
11
- "plugins/project-knowledge/.mcp.json": "b2b7eea17eb9c934b71452048fc29cd354af320fac4525048b9bd570402096c7",
10
+ "plugins/project-knowledge/.claude-plugin/plugin.json": "cf50fe8dacbec068c46e531114c1f81576e63578692ab165795b96c69ace63a3",
11
+ "plugins/project-knowledge/.mcp.json": "6b4e25e51cc24361810af627be6bda0a97cc416230056308fab56ed5ddc1d73b",
12
12
  "plugins/project-knowledge/hooks/codex.json": "216e03c07295606ef6ebb2c4f669ad0d790b5e7c4f3f34600488033db75e4239",
13
- "plugins/project-knowledge/.codex-plugin/plugin.json": "7a51b3c5db64c57e7c35ad0511ca206a833a95c09628f94b64caca25be9a55fe"
13
+ "plugins/project-knowledge/.codex-plugin/plugin.json": "33075d6e47ba58c443eda339706f57688efb9974fa3bbb08f7cc09604602a87f"
14
14
  },
15
15
  "modules": {
16
16
  "terminal": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanqianx/project-knowledge",
3
- "version": "5.0.12",
3
+ "version": "5.0.13",
4
4
  "description": "Knowledge base manager with Git integration, AI-driven analysis, and bilingual (zh-CN/en-US) knowledge output",
5
5
  "main": "_site/server.js",
6
6
  "scripts": {
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "project-knowledge",
4
4
  "hooks": "./hooks/claude.json",
5
- "version": "5.0.12",
5
+ "version": "5.0.13",
6
6
  "description": "Read durable project decisions and implementation history from Project Knowledge.",
7
7
  "author": {
8
8
  "name": "SanQianX",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-knowledge",
3
- "version": "5.0.12",
3
+ "version": "5.0.13",
4
4
  "description": "Read durable project decisions and implementation history from Project Knowledge.",
5
5
  "author": {
6
6
  "name": "SanQianX",
@@ -6,7 +6,7 @@
6
6
  "args": [
7
7
  "-y",
8
8
  "--package",
9
- "@sanqianx/project-knowledge@5.0.12",
9
+ "@sanqianx/project-knowledge@5.0.13",
10
10
  "project-knowledge-mcp"
11
11
  ]
12
12
  }