prompt-contract 0.2.1 → 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.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  English · [简体中文](README.zh-CN.md)
9
9
 
10
- ![PromptContract — a vague one-line request becomes a structured task spec (goal, scope, acceptance criteria, non-goals), verified by six hard rules via `prompt-contract check`. Real-LLM latency P50 2.3s; engine adds 0.005ms. CLI · MCP · Playground — zero dependencies, BYOK, offline-capable.](docs/assets/hero-cover.png)
10
+ ![PromptContract — a vague one-line request becomes a structured task spec (goal, scope, acceptance criteria, non-goals), verified by six hard rules via `prompt-prompt-contract check`. Real-LLM latency P50 2.3s; engine adds 0.005ms. CLI · MCP · Playground — zero dependencies, BYOK, offline-capable.](docs/assets/hero-cover.png)
11
11
 
12
12
  **PromptContract is a deterministic prompt-contract layer for AI coding agents.** It compiles a vague one-line request into a structured task specification — goal, scope, constraints, acceptance criteria — and verifies the result against six hard rules before you ever see it.
13
13
 
@@ -16,7 +16,7 @@ It is *not* a smarter brain: your model does the thinking, PromptContract makes
16
16
  ## Why PromptContract
17
17
 
18
18
  - **Stable agent inputs** — a profile plus hard constraints turn "a website for my dog" into goal / scope / acceptance criteria / explicit non-goals. Reduced retries and scope drift are hypotheses measured by the task-level harness, not current product claims.
19
- - **Six deterministic guardrails** — language consistency, enhanced-text-only, length & completeness, expand-don't-answer, no hallucinated tech. Every enhancement can be asserted with `prompt-contract check`; the same spec drives templates and tests.
19
+ - **Six deterministic guardrails** — language consistency, enhanced-text-only, length & completeness, expand-don't-answer, no hallucinated tech. Every enhancement can be asserted with `prompt-prompt-contract check`; the same spec drives templates and tests.
20
20
  - **One engine, three surfaces** — a CLI, an MCP server (agent-invoked **tool** + user-invoked **slash prompts**), and a browser playground. All share one zero-dependency core.
21
21
  - **Private by architecture** — bring your own key, no server in the middle, no telemetry, offline-capable via Ollama.
22
22
 
@@ -30,16 +30,21 @@ node packages/cli/bin/contract.js "帮我做一个展示我家狗的网站" \
30
30
  node packages/playground/serve.js # → http://127.0.0.1:8123/ (or ?demo=1 for the self-running demo)
31
31
  ```
32
32
 
33
- Install: `npm i -g prompt-contract` — or zero-install: `npx prompt-contract "your vague idea"`.
33
+ Install: `npm i -g prompt-contract` — or zero-install: `npx prompt-prompt-prompt-contract "your vague idea"`.
34
34
 
35
35
  ## Your real model
36
36
 
37
37
  ```bash
38
- export CONTRACT_API_KEY=sk-xxx CONTRACT_MODEL=gpt-4o-mini # any OpenAI-compatible endpoint (DeepSeek, Qwen, GLM, vLLM…)
39
- prompt-contract "A website for my dog"
38
+ export CONTRACT_API_KEY=sk-xxx CONTRACT_MODEL=gpt-4o-mini # any OpenAI-compatible endpoint (vLLM, OpenRouter, your gateway…)
39
+ prompt-prompt-contract "A website for my dog"
40
+
41
+ export CONTRACT_PROVIDER=deepseek CONTRACT_API_KEY=sk-… # vendor presets carry the base URL + a suggested model
42
+ export CONTRACT_PROVIDER=qwen # also: glm, moonshot, groq, openrouter, lmstudio (keyless)
43
+ export CONTRACT_PROVIDER=anthropic CONTRACT_API_KEY=sk-ant-… # native Messages API — thinking deltas dropped in transport
44
+ prompt-prompt-contract "帮我写一封请假邮件"
40
45
 
41
46
  export CONTRACT_PROVIDER=ollama CONTRACT_MODEL=qwen3:4b # fully local/offline; keep_alive pins the model in RAM
42
- prompt-contract "帮我写一封请假邮件"
47
+ prompt-prompt-prompt-contract "帮我写一封请假邮件"
43
48
  ```
44
49
 
45
50
  Or write `~/.prompt-contract/config.json` once: `{ "provider": "openai", "baseUrl": "…", "apiKey": "…", "model": "…" }`.
@@ -68,7 +73,7 @@ Community profiles are the main contribution surface — a PR adding `profiles/<
68
73
  ## Quality gates — and their honest limits
69
74
 
70
75
  ```bash
71
- npm test # 60 tests: engine units + SSE/ndjson streaming + CLI/MCP e2e against a local mock
76
+ npm test # 102 tests (serial): engine units + SSE/ndjson streaming + CLI/MCP e2e against a local mock
72
77
  npm run eval # 9 deterministic cases over the six hard-constraint assertions
73
78
  npm run eval:tasks -- --format-only # validate the coding-agent task fixture format gate
74
79
  npm run bench # engine overhead: P50 ≈ 0.005ms (budget < 5ms)
@@ -81,20 +86,20 @@ npm run bench # engine overhead: P50 ≈ 0.005ms (budget < 5ms)
81
86
  ```
82
87
  raw input → script/scenario detect → profile + hard constraints + strength (+ optional context)
