@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +6 -0
- package/README.md +5 -0
- package/_site/_test/capture-hook-dedup-test.js +158 -0
- package/_site/_test/codex-notify-capture-e2e-test.js +4 -2
- package/_site/_test/codex-notify-upgrade-test.js +16 -0
- package/_site/_test/zcode-capture-integration-test.js +1 -1
- package/_site/lib/capture-hook-config.js +108 -0
- package/_site/lib/codex-notify-config.js +3 -2
- package/_site/lib/integration-installer.js +17 -3
- package/module-runtime-manifest.json +3 -3
- package/package.json +1 -1
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +1 -1
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.
|
|
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
|
-
|
|
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('
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
11
|
-
"plugins/project-knowledge/.mcp.json": "
|
|
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": "
|
|
13
|
+
"plugins/project-knowledge/.codex-plugin/plugin.json": "33075d6e47ba58c443eda339706f57688efb9974fa3bbb08f7cc09604602a87f"
|
|
14
14
|
},
|
|
15
15
|
"modules": {
|
|
16
16
|
"terminal": {
|
package/package.json
CHANGED
|
@@ -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.
|
|
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",
|