prompt-contract 0.2.0 → 0.3.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.
@@ -0,0 +1,609 @@
1
+ /**
2
+ * prompt-contract watch — resident hotkey mode (PRD §7, Snipaste-style).
3
+ * Hotkey → capture selection (clipboard fallback) → enhance → focus re-validation → paste back.
4
+ *
5
+ * Safety contract (carried over from Spike-0, now with a real paste step):
6
+ * - The user's clipboard is snapshotted and restored around every capture and paste.
7
+ * - Paste only fires when the foreground/focus identity still matches capture time;
8
+ * otherwise the cycle aborts with a notification (fail closed).
9
+ * - Evidence gate: `prompt-prompt-contract watch` requires a passing `prompt-prompt-contract spike-0` report (--report) or an
10
+ * explicit --force, honoring decision D7. Spike-0 itself never unlocks anything.
11
+ * - --dry-run exercises capture + enhance but never issues ⌘V.
12
+ *
13
+ * macOS only. The global hotkey comes from a small Swift helper (Carbon
14
+ * RegisterEventHotKey — needs no Accessibility permission) compiled on first run
15
+ * from the embedded source below; capture/paste keystrokes still need Accessibility.
16
+ */
17
+ import { spawn } from 'node:child_process';
18
+ import { createHash } from 'node:crypto';
19
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
20
+ import { readFile as readFileAsync } from 'node:fs/promises';
21
+ import { homedir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { createInterface } from 'node:readline';
24
+ import { enhance as enhanceCore, PromptContractError } from '../../core/src/index.js';
25
+ import { loadProfile, readUserConfig, resolveConfig } from '../../core/src/node.js';
26
+ import { createOpenAIProvider } from '../../providers/src/openai.js';
27
+ import { createOllamaProvider } from '../../providers/src/ollama.js';
28
+ import {
29
+ captureSelectedText,
30
+ createMacOSAdapter,
31
+ isMacOS,
32
+ sameFocusIdentity,
33
+ } from './spike-0.js';
34
+
35
+ export const DEFAULT_WATCH_OPTIONS = Object.freeze({
36
+ hotkey: 'alt+b',
37
+ settleMs: 75,
38
+ // 1000ms between ⌘V and the clipboard restore — measured on a live TextEdit
39
+ // round-trip: 150ms and 500ms both let the restore beat the target app's
40
+ // paste read (the document then receives the RESTORED content); 1000ms and
41
+ // 2500ms passed. The window cuts both ways (a user ⌘C inside it gets
42
+ // clobbered by the restore), so it is deliberately the tested minimum.
43
+ pasteDelayMs: 1000,
44
+ cooldownMs: 800,
45
+ dryRun: false,
46
+ });
47
+
48
+ // Carbon key codes (kVK_*) for the keys we accept in --hotkey.
49
+ export const KEY_CODES = Object.freeze({
50
+ a: 0, s: 1, d: 2, f: 3, h: 4, g: 5, z: 6, x: 7, c: 8, v: 9, b: 11,
51
+ q: 12, w: 13, e: 14, r: 15, y: 16, t: 17, u: 32, i: 34, o: 31, p: 35,
52
+ l: 37, j: 38, k: 40, n: 45, m: 46,
53
+ 0: 29, 1: 18, 2: 19, 3: 20, 4: 21, 5: 23, 6: 22, 7: 26, 8: 28, 9: 25,
54
+ space: 49, return: 36, enter: 36, tab: 48, escape: 53, delete: 51, forwarddelete: 117,
55
+ '=': 24, '-': 27, '[': 33, ']': 30, ';': 41, "'": 39, ',': 43, '.': 47, '/': 44, '\\': 42, '`': 50,
56
+ f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97, f7: 98, f8: 100, f9: 101,
57
+ f10: 109, f11: 103, f12: 111,
58
+ });
59
+
60
+ // Carbon modifier masks (cmdKey/shiftKey/optionKey/controlKey).
61
+ export const MODIFIER_MASKS = Object.freeze({
62
+ cmd: 1 << 8,
63
+ shift: 1 << 9,
64
+ alt: 1 << 11,
65
+ ctrl: 1 << 12,
66
+ });
67
+
68
+ const MODIFIER_ALIASES = Object.freeze({
69
+ cmd: 'cmd', meta: 'cmd', apple: 'cmd',
70
+ alt: 'alt', option: 'alt',
71
+ ctrl: 'ctrl', control: 'ctrl',
72
+ shift: 'shift',
73
+ });
74
+
75
+ const GLYPHS = Object.freeze({ cmd: '⌘', shift: '⇧', alt: '⌥', ctrl: '⌃' });
76
+
77
+ export function parseHotkey(spec = DEFAULT_WATCH_OPTIONS.hotkey) {
78
+ const parts = String(spec).split('+').map((part) => part.trim()).filter(Boolean);
79
+ if (parts.length < 2) {
80
+ throw new PromptContractError('config_error', `hotkey "${spec}" must combine a modifier with a key, e.g. alt+b (supported modifiers: cmd, alt/option, ctrl, shift)`);
81
+ }
82
+ let modifiers = 0;
83
+ const labelParts = [];
84
+ for (const part of parts.slice(0, -1)) {
85
+ const name = MODIFIER_ALIASES[part.toLowerCase()];
86
+ if (!name) {
87
+ throw new PromptContractError('config_error', `unknown hotkey modifier "${part}" (supported: cmd(⌘), alt/option(⌥), ctrl(⌃), shift(⇧))`);
88
+ }
89
+ const mask = MODIFIER_MASKS[name];
90
+ if (modifiers & mask) {
91
+ throw new PromptContractError('config_error', `duplicate hotkey modifier "${part}"`);
92
+ }
93
+ modifiers |= mask;
94
+ labelParts.push(GLYPHS[name]);
95
+ }
96
+ const keyToken = parts[parts.length - 1].toLowerCase();
97
+ const keyCode = KEY_CODES[keyToken];
98
+ if (keyCode === undefined) {
99
+ throw new PromptContractError('config_error', `unknown hotkey key "${parts[parts.length - 1]}" (supported: a-z, 0-9, space, return, tab, escape, delete, f1-f12, -=[];',./\\)`);
100
+ }
101
+ const keyLabel = keyToken.length === 1 ? keyToken.toUpperCase() : keyToken.charAt(0).toUpperCase() + keyToken.slice(1);
102
+ return { keyCode, modifiers, label: labelParts.join('') + keyLabel };
103
+ }
104
+
105
+ /**
106
+ * Trigger precedence: --hotkey flag > "hotkey" in ~/.prompt-contract/config.json > default.
107
+ * The hotkey is pressed deliberately, so everyday ⌘C copies never trigger anything;
108
+ * ⌘C is only sent synthetically after the trigger fires.
109
+ */
110
+ export function resolveWatchHotkey(flags = {}, config = {}) {
111
+ return String(flags.hotkey ?? config.hotkey ?? DEFAULT_WATCH_OPTIONS.hotkey);
112
+ }
113
+
114
+ /**
115
+ * D7 evidence gate: a passing Spike-0 compatibility report, or an explicit --force.
116
+ */
117
+ export async function evaluateWatchGate({ force = false, reportPath, readFile } = {}) {
118
+ if (force) return { ok: true, mode: 'forced' };
119
+ if (!reportPath) {
120
+ return { ok: false, reason: 'no Spike-0 evidence provided' };
121
+ }
122
+ let raw;
123
+ try {
124
+ raw = await readFile(reportPath, 'utf8');
125
+ } catch (err) {
126
+ return { ok: false, reason: `cannot read report ${reportPath}: ${err.code || err.message}` };
127
+ }
128
+ let report;
129
+ try {
130
+ report = JSON.parse(raw);
131
+ } catch {
132
+ return { ok: false, reason: `report ${reportPath} is not valid JSON` };
133
+ }
134
+ if (report?.schemaVersion !== 'prompt-contract/spike-0.v1' || report?.kind !== 'compatibility-report') {
135
+ return { ok: false, reason: `${reportPath} is not a Spike-0 compatibility report (schemaVersion/kind mismatch)` };
136
+ }
137
+ if (report.decision?.pass !== true) {
138
+ const reasons = Array.isArray(report.decision?.reasons) && report.decision.reasons.length
139
+ ? report.decision.reasons.join('; ')
140
+ : 'decision.pass is false';
141
+ return { ok: false, reason: `Spike-0 report did not pass: ${reasons}` };
142
+ }
143
+ return { ok: true, mode: 'report', report };
144
+ }
145
+
146
+ /**
147
+ * Read-only startup probe: proves clipboard access and the Accessibility-backed
148
+ * focus query work before watch goes resident. Never writes the clipboard.
149
+ */
150
+ export async function probeCaptureSafety(adapter) {
151
+ const warnings = [];
152
+ try {
153
+ await adapter.readClipboard();
154
+ } catch (err) {
155
+ return { ok: false, error: `clipboard read failed: ${err.message}` };
156
+ }
157
+ try {
158
+ await adapter.checkClipboardRestorable();
159
+ } catch (err) {
160
+ warnings.push(`current clipboard is not text-only (${err.message}); capture refuses to run while rich content is on the pasteboard`);
161
+ }
162
+ try {
163
+ await adapter.getFocusIdentity();
164
+ } catch (err) {
165
+ return { ok: false, error: `focus query failed: ${err.message} — grant Accessibility to your terminal under System Settings → Privacy & Security → Accessibility` };
166
+ }
167
+ return { ok: true, warnings };
168
+ }
169
+
170
+ /**
171
+ * The resident loop. Pure logic: every side effect (capture, enhance, paste,
172
+ * notify) is injected or goes through the adapter, so tests drive it with fakes.
173
+ */
174
+ export function createWatchService({
175
+ adapter,
176
+ enhance,
177
+ options = {},
178
+ notify = async () => {},
179
+ log = () => {},
180
+ now = () => Date.now(),
181
+ } = {}) {
182
+ const opts = { ...DEFAULT_WATCH_OPTIONS, ...options };
183
+ let busy = false;
184
+ let lastCycleEnd = -Infinity;
185
+
186
+ async function pasteBack(enhancedText) {
187
+ // Snapshot whatever is on the clipboard right now (the user may have copied
188
+ // something while the model was thinking); restore it after the paste lands.
189
+ let saved = null;
190
+ let restorable = false;
191
+ try {
192
+ saved = await adapter.readClipboard();
193
+ await adapter.checkClipboardRestorable();
194
+ restorable = true;
195
+ } catch {
196
+ restorable = false;
197
+ }
198
+ await adapter.writeClipboard(enhancedText);
199
+ await adapter.pasteSelection();
200
+ if (opts.pasteDelayMs > 0) await adapter.sleep(opts.pasteDelayMs);
201
+ if (restorable) {
202
+ await adapter.writeClipboard(saved);
203
+ const restored = await adapter.readClipboard();
204
+ if (restored !== saved) log('watch: clipboard restore could not be verified');
205
+ } else {
206
+ log('watch: clipboard held non-text content; it was not preserved across the paste');
207
+ }
208
+ }
209
+
210
+ async function handleTrigger() {
211
+ if (busy) {
212
+ log('watch: busy — trigger ignored (D8: no queueing on the hotkey path)');
213
+ return;
214
+ }
215
+ if (now() - lastCycleEnd < opts.cooldownMs) {
216
+ log('watch: cooldown — trigger ignored');
217
+ return;
218
+ }
219
+ busy = true;
220
+ try {
221
+ const capture = await captureSelectedText(adapter, { settleMs: opts.settleMs });
222
+ if (capture.error) {
223
+ await notify({ title: 'PromptContract watch', message: `capture failed: ${capture.error}` });
224
+ return;
225
+ }
226
+ let text = capture.selectedText;
227
+ let source = 'selection';
228
+ if (!text) {
229
+ // PRD §7.3: with nothing selected, fall back to the clipboard content
230
+ // (captureSelectedText has already restored the pre-capture clipboard).
231
+ const clip = await adapter.readClipboard();
232
+ if (typeof clip === 'string' && clip.trim()) {
233
+ text = clip;
234
+ source = 'clipboard';
235
+ }
236
+ }
237
+ if (!text) {
238
+ await notify({ title: 'PromptContract watch', message: 'No selected text and an empty clipboard — select text first.' });
239
+ return;
240
+ }
241
+ log(`watch: captured ${text.length} chars from ${source}`);
242
+ const res = await enhance(text);
243
+ log(`watch: enhanced ${text.length} → ${res.text.length} chars · ${res.meta?.model ?? 'model'} · ${res.meta?.ms ?? '?'}ms`);
244
+ if (opts.dryRun) {
245
+ log('watch: dry-run — nothing pasted, clipboard untouched. Enhanced text:');
246
+ log(res.text);
247
+ return;
248
+ }
249
+ if (!capture.contextBefore) {
250
+ await notify({ title: 'PromptContract watch', message: 'Focus identity unavailable at capture time — paste aborted.' });
251
+ return;
252
+ }
253
+ const focusNow = await adapter.getFocusIdentity();
254
+ if (!sameFocusIdentity(capture.contextBefore, focusNow)) {
255
+ await notify({
256
+ title: 'PromptContract watch',
257
+ message: `Focus moved to ${focusNow?.processName ?? 'another app'}; return to ${capture.contextBefore.processName} and press the hotkey again.`,
258
+ });
259
+ log('watch: focus drift detected — paste aborted (fail closed)');
260
+ return;
261
+ }
262
+ await pasteBack(res.text);
263
+ log('watch: pasted enhanced text');
264
+ } catch (err) {
265
+ await notify({ title: 'PromptContract watch', message: `error: ${err.code || ''} ${err.message}`.trim() });
266
+ log(`watch: cycle failed: ${err.code || ''} ${err.message}`.trim());
267
+ } finally {
268
+ lastCycleEnd = now();
269
+ busy = false;
270
+ }
271
+ }
272
+
273
+ return {
274
+ handleTrigger,
275
+ stop: () => { busy = false; },
276
+ isBusy: () => busy,
277
+ };
278
+ }
279
+
280
+ // Swift helper: registers one global hotkey via Carbon and prints a line-based
281
+ // protocol on stdout. Kept interpolation-free so it can live in a JS template
282
+ // literal. Compiled on demand to the cache dir; source is embedded here so the
283
+ // compiled binary is always reproducible from this audited file.
284
+ export const HELPER_SOURCE = `import AppKit
285
+ import Carbon.HIToolbox
286
+ import Foundation
287
+
288
+ // pb hotkey helper — embedded in packages/cli/src/watch.js; audit changes there.
289
+ // argv: <keyCode> <modifierMask>. Prints READY, then one TRIGGER line per press.
290
+ let args = CommandLine.arguments
291
+ guard args.count >= 3, let keyCode = UInt32(args[1]), let modifiers = UInt32(args[2]) else {
292
+ fputs("usage: pb-hotkey-helper <keyCode> <modifierMask>\\n", stderr)
293
+ exit(2)
294
+ }
295
+
296
+ let signature = OSType(0x5042_484B) // 'PBHK'
297
+ var hotKeyID = EventHotKeyID(signature: signature, id: 1)
298
+ var hotKeyRef: EventHotKeyRef?
299
+ var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
300
+
301
+ func hotKeyHandler(_ callRef: EventHandlerCallRef?, _ event: EventRef?, _ data: UnsafeMutableRawPointer?) -> OSStatus {
302
+ var id = EventHotKeyID()
303
+ let status = GetEventParameter(event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID), nil, MemoryLayout<EventHotKeyID>.size, nil, &id)
304
+ if status == noErr && id.signature == signature && id.id == 1 {
305
+ fputs("TRIGGER\\n", stdout)
306
+ fflush(stdout)
307
+ }
308
+ return status
309
+ }
310
+
311
+ let installStatus = InstallEventHandler(GetApplicationEventTarget(), hotKeyHandler, 1, &eventType, nil, nil)
312
+ let registerStatus = RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef)
313
+ if installStatus != noErr || registerStatus != noErr {
314
+ fputs("hotkey registration failed\\n", stderr)
315
+ exit(1)
316
+ }
317
+
318
+ let app = NSApplication.shared
319
+ app.setActivationPolicy(.accessory) // invisible resident: no Dock icon, no window
320
+ fputs("READY\\n", stdout)
321
+ fflush(stdout)
322
+ app.run()
323
+ `;
324
+
325
+ export function defaultCacheDir() {
326
+ return process.env.CONTRACT_CACHE_DIR || join(homedir(), '.cache', 'prompt-boost');
327
+ }
328
+
329
+ function runProcess(child, { timeoutMs = 120000 } = {}) {
330
+ return new Promise((resolve) => {
331
+ let stdout = '';
332
+ let stderr = '';
333
+ let timedOut = false;
334
+ const timer = setTimeout(() => {
335
+ timedOut = true;
336
+ child.kill('SIGTERM');
337
+ }, timeoutMs);
338
+ child.stdout?.setEncoding('utf8');
339
+ child.stderr?.setEncoding('utf8');
340
+ child.stdout?.on('data', (chunk) => { stdout += chunk; });
341
+ child.stderr?.on('data', (chunk) => { stderr += chunk; });
342
+ child.on('error', (error) => {
343
+ clearTimeout(timer);
344
+ resolve({ code: null, stdout, stderr, error });
345
+ });
346
+ child.on('close', (code) => {
347
+ clearTimeout(timer);
348
+ resolve({ code, stdout, stderr, timedOut });
349
+ });
350
+ });
351
+ }
352
+
353
+ async function ensureHelperBinary({ cacheDir, spawnFn = spawn, log = () => {} }) {
354
+ const hash = createHash('sha256').update(HELPER_SOURCE).digest('hex').slice(0, 12);
355
+ const binPath = join(cacheDir, `pb-hotkey-helper-${hash}`);
356
+ if (existsSync(binPath)) return binPath;
357
+ mkdirSync(cacheDir, { recursive: true });
358
+ const srcPath = `${binPath}.swift`;
359
+ // HELPER_SOURCE is written verbatim: its \n sequences are Swift string
360
+ // escapes and must reach the compiler untouched.
361
+ writeFileSync(srcPath, HELPER_SOURCE, 'utf8');
362
+ log('watch: compiling global-hotkey helper (one-time, up to ~30s, needs the Xcode Command Line Tools)…');
363
+ const result = await runProcess(spawnFn('swiftc', ['-O', srcPath, '-o', binPath], { stdio: ['ignore', 'pipe', 'pipe'] }), { timeoutMs: 180000 });
364
+ if (result.error || result.code !== 0) {
365
+ const detail = String(result.stderr || result.error?.message || '').trim().split('\n').slice(0, 5).join('\n');
366
+ const error = new Error(`failed to compile the hotkey helper (swiftc${result.timedOut ? ' timed out' : ` exited ${result.code ?? 'n/a'}`}). Is the Xcode Command Line Tools installed (xcode-select --install)?\n${detail}`);
367
+ error.code = 'hotkey_helper_compile_failed';
368
+ throw error;
369
+ }
370
+ log('watch: helper compiled');
371
+ return binPath;
372
+ }
373
+
374
+ /**
375
+ * Global hotkey via the Swift helper. Registration itself needs no permission;
376
+ * only the later ⌘C/⌘V keystrokes require Accessibility.
377
+ */
378
+ export function createSwiftHotkeySource({
379
+ hotkeySpec = DEFAULT_WATCH_OPTIONS.hotkey,
380
+ cacheDir = defaultCacheDir(),
381
+ spawnFn = spawn,
382
+ readyTimeoutMs = 30000,
383
+ log = () => {},
384
+ } = {}) {
385
+ let child = null;
386
+ let triggerCb = null;
387
+ let exitCb = null;
388
+ let stopped = false;
389
+
390
+ async function start() {
391
+ const { keyCode, modifiers, label } = parseHotkey(hotkeySpec);
392
+ const binPath = await ensureHelperBinary({ cacheDir, spawnFn, log });
393
+ child = spawnFn(binPath, [String(keyCode), String(modifiers)], { stdio: ['ignore', 'pipe', 'pipe'] });
394
+ child.stdout.setEncoding('utf8');
395
+ child.stderr.setEncoding('utf8');
396
+ const ready = new Promise((resolve, reject) => {
397
+ const timer = setTimeout(() => reject(new Error(`hotkey helper not READY within ${readyTimeoutMs}ms`)), readyTimeoutMs);
398
+ let readySeen = false;
399
+ const onLine = (line) => {
400
+ if (line === 'READY' && !readySeen) {
401
+ readySeen = true;
402
+ clearTimeout(timer);
403
+ resolve(label);
404
+ } else if (line === 'TRIGGER') {
405
+ triggerCb?.();
406
+ } else if (line) {
407
+ log(`watch helper: ${line}`);
408
+ }
409
+ };
410
+ createInterface({ input: child.stdout }).on('line', onLine);
411
+ createInterface({ input: child.stderr }).on('line', (line) => { if (line) log(`watch helper: ${line}`); });
412
+ child.on('error', (err) => { clearTimeout(timer); reject(err); });
413
+ child.on('close', (code) => {
414
+ clearTimeout(timer);
415
+ if (!readySeen) reject(new Error(`hotkey helper exited before READY (code ${code})`));
416
+ else exitCb?.({ code, clean: stopped });
417
+ });
418
+ });
419
+ return ready;
420
+ }
421
+
422
+ return {
423
+ start,
424
+ label: parseHotkey(hotkeySpec).label,
425
+ onTrigger: (cb) => { triggerCb = cb; },
426
+ onExit: (cb) => { exitCb = cb; },
427
+ async stop() {
428
+ stopped = true;
429
+ if (!child || child.exitCode !== null) return;
430
+ child.kill('SIGTERM');
431
+ await new Promise((resolve) => {
432
+ const timer = setTimeout(() => {
433
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
434
+ resolve();
435
+ }, 2000);
436
+ child.on('close', () => { clearTimeout(timer); resolve(); });
437
+ });
438
+ },
439
+ get running() { return !stopped && child && child.exitCode === null; },
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Portable fallback trigger (any platform): each Enter on stdin fires the
445
+ * cycle; the line "q" quits. Useful without a desktop session or for manual testing.
446
+ */
447
+ export function createStdinTriggerSource({ input = process.stdin, log = () => {} } = {}) {
448
+ let triggerCb = null;
449
+ let exitCb = null;
450
+ let rl = null;
451
+ return {
452
+ label: 'Enter',
453
+ onTrigger: (cb) => { triggerCb = cb; },
454
+ onExit: (cb) => { exitCb = cb; },
455
+ async start() {
456
+ rl = createInterface({ input });
457
+ rl.on('line', (line) => {
458
+ const trimmed = line.trim().toLowerCase();
459
+ if (trimmed === 'q') {
460
+ rl.close();
461
+ return;
462
+ }
463
+ triggerCb?.();
464
+ });
465
+ rl.on('close', () => exitCb?.({ code: 0, clean: true }));
466
+ return 'Enter';
467
+ },
468
+ async stop() {
469
+ rl?.close();
470
+ },
471
+ get running() { return Boolean(rl); },
472
+ log,
473
+ };
474
+ }
475
+
476
+ function buildProvider(cfg) {
477
+ return cfg.provider === 'ollama'
478
+ ? createOllamaProvider({ baseUrl: cfg.baseUrl })
479
+ : createOpenAIProvider({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey });
480
+ }
481
+
482
+ async function defaultNotify({ title, message }) {
483
+ const escaped = String(message).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
484
+ const escapedTitle = String(title).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
485
+ await runProcess(spawn('/usr/bin/osascript', ['-e', `display notification "${escaped}" with title "${escapedTitle}"`], { stdio: ['ignore', 'ignore', 'pipe'] }), { timeoutMs: 5000 });
486
+ }
487
+
488
+ /**
489
+ * `prompt-prompt-contract watch` entry point. Deps are injectable for tests; flags come from contract.js parseArgs.
490
+ */
491
+ export async function runWatch(flags = {}, deps = {}) {
492
+ const {
493
+ isMacOSPlatform = isMacOS,
494
+ adapter = null,
495
+ enhance = null,
496
+ source = null,
497
+ notify = defaultNotify,
498
+ log = (message) => process.stderr.write(`${message}\n`),
499
+ readFile = readFileAsync,
500
+ config = null,
501
+ registerSignals = true,
502
+ processRef = process,
503
+ } = deps;
504
+
505
+ if (!isMacOSPlatform) {
506
+ log('prompt-contract watch is macOS-only: it drives ⌘C/⌘V through System Events and needs the macOS clipboard. See docs/WATCH.md for the platform matrix.');
507
+ return 2;
508
+ }
509
+
510
+ const gate = await evaluateWatchGate({
511
+ force: Boolean(flags.force),
512
+ reportPath: flags.report,
513
+ readFile,
514
+ });
515
+ if (!gate.ok) {
516
+ log(`prompt-prompt-contract watch refused to start (decision D7 evidence gate): ${gate.reason}
517
+ Watch pastes over your selection, so it runs only with measured evidence. Either:
518
+ - run prompt-contract spike-0 --json --output ~/.cache/prompt-contract/spike-0.json first, then
519
+ prompt-contract watch --report ~/.cache/prompt-contract/spike-0.json
520
+ - or pass --force to accept the risk without evidence.`);
521
+ return 2;
522
+ }
523
+
524
+ // Fail fast on a bad hotkey before touching the clipboard or going resident.
525
+ const hotkeySpec = resolveWatchHotkey(flags, config ?? readUserConfig(flags.configPath));
526
+ try {
527
+ parseHotkey(hotkeySpec);
528
+ } catch (err) {
529
+ log(`prompt-prompt-contract watch: ${err.message}
530
+ Fix it with --hotkey <spec> or the "hotkey" field in ~/.prompt-contract/config.json (e.g. "hotkey": "ctrl+alt+b").`);
531
+ return 2;
532
+ }
533
+
534
+ const macAdapter = adapter ?? createMacOSAdapter();
535
+ const probe = await probeCaptureSafety(macAdapter);
536
+ if (!probe.ok) {
537
+ log(`prompt-prompt-contract watch startup probe failed: ${probe.error}`);
538
+ return 2;
539
+ }
540
+ for (const warning of probe.warnings) log(`watch: warning — ${warning}`);
541
+
542
+ // D8 low-latency charter: load config/profile and warm the provider once at
543
+ // startup; the hotkey path below is pure async with no further initialization.
544
+ // (Skipped when a test injects enhance directly.)
545
+ let enhanceFn = enhance;
546
+ let cfg = null;
547
+ if (!enhanceFn) {
548
+ const profile = loadProfile(flags.profile || 'coding-agent');
549
+ cfg = resolveConfig(flags);
550
+ const provider = buildProvider(cfg);
551
+ provider.warmup?.({ model: cfg.model });
552
+ enhanceFn = (text) => enhanceCore(text, {
553
+ profile,
554
+ provider,
555
+ model: cfg.model,
556
+ strength: flags.strength,
557
+ context: flags.context,
558
+ maxChars: flags.maxChars ? parseInt(flags.maxChars, 10) : undefined,
559
+ timeoutMs: flags.timeout ? parseInt(flags.timeout, 10) : undefined,
560
+ });
561
+ }
562
+
563
+ const triggerSource = source ?? (flags.trigger === 'stdin'
564
+ ? createStdinTriggerSource({ log })
565
+ : createSwiftHotkeySource({ hotkeySpec, log }));
566
+
567
+ const service = createWatchService({
568
+ adapter: macAdapter,
569
+ enhance: enhanceFn,
570
+ options: {
571
+ settleMs: flags.settleMs !== undefined ? parseInt(flags.settleMs, 10) : undefined,
572
+ pasteDelayMs: flags.pasteDelayMs !== undefined ? parseInt(flags.pasteDelayMs, 10) : undefined,
573
+ cooldownMs: flags.cooldownMs !== undefined ? parseInt(flags.cooldownMs, 10) : undefined,
574
+ dryRun: Boolean(flags.dryRun),
575
+ },
576
+ notify,
577
+ log,
578
+ });
579
+
580
+ let stopReason = null;
581
+ const stopped = new Promise((resolve) => {
582
+ triggerSource.onTrigger(() => { service.handleTrigger(); });
583
+ // Sources classify their own exit: { clean: true } for a shutdown we (or
584
+ // the user via `q`) initiated, { clean: false } for a crash.
585
+ triggerSource.onExit((info) => {
586
+ if (info?.clean === false) stopReason = stopReason ?? `trigger source exited (code ${info?.code ?? '?'})`;
587
+ resolve();
588
+ });
589
+ if (registerSignals) {
590
+ const shutdown = () => { stopReason = stopReason ?? 'interrupted'; resolve(); };
591
+ processRef.once?.('SIGINT', shutdown);
592
+ processRef.once?.('SIGTERM', shutdown);
593
+ }
594
+ });
595
+
596
+ const label = await triggerSource.start();
597
+ const profileName = flags.profile || 'coding-agent';
598
+ const providerLabel = cfg ? `${cfg.provider} model=${cfg.model}` : 'injected enhance fn';
599
+ const suffix = flags.dryRun ? ' · DRY-RUN (never pastes)' : '';
600
+ const usingStdin = !source && flags.trigger === 'stdin';
601
+ const triggerDesc = usingStdin ? label : `${label} [${hotkeySpec}]`;
602
+ log(`prompt-prompt-contract watch resident — ${triggerDesc} enhances the selection · profile=${profileName} provider=${providerLabel}${suffix}
603
+ Ctrl+C to quit. Paste replaces the selected text; the clipboard is restored afterwards. See docs/WATCH.md.`);
604
+
605
+ await stopped;
606
+ await triggerSource.stop();
607
+ log(`watch: stopped${stopReason ? ` — ${stopReason}` : ''}`);
608
+ return stopReason && /exited/.test(stopReason) ? 1 : 0;
609
+ }
@@ -11,6 +11,28 @@ const QUOTE_PAIRS = [
11
11
  ['\u300c', '\u300d'] // 「 」
12
12
  ];
13
13
 
14
+ /**
15
+ * Strip reasoning-model blocks (<think>/<thinking>/<reasoning>/<thought>) so chain-of-thought
16
+ * from models like DeepSeek-R1, Qwen3-thinking, or Hermes never reaches a user surface
17
+ * (critical for prompt-prompt-contract watch: the cleaned text is pasted into the user's document).
18
+ * A reasoning tag opened but never closed is cut to end-of-text (truncated streams).
19
+ */
20
+ const REASONING_TAGS = ['think', 'thinking', 'reasoning', 'thought'];
21
+
22
+ export function stripReasoningBlocks(t) {
23
+ let prev;
24
+ do {
25
+ prev = t;
26
+ for (const tag of REASONING_TAGS) {
27
+ const tagPattern = new RegExp(`<${tag}>[\\s\\S]*?</${tag}>`, 'gi');
28
+ t = t.replace(tagPattern, '');
29
+ const openPattern = new RegExp(`<${tag}>[\\s\\S]*$`, 'i');
30
+ t = t.replace(openPattern, '');
31
+ }
32
+ } while (t !== prev);
33
+ return t;
34
+ }
35
+
14
36
  /** Remove wrapping quote pairs, repeatedly (WorkBuddy stripWrappingQuotes, generalized). */
15
37
  export function stripWrappingQuotes(t) {
16
38
  let prev;
@@ -50,6 +72,7 @@ export function clampChars(t, maxChars) {
50
72
 
51
73
  export function postprocess(raw, maxChars) {
52
74
  let t = String(raw ?? '');
75
+ t = stripReasoningBlocks(t);
53
76
  t = stripFences(t);
54
77
  t = stripWrappingQuotes(t);
55
78
  t = t.trim();