83
88
  → single streaming LLM call (small fast model by default; the model does the thinking)
84
- → deterministic cleaning (quotes/fences/length clamp, empty → llm_error)
89
+ → deterministic cleaning (reasoning-block strip, quotes/fences/length clamp, empty → llm_error)
85
90
  → { enhanced, original, meta } — original always preserved, one-key revert in every surface
86
91
  ```
87
92
 
88
93
  ## Status & roadmap — stated honestly
89
94
 
90
- - **Shipped:** engine, CLI (`prompt-contract` / `check` / `doctor` / `profiles` / `spike-0`), MCP server (tool + zero-key prompts), playground, 3 profiles, eval cases, CI matrix.
91
- - **Gated:** `prompt-contract watch` (global-hotkey resident mode) remains intentionally unavailable. `prompt-contract spike-0` measures macOS capture/clipboard safety and dry-run focus eligibility, but never pastes or unlocks watch by itself; see [docs/SPIKE-0.md](docs/SPIKE-0.md).
95
+ - **Shipped:** engine, CLI (`prompt-contract` / `check` / `doctor` / `profiles` / `spike-0` / `watch`), MCP server (tool + zero-key prompts), playground, 3 profiles, eval cases, CI matrix.
96
+ - **Resident mode:** `prompt-prompt-contract watch` — select text anywhere on macOS, press ⌥B, and the enhanced prompt replaces your selection: clipboard backed up and restored, focus re-validated before pasting, gated by `prompt-prompt-contract spike-0` evidence (decision D7). See [docs/WATCH.md](docs/WATCH.md). `prompt-prompt-contract spike-0` itself remains a dry-run diagnostic and never pastes; see [docs/SPIKE-0.md](docs/SPIKE-0.md).
92
97
  - **Open validation:** task-level outcome evaluation remains an evidence-gathering task. The harness and curated fixture set exist, but PromptContract's downstream effectiveness is still a hypothesis until a declared runner produces reviewed results.
93
98
  - Deferred: animated demo asset, IDE plugins, LLM-as-judge as *one* scorer inside the task-level eval.
94
99
 
95
100
  ## Layout
96
101
 
97
- `packages/core` (engine, browser-safe, zero deps) · `packages/providers` (OpenAI-compatible SSE + Ollama `keep_alive`) · `packages/cli` · `packages/mcp-server` · `packages/playground` · `profiles/` · `eval/` · `mock/` · `docs/ACCEPTANCE.md` · `docs/SPIKE-0.md` (requirements → implementation → acceptance + evidence boundaries)
102
+ docs/ACCEPTANCE.md` · `docs/SPIKE-0.md` · `docs/WATCH.md` (requirements → implementation → acceptance + evidence boundaries)
98
103
 
99
104
  ## Contributing & License
100
105
 
package/README.zh-CN.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  [English](README.md) · 简体中文
8
8
 
9
- ![PromptContract——模糊的一句话被编译成结构化任务规范(目标 / 范围 / 验收标准 / 明确不做的事),六条硬规则经 `contract check` 校验。真实 LLM 端到端 P50 2.3s,引擎附加开销 0.005ms。CLI · MCP · Playground——零依赖、BYOK、可离线。](docs/assets/hero-cover.png)
9
+ ![PromptContract——模糊的一句话被编译成结构化任务规范(目标 / 范围 / 验收标准 / 明确不做的事),六条硬规则经 `prompt-prompt-contract check` 校验。真实 LLM 端到端 P50 2.3s,引擎附加开销 0.005ms。CLI · MCP · Playground——零依赖、BYOK、可离线。](docs/assets/hero-cover.png)
10
10
 
11
11
  **PromptContract 是面向 AI 编码 agent 的确定性 prompt 契约层**:把模糊的一句话编译成结构化任务规范(目标 / 范围 / 约束 / 验收标准),并在你看到结果之前用六条硬规则完成校验。
12
12
 
@@ -15,7 +15,7 @@
15
15
  ## 为什么
16
16
 
17
17
  - **稳定的 agent 输入**——profile + 硬约束把「帮我做个网站」编译成目标 / 范围 / 验收标准 / 明确不做的事;重试与范围漂移是否减少,仍须由任务级 harness 验证,当前不作产品结论
18
- - **六条确定性护栏**——语言一致性、只输出增强文本、长度与完整性、扩写而非回答、无幻觉技术栈;`prompt-contract check` 随时可断言,模板与测试共用同一份规格
18
+ - **六条确定性护栏**——语言一致性、只输出增强文本、长度与完整性、扩写而非回答、无幻觉技术栈;`prompt-prompt-contract check` 随时可断言,模板与测试共用同一份规格
19
19
  - **一个引擎、三个形态**——CLI、MCP server(agent 调用的 tool + 用户调用的斜杠 prompts)、浏览器 Playground,共享同一个零依赖内核
20
20
  - **隐私即架构**——BYOK、无中间服务、零遥测,Ollama 全本地可用
21
21
 
@@ -29,16 +29,21 @@ node packages/cli/bin/contract.js "帮我做一个展示我家狗的网站" \
29
29
  node packages/playground/serve.js # → http://127.0.0.1:8123/(?demo=1 为自运行演示)
