patina-cli 3.11.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/.patina.default.yaml +29 -29
  2. package/CHANGELOG.md +53 -0
  3. package/NOTICE +21 -0
  4. package/README.md +117 -224
  5. package/README_JA.md +134 -77
  6. package/README_KR.md +132 -74
  7. package/README_ZH.md +137 -80
  8. package/SKILL.md +11 -20
  9. package/artifacts/rebaseline-2025/README.md +147 -0
  10. package/artifacts/rebaseline-2025/human-controls.public.jsonl +250 -0
  11. package/artifacts/rebaseline-2025/intake.example.jsonl +2 -0
  12. package/artifacts/rebaseline-2025/intake.local.example.jsonl +25 -0
  13. package/artifacts/rebaseline-2025/prompts.template.jsonl +7 -0
  14. package/artifacts/rebaseline-2025/sources.ko-public.jsonl +39 -0
  15. package/assets/brand/patina-badge.svg +18 -0
  16. package/assets/brand/patina-mark.svg +8 -0
  17. package/assets/demo/README.md +79 -0
  18. package/core/scoring.md +12 -12
  19. package/core/standalone-prompt.md +3 -1
  20. package/core/stylometry.md +93 -22
  21. package/docs/API.md +1554 -0
  22. package/docs/AUTHENTICATION.md +50 -26
  23. package/docs/AUTHENTICATION_KR.md +54 -29
  24. package/docs/BRANDING.md +9 -8
  25. package/docs/CLI.md +55 -14
  26. package/docs/COOKBOOK.md +8 -21
  27. package/docs/DEMO.md +32 -5
  28. package/docs/EXIT-CODES.md +2 -3
  29. package/docs/FALSE-POSITIVES.md +63 -0
  30. package/docs/FAQ.md +9 -1
  31. package/docs/FAQ_KR.md +3 -1
  32. package/docs/FLAG-PARITY.md +33 -47
  33. package/docs/ISSUE-WAVES.md +57 -0
  34. package/docs/PATTERNS-EN.md +67 -3
  35. package/docs/PATTERNS-JA.md +68 -2
  36. package/docs/PATTERNS-KO.md +70 -7
  37. package/docs/PATTERNS-ZH.md +67 -3
  38. package/docs/PATTERNS.md +5 -5
  39. package/docs/RESEARCH-DOCS-PLATFORM.md +54 -0
  40. package/docs/ROADMAP.md +46 -66
  41. package/docs/TRANSLATIONESE-KO.md +51 -0
  42. package/docs/audits/2026-05-deep-research.md +3 -1
  43. package/docs/benchmarks/README.md +51 -0
  44. package/docs/benchmarks/detector-comparison.json +69 -9
  45. package/docs/benchmarks/detector-comparison.md +10 -5
  46. package/docs/benchmarks/katfish-ko-latest.json +657 -0
  47. package/docs/benchmarks/katfish-ko-latest.md +77 -0
  48. package/docs/benchmarks/latest.json +1183 -108
  49. package/docs/benchmarks/latest.md +84 -60
  50. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.json +1121 -0
  51. package/docs/benchmarks/lexicon-freshness-en-2026-05-22.md +136 -0
  52. package/docs/benchmarks/rebaseline-latest.json +381 -0
  53. package/docs/benchmarks/rebaseline-latest.md +121 -0
  54. package/docs/benchmarks/register-stratified-latest.json +164 -0
  55. package/docs/benchmarks/register-stratified-latest.md +99 -0
  56. package/docs/benchmarks/register-stratified.md +43 -0
  57. package/docs/integrations/github-action.md +44 -11
  58. package/docs/integrations/playground.md +58 -0
  59. package/docs/integrations/pre-commit.md +5 -5
  60. package/docs/integrations/release.md +5 -3
  61. package/docs/integrations/static-sites.md +83 -0
  62. package/docs/research/2025-rebaseline-plan.md +71 -2
  63. package/docs/research/2026-rebaseline.md +102 -0
  64. package/docs/research/adversarial-mps.md +41 -0
  65. package/docs/research/ai-human-metrics.md +35 -23
  66. package/docs/research/human-eval-panel.md +42 -0
  67. package/docs/research/judge-agreement.md +24 -0
  68. package/docs/research/ko-2025-corpus-sources.md +135 -0
  69. package/docs/research/lexicon-freshness-audit.md +64 -0
  70. package/docs/research/zh-ja-lexicon-calibration.md +60 -0
  71. package/docs/social/patina-launch-copy.md +173 -100
  72. package/docs/social/patina-launch-execution.md +94 -0
  73. package/docs/social/patina-launch-korean-first.md +83 -0
  74. package/docs/social/signs-of-ai-writing.md +26 -0
  75. package/docs/social/signs-of-ai-writing_KR.md +26 -0
  76. package/lexicon/ai-en.md +21 -24
  77. package/lexicon/ai-ja.md +158 -0
  78. package/lexicon/ai-ko.md +9 -9
  79. package/lexicon/ai-zh.md +158 -0
  80. package/lexicon/provenance/ai-en.json +970 -0
  81. package/lexicon/provenance/ai-ja.json +542 -0
  82. package/lexicon/provenance/ai-ko.json +866 -0
  83. package/lexicon/provenance/ai-zh.json +542 -0
  84. package/package.json +49 -8
  85. package/patterns/en-communication.md +5 -0
  86. package/patterns/en-content.md +5 -0
  87. package/patterns/en-filler.md +5 -0
  88. package/patterns/en-language.md +29 -1
  89. package/patterns/en-structure.md +5 -0
  90. package/patterns/en-style.md +5 -0
  91. package/patterns/en-viral-hook.md +42 -2
  92. package/patterns/ja-communication.md +5 -0
  93. package/patterns/ja-content.md +5 -0
  94. package/patterns/ja-filler.md +5 -0
  95. package/patterns/ja-language.md +33 -1
  96. package/patterns/ja-structure.md +12 -0
  97. package/patterns/ja-style.md +5 -0
  98. package/patterns/ja-viral-hook.md +41 -2
  99. package/patterns/ko-communication.md +5 -0
  100. package/patterns/ko-content.md +5 -0
  101. package/patterns/ko-filler.md +5 -0
  102. package/patterns/ko-language.md +33 -1
  103. package/patterns/ko-structure.md +25 -6
  104. package/patterns/ko-style.md +5 -0
  105. package/patterns/ko-viral-hook.md +38 -2
  106. package/patterns/zh-communication.md +5 -0
  107. package/patterns/zh-content.md +5 -0
  108. package/patterns/zh-filler.md +5 -0
  109. package/patterns/zh-language.md +37 -1
  110. package/patterns/zh-structure.md +12 -0
  111. package/patterns/zh-style.md +5 -0
  112. package/patterns/zh-viral-hook.md +38 -2
  113. package/playground/README.md +55 -0
  114. package/playground/analytics.js +4 -0
  115. package/playground/analyzer.js +883 -0
  116. package/playground/app.js +157 -0
  117. package/playground/data/lexicons.js +343 -0
  118. package/playground/index.html +138 -0
  119. package/playground/styles.css +267 -0
  120. package/profiles/namuwiki.md +111 -0
  121. package/scripts/adversarial-mps-report.mjs +201 -0
  122. package/scripts/badge-json.mjs +79 -0
  123. package/scripts/benchmark-report.mjs +56 -9
  124. package/scripts/check-release-metadata.mjs +0 -2
  125. package/scripts/detector-comparison.mjs +7 -7
  126. package/scripts/generate-playground-data.mjs +77 -0
  127. package/scripts/katfish-calibration.mjs +464 -0
  128. package/scripts/lexicon-freshness.mjs +485 -0
  129. package/scripts/lint.mjs +1 -1
  130. package/scripts/precommit-score.mjs +4 -3
  131. package/scripts/prose-score.mjs +81 -5
  132. package/scripts/rebaseline-intake.mjs +242 -0
  133. package/scripts/rebaseline-score.mjs +268 -0
  134. package/scripts/rebaseline-summary.mjs +773 -0
  135. package/scripts/rebaseline-web-collect.mjs +410 -0
  136. package/scripts/update-benchmark-ranges.mjs +1 -0
  137. package/src/api.js +69 -105
  138. package/src/auth.js +50 -2
  139. package/src/backends/claude-cli.js +19 -4
  140. package/src/backends/codex-cli.js +19 -3
  141. package/src/backends/contract.js +230 -1
  142. package/src/backends/gemini-cli.js +18 -5
  143. package/src/backends/index.js +87 -12
  144. package/src/backends/kimi-cli.js +161 -0
  145. package/src/cli.js +577 -567
  146. package/src/commands/doctor.js +2 -2
  147. package/src/config.js +29 -0
  148. package/src/errors.js +53 -1
  149. package/src/features/discourse-tells.js +68 -0
  150. package/src/features/index.js +82 -8
  151. package/src/features/lexicon.js +40 -6
  152. package/src/features/markup-leakage.js +69 -0
  153. package/src/features/segment.js +41 -0
  154. package/src/features/signal-strength.js +81 -0
  155. package/src/features/stylometry.js +231 -1
  156. package/src/features/translationese.js +127 -0
  157. package/src/loader.js +76 -0
  158. package/src/logger.js +22 -23
  159. package/src/model-defaults.js +55 -0
  160. package/src/ouroboros.js +31 -0
  161. package/src/output.js +102 -90
  162. package/src/prompt-builder.js +103 -68
  163. package/src/providers.js +51 -4
  164. package/src/scoring.js +210 -2
  165. package/src/security.js +75 -0
  166. package/tests/fixtures/live-quality/en/public-docs-01.md +26 -0
  167. package/tests/fixtures/live-quality/ko/public-docs-01.md +26 -0
  168. package/tests/fixtures/suspect-zones/expected-ranges.json +207 -16
  169. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-04-lexicon.md +11 -0
  170. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-04-lexicon-cold.md +11 -0
  171. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +4 -5
  172. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-07-ko-diagnostic.md +11 -0
  173. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-04-lexicon.md +11 -0
  174. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-04-lexicon-cold.md +11 -0
  175. package/tests/quality/README.md +188 -11
  176. package/tests/quality/adversarial-mps/fixtures.jsonl +10 -0
  177. package/tests/quality/benchmark.mjs +39 -1
  178. package/tests/quality/dogfood.mjs +5 -3
  179. package/tests/quality/live-fixtures.jsonl +2 -0
  180. package/tests/quality/live-quality.mjs +596 -0
  181. package/tests/quality/ranking-metrics.mjs +136 -0
  182. package/tests/quality/rebaseline-manifest.example.jsonl +5 -0
  183. package/vercel.json +53 -0
  184. package/SKILL-MAX.md +0 -455
  185. package/docs/internal/HARNESS.md +0 -14
  186. package/docs/internal/README.md +0 -14
  187. package/docs/internal/WARP.md +0 -23
  188. package/patina-max/SKILL.md +0 -523
  189. package/patina-max/composite.py +0 -457
  190. package/src/cache.js +0 -106
  191. package/src/commands/init.js +0 -208
  192. package/src/manifest.js +0 -162
  193. package/src/max-mode.js +0 -207
