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.
Files changed (35) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +102 -0
  3. package/README.zh-CN.md +73 -0
  4. package/package.json +42 -0
  5. package/packages/cli/bin/contract.js +266 -0
  6. package/packages/cli/package.json +10 -0
  7. package/packages/cli/src/spike-0.js +582 -0
  8. package/packages/cli/test/cli.test.js +89 -0
  9. package/packages/cli/test/spike-0.test.js +260 -0
  10. package/packages/core/bench/bench.js +48 -0
  11. package/packages/core/package.json +11 -0
  12. package/packages/core/src/clean.js +58 -0
  13. package/packages/core/src/errors.js +26 -0
  14. package/packages/core/src/index.js +10 -0
  15. package/packages/core/src/lang.js +37 -0
  16. package/packages/core/src/node.js +83 -0
  17. package/packages/core/src/pipeline.js +84 -0
  18. package/packages/core/src/profile.js +50 -0
  19. package/packages/core/src/rules.js +91 -0
  20. package/packages/core/test/clean.test.js +44 -0
  21. package/packages/core/test/lang.test.js +21 -0
  22. package/packages/core/test/pipeline.test.js +85 -0
  23. package/packages/core/test/profile.test.js +40 -0
  24. package/packages/core/test/rules.test.js +53 -0
  25. package/packages/mcp-server/bin/prompt-contract-mcp.js +7 -0
  26. package/packages/mcp-server/package.json +10 -0
  27. package/packages/mcp-server/src/server.js +184 -0
  28. package/packages/mcp-server/test/mcp.test.js +167 -0
  29. package/packages/providers/package.json +7 -0
  30. package/packages/providers/src/ollama.js +89 -0
  31. package/packages/providers/src/openai.js +89 -0
  32. package/packages/providers/test/providers.test.js +64 -0
  33. package/profiles/coding-agent.md +9 -0
  34. package/profiles/image-gen.md +9 -0
  35. 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
+ });