30
30
  ```
31
31
 
32
- 安装:`npm i -g prompt-contract`;或零安装体验:`npx prompt-contract "你的模糊想法"`。
32
+ 安装:`npm i -g prompt-contract`;或零安装体验:`npx prompt-prompt-prompt-contract "你的模糊想法"`。
33
33
 
34
34
  ## 接入真实模型
35
35
 
36
36
  ```bash
37
- export CONTRACT_API_KEY=sk-xxx CONTRACT_MODEL=gpt-4o-mini # 任意 OpenAI 兼容端点(DeepSeek/Qwen/GLM/vLLM…)
38
- prompt-contract "A website for my dog"
37
+ export CONTRACT_API_KEY=sk-xxx CONTRACT_MODEL=gpt-4o-mini # 任意 OpenAI 兼容端点(vLLM、OpenRouter、自家网关…)
38
+ prompt-prompt-contract "A website for my dog"
39
+
40
+ export CONTRACT_PROVIDER=deepseek CONTRACT_API_KEY=sk-… # 厂商预设:自带 base URL 与建议默认模型
41
+ export CONTRACT_PROVIDER=qwen # 另有 glm、moonshot、groq、openrouter、lmstudio(免 key)
42
+ export CONTRACT_PROVIDER=anthropic CONTRACT_API_KEY=sk-ant-… # Anthropic 原生 Messages API——思考增量在传输层即被丢弃
43
+ prompt-prompt-contract "帮我写一封请假邮件"
39
44
 
40
45
  export CONTRACT_PROVIDER=ollama CONTRACT_MODEL=qwen3:4b # 全本地;keep_alive 把模型钉在内存
41
- prompt-contract "帮我写一封请假邮件"
46
+ prompt-prompt-prompt-contract "帮我写一封请假邮件"
42
47
  ```
43
48
 
44
49
  或一次写入 `~/.prompt-contract/config.json`。
@@ -53,7 +58,7 @@ prompt-contract "帮我写一封请假邮件"
53
58
  ## 质量门禁与诚实边界
54
59
 
55
60
  ```bash
56
- npm test # 60 项:引擎单测 + SSE/ndjson 流式 + CLI/MCP 端到端(对本地 mock)
61
+ npm test # 102 项(串行):引擎单测 + SSE/ndjson 流式 + CLI/MCP 端到端(对本地 mock)
57
62
  npm run eval # 9 个确定性用例(六条硬约束断言)
58
63
  npm run eval:tasks -- --format-only # 校验 coding-agent 任务 fixture 的格式门禁
59
64
  npm run bench # 引擎自身开销 P50 ≈ 0.005ms(预算 <5ms)
@@ -63,8 +68,8 @@ npm run bench # 引擎自身开销 P50 ≈ 0.005ms(预算 <5ms)
63
68
 
64
69
  ## 状态与路线图(诚实版)
65
70
 
66
- - **已交付**:引擎、CLI(含 `spike-0`)、MCP server(tool + 零 key prompts)、Playground、3 个 profiles、eval 用例、CI 矩阵
67
- - **被门控**:`prompt-contract watch`(全局热键常驻)仍不可用。`prompt-contract spike-0` 只测 macOS 取词、剪贴板恢复和焦点校验的 dry-run,不发送粘贴,也不会自行解锁 watch;见 [docs/SPIKE-0.md](docs/SPIKE-0.md)
71
+ - **已交付**:引擎、CLI(含 `prompt-prompt-contract spike-0`、`prompt-prompt-contract watch` 常驻模式)、MCP server(tool + 零 key prompts)、Playground、3 个 profiles、eval 用例、CI 矩阵
72
+ - **常驻模式**:`prompt-prompt-contract watch`——在 macOS 任意应用选中一段粗糙 prompt,按 ⌥B,增强结果原地替换选区:剪贴板先备份后恢复、回贴前焦点复验(漂移即放弃)、以 `prompt-prompt-contract spike-0` 证据为启动门控(决策 D7);详见 [docs/WATCH.md](docs/WATCH.md)。`prompt-prompt-contract spike-0` 本身仍是 dry-run 诊断,永不发送粘贴;见 [docs/SPIKE-0.md](docs/SPIKE-0.md)
68
73
  - **开放验证**:任务级效果评测的 harness 与任务 fixture 已交付,但还没有声明 runner 产生并复核结果;长期价值在此之前仍是假设
69
74
  - **推迟**:动画 demo 资产、IDE 插件、LLM-as-judge(作为任务级评测中的评分器之一)
70
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prompt-contract",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -2,11 +2,13 @@
2
2
  /**
3
3
  * contract — PromptContract CLI (PRD §4 P0). Physical file kept as contract.js for path stability.
4
4
  * One-shot enhance (arg or stdin), profiles, check (rule assertions), doctor.
5
- * `contract watch` is intentionally gated by decision D7 (Spike-0 first) — see docs/SPIKE-0.md.
5
+ * One-shot enhance (arg or stdin), profiles, check (rule assertions), doctor,
6
+ * spike-0 (capture diagnostic), watch (resident hotkey mode — see docs/WATCH.md).
6
7
  */
7
8
  import { enhance, checkRules, PromptContractError, normalizeError } from '../../core/src/index.js';
8
9
  import { loadProfiles, loadProfile, resolveConfig } from '../../core/src/node.js';
9
10
  import { createOpenAIProvider } from '../../providers/src/openai.js';
