@principles/workbuddy-adapter 0.5.0 → 0.5.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.
package/dist/pd-hook.js
CHANGED
|
@@ -280,8 +280,9 @@ export async function processWorkbuddyHookInvocation(rawStdin, channelEventName,
|
|
|
280
280
|
typeof prompt === 'string' && !explicitReserved) {
|
|
281
281
|
// AUDIT-P3-01: 预检与 admitTrustedWorkbuddyLiveCorrection 内部使用同一份
|
|
282
282
|
// 共享 live 词库(seed ∪ learned)——预检不会漏掉学习词,也不会比准入
|
|
283
|
-
// 更宽。
|
|
284
|
-
//
|
|
283
|
+
// 更宽。P-5 退役后不再做句首 startsWith 预过滤,位置安全交给共享守卫
|
|
284
|
+
// (引号 / 第三人称 / 否定 / 条件语境);歧义候选只落候选,同步 hook
|
|
285
|
+
// 内零 LLM。
|
|
285
286
|
const matched = assessWorkbuddyCorrectionCandidate(prompt, event.context.sessionId, resolveSharedCorrectionKeywordStoreForDetection(resolution.workspaceDir));
|
|
286
287
|
if (matched.kind !== 'none') {
|
|
287
288
|
const admitted = admitTrustedWorkbuddyLiveCorrection({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@principles/workbuddy-adapter",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "WorkBuddy host adapter for Principles Disciple — implements the HostAdapter interface for WorkBuddy's Git Bash command-hook model (ADR-0020 third host, SPEC workbuddy-host-adapter v0.3.0).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"lint": "eslint \"src/**/*.ts\""
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@principles/core": "^
|
|
46
|
-
"@principles/host-runtime": "^
|
|
45
|
+
"@principles/core": "^4.0.1",
|
|
46
|
+
"@principles/host-runtime": "^2.0.1",
|
|
47
47
|
"@principles/install-layout": "^0.4.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
@@ -61,5 +61,5 @@
|
|
|
61
61
|
"publishConfig": {
|
|
62
62
|
"access": "public"
|
|
63
63
|
},
|
|
64
|
-
"gitHead": "
|
|
64
|
+
"gitHead": "d9bbe994104eac08a8a41fa1c9374f28dc629aec"
|
|
65
65
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "principles-disciple",
|
|
3
3
|
"version": "0.2.2",
|
|
4
|
-
"description": "Principles Disciple — Owner-governed behavior internalization for AI agents (WorkBuddy host adapter, ADR-0020 third host).
|
|
4
|
+
"description": "Principles Disciple — Owner-governed behavior internalization for AI agents (WorkBuddy host adapter, ADR-0020 third host). Three independent states: (1) the PD installer registers this plugin AND sets enabledPlugins=true, so installed means enabled; (2) governance is ON by default at install (features.host.workbuddy.enabled=true) and an explicit false opt-out is preserved; (3) automatic pain detection/correction is NOT available in WorkBuddy — record pain only with the explicit \"PD记录痛苦:\" prefix. The manifest defaultEnabled=false applies only when a host installs this plugin WITHOUT the PD installer. Ships the read-only pd-recall MCP server (Gate 4).",
|
|
5
5
|
"defaultEnabled": false
|
|
6
6
|
}
|
package/plugin/mcp/launcher.cjs
CHANGED
|
@@ -36,6 +36,7 @@ const fs = require('fs');
|
|
|
36
36
|
const os = require('os');
|
|
37
37
|
const path = require('path');
|
|
38
38
|
const { pathToFileURL } = require('url');
|
|
39
|
+
const crypto = require('crypto');
|
|
39
40
|
|
|
40
41
|
const BINDING_SCHEMA = 'pd.mcp-binding.v1';
|
|
41
42
|
// Ownership stamp (SPEC §7.5/§11.3): installers and `pd health` prove that a
|
|
@@ -60,7 +61,200 @@ const SERVER_ENTRY_SEGMENTS = {
|
|
|
60
61
|
const SERVER_PACKAGE_NAME = '@principles/pd-mcp-server';
|
|
61
62
|
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
62
63
|
|
|
64
|
+
// ── Durable observability log (default ON, PD_MCP_LOG=0 disables) ──────────
|
|
65
|
+
// Same file contract as the server: ~/.pd/logs/mcp, one JSONL file per
|
|
66
|
+
// process, 2 MiB/file, 64 MiB/dir, 14 days retention. The launcher writes
|
|
67
|
+
// `launcher_start` / `startup_failure` and then hands the SAME file to the
|
|
68
|
+
// server through PD_MCP_LOG_FILE (basename only) so a process keeps one file.
|
|
69
|
+
// Everything here is best-effort: a logging failure never changes the
|
|
70
|
+
// fail-closed decision or the stderr line the host parses.
|
|
71
|
+
const LAUNCHER_VERSION = 'pd-mcp-launcher.v1';
|
|
72
|
+
const LOG_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
73
|
+
const LOG_MAX_DIR_BYTES = 64 * 1024 * 1024;
|
|
74
|
+
const LOG_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
75
|
+
const LOG_MAX_SHARDS = 3;
|
|
76
|
+
const LOG_DISABLED = ['0', 'false', 'off'].includes(String(process.env.PD_MCP_LOG || '').trim().toLowerCase());
|
|
77
|
+
const INITIAL_ENV_PRESENCE = Object.freeze({
|
|
78
|
+
PD_MCP_WORKSPACE_DIR: typeof process.env.PD_MCP_WORKSPACE_DIR === 'string' && process.env.PD_MCP_WORKSPACE_DIR.length > 0,
|
|
79
|
+
PD_MCP_HOST: typeof process.env.PD_MCP_HOST === 'string' && process.env.PD_MCP_HOST.length > 0,
|
|
80
|
+
PD_WORKSPACE_DIR: typeof process.env.PD_WORKSPACE_DIR === 'string' && process.env.PD_WORKSPACE_DIR.length > 0,
|
|
81
|
+
OPENCLAW_WORKSPACE: typeof process.env.OPENCLAW_WORKSPACE === 'string' && process.env.OPENCLAW_WORKSPACE.length > 0,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const logState = {
|
|
85
|
+
host: 'unknown',
|
|
86
|
+
bindingPathExists: false,
|
|
87
|
+
workspaceResolution: 'not_checked',
|
|
88
|
+
seq: 0,
|
|
89
|
+
runId: null,
|
|
90
|
+
fileName: null,
|
|
91
|
+
currentPath: null,
|
|
92
|
+
bytes: 0,
|
|
93
|
+
shard: 1,
|
|
94
|
+
exhausted: false,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function pad(value, width) {
|
|
98
|
+
return String(value).padStart(width || 2, '0');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function localTimestamp(date) {
|
|
102
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
103
|
+
const sign = offsetMinutes >= 0 ? '+' : '-';
|
|
104
|
+
const abs = Math.abs(offsetMinutes);
|
|
105
|
+
return {
|
|
106
|
+
ts:
|
|
107
|
+
`${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
108
|
+
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}` +
|
|
109
|
+
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`,
|
|
110
|
+
tzOffsetMinutes: offsetMinutes,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function logDir() {
|
|
115
|
+
const override = process.env.PD_MCP_LOG_DIR;
|
|
116
|
+
if (typeof override === 'string' && override.trim().length > 0) return path.resolve(override);
|
|
117
|
+
return path.join(os.homedir(), '.pd', 'logs', 'mcp');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sanitizeSegment(value) {
|
|
121
|
+
const cleaned = String(value).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40);
|
|
122
|
+
return cleaned.length > 0 ? cleaned : 'unknown';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function listOwnLogFiles(dir) {
|
|
126
|
+
try {
|
|
127
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
128
|
+
.filter((entry) => entry.isFile() && /^mcp-.*\.jsonl$/.test(entry.name))
|
|
129
|
+
.map((entry) => {
|
|
130
|
+
const fullPath = path.join(dir, entry.name);
|
|
131
|
+
try {
|
|
132
|
+
const stat = fs.statSync(fullPath);
|
|
133
|
+
return { fullPath, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
.filter((file) => file !== null);
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function maintainLogDir(dir, nowMs, keepPath) {
|
|
145
|
+
try {
|
|
146
|
+
const survivors = [];
|
|
147
|
+
for (const file of listOwnLogFiles(dir)) {
|
|
148
|
+
if (nowMs - file.mtimeMs > LOG_RETENTION_MS && file.fullPath !== keepPath) {
|
|
149
|
+
try {
|
|
150
|
+
fs.unlinkSync(file.fullPath);
|
|
151
|
+
continue;
|
|
152
|
+
} catch {
|
|
153
|
+
// Keep it.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
survivors.push(file);
|
|
157
|
+
}
|
|
158
|
+
let total = survivors.reduce((sum, file) => sum + file.size, 0);
|
|
159
|
+
if (total <= LOG_MAX_DIR_BYTES) return;
|
|
160
|
+
for (const file of survivors.filter((f) => f.fullPath !== keepPath).sort((a, b) => a.mtimeMs - b.mtimeMs)) {
|
|
161
|
+
if (total <= LOG_MAX_DIR_BYTES) break;
|
|
162
|
+
try {
|
|
163
|
+
fs.unlinkSync(file.fullPath);
|
|
164
|
+
total -= file.size;
|
|
165
|
+
} catch {
|
|
166
|
+
// Leave it.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// Maintenance is advisory only.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function ensureLogFile() {
|
|
175
|
+
if (LOG_DISABLED) return null;
|
|
176
|
+
if (logState.currentPath !== null) return logState.currentPath;
|
|
177
|
+
try {
|
|
178
|
+
const dir = logDir();
|
|
179
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
180
|
+
if (!fs.statSync(dir).isDirectory()) return null;
|
|
181
|
+
maintainLogDir(dir, Date.now(), null);
|
|
182
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
|
183
|
+
logState.fileName = `mcp-${sanitizeSegment(logState.host)}-${process.pid}-${stamp}.jsonl`;
|
|
184
|
+
logState.runId = crypto.randomBytes(4).toString('hex');
|
|
185
|
+
logState.currentPath = path.join(dir, logState.fileName);
|
|
186
|
+
try {
|
|
187
|
+
logState.bytes = fs.statSync(logState.currentPath).size;
|
|
188
|
+
} catch {
|
|
189
|
+
logState.bytes = 0;
|
|
190
|
+
}
|
|
191
|
+
return logState.currentPath;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function writeLogEvent(level, event, fields) {
|
|
198
|
+
try {
|
|
199
|
+
const file = ensureLogFile();
|
|
200
|
+
if (file === null) return;
|
|
201
|
+
const dir = path.dirname(file);
|
|
202
|
+
const stamp = localTimestamp(new Date());
|
|
203
|
+
const entry = Object.assign(
|
|
204
|
+
{
|
|
205
|
+
ts: stamp.ts,
|
|
206
|
+
tzOffsetMinutes: stamp.tzOffsetMinutes,
|
|
207
|
+
runId: logState.runId,
|
|
208
|
+
seq: (logState.seq += 1),
|
|
209
|
+
server: 'pd-mcp-launcher',
|
|
210
|
+
version: LAUNCHER_VERSION,
|
|
211
|
+
level,
|
|
212
|
+
event,
|
|
213
|
+
host: logState.host,
|
|
214
|
+
pid: process.pid,
|
|
215
|
+
},
|
|
216
|
+
fields
|
|
217
|
+
);
|
|
218
|
+
const line = JSON.stringify(entry) + '\n';
|
|
219
|
+
const bytes = Buffer.byteLength(line, 'utf8');
|
|
220
|
+
if (logState.exhausted || logState.bytes + bytes > LOG_MAX_FILE_BYTES) {
|
|
221
|
+
if (logState.shard >= LOG_MAX_SHARDS) {
|
|
222
|
+
logState.exhausted = true;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
logState.shard += 1;
|
|
226
|
+
logState.currentPath = path.join(dir, logState.fileName.replace(/\.jsonl$/, `-${logState.shard}.jsonl`));
|
|
227
|
+
logState.bytes = 0;
|
|
228
|
+
maintainLogDir(dir, Date.now(), logState.currentPath);
|
|
229
|
+
}
|
|
230
|
+
fs.appendFileSync(logState.currentPath, line, { mode: 0o600 });
|
|
231
|
+
logState.bytes += bytes;
|
|
232
|
+
} catch {
|
|
233
|
+
// Logging must never affect launch behaviour.
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Hand the same file/runId/seq to the server so one process keeps one file. */
|
|
238
|
+
function shareLogFileWithServer() {
|
|
239
|
+
try {
|
|
240
|
+
if (logState.fileName === null) ensureLogFile();
|
|
241
|
+
if (logState.fileName === null) return;
|
|
242
|
+
process.env.PD_MCP_LOG_FILE = path.basename(logState.fileName);
|
|
243
|
+
process.env.PD_MCP_LOG_RUN_ID = logState.runId;
|
|
244
|
+
process.env.PD_MCP_LOG_SEQ = String(logState.seq);
|
|
245
|
+
} catch {
|
|
246
|
+
// The server then opens its own file — still bounded, still silent.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
63
250
|
function fail(reason, nextAction) {
|
|
251
|
+
writeLogEvent('error', 'startup_failure', {
|
|
252
|
+
stage: 'launcher',
|
|
253
|
+
reason: String(reason).replace(/[^A-Za-z0-9_.:-]/g, '').slice(0, 120),
|
|
254
|
+
bindingPathExists: logState.bindingPathExists,
|
|
255
|
+
workspaceResolution: logState.workspaceResolution,
|
|
256
|
+
envPresence: INITIAL_ENV_PRESENCE,
|
|
257
|
+
});
|
|
64
258
|
process.stderr.write(JSON.stringify({ launcher: 'pd-mcp', status: 'failed', reason, nextAction }) + '\n');
|
|
65
259
|
process.exit(1);
|
|
66
260
|
}
|
|
@@ -179,12 +373,19 @@ if (detectedHosts.length !== 1) {
|
|
|
179
373
|
);
|
|
180
374
|
}
|
|
181
375
|
const host = detectedHosts[0];
|
|
376
|
+
logState.host = host;
|
|
182
377
|
|
|
183
378
|
// rc-1/rc-3/rc-5: the binding file is read as untrusted; every field is
|
|
184
379
|
// validated before use and unknown content is never coerced.
|
|
380
|
+
const bindingPath = path.join(pluginRoot, 'mcp-binding.json');
|
|
381
|
+
try {
|
|
382
|
+
logState.bindingPathExists = fs.statSync(bindingPath).isFile();
|
|
383
|
+
} catch {
|
|
384
|
+
logState.bindingPathExists = false;
|
|
385
|
+
}
|
|
185
386
|
let raw;
|
|
186
387
|
try {
|
|
187
|
-
raw = fs.readFileSync(
|
|
388
|
+
raw = fs.readFileSync(bindingPath, 'utf8');
|
|
188
389
|
} catch {
|
|
189
390
|
fail('binding_missing', 'Re-run the PD host setup (Codex: $pd-control setup) so mcp-binding.json is written into the plugin directory.');
|
|
190
391
|
}
|
|
@@ -294,6 +495,7 @@ try {
|
|
|
294
495
|
if (!workspaceStat.isDirectory()) {
|
|
295
496
|
fail('binding_workspace_not_directory', 'Re-run the PD host setup so the binding points at the governance workspace directory.');
|
|
296
497
|
}
|
|
498
|
+
logState.workspaceResolution = 'ok';
|
|
297
499
|
|
|
298
500
|
// ── OpenClaw workspace-declaration gate (SPEC §3.5, final safety closure) ──
|
|
299
501
|
// The OpenClaw binding is HOST-WIDE while agents have per-agent workspaces, so
|
|
@@ -328,6 +530,18 @@ if (host === 'openclaw') {
|
|
|
328
530
|
process.env.PD_MCP_WORKSPACE_DIR = binding.workspaceDir;
|
|
329
531
|
process.env.PD_MCP_HOST = host;
|
|
330
532
|
|
|
533
|
+
// Validation is complete: record the launch attempt (version, binding
|
|
534
|
+
// presence, workspace resolution and env PRESENCE — never values) and then
|
|
535
|
+
// share this file with the server.
|
|
536
|
+
writeLogEvent('info', 'launcher_start', {
|
|
537
|
+
launcherVersion: LAUNCHER_VERSION,
|
|
538
|
+
mcpServerVersion: packageManifest.version,
|
|
539
|
+
bindingPathExists: logState.bindingPathExists,
|
|
540
|
+
workspaceResolution: logState.workspaceResolution,
|
|
541
|
+
envPresence: INITIAL_ENV_PRESENCE,
|
|
542
|
+
});
|
|
543
|
+
shareLogFileWithServer();
|
|
544
|
+
|
|
331
545
|
// Run the shared server entry in-process (single process per host). Nothing
|
|
332
546
|
// above imports the server, so a rejected binding never emits MCP traffic.
|
|
333
547
|
import(pathToFileURL(realEntry).href)
|
|
@@ -5,6 +5,10 @@ description: 当 Owner 要求记录 WorkBuddy 智能体的行为错误、痛苦
|
|
|
5
5
|
|
|
6
6
|
# PD Pain Signal(WorkBuddy)
|
|
7
7
|
|
|
8
|
+
WorkBuddy **没有自动识别/自动纠正通道**:宿主侧缺少持久化的观测证据写入方,
|
|
9
|
+
自动关键词候选只会被标记为 `observation_only`,不会写入痛苦数据库。因此不要
|
|
10
|
+
声称、暗示或期待自动检测生效;唯一入口就是下面的显式标记。
|
|
11
|
+
|
|
8
12
|
## 唯一的正常入口
|
|
9
13
|
|
|
10
14
|
Owner 在当前 WorkBuddy 会话中提交:
|