prompt-contract 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +102 -0
- package/README.zh-CN.md +73 -0
- package/package.json +42 -0
- package/packages/cli/bin/contract.js +266 -0
- package/packages/cli/package.json +10 -0
- package/packages/cli/src/spike-0.js +582 -0
- package/packages/cli/test/cli.test.js +89 -0
- package/packages/cli/test/spike-0.test.js +260 -0
- package/packages/core/bench/bench.js +48 -0
- package/packages/core/package.json +11 -0
- package/packages/core/src/clean.js +58 -0
- package/packages/core/src/errors.js +26 -0
- package/packages/core/src/index.js +10 -0
- package/packages/core/src/lang.js +37 -0
- package/packages/core/src/node.js +83 -0
- package/packages/core/src/pipeline.js +84 -0
- package/packages/core/src/profile.js +50 -0
- package/packages/core/src/rules.js +91 -0
- package/packages/core/test/clean.test.js +44 -0
- package/packages/core/test/lang.test.js +21 -0
- package/packages/core/test/pipeline.test.js +85 -0
- package/packages/core/test/profile.test.js +40 -0
- package/packages/core/test/rules.test.js +53 -0
- package/packages/mcp-server/bin/prompt-contract-mcp.js +7 -0
- package/packages/mcp-server/package.json +10 -0
- package/packages/mcp-server/src/server.js +184 -0
- package/packages/mcp-server/test/mcp.test.js +167 -0
- package/packages/providers/package.json +7 -0
- package/packages/providers/src/ollama.js +89 -0
- package/packages/providers/src/openai.js +89 -0
- package/packages/providers/test/providers.test.js +64 -0
- package/profiles/coding-agent.md +9 -0
- package/profiles/image-gen.md +9 -0
- package/profiles/writing.md +9 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* contract — PromptContract CLI (PRD §4 P0). Physical file kept as contract.js for path stability.
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
import { enhance, checkRules, PromptContractError, normalizeError } from '../../core/src/index.js';
|
|
8
|
+
import { loadProfiles, loadProfile, resolveConfig } from '../../core/src/node.js';
|
|
9
|
+
import { createOpenAIProvider } from '../../providers/src/openai.js';
|
|
10
|
+
import { createOllamaProvider } from '../../providers/src/ollama.js';
|
|
11
|
+
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { basename } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
createMacOSAdapter,
|
|
15
|
+
DEFAULT_SPIKE_THRESHOLDS,
|
|
16
|
+
formatSpike0Summary,
|
|
17
|
+
isMacOS,
|
|
18
|
+
resolveSpikeTargets,
|
|
19
|
+
runSpike0,
|
|
20
|
+
} from '../src/spike-0.js';
|
|
21
|
+
|
|
22
|
+
// `contract` was the command name before the prompt-contract rename; warn while the alias ships.
|
|
23
|
+
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');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const VERSION = '0.2.0';
|
|
28
|
+
const USAGE = `contract — one-key prompt enhancement (PromptContract v${VERSION})
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
contract "build me a website for my dog" enhance a prompt (prints enhanced text to stdout)
|
|
32
|
+
cat prompt.txt | contract enhance from stdin
|
|
33
|
+
contract profiles list built-in profiles
|
|
34
|
+
contract check --original "..." --enhanced "..."
|
|
35
|
+
run the six hard-constraint rule assertions
|
|
36
|
+
contract doctor verify config, provider reachability, profiles
|
|
37
|
+
contract spike-0 macOS-only capture/restore compatibility diagnostic (dry-run)
|
|
38
|
+
contract watch NOT BUILT — gated by decision D7 (Spike-0 first); see docs/SPIKE-0.md
|
|
39
|
+
|
|
40
|
+
Options:
|
|
41
|
+
-p, --profile <name> scenario profile (default: coding-agent)
|
|
42
|
+
-s, --strength <mode> polish | standard (default) | expand
|
|
43
|
+
-m, --model <model> model override
|
|
44
|
+
--provider <name> openai (default) | ollama
|
|
45
|
+
--base-url <url> OpenAI-compatible base URL (or CONTRACT_BASE_URL)
|
|
46
|
+
--api-key <key> API key (or CONTRACT_API_KEY; local ollama needs none)
|
|
47
|
+
--context <text> background context to assemble into the prompt
|
|
48
|
+
--max-chars <n> output clamp (default from profile, 800)
|
|
49
|
+
--timeout <ms> request timeout (default 30000)
|
|
50
|
+
--app <names> Spike-0 targets: Chrome,PyCharm,iTerm (default: all three)
|
|
51
|
+
--iterations <n> Spike-0 attempts per target (default: 20)
|
|
52
|
+
--settle-ms <ms> Spike-0 wait after ⌘C before reading (default: 75)
|
|
53
|
+
--pause-ms <ms> Spike-0 pause before dry-run focus validation
|
|
54
|
+
--setup-delay-ms <ms> Spike-0 delay before each target capture
|
|
55
|
+
--no-prompt Spike-0 do not wait for target/app setup
|
|
56
|
+
--output <path> write Spike-0 JSON report to a file
|
|
57
|
+
--json machine-readable output {original, enhanced, meta, rules}
|
|
58
|
+
--no-stream buffer instead of streaming progress
|
|
59
|
+
-h, --help show this help`;
|
|
60
|
+
|
|
61
|
+
function parseArgs(argv) {
|
|
62
|
+
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']);
|
|
64
|
+
const camel = (k) => k.replace(/^--?/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
65
|
+
for (let i = 0; i < argv.length; i++) {
|
|
66
|
+
const a = argv[i];
|
|
67
|
+
if (a === '--json') flags.json = true;
|
|
68
|
+
else if (a === '--no-stream') flags.noStream = true;
|
|
69
|
+
else if (a === '--no-prompt') flags.noPrompt = true;
|
|
70
|
+
else if (a === '--help' || a === '-h') flags.help = true;
|
|
71
|
+
else if (a === '--version') flags.version = true;
|
|
72
|
+
else if (needsValue.has(a)) flags[camel(a)] = argv[++i];
|
|
73
|
+
else if (a.startsWith('-')) throw new PromptContractError('config_error', `unknown flag ${a}`);
|
|
74
|
+
else flags._.push(a);
|
|
75
|
+
}
|
|
76
|
+
return flags;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildProvider(cfg) {
|
|
80
|
+
return cfg.provider === 'ollama'
|
|
81
|
+
? createOllamaProvider({ baseUrl: cfg.baseUrl })
|
|
82
|
+
: createOpenAIProvider({ baseUrl: cfg.baseUrl, apiKey: cfg.apiKey });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function makeProvider(cfg, { warm = false } = {}) {
|
|
86
|
+
const provider = buildProvider(cfg);
|
|
87
|
+
if (warm) provider.warmup({ model: cfg.model }); // fire-and-forget connection/model preload (§7.6-1)
|
|
88
|
+
return provider;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readStdin() {
|
|
92
|
+
try {
|
|
93
|
+
if (existsSync('/dev/stdin') && !process.stdin.isTTY) return readFileSync(0, 'utf8');
|
|
94
|
+
} catch { /* fall through */ }
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function printRules(rules) {
|
|
99
|
+
for (const r of rules.results) {
|
|
100
|
+
const mark = r.pass ? 'PASS' : 'FAIL';
|
|
101
|
+
process.stderr.write(` [${mark}] ${r.title}${r.detail ? ` — ${r.detail}` : ''}\n`);
|
|
102
|
+
}
|
|
103
|
+
return rules.pass;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function cmdEnhance(flags) {
|
|
107
|
+
let text = flags._.join(' ');
|
|
108
|
+
if (!text.trim() && !process.stdin.isTTY) text = readStdin();
|
|
109
|
+
const profile = loadProfile(flags.profile || 'coding-agent');
|
|
110
|
+
const cfg = resolveConfig(flags);
|
|
111
|
+
const provider = makeProvider(cfg, { warm: true });
|
|
112
|
+
|
|
113
|
+
const onDelta = flags.json || flags.noStream
|
|
114
|
+
? undefined
|
|
115
|
+
: (d) => { process.stderr.write(d); };
|
|
116
|
+
|
|
117
|
+
const res = await enhance(text, {
|
|
118
|
+
profile,
|
|
119
|
+
provider,
|
|
120
|
+
model: cfg.model,
|
|
121
|
+
strength: flags.strength,
|
|
122
|
+
context: flags.context,
|
|
123
|
+
maxChars: flags.maxChars ? parseInt(flags.maxChars, 10) : undefined,
|
|
124
|
+
timeoutMs: flags.timeout ? parseInt(flags.timeout, 10) : undefined,
|
|
125
|
+
onDelta
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const rules = checkRules(res.original, res.text, { maxChars: profile.maxChars });
|
|
129
|
+
if (flags.json) {
|
|
130
|
+
process.stdout.write(JSON.stringify({ original: res.original, enhanced: res.text, meta: res.meta, rules }, null, 2) + '\n');
|
|
131
|
+
} else {
|
|
132
|
+
process.stdout.write(res.text + '\n');
|
|
133
|
+
if (!flags.noStream) {
|
|
134
|
+
process.stderr.write(`\n— ${res.meta.profile} · ${res.meta.model} · ${res.meta.ms}ms · ${res.meta.chars} chars\n`);
|
|
135
|
+
if (!rules.pass) {
|
|
136
|
+
process.stderr.write('rule assertions (advisory — run `contract check` for gate mode):\n');
|
|
137
|
+
for (const r of rules.results.filter((r) => !r.pass)) {
|
|
138
|
+
process.stderr.write(` [FAIL] ${r.title}${r.detail ? ` — ${r.detail}` : ''}\n`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cmdProfiles(flags) {
|
|
147
|
+
const profiles = loadProfiles();
|
|
148
|
+
if (flags.json) {
|
|
149
|
+
process.stdout.write(JSON.stringify(profiles.map((p) => ({ name: p.name, domain: p.domain, maxChars: p.maxChars })), null, 2) + '\n');
|
|
150
|
+
} else {
|
|
151
|
+
for (const p of profiles) process.stdout.write(`${p.name.padEnd(14)} ${p.domain} (≤${p.maxChars} chars)\n`);
|
|
152
|
+
}
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function cmdCheck(flags) {
|
|
157
|
+
let original = flags.original;
|
|
158
|
+
let enhanced = flags.enhanced;
|
|
159
|
+
if (!original && !enhanced && !process.stdin.isTTY) {
|
|
160
|
+
// accept "original\tenhanced" or JSON line on stdin
|
|
161
|
+
const line = readStdin().trim();
|
|
162
|
+
try {
|
|
163
|
+
const j = JSON.parse(line);
|
|
164
|
+
original = j.original; enhanced = j.enhanced;
|
|
165
|
+
} catch {
|
|
166
|
+
const [o, e] = line.split('\t');
|
|
167
|
+
original = o; enhanced = e;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (original === undefined || enhanced === undefined) {
|
|
171
|
+
process.stderr.write('contract check requires --original and --enhanced (or a JSON {original, enhanced} line on stdin)\n');
|
|
172
|
+
return 2;
|
|
173
|
+
}
|
|
174
|
+
const rules = checkRules(original, enhanced, { maxChars: flags.maxChars ? parseInt(flags.maxChars, 10) : 800 });
|
|
175
|
+
process.stderr.write(`checking ${rules.results.length} rule assertions:\n`);
|
|
176
|
+
const pass = printRules(rules);
|
|
177
|
+
if (flags.json) process.stdout.write(JSON.stringify(rules, null, 2) + '\n');
|
|
178
|
+
return pass ? 0 : 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function cmdDoctor(flags) {
|
|
182
|
+
let ok = true;
|
|
183
|
+
const cfg = (() => { try { return { ...resolveConfig(flags) }; } catch (err) { process.stderr.write(`[FAIL] config: ${err.message}\n`); return null; } })();
|
|
184
|
+
if (!cfg) return 1;
|
|
185
|
+
process.stderr.write(`[PASS] provider=${cfg.provider} base=${cfg.baseUrl} model=${cfg.model} key=${cfg.provider === 'ollama' ? 'not-needed' : 'set'}\n`);
|
|
186
|
+
try {
|
|
187
|
+
loadProfiles();
|
|
188
|
+
const names = loadProfiles().map((p) => p.name).join(', ');
|
|
189
|
+
process.stderr.write(`[PASS] profiles: ${names}\n`);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
ok = false;
|
|
192
|
+
process.stderr.write(`[FAIL] profiles: ${err.message}\n`);
|
|
193
|
+
}
|
|
194
|
+
const provider = buildProvider(cfg);
|
|
195
|
+
const probe = await Promise.race([
|
|
196
|
+
provider.complete({ system: 'ping', user: 'ping', model: cfg.model, maxTokens: 1, timeoutMs: 5000 }).then(() => 'ok').catch((e) => `error: ${e.code || e.message}`),
|
|
197
|
+
new Promise((r) => setTimeout(() => r('timeout'), 6000))
|
|
198
|
+
]);
|
|
199
|
+
if (probe === 'ok') process.stderr.write('[PASS] provider reachable and responding\n');
|
|
200
|
+
else { ok = false; process.stderr.write(`[FAIL] provider probe: ${probe}\n`); }
|
|
201
|
+
process.stderr.write(ok ? 'doctor: OK\n' : 'doctor: PROBLEMS FOUND\n');
|
|
202
|
+
return ok ? 0 : 1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function cmdSpike0(flags) {
|
|
206
|
+
if (!isMacOS) {
|
|
207
|
+
process.stderr.write('contract spike-0 is macOS-only: requires pbpaste, pbcopy, and Accessibility-backed System Events.\n');
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const targets = resolveSpikeTargets(flags.app || DEFAULT_SPIKE_THRESHOLDS.requiredTargets.join(','));
|
|
212
|
+
const iterations = flags.iterations === undefined
|
|
213
|
+
? DEFAULT_SPIKE_THRESHOLDS.iterationsPerTarget
|
|
214
|
+
: Number.parseInt(flags.iterations, 10);
|
|
215
|
+
const settleMs = flags.settleMs === undefined ? 75 : Number.parseInt(flags.settleMs, 10);
|
|
216
|
+
const pauseMs = flags.pauseMs === undefined ? 0 : Number.parseInt(flags.pauseMs, 10);
|
|
217
|
+
const setupDelayMs = flags.setupDelayMs === undefined ? 0 : Number.parseInt(flags.setupDelayMs, 10);
|
|
218
|
+
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');
|
|
220
|
+
return 2;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const report = await runSpike0({
|
|
224
|
+
adapter: createMacOSAdapter(),
|
|
225
|
+
targets,
|
|
226
|
+
iterations,
|
|
227
|
+
settleMs,
|
|
228
|
+
pauseMs,
|
|
229
|
+
setupDelayMs,
|
|
230
|
+
interactive: !flags.noPrompt && Boolean(process.stdin.isTTY),
|
|
231
|
+
announce: (message) => process.stderr.write(`${message}\n`),
|
|
232
|
+
});
|
|
233
|
+
const serialized = JSON.stringify(report, null, 2) + '\n';
|
|
234
|
+
if (flags.output) writeFileSync(flags.output, serialized, 'utf8');
|
|
235
|
+
if (flags.json) process.stdout.write(serialized);
|
|
236
|
+
else process.stdout.write(formatSpike0Summary(report) + '\n');
|
|
237
|
+
return report.decision.pass ? 0 : 1;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function main() {
|
|
241
|
+
const flags = parseArgs(process.argv.slice(2));
|
|
242
|
+
if (flags.help) { process.stdout.write(USAGE + '\n'); return 0; }
|
|
243
|
+
if (flags.version) { process.stdout.write(VERSION + '\n'); return 0; }
|
|
244
|
+
const cmd = flags._.shift();
|
|
245
|
+
switch (cmd) {
|
|
246
|
+
case undefined:
|
|
247
|
+
case 'boost': return await cmdEnhance(flags);
|
|
248
|
+
case 'profiles': return cmdProfiles(flags);
|
|
249
|
+
case 'check': return cmdCheck(flags);
|
|
250
|
+
case 'doctor': return await cmdDoctor(flags);
|
|
251
|
+
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;
|
|
255
|
+
default:
|
|
256
|
+
// treat unknown first word as prompt text
|
|
257
|
+
flags._.unshift(cmd);
|
|
258
|
+
return await cmdEnhance(flags);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
main().then((code) => process.exit(code)).catch((err) => {
|
|
263
|
+
const e = normalizeError(err);
|
|
264
|
+
process.stderr.write(`contract: ${e.code}: ${e.message}\n`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
});
|