11
+ import { createAnthropicProvider } from '../../providers/src/anthropic.js';
10
12
  import { createOllamaProvider } from '../../providers/src/ollama.js';
11
13
  import { readFileSync, existsSync, writeFileSync } from 'node:fs';
12
14
  import { basename } from 'node:path';
@@ -18,30 +20,32 @@ import {
18
20
  resolveSpikeTargets,
19
21
  runSpike0,
20
22
  } from '../src/spike-0.js';
23
+ import { runWatch } from '../src/watch.js';
21
24
 
22
- // `contract` was the command name before the prompt-contract rename; warn while the alias ships.
25
+ // `prompt-contract` was the command name before the prompt-contract rename; warn while the alias ships.
23
26
  if (basename(process.argv[1] || '') === 'contract') {
24
- process.stderr.write('[deprecated] this CLI is now `contract` (prompt-contract); the `contract` command will be removed in a future release.\n');
27
+ process.stderr.write('[deprecated] this CLI is now `prompt-contract` (prompt-contract); the `prompt-contract` command will be removed in a future release.\n');
25
28
  }
26
29
 
27
- const VERSION = '0.2.1';
30
+ const VERSION = '0.3.0';
28
31
  const USAGE = `prompt-contract — one-key prompt enhancement (PromptContract v${VERSION})
29
32
 
30
33
  Usage:
31
- prompt-contract "build me a website for my dog" enhance a prompt (prints enhanced text to stdout)
34
+ prompt-prompt-prompt-contract "build me a website for my dog" enhance a prompt (prints enhanced text to stdout)
32
35
  cat prompt.txt | prompt-contract enhance from stdin
33
- prompt-contract profiles list built-in profiles
34
- prompt-contract check --original "..." --enhanced "..."
36
+ prompt-prompt-contract profiles list built-in profiles
37
+ prompt-prompt-prompt-contract check --original "..." --enhanced "..."
35
38
  run the six hard-constraint rule assertions
36
- prompt-contract doctor verify config, provider reachability, profiles
37
- prompt-contract spike-0 macOS-only capture/restore compatibility diagnostic (dry-run)
38
- prompt-contract watch NOT BUILT gated by decision D7 (Spike-0 first); see docs/SPIKE-0.md
39
+ prompt-prompt-contract doctor verify config, provider reachability, profiles
40
+ prompt-prompt-prompt-contract spike-0 macOS-only capture/restore compatibility diagnostic (dry-run)
41
+ prompt-prompt-prompt-contract watch resident mode: select text hotkey enhanced text replaces it (macOS; docs/WATCH.md)
39
42
 
40
43
  Options:
41
44
  -p, --profile <name> scenario profile (default: coding-agent)
42
45
  -s, --strength <mode> polish | standard (default) | expand
43
46
  -m, --model <model> model override
44
- --provider <name> openai (default) | ollama
47
+ --provider <name> openai (default) | anthropic | ollama | vendor presets:
48
+ deepseek qwen glm moonshot groq openrouter lmstudio
45
49
  --base-url <url> OpenAI-compatible base URL (or CONTRACT_BASE_URL)
46
50
  --api-key <key> API key (or CONTRACT_API_KEY; local ollama needs none)
47
51
  --context <text> background context to assemble into the prompt
@@ -54,19 +58,30 @@ Options:
54
58
  --setup-delay-ms <ms> Spike-0 delay before each target capture
55
59
  --no-prompt Spike-0 do not wait for target/app setup
56
60
  --output <path> write Spike-0 JSON report to a file
61
+ --hotkey <spec> watch trigger combo: ctrl=⌃ alt/option=⌥ cmd=⌘ shift=⇧ + key,
62
+ e.g. "ctrl+alt+b" = hold ⌃⌥ and press B (default alt+b; persist via "hotkey"
63
+ in ~/.prompt-contract/config.json)
64
+ --trigger <name> watch trigger source: hotkey (default) | stdin (each Enter = trigger, q quits)
65
+ --report <path> Spike-0 JSON report satisfying the watch evidence gate (D7)
66
+ --force run watch without Spike-0 evidence (at your own risk)
67
+ --dry-run watch: capture + enhance but never paste
68
+ --paste-delay-ms <ms> watch: wait between ⌘V and clipboard restore (default: 1000)
69
+ --cooldown-ms <ms> watch: minimum gap between cycles (default: 800)
57
70
  --json machine-readable output {original, enhanced, meta, rules}
58
71
  --no-stream buffer instead of streaming progress
59
72
  -h, --help show this help`;
60
73
 
