prompt-contract 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +102 -0
- package/README.zh-CN.md +73 -0
- package/package.json +42 -0
- package/packages/cli/bin/contract.js +266 -0
- package/packages/cli/package.json +10 -0
- package/packages/cli/src/spike-0.js +582 -0
- package/packages/cli/test/cli.test.js +89 -0
- package/packages/cli/test/spike-0.test.js +260 -0
- package/packages/core/bench/bench.js +48 -0
- package/packages/core/package.json +11 -0
- package/packages/core/src/clean.js +58 -0
- package/packages/core/src/errors.js +26 -0
- package/packages/core/src/index.js +10 -0
- package/packages/core/src/lang.js +37 -0
- package/packages/core/src/node.js +83 -0
- package/packages/core/src/pipeline.js +84 -0
- package/packages/core/src/profile.js +50 -0
- package/packages/core/src/rules.js +91 -0
- package/packages/core/test/clean.test.js +44 -0
- package/packages/core/test/lang.test.js +21 -0
- package/packages/core/test/pipeline.test.js +85 -0
- package/packages/core/test/profile.test.js +40 -0
- package/packages/core/test/rules.test.js +53 -0
- package/packages/mcp-server/bin/prompt-contract-mcp.js +7 -0
- package/packages/mcp-server/package.json +10 -0
- package/packages/mcp-server/src/server.js +184 -0
- package/packages/mcp-server/test/mcp.test.js +167 -0
- package/packages/providers/package.json +7 -0
- package/packages/providers/src/ollama.js +89 -0
- package/packages/providers/src/openai.js +89 -0
- package/packages/providers/test/providers.test.js +64 -0
- package/profiles/coding-agent.md +9 -0
- package/profiles/image-gen.md +9 -0
- package/profiles/writing.md +9 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { arch, platform, version as nodeVersion } from 'node:process';
|
|
4
|
+
|
|
5
|
+
const MACOS = 'darwin';
|
|
6
|
+
const FIELD_SEPARATOR = String.fromCharCode(30);
|
|
7
|
+
const CLIPBOARD_INFO_SCRIPT = 'clipboard info';
|
|
8
|
+
const PLAIN_TEXT_CLIPBOARD_TYPES = new Set(['string', 'unicode text', '«class utf8»', '«class ut16»', '«class utxt»']);
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_SPIKE_THRESHOLDS = Object.freeze({
|
|
11
|
+
iterationsPerTarget: 20,
|
|
12
|
+
requiredTargets: ['Chrome', 'PyCharm', 'iTerm'],
|
|
13
|
+
combinedCaptureSuccessRateMin: 0.9,
|
|
14
|
+
combinedDryRunPasteBackRateMin: 0.9,
|
|
15
|
+
clipboardRestoreSuccessRateMin: 1,
|
|
16
|
+
focusRecordRateMin: 1,
|
|
17
|
+
maxSafetyFailures: 0,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const APP_TARGETS = Object.freeze({
|
|
21
|
+
Chrome: Object.freeze({
|
|
22
|
+
kind: 'browser',
|
|
23
|
+
aliases: ['chrome', 'google chrome'],
|
|
24
|
+
bundleIds: ['com.google.chrome'],
|
|
25
|
+
}),
|
|
26
|
+
PyCharm: Object.freeze({
|
|
27
|
+
kind: 'editor',
|
|
28
|
+
aliases: ['pycharm', 'pycharm ce', 'pycharm community', 'pycharm professional'],
|
|
29
|
+
bundleIds: ['com.jetbrains.pycharm'],
|
|
30
|
+
}),
|
|
31
|
+
iTerm: Object.freeze({
|
|
32
|
+
kind: 'terminal',
|
|
33
|
+
aliases: ['iterm', 'iterm2'],
|
|
34
|
+
bundleIds: ['com.googlecode.iterm2'],
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const COPY_SCRIPT = 'tell application "System Events" to keystroke "c" using {command down}';
|
|
39
|
+
|
|
40
|
+
// The report deliberately excludes AXValue: it can contain the user's prompt or
|
|
41
|
+
// other sensitive text. These attributes are enough to conservatively detect a
|
|
42
|
+
// focus change while keeping the report useful for a compatibility matrix.
|
|
43
|
+
const CONTEXT_SCRIPT = String.raw`
|
|
44
|
+
on replaceText(findText, replaceWith, subjectText)
|
|
45
|
+
set oldDelimiters to AppleScript's text item delimiters
|
|
46
|
+
set AppleScript's text item delimiters to findText
|
|
47
|
+
set textParts to text items of subjectText
|
|
48
|
+
set AppleScript's text item delimiters to replaceWith
|
|
49
|
+
set resultText to textParts as text
|
|
50
|
+
set AppleScript's text item delimiters to oldDelimiters
|
|
51
|
+
return resultText
|
|
52
|
+
end replaceText
|
|
53
|
+
|
|
54
|
+
on safeText(valueToConvert)
|
|
55
|
+
try
|
|
56
|
+
set textValue to valueToConvert as text
|
|
57
|
+
on error
|
|
58
|
+
set textValue to ""
|
|
59
|
+
end try
|
|
60
|
+
set textValue to my replaceText((ASCII character 30), " ", textValue)
|
|
61
|
+
set textValue to my replaceText(return, " ", textValue)
|
|
62
|
+
set textValue to my replaceText(linefeed, " ", textValue)
|
|
63
|
+
return textValue
|
|
64
|
+
end safeText
|
|
65
|
+
|
|
66
|
+
on axAttribute(elementReference, attributeName)
|
|
67
|
+
try
|
|
68
|
+
tell application "System Events"
|
|
69
|
+
return value of attribute attributeName of elementReference
|
|
70
|
+
end tell
|
|
71
|
+
on error
|
|
72
|
+
return ""
|
|
73
|
+
end try
|
|
74
|
+
end axAttribute
|
|
75
|
+
|
|
76
|
+
tell application "System Events"
|
|
77
|
+
set frontProcess to first application process whose frontmost is true
|
|
78
|
+
set processName to my safeText(name of frontProcess)
|
|
79
|
+
try
|
|
80
|
+
set processId to my safeText(unix id of frontProcess)
|
|
81
|
+
on error
|
|
82
|
+
set processId to ""
|
|
83
|
+
end try
|
|
84
|
+
try
|
|
85
|
+
set bundleId to my safeText(bundle identifier of frontProcess)
|
|
86
|
+
on error
|
|
87
|
+
set bundleId to ""
|
|
88
|
+
end try
|
|
89
|
+
try
|
|
90
|
+
set windowTitle to my safeText(name of front window of frontProcess)
|
|
91
|
+
on error
|
|
92
|
+
set windowTitle to ""
|
|
93
|
+
end try
|
|
94
|
+
|
|
95
|
+
set focusedElement to my axAttribute(frontProcess, "AXFocusedUIElement")
|
|
96
|
+
set focusRole to my safeText(my axAttribute(focusedElement, "AXRole"))
|
|
97
|
+
set focusSubrole to my safeText(my axAttribute(focusedElement, "AXSubrole"))
|
|
98
|
+
set focusIdentifier to my safeText(my axAttribute(focusedElement, "AXIdentifier"))
|
|
99
|
+
set focusTitle to my safeText(my axAttribute(focusedElement, "AXTitle"))
|
|
100
|
+
set focusDescription to my safeText(my axAttribute(focusedElement, "AXDescription"))
|
|
101
|
+
set focusRoleDescription to my safeText(my axAttribute(focusedElement, "AXRoleDescription"))
|
|
102
|
+
|
|
103
|
+
set outputFields to {processName, bundleId, processId, windowTitle, focusRole, focusSubrole, focusIdentifier, focusTitle, focusDescription, focusRoleDescription}
|
|
104
|
+
set AppleScript's text item delimiters to (ASCII character 30)
|
|
105
|
+
set outputText to outputFields as text
|
|
106
|
+
set AppleScript's text item delimiters to ""
|
|
107
|
+
return outputText
|
|
108
|
+
end tell
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
function errorText(error) {
|
|
112
|
+
if (!error) return null;
|
|
113
|
+
return error instanceof Error ? error.message : String(error);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function commandError(command, result) {
|
|
117
|
+
const detail = String(result?.stderr || '').trim();
|
|
118
|
+
const error = new Error(`${command} failed${detail ? `: ${detail}` : ''}`);
|
|
119
|
+
error.code = `${command}_failed`;
|
|
120
|
+
return error;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runCommand(command, args = [], { input, timeoutMs = 5000 } = {}) {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
126
|
+
let stdout = '';
|
|
127
|
+
let stderr = '';
|
|
128
|
+
let timedOut = false;
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
timedOut = true;
|
|
131
|
+
child.kill('SIGTERM');
|
|
132
|
+
}, timeoutMs);
|
|
133
|
+
|
|
134
|
+
child.stdout.setEncoding('utf8');
|
|
135
|
+
child.stderr.setEncoding('utf8');
|
|
136
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
137
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
138
|
+
child.on('error', (error) => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
resolve({ code: null, stdout, stderr, error });
|
|
141
|
+
});
|
|
142
|
+
child.on('close', (code, signal) => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
resolve({ code, signal, stdout, stderr, timedOut });
|
|
145
|
+
});
|
|
146
|
+
if (input === undefined) child.stdin.end();
|
|
147
|
+
else child.stdin.end(input, 'utf8');
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function stripFinalNewline(value) {
|
|
152
|
+
return value.endsWith('\n') ? value.slice(0, -1).replace(/\r$/, '') : value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isPlainTextClipboardInfo(output) {
|
|
156
|
+
const parts = String(output || '').split(',').map((part) => part.trim());
|
|
157
|
+
if (!parts.length || (parts.length === 1 && !parts[0])) return true;
|
|
158
|
+
if (parts.length % 2 !== 0) return false;
|
|
159
|
+
const typeNames = parts.filter((_, index) => index % 2 === 0).map((typeName) => typeName.toLowerCase());
|
|
160
|
+
return typeNames.every((typeName) => PLAIN_TEXT_CLIPBOARD_TYPES.has(typeName));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseContext(output) {
|
|
164
|
+
const fields = stripFinalNewline(output).split(FIELD_SEPARATOR);
|
|
165
|
+
if (fields.length < 10 || !fields[0]) {
|
|
166
|
+
const error = new Error('Accessibility query returned an incomplete foreground/focus identity');
|
|
167
|
+
error.code = 'focus_identity_incomplete';
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
const [processName, bundleId, pidText, windowTitle, role, subrole, identifier, title, description, roleDescription] = fields;
|
|
171
|
+
const focus = { role, subrole, identifier, title, description, roleDescription };
|
|
172
|
+
return {
|
|
173
|
+
processName,
|
|
174
|
+
bundleId,
|
|
175
|
+
pid: pidText ? Number(pidText) : null,
|
|
176
|
+
windowTitle,
|
|
177
|
+
focus,
|
|
178
|
+
focusFingerprint: fingerprintForContext({ processName, bundleId, pid: pidText, windowTitle, focus }),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function createMacOSAdapter({ run = runCommand } = {}) {
|
|
183
|
+
return {
|
|
184
|
+
async readClipboard() {
|
|
185
|
+
const result = await run('/usr/bin/pbpaste', ['-Prefer', 'public.utf8-plain-text']);
|
|
186
|
+
if (result.error) throw result.error;
|
|
187
|
+
if (result.code !== 0) throw commandError('pbpaste', result);
|
|
188
|
+
return result.stdout;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async writeClipboard(value) {
|
|
192
|
+
const result = await run('/usr/bin/pbcopy', [], { input: value });
|
|
193
|
+
if (result.error) throw result.error;
|
|
194
|
+
if (result.code !== 0) throw commandError('pbcopy', result);
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async checkClipboardRestorable() {
|
|
198
|
+
const result = await run('/usr/bin/osascript', ['-e', CLIPBOARD_INFO_SCRIPT]);
|
|
199
|
+
if (result.error) throw result.error;
|
|
200
|
+
if (result.code !== 0) throw commandError('osascript-clipboard-info', result);
|
|
201
|
+
const clipboardInfo = stripFinalNewline(result.stdout);
|
|
202
|
+
if (!isPlainTextClipboardInfo(clipboardInfo)) {
|
|
203
|
+
const error = new Error('clipboard_not_plain_text: only text pasteboards are restorable by this Spike-0');
|
|
204
|
+
error.code = 'clipboard_not_plain_text';
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
async copySelection() {
|
|
210
|
+
const result = await run('/usr/bin/osascript', ['-e', COPY_SCRIPT]);
|
|
211
|
+
if (result.error) throw result.error;
|
|
212
|
+
if (result.code !== 0) throw commandError('osascript-copy', result);
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async getFocusIdentity() {
|
|
216
|
+
const result = await run('/usr/bin/osascript', ['-e', CONTEXT_SCRIPT], { timeoutMs: 5000 });
|
|
217
|
+
if (result.error) throw result.error;
|
|
218
|
+
if (result.code !== 0) throw commandError('osascript-focus', result);
|
|
219
|
+
return parseContext(result.stdout);
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
sleep(ms) {
|
|
223
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizeTargetLabel(value) {
|
|
229
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
230
|
+
for (const [label, spec] of Object.entries(APP_TARGETS)) {
|
|
231
|
+
if (label.toLowerCase() === normalized || spec.aliases.includes(normalized)) return label;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function resolveSpikeTargets(value) {
|
|
237
|
+
const values = Array.isArray(value) ? value : String(value || '').split(',');
|
|
238
|
+
const labels = values.map(normalizeTargetLabel).filter(Boolean);
|
|
239
|
+
const invalid = values.filter((item) => String(item || '').trim() && !normalizeTargetLabel(item));
|
|
240
|
+
if (invalid.length) {
|
|
241
|
+
const error = new Error(`unknown Spike-0 target(s): ${invalid.join(', ')}; choose Chrome, PyCharm, or iTerm`);
|
|
242
|
+
error.code = 'invalid_spike_target';
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
return [...new Set(labels)];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function normalized(value) {
|
|
249
|
+
return String(value ?? '').trim().toLowerCase();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function targetMatchesContext(target, context) {
|
|
253
|
+
const label = normalizeTargetLabel(target);
|
|
254
|
+
if (!label || !context) return false;
|
|
255
|
+
const spec = APP_TARGETS[label];
|
|
256
|
+
const bundleId = normalized(context.bundleId);
|
|
257
|
+
const processName = normalized(context.processName);
|
|
258
|
+
return spec.bundleIds.some((id) => bundleId === id || bundleId.startsWith(`${id}.`))
|
|
259
|
+
|| spec.aliases.some((alias) => processName === alias || processName.includes(alias));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function fingerprintForContext(context) {
|
|
263
|
+
const focus = context?.focus || {};
|
|
264
|
+
const fields = [
|
|
265
|
+
context?.processName,
|
|
266
|
+
context?.bundleId,
|
|
267
|
+
context?.pid,
|
|
268
|
+
focus.role,
|
|
269
|
+
focus.subrole,
|
|
270
|
+
focus.identifier,
|
|
271
|
+
focus.title,
|
|
272
|
+
focus.description,
|
|
273
|
+
focus.roleDescription,
|
|
274
|
+
].map((value) => String(value ?? ''));
|
|
275
|
+
return createHash('sha256').update(fields.join('\u001f')).digest('hex').slice(0, 16);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function sameFocusIdentity(left, right) {
|
|
279
|
+
if (!left || !right) return false;
|
|
280
|
+
const appStable = normalized(left.processName) === normalized(right.processName)
|
|
281
|
+
&& normalized(left.bundleId) === normalized(right.bundleId)
|
|
282
|
+
&& String(left.pid ?? '') === String(right.pid ?? '');
|
|
283
|
+
const bothWindowTitlesKnown = Boolean(left.windowTitle) && Boolean(right.windowTitle);
|
|
284
|
+
const windowStable = !bothWindowTitlesKnown || left.windowTitle === right.windowTitle;
|
|
285
|
+
return appStable && windowStable && fingerprintForContext(left) === fingerprintForContext(right);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function captureSelectedText(adapter, { settleMs = 75 } = {}) {
|
|
289
|
+
let originalClipboard;
|
|
290
|
+
let snapshotTaken = false;
|
|
291
|
+
let contextBefore = null;
|
|
292
|
+
let contextAfterCapture = null;
|
|
293
|
+
let selectedText = null;
|
|
294
|
+
let error = null;
|
|
295
|
+
let restoreError = null;
|
|
296
|
+
let clipboardRestored = false;
|
|
297
|
+
let clipboardRestoreVerified = false;
|
|
298
|
+
let clipboardUntouched = true;
|
|
299
|
+
let clipboardMayHaveChanged = false;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
originalClipboard = await adapter.readClipboard();
|
|
303
|
+
snapshotTaken = true;
|
|
304
|
+
if (typeof adapter.checkClipboardRestorable === 'function') {
|
|
305
|
+
await adapter.checkClipboardRestorable();
|
|
306
|
+
}
|
|
307
|
+
contextBefore = await adapter.getFocusIdentity();
|
|
308
|
+
clipboardMayHaveChanged = true;
|
|
309
|
+
clipboardUntouched = false;
|
|
310
|
+
await adapter.copySelection();
|
|
311
|
+
await adapter.sleep(settleMs);
|
|
312
|
+
const copiedText = await adapter.readClipboard();
|
|
313
|
+
selectedText = typeof copiedText === 'string' && copiedText.trim() ? copiedText : null;
|
|
314
|
+
contextAfterCapture = await adapter.getFocusIdentity();
|
|
315
|
+
} catch (captureError) {
|
|
316
|
+
error = captureError;
|
|
317
|
+
} finally {
|
|
318
|
+
if (snapshotTaken && clipboardMayHaveChanged) {
|
|
319
|
+
try {
|
|
320
|
+
await adapter.writeClipboard(originalClipboard);
|
|
321
|
+
clipboardRestored = true;
|
|
322
|
+
clipboardRestoreVerified = (await adapter.readClipboard()) === originalClipboard;
|
|
323
|
+
} catch (restoreFailure) {
|
|
324
|
+
restoreError = restoreFailure;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
selectedText,
|
|
331
|
+
selectedTextLength: selectedText ? selectedText.length : 0,
|
|
332
|
+
selectedTextCaptured: Boolean(selectedText),
|
|
333
|
+
clipboardRestored,
|
|
334
|
+
clipboardRestoreVerified,
|
|
335
|
+
clipboardUntouched,
|
|
336
|
+
focusRecorded: Boolean(contextBefore),
|
|
337
|
+
contextBefore,
|
|
338
|
+
contextAfterCapture,
|
|
339
|
+
error: errorText(error),
|
|
340
|
+
restoreError: errorText(restoreError),
|
|
341
|
+
userTextMutated: false,
|
|
342
|
+
pasteCommandSent: false,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function validatePasteBackDryRun(adapter, {
|
|
347
|
+
capturedContext,
|
|
348
|
+
selectedText,
|
|
349
|
+
pauseMs = 0,
|
|
350
|
+
} = {}) {
|
|
351
|
+
if (pauseMs > 0) await adapter.sleep(pauseMs);
|
|
352
|
+
|
|
353
|
+
let contextAtValidation = null;
|
|
354
|
+
let error = null;
|
|
355
|
+
try {
|
|
356
|
+
contextAtValidation = await adapter.getFocusIdentity();
|
|
357
|
+
} catch (validationError) {
|
|
358
|
+
error = validationError;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const focusStable = sameFocusIdentity(capturedContext, contextAtValidation);
|
|
362
|
+
const selectedTextCaptured = typeof selectedText === 'string' && Boolean(selectedText.trim());
|
|
363
|
+
let reason = 'ready';
|
|
364
|
+
if (!selectedTextCaptured) reason = 'empty_selection';
|
|
365
|
+
else if (!focusStable) reason = 'focus_drift';
|
|
366
|
+
if (error) reason = 'focus_identity_unavailable';
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
mode: 'dry-run',
|
|
370
|
+
executed: false,
|
|
371
|
+
pasteCommandSent: false,
|
|
372
|
+
userTextMutated: false,
|
|
373
|
+
focusStable,
|
|
374
|
+
wouldPasteBack: selectedTextCaptured && focusStable && !error,
|
|
375
|
+
reason,
|
|
376
|
+
contextAtValidation,
|
|
377
|
+
error: errorText(error),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function rate(numerator, denominator) {
|
|
382
|
+
return denominator ? Number((numerator / denominator).toFixed(4)) : 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function summarizeRuns(runs) {
|
|
386
|
+
const attempts = runs.length;
|
|
387
|
+
const captureSuccesses = runs.filter((run) => run.capture?.selectedTextCaptured).length;
|
|
388
|
+
const clipboardRestoreSuccesses = runs.filter((run) => run.capture?.clipboardRestored && run.capture?.clipboardRestoreVerified).length;
|
|
389
|
+
const focusRecords = runs.filter((run) => run.capture?.focusRecorded && run.pasteBack?.contextAtValidation).length;
|
|
390
|
+
const dryRunPasteBackSuccesses = runs.filter((run) => run.pasteBack?.wouldPasteBack).length;
|
|
391
|
+
const safetyFailures = runs.filter((run) => run.safety?.userTextMutated || run.safety?.pasteCommandSent).length;
|
|
392
|
+
return {
|
|
393
|
+
attempts,
|
|
394
|
+
captureSuccesses,
|
|
395
|
+
captureSuccessRate: rate(captureSuccesses, attempts),
|
|
396
|
+
clipboardRestoreSuccesses,
|
|
397
|
+
clipboardRestoreSuccessRate: rate(clipboardRestoreSuccesses, attempts),
|
|
398
|
+
focusRecords,
|
|
399
|
+
focusRecordRate: rate(focusRecords, attempts),
|
|
400
|
+
dryRunPasteBackSuccesses,
|
|
401
|
+
dryRunPasteBackRate: rate(dryRunPasteBackSuccesses, attempts),
|
|
402
|
+
safetyFailures,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function buildCompatibilityReport({
|
|
407
|
+
runs,
|
|
408
|
+
targets,
|
|
409
|
+
thresholds = DEFAULT_SPIKE_THRESHOLDS,
|
|
410
|
+
platformInfo = { os: platform, arch, node: nodeVersion },
|
|
411
|
+
startedAt,
|
|
412
|
+
finishedAt,
|
|
413
|
+
} = {}) {
|
|
414
|
+
const allRuns = Array.isArray(runs) ? runs : [];
|
|
415
|
+
const selectedTargets = [...new Set((targets || []).map(normalizeTargetLabel).filter(Boolean))];
|
|
416
|
+
const summary = summarizeRuns(allRuns);
|
|
417
|
+
const byTarget = Object.fromEntries(selectedTargets.map((target) => [
|
|
418
|
+
target,
|
|
419
|
+
summarizeRuns(allRuns.filter((run) => run.target === target)),
|
|
420
|
+
]));
|
|
421
|
+
const reasons = [];
|
|
422
|
+
|
|
423
|
+
for (const required of thresholds.requiredTargets) {
|
|
424
|
+
const targetSummary = byTarget[required];
|
|
425
|
+
if (!targetSummary || targetSummary.attempts < thresholds.iterationsPerTarget) {
|
|
426
|
+
reasons.push(`${required} requires ${thresholds.iterationsPerTarget} attempts`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (summary.captureSuccessRate < thresholds.combinedCaptureSuccessRateMin) {
|
|
430
|
+
reasons.push(`capture success ${summary.captureSuccessRate} is below ${thresholds.combinedCaptureSuccessRateMin}`);
|
|
431
|
+
}
|
|
432
|
+
if (summary.dryRunPasteBackRate < thresholds.combinedDryRunPasteBackRateMin) {
|
|
433
|
+
reasons.push(`dry-run paste-back eligibility ${summary.dryRunPasteBackRate} is below ${thresholds.combinedDryRunPasteBackRateMin}`);
|
|
434
|
+
}
|
|
435
|
+
if (summary.clipboardRestoreSuccessRate < thresholds.clipboardRestoreSuccessRateMin) {
|
|
436
|
+
reasons.push(`clipboard restoration ${summary.clipboardRestoreSuccessRate} is below ${thresholds.clipboardRestoreSuccessRateMin}`);
|
|
437
|
+
}
|
|
438
|
+
if (summary.focusRecordRate < thresholds.focusRecordRateMin) {
|
|
439
|
+
reasons.push(`focus identity coverage ${summary.focusRecordRate} is below ${thresholds.focusRecordRateMin}`);
|
|
440
|
+
}
|
|
441
|
+
if (summary.safetyFailures > thresholds.maxSafetyFailures) {
|
|
442
|
+
reasons.push(`safety failures ${summary.safetyFailures} exceed ${thresholds.maxSafetyFailures}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
schemaVersion: 'prompt-contract/spike-0.v1',
|
|
447
|
+
kind: 'compatibility-report',
|
|
448
|
+
mode: 'dry-run',
|
|
449
|
+
platform: platformInfo,
|
|
450
|
+
startedAt: startedAt || new Date().toISOString(),
|
|
451
|
+
finishedAt: finishedAt || new Date().toISOString(),
|
|
452
|
+
targets: selectedTargets,
|
|
453
|
+
thresholds,
|
|
454
|
+
runs: allRuns,
|
|
455
|
+
summary: { ...summary, byTarget },
|
|
456
|
+
decision: {
|
|
457
|
+
pass: reasons.length === 0,
|
|
458
|
+
reasons,
|
|
459
|
+
watchGate: 'closed',
|
|
460
|
+
note: 'A dry-run cannot prove actual paste landing; contract watch remains gated until a separate implementation decision.',
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function makeRunRecord(target, iteration, capture, pasteBack, startedAt, durationMs) {
|
|
466
|
+
const expectedAppMatch = targetMatchesContext(target, capture.contextBefore);
|
|
467
|
+
const capturePass = capture.selectedTextCaptured
|
|
468
|
+
&& capture.clipboardRestored
|
|
469
|
+
&& capture.clipboardRestoreVerified
|
|
470
|
+
&& capture.focusRecorded;
|
|
471
|
+
const pastePass = pasteBack.wouldPasteBack && expectedAppMatch;
|
|
472
|
+
return {
|
|
473
|
+
target,
|
|
474
|
+
iteration,
|
|
475
|
+
startedAt,
|
|
476
|
+
durationMs,
|
|
477
|
+
capture: {
|
|
478
|
+
selectedTextCaptured: capture.selectedTextCaptured,
|
|
479
|
+
selectedTextLength: capture.selectedTextLength,
|
|
480
|
+
clipboardFormat: 'public.utf8-plain-text',
|
|
481
|
+
clipboardRestored: capture.clipboardRestored,
|
|
482
|
+
clipboardRestoreVerified: capture.clipboardRestoreVerified,
|
|
483
|
+
clipboardUntouched: capture.clipboardUntouched,
|
|
484
|
+
focusRecorded: capture.focusRecorded,
|
|
485
|
+
error: capture.error,
|
|
486
|
+
restoreError: capture.restoreError,
|
|
487
|
+
},
|
|
488
|
+
foreground: {
|
|
489
|
+
before: capture.contextBefore,
|
|
490
|
+
afterCapture: capture.contextAfterCapture,
|
|
491
|
+
expectedAppMatch,
|
|
492
|
+
},
|
|
493
|
+
pasteBack: {
|
|
494
|
+
mode: pasteBack.mode,
|
|
495
|
+
executed: pasteBack.executed,
|
|
496
|
+
pasteCommandSent: pasteBack.pasteCommandSent,
|
|
497
|
+
userTextMutated: pasteBack.userTextMutated,
|
|
498
|
+
focusStable: pasteBack.focusStable,
|
|
499
|
+
wouldPasteBack: pastePass,
|
|
500
|
+
reason: expectedAppMatch ? pasteBack.reason : 'unexpected_foreground_app',
|
|
501
|
+
contextAtValidation: pasteBack.contextAtValidation,
|
|
502
|
+
error: pasteBack.error,
|
|
503
|
+
},
|
|
504
|
+
safety: {
|
|
505
|
+
userTextMutated: capture.userTextMutated || pasteBack.userTextMutated,
|
|
506
|
+
pasteCommandSent: capture.pasteCommandSent || pasteBack.pasteCommandSent,
|
|
507
|
+
},
|
|
508
|
+
pass: capturePass && pastePass,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function defaultPrompt(message) {
|
|
513
|
+
if (!process.stdin.isTTY) return;
|
|
514
|
+
const { createInterface } = await import('node:readline/promises');
|
|
515
|
+
const readline = createInterface({ input: process.stdin, output: process.stderr });
|
|
516
|
+
try {
|
|
517
|
+
await readline.question(message);
|
|
518
|
+
} finally {
|
|
519
|
+
readline.close();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export async function runSpike0({
|
|
524
|
+
adapter = createMacOSAdapter(),
|
|
525
|
+
targets = DEFAULT_SPIKE_THRESHOLDS.requiredTargets,
|
|
526
|
+
iterations = DEFAULT_SPIKE_THRESHOLDS.iterationsPerTarget,
|
|
527
|
+
settleMs = 75,
|
|
528
|
+
pauseMs = 0,
|
|
529
|
+
setupDelayMs = 0,
|
|
530
|
+
interactive = process.stdin.isTTY,
|
|
531
|
+
prompt = defaultPrompt,
|
|
532
|
+
announce = () => {},
|
|
533
|
+
platformInfo = { os: platform, arch, node: nodeVersion },
|
|
534
|
+
now = () => new Date(),
|
|
535
|
+
} = {}) {
|
|
536
|
+
const selectedTargets = resolveSpikeTargets(targets);
|
|
537
|
+
const startedAt = now().toISOString();
|
|
538
|
+
const runs = [];
|
|
539
|
+
|
|
540
|
+
for (const target of selectedTargets) {
|
|
541
|
+
if (interactive) {
|
|
542
|
+
await prompt(`Focus ${target}, select non-sensitive text, then press Enter (dry-run; never pastes): `);
|
|
543
|
+
}
|
|
544
|
+
if (setupDelayMs > 0) {
|
|
545
|
+
announce('Focus ' + target + ' and select text now; capture starts after the setup delay.');
|
|
546
|
+
await adapter.sleep(setupDelayMs);
|
|
547
|
+
}
|
|
548
|
+
for (let iteration = 1; iteration <= iterations; iteration++) {
|
|
549
|
+
const started = now();
|
|
550
|
+
const capture = await captureSelectedText(adapter, { settleMs });
|
|
551
|
+
const pasteBack = await validatePasteBackDryRun(adapter, {
|
|
552
|
+
capturedContext: capture.contextBefore,
|
|
553
|
+
selectedText: capture.selectedText,
|
|
554
|
+
pauseMs,
|
|
555
|
+
});
|
|
556
|
+
runs.push(makeRunRecord(target, iteration, capture, pasteBack, started.toISOString(), now() - started));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return buildCompatibilityReport({
|
|
561
|
+
runs,
|
|
562
|
+
targets: selectedTargets,
|
|
563
|
+
thresholds: { ...DEFAULT_SPIKE_THRESHOLDS, iterationsPerTarget: iterations },
|
|
564
|
+
platformInfo,
|
|
565
|
+
startedAt,
|
|
566
|
+
finishedAt: now().toISOString(),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function formatSpike0Summary(report) {
|
|
571
|
+
const status = report.decision.pass ? 'PASS' : 'FAIL';
|
|
572
|
+
const summary = report.summary;
|
|
573
|
+
return [
|
|
574
|
+
`Spike-0 ${status} (dry-run; no paste issued)`,
|
|
575
|
+
`capture ${summary.captureSuccesses}/${summary.attempts} (${summary.captureSuccessRate})`,
|
|
576
|
+
`clipboard restore ${summary.clipboardRestoreSuccesses}/${summary.attempts} (${summary.clipboardRestoreSuccessRate})`,
|
|
577
|
+
`dry-run paste-back eligibility ${summary.dryRunPasteBackSuccesses}/${summary.attempts} (${summary.dryRunPasteBackRate})`,
|
|
578
|
+
report.decision.reasons.length ? `reasons: ${report.decision.reasons.join('; ')}` : 'thresholds met; contract watch remains gated',
|
|
579
|
+
].join('\n');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export const isMacOS = platform === MACOS;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { test, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { createMockServer, ZH_RESULT } from '../../../mock/server.js';
|
|
7
|
+
|
|
8
|
+
let mock, base, repoRoot;
|
|
9
|
+
const PB = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'contract.js');
|
|
10
|
+
|
|
11
|
+
before(async () => {
|
|
12
|
+
mock = createMockServer({});
|
|
13
|
+
const port = await mock.listen();
|
|
14
|
+
base = `http://127.0.0.1:${port}`;
|
|
15
|
+
repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
after(async () => { await mock.close(); });
|
|
19
|
+
|
|
20
|
+
function runPb(args, { env = {}, input } = {}) {
|
|
21
|
+
return new Promise((resolveRun) => {
|
|
22
|
+
const child = spawn(process.execPath, [PB, ...args], {
|
|
23
|
+
cwd: repoRoot,
|
|
24
|
+
env: { ...process.env, ...env }
|
|
25
|
+
});
|
|
26
|
+
let stdout = '', stderr = '';
|
|
27
|
+
child.stdout.on('data', (d) => { stdout += d; });
|
|
28
|
+
child.stderr.on('data', (d) => { stderr += d; });
|
|
29
|
+
if (input !== undefined) child.stdin.end(input); else child.stdin.end();
|
|
30
|
+
child.on('close', (code) => resolveRun({ code, stdout, stderr }));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OPENAI_ENV = { CONTRACT_PROVIDER: 'openai', CONTRACT_BASE_URL: `${base}/v1`, CONTRACT_API_KEY: 'test-key-123', CONTRACT_MODEL: 'mock-model' };
|
|
35
|
+
|
|
36
|
+
test('e2e: contract "..." enhances via openai-compatible upstream and passes rule assertions', async () => {
|
|
37
|
+
const { code, stdout, stderr } = await runPb(['--json', '--provider', 'openai', '--base-url', `${base}/v1`, '--api-key', 'test-key-123', '--model', 'mock-model', '帮我做一个展示我家狗的网站']);
|
|
38
|
+
assert.equal(code, 0, stderr);
|
|
39
|
+
const out = JSON.parse(stdout);
|
|
40
|
+
assert.equal(out.original, '帮我做一个展示我家狗的网站');
|
|
41
|
+
assert.equal(out.enhanced, ZH_RESULT);
|
|
42
|
+
assert.equal(out.meta.profile, 'coding-agent');
|
|
43
|
+
assert.equal(out.meta.model, 'mock-model');
|
|
44
|
+
assert.equal(out.rules.pass, true, JSON.stringify(out.rules.results));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('e2e: contract reads prompt from stdin (pipe mode)', async () => {
|
|
48
|
+
const { code, stdout } = await runPb(
|
|
49
|
+
['--no-stream', '--provider', 'openai', '--base-url', `${base}/v1`, '--api-key', 'test-key-123', '--model', 'mock-model'],
|
|
50
|
+
{ input: '帮我做一个展示我家狗的网站' }
|
|
51
|
+
);
|
|
52
|
+
assert.equal(code, 0);
|
|
53
|
+
assert.equal(stdout.trim(), ZH_RESULT);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('contract profiles lists the three built-in profiles', async () => {
|
|
57
|
+
const { code, stdout } = await runPb(['profiles']);
|
|
58
|
+
assert.equal(code, 0);
|
|
59
|
+
for (const name of ['coding-agent', 'writing', 'image-gen']) assert.match(stdout, new RegExp(name));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('contract check exits 1 on a failing pair (gate mode) and 0 on a good pair', async () => {
|
|
63
|
+
const bad = await runPb(['check', '--original', '做个博客', '--enhanced', '好的,以下是实现方案:先安装依赖。']);
|
|
64
|
+
assert.equal(bad.code, 1);
|
|
65
|
+
assert.match(bad.stderr, /FAIL/);
|
|
66
|
+
const good = await runPb(['check', '--original', '帮我做一个展示我家狗的网站', '--enhanced', ZH_RESULT]);
|
|
67
|
+
assert.equal(good.code, 0, good.stderr);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('contract watch is gated by decision D7', async () => {
|
|
71
|
+
const { code, stderr } = await runPb(['watch']);
|
|
72
|
+
assert.equal(code, 2);
|
|
73
|
+
assert.match(stderr, /D7/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('contract spike-0 exposes the macOS dry-run diagnostic', async () => {
|
|
77
|
+
const { code, stdout } = await runPb(['spike-0', '--help']);
|
|
78
|
+
assert.equal(code, 0);
|
|
79
|
+
assert.match(stdout, /dry-run/);
|
|
80
|
+
assert.match(stdout, /Chrome/);
|
|
81
|
+
assert.match(stdout, /PyCharm/);
|
|
82
|
+
assert.match(stdout, /iTerm/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('contract doctor reports provider problems honestly', async () => {
|
|
86
|
+
const { code, stderr } = await runPb(['doctor', '--provider', 'openai', '--base-url', `${base}/v1`, '--api-key', 'wrong-key', '--model', 'mock-model']);
|
|
87
|
+
assert.equal(code, 1);
|
|
88
|
+
assert.match(stderr, /FAIL/);
|
|
89
|
+
});
|