patina-cli 3.11.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 (180) hide show
  1. package/.patina.default.yaml +211 -0
  2. package/CHANGELOG.md +265 -0
  3. package/LICENSE +21 -0
  4. package/README.md +319 -0
  5. package/README_JA.md +254 -0
  6. package/README_KR.md +253 -0
  7. package/README_ZH.md +254 -0
  8. package/SKILL-MAX.md +455 -0
  9. package/SKILL.md +730 -0
  10. package/assets/brand/patina-icon.svg +9 -0
  11. package/assets/brand/patina-logo.svg +17 -0
  12. package/assets/social/patina-before-after.svg +46 -0
  13. package/assets/social/patina-og.svg +31 -0
  14. package/bin/patina.js +9 -0
  15. package/core/scoring.md +657 -0
  16. package/core/standalone-prompt.md +364 -0
  17. package/core/stylometry.md +754 -0
  18. package/core/voice.md +163 -0
  19. package/docs/AUTHENTICATION.md +105 -0
  20. package/docs/AUTHENTICATION_KR.md +105 -0
  21. package/docs/BRANDING.md +37 -0
  22. package/docs/CLI.md +80 -0
  23. package/docs/COMPARISON.md +38 -0
  24. package/docs/COOKBOOK.md +173 -0
  25. package/docs/DEMO.md +40 -0
  26. package/docs/ETHICS.md +27 -0
  27. package/docs/EXAMPLES.md +130 -0
  28. package/docs/EXAMPLES_KR.md +130 -0
  29. package/docs/EXIT-CODES.md +25 -0
  30. package/docs/FAQ.md +67 -0
  31. package/docs/FAQ_KR.md +65 -0
  32. package/docs/FLAG-PARITY.md +53 -0
  33. package/docs/GLOSSARY.md +123 -0
  34. package/docs/PATTERNS-EN.md +718 -0
  35. package/docs/PATTERNS-JA.md +706 -0
  36. package/docs/PATTERNS-KO.md +707 -0
  37. package/docs/PATTERNS-ZH.md +706 -0
  38. package/docs/PATTERNS.md +22 -0
  39. package/docs/ROADMAP.md +315 -0
  40. package/docs/audits/2026-05-deep-research.md +290 -0
  41. package/docs/benchmarks/detector-comparison.json +442 -0
  42. package/docs/benchmarks/detector-comparison.md +65 -0
  43. package/docs/benchmarks/latest.json +988 -0
  44. package/docs/benchmarks/latest.md +112 -0
  45. package/docs/integrations/docker.md +19 -0
  46. package/docs/integrations/github-action.md +59 -0
  47. package/docs/integrations/pre-commit.md +77 -0
  48. package/docs/integrations/release.md +43 -0
  49. package/docs/internal/HARNESS.md +14 -0
  50. package/docs/internal/README.md +14 -0
  51. package/docs/internal/WARP.md +23 -0
  52. package/docs/research/2025-rebaseline-plan.md +89 -0
  53. package/docs/research/ai-human-metrics.md +380 -0
  54. package/docs/social/gstack-cardnews.html +236 -0
  55. package/docs/social/gstack-cardnews.md +88 -0
  56. package/docs/social/gstack-thread.md +106 -0
  57. package/docs/social/patina-launch-copy.md +227 -0
  58. package/docs/superpowers/specs/2026-04-03-meaning-preservation-design.md +299 -0
  59. package/lexicon/ai-en.md +162 -0
  60. package/lexicon/ai-ko.md +159 -0
  61. package/package.json +100 -0
  62. package/patina-max/SKILL.md +523 -0
  63. package/patina-max/composite.py +457 -0
  64. package/patterns/en-communication.md +89 -0
  65. package/patterns/en-content.md +133 -0
  66. package/patterns/en-filler.md +113 -0
  67. package/patterns/en-language.md +163 -0
  68. package/patterns/en-structure.md +173 -0
  69. package/patterns/en-style.md +139 -0
  70. package/patterns/en-viral-hook.md +211 -0
  71. package/patterns/ja-communication.md +101 -0
  72. package/patterns/ja-content.md +153 -0
  73. package/patterns/ja-filler.md +123 -0
  74. package/patterns/ja-language.md +190 -0
  75. package/patterns/ja-structure.md +142 -0
  76. package/patterns/ja-style.md +147 -0
  77. package/patterns/ja-viral-hook.md +216 -0
  78. package/patterns/ko-communication.md +98 -0
  79. package/patterns/ko-content.md +154 -0
  80. package/patterns/ko-filler.md +105 -0
  81. package/patterns/ko-language.md +182 -0
  82. package/patterns/ko-structure.md +147 -0
  83. package/patterns/ko-style.md +146 -0
  84. package/patterns/ko-viral-hook.md +211 -0
  85. package/patterns/zh-communication.md +101 -0
  86. package/patterns/zh-content.md +153 -0
  87. package/patterns/zh-filler.md +118 -0
  88. package/patterns/zh-language.md +173 -0
  89. package/patterns/zh-structure.md +145 -0
  90. package/patterns/zh-style.md +159 -0
  91. package/patterns/zh-viral-hook.md +216 -0
  92. package/profiles/academic.md +53 -0
  93. package/profiles/blog.md +81 -0
  94. package/profiles/casual-conversation.md +105 -0
  95. package/profiles/code-comment.md +104 -0
  96. package/profiles/commit-message.md +99 -0
  97. package/profiles/default.md +62 -0
  98. package/profiles/email.md +52 -0
  99. package/profiles/formal.md +98 -0
  100. package/profiles/instructional.md +80 -0
  101. package/profiles/legal.md +57 -0
  102. package/profiles/marketing.md +56 -0
  103. package/profiles/medical.md +53 -0
  104. package/profiles/narrative.md +79 -0
  105. package/profiles/release-notes.md +98 -0
  106. package/profiles/social.md +56 -0
  107. package/profiles/technical.md +53 -0
  108. package/scripts/benchmark-report.mjs +252 -0
  109. package/scripts/check-release-metadata.mjs +48 -0
  110. package/scripts/detector-comparison.mjs +267 -0
  111. package/scripts/lint.mjs +40 -0
  112. package/scripts/precommit-score.mjs +31 -0
  113. package/scripts/prose-score.mjs +186 -0
  114. package/scripts/update-benchmark-ranges.mjs +108 -0
  115. package/src/api.js +330 -0
  116. package/src/auth.js +105 -0
  117. package/src/backends/claude-cli.js +112 -0
  118. package/src/backends/codex-cli.js +121 -0
  119. package/src/backends/contract.js +21 -0
  120. package/src/backends/gemini-cli.js +135 -0
  121. package/src/backends/index.js +159 -0
  122. package/src/cache.js +106 -0
  123. package/src/cli.js +1280 -0
  124. package/src/commands/doctor.js +229 -0
  125. package/src/commands/init.js +208 -0
  126. package/src/config.js +126 -0
  127. package/src/errors.js +53 -0
  128. package/src/features/index.js +96 -0
  129. package/src/features/lexicon.js +90 -0
  130. package/src/features/segment.js +49 -0
  131. package/src/features/stylometry.js +50 -0
  132. package/src/loader.js +103 -0
  133. package/src/logger.js +70 -0
  134. package/src/manifest.js +162 -0
  135. package/src/max-mode.js +207 -0
  136. package/src/ouroboros.js +233 -0
  137. package/src/output.js +480 -0
  138. package/src/prompt-builder.js +409 -0
  139. package/src/providers.js +100 -0
  140. package/src/scoring.js +531 -0
  141. package/src/security.js +133 -0
  142. package/tests/fixtures/suspect-zones/en/ai/en-ai-01.md +16 -0
  143. package/tests/fixtures/suspect-zones/en/ai/en-ai-02.md +16 -0
  144. package/tests/fixtures/suspect-zones/en/ai/en-ai-03.md +17 -0
  145. package/tests/fixtures/suspect-zones/en/ai/en-ai-04.md +15 -0
  146. package/tests/fixtures/suspect-zones/en/ai/en-ai-05.md +16 -0
  147. package/tests/fixtures/suspect-zones/en/ai/en-ai-06-chat-register.md +16 -0
  148. package/tests/fixtures/suspect-zones/en/natural/en-nat-01.md +15 -0
  149. package/tests/fixtures/suspect-zones/en/natural/en-nat-02.md +15 -0
  150. package/tests/fixtures/suspect-zones/en/natural/en-nat-03.md +15 -0
  151. package/tests/fixtures/suspect-zones/en/natural/en-nat-04.md +15 -0
  152. package/tests/fixtures/suspect-zones/en/natural/en-nat-05.md +15 -0
  153. package/tests/fixtures/suspect-zones/expected-ranges.json +939 -0
  154. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-01.md +11 -0
  155. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-02.md +11 -0
  156. package/tests/fixtures/suspect-zones/ja/ai/ja-ai-03.md +11 -0
  157. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-01.md +11 -0
  158. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-02.md +11 -0
  159. package/tests/fixtures/suspect-zones/ja/natural/ja-nat-03.md +11 -0
  160. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-01.md +14 -0
  161. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +16 -0
  162. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-03.md +15 -0
  163. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-04.md +15 -0
  164. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-05.md +16 -0
  165. package/tests/fixtures/suspect-zones/ko/ai/ko-ai-06-chat-register.md +16 -0
  166. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-01.md +15 -0
  167. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-02.md +15 -0
  168. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-03.md +15 -0
  169. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-04.md +14 -0
  170. package/tests/fixtures/suspect-zones/ko/natural/ko-nat-05.md +15 -0
  171. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-01.md +11 -0
  172. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-02.md +11 -0
  173. package/tests/fixtures/suspect-zones/zh/ai/zh-ai-03.md +11 -0
  174. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-01.md +11 -0
  175. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-02.md +11 -0
  176. package/tests/fixtures/suspect-zones/zh/natural/zh-nat-03.md +11 -0
  177. package/tests/quality/README.md +121 -0
  178. package/tests/quality/benchmark.mjs +306 -0
  179. package/tests/quality/detectors.manual.example.json +31 -0
  180. package/tests/quality/dogfood.mjs +44 -0