61
74
  function parseArgs(argv) {
62
75
  const flags = { _: [] };
63
- const needsValue = new Set(['--profile', '-p', '--strength', '-s', '--model', '-m', '--provider', '--base-url', '--api-key', '--context', '--max-chars', '--timeout', '--app', '--iterations', '--settle-ms', '--pause-ms', '--setup-delay-ms', '--output', '--original', '--enhanced']);
76
+ const needsValue = new Set(['--profile', '-p', '--strength', '-s', '--model', '-m', '--provider', '--base-url', '--api-key', '--context', '--max-chars', '--timeout', '--app', '--iterations', '--settle-ms', '--pause-ms', '--setup-delay-ms', '--output', '--original', '--enhanced', '--hotkey', '--trigger', '--report', '--paste-delay-ms', '--cooldown-ms']);
64
77
  const camel = (k) => k.replace(/^--?/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase());
65
78
  for (let i = 0; i < argv.length; i++) {
66
79
  const a = argv[i];
67
80
  if (a === '--json') flags.json = true;
68
81
  else if (a === '--no-stream') flags.noStream = true;
69
82
  else if (a === '--no-prompt') flags.noPrompt = true;
83
+ else if (a === '--dry-run') flags.dryRun = true;
84
+ else if (a === '--force') flags.force = true;
70
85
  else if (a === '--help' || a === '-h') flags.help = true;
71
86
  else if (a === '--version') flags.version = true;
72
87
  else if (needsValue.has(a)) flags[camel(a)] = argv[++i];
@@ -77,9 +92,9 @@ function parseArgs(argv) {
77
92
  }
78
93
 
79
94
  function buildProvider(cfg) {
80
- return cfg.provider === 'ollama'
81
- ? createOllamaProvider({ baseUrl: cfg.baseUrl })
82
- : createOpenAIProvider({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey });
95
+ if (cfg.provider === 'ollama') return createOllamaProvider({ baseUrl: cfg.baseUrl });
96
+ if (cfg.provider === 'anthropic') return createAnthropicProvider({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey });
97
+ return createOpenAIProvider({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey });
83
98
  }
84
99
 
85
100
  function makeProvider(cfg, { warm = false } = {}) {
@@ -133,7 +148,7 @@ async function cmdEnhance(flags) {
133
148
  if (!flags.noStream) {
134
149
  process.stderr.write(`\n— ${res.meta.profile} · ${res.meta.model} · ${res.meta.ms}ms · ${res.meta.chars} chars\n`);
135
150
  if (!rules.pass) {
136
- process.stderr.write('rule assertions (advisory — run `contract check` for gate mode):\n');
151
+ process.stderr.write('rule assertions (advisory — run `prompt-prompt-prompt-contract check` for gate mode):\n');
137
152
  for (const r of rules.results.filter((r) => !r.pass)) {
138
153
  process.stderr.write(` [FAIL] ${r.title}${r.detail ? ` — ${r.detail}` : ''}\n`);
139
154
  }
@@ -168,7 +183,7 @@ function cmdCheck(flags) {
168
183
  }
169
184
  }
170
185
  if (original === undefined || enhanced === undefined) {
171
- process.stderr.write('contract check requires --original and --enhanced (or a JSON {original, enhanced} line on stdin)\n');
186
+ process.stderr.write('prompt-prompt-contract check requires --original and --enhanced (or a JSON {original, enhanced} line on stdin)\n');
172
187
  return 2;
173
188
  }
174
189
  const rules = checkRules(original, enhanced, { maxChars: flags.maxChars ? parseInt(flags.maxChars, 10) : 800 });
@@ -204,7 +219,7 @@ async function cmdDoctor(flags) {
204
219
 
205
220
  async function cmdSpike0(flags) {
206
221
  if (!isMacOS) {
207
- process.stderr.write('contract spike-0 is macOS-only: requires pbpaste, pbcopy, and Accessibility-backed System Events.\n');
222
+ process.stderr.write('prompt-prompt-contract spike-0 is macOS-only: requires pbpaste, pbcopy, and Accessibility-backed System Events.\n');
208
223
  return 2;
209
224
  }
210
225
 
@@ -216,7 +231,7 @@ async function cmdSpike0(flags) {
216
231
  const pauseMs = flags.pauseMs === undefined ? 0 : Number.parseInt(flags.pauseMs, 10);
217
232
  const setupDelayMs = flags.setupDelayMs === undefined ? 0 : Number.parseInt(flags.setupDelayMs, 10);
218
233
  if (!Number.isInteger(iterations) || iterations < 1 || !Number.isInteger(settleMs) || settleMs < 0 || !Number.isInteger(pauseMs) || pauseMs < 0 || !Number.isInteger(setupDelayMs) || setupDelayMs < 0) {
219
- process.stderr.write('contract spike-0 requires non-negative integer --settle-ms/--pause-ms/--setup-delay-ms and positive integer --iterations\n');
234
+ process.stderr.write('prompt-prompt-contract spike-0 requires non-negative integer --settle-ms/--pause-ms/--setup-delay-ms and positive integer --iterations\n');
220
235
  return 2;
221
236
  }
222
237
 
@@ -237,6 +252,10 @@ async function cmdSpike0(flags) {
237
252
  return report.decision.pass ? 0 : 1;
238
253
  }
239
254
 
255
+ async function cmdWatch(flags) {
256
+ return runWatch(flags);
257
+ }
258
+
240
259
  async function main() {
241
260
  const flags = parseArgs(process.argv.slice(2));
242
261
  if (flags.help) { process.stdout.write(USAGE + '\n'); return 0; }
@@ -249,9 +268,7 @@ async function main() {
249
268
  case 'check': return cmdCheck(flags);
250
269
  case 'doctor': return await cmdDoctor(flags);
251
270
  case 'spike-0': return await cmdSpike0(flags);
252
- case 'watch':
253
- process.stderr.write('contract watch is gated by decision D7: run `contract spike-0` and review its evidence before implementing watch.\nThe watch implementation remains intentionally unavailable in this Spike-0-only change.\n');
254
- return 2;
271
+ case 'watch': return await cmdWatch(flags);
255
272
  default:
256
273
  // treat unknown first word as prompt text
257
274
  flags._.unshift(cmd);
@@ -36,6 +36,9 @@ export const APP_TARGETS = Object.freeze({
36
36
  });
37
37
 
38
38
  const COPY_SCRIPT = 'tell application "System Events" to keystroke "c" using {command down}';
39
+ // Used only by prompt-contract watch. The Spike-0 diagnostic itself never issues ⌘V; its
40
+ // safety contract is enforced in captureSelectedText/validatePasteBackDryRun.
41
+ const PASTE_SCRIPT = 'tell application "System Events" to keystroke "v" using {command down}';
39
42
 
40
43
  // The report deliberately excludes AXValue: it can contain the user's prompt or
41
44
  // other sensitive text. These attributes are enough to conservatively detect a
@@ -212,6 +215,12 @@ export function createMacOSAdapter({ run = runCommand } = {}) {
212
215
  if (result.code !== 0) throw commandError('osascript-copy', result);
213
216
  },
214
217
 
218
+ async pasteSelection() {
219
+ const result = await run('/usr/bin/osascript', ['-e', PASTE_SCRIPT]);
220
+ if (result.error) throw result.error;
221
+ if (result.code !== 0) throw commandError('osascript-paste', result);
222
+ },
223
+
215
224
  async getFocusIdentity() {
216
225
  const result = await run('/usr/bin/osascript', ['-e', CONTEXT_SCRIPT], { timeoutMs: 5000 });
217
226
  if (result.error) throw result.error;
@@ -275,7 +284,7 @@ function fingerprintForContext(context) {
275
284
  return createHash('sha256').update(fields.join('\u001f')).digest('hex').slice(0, 16);
276
285
  }
277
286
 
278
- function sameFocusIdentity(left, right) {
287
+ export function sameFocusIdentity(left, right) {
279
288
  if (!left || !right) return false;
280
289
  const appStable = normalized(left.processName) === normalized(right.processName)
281
290
  && normalized(left.bundleId) === normalized(right.bundleId)
@@ -309,8 +318,17 @@ export async function captureSelectedText(adapter, { settleMs = 75 } = {}) {
309
318
  clipboardUntouched = false;
310
319
  await adapter.copySelection();
311
320
  await adapter.sleep(settleMs);
312
- const copiedText = await adapter.readClipboard();
313
- selectedText = typeof copiedText === 'string' && copiedText.trim() ? copiedText : null;
321
+ let copiedText = await adapter.readClipboard();
322
+ // ⌘C is delivered asynchronously: a read that still shows the pre-copy
323
+ // clipboard means the copy has not landed — poll briefly instead of
324
+ // mistaking the stale clipboard for the selection. A read identical to the
325
+ // pre-copy clipboard after polling reports an empty selection (the
326
+ // clipboard fallback then covers the intentional selection==clipboard case).
327
+ for (let polls = 0; copiedText === originalClipboard && polls < 6; polls++) {
328
+ await adapter.sleep(60);
329
+ copiedText = await adapter.readClipboard();
330
+ }
331
+ selectedText = typeof copiedText === 'string' && copiedText.trim() && copiedText !== originalClipboard ? copiedText : null;
314
332
  contextAfterCapture = await adapter.getFocusIdentity();
315
333
  } catch (captureError) {
316
334
  error = captureError;
@@ -457,7 +475,7 @@ export function buildCompatibilityReport({
457
475
  pass: reasons.length === 0,
458
476
  reasons,
459
477
  watchGate: 'closed',
460
- note: 'A dry-run cannot prove actual paste landing; contract watch remains gated until a separate implementation decision.',
478
+ note: 'A dry-run cannot prove actual paste landing; this report is evidence for prompt-contract watch, never an authorization by itself.',
461
479
  },
462
480
  };
463
481
  }
@@ -575,7 +593,7 @@ export function formatSpike0Summary(report) {
575
593
  `capture ${summary.captureSuccesses}/${summary.attempts} (${summary.captureSuccessRate})`,
576
594
  `clipboard restore ${summary.clipboardRestoreSuccesses}/${summary.attempts} (${summary.clipboardRestoreSuccessRate})`,
577
595
  `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',
596
+ report.decision.reasons.length ? `reasons: ${report.decision.reasons.join('; ')}` : 'thresholds met; evidence usable by prompt-contract watch',
579
597
  ].join('\n');
580
598
  }
581
599
 
@@ -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();
@@ -55,24 +55,48 @@ export function loadProfile(name, explicitDir) {
55
55
  }
56
56
 
57
57
  /**
58
- * Config resolution order: explicit flags > env (PB_*) > config file (CONTRACT_CONFIG or ~/.prompt-contract/config.json).
58
+ * Vendor presets convenience sugar over the OpenAI-compatible protocol (openai.js already
59
+ * speaks it): `"provider": "deepseek"` resolves the known base URL and a suggested default
60
+ * small/fast model. Explicit flags/env/config always win over preset values. Model ids are
61
+ * best-effort defaults maintained per vendor naming and can always be overridden with
62
+ * CONTRACT_MODEL/--model; presets deliberately stay a data table, not an SDK dependency.
63
+ */
64
+ export const PROVIDER_PRESETS = Object.freeze({
65
+ deepseek: { baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat' },
66
+ qwen: { baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus' },
67
+ glm: { baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-4-flash' },
68
+ moonshot: { baseUrl: 'https://api.moonshot.cn/v1', model: 'kimi-k2-turbo-preview' },
69
+ groq: { baseUrl: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile' },
70
+ openrouter: { baseUrl: 'https://openrouter.ai/api/v1', model: 'openai/gpt-4o-mini' },
71
+ lmstudio: { baseUrl: 'http://127.0.0.1:1234/v1', keyless: true },
72
+ anthropic: { baseUrl: 'https://api.anthropic.com', model: 'claude-haiku-4-5' },
73
+ });
74
+
75
+ /**
76
+ * Config resolution order: explicit flags > env (CONTRACT_*) > config file (CONTRACT_CONFIG or ~/.prompt-contract/config.json).
59
77
  * Defaults follow PRD §3.2: local Ollama if nothing else is configured (privacy-first).
60
78
  * `flags.configPath` / `CONTRACT_CONFIG` exist so tests and embedded shells can isolate the file source.
61
79
  */
62
- export function resolveConfig(flags = {}) {
63
- const cfgPath = flags.configPath ?? process.env.CONTRACT_CONFIG ?? join(homedir(), '.prompt-contract', 'config.json');
80
+ export function readUserConfig(configPath) {
81
+ const cfgPath = configPath ?? process.env.CONTRACT_CONFIG ?? join(homedir(), '.prompt-contract', 'config.json');
64
82
  let file = {};
65
83
  try {
66
84
  if (existsSync(cfgPath)) file = JSON.parse(readFileSync(cfgPath, 'utf8'));
67
85
  } catch (err) {
68
86
  throw new PromptContractError(CODES.CONFIG, `invalid config at ${cfgPath}: ${err.message}`);
69
87
  }
88
+ return file;
89
+ }
90
+
91
+ export function resolveConfig(flags = {}) {
92
+ const file = readUserConfig(flags.configPath);
70
93
  const pick = (...sources) => { for (const s of sources) if (s !== undefined && s !== null && s !== '') return s; return undefined; };
71
94
 
72
95
  const provider = pick(flags.provider, process.env.CONTRACT_PROVIDER, file.provider, guessProvider(flags.baseUrl ?? process.env.CONTRACT_BASE_URL ?? file.baseUrl), 'openai');
73
- const baseUrl = String(pick(flags.baseUrl, process.env.CONTRACT_BASE_URL, file.baseUrl, provider === 'ollama' ? 'http://localhost:11434' : 'https://api.openai.com/v1')).replace(/\/+$/, '');
74
- const apiKey = pick(flags.apiKey, process.env.CONTRACT_API_KEY, file.apiKey, provider === 'ollama' ? 'ollama' : undefined);
75
- const model = pick(flags.model, process.env.CONTRACT_MODEL, file.model, provider === 'ollama' ? 'qwen3:4b' : 'gpt-4o-mini');
96
+ const preset = PROVIDER_PRESETS[provider];
97
+ const baseUrl = String(pick(flags.baseUrl, process.env.CONTRACT_BASE_URL, file.baseUrl, preset?.baseUrl, provider === 'ollama' ? 'http://localhost:11434' : 'https://api.openai.com/v1')).replace(/\/+$/, '');
98
+ const apiKey = pick(flags.apiKey, process.env.CONTRACT_API_KEY, file.apiKey, preset?.keyless ? 'not-needed' : undefined, provider === 'ollama' ? 'ollama' : undefined);
99
+ const model = pick(flags.model, process.env.CONTRACT_MODEL, file.model, preset?.model, provider === 'ollama' ? 'qwen3:4b' : 'gpt-4o-mini');
76
100
  if (!apiKey) throw new PromptContractError(CODES.CONFIG, `no API key: set CONTRACT_API_KEY, --api-key, or ~/.prompt-contract/config.json (or use --provider ollama)`);
77
101
  return { provider, baseUrl, apiKey, model };
78
102
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The six hard constraints from the PRD appendix, as deterministic rule assertions.
3
- * Same spec drives three surfaces: `contract check` (CLI), playground badges, eval runner —
3
+ * Same spec drives three surfaces: `prompt-prompt-prompt-contract check` (CLI), playground badges, eval runner —
4
4
  * template and evaluation share one source of truth (PRD: 模板与评测共用同一份规格).
5
5
  *
6
6
  * Known heuristic limits (do not oversell — see docs/ACCEPTANCE.md "Evidence boundaries"):
@@ -10,6 +10,7 @@
10
10
  import { enhance, hardConstraints, STRENGTHS, PromptContractError, normalizeError } from '../../core/src/index.js';
11
11
  import { loadProfiles, loadProfile, resolveConfig } from '../../core/src/node.js';
12
12
  import { createOpenAIProvider } from '../../providers/src/openai.js';
13
+ import { createAnthropicProvider } from '../../providers/src/anthropic.js';
13
14
  import { createOllamaProvider } from '../../providers/src/ollama.js';
14
15
 
15
16
  const SERVER_INFO = { name: 'prompt-contract', version: '0.1.0' };
@@ -131,7 +132,9 @@ export async function serve({ stdin = process.stdin, stdout = process.stdout, st
131
132
  const config = resolveConfig({ provider: flag('--provider'), baseUrl: flag('--base-url'), apiKey: flag('--api-key'), model: flag('--model'), configPath: flag('--config') });
132
133
  const provider = config.provider === 'ollama'
133
134
  ? createOllamaProvider({ baseUrl: config.baseUrl })
134
- : createOpenAIProvider({ baseUrl: config.baseUrl, apiKey: config.apiKey });
135
+ : config.provider === 'anthropic'
136
+ ? createAnthropicProvider({ baseUrl: config.baseUrl, apiKey: config.apiKey })
137
+ : createOpenAIProvider({ baseUrl: config.baseUrl, apiKey: config.apiKey });
135
138
  provider.warmup({ model: config.model }).catch(() => {}); // §7.6-1: prewarm, best effort
136
139
  runtime = { config, provider };
137
140
  return runtime;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Anthropic provider — native Messages API (SSE). The one protocol family the OpenAI-compatible
3
+ * adapter cannot cover: different auth header (x-api-key), separate `system` parameter, and
4
+ * content_block_delta events. Reasoning deltas (thinking_delta) are dropped at the transport
5
+ * layer; any <think>…</think> that still lands inside text is stripped later by core/clean.js.
6
+ */
7
+ import { PromptContractError } from '../../core/src/errors.js';
8
+ import { withTimeout } from './openai.js';
9
+
10
+ export const ANTHROPIC_VERSION = '2023-06-01';
11
+
12
+ export function createAnthropicProvider({ baseUrl = 'https://api.anthropic.com', apiKey = '', version = ANTHROPIC_VERSION, fetchImpl = globalThis.fetch } = {}) {
13
+ const root = String(baseUrl).replace(/\/+$/, '');
14
+ const headers = { 'content-type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': version };
15
+ return {
16
+ name: 'anthropic',
17
+ /** Best-effort connection open (PRD §7.6-1); /v1/models exists but a miss never blocks a request. */
18
+ async warmup({ signal } = {}) {
19
+ try { await fetchImpl(`${root}/v1/models`, { headers, signal }); } catch { /* best effort */ }
20
+ },
21
+ async complete({ system, user, model, signal, onDelta, maxTokens = 1024, timeoutMs = 30000, temperature = 0.7 }) {
22
+ const { signal: fullSignal, cleanup } = withTimeout(signal, timeoutMs);
23
+ let res;
24
+ try {
25
+ res = await fetchImpl(`${root}/v1/messages`, {
26
+ method: 'POST',
27
+ signal: fullSignal,
28
+ headers,
29
+ body: JSON.stringify({
30
+ model,
31
+ stream: true,
32
+ // Anthropic requires max_tokens — pipeline derives it from the profile's maxChars.
33
+ max_tokens: maxTokens,
34
+ temperature,
35
+ system,
36
+ messages: [{ role: 'user', content: user }]
37
+ })
38
+ });
39
+ } catch (err) {
40
+ cleanup();
41
+ throw err; // pipeline normalizes AbortError / network errors
42
+ }
43
+ if (!res.ok) {
44
+ cleanup();
45
+ let detail = '';
46
+ try { detail = (await res.text()).slice(0, 300); } catch { /* body unreadable */ }
47
+ throw new PromptContractError('provider_unavailable', `anthropic HTTP ${res.status}${detail ? `: ${detail}` : ''}`);
48
+ }
49
+ let text = '';
50
+ const reader = res.body.getReader();
51
+ const decoder = new TextDecoder();
52
+ let buffer = '';
53
+ try {
54
+ for (;;) {
55
+ const { done, value } = await reader.read();
56
+ if (done) break;
57
+ buffer += decoder.decode(value, { stream: true });
58
+ let nl;
59
+ while ((nl = buffer.indexOf('\n')) >= 0) {
60
+ const line = buffer.slice(0, nl).trim();
61
+ buffer = buffer.slice(nl + 1);
62
+ if (!line.startsWith('data:')) continue;
63
+ const data = line.slice(5).trim();
64
+ if (!data) continue;
65
+ let json;
66
+ try { json = JSON.parse(data); } catch { continue; }
67
+ if (json.type === 'error') {
68
+ throw new PromptContractError('provider_unavailable', `anthropic stream error: ${json.error?.message ?? 'unknown'}`);
69
+ }
70
+ // Only text deltas are answer content; thinking_delta / signature deltas are dropped here.
71
+ if (json.type === 'content_block_delta' && json.delta?.type === 'text_delta' && json.delta.text) {
72
+ text += json.delta.text;
73
+ if (onDelta) onDelta(json.delta.text);
74
+ }
75
+ if (json.type === 'message_stop') return { text };
76
+ }
77
+ }
78
+ } finally {
79
+ cleanup();
80
+ }
81
+ return { text };
82
+ }
83
+ };
84
+ }
@@ -4,8 +4,9 @@
4
4
  */
5
5
  import { PromptContractError } from '../../core/src/errors.js';
6
6
 
7
- /** Combine caller signal + internal timeout. Caller abort must always stay effective (ADR-017). */
8
- function withTimeout(signal, timeoutMs) {
7
+ /** Combine caller signal + internal timeout. Caller abort must always stay effective (ADR-017).
8
+ * Shared by the anthropic provider (same streaming-shape needs). */
9
+ export function withTimeout(signal, timeoutMs) {
9
10
  const timeoutCtrl = new AbortController();
10
11
  const timer = timeoutMs
11
12
  ? setTimeout(() => timeoutCtrl.abort(new DOMException('timeout', 'TimeoutError')), timeoutMs)