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
package/src/ouroboros.js CHANGED
@@ -3,6 +3,31 @@ import { scoreText, scoreMPS, scoreFidelity, combinedScore } from './scoring.js'
3
3
  import { buildPrompt } from './prompt-builder.js';
4
4
  import { createLogger } from './logger.js';
5
5
 
6
+ /**
7
+ * Run the iterative Ouroboros rewrite-and-score loop.
8
+ *
9
+ * @param {object} options Ouroboros options.
10
+ * @param {object} options.config Effective config with ouroboros settings.
11
+ * @param {object[]} options.patterns Loaded pattern packs.
12
+ * @param {object|null} options.profile Parsed profile.
13
+ * @param {object|null} options.voice Parsed voice guide.
14
+ * @param {object|null} [options.voiceSample] Optional voice sample payload.
15
+ * @param {object|null} options.scoring Parsed scoring guide.
16
+ * @param {string} options.text Source text to improve.
17
+ * @param {string} [options.apiKey] Provider API key.
18
+ * @param {string} [options.baseURL] Provider base URL.
19
+ * @param {string} [options.model] Model id.
20
+ * @param {Function} [options.callLLM] LLM implementation.
21
+ * @param {Function} [options.now] Clock returning epoch milliseconds.
22
+ * @param {Function} [options.sleep] Sleep helper for tests.
23
+ * @param {AbortSignal} [options.signal] External cancellation signal.
24
+ * @param {number} [options.timeout] Per-attempt backend timeout in milliseconds.
25
+ * @param {object} [options.logger] patina logger.
26
+ * @returns {Promise<{finalText: string, finalScore: number, iterations: number, reason: string, log: object[]}>} Final text and iteration log.
27
+ * @throws {Error} When model calls or scoring fail outside handled schema fallbacks.
28
+ * @example
29
+ * const result = await runOuroboros({ config, patterns, profile, voice, scoring, text });
30
+ */
6
31
  export async function runOuroboros({
7
32
  config,
8
33
  patterns,
@@ -18,6 +43,7 @@ export async function runOuroboros({
18
43
  now,
19
44
  sleep,
20
45
  signal,
46
+ timeout,
21
47
  logger = createLogger(),
22
48
  }) {
23
49
  const ouroborosConfig = config.ouroboros || {};
@@ -40,6 +66,7 @@ export async function runOuroboros({
40
66
  now,
41
67
  sleep,
42
68
  signal,
69
+ timeout,
43
70
  logger,
44
71
  });
45
72
 
@@ -98,6 +125,7 @@ export async function runOuroboros({
98
125
  now,
99
126
  sleep,
100
127
  signal,
128
+ timeout,
101
129
  });
102
130
 
103
131
  const scoreResult = await scoreText({
@@ -111,6 +139,7 @@ export async function runOuroboros({
111
139
  now,
112
140
  sleep,
113
141
  signal,
142
+ timeout,
114
143
  logger,
115
144
  });
116
145
 
@@ -134,6 +163,7 @@ export async function runOuroboros({
134
163
  now,
135
164
  sleep,
136
165
  signal,
166
+ timeout,
137
167
  logger,
138
168
  }),
139
169
  scoreFidelity({
@@ -146,6 +176,7 @@ export async function runOuroboros({
146
176
  now,
147
177
  sleep,
148
178
  signal,
179
+ timeout,
149
180
  logger,
150
181
  }),
151
182
  ]);
package/src/output.js CHANGED
@@ -1,9 +1,33 @@
1
+ // @ts-check
1
2
  import { createLogger } from './logger.js';
2
-
3
+ import { analyzeText } from './features/index.js';
4
+ import { TRANSLATIONESE_RULES } from './features/translationese.js';
5
+
6
+ /**
7
+ * Format a raw backend result for CLI output mode and requested format.
8
+ *
9
+ * @param {string|object} result Backend result or structured mode result.
10
+ * @param {string} mode Output mode: rewrite, diff, audit, score, or ouroboros.
11
+ * @param {object} [parsed={}] Parsed CLI options.
12
+ * @param {object} [opts={}] Formatting options.
13
+ * @param {object|null} [opts.tone] Tone metadata to append.
14
+ * @param {object} [opts.logger] Logger for output warnings.
15
+ * @param {object} [opts.env] Environment map for color decisions.
16
+ * @param {object} [opts.stdout] Stdout-like stream for color decisions.
17
+ * @param {string} [opts.auditBackstop] Deterministic audit-mode section to append before the tone footer.
18
+ * @returns {string} User-facing formatted output.
19
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
20
+ * @example
21
+ * const output = formatOutput('[BODY]Hi[/BODY]', 'rewrite');
22
+ */
3
23
  export function formatOutput(result, mode, parsed = {}, opts = {}) {
4
24
  const tone = opts.tone || null;
5
25
  const format = parsed.format || 'markdown';
6
- const body = renderFormattedBody(result, mode, parsed, opts);
26
+ let body = renderFormattedBody(result, mode, parsed, opts);
27
+
28
+ if (mode === 'audit' && format !== 'json' && opts.auditBackstop) {
29
+ body += opts.auditBackstop;
30
+ }
7
31
 
8
32
  if (format === 'json') {
9
33
  return formatJsonOutput({ result, mode, body, tone, gate: parsed.gate });
@@ -18,11 +42,10 @@ export function formatOutput(result, mode, parsed = {}, opts = {}) {
18
42
 
19
43
  function renderFormattedBody(result, mode, parsed = {}, opts = {}) {
20
44
  let body = renderBody(result);
21
- // Only rewrite and ouroboros emit [BODY]/[VARIANT n] tags; diff/audit/score
45
+ // Only rewrite and ouroboros emit [BODY] tags; diff/audit/score
22
46
  // emit tables and don't need the extraction step.
23
47
  if (mode === 'rewrite' || mode === 'ouroboros') {
24
- const variants = extractVariants(body);
25
- body = variants.length > 0 ? formatVariants(variants, body) : stripSelfAudit(body, { logger: opts.logger });
48
+ body = stripSelfAudit(body, { logger: opts.logger });
26
49
  }
27
50
  if (mode === 'diff') {
28
51
  body = colorizeDiff(body, { parsed, env: opts.env, stdout: opts.stdout });
@@ -54,39 +77,18 @@ function colorizeDiff(body, { parsed = {}, env = process.env, stdout = process.s
54
77
  }).join('\n');
55
78
  }
56
79
 
80
+ /**
81
+ * @param {object} [options]
82
+ * @param {object} [options.parsed]
83
+ * @param {boolean} [options.parsed.noColor]
84
+ * @param {Record<string, string|undefined>} [options.env]
85
+ * @param {object} [options.stdout]
86
+ * @param {boolean} [options.stdout.isTTY]
87
+ */
57
88
  function shouldColorDiff({ parsed = {}, env = process.env, stdout = process.stdout } = {}) {
58
89
  return !parsed.noColor && env.NO_COLOR === undefined && stdout?.isTTY === true;
59
90
  }
60
91
 
61
- // v3.11 Phase 3.1: extract [VARIANT n]...[/VARIANT] blocks from a model
62
- // response. Returns an array of { id, text } sorted by id, empty if no
63
- // variant tags are present.
64
- export function extractVariants(body) {
65
- if (!body) return [];
66
- const re = /\[VARIANT\s*(\d+)\]\s*\n([\s\S]*?)\n\s*\[\/VARIANT\]/g;
67
- const out = [];
68
- let m;
69
- while ((m = re.exec(body)) !== null) {
70
- const id = parseInt(m[1], 10);
71
- const text = m[2].trim();
72
- if (text) out.push({ id, text });
73
- }
74
- out.sort((a, b) => a.id - b.id);
75
- return out;
76
- }
77
-
78
- function formatVariants(variants, raw) {
79
- // Surface each variant with a labeled header so users can copy whichever
80
- // voice they want. Strip [SELF_AUDIT] and any tail metadata that follows
81
- // the last [/VARIANT] block, but preserve the YAML footer if present.
82
- const lastClose = raw.lastIndexOf('[/VARIANT]');
83
- const tail = lastClose >= 0
84
- ? raw.slice(lastClose + '[/VARIANT]'.length).replace(/\[SELF_AUDIT\][\s\S]*?\[\/SELF_AUDIT\]/g, '').trim()
85
- : '';
86
- const blocks = variants.map(({ id, text }) => `## Variant ${id}\n\n${text}`);
87
- const merged = blocks.join('\n\n');
88
- return tail ? `${merged}\n\n${tail}` : merged;
89
- }
90
92
 
91
93
  // v3.11 Phase 1.3: parse the model's score table and check that the Weight
92
94
  // column matches the config-supplied category-weights. case-02 found that
@@ -95,6 +97,16 @@ function formatVariants(variants, raw) {
95
97
  //
96
98
  // Returns an array of human-readable warning strings (empty if everything
97
99
  // matches). Caller is responsible for emitting to stderr.
100
+ /**
101
+ * Validate that a model-emitted score table used configured category weights.
102
+ *
103
+ * @param {string} output Score-mode markdown output.
104
+ * @param {object} configWeights Expected category weight map.
105
+ * @returns {string[]} Human-readable warnings for missing, mismatched, or unexpected categories.
106
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
107
+ * @example
108
+ * const warnings = validateScoreWeights('| content | 0.4 | 1 | 10 | 4 |', { content: 0.4 });
109
+ */
98
110
  export function validateScoreWeights(output, configWeights) {
99
111
  if (!output || !configWeights || Object.keys(configWeights).length === 0) {
100
112
  return [];
@@ -201,6 +213,17 @@ function normalizeCategoryName(raw) {
201
213
  // We extract the body block and drop the audit so callers get clean text.
202
214
  // If the model didn't honor the tags (older runs, mocked tests, etc.), we
203
215
  // fall back to returning the full output untouched.
216
+ /**
217
+ * Remove SELF_AUDIT blocks and unwrap the BODY block from rewrite output.
218
+ *
219
+ * @param {string} body Raw model response.
220
+ * @param {object} [options] Strip options.
221
+ * @param {object} [options.logger] Logger for malformed output warnings.
222
+ * @returns {string} Clean user-facing body text.
223
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
224
+ * @example
225
+ * const clean = stripSelfAudit('[BODY]Hello[/BODY]\n[SELF_AUDIT]ok[/SELF_AUDIT]');
226
+ */
204
227
  export function stripSelfAudit(body, { logger = createLogger() } = {}) {
205
228
  if (!body) return body;
206
229
  const bodyOpen = body.indexOf('[BODY]');
@@ -209,7 +232,7 @@ export function stripSelfAudit(body, { logger = createLogger() } = {}) {
209
232
  const stripped = removeSelfAuditBlocks(body).trim();
210
233
  if (stripped !== body.trim()) {
211
234
  logger.warn('output.missing_body_tags', {
212
- message: `[patina] warning: model output omitted [BODY] tags (${body.length} chars); stripped [SELF_AUDIT]. Re-run with --prompt-mode strict if the output looks wrong.`,
235
+ message: `[patina] warning: model output omitted [BODY] tags (${body.length} chars); stripped [SELF_AUDIT]. Try a different backend if the output looks wrong.`,
213
236
  });
214
237
  return stripped;
215
238
  }
@@ -233,9 +256,6 @@ function renderBody(result) {
233
256
  return String(result.raw).trim();
234
257
  }
235
258
 
236
- if (result?.type === 'max-mode') {
237
- return formatMaxModeOutput(result);
238
- }
239
259
 
240
260
  return String(result).trim();
241
261
  }
@@ -282,24 +302,6 @@ function formatJsonOutput({ result, mode, body, tone, gate }) {
282
302
  const scoreDetails = extractScoreDetails(result);
283
303
  if (scoreDetails) payload.scores = scoreDetails;
284
304
 
285
- if (result?.type === 'max-mode') {
286
- payload.max = {
287
- allFailed: Boolean(result.allFailed),
288
- mpsFallback: Boolean(result.mpsFallback),
289
- best: result.best ? {
290
- model: result.best.model,
291
- aiScore: result.best.aiScore ?? null,
292
- mps: result.best.mps ?? null,
293
- } : null,
294
- candidates: result.candidates.map((candidate) => ({
295
- model: candidate.model,
296
- ok: Boolean(candidate.ok),
297
- aiScore: candidate.aiScore ?? null,
298
- mps: candidate.mps ?? null,
299
- error: candidate.error ?? null,
300
- })),
301
- };
302
- }
303
305
 
304
306
  return JSON.stringify(payload, null, 2);
305
307
  }
@@ -436,45 +438,55 @@ function normalizeFooterTail(lines) {
436
438
  .trim();
437
439
  }
438
440
 
439
- function formatMaxModeOutput(result) {
440
- const { candidates, best } = result;
441
+ /**
442
+ * Build a deterministic "backstop" section for audit mode. The LLM audit is
443
+ * model-dependent (a weak model silently drops 번역투/calques); these signals are
444
+ * computed deterministically so they appear regardless of which model ran. ko
445
+ * translationese rules are listed even below the hot-density gate, because audit
446
+ * is a hint surface, not a verdict.
447
+ *
448
+ * @param {string} text Source text.
449
+ * @param {object} [opts]
450
+ * @param {string} [opts.lang]
451
+ * @param {string} [opts.repoRoot]
452
+ * @returns {string} Markdown section (empty string when nothing fired).
453
+ */
454
+ export function buildDeterministicAuditBackstop(text, opts = {}) {
455
+ const lang = opts.lang ?? 'ko';
456
+ const str = typeof text === 'string' ? text : '';
457
+ /** @type {Array<{signal:string,label:string,severity:string,location:string}>} */
458
+ const rows = [];
441
459
 
442
- let output = '## MAX Mode Results\n\n';
443
- if (result.timedOut) {
444
- output += '⚠ MAX wall-clock timeout reached; showing partial results.\n\n';
445
- }
446
- output += '| Model | AI Score | MPS | Status |\n';
447
- output += '|-------|----------|-----|--------|\n';
448
-
449
- for (const c of candidates) {
450
- const status = c.ok ? (c.model === best?.model ? '✅ best' : '✅') : '❌ failed';
451
- const score = c.aiScore ?? '--';
452
- const mps = c.mps ?? '--';
453
- output += `| ${c.model} | ${score} | ${mps} | ${status} |\n`;
460
+ // ko translationese per-rule, with matched samples (model-independent).
461
+ if (lang === 'ko' && str) {
462
+ for (const rule of TRANSLATIONESE_RULES) {
463
+ const matches = str.match(rule.re());
464
+ if (matches && matches.length) {
465
+ const samples = [...new Set(matches.map((m) => m.trim()).filter(Boolean))].slice(0, 4);
466
+ rows.push({ signal: `번역투: ${rule.id}`, label: rule.label, severity: rule.strong ? 'MEDIUM' : 'LOW', location: samples.join(', ') });
467
+ }
468
+ }
454
469
  }
455
470
 
456
- output += `\n**Best: ${best?.model || 'none'}**\n\n`;
457
-
458
- if (result.allFailed) {
459
- output += '> No MAX candidate produced a scoreable result. Exit code: 4.\n\n';
460
- } else if (result.mpsFallback) {
461
- output += '⚠ No candidate passed MPS ≥ 70 — selecting by highest MPS (fallback)\n\n';
462
- output += '> Exit code: 4.\n\n';
471
+ // markup leakage (near-proof) + density-gated discourse tells — language-agnostic.
472
+ const a = analyzeText(str, { lang, repoRoot: opts.repoRoot });
473
+ for (const h of a.markupLeakage?.hits ?? []) {
474
+ rows.push({ signal: 'markup-leakage', label: h.label, severity: 'HIGH', location: (h.samples ?? []).join(', ') });
463
475
  }
464
-
465
- if (best?.result) {
466
- output += '### Final Text\n\n';
467
- output += best.result.trim();
468
- output += '\n\n';
476
+ if (a.discourseTells?.fakeCandor?.hot) {
477
+ rows.push({ signal: 'discourse: fake-candor', label: '친근함 위장 도입부', severity: 'MEDIUM', location: (a.discourseTells.fakeCandor.hits ?? []).join(', ') });
469
478
  }
470
-
471
- for (const c of candidates) {
472
- if (c.model !== best?.model && c.ok && c.result) {
473
- output += `\n<details>\n<summary>${c.model} result</summary>\n\n`;
474
- output += c.result.trim();
475
- output += '\n</details>\n';
476
- }
479
+ if (a.discourseTells?.thematicBreaks?.hot) {
480
+ rows.push({ signal: 'discourse: thematic-breaks', label: '장식용 구분선 남용', severity: 'LOW', location: `${a.discourseTells.thematicBreaks.count}개` });
477
481
  }
478
482
 
479
- return output;
483
+ if (rows.length === 0) return '';
484
+ const lines = [
485
+ '## 결정적 신호 (deterministic backstop — 모델과 무관하게 항상 검사)',
486
+ '',
487
+ '| 신호 | 설명 | 심각도 | 위치 |',
488
+ '|------|------|--------|------|',
489
+ ...rows.map((r) => `| ${r.signal} | ${r.label} | ${r.severity} | ${r.location} |`),
490
+ ];
491
+ return `\n\n${lines.join('\n')}`;
480
492
  }
@@ -1,23 +1,41 @@
1
- export function buildPrompt({
2
- config,
3
- patterns,
4
- profile,
5
- voice,
6
- voiceSample,
7
- scoring,
8
- text,
9
- mode = 'rewrite',
10
- tone = null,
11
- promptMode = 'strict',
12
- variants = 1,
13
- }) {
14
- // v3.11+ prompt-mode dispatch (case-04 hypothesis test). minimal prompt
15
- // strips pattern definitions/examples and uses a casual instruction; only
16
- // applies to rewrite mode where voice prior matters most. Profile body is
17
- // still passed through (Round 2 found Gemini ignored casual-conversation
18
- // when the profile was dropped).
1
+ // @ts-check
2
+ /**
3
+ * Build the LLM prompt for rewrite, diff, audit, score, or ouroboros mode.
4
+ *
5
+ * @param {object} options Prompt inputs.
6
+ * @param {object} options.config Effective patina config.
7
+ * @param {object[]} options.patterns Loaded pattern packs.
8
+ * @param {object|null} options.profile Parsed profile document.
9
+ * @param {object|null} options.voice Parsed voice guide.
10
+ * @param {object|null} [options.voiceSample] Optional voice sample payload.
11
+ * @param {object|null} options.scoring Parsed scoring guide.
12
+ * @param {string} options.text Input text.
13
+ * @param {string} [options.mode=rewrite] Output mode.
14
+ * @param {object|null} [options.tone=null] Tone resolution metadata.
15
+ * @returns {string} Complete prompt text.
16
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
17
+ * @example
18
+ * const prompt = buildPrompt({ config, patterns, profile, voice, scoring, text: 'Draft' });
19
+ */
20
+ export function buildPrompt(options) {
21
+ const {
22
+ config,
23
+ patterns,
24
+ profile,
25
+ voice,
26
+ voiceSample,
27
+ scoring,
28
+ text,
29
+ mode = 'rewrite',
30
+ tone = null,
31
+ } = options;
32
+ const promptMode = /** @type {any} */ (options).promptMode || 'strict';
33
+ // v3.11+ internal backend prompt-style dispatch. The compact prompt strips
34
+ // pattern definitions/examples and uses a casual instruction; it only applies
35
+ // to rewrite mode where voice prior matters most. Profile body is still passed
36
+ // through (Round 2 found Gemini ignored casual-conversation when omitted).
19
37
  if (promptMode === 'minimal' && mode === 'rewrite') {
20
- return buildMinimalPrompt({ config, patterns, profile, voiceSample, text, tone, variants });
38
+ return buildMinimalPrompt({ config, patterns, profile, voiceSample, text, tone });
21
39
  }
22
40
 
23
41
  const lang = config.language || 'ko';
@@ -100,7 +118,7 @@ export function buildPrompt({
100
118
  prompt += `Process the following text according to the output mode "${mode}".\n\n`;
101
119
 
102
120
  if (mode === 'rewrite') {
103
- prompt += buildRewriteInstructions(structurePacks, lexicalPacks, { variants });
121
+ prompt += buildRewriteInstructions(structurePacks, lexicalPacks, { lang });
104
122
  } else if (mode === 'diff') {
105
123
  prompt += buildDiffInstructions();
106
124
  } else if (mode === 'audit') {
@@ -117,7 +135,7 @@ export function buildPrompt({
117
135
  return prompt;
118
136
  }
119
137
 
120
- function buildRewriteInstructions(structurePacks, lexicalPacks, { includeSelfAudit = true, variants = 1 } = {}) {
138
+ function buildRewriteInstructions(structurePacks, lexicalPacks, { includeSelfAudit = true, lang = 'ko' } = {}) {
121
139
  const phaseCount = includeSelfAudit ? 3 : 2;
122
140
  let inst = `Follow the ${phaseCount}-Phase pipeline:\n\n`;
123
141
 
@@ -145,6 +163,11 @@ function buildRewriteInstructions(structurePacks, lexicalPacks, { includeSelfAud
145
163
  inst += `4. Match profile tone\n`;
146
164
  inst += `5. Inject personality per voice guidelines\n`;
147
165
  inst += `6. Respect blocklist/allowlist and pattern overrides\n\n`;
166
+ const cjkGuard = buildCjkClauseRewriteGuard(lang);
167
+ if (cjkGuard) {
168
+ inst += `${cjkGuard}\n`;
169
+ }
170
+
148
171
 
149
172
  if (includeSelfAudit) {
150
173
  inst += `### Phase 3: Self-Audit\n\n`;
@@ -153,7 +176,7 @@ function buildRewriteInstructions(structurePacks, lexicalPacks, { includeSelfAud
153
176
  inst += `3. Ensure Phase 1 corrections were not reverted in Phase 2\n`;
154
177
  inst += `4. Final check: meaning preserved?\n\n`;
155
178
 
156
- inst += buildOutputFormatBlock({ variants });
179
+ inst += buildOutputFormatBlock();
157
180
  } else {
158
181
  // Self-audit suppressed: external evaluators (scoreText, scoreMPS,
159
182
  // scoreFidelity) handle AI-tell detection, polarity, and meaning checks
@@ -165,50 +188,55 @@ function buildRewriteInstructions(structurePacks, lexicalPacks, { includeSelfAud
165
188
  return inst;
166
189
  }
167
190
 
168
- // v3.11: emit the strict-mode output-format block. Single-variant uses
169
- // [BODY]/[/BODY]; --variants > 1 uses [VARIANT n]/[/VARIANT] blocks.
170
- function buildOutputFormatBlock({ variants = 1 } = {}) {
171
- const isVariants = variants > 1;
172
- const tag = isVariants ? '[VARIANT n]/[/VARIANT]' : '[BODY]/[/BODY]';
173
- const itemDesc = isVariants
174
- ? `Produce ${variants} stylistic VARIANTS of the rewrite, each wrapped in ` +
175
- `\`[VARIANT n]\`/\`[/VARIANT]\` tags where n is 1..${variants}. Each ` +
176
- `variant must preserve all facts, numbers, and causation, but differ in ` +
177
- `voice (e.g., V1 casual conversational, V2 direct/punchy, V3 measured/` +
178
- `professional). No headings, no preamble inside the tags.`
179
- : `The rewritten text wrapped in \`[BODY]\`/\`[/BODY]\` tags. The body ` +
180
- `block must contain ONLY the user-facing rewrite — no headings, no ` +
181
- `Phase labels, no preamble like "잔여 AI 티" or "최종 결과물".`;
182
- const auditDesc = isVariants
183
- ? `(brief: what differs across variants, residual AI signals, applied patterns)`
184
- : `(brief: what still looks AI-written, which patterns were applied). ` +
185
- `This block is for downstream review — patina strips it before showing the user`;
186
-
187
- let exampleBody = '';
188
- if (isVariants) {
189
- for (let i = 1; i <= variants; i++) {
190
- exampleBody += `[VARIANT ${i}]\n<rewritten text — voice ${i}>\n[/VARIANT]\n\n`;
191
- }
192
- } else {
193
- exampleBody = `[BODY]\n<rewritten text>\n[/BODY]\n\n`;
194
- }
195
-
191
+ function buildOutputFormatBlock() {
196
192
  return (
197
193
  `### Output format (STRICT — v3.11)\n\n` +
198
194
  `Produce output in this exact order, with no other text outside the tagged blocks:\n\n` +
199
- `1. ${itemDesc}\n` +
200
- `2. Self-audit notes wrapped in \`[SELF_AUDIT]\`/\`[/SELF_AUDIT]\` tags ${auditDesc}.\n` +
195
+ `1. The rewritten text wrapped in \`[BODY]\`/\`[/BODY]\` tags. The body ` +
196
+ `block must contain ONLY the user-facing rewrite no headings, no ` +
197
+ `Phase labels, no preamble like "잔여 AI 티" or "최종 결과물".\n` +
198
+ `2. Self-audit notes wrapped in \`[SELF_AUDIT]\`/\`[/SELF_AUDIT]\` tags ` +
199
+ `(brief: what still looks AI-written, which patterns were applied). ` +
200
+ `This block is for downstream review — patina strips it before showing the user.\n` +
201
201
  `3. The Phase 6 YAML footer if tone resolution requires it.\n\n` +
202
- `Example shape (uses ${tag}):\n\n` +
202
+ `Example shape (uses [BODY]/[/BODY]):\n\n` +
203
203
  '```\n' +
204
- exampleBody +
205
- `[SELF_AUDIT]\n- ${isVariants ? 'voice axis' : 'residual signals'}: ...\n` +
206
- `- ${isVariants ? 'residual signals' : 'patterns applied'}: ...\n[/SELF_AUDIT]\n\n` +
204
+ `[BODY]\n<rewritten text>\n[/BODY]\n\n` +
205
+ `[SELF_AUDIT]\n- residual signals: ...\n` +
206
+ `- patterns applied: ...\n[/SELF_AUDIT]\n\n` +
207
207
  `---\ntone: ...\ntone_source: ...\ntone_evidence: [...]\ntone_confidence: ...\n---\n` +
208
208
  '```\n'
209
209
  );
210
210
  }
211
211
 
212
+ function buildCjkClauseRewriteGuard(lang) {
213
+ if (!['ko', 'zh', 'ja'].includes(lang)) return '';
214
+
215
+ const shared = [
216
+ `### CJK clause-level rewrite guard`,
217
+ ``,
218
+ `For Korean, Chinese, and Japanese, do not fix AI tells by swapping punctuation or single tokens in place. Read the full sentence, then rewrite the affected clause or sentence so the clause relationship is idiomatic in the target language.`,
219
+ `- If the suspect segment uses connective punctuation (em dash, colon, semicolon, slash, comma splice, parenthetical aside), choose a natural clause structure, sentence split, or connective phrase; do not replace every mark 1:1 with a comma or parentheses.`,
220
+ `- If a calque/translationese phrase is attached to punctuation, fix both together at clause level. Preserve who did what, polarity, conditions, numbers, and causation.`,
221
+ ];
222
+
223
+ if (lang === 'ko') {
224
+ shared.push(
225
+ `- Korean examples: write "TUI 없이 완전 자율로 설치하려면 ..." rather than "무 TUI ..."; write "끝난 것 같아요"만으로는 부족한, 결과를 끝까지 확인해야 하는 열린 작업 rather than "끝난 것 같아요"로는 부족한 열린 작업.`
226
+ );
227
+ } else if (lang === 'zh') {
228
+ shared.push(
229
+ `- Chinese example: "不用 TUI 就能全自动安装时,打开自律模式参数" is preferable to a literal "无 TUI 设置"; an em dash should become a causal, contrastive, or appositive clause only when that relation is present.`
230
+ );
231
+ } else if (lang === 'ja') {
232
+ shared.push(
233
+ `- Japanese example: "TUIなしで完全自律インストールにしたい場合は..." is preferable to a literal calque; an em dash should become a natural 接続, 説明節, or sentence split only when the relation is present.`
234
+ );
235
+ }
236
+
237
+ return `${shared.join('\n')}\n`;
238
+ }
239
+
212
240
  function buildDiffInstructions() {
213
241
  return `Show what changed and why, pattern by pattern. For each change use this exact label format:\n\n` +
214
242
  `Pattern: N. Pattern Name\n` +
@@ -271,6 +299,15 @@ function buildScoreInstructions(config, lang, text = '') {
271
299
 
272
300
  // v3.11 Phase 3.2 helper: classify a text as "short" for scoring boost.
273
301
  // Threshold: ≤200 non-whitespace chars OR ≤3 non-empty paragraphs.
302
+ /**
303
+ * Classify whether text should use the short-text scoring boost.
304
+ *
305
+ * @param {string} text Text to inspect.
306
+ * @returns {boolean} True when text is <=200 non-whitespace chars or <=3 paragraphs.
307
+ * @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
308
+ * @example
309
+ * const short = isShortText('A short note.');
310
+ */
274
311
  export function isShortText(text) {
275
312
  if (!text) return true;
276
313
  const stripped = text.replace(/\s+/g, '');
@@ -284,7 +321,7 @@ export function isShortText(text) {
284
321
  // model's natural voice prior isn't overridden by analytical framing. Only
285
322
  // invoked for rewrite mode; score/audit/diff/ouroboros stay on the strict
286
323
  // path because they need precise pattern references.
287
- function buildMinimalPrompt({ config, patterns, profile, voiceSample, text, tone, variants = 1 }) {
324
+ function buildMinimalPrompt({ config, patterns, profile, voiceSample, text, tone }) {
288
325
  const lang = config.language || 'ko';
289
326
  const activePatterns = patterns.filter((p) => !p.isScoreOnly);
290
327
 
@@ -302,6 +339,10 @@ function buildMinimalPrompt({ config, patterns, profile, voiceSample, text, tone
302
339
  : `This text reads like AI. Rewrite it so it sounds like a real person wrote it. If you spot any of the phrases below, swap them out for something natural. Don't over-paraphrase — keep the meaning, numbers, and causation intact.`;
303
340
 
304
341
  let prompt = `${instruction}\n\n`;
342
+ const cjkGuard = buildCjkClauseRewriteGuard(lang);
343
+ if (cjkGuard) {
344
+ prompt += `${cjkGuard}\n`;
345
+ }
305
346
 
306
347
  if (watchWords.length > 0) {
307
348
  prompt += lang === 'ko' ? `## AI 신호 어휘 (참고)\n\n` : `## AI signal words (reference)\n\n`;
@@ -328,16 +369,9 @@ function buildMinimalPrompt({ config, patterns, profile, voiceSample, text, tone
328
369
  }
329
370
 
330
371
  prompt += lang === 'ko' ? `## 출력 형식\n\n` : `## Output format\n\n`;
331
- if (variants > 1) {
332
- prompt += `1. ${variants}개 voice variant를 각각 \`[VARIANT 1]\` ~ \`[VARIANT ${variants}]\` ` +
333
- `태그 안에. 사실·숫자·인과관계는 동일하되 voice만 다르게 (예: V1 캐주얼 대화체, V2 직설·짧은 문장, V3 정중·차분).\n`;
334
- prompt += `2. \`[SELF_AUDIT]\` ... \`[/SELF_AUDIT]\` 안에 짧게: variant별 voice 차이, 남은 AI 신호.\n`;
335
- prompt += `3. 톤 정보가 있으면 마지막에 YAML 푸터.\n\n`;
336
- } else {
337
- prompt += `1. 다듬은 본문을 \`[BODY]\` ... \`[/BODY]\` 안에. 본문만, 머리말·메타·"최종 결과물" 같은 라벨 없이.\n`;
338
- prompt += `2. \`[SELF_AUDIT]\` ... \`[/SELF_AUDIT]\` 안에 짧게: 어떤 부분 손봤는지, 남은 AI 신호 있는지.\n`;
339
- prompt += `3. 톤 정보가 있으면 마지막에 YAML 푸터: \`---\\ntone: ...\\ntone_source: ...\\ntone_evidence: [...]\\ntone_confidence: ...\\n---\`\n\n`;
340
- }
372
+ prompt += `1. 다듬은 본문을 \`[BODY]\` ... \`[/BODY]\` 안에. 본문만, 머리말·메타·"최종 결과물" 같은 라벨 없이.\n`;
373
+ prompt += `2. \`[SELF_AUDIT]\` ... \`[/SELF_AUDIT]\` 안에 짧게: 어떤 부분 손봤는지, 남은 AI 신호 있는지.\n`;
374
+ prompt += `3. 정보가 있으면 마지막에 YAML 푸터: \`---\\ntone: ...\\ntone_source: ...\\ntone_evidence: [...]\\ntone_confidence: ...\\n---\`\n\n`;
341
375
 
342
376
  prompt += lang === 'ko' ? `## 입력\n\n${text}\n\n` : `## Input\n\n${text}\n\n`;
343
377
  prompt += lang === 'ko' ? `## 출력\n\n` : `## Output\n\n`;
@@ -385,6 +419,7 @@ function buildOuroborosInstructions(config, structurePacks, lexicalPacks) {
385
419
  const fidelityFloor = ouroboros['fidelity-floor'] ?? 70;
386
420
  const mpsFloor = ouroboros['mps-floor'] ?? 70;
387
421
 
422
+ const lang = config.language || 'ko';
388
423
  let inst = `Iterative self-improvement loop:\n\n`;
389
424
  inst += `1. Measure initial AI-likeness score\n`;
390
425
  inst += `2. If score ≤ ${targetScore}, stop immediately\n`;
@@ -403,7 +438,7 @@ function buildOuroborosInstructions(config, structurePacks, lexicalPacks) {
403
438
  // Skip Phase 3 self-audit: each iteration runs through external evaluators
404
439
  // (scoreText, scoreMPS, scoreFidelity) in src/ouroboros.js, so an in-prompt
405
440
  // self-audit duplicates work and inflates token cost.
406
- inst += buildRewriteInstructions(structurePacks, lexicalPacks, { includeSelfAudit: false });
441
+ inst += buildRewriteInstructions(structurePacks, lexicalPacks, { includeSelfAudit: false, lang });
407
442
 
408
443
  return inst;
409
444
  }