package/src/output.js ADDED
@@ -0,0 +1,480 @@
1
+ import { createLogger } from './logger.js';
2
+
3
+ export function formatOutput(result, mode, parsed = {}, opts = {}) {
4
+ const tone = opts.tone || null;
5
+ const format = parsed.format || 'markdown';
6
+ const body = renderFormattedBody(result, mode, parsed, opts);
7
+
8
+ if (format === 'json') {
9
+ return formatJsonOutput({ result, mode, body, tone, gate: parsed.gate });
10
+ }
11
+
12
+ if (format === 'text') {
13
+ return formatTextOutput(body, tone);
14
+ }
15
+
16
+ return appendToneFooter(body, tone);
17
+ }
18
+
19
+ function renderFormattedBody(result, mode, parsed = {}, opts = {}) {
20
+ let body = renderBody(result);
21
+ // Only rewrite and ouroboros emit [BODY]/[VARIANT n] tags; diff/audit/score
22
+ // emit tables and don't need the extraction step.
23
+ if (mode === 'rewrite' || mode === 'ouroboros') {
24
+ const variants = extractVariants(body);
25
+ body = variants.length > 0 ? formatVariants(variants, body) : stripSelfAudit(body, { logger: opts.logger });
26
+ }
27
+ if (mode === 'diff') {
28
+ body = colorizeDiff(body, { parsed, env: opts.env, stdout: opts.stdout });
29
+ }
30
+ return body;
31
+ }
32
+
33
+ const ANSI = {
34
+ red: '\x1b[31m',
35
+ green: '\x1b[32m',
36
+ bold: '\x1b[1m',
37
+ reset: '\x1b[0m',
38
+ };
39
+
40
+ function colorizeDiff(body, { parsed = {}, env = process.env, stdout = process.stdout } = {}) {
41
+ if (!shouldColorDiff({ parsed, env, stdout })) return body;
42
+
43
+ return String(body || '').split(/\r?\n/).map((line) => {
44
+ if (/^(\s*)(Removed:)(.*)$/u.test(line)) {
45
+ return line.replace(/^(\s*)(Removed:)(.*)$/u, `$1${ANSI.red}$2$3${ANSI.reset}`);
46
+ }
47
+ if (/^(\s*)(Added:)(.*)$/u.test(line)) {
48
+ return line.replace(/^(\s*)(Added:)(.*)$/u, `$1${ANSI.green}$2$3${ANSI.reset}`);
49
+ }
50
+ if (/^(\s*)(Pattern:)(.*)$/u.test(line)) {
51
+ return line.replace(/^(\s*)(Pattern:)(.*)$/u, `$1${ANSI.bold}$2$3${ANSI.reset}`);
52
+ }
53
+ return line;
54
+ }).join('\n');
55
+ }
56
+
57
+ function shouldColorDiff({ parsed = {}, env = process.env, stdout = process.stdout } = {}) {
58
+ return !parsed.noColor && env.NO_COLOR === undefined && stdout?.isTTY === true;
59
+ }
60
+
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
+
91
+ // v3.11 Phase 1.3: parse the model's score table and check that the Weight
92
+ // column matches the config-supplied category-weights. case-02 found that
93
+ // the model often invents weights or extra categories (e.g., "discord");
94
+ // this surfaces those drifts as warnings rather than silently accepting them.
95
+ //
96
+ // Returns an array of human-readable warning strings (empty if everything
97
+ // matches). Caller is responsible for emitting to stderr.
98
+ export function validateScoreWeights(output, configWeights) {
99
+ if (!output || !configWeights || Object.keys(configWeights).length === 0) {
100
+ return [];
101
+ }
102
+ const warnings = [];
103
+ // Match table rows where the first column is a category label and the
104
+ // second is a numeric weight. Category labels may be localized by weaker
105
+ // models (for example `내용` or `言語`), so parse Unicode letters and map
106
+ // them back to the canonical config keys before comparison.
107
+ const rowRe = /^\|\s*([\p{L}\p{N}_-][^|]*?)\s*\|\s*([0-9]+(?:\.[0-9]+)?)\s*\|/u;
108
+ const seen = new Map();
109
+ for (const line of output.split(/\r?\n/)) {
110
+ const m = line.match(rowRe);
111
+ if (!m) continue;
112
+ const cat = normalizeCategoryName(m[1]);
113
+ if (!cat) continue;
114
+ const weight = parseFloat(m[2]);
115
+ if (!Number.isNaN(weight) && !seen.has(cat)) {
116
+ seen.set(cat, weight);
117
+ }
118
+ }
119
+ for (const [cat, expected] of Object.entries(configWeights)) {
120
+ if (!seen.has(cat)) {
121
+ warnings.push(`weight check: category "${cat}" missing from score output`);
122
+ continue;
123
+ }
124
+ const actual = seen.get(cat);
125
+ if (Math.abs(actual - expected) > 0.005) {
126
+ warnings.push(`weight check: "${cat}" expected ${expected}, model used ${actual}`);
127
+ }
128
+ }
129
+ for (const cat of seen.keys()) {
130
+ if (!(cat in configWeights)) {
131
+ warnings.push(`weight check: unexpected category "${cat}" — likely model hallucination`);
132
+ }
133
+ }
134
+ return warnings;
135
+ }
136
+
137
+ const CATEGORY_ALIASES = new Map([
138
+ ['content', 'content'],
139
+ ['내용', 'content'],
140
+ ['콘텐츠', 'content'],
141
+ ['内容', 'content'],
142
+ ['language', 'language'],
143
+ ['언어', 'language'],
144
+ ['语言', 'language'],
145
+ ['語言', 'language'],
146
+ ['言語', 'language'],
147
+ ['style', 'style'],
148
+ ['문체', 'style'],
149
+ ['스타일', 'style'],
150
+ ['文体', 'style'],
151
+ ['文體', 'style'],
152
+ ['风格', 'style'],
153
+ ['風格', 'style'],
154
+ ['communication', 'communication'],
155
+ ['커뮤니케이션', 'communication'],
156
+ ['소통', 'communication'],
157
+ ['沟通', 'communication'],
158
+ ['溝通', 'communication'],
159
+ ['コミュニケーション', 'communication'],
160
+ ['filler', 'filler'],
161
+ ['채움', 'filler'],
162
+ ['필러', 'filler'],
163
+ ['填充', 'filler'],
164
+ ['フィラー', 'filler'],
165
+ ['structure', 'structure'],
166
+ ['구조', 'structure'],
167
+ ['结构', 'structure'],
168
+ ['結構', 'structure'],
169
+ ['構造', 'structure'],
170
+ ['viral-hook', 'viral-hook'],
171
+ ['viral hook', 'viral-hook'],
172
+ ['바이럴훅', 'viral-hook'],
173
+ ['바이럴-훅', 'viral-hook'],
174
+ ['病毒钩子', 'viral-hook'],
175
+ ['病毒鉤子', 'viral-hook'],
176
+ ['バイラルフック', 'viral-hook'],
177
+ ]);
178
+
179
+ function normalizeCategoryName(raw) {
180
+ const cleaned = String(raw || '')
181
+ .replace(/<[^>]*>/g, '')
182
+ .replace(/[`*_]/g, '')
183
+ .trim()
184
+ .toLowerCase()
185
+ .replace(/\s+/g, ' ')
186
+ .replace(/[::]+$/u, '');
187
+
188
+ if (!cleaned || cleaned === 'total' || cleaned === '합계' || cleaned === '总计' || cleaned === '總計' || cleaned === '合計') {
189
+ return null;
190
+ }
191
+
192
+ const ascii = cleaned.match(/\b(content|language|style|communication|filler|structure|viral[\s-]?hook)\b/);
193
+ if (ascii) return ascii[1].replace(/\s+/, '-');
194
+
195
+ const compact = cleaned.replace(/[\s・·_/]+/gu, '');
196
+ return CATEGORY_ALIASES.get(cleaned) || CATEGORY_ALIASES.get(compact) || compact;
197
+ }
198
+
199
+ // v3.11: rewrite/diff/ouroboros prompts ask the model to wrap user-facing
200
+ // text in [BODY]...[/BODY] and put audit notes in [SELF_AUDIT]...[/SELF_AUDIT].
201
+ // We extract the body block and drop the audit so callers get clean text.
202
+ // If the model didn't honor the tags (older runs, mocked tests, etc.), we
203
+ // fall back to returning the full output untouched.
204
+ export function stripSelfAudit(body, { logger = createLogger() } = {}) {
205
+ if (!body) return body;
206
+ const bodyOpen = body.indexOf('[BODY]');
207
+ const bodyClose = body.indexOf('[/BODY]', bodyOpen);
208
+ if (bodyOpen < 0 || bodyClose <= bodyOpen) {
209
+ const stripped = removeSelfAuditBlocks(body).trim();
210
+ if (stripped !== body.trim()) {
211
+ 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.`,
213
+ });
214
+ return stripped;
215
+ }
216
+ return body;
217
+ }
218
+ const inner = body.slice(bodyOpen + '[BODY]'.length, bodyClose).trim();
219
+ const tail = removeSelfAuditBlocks(body.slice(bodyClose + '[/BODY]'.length)).trim();
220
+ return tail ? `${inner}\n\n${tail}` : inner;
221
+ }
222
+
223
+ function removeSelfAuditBlocks(body) {
224
+ return String(body || '').replace(/\[SELF_AUDIT\][\s\S]*?\[\/SELF_AUDIT\]/g, '');
225
+ }
226
+
227
+ function renderBody(result) {
228
+ if (typeof result === 'string') {
229
+ return result.trim();
230
+ }
231
+
232
+ if (result && typeof result === 'object' && 'raw' in result) {
233
+ return String(result.raw).trim();
234
+ }
235
+
236
+ if (result?.type === 'max-mode') {
237
+ return formatMaxModeOutput(result);
238
+ }
239
+
240
+ return String(result).trim();
241
+ }
242
+
243
+ function extractScoreDetails(result) {
244
+ if (!result || typeof result !== 'object') return null;
245
+ if (!result.llmScore && !result.deterministicScore && !result.scorePreference) return null;
246
+ return {
247
+ llm: result.llmScore ?? null,
248
+ deterministic: result.deterministicScore ?? null,
249
+ preference: result.scorePreference ?? null,
250
+ };
251
+ }
252
+
253
+ function formatTextOutput(body, tone) {
254
+ const lines = [body.trim()];
255
+ if (tone?.tone_source) {
256
+ lines.push(
257
+ '',
258
+ `Tone: ${tone.tone === null || tone.tone === undefined ? 'profile-only' : tone.tone} (${tone.tone_source})`
259
+ );
260
+ }
261
+ return lines.join('\n').trimEnd();
262
+ }
263
+
264
+ function formatJsonOutput({ result, mode, body, tone, gate }) {
265
+ const overall = extractOverall(result, body);
266
+ const payload = {
267
+ mode,
268
+ format: 'json',
269
+ overall,
270
+ categories: extractCategories(result, body),
271
+ tone: tone ? {
272
+ tone: tone.tone ?? null,
273
+ tone_source: tone.tone_source ?? null,
274
+ tone_evidence: Array.isArray(tone.tone_evidence) ? tone.tone_evidence : [],
275
+ tone_confidence: tone.tone_confidence ?? null,
276
+ } : null,
277
+ mps: extractMps(result, body),
278
+ gateResult: buildGateResult(overall, gate),
279
+ output: body,
280
+ };
281
+
282
+ const scoreDetails = extractScoreDetails(result);
283
+ if (scoreDetails) payload.scores = scoreDetails;
284
+
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
+
304
+ return JSON.stringify(payload, null, 2);
305
+ }
306
+
307
+ function buildGateResult(overall, gate) {
308
+ if (gate === undefined) return null;
309
+ if (overall === null) {
310
+ return { threshold: gate, overall: null, passed: null, exitCode: null };
311
+ }
312
+ const passed = overall <= gate;
313
+ return { threshold: gate, overall, passed, exitCode: passed ? 0 : 3 };
314
+ }
315
+
316
+ function extractOverall(result, body) {
317
+ const direct = toFiniteNumber(result?.overall);
318
+ if (direct !== null) return direct;
319
+ const parsed = parseFirstJson(body) || (typeof result === 'string' ? parseFirstJson(result) : null);
320
+ const parsedOverall = toFiniteNumber(parsed?.overall);
321
+ if (parsedOverall !== null) return parsedOverall;
322
+ const overallFromTable = String(body || '').match(/(?:^|\n)\|\s*(?:\*\*)?Overall(?:\*\*)?\s*\|[^|]*\|[^|]*\|[^|]*\|\s*(?:\*\*)?([0-9]+(?:\.[0-9]+)?)/i);
323
+ if (overallFromTable) return Number(overallFromTable[1]);
324
+ const overallFromText = String(body || '').match(/(?:^|[\s{,"])overall(?:["\s]*[:|]|\s+score\s*[:|]?)\s*(\d+(?:\.\d+)?)/i);
325
+ return overallFromText ? Number(overallFromText[1]) : null;
326
+ }
327
+
328
+ function extractMps(result, body) {
329
+ const direct = toFiniteNumber(result?.mps ?? result?.best?.mps);
330
+ if (direct !== null) return direct;
331
+ const parsed = parseFirstJson(body);
332
+ return toFiniteNumber(parsed?.mps);
333
+ }
334
+
335
+ function extractCategories(result, body) {
336
+ const direct = normalizeCategories(result?.categories);
337
+ if (direct.length > 0) return direct;
338
+
339
+ const parsed = parseFirstJson(body) || (typeof result === 'string' ? parseFirstJson(result) : null);
340
+ const parsedCategories = normalizeCategories(parsed?.categories);
341
+ if (parsedCategories.length > 0) return parsedCategories;
342
+
343
+ return parseMarkdownCategories(body);
344
+ }
345
+
346
+ function normalizeCategories(categories) {
347
+ if (Array.isArray(categories)) {
348
+ return categories.map((category) => ({ ...category }));
349
+ }
350
+ if (!categories || typeof categories !== 'object') {
351
+ return [];
352
+ }
353
+ return Object.entries(categories).map(([name, value]) => ({
354
+ name,
355
+ ...(value && typeof value === 'object' ? value : { value }),
356
+ }));
357
+ }
358
+
359
+ function parseMarkdownCategories(body) {
360
+ const rows = [];
361
+ for (const line of String(body || '').split(/\r?\n/)) {
362
+ if (!line.trim().startsWith('|')) continue;
363
+ const cells = line.split('|').slice(1, -1).map((cell) => cell.trim().replace(/^\*\*|\*\*$/g, ''));
364
+ if (cells.length < 5) continue;
365
+ const [name, weight, detected, rawScore, weighted] = cells;
366
+ if (!name || /^-+$/.test(name) || /^category$/i.test(name) || /^overall$/i.test(name)) continue;
367
+ rows.push({
368
+ name: normalizeCategoryName(name) || name,
369
+ weight: toFiniteNumber(weight),
370
+ detected: toFiniteNumber(detected),
371
+ rawScore: toFiniteNumber(rawScore),
372
+ weighted: toFiniteNumber(weighted),
373
+ });
374
+ }
375
+ return rows;
376
+ }
377
+
378
+ function toFiniteNumber(value) {
379
+ if (value === null || value === undefined || value === '') return null;
380
+ const n = Number(String(value).replace(/[^\d.-]/g, ''));
381
+ return Number.isFinite(n) ? n : null;
382
+ }
383
+
384
+ function parseFirstJson(text) {
385
+ if (!text || typeof text !== 'string') return null;
386
+ const candidates = [
387
+ text.trim(),
388
+ text.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1],
389
+ text.match(/\{[\s\S]*\}/)?.[0],
390
+ ].filter(Boolean);
391
+ for (const candidate of candidates) {
392
+ try {
393
+ return JSON.parse(candidate);
394
+ } catch {}
395
+ }
396
+ return null;
397
+ }
398
+
399
+ // Append the v3.10 YAML footer to every output mode (rewrite/diff/audit/score).
400
+ // SKILL.md Phase 6 spec: footer is the *only* sanctioned tone-info surface.
401
+ // If the LLM already emitted a footer (it should, per SKILL.md), do not duplicate.
402
+ function appendToneFooter(body, tone) {
403
+ if (!tone || !tone.tone_source) return body;
404
+ if (hasToneFooter(body)) return body;
405
+
406
+ const lines = ['', '---'];
407
+ lines.push(`tone: ${tone.tone === null || tone.tone === undefined ? 'null' : tone.tone}`);
408
+ lines.push(`tone_source: ${tone.tone_source}`);
409
+ const ev = Array.isArray(tone.tone_evidence) ? tone.tone_evidence : [];
410
+ lines.push(`tone_evidence: ${JSON.stringify(ev)}`);
411
+ lines.push(`tone_confidence: ${tone.tone_confidence ?? 'null'}`);
412
+ lines.push('---');
413
+ return `${body}\n${lines.join('\n')}\n`;
414
+ }
415
+
416
+ // Detect a trailing YAML footer block emitted by the model. Match a `---`
417
+ // fenced block within the last ~30 non-empty lines that contains a `tone:`
418
+ // key. We avoid double-printing when the model honored Phase 6.
419
+ function hasToneFooter(body) {
420
+ if (!body) return false;
421
+ const tail = normalizeFooterTail(body.split(/\r?\n/).slice(-30));
422
+ const m = tail.match(/(^|\n)---\s*\n([\s\S]*?)\n---\s*$/);
423
+ if (!m) return false;
424
+ const block = m[2];
425
+ return /\btone\s*:/.test(block)
426
+ && /\btone_source\s*:/.test(block)
427
+ && /\btone_evidence\s*:/.test(block)
428
+ && /\btone_confidence\s*:/.test(block);
429
+ }
430
+
431
+ function normalizeFooterTail(lines) {
432
+ return lines
433
+ .map((line) => line.replace(/^\s*>\s?/u, '').trimEnd())
434
+ .filter((line) => !/^\s*```[\w-]*\s*$/u.test(line))
435
+ .join('\n')
436
+ .trim();
437
+ }
438
+
439
+ function formatMaxModeOutput(result) {
440
+ const { candidates, best } = result;
441
+
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`;
454
+ }
455
+
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';
463
+ }
464
+
465
+ if (best?.result) {
466
+ output += '### Final Text\n\n';
467
+ output += best.result.trim();
468
+ output += '\n\n';
469
+ }
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
+ }
477
+ }
478
+
479
+ return output;
480
+ }