@@ -0,0 +1,596 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Opt-in live rewrite quality runner.
5
+ *
6
+ * Default execution never calls a model: it scores fixture inputs and reports a
7
+ * skipped live pass. Pass --live or set PATINA_LIVE=1 / PATINA_LIVE_* env vars
8
+ * to run credentialed OpenAI-compatible rewrites and model-graded checks.
9
+ */
10
+
11
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
12
+ import { dirname, extname, join, resolve } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import yaml from 'js-yaml';
15
+
16
+ import { callLLM as defaultCallLLM } from '../../src/api.js';
17
+ import { providerHttpKeyEnvVars, resolveHttpApiKey } from '../../src/auth.js';
18
+ import { loadConfig, getRepoRoot } from '../../src/config.js';
19
+ import { loadCoreFile, loadPatterns, loadProfile } from '../../src/loader.js';
20
+ import { formatOutput } from '../../src/output.js';
21
+ import { buildPrompt } from '../../src/prompt-builder.js';
22
+ import { resolveProviderConfig, selectProvider } from '../../src/providers.js';
23
+ import { scoreFidelity, scoreMPS, scoreText } from '../../src/scoring.js';
24
+ import { scoreText as scoreProseText } from '../../scripts/prose-score.mjs';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const REPO_ROOT = resolve(__dirname, '../..');
28
+ const DEFAULT_FIXTURE_DIR = resolve(REPO_ROOT, 'tests/fixtures/live-quality');
29
+ const LEGACY_FIXTURE_PATH = resolve(REPO_ROOT, 'tests/quality/live-fixtures.jsonl');
30
+
31
+ export const LIVE_QUALITY_SCHEMA_VERSION = 1;
32
+ export const DEFAULT_POLICY = Object.freeze({
33
+ aiAfterCeiling: 30,
34
+ mpsFloor: 70,
35
+ fidelityFloor: 70,
36
+ requireAiImprovement: true,
37
+ });
38
+
39
+ const REQUIRED_FIXTURE_FIELDS = ['fixture_id', 'language', 'redistribution', 'text'];
40
+
41
+ export function loadLiveFixtures(source = DEFAULT_FIXTURE_DIR) {
42
+ const resolved = resolve(source);
43
+ if (existsSync(resolved) && statSync(resolved).isDirectory()) {
44
+ return loadMarkdownFixtureDir(resolved);
45
+ }
46
+ if (existsSync(resolved)) {
47
+ return loadJsonlFixtures(resolved);
48
+ }
49
+ return loadJsonlFixtures(LEGACY_FIXTURE_PATH);
50
+ }
51
+
52
+ function loadJsonlFixtures(fixturePath) {
53
+ const body = readFileSync(fixturePath, 'utf8');
54
+ return body
55
+ .split('\n')
56
+ .map((line) => line.trim())
57
+ .filter(Boolean)
58
+ .map((line, index) => validateFixture(JSON.parse(line), `${fixturePath}:${index + 1}`));
59
+ }
60
+
61
+ function loadMarkdownFixtureDir(root) {
62
+ const paths = collectMarkdownFiles(root);
63
+ if (paths.length === 0) throw new Error(`no live-quality markdown fixtures found in ${root}`);
64
+ return paths.map((path) => parseMarkdownFixture(path));
65
+ }
66
+
67
+ function collectMarkdownFiles(root) {
68
+ const entries = readdirSync(root, { withFileTypes: true });
69
+ const paths = [];
70
+ for (const entry of entries) {
71
+ const path = join(root, entry.name);
72
+ if (entry.isDirectory()) paths.push(...collectMarkdownFiles(path));
73
+ else if (entry.isFile() && extname(entry.name) === '.md') paths.push(path);
74
+ }
75
+ return paths.sort();
76
+ }
77
+
78
+ function parseMarkdownFixture(path) {
79
+ const raw = readFileSync(path, 'utf8');
80
+ const match = raw.match(/^---\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
81
+ if (!match) throw new Error(`${path}: missing YAML frontmatter`);
82
+ const meta = yaml.load(match[1]) || {};
83
+ return validateFixture({ ...meta, text: match[2].trim() }, path);
84
+ }
85
+
86
+ function validateFixture(fixture, source) {
87
+ for (const field of REQUIRED_FIXTURE_FIELDS) {
88
+ if (!(field in fixture)) throw new Error(`missing ${field} in ${source}`);
89
+ }
90
+ if (!['en', 'ko', 'zh', 'ja'].includes(fixture.language)) {
91
+ throw new Error(`unsupported language ${fixture.language} in ${source}`);
92
+ }
93
+ const anchors = normalizeStringArray(fixture.anchors ?? fixture.facts, `${source}: anchors`);
94
+ if (anchors.length === 0) throw new Error(`anchors/facts must be a non-empty array in ${source}`);
95
+ return {
96
+ register: 'unspecified',
97
+ source_type: 'fixture',
98
+ model_family: 'fixture',
99
+ prompt_id: 'live-quality-v2',
100
+ ...fixture,
101
+ anchors,
102
+ facts: anchors,
103
+ expected_focus: normalizeStringArray(fixture.expected_focus, `${source}: expected_focus`, { required: false }),
104
+ };
105
+ }
106
+
107
+ function normalizeStringArray(value, source, { required = true } = {}) {
108
+ if (value === undefined || value === null) {
109
+ if (required) throw new Error(`${source} must be an array`);
110
+ return [];
111
+ }
112
+ if (!Array.isArray(value)) throw new Error(`${source} must be an array`);
113
+ return value.map((item) => String(item).trim()).filter(Boolean);
114
+ }
115
+
116
+ export async function buildPatinaRewritePrompt(fixture, { repoRoot = getRepoRoot() } = {}) {
117
+ const config = loadConfig();
118
+ config.language = fixture.language;
119
+ if (fixture.profile) config.profile = fixture.profile;
120
+
121
+ const patterns = loadPatterns(repoRoot, fixture.language);
122
+ const profile = loadProfile(repoRoot, config.profile || 'default');
123
+ const voice = loadCoreFile(repoRoot, 'voice.md');
124
+
125
+ return buildPrompt({
126
+ config,
127
+ patterns,
128
+ profile: profile.body ? profile : null,
129
+ voice: voice.body ? voice : null,
130
+ scoring: null,
131
+ text: fixture.text,
132
+ mode: 'rewrite',
133
+ });
134
+ }
135
+
136
+ export function deliveredRewrite(rawRewrite, { logger = { warn() {} } } = {}) {
137
+ return formatOutput(String(rawRewrite || ''), 'rewrite', {}, { logger }).trim();
138
+ }
139
+
140
+ export function evaluateRewriteQuality(fixture, rawRewrite, { repoRoot = REPO_ROOT } = {}) {
141
+ const rewrite = deliveredRewrite(rawRewrite);
142
+ const before = scoreProseText(fixture.text, {
143
+ file: `${fixture.fixture_id}.md`,
144
+ lang: fixture.language,
145
+ repoRoot,
146
+ });
147
+ const after = scoreProseText(rewrite, {
148
+ file: `${fixture.fixture_id}.rewrite.md`,
149
+ lang: fixture.language,
150
+ repoRoot,
151
+ });
152
+ const humanizationGain = round1(before.score - after.score);
153
+ const meaningSafety = round1(computeMeaningSafety(fixture, rewrite));
154
+ const safeGain = round1(Math.max(0, humanizationGain) * (meaningSafety / 100));
155
+ const status = classifyQuality({ afterScore: after.score, meaningSafety, safeGain });
156
+ const facts = preservedFacts(fixture.facts, rewrite, fixture.language);
157
+
158
+ return {
159
+ fixture_id: fixture.fixture_id,
160
+ language: fixture.language,
161
+ register: fixture.register,
162
+ mode: 'offline-candidate',
163
+ before_score: round1(before.score),
164
+ after_score: round1(after.score),
165
+ humanization_gain: humanizationGain,
166
+ meaning_safety: meaningSafety,
167
+ safe_gain: safeGain,
168
+ status,
169
+ preserved_facts: facts.preserved,
170
+ total_facts: facts.total,
171
+ };
172
+ }
173
+
174
+ export async function evaluateModelGradedRewrite(fixture, rawRewrite, options = {}) {
175
+ const repoRoot = options.repoRoot || REPO_ROOT;
176
+ const policy = options.policy || DEFAULT_POLICY;
177
+ const settings = options.settings || resolveLiveSettings(options);
178
+ const rewrite = deliveredRewrite(rawRewrite, { logger: options.logger });
179
+ const config = loadConfig();
180
+ config.language = fixture.language;
181
+ if (fixture.profile) config.profile = fixture.profile;
182
+ const patterns = loadPatterns(repoRoot, fixture.language);
183
+ const deadline = settings.timeoutMs ? Date.now() + settings.timeoutMs : undefined;
184
+ const callLLM = createLiveCallLLM(options.callLLM || defaultCallLLM, settings);
185
+
186
+ const common = {
187
+ apiKey: settings.apiKey,
188
+ baseURL: settings.baseURL,
189
+ model: settings.model,
190
+ deadline,
191
+ callLLM,
192
+ logger: options.logger,
193
+ };
194
+
195
+ const [beforeScore, afterScore, mpsResult, fidelityResult] = await Promise.all([
196
+ scoreText({ text: fixture.text, config, patterns, ...common }),
197
+ scoreText({ text: rewrite, config, patterns, ...common }),
198
+ scoreMPS({ original: fixture.text, rewritten: rewrite, ...common }),
199
+ scoreFidelity({ original: fixture.text, rewritten: rewrite, ...common }),
200
+ ]);
201
+
202
+ return modelGradedResult({
203
+ fixture,
204
+ beforeScore,
205
+ afterScore,
206
+ mpsResult,
207
+ fidelityResult,
208
+ policy,
209
+ });
210
+ }
211
+
212
+ function modelGradedResult({ fixture, beforeScore, afterScore, mpsResult, fidelityResult, policy }) {
213
+ const before = numberOrNull(beforeScore?.overall);
214
+ const after = numberOrNull(afterScore?.overall);
215
+ const mps = numberOrNull(mpsResult?.mps);
216
+ const fidelity = numberOrNull(fidelityResult?.fidelity);
217
+ const errors = [];
218
+ const violations = [];
219
+
220
+ if (before === null || beforeScore?.error) errors.push('before-score-unavailable');
221
+ if (after === null || afterScore?.error) errors.push('after-score-unavailable');
222
+ if (mps === null || mpsResult?.error) errors.push('mps-unavailable');
223
+ if (fidelity === null || fidelityResult?.error) errors.push('fidelity-unavailable');
224
+
225
+ if (mps !== null && mps < policy.mpsFloor) violations.push(`mps<${policy.mpsFloor}`);
226
+ if (fidelity !== null && fidelity < policy.fidelityFloor) violations.push(`fidelity<${policy.fidelityFloor}`);
227
+ if (after !== null && after > policy.aiAfterCeiling) violations.push(`ai_after>${policy.aiAfterCeiling}`);
228
+ if (policy.requireAiImprovement && before !== null && after !== null && after >= before) {
229
+ violations.push('ai_not_improved');
230
+ }
231
+
232
+ const meaningUnsafe = violations.some((item) => item.startsWith('mps<') || item.startsWith('fidelity<'));
233
+ const status = errors.length > 0 || meaningUnsafe
234
+ ? 'error'
235
+ : violations.length > 0
236
+ ? 'warn'
237
+ : 'pass';
238
+
239
+ return {
240
+ fixture_id: fixture.fixture_id,
241
+ language: fixture.language,
242
+ register: fixture.register,
243
+ mode: 'live-api',
244
+ status,
245
+ before_score: before,
246
+ after_score: after,
247
+ ai_delta: before !== null && after !== null ? round1(before - after) : null,
248
+ mps,
249
+ fidelity,
250
+ policy_violations: violations,
251
+ errors,
252
+ };
253
+ }
254
+
255
+ function createLiveCallLLM(callLLM, settings) {
256
+ return (args) => callLLM({
257
+ ...args,
258
+ timeout: settings.timeoutMs,
259
+ });
260
+ }
261
+
262
+ export function computeMeaningSafety(fixture, rewrite) {
263
+ const facts = preservedFacts(fixture.facts, rewrite, fixture.language);
264
+ const factScore = facts.total ? (facts.preserved.length / facts.total) * 100 : 100;
265
+ const lengthScore = lengthSafetyScore(fixture.text, rewrite);
266
+ return Math.min(factScore, lengthScore);
267
+ }
268
+
269
+ export function classifyQuality({ afterScore, meaningSafety, safeGain }) {
270
+ if (afterScore <= 30 && meaningSafety >= 70 && safeGain > 0) return 'pass';
271
+ if (meaningSafety >= 70 && safeGain > 0) return 'warn';
272
+ return 'fail';
273
+ }
274
+
275
+ export async function runLiveQuality(options = {}) {
276
+ const report = await runLiveQualityReport(options);
277
+ return report.results;
278
+ }
279
+
280
+ export async function runLiveQualityReport(options = {}) {
281
+ const fixtures = selectFixtures(options.fixtures ?? loadLiveFixtures(options.fixturePath), options);
282
+ const policy = { ...DEFAULT_POLICY, ...(options.policy || {}) };
283
+ const liveRequested = shouldRunLive(options);
284
+ const settings = resolveLiveSettings(options);
285
+ const candidateDir = options.candidateDir ? resolve(options.candidateDir) : null;
286
+ const results = [];
287
+
288
+ if (fixtures.length === 0) throw new Error('no live-quality fixtures selected');
289
+
290
+ for (const fixture of fixtures) {
291
+ const candidate = candidateDir ? readCandidate(candidateDir, fixture.fixture_id) : null;
292
+ if (!liveRequested && !candidate) {
293
+ results.push(skippedResult(fixture, 'live rewrite disabled; pass --live, set PATINA_LIVE=1, or pass --candidate-dir'));
294
+ continue;
295
+ }
296
+ if (liveRequested && !settings.hasApiKey) {
297
+ results.push(failedResult(fixture, new Error('live rewrite requested but no API key was found')));
298
+ continue;
299
+ }
300
+
301
+ try {
302
+ const rawRewrite = candidate ?? await runWithApi(fixture, { ...options, settings });
303
+ const result = liveRequested
304
+ ? await evaluateModelGradedRewrite(fixture, rawRewrite, { ...options, settings, policy })
305
+ : evaluateRewriteQuality(fixture, rawRewrite, options);
306
+ results.push(result);
307
+ } catch (err) {
308
+ results.push(failedResult(fixture, err));
309
+ }
310
+ }
311
+
312
+ return buildReport({ results, settings: redactSettings(settings), policy });
313
+ }
314
+
315
+ function shouldRunLive(options = {}) {
316
+ if (options.dryRun) return false;
317
+ if (options.live !== undefined) return Boolean(options.live);
318
+ const env = options.env || process.env;
319
+ return env.PATINA_LIVE === '1' ||
320
+ Boolean(env.PATINA_LIVE_PROVIDER || env.PATINA_LIVE_API_KEY || env.PATINA_LIVE_MODEL || env.PATINA_LIVE_API_BASE);
321
+ }
322
+
323
+ export function resolveLiveSettings(options = {}) {
324
+ const env = options.env || process.env;
325
+ const providerName = options.provider ?? env.PATINA_LIVE_PROVIDER ?? env.PATINA_PROVIDER ?? null;
326
+ const provider = selectProvider(providerName);
327
+ const explicitApiKey = options.apiKey ?? env.PATINA_LIVE_API_KEY ?? null;
328
+ const fallbackKey = explicitApiKey ? undefined : resolveOptionalApiKey(provider, env, options.apiKeyFile);
329
+ const apiKey = explicitApiKey || fallbackKey?.apiKey || null;
330
+ const apiKeySource = explicitApiKey
331
+ ? (options.apiKey ? 'option:apiKey' : 'env:PATINA_LIVE_API_KEY')
332
+ : fallbackKey?.source ?? null;
333
+ const baseURL = options.baseURL ?? env.PATINA_LIVE_API_BASE ?? env.PATINA_API_BASE;
334
+ const model = options.model ?? env.PATINA_LIVE_MODEL ?? env.PATINA_MODEL;
335
+ const resolved = resolveProviderConfig({ provider, apiKey, baseURL, model });
336
+ const timeoutMs = parsePositiveInt(options.timeoutMs ?? env.PATINA_LIVE_TIMEOUT_MS, 120000);
337
+
338
+ return {
339
+ provider: provider?.name ?? null,
340
+ baseURL: resolved.baseURL,
341
+ model: resolved.model,
342
+ apiKey,
343
+ hasApiKey: Boolean(apiKey),
344
+ apiKeySource,
345
+ baseURLSource: baseURL ? sourceLabel(options.baseURL, env.PATINA_LIVE_API_BASE, 'baseURL') : resolved.baseURLSource,
346
+ modelSource: model ? sourceLabel(options.model, env.PATINA_LIVE_MODEL, 'model') : resolved.modelSource,
347
+ timeoutMs,
348
+ };
349
+ }
350
+
351
+ function resolveOptionalApiKey(provider, env, apiKeyFile) {
352
+ try {
353
+ const envVars = providerHttpKeyEnvVars(provider?.apiKeyEnv);
354
+ const apiKey = resolveHttpApiKey({ apiKeyFile, env, envVars });
355
+ if (!apiKey) return null;
356
+ if (apiKeyFile) return { apiKey, source: 'option:apiKeyFile' };
357
+ const source = envVars.find((key) => env[key]) || 'env:PATINA_API_KEY';
358
+ return { apiKey, source: `env:${source}` };
359
+ } catch (err) {
360
+ if (apiKeyFile) throw err;
361
+ return null;
362
+ }
363
+ }
364
+
365
+ function sourceLabel(optionValue, liveEnvValue, name) {
366
+ if (optionValue) return `option:${name}`;
367
+ if (liveEnvValue) return `env:PATINA_LIVE_${name === 'baseURL' ? 'API_BASE' : 'MODEL'}`;
368
+ return 'env:PATINA';
369
+ }
370
+
371
+ function redactSettings(settings) {
372
+ const { apiKey: _apiKey, ...safe } = settings;
373
+ return safe;
374
+ }
375
+
376
+ function buildReport({ results, settings, policy }) {
377
+ const summary = {
378
+ total: results.length,
379
+ pass: results.filter((result) => result.status === 'pass').length,
380
+ warn: results.filter((result) => result.status === 'warn').length,
381
+ error: results.filter((result) => result.status === 'error' || result.status === 'fail').length,
382
+ skipped: results.filter((result) => result.status === 'skipped').length,
383
+ };
384
+ return {
385
+ schema_version: LIVE_QUALITY_SCHEMA_VERSION,
386
+ settings,
387
+ policy,
388
+ summary,
389
+ results,
390
+ };
391
+ }
392
+
393
+ export function renderMarkdownReport(reportOrResults) {
394
+ const report = Array.isArray(reportOrResults)
395
+ ? buildReport({ results: reportOrResults, settings: { legacy: true }, policy: DEFAULT_POLICY })
396
+ : reportOrResults;
397
+ const lines = [
398
+ '# Patina live rewrite quality',
399
+ '',
400
+ `schema_version: ${report.schema_version}`,
401
+ `provider: ${report.settings.provider ?? 'default'}`,
402
+ `model: ${report.settings.model ?? 'default'}`,
403
+ `api_key: ${report.settings.hasApiKey ? `present (${report.settings.apiKeySource || 'unknown source'})` : 'missing'}`,
404
+ `policy: AI-after<=${report.policy.aiAfterCeiling}, MPS>=${report.policy.mpsFloor}, fidelity>=${report.policy.fidelityFloor}`,
405
+ '',
406
+ '| status | fixture | lang | mode | before | after | delta | mps | fidelity | notes |',
407
+ '|---|---|---:|---|---:|---:|---:|---:|---:|---|',
408
+ ];
409
+
410
+ for (const result of report.results) {
411
+ const notes = [
412
+ ...(result.policy_violations || []),
413
+ ...(result.errors || []),
414
+ result.reason,
415
+ ].filter(Boolean).join('; ');
416
+ const cells = [
417
+ result.status,
418
+ result.fixture_id,
419
+ result.language,
420
+ result.mode || 'offline-candidate',
421
+ formatNumber(result.before_score),
422
+ formatNumber(result.after_score),
423
+ formatNumber(result.ai_delta ?? result.humanization_gain),
424
+ formatNumber(result.mps),
425
+ formatNumber(result.fidelity ?? result.meaning_safety),
426
+ notes || '-',
427
+ ];
428
+ lines.push(`| ${cells.join(' | ')} |`);
429
+ }
430
+
431
+ lines.push('', `Summary: ${report.summary.pass} pass, ${report.summary.warn} warn, ${report.summary.error} error, ${report.summary.skipped} skipped.`);
432
+ return `${lines.join('\n')}\n`;
433
+ }
434
+
435
+ export function parseArgs(argv = process.argv.slice(2)) {
436
+ const options = {};
437
+ for (let i = 0; i < argv.length; i += 1) {
438
+ const arg = argv[i];
439
+ if (arg === '--json') options.json = true;
440
+ else if (arg === '--dry-run') options.dryRun = true;
441
+ else if (arg === '--live') options.live = true;
442
+ else if (arg === '--fixtures') options.fixturePath = resolve(argv[++i]);
443
+ else if (arg === '--candidate-dir') options.candidateDir = argv[++i];
444
+ else if (arg === '--language') options.language = argv[++i];
445
+ else if (arg === '--limit') options.limit = Number(argv[++i]);
446
+ else if (arg === '--model') options.model = argv[++i];
447
+ else if (arg === '--provider') options.provider = argv[++i];
448
+ else if (arg === '--base-url') options.baseURL = argv[++i];
449
+ else if (arg === '--api-key-file') options.apiKeyFile = argv[++i];
450
+ else if (arg === '--timeout-ms') options.timeoutMs = Number(argv[++i]);
451
+ else if (arg === '--help' || arg === '-h') options.help = true;
452
+ else throw new Error(`unknown argument: ${arg}`);
453
+ }
454
+ return options;
455
+ }
456
+
457
+ export async function main(argv = process.argv.slice(2)) {
458
+ const options = parseArgs(argv);
459
+ if (options.help) {
460
+ console.log(helpText());
461
+ return;
462
+ }
463
+
464
+ const report = await runLiveQualityReport(options);
465
+ if (options.json) console.log(JSON.stringify(report, null, 2));
466
+ else process.stdout.write(renderMarkdownReport(report));
467
+
468
+ if (report.summary.error > 0) process.exitCode = 1;
469
+ }
470
+
471
+ async function runWithApi(fixture, options = {}) {
472
+ const prompt = await buildPatinaRewritePrompt(fixture, options);
473
+ const settings = options.settings || resolveLiveSettings(options);
474
+ const callLLM = createLiveCallLLM(options.callLLM || defaultCallLLM, settings);
475
+ return callLLM({
476
+ prompt,
477
+ apiKey: settings.apiKey,
478
+ baseURL: settings.baseURL,
479
+ model: settings.model,
480
+ temperature: 0.2,
481
+ });
482
+ }
483
+
484
+ function selectFixtures(fixtures, { language, limit } = {}) {
485
+ let selected = fixtures;
486
+ if (language) selected = selected.filter((fixture) => fixture.language === language);
487
+ if (Number.isFinite(limit) && limit >= 0) selected = selected.slice(0, limit);
488
+ return selected;
489
+ }
490
+
491
+ function readCandidate(candidateDir, fixtureId) {
492
+ const path = resolve(candidateDir, `${fixtureId}.md`);
493
+ if (!existsSync(path)) throw new Error(`missing candidate rewrite: ${path}`);
494
+ return readFileSync(path, 'utf8');
495
+ }
496
+
497
+ function skippedResult(fixture, reason) {
498
+ const before = scoreProseText(fixture.text, {
499
+ file: `${fixture.fixture_id}.md`,
500
+ lang: fixture.language,
501
+ repoRoot: REPO_ROOT,
502
+ });
503
+ return {
504
+ fixture_id: fixture.fixture_id,
505
+ language: fixture.language,
506
+ register: fixture.register,
507
+ mode: 'skipped',
508
+ before_score: round1(before.score),
509
+ after_score: null,
510
+ humanization_gain: null,
511
+ meaning_safety: null,
512
+ safe_gain: null,
513
+ status: 'skipped',
514
+ reason,
515
+ };
516
+ }
517
+
518
+ function failedResult(fixture, err) {
519
+ return {
520
+ fixture_id: fixture.fixture_id,
521
+ language: fixture.language,
522
+ register: fixture.register,
523
+ mode: 'live-api',
524
+ before_score: null,
525
+ after_score: null,
526
+ ai_delta: null,
527
+ mps: null,
528
+ fidelity: null,
529
+ status: 'error',
530
+ errors: [err.message],
531
+ };
532
+ }
533
+
534
+ function preservedFacts(facts, rewrite, language = 'en') {
535
+ const lowerRewrite = language === 'en' ? rewrite.toLowerCase() : rewrite;
536
+ const preserved = facts.filter((fact) => {
537
+ const needle = language === 'en' ? String(fact).toLowerCase() : String(fact);
538
+ return lowerRewrite.includes(needle);
539
+ });
540
+ return { preserved, total: facts.length };
541
+ }
542
+
543
+ function lengthSafetyScore(original, rewrite) {
544
+ if (!original || !rewrite) return 0;
545
+ const ratio = rewrite.length / original.length;
546
+ if (ratio >= 0.7 && ratio <= 1.3) return 100;
547
+ if (ratio >= 0.5 && ratio <= 1.5) return 80;
548
+ if (ratio >= 0.3 && ratio <= 2.0) return 60;
549
+ return 30;
550
+ }
551
+
552
+ function parsePositiveInt(value, fallback) {
553
+ const n = Number(value);
554
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
555
+ }
556
+
557
+ function numberOrNull(value) {
558
+ const n = Number(value);
559
+ return Number.isFinite(n) ? round1(n) : null;
560
+ }
561
+
562
+ function round1(n) {
563
+ return Math.round(Number(n) * 10) / 10;
564
+ }
565
+
566
+ function formatNumber(n) {
567
+ return Number.isFinite(n) ? Number(n).toFixed(1) : '-';
568
+ }
569
+
570
+ function helpText() {
571
+ return `Usage: npm run quality:live -- [options]
572
+
573
+ Runs the deliberate live rewrite quality probe. By default this does not call a model; pass --live or set PATINA_LIVE=1 to use an OpenAI-compatible provider.
574
+
575
+ Options:
576
+ --live Run credentialed API rewrites and model-graded checks
577
+ --provider <name> Provider preset (openai, gemini, groq, kimi, moonshot, together)
578
+ --model <id> Model id (or PATINA_LIVE_MODEL)
579
+ --base-url <url> OpenAI-compatible base URL (or PATINA_LIVE_API_BASE)
580
+ --api-key-file <path> Read API key from a file
581
+ --timeout-ms <ms> Per fixture live timeout budget (default: 120000)
582
+ --fixtures <path> Fixture directory or legacy JSONL file
583
+ --candidate-dir <dir> Score precomputed rewrites named <fixture_id>.md
584
+ --language <lang> Filter fixtures by language
585
+ --limit <n> Limit selected fixtures
586
+ --json Emit structured JSON report
587
+ --dry-run Force skip mode even if PATINA_LIVE_* is set
588
+ `;
589
+ }
590
+
591
+ if (import.meta.url === `file://${process.argv[1]}`) {
592
+ main().catch((err) => {
593
+ console.error(err.message);
594
+ process.exitCode = 1;
595
+ });
596
+ }