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.
- package/.patina.default.yaml +29 -29
- package/CHANGELOG.md +53 -0
- package/NOTICE +21 -0
- package/README.md +117 -224
- package/README_JA.md +134 -77
- package/README_KR.md +132 -74
- package/README_ZH.md +137 -80
- package/SKILL.md +11 -20
- package/artifacts/rebaseline-2025/README.md +147 -0
- package/artifacts/rebaseline-2025/human-controls.public.jsonl +250 -0
- package/artifacts/rebaseline-2025/intake.example.jsonl +2 -0
- package/artifacts/rebaseline-2025/intake.local.example.jsonl +25 -0
- package/artifacts/rebaseline-2025/prompts.template.jsonl +7 -0
- package/artifacts/rebaseline-2025/sources.ko-public.jsonl +39 -0
- package/assets/brand/patina-badge.svg +18 -0
- package/assets/brand/patina-mark.svg +8 -0
- package/assets/demo/README.md +79 -0
- package/core/scoring.md +12 -12
- package/core/standalone-prompt.md +3 -1
- package/core/stylometry.md +93 -22
- package/docs/API.md +1554 -0
- package/docs/AUTHENTICATION.md +50 -26
- package/docs/AUTHENTICATION_KR.md +54 -29
- package/docs/BRANDING.md +9 -8
- package/docs/CLI.md +55 -14
- package/docs/COOKBOOK.md +8 -21
- package/docs/DEMO.md +32 -5
- package/docs/EXIT-CODES.md +2 -3
- package/docs/FALSE-POSITIVES.md +63 -0
- package/docs/FAQ.md +9 -1
- package/docs/FAQ_KR.md +3 -1
- package/docs/FLAG-PARITY.md +33 -47
- package/docs/ISSUE-WAVES.md +57 -0
- package/docs/PATTERNS-EN.md +67 -3
- package/docs/PATTERNS-JA.md +68 -2
- package/docs/PATTERNS-KO.md +70 -7
- package/docs/PATTERNS-ZH.md +67 -3
- package/docs/PATTERNS.md +5 -5
- package/docs/RESEARCH-DOCS-PLATFORM.md +54 -0
- package/docs/ROADMAP.md +46 -66
- package/docs/TRANSLATIONESE-KO.md +51 -0
- package/docs/audits/2026-05-deep-research.md +3 -1
- package/docs/benchmarks/README.md +51 -0
- package/docs/benchmarks/detector-comparison.json +69 -9
- package/docs/benchmarks/detector-comparison.md +10 -5
- package/docs/benchmarks/katfish-ko-latest.json +657 -0
- package/docs/benchmarks/katfish-ko-latest.md +77 -0
- package/docs/benchmarks/latest.json +1183 -108
- package/docs/benchmarks/latest.md +84 -60
- package/docs/benchmarks/lexicon-freshness-en-2026-05-22.json +1121 -0
- package/docs/benchmarks/lexicon-freshness-en-2026-05-22.md +136 -0
- package/docs/benchmarks/rebaseline-latest.json +381 -0
- package/docs/benchmarks/rebaseline-latest.md +121 -0
- package/docs/benchmarks/register-stratified-latest.json +164 -0
- package/docs/benchmarks/register-stratified-latest.md +99 -0
- package/docs/benchmarks/register-stratified.md +43 -0
- package/docs/integrations/github-action.md +44 -11
- package/docs/integrations/playground.md +58 -0
- package/docs/integrations/pre-commit.md +5 -5
- package/docs/integrations/release.md +5 -3
- package/docs/integrations/static-sites.md +83 -0
- package/docs/research/2025-rebaseline-plan.md +71 -2
- package/docs/research/2026-rebaseline.md +102 -0
- package/docs/research/adversarial-mps.md +41 -0
- package/docs/research/ai-human-metrics.md +35 -23
- package/docs/research/human-eval-panel.md +42 -0
- package/docs/research/judge-agreement.md +24 -0
- package/docs/research/ko-2025-corpus-sources.md +135 -0
- package/docs/research/lexicon-freshness-audit.md +64 -0
- package/docs/research/zh-ja-lexicon-calibration.md +60 -0
- package/docs/social/patina-launch-copy.md +173 -100
- package/docs/social/patina-launch-execution.md +94 -0
- package/docs/social/patina-launch-korean-first.md +83 -0
- package/docs/social/signs-of-ai-writing.md +26 -0
- package/docs/social/signs-of-ai-writing_KR.md +26 -0
- package/lexicon/ai-en.md +21 -24
- package/lexicon/ai-ja.md +158 -0
- package/lexicon/ai-ko.md +9 -9
- package/lexicon/ai-zh.md +158 -0
- package/lexicon/provenance/ai-en.json +970 -0
- package/lexicon/provenance/ai-ja.json +542 -0
- package/lexicon/provenance/ai-ko.json +866 -0
- package/lexicon/provenance/ai-zh.json +542 -0
- package/package.json +49 -8
- package/patterns/en-communication.md +5 -0
- package/patterns/en-content.md +5 -0
- package/patterns/en-filler.md +5 -0
- package/patterns/en-language.md +29 -1
- package/patterns/en-structure.md +5 -0
- package/patterns/en-style.md +5 -0
- package/patterns/en-viral-hook.md +42 -2
- package/patterns/ja-communication.md +5 -0
- package/patterns/ja-content.md +5 -0
- package/patterns/ja-filler.md +5 -0
- package/patterns/ja-language.md +33 -1
- package/patterns/ja-structure.md +12 -0
- package/patterns/ja-style.md +5 -0
- package/patterns/ja-viral-hook.md +41 -2
- package/patterns/ko-communication.md +5 -0
- package/patterns/ko-content.md +5 -0
- package/patterns/ko-filler.md +5 -0
- package/patterns/ko-language.md +33 -1
- package/patterns/ko-structure.md +25 -6
- package/patterns/ko-style.md +5 -0
- package/patterns/ko-viral-hook.md +38 -2
- package/patterns/zh-communication.md +5 -0
- package/patterns/zh-content.md +5 -0
- package/patterns/zh-filler.md +5 -0
- package/patterns/zh-language.md +37 -1
- package/patterns/zh-structure.md +12 -0
- package/patterns/zh-style.md +5 -0
- package/patterns/zh-viral-hook.md +38 -2
- package/playground/README.md +55 -0
- package/playground/analytics.js +4 -0
- package/playground/analyzer.js +883 -0
- package/playground/app.js +157 -0
- package/playground/data/lexicons.js +343 -0
- package/playground/index.html +138 -0
- package/playground/styles.css +267 -0
- package/profiles/namuwiki.md +111 -0
- package/scripts/adversarial-mps-report.mjs +201 -0
- package/scripts/badge-json.mjs +79 -0
- package/scripts/benchmark-report.mjs +56 -9
- package/scripts/check-release-metadata.mjs +0 -2
- package/scripts/detector-comparison.mjs +7 -7
- package/scripts/generate-playground-data.mjs +77 -0
- package/scripts/katfish-calibration.mjs +464 -0
- package/scripts/lexicon-freshness.mjs +485 -0
- package/scripts/lint.mjs +1 -1
- package/scripts/precommit-score.mjs +4 -3
- package/scripts/prose-score.mjs +81 -5
- package/scripts/rebaseline-intake.mjs +242 -0
- package/scripts/rebaseline-score.mjs +268 -0
- package/scripts/rebaseline-summary.mjs +773 -0
- package/scripts/rebaseline-web-collect.mjs +410 -0
- package/scripts/update-benchmark-ranges.mjs +1 -0
- package/src/api.js +69 -105
- package/src/auth.js +50 -2
- package/src/backends/claude-cli.js +19 -4
- package/src/backends/codex-cli.js +19 -3
- package/src/backends/contract.js +230 -1
- package/src/backends/gemini-cli.js +18 -5
- package/src/backends/index.js +87 -12
- package/src/backends/kimi-cli.js +161 -0
- package/src/cli.js +577 -567
- package/src/commands/doctor.js +2 -2
- package/src/config.js +29 -0
- package/src/errors.js +53 -1
- package/src/features/discourse-tells.js +68 -0
- package/src/features/index.js +82 -8
- package/src/features/lexicon.js +40 -6
- package/src/features/markup-leakage.js +69 -0
- package/src/features/segment.js +41 -0
- package/src/features/signal-strength.js +81 -0
- package/src/features/stylometry.js +231 -1
- package/src/features/translationese.js +127 -0
- package/src/loader.js +76 -0
- package/src/logger.js +22 -23
- package/src/model-defaults.js +55 -0
- package/src/ouroboros.js +31 -0
- package/src/output.js +102 -90
- package/src/prompt-builder.js +103 -68
- package/src/providers.js +51 -4
- package/src/scoring.js +210 -2
- package/src/security.js +75 -0
- package/tests/fixtures/live-quality/en/public-docs-01.md +26 -0
- package/tests/fixtures/live-quality/ko/public-docs-01.md +26 -0
- package/tests/fixtures/suspect-zones/expected-ranges.json +207 -16
- package/tests/fixtures/suspect-zones/ja/ai/ja-ai-04-lexicon.md +11 -0
- package/tests/fixtures/suspect-zones/ja/natural/ja-nat-04-lexicon-cold.md +11 -0
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-02.md +4 -5
- package/tests/fixtures/suspect-zones/ko/ai/ko-ai-07-ko-diagnostic.md +11 -0
- package/tests/fixtures/suspect-zones/zh/ai/zh-ai-04-lexicon.md +11 -0
- package/tests/fixtures/suspect-zones/zh/natural/zh-nat-04-lexicon-cold.md +11 -0
- package/tests/quality/README.md +188 -11
- package/tests/quality/adversarial-mps/fixtures.jsonl +10 -0
- package/tests/quality/benchmark.mjs +39 -1
- package/tests/quality/dogfood.mjs +5 -3
- package/tests/quality/live-fixtures.jsonl +2 -0
- package/tests/quality/live-quality.mjs +596 -0
- package/tests/quality/ranking-metrics.mjs +136 -0
- package/tests/quality/rebaseline-manifest.example.jsonl +5 -0
- package/vercel.json +53 -0
- package/SKILL-MAX.md +0 -455
- package/docs/internal/HARNESS.md +0 -14
- package/docs/internal/README.md +0 -14
- package/docs/internal/WARP.md +0 -23
- package/patina-max/SKILL.md +0 -523
- package/patina-max/composite.py +0 -457
- package/src/cache.js +0 -106
- package/src/commands/init.js +0 -208
- package/src/manifest.js +0 -162
- package/src/max-mode.js +0 -207
package/src/cli.js
CHANGED
|
@@ -8,29 +8,41 @@ import {
|
|
|
8
8
|
toneToBackboneProfile,
|
|
9
9
|
} from './loader.js';
|
|
10
10
|
import { buildPrompt } from './prompt-builder.js';
|
|
11
|
-
import { invokeBackendChain, selectBackendChain, listBackends, listBackendNames } from './backends/index.js';
|
|
12
|
-
import { selectProvider, resolveProviderConfig
|
|
11
|
+
import { invokeBackendChain, selectBackendChain, listBackends, listBackendNames, resolveBackend } from './backends/index.js';
|
|
12
|
+
import { selectProvider, resolveProviderConfig } from './providers.js';
|
|
13
13
|
import { validateBaseURL, applyInsecureBaseURLOptIn, applyPrivateBaseURLOptIn } from './security.js';
|
|
14
|
-
import { formatOutput, validateScoreWeights } from './output.js';
|
|
15
|
-
import { runMaxMode } from './max-mode.js';
|
|
14
|
+
import { formatOutput, validateScoreWeights, buildDeterministicAuditBackstop } from './output.js';
|
|
16
15
|
import { runOuroboros } from './ouroboros.js';
|
|
17
16
|
import { interpretScore, reconcileScoreOverall, scoreDeterministicSignals } from './scoring.js';
|
|
18
|
-
import { callLLM, DEFAULT_TEMPERATURE } from './api.js';
|
|
19
|
-
import { createResponseCache, DEFAULT_CACHE_TTL_SECONDS } from './cache.js';
|
|
20
|
-
import { buildManifest, appendResult, writeManifest, hashSha256 } from './manifest.js';
|
|
21
17
|
import { runDoctor } from './commands/doctor.js';
|
|
22
|
-
import { runInit } from './commands/init.js';
|
|
23
18
|
import { PatinaCliError, inputError, runtimeError, renderCliError, getExitCode } from './errors.js';
|
|
24
|
-
import {
|
|
19
|
+
import { providerHttpKeyEnvVars, resolveHttpApiKey } from './auth.js';
|
|
25
20
|
import { createLogger } from './logger.js';
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_BACKEND_TIMEOUT_MS,
|
|
23
|
+
PROMPT_SIZE_WARNING_CHARS,
|
|
24
|
+
getBackendSafety,
|
|
25
|
+
resolveBackendMaxConcurrency,
|
|
26
|
+
resolveBackendMaxRetries,
|
|
27
|
+
} from './backends/contract.js';
|
|
26
28
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
27
29
|
import { resolve, basename, extname } from 'node:path';
|
|
28
30
|
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { createInterface } from 'node:readline/promises';
|
|
29
32
|
|
|
30
33
|
const PACKAGE_VERSION = JSON.parse(
|
|
31
34
|
readFileSync(resolve(getRepoRoot(), 'package.json'), 'utf8')
|
|
32
35
|
).version;
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Run the patina CLI command dispatcher.
|
|
39
|
+
*
|
|
40
|
+
* @param {string[]} args Command-line arguments excluding node and script path.
|
|
41
|
+
* @returns {Promise<void>} Resolves after command output is written.
|
|
42
|
+
* @throws {Error} For validation, provider, file, or runtime failures.
|
|
43
|
+
* @example
|
|
44
|
+
* await main(['--help']);
|
|
45
|
+
*/
|
|
34
46
|
export async function main(args) {
|
|
35
47
|
if (args[0] === 'auth') {
|
|
36
48
|
return handleAuth(args.slice(1));
|
|
@@ -39,7 +51,11 @@ export async function main(args) {
|
|
|
39
51
|
return runDoctor(args.slice(1), { version: PACKAGE_VERSION });
|
|
40
52
|
}
|
|
41
53
|
if (args[0] === 'init') {
|
|
42
|
-
|
|
54
|
+
throw inputError(
|
|
55
|
+
'patina init was removed',
|
|
56
|
+
'patina is zero-config; use CLI flags for one-off runs or add .patina.yaml only when project defaults are needed.',
|
|
57
|
+
'Copy .patina.default.yaml to .patina.yaml and edit it manually, or pass --config <path>.'
|
|
58
|
+
);
|
|
43
59
|
}
|
|
44
60
|
if (args[0] === 'help') {
|
|
45
61
|
printHelp();
|
|
@@ -47,7 +63,7 @@ export async function main(args) {
|
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
const parsed = parseArgs(args);
|
|
50
|
-
const logger = createLogger({ quiet: parsed.quiet
|
|
66
|
+
const logger = createLogger({ quiet: parsed.quiet });
|
|
51
67
|
|
|
52
68
|
if (parsed.help) {
|
|
53
69
|
printHelp();
|
|
@@ -59,17 +75,10 @@ export async function main(args) {
|
|
|
59
75
|
return;
|
|
60
76
|
}
|
|
61
77
|
|
|
62
|
-
if (parsed.models && parsed.variants && parsed.variants > 1) {
|
|
63
|
-
throw inputError(
|
|
64
|
-
'--variants is not supported with --models/MAX mode yet',
|
|
65
|
-
'MAX mode already fans out across models, so variant fan-out is ambiguous.',
|
|
66
|
-
'Omit --variants or run one model at a time.'
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
78
|
|
|
70
79
|
if (parsed.gate !== undefined && !parsed.score) {
|
|
71
80
|
throw inputError(
|
|
72
|
-
|
|
81
|
+
'--exit-on can only be used with --score',
|
|
73
82
|
'Score gates need a parsed overall score.',
|
|
74
83
|
'Run `patina --score --exit-on 30 <file>`.'
|
|
75
84
|
);
|
|
@@ -80,11 +89,6 @@ export async function main(args) {
|
|
|
80
89
|
return;
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
if (parsed.listProviders) {
|
|
84
|
-
printProviderStatus();
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
92
|
const configPath = parsed.config ? resolve(process.cwd(), parsed.config) : undefined;
|
|
89
93
|
const config = loadConfig(configPath);
|
|
90
94
|
|
|
@@ -92,7 +96,7 @@ export async function main(args) {
|
|
|
92
96
|
if (parsed.profile) config.profile = parsed.profile;
|
|
93
97
|
|
|
94
98
|
const provider = selectProvider(parsed.provider ?? config.provider);
|
|
95
|
-
const apiKey = resolveApiKey(parsed, provider
|
|
99
|
+
const apiKey = resolveApiKey(parsed, provider);
|
|
96
100
|
const resolved = resolveProviderConfig({
|
|
97
101
|
provider,
|
|
98
102
|
apiKey,
|
|
@@ -103,12 +107,6 @@ export async function main(args) {
|
|
|
103
107
|
applyPrivateBaseURLOptIn(parsed);
|
|
104
108
|
validateBaseURL(resolved.baseURL);
|
|
105
109
|
|
|
106
|
-
const startedAt = new Date().toISOString();
|
|
107
|
-
const manifestResults = [];
|
|
108
|
-
const manifestOutputs = [];
|
|
109
|
-
const manifestTemperature = DEFAULT_TEMPERATURE;
|
|
110
|
-
const manifestSeed = null;
|
|
111
|
-
const responseCache = resolveResponseCache(parsed);
|
|
112
110
|
|
|
113
111
|
const repoRoot = getRepoRoot();
|
|
114
112
|
const lang = config.language || 'ko';
|
|
@@ -134,6 +132,11 @@ export async function main(args) {
|
|
|
134
132
|
const backbone = toneToBackboneProfile(toneResolution.tone);
|
|
135
133
|
if (backbone) profileName = backbone;
|
|
136
134
|
}
|
|
135
|
+
const resolvedProfileName = resolveProfileForLanguage(profileName, lang, logger);
|
|
136
|
+
if (resolvedProfileName !== profileName) {
|
|
137
|
+
profileName = resolvedProfileName;
|
|
138
|
+
config.profile = 'default';
|
|
139
|
+
}
|
|
137
140
|
|
|
138
141
|
const patterns = loadPatterns(repoRoot, lang, config['skip-patterns'] || []);
|
|
139
142
|
const profile = loadProfile(repoRoot, profileName);
|
|
@@ -157,197 +160,181 @@ export async function main(args) {
|
|
|
157
160
|
}
|
|
158
161
|
|
|
159
162
|
const inputTexts = await loadInputs(parsed, logger);
|
|
160
|
-
const
|
|
163
|
+
const timeoutMs = parsed.timeoutMs ?? DEFAULT_BACKEND_TIMEOUT_MS;
|
|
164
|
+
const backendSelection = parsed.ouroboros
|
|
165
|
+
? null
|
|
166
|
+
: selectBackendChain({
|
|
167
|
+
name: parsed.backend ?? config.backend ?? (resolved.baseURLSource !== 'default' ? 'openai-http' : undefined),
|
|
168
|
+
model: resolved.model,
|
|
169
|
+
modelSource: resolved.modelSource,
|
|
170
|
+
});
|
|
171
|
+
const backends = backendSelection?.backends || [];
|
|
172
|
+
const backend = backends[0] || null;
|
|
161
173
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const manifestCalls = [];
|
|
167
|
-
const recordManifestCall = parsed.saveRun ? createManifestCallRecorder(manifestCalls) : null;
|
|
168
|
-
const trackedCallLLM = (parsed.saveRun || responseCache)
|
|
169
|
-
? (args) => callLLM({
|
|
170
|
-
...args,
|
|
171
|
-
cache: responseCache,
|
|
172
|
-
onResponse: (metadata) => {
|
|
173
|
-
args.onResponse?.(metadata);
|
|
174
|
-
recordManifestCall?.(metadata);
|
|
175
|
-
},
|
|
176
|
-
})
|
|
177
|
-
: undefined;
|
|
178
|
-
const prompt = buildPrompt({
|
|
179
|
-
config,
|
|
180
|
-
patterns,
|
|
181
|
-
profile: profile.body ? profile : null,
|
|
182
|
-
voice: voice.body ? voice : null,
|
|
183
|
-
voiceSample,
|
|
184
|
-
scoring: scoring.body ? scoring : null,
|
|
185
|
-
text,
|
|
186
|
-
mode,
|
|
187
|
-
tone: toneResolution,
|
|
188
|
-
promptMode: resolvePromptMode(
|
|
189
|
-
parsed.promptMode || config['prompt-mode'] || 'strict',
|
|
190
|
-
{ backend: parsed.backend ?? config.backend, model: resolved.model }
|
|
191
|
-
),
|
|
192
|
-
variants: parsed.variants || 1,
|
|
174
|
+
if (backendSelection) {
|
|
175
|
+
if (backendSelection.autoSelected) {
|
|
176
|
+
logger.info('backend.selected', {
|
|
177
|
+
message: `[patina] Using ${backend.name} backend (${backendSelection.reason}). Run \`patina auth status\` for details.`,
|
|
193
178
|
});
|
|
179
|
+
}
|
|
180
|
+
if (backends.length > 1) {
|
|
181
|
+
logger.info('backend.chain', {
|
|
182
|
+
message: `[patina] Backend fallback chain: ${backends.map((b) => b.name).join(' → ')}`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (backend.name === 'openai-http' && !resolved.apiKey) {
|
|
186
|
+
const msg = ['No API key found. Set PATINA_API_KEY, PATINA_API_KEY_FILE, OPENAI_API_KEY, or use --api-key-file.'];
|
|
187
|
+
if (provider) {
|
|
188
|
+
msg.push(`(--provider ${provider.name} expects ${provider.apiKeyEnv} or PATINA_API_KEY.)`);
|
|
189
|
+
}
|
|
190
|
+
const codex = listBackends().find((b) => b.name === 'codex-cli');
|
|
191
|
+
if (codex && codex.available && codex.authenticated) {
|
|
192
|
+
msg.push('Or pass `--backend codex-cli` to use the codex-cli backend (no key needed).');
|
|
193
|
+
} else if (codex && codex.available && !codex.authenticated) {
|
|
194
|
+
msg.push('Or run `codex login`, then pass `--backend codex-cli`.');
|
|
195
|
+
} else if (codex && !codex.available) {
|
|
196
|
+
msg.push('Or install `codex` from https://github.com/openai/codex and pass `--backend codex-cli`.');
|
|
197
|
+
}
|
|
198
|
+
throw runtimeError(
|
|
199
|
+
'no API key found',
|
|
200
|
+
msg[0],
|
|
201
|
+
msg.slice(1).join(' ') || 'Set PATINA_API_KEY or pass --backend codex-cli after logging in.'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
194
205
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
config,
|
|
215
|
-
patterns,
|
|
216
|
-
profile: profile.body ? profile : null,
|
|
217
|
-
voice: voice.body ? voice : null,
|
|
218
|
-
voiceSample,
|
|
219
|
-
scoring: scoring.body ? scoring : null,
|
|
220
|
-
text,
|
|
221
|
-
apiKey: resolved.apiKey,
|
|
222
|
-
baseURL: resolved.baseURL,
|
|
223
|
-
model: resolved.model,
|
|
224
|
-
callLLM: trackedCallLLM,
|
|
225
|
-
signal: cancellation.signal,
|
|
226
|
-
logger,
|
|
227
|
-
});
|
|
228
|
-
} else {
|
|
229
|
-
const { backends, autoSelected, reason } = selectBackendChain({
|
|
230
|
-
name: parsed.backend ?? config.backend,
|
|
231
|
-
model: resolved.model,
|
|
232
|
-
});
|
|
233
|
-
const backend = backends[0];
|
|
206
|
+
const promptMode = backendSelection
|
|
207
|
+
? resolvePromptMode({ backend: backend.name, model: resolved.model })
|
|
208
|
+
: 'strict';
|
|
209
|
+
const jobs = inputTexts.map(({ path, text }) => ({
|
|
210
|
+
path,
|
|
211
|
+
text,
|
|
212
|
+
prompt: parsed.ouroboros ? null : buildPrompt({
|
|
213
|
+
config,
|
|
214
|
+
patterns,
|
|
215
|
+
profile: profile.body ? profile : null,
|
|
216
|
+
voice: voice.body ? voice : null,
|
|
217
|
+
voiceSample,
|
|
218
|
+
scoring: scoring.body ? scoring : null,
|
|
219
|
+
text,
|
|
220
|
+
mode,
|
|
221
|
+
tone: toneResolution,
|
|
222
|
+
promptMode,
|
|
223
|
+
}),
|
|
224
|
+
}));
|
|
234
225
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
226
|
+
if (backendSelection) {
|
|
227
|
+
logBatchSafetyPlan({
|
|
228
|
+
jobs,
|
|
229
|
+
backends,
|
|
230
|
+
parsed,
|
|
231
|
+
promptMode,
|
|
232
|
+
timeoutMs,
|
|
233
|
+
logger,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const cancellation = createCancellationController({ logger });
|
|
238
|
+
const batchState = createBatchCircuitBreaker({ parsed, total: jobs.length });
|
|
239
|
+
|
|
240
|
+
cancellation.install();
|
|
241
|
+
try {
|
|
242
|
+
for (const { path, text, prompt } of jobs) {
|
|
243
|
+
try {
|
|
244
|
+
cancellation.throwIfCanceled();
|
|
245
|
+
let result;
|
|
246
|
+
|
|
247
|
+
if (parsed.ouroboros) {
|
|
248
|
+
result = await runOuroboros({
|
|
249
|
+
config,
|
|
250
|
+
patterns,
|
|
251
|
+
profile: profile.body ? profile : null,
|
|
252
|
+
voice: voice.body ? voice : null,
|
|
253
|
+
voiceSample,
|
|
254
|
+
scoring: scoring.body ? scoring : null,
|
|
255
|
+
text,
|
|
256
|
+
apiKey: resolved.apiKey,
|
|
257
|
+
baseURL: resolved.baseURL,
|
|
258
|
+
model: resolved.model,
|
|
259
|
+
timeout: timeoutMs,
|
|
260
|
+
signal: cancellation.signal,
|
|
261
|
+
logger,
|
|
262
|
+
});
|
|
263
|
+
} else {
|
|
264
|
+
result = await invokeBackendChain({
|
|
265
|
+
backends,
|
|
266
|
+
prompt,
|
|
267
|
+
apiKey: resolved.apiKey,
|
|
268
|
+
baseURL: resolved.baseURL,
|
|
269
|
+
model: resolved.model,
|
|
270
|
+
modelSource: resolved.modelSource,
|
|
271
|
+
signal: cancellation.signal,
|
|
272
|
+
timeout: timeoutMs,
|
|
273
|
+
maxConcurrency: parsed.maxConcurrency,
|
|
274
|
+
maxRetries: parsed.maxRetries,
|
|
275
|
+
logger,
|
|
238
276
|
});
|
|
239
277
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
278
|
+
cancellation.throwIfCanceled();
|
|
279
|
+
|
|
280
|
+
if (mode === 'score' && !parsed.ouroboros) {
|
|
281
|
+
result = withDeterministicScore(result, {
|
|
282
|
+
text,
|
|
283
|
+
config,
|
|
284
|
+
repoRoot,
|
|
285
|
+
logger,
|
|
243
286
|
});
|
|
244
287
|
}
|
|
245
288
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
289
|
+
const auditBackstop =
|
|
290
|
+
mode === 'audit' && (parsed.format ?? 'markdown') !== 'json' && !parsed.batch
|
|
291
|
+
? buildDeterministicAuditBackstop(text, { lang, repoRoot })
|
|
292
|
+
: '';
|
|
293
|
+
let output;
|
|
294
|
+
let scoreValidationOutput = null;
|
|
295
|
+
if (parsed.ouroboros) {
|
|
296
|
+
const ouroborosBody = formatOuroborosOutput(result);
|
|
297
|
+
output = formatOutput(ouroborosBody, mode, parsed, { tone: toneResolution, logger, auditBackstop });
|
|
298
|
+
scoreValidationOutput = ouroborosBody;
|
|
299
|
+
} else {
|
|
300
|
+
output = formatOutput(result, mode, parsed, { tone: toneResolution, logger, auditBackstop });
|
|
301
|
+
if (mode === 'score') {
|
|
302
|
+
scoreValidationOutput = formatOutput(result, mode, { ...parsed, format: 'markdown' }, { logger });
|
|
258
303
|
}
|
|
259
|
-
throw runtimeError(
|
|
260
|
-
'no API key found',
|
|
261
|
-
msg[0],
|
|
262
|
-
msg.slice(1).join(' ') || 'Set PATINA_API_KEY or pass --backend codex-cli after logging in.'
|
|
263
|
-
);
|
|
264
304
|
}
|
|
265
305
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
prompt,
|
|
269
|
-
apiKey: resolved.apiKey,
|
|
270
|
-
baseURL: resolved.baseURL,
|
|
271
|
-
model: resolved.model,
|
|
272
|
-
signal: cancellation.signal,
|
|
273
|
-
temperature: manifestTemperature,
|
|
274
|
-
seed: manifestSeed,
|
|
275
|
-
onResponse: recordManifestCall,
|
|
276
|
-
cache: responseCache,
|
|
277
|
-
logger,
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
cancellation.throwIfCanceled();
|
|
281
|
-
|
|
282
|
-
if (mode === 'score' && !parsed.models && !parsed.ouroboros) {
|
|
283
|
-
result = withDeterministicScore(result, {
|
|
284
|
-
text,
|
|
285
|
-
config,
|
|
286
|
-
repoRoot,
|
|
287
|
-
logger,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
let output;
|
|
292
|
-
let scoreValidationOutput = null;
|
|
293
|
-
if (parsed.ouroboros) {
|
|
294
|
-
const ouroborosBody = formatOuroborosOutput(result);
|
|
295
|
-
output = formatOutput(ouroborosBody, mode, parsed, { tone: toneResolution, logger });
|
|
296
|
-
scoreValidationOutput = ouroborosBody;
|
|
297
|
-
} else {
|
|
298
|
-
output = formatOutput(result, mode, parsed, { tone: toneResolution, logger });
|
|
306
|
+
// v3.11 Phase 1.3: surface weight drift between config and the score
|
|
307
|
+
// table the model emitted. Warnings only — does not alter the output.
|
|
299
308
|
if (mode === 'score') {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
309
|
+
const configWeights = config.ouroboros?.['category-weights']?.[lang] || {};
|
|
310
|
+
const warnings = validateScoreWeights(scoreValidationOutput || output, configWeights);
|
|
311
|
+
for (const w of warnings) {
|
|
312
|
+
logger.warn('score.weight_check', { message: `[patina] ${w}` });
|
|
313
|
+
}
|
|
303
314
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const configWeights = config.ouroboros?.['category-weights']?.[lang] || {};
|
|
308
|
-
const warnings = validateScoreWeights(scoreValidationOutput || output, configWeights);
|
|
309
|
-
for (const w of warnings) {
|
|
310
|
-
logger.warn('score.weight_check', { message: `[patina] ${w}` });
|
|
315
|
+
if (parsed.gate !== undefined) {
|
|
316
|
+
applyScoreGate(result, output, parsed.gate, logger);
|
|
317
|
+
}
|
|
311
318
|
}
|
|
312
319
|
|
|
313
|
-
if (parsed.
|
|
314
|
-
|
|
320
|
+
if (parsed.batch) {
|
|
321
|
+
await writeBatchOutput(parsed, path, output);
|
|
322
|
+
} else {
|
|
323
|
+
console.log(output);
|
|
315
324
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (parsed.saveRun) {
|
|
323
|
-
const idx = manifestResults.length + 1;
|
|
324
|
-
const outputName = `output-${idx}.txt`;
|
|
325
|
-
appendResult(manifestResults, {
|
|
326
|
-
inputPath: path,
|
|
327
|
-
prompt,
|
|
328
|
-
response: manifestResponseText(result, output),
|
|
329
|
-
outputRef: { kind: 'file', name: outputName },
|
|
330
|
-
tokensIn: sumManifestCalls(manifestCalls, 'tokensIn'),
|
|
331
|
-
tokensOut: sumManifestCalls(manifestCalls, 'tokensOut'),
|
|
332
|
-
temperature: manifestTemperature,
|
|
333
|
-
seed: manifestSeed,
|
|
334
|
-
cost: sumManifestCallCost(manifestCalls),
|
|
335
|
-
scores: manifestScoreDetails(result),
|
|
336
|
-
iterationLog: manifestIterationLog(result),
|
|
337
|
-
calls: manifestCalls.length > 0 ? manifestCalls : undefined,
|
|
325
|
+
batchState.recordSuccess();
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (!shouldHandleBatchFailure(parsed, jobs.length)) throw err;
|
|
328
|
+
batchState.recordFailure({ path, err });
|
|
329
|
+
logger.warn('batch.file_failed', {
|
|
330
|
+
message: `[patina] batch file failed: ${path} (${batchState.failures.length}/${batchState.maxFailures} failures): ${err.message}`,
|
|
338
331
|
});
|
|
339
|
-
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (parsed.batch) {
|
|
343
|
-
await writeBatchOutput(parsed, path, output);
|
|
344
|
-
} else {
|
|
345
|
-
console.log(output);
|
|
332
|
+
if (batchState.shouldStop()) throw batchState.toError();
|
|
346
333
|
}
|
|
347
334
|
}
|
|
348
335
|
|
|
349
|
-
if (
|
|
350
|
-
|
|
336
|
+
if (batchState.hasFailures()) {
|
|
337
|
+
throw batchState.toError({ completed: true });
|
|
351
338
|
}
|
|
352
339
|
} catch (err) {
|
|
353
340
|
if (cancellation.signal.aborted) throw cancellationError();
|
|
@@ -357,30 +344,6 @@ export async function main(args) {
|
|
|
357
344
|
logger.closeProgress();
|
|
358
345
|
}
|
|
359
346
|
|
|
360
|
-
if (parsed.saveRun) {
|
|
361
|
-
const manifest = buildManifest({
|
|
362
|
-
patinaVersion: PACKAGE_VERSION,
|
|
363
|
-
mode,
|
|
364
|
-
lang,
|
|
365
|
-
profile: profileName,
|
|
366
|
-
provider: provider?.name,
|
|
367
|
-
backend: parsed.backend ?? config.backend ?? 'openai-http',
|
|
368
|
-
model: resolved.model,
|
|
369
|
-
configPath: configPath ?? null,
|
|
370
|
-
config,
|
|
371
|
-
patterns,
|
|
372
|
-
results: manifestResults,
|
|
373
|
-
startedAt,
|
|
374
|
-
temperature: manifestTemperature,
|
|
375
|
-
seed: manifestSeed,
|
|
376
|
-
});
|
|
377
|
-
const manifestPath = writeManifest(
|
|
378
|
-
resolve(process.cwd(), parsed.saveRun),
|
|
379
|
-
manifest,
|
|
380
|
-
manifestOutputs
|
|
381
|
-
);
|
|
382
|
-
logger.info('manifest.written', { message: `[patina] wrote manifest to ${manifestPath}` });
|
|
383
|
-
}
|
|
384
347
|
}
|
|
385
348
|
|
|
386
349
|
function parseArgs(args) {
|
|
@@ -451,30 +414,9 @@ function parseArgs(args) {
|
|
|
451
414
|
parsed.format = value;
|
|
452
415
|
break;
|
|
453
416
|
}
|
|
454
|
-
case '--json':
|
|
455
|
-
parsed.format = 'json';
|
|
456
|
-
break;
|
|
457
417
|
case '--quiet':
|
|
458
418
|
parsed.quiet = true;
|
|
459
419
|
break;
|
|
460
|
-
case '--json-logs':
|
|
461
|
-
parsed.jsonLogs = true;
|
|
462
|
-
break;
|
|
463
|
-
case '--gate': {
|
|
464
|
-
const value = readOptionValue(args, i, arg, { allowFlagLike: true });
|
|
465
|
-
i++;
|
|
466
|
-
const n = Number(value);
|
|
467
|
-
if (!Number.isFinite(n) || n < 0 || n > 100) {
|
|
468
|
-
throw inputError(
|
|
469
|
-
'--gate expects a number from 0 to 100',
|
|
470
|
-
`Received ${value === undefined ? 'no value' : `"${value}"`}.`,
|
|
471
|
-
'Use `patina --score --gate 30 <file>` for CI gates.'
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
parsed.gate = n;
|
|
475
|
-
parsed.gateOption = '--gate';
|
|
476
|
-
break;
|
|
477
|
-
}
|
|
478
420
|
case '--exit-on': {
|
|
479
421
|
const value = readOptionValue(args, i, arg, { allowFlagLike: true });
|
|
480
422
|
i++;
|
|
@@ -487,7 +429,6 @@ function parseArgs(args) {
|
|
|
487
429
|
);
|
|
488
430
|
}
|
|
489
431
|
parsed.gate = n;
|
|
490
|
-
parsed.gateOption = '--exit-on';
|
|
491
432
|
break;
|
|
492
433
|
}
|
|
493
434
|
case '--ouroboros':
|
|
@@ -507,56 +448,10 @@ function parseArgs(args) {
|
|
|
507
448
|
parsed.outdir = readOptionValue(args, i, arg);
|
|
508
449
|
i++;
|
|
509
450
|
break;
|
|
510
|
-
case '--models':
|
|
511
|
-
parsed.models = readOptionValue(args, i, arg)
|
|
512
|
-
.split(',')
|
|
513
|
-
.map((m) => m.trim())
|
|
514
|
-
.filter(Boolean);
|
|
515
|
-
i++;
|
|
516
|
-
if (parsed.models.length === 0) {
|
|
517
|
-
throw inputError(
|
|
518
|
-
'--models expects at least one model id',
|
|
519
|
-
'The comma-separated model list was empty.',
|
|
520
|
-
'Use `--models gpt-4o,claude-3-5-sonnet`.'
|
|
521
|
-
);
|
|
522
|
-
}
|
|
523
|
-
break;
|
|
524
|
-
case '--max-concurrency': {
|
|
525
|
-
const value = readOptionValue(args, i, arg, { allowFlagLike: true });
|
|
526
|
-
i++;
|
|
527
|
-
const n = Number(value);
|
|
528
|
-
if (!Number.isInteger(n) || n < 0) {
|
|
529
|
-
throw inputError(
|
|
530
|
-
'--max-concurrency expects a non-negative integer',
|
|
531
|
-
`Received ${value === undefined ? 'no value' : `"${value}"`}.`,
|
|
532
|
-
'Use `--max-concurrency 2`, omit it for the safe default, or pass 0 for unlimited concurrency.'
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
parsed.maxConcurrency = n;
|
|
536
|
-
break;
|
|
537
|
-
}
|
|
538
|
-
case '--max-timeout': {
|
|
539
|
-
const value = readOptionValue(args, i, arg, { allowFlagLike: true });
|
|
540
|
-
i++;
|
|
541
|
-
const n = Number(value);
|
|
542
|
-
if (!Number.isFinite(n) || n <= 0) {
|
|
543
|
-
throw inputError(
|
|
544
|
-
'--max-timeout expects a positive number of seconds',
|
|
545
|
-
`Received ${value === undefined ? 'no value' : `"${value}"`}.`,
|
|
546
|
-
'Use `--max-timeout 300`, or omit it for the default 300 seconds.'
|
|
547
|
-
);
|
|
548
|
-
}
|
|
549
|
-
parsed.maxTimeoutSeconds = n;
|
|
550
|
-
break;
|
|
551
|
-
}
|
|
552
451
|
case '--model':
|
|
553
452
|
parsed.model = readOptionValue(args, i, arg);
|
|
554
453
|
i++;
|
|
555
454
|
break;
|
|
556
|
-
case '--api-key':
|
|
557
|
-
parsed.apiKey = readOptionValue(args, i, arg);
|
|
558
|
-
i++;
|
|
559
|
-
break;
|
|
560
455
|
case '--api-key-file':
|
|
561
456
|
parsed.apiKeyFile = readOptionValue(args, i, arg);
|
|
562
457
|
i++;
|
|
@@ -572,75 +467,53 @@ function parseArgs(args) {
|
|
|
572
467
|
parsed.backend = readOptionValue(args, i, arg);
|
|
573
468
|
i++;
|
|
574
469
|
break;
|
|
575
|
-
case '--
|
|
576
|
-
|
|
577
|
-
break;
|
|
578
|
-
case '--provider':
|
|
579
|
-
parsed.provider = readOptionValue(args, i, arg);
|
|
470
|
+
case '--timeout-ms': {
|
|
471
|
+
const value = readOptionValue(args, i, arg, { allowFlagLike: true });
|
|
580
472
|
i++;
|
|
473
|
+
parsed.timeoutMs = parsePositiveIntegerOption(value, arg);
|
|
581
474
|
break;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
case '--allow-insecure-base-url':
|
|
586
|
-
parsed.allowInsecureBaseURL = true;
|
|
587
|
-
break;
|
|
588
|
-
case '--config':
|
|
589
|
-
parsed.config = readOptionValue(args, i, arg);
|
|
475
|
+
}
|
|
476
|
+
case '--max-concurrency': {
|
|
477
|
+
const value = readOptionValue(args, i, arg, { allowFlagLike: true });
|
|
590
478
|
i++;
|
|
479
|
+
parsed.maxConcurrency = parsePositiveIntegerOption(value, arg);
|
|
591
480
|
break;
|
|
592
|
-
|
|
593
|
-
|
|
481
|
+
}
|
|
482
|
+
case '--max-retries': {
|
|
483
|
+
const value = readOptionValue(args, i, arg, { allowFlagLike: true });
|
|
594
484
|
i++;
|
|
485
|
+
parsed.maxRetries = parseNonNegativeIntegerOption(value, arg);
|
|
595
486
|
break;
|
|
596
|
-
|
|
597
|
-
|
|
487
|
+
}
|
|
488
|
+
case '--max-failures': {
|
|
489
|
+
const value = readOptionValue(args, i, arg, { allowFlagLike: true });
|
|
598
490
|
i++;
|
|
491
|
+
parsed.maxFailures = parsePositiveIntegerOption(value, arg);
|
|
599
492
|
break;
|
|
600
|
-
|
|
493
|
+
}
|
|
494
|
+
case '--max-failure-rate': {
|
|
601
495
|
const value = readOptionValue(args, i, arg, { allowFlagLike: true });
|
|
602
496
|
i++;
|
|
603
|
-
|
|
604
|
-
if (!Number.isFinite(n) || n <= 0) {
|
|
605
|
-
throw inputError(
|
|
606
|
-
'--cache-ttl expects a positive number of seconds',
|
|
607
|
-
`Received ${value === undefined ? 'no value' : `"${value}"`}.`,
|
|
608
|
-
'Use `--cache-ttl 86400` for a one-day response cache.'
|
|
609
|
-
);
|
|
610
|
-
}
|
|
611
|
-
parsed.cacheTtlSeconds = n;
|
|
497
|
+
parsed.maxFailureRate = parseFailureRateOption(value, arg);
|
|
612
498
|
break;
|
|
613
499
|
}
|
|
614
|
-
case '--
|
|
615
|
-
parsed.
|
|
500
|
+
case '--stop-on-retryable-storm':
|
|
501
|
+
parsed.stopOnRetryableStorm = true;
|
|
502
|
+
break;
|
|
503
|
+
case '--list-backends':
|
|
504
|
+
parsed.listBackends = true;
|
|
616
505
|
break;
|
|
617
|
-
case '--
|
|
618
|
-
|
|
506
|
+
case '--provider':
|
|
507
|
+
parsed.provider = readOptionValue(args, i, arg);
|
|
619
508
|
i++;
|
|
620
|
-
if (!m || !['strict', 'minimal', 'auto'].includes(m)) {
|
|
621
|
-
throw inputError(
|
|
622
|
-
'--prompt-mode expects strict, minimal, or auto',
|
|
623
|
-
`Received ${m === undefined ? 'no value' : `"${m}"`}.`,
|
|
624
|
-
'Use `--prompt-mode auto` unless you need a specific prompt style.'
|
|
625
|
-
);
|
|
626
|
-
}
|
|
627
|
-
parsed.promptMode = m;
|
|
628
509
|
break;
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
510
|
+
case '--allow-insecure-base-url':
|
|
511
|
+
parsed.allowInsecureBaseURL = true;
|
|
512
|
+
break;
|
|
513
|
+
case '--config':
|
|
514
|
+
parsed.config = readOptionValue(args, i, arg);
|
|
632
515
|
i++;
|
|
633
|
-
const n = Number(value);
|
|
634
|
-
if (!Number.isInteger(n) || n < 1 || n > 5) {
|
|
635
|
-
throw inputError(
|
|
636
|
-
'--variants expects an integer from 1 to 5',
|
|
637
|
-
`Received ${value === undefined ? 'no value' : `"${value}"`}.`,
|
|
638
|
-
'Use `--variants 2` for alternate rewrite drafts.'
|
|
639
|
-
);
|
|
640
|
-
}
|
|
641
|
-
parsed.variants = n;
|
|
642
516
|
break;
|
|
643
|
-
}
|
|
644
517
|
case '--no-interactive':
|
|
645
518
|
parsed.noInteractive = true;
|
|
646
519
|
break;
|
|
@@ -670,6 +543,19 @@ function cancellationError() {
|
|
|
670
543
|
});
|
|
671
544
|
}
|
|
672
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Create a SIGINT-aware cancellation controller for long-running CLI operations.
|
|
548
|
+
*
|
|
549
|
+
* @param {object} [options] Cancellation integration points.
|
|
550
|
+
* @param {NodeJS.Process} [options.processObj=process] Process-like object used for signal listeners.
|
|
551
|
+
* @param {NodeJS.WritableStream} [options.stderr=process.stderr] Stream for fallback cancel messages.
|
|
552
|
+
* @param {object|null} [options.logger] Optional patina logger.
|
|
553
|
+
* @returns {{signal: AbortSignal, install: Function, uninstall: Function, throwIfCanceled: Function}} Controller facade.
|
|
554
|
+
* @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
|
|
555
|
+
* @example
|
|
556
|
+
* const cancellation = createCancellationController();
|
|
557
|
+
* cancellation.install();
|
|
558
|
+
*/
|
|
673
559
|
export function createCancellationController({
|
|
674
560
|
processObj = process,
|
|
675
561
|
stderr = process.stderr,
|
|
@@ -736,44 +622,223 @@ function readOptionValue(args, index, option, { allowFlagLike = false } = {}) {
|
|
|
736
622
|
return value;
|
|
737
623
|
}
|
|
738
624
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
625
|
+
function parsePositiveIntegerOption(value, option) {
|
|
626
|
+
const n = Number(value);
|
|
627
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
628
|
+
throw inputError(
|
|
629
|
+
`${option} expects a positive integer`,
|
|
630
|
+
`Received ${value === undefined ? 'no value' : `"${value}"`}.`,
|
|
631
|
+
`Use ${option} 1 or another whole number greater than zero.`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
return n;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function parseNonNegativeIntegerOption(value, option) {
|
|
638
|
+
const n = Number(value);
|
|
639
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
640
|
+
throw inputError(
|
|
641
|
+
`${option} expects a non-negative integer`,
|
|
642
|
+
`Received ${value === undefined ? 'no value' : `"${value}"`}.`,
|
|
643
|
+
`Use ${option} 0 to disable retries, or another whole number.`
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
return n;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function parseFailureRateOption(value, option) {
|
|
650
|
+
const n = Number(value);
|
|
651
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
652
|
+
throw inputError(
|
|
653
|
+
`${option} expects a ratio or percent`,
|
|
654
|
+
`Received ${value === undefined ? 'no value' : `"${value}"`}.`,
|
|
655
|
+
`Use ${option} 0.25 for 25%, or ${option} 25.`
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
const ratio = n > 1 ? n / 100 : n;
|
|
659
|
+
if (ratio > 1) {
|
|
660
|
+
throw inputError(
|
|
661
|
+
`${option} expects a value from 0 to 1 or 0 to 100`,
|
|
662
|
+
`Received "${value}".`,
|
|
663
|
+
`Use ${option} 0.25 for 25%, or ${option} 25.`
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
return ratio;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Internal prompt style is selected from backend safety metadata. Local agent
|
|
670
|
+
// CLIs use the compact rewrite prompt by default to avoid feeding large pattern
|
|
671
|
+
// packs into batch-oriented agent runtimes.
|
|
672
|
+
export function resolvePromptMode({ backend, model }) {
|
|
745
673
|
const backendStr = (backend || '').toLowerCase();
|
|
746
674
|
const modelStr = (model || '').toLowerCase();
|
|
747
|
-
if (backendStr
|
|
748
|
-
if (modelStr.includes('
|
|
749
|
-
|
|
750
|
-
|
|
675
|
+
if (backendStr && backendStr !== 'openai-http') return getBackendSafety(backendStr).promptMode;
|
|
676
|
+
if (modelStr.includes('gemini')) return 'minimal';
|
|
677
|
+
if (backendStr) return getBackendSafety(backendStr).promptMode;
|
|
678
|
+
if (modelStr.includes('kimi') || modelStr.includes('claude') || modelStr.includes('codex')) return 'minimal';
|
|
751
679
|
return 'strict';
|
|
752
680
|
}
|
|
753
681
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
682
|
+
/**
|
|
683
|
+
* Resolve a profile name against language-specific profile limits.
|
|
684
|
+
*
|
|
685
|
+
* @param {string} profileName Requested profile name.
|
|
686
|
+
* @param {string} lang Active language code.
|
|
687
|
+
* @param {object} [logger] Logger with warn(event, payload).
|
|
688
|
+
* @returns {string} Effective profile name.
|
|
689
|
+
* @throws {Error} Propagates validation, filesystem, network, or dependency failures when the underlying operation cannot complete.
|
|
690
|
+
* @example
|
|
691
|
+
* resolveProfileForLanguage('namuwiki', 'en') // 'default'
|
|
692
|
+
*/
|
|
693
|
+
export function resolveProfileForLanguage(profileName, lang, logger = null) {
|
|
694
|
+
const effective = profileName || 'default';
|
|
695
|
+
if (effective === 'namuwiki' && lang !== 'ko') {
|
|
696
|
+
logger?.warn?.('profile.unsupported_language', {
|
|
697
|
+
message: `[patina] profile "namuwiki" is ko-only; falling back to default profile for --lang ${lang}`,
|
|
698
|
+
});
|
|
699
|
+
return 'default';
|
|
700
|
+
}
|
|
701
|
+
return effective;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
// Resolve the API key from file or environment. Precedence: --api-key-file >
|
|
706
|
+
// PATINA_API_KEY_FILE > provider/default env vars.
|
|
707
|
+
function resolveApiKey(parsed, provider) {
|
|
708
|
+
return resolveHttpApiKey({
|
|
762
709
|
apiKeyFile: parsed.apiKeyFile,
|
|
763
710
|
envVars: providerHttpKeyEnvVars(provider?.apiKeyEnv),
|
|
764
711
|
});
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function logBatchSafetyPlan({ jobs, backends, parsed, promptMode, timeoutMs, logger }) {
|
|
715
|
+
if (!parsed.batch || jobs.length <= 1) return;
|
|
716
|
+
|
|
717
|
+
const primary = backends[0];
|
|
718
|
+
const promptSizes = jobs
|
|
719
|
+
.map((job) => typeof job.prompt === 'string' ? job.prompt.length : 0)
|
|
720
|
+
.filter((size) => size > 0);
|
|
721
|
+
const maxPromptChars = promptSizes.length > 0 ? Math.max(...promptSizes) : 0;
|
|
722
|
+
const avgPromptChars = promptSizes.length > 0
|
|
723
|
+
? Math.round(promptSizes.reduce((sum, size) => sum + size, 0) / promptSizes.length)
|
|
724
|
+
: 0;
|
|
725
|
+
const maxConcurrency = resolveBackendMaxConcurrency(primary?.name, parsed.maxConcurrency);
|
|
726
|
+
const perFileRequests = backends.reduce(
|
|
727
|
+
(sum, item) => sum + resolveBackendMaxRetries(item.name, parsed.maxRetries) + 1,
|
|
728
|
+
0
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
logger.info('batch.safety_plan', {
|
|
732
|
+
message: `[patina] batch safety: files=${jobs.length}, backend=${backends.map((b) => b.name).join('→')}, prompt_mode=${promptMode}, max_concurrency=${formatLimit(maxConcurrency)}, max_retries=${resolveBackendMaxRetries(primary?.name, parsed.maxRetries)}, timeout_ms=${timeoutMs}, worst_case_requests=${jobs.length * perFileRequests}, max_prompt_chars=${maxPromptChars}, avg_prompt_chars=${avgPromptChars}`,
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
if (primary && getBackendSafety(primary.name).agentRuntime) {
|
|
736
|
+
logger.warn('batch.local_cli_caveat', {
|
|
737
|
+
message: `[patina] ${primary.name} is a local agent CLI, not a stateless batch completion API. Large batches should prefer an OpenAI-compatible HTTP provider when possible.`,
|
|
768
738
|
});
|
|
769
739
|
}
|
|
770
|
-
if (
|
|
771
|
-
logger.warn('
|
|
772
|
-
message:
|
|
773
|
-
' Prefer PATINA_API_KEY env var, --api-key-file <path>, or PATINA_API_KEY_FILE.',
|
|
740
|
+
if (maxPromptChars >= PROMPT_SIZE_WARNING_CHARS) {
|
|
741
|
+
logger.warn('batch.prompt_size', {
|
|
742
|
+
message: `[patina] largest prompt is ~${maxPromptChars.toLocaleString()} chars; failed attempts still send the full prompt.`,
|
|
774
743
|
});
|
|
775
744
|
}
|
|
776
|
-
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function createBatchCircuitBreaker({ parsed, total }) {
|
|
748
|
+
const active = parsed.batch && total > 1;
|
|
749
|
+
const maxFailures = active
|
|
750
|
+
? (parsed.maxFailures ?? Math.min(10, Math.max(3, Math.ceil(total * 0.1))))
|
|
751
|
+
: Infinity;
|
|
752
|
+
const maxFailureRate = active ? (parsed.maxFailureRate ?? 0.25) : Infinity;
|
|
753
|
+
const stormEnabled = active && (parsed.stopOnRetryableStorm ?? true);
|
|
754
|
+
const stormLimit = 3;
|
|
755
|
+
const failures = [];
|
|
756
|
+
const retryableBuckets = new Map();
|
|
757
|
+
let successes = 0;
|
|
758
|
+
let processed = 0;
|
|
759
|
+
let stopReason = null;
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
get failures() {
|
|
763
|
+
return failures;
|
|
764
|
+
},
|
|
765
|
+
get maxFailures() {
|
|
766
|
+
return maxFailures;
|
|
767
|
+
},
|
|
768
|
+
recordSuccess() {
|
|
769
|
+
processed++;
|
|
770
|
+
successes++;
|
|
771
|
+
},
|
|
772
|
+
recordFailure({ path, err }) {
|
|
773
|
+
processed++;
|
|
774
|
+
failures.push({ path, err });
|
|
775
|
+
const bucket = classifyRetryableStorm(err);
|
|
776
|
+
if (bucket) {
|
|
777
|
+
retryableBuckets.set(bucket, (retryableBuckets.get(bucket) || 0) + 1);
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
hasFailures() {
|
|
781
|
+
return failures.length > 0;
|
|
782
|
+
},
|
|
783
|
+
shouldStop() {
|
|
784
|
+
if (!active) return false;
|
|
785
|
+
if (failures.length >= maxFailures) {
|
|
786
|
+
stopReason = `max failures reached (${failures.length}/${maxFailures})`;
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
if (
|
|
790
|
+
Number.isFinite(maxFailureRate) &&
|
|
791
|
+
processed >= (parsed.maxFailureRate === undefined ? Math.min(total, 4) : 1) &&
|
|
792
|
+
failures.length / processed > maxFailureRate
|
|
793
|
+
) {
|
|
794
|
+
stopReason = `failure rate ${(failures.length / processed * 100).toFixed(1)}% exceeded ${(maxFailureRate * 100).toFixed(1)}%`;
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
if (stormEnabled) {
|
|
798
|
+
for (const [bucket, count] of retryableBuckets) {
|
|
799
|
+
if (count >= stormLimit) {
|
|
800
|
+
stopReason = `retryable storm detected (${count} × ${bucket})`;
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return false;
|
|
806
|
+
},
|
|
807
|
+
toError({ completed = false } = {}) {
|
|
808
|
+
const summary = failures
|
|
809
|
+
.slice(0, 5)
|
|
810
|
+
.map((failure) => `${failure.path}: ${failure.err.message}`)
|
|
811
|
+
.join(' | ');
|
|
812
|
+
const why = stopReason || (completed
|
|
813
|
+
? `Batch completed with ${failures.length} failed file(s).`
|
|
814
|
+
: `Batch stopped after ${failures.length} failed file(s).`);
|
|
815
|
+
return runtimeError(
|
|
816
|
+
completed ? 'batch completed with failures' : 'batch circuit breaker stopped the run',
|
|
817
|
+
`${why} Successes: ${successes}/${total}. Failures: ${failures.length}/${total}.`,
|
|
818
|
+
summary || 'Fix the backend failure, lower concurrency/retries, or rerun with a smaller batch.'
|
|
819
|
+
);
|
|
820
|
+
},
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function shouldHandleBatchFailure(parsed, total) {
|
|
825
|
+
return parsed.batch && total > 1;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function classifyRetryableStorm(err) {
|
|
829
|
+
const message = String(err?.message || err || '');
|
|
830
|
+
if (/\bHTTP\s+429\b/i.test(message) || err?.status === 429) return 'HTTP 429';
|
|
831
|
+
if (/\bHTTP\s+503\b/i.test(message) || err?.status === 503) return 'HTTP 503';
|
|
832
|
+
if (/Provider stream timed out/i.test(message)) return 'provider stream timeout';
|
|
833
|
+
if (/timed out/i.test(message) || err?.name === 'AbortError') return 'timeout';
|
|
834
|
+
const exit = message.match(/\bexited with code\s+(75|1)\b/i);
|
|
835
|
+
if (exit) return `exit ${exit[1]}`;
|
|
836
|
+
if (/no final response body|empty response|final-message-only/i.test(message)) return 'empty response';
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function formatLimit(value) {
|
|
841
|
+
return Number.isFinite(value) ? String(value) : 'unbounded';
|
|
777
842
|
}
|
|
778
843
|
|
|
779
844
|
async function loadInputs(parsed, logger = createLogger()) {
|
|
@@ -868,6 +933,7 @@ async function writeBatchOutput(parsed, inputPath, output) {
|
|
|
868
933
|
console.log(`Written: ${outPath}`);
|
|
869
934
|
}
|
|
870
935
|
|
|
936
|
+
|
|
871
937
|
function formatOuroborosOutput(result) {
|
|
872
938
|
let output = '## Ouroboros Iteration Log\n\n';
|
|
873
939
|
output += '| Iter | Before | After | Improvement | Reason |\n';
|
|
@@ -894,33 +960,31 @@ function printHelp() {
|
|
|
894
960
|
Usage: patina [command] [options] [file...]
|
|
895
961
|
|
|
896
962
|
COMMANDS
|
|
897
|
-
patina init Create a project .patina.yaml
|
|
898
963
|
patina doctor [--json] Check Node, backends, tmux, and auth setup
|
|
899
964
|
patina auth status Show backend availability and authentication status
|
|
900
965
|
patina auth login Print per-backend authentication instructions
|
|
966
|
+
patina auth login <backend> [--yes]
|
|
967
|
+
Launch a backend login flow after confirmation
|
|
901
968
|
|
|
902
969
|
MODES
|
|
903
970
|
--diff Show changes pattern by pattern
|
|
904
971
|
--no-color Disable ANSI colors in --diff output
|
|
905
972
|
--audit Detect patterns only (no rewrite)
|
|
906
973
|
--score Output AI-likeness score (0-100)
|
|
907
|
-
--
|
|
908
|
-
--exit-on <n> Alias for --gate, intended for CI scripts
|
|
974
|
+
--exit-on <n> With --score, exit 3 when overall score > n
|
|
909
975
|
--ouroboros Iterative self-improvement loop
|
|
910
976
|
|
|
911
977
|
OUTPUT & BATCH
|
|
912
978
|
--format <fmt> Output format: markdown (default), text, json
|
|
913
|
-
--json Alias for --format json
|
|
914
979
|
--quiet Suppress patina status/warning logs on stderr
|
|
915
|
-
--json-logs Emit stderr logs as NDJSON objects
|
|
916
980
|
--batch Process multiple files
|
|
917
981
|
--in-place Overwrite original files (with --batch)
|
|
918
982
|
--suffix <ext> Save as {name}{ext}{extname}
|
|
919
983
|
--outdir <dir> Save results to directory
|
|
920
|
-
--
|
|
921
|
-
--
|
|
922
|
-
--
|
|
923
|
-
|
|
984
|
+
--max-failures <n> Stop batch after n failed files
|
|
985
|
+
--max-failure-rate <r> Stop batch when failure ratio exceeds r (0.25 or 25)
|
|
986
|
+
--stop-on-retryable-storm
|
|
987
|
+
Stop batch after repeated 429/timeouts/empty local-CLI exits
|
|
924
988
|
--no-interactive Do not wait for TTY stdin; exit 2 when no input is given
|
|
925
989
|
|
|
926
990
|
LANGUAGE & PROFILE
|
|
@@ -928,32 +992,28 @@ LANGUAGE & PROFILE
|
|
|
928
992
|
--profile <name> Profile: default, blog, academic, technical, formal,
|
|
929
993
|
social, email, legal, medical, marketing,
|
|
930
994
|
narrative, instructional, casual-conversation,
|
|
931
|
-
code-comment, commit-message, release-notes
|
|
995
|
+
code-comment, commit-message, release-notes, namuwiki
|
|
932
996
|
--tone <name> Tone: casual, professional, academic, narrative,
|
|
933
997
|
marketing, instructional, auto. Resolution:
|
|
934
998
|
--tone > config tone > config profile.
|
|
935
999
|
--voice-sample <path> Use 1-3 user paragraphs as style-only voice anchors
|
|
936
1000
|
|
|
937
1001
|
MODEL & AUTH
|
|
938
|
-
--model <id> Single model ID
|
|
939
|
-
|
|
940
|
-
|
|
1002
|
+
--model <id> Single model ID. Defaults use the strongest
|
|
1003
|
+
documented model per backend: openai/codex gpt-5.5,
|
|
1004
|
+
claude-sonnet-4-6, gemini-2.5-pro,
|
|
1005
|
+
kimi-code/kimi-for-coding.
|
|
941
1006
|
--api-key-file <path> Read API key from file (recommended)
|
|
942
1007
|
--base-url <url> API base URL (or PATINA_API_BASE env)
|
|
943
1008
|
--backend <name[,name]> Backend or explicit fallback chain:
|
|
944
1009
|
${backendChoices} (default: openai-http)
|
|
945
|
-
--list-backends List
|
|
946
|
-
--
|
|
947
|
-
--
|
|
948
|
-
--
|
|
949
|
-
--
|
|
950
|
-
use 0 for unlimited, which can hit free-tier quotas)
|
|
951
|
-
--max-timeout <sec> Wall-clock budget for standalone MAX mode (default: 300)
|
|
952
|
-
|
|
1010
|
+
--list-backends List backends, selectors, default models, and auth status
|
|
1011
|
+
--timeout-ms <n> Per-request/backend timeout in milliseconds
|
|
1012
|
+
--max-concurrency <n> Cross-process backend cap (safe defaults per backend)
|
|
1013
|
+
--max-retries <n> Retry budget per backend (local CLIs default to 0)
|
|
1014
|
+
--provider <name> Provider preset: openai, gemini, groq, kimi, moonshot, together
|
|
953
1015
|
ADVANCED
|
|
954
|
-
--variants <n> Generate N rewrite variants (1-5; rewrite mode only)
|
|
955
1016
|
--config <path> Load config from <path> instead of .patina.default.yaml
|
|
956
|
-
--prompt-mode <m> strict | minimal | auto. auto picks per backend.
|
|
957
1017
|
--allow-insecure-base-url Permit plaintext http:// to non-localhost endpoints
|
|
958
1018
|
--allow-private-base-url Permit private/IMDS base URLs
|
|
959
1019
|
-h, --help Show this help message
|
|
@@ -962,16 +1022,15 @@ ADVANCED
|
|
|
962
1022
|
EXAMPLES
|
|
963
1023
|
echo "This is a draft." | patina --lang en --backend codex-cli
|
|
964
1024
|
patina --score --exit-on 30 --format json draft.md
|
|
965
|
-
patina init --defaults
|
|
966
1025
|
patina doctor --json
|
|
967
1026
|
|
|
968
1027
|
ENVIRONMENT
|
|
969
1028
|
PATINA_API_KEY, PATINA_API_KEY_FILE, PATINA_API_BASE, PATINA_MODEL
|
|
970
|
-
|
|
971
|
-
|
|
1029
|
+
OPENAI_API_KEY, GEMINI_API_KEY, GROQ_API_KEY, TOGETHER_API_KEY,
|
|
1030
|
+
KIMI_API_KEY, MOONSHOT_API_KEY
|
|
972
1031
|
|
|
973
1032
|
EXIT CODES
|
|
974
|
-
0 success · 1 runtime/backend · 2 input/usage · 3 score gate exceeded ·
|
|
1033
|
+
0 success · 1 runtime/backend · 2 input/usage · 3 score gate exceeded · 130 interrupted
|
|
975
1034
|
|
|
976
1035
|
If no API key is set, pass --backend codex-cli to use a logged-in codex CLI
|
|
977
1036
|
(no key required). Auto-fallback was removed in v3.9 to keep agent-mode
|
|
@@ -1001,144 +1060,12 @@ function withDeterministicScore(rawResult, { text, config, repoRoot, logger }) {
|
|
|
1001
1060
|
};
|
|
1002
1061
|
}
|
|
1003
1062
|
|
|
1004
|
-
function manifestScoreDetails(result) {
|
|
1005
|
-
if (!result || typeof result !== 'object') return null;
|
|
1006
|
-
if (!result.llmScore && !result.deterministicScore && !result.scorePreference) return null;
|
|
1007
|
-
return {
|
|
1008
|
-
llm: result.llmScore ?? null,
|
|
1009
|
-
deterministic: result.deterministicScore ?? null,
|
|
1010
|
-
preference: result.scorePreference ?? null,
|
|
1011
|
-
};
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
function resolveResponseCache(parsed) {
|
|
1015
|
-
if (parsed.noCache) return null;
|
|
1016
|
-
const dir = parsed.cacheDir ?? process.env.PATINA_CACHE_DIR;
|
|
1017
|
-
if (!dir) return null;
|
|
1018
1063
|
|
|
1019
|
-
const ttlSeconds =
|
|
1020
|
-
parsed.cacheTtlSeconds ??
|
|
1021
|
-
parseOptionalPositiveNumber(process.env.PATINA_CACHE_TTL_SECONDS, 'PATINA_CACHE_TTL_SECONDS') ??
|
|
1022
|
-
DEFAULT_CACHE_TTL_SECONDS;
|
|
1023
|
-
|
|
1024
|
-
return createResponseCache({
|
|
1025
|
-
dir: resolve(process.cwd(), dir),
|
|
1026
|
-
ttlSeconds,
|
|
1027
|
-
});
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
function parseOptionalPositiveNumber(value, name) {
|
|
1031
|
-
if (value === undefined || value === null || value === '') return null;
|
|
1032
|
-
const n = Number(value);
|
|
1033
|
-
if (!Number.isFinite(n) || n <= 0) {
|
|
1034
|
-
throw inputError(
|
|
1035
|
-
`${name} expects a positive number of seconds`,
|
|
1036
|
-
`Received "${value}".`,
|
|
1037
|
-
`Set ${name}=86400 or omit it for the default.`
|
|
1038
|
-
);
|
|
1039
|
-
}
|
|
1040
|
-
return n;
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
function formatCacheStats(stats) {
|
|
1044
|
-
const expired = stats.expired ? `, expired ${stats.expired}` : '';
|
|
1045
|
-
const errors = stats.errors ? `, errors ${stats.errors}` : '';
|
|
1046
|
-
return `[patina] cache hits ${stats.hits}, misses ${stats.misses}, writes ${stats.writes}${expired}${errors}`;
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
function createManifestCallRecorder(calls) {
|
|
1050
|
-
return (metadata = {}) => {
|
|
1051
|
-
calls.push({
|
|
1052
|
-
provider: metadata.provider ?? null,
|
|
1053
|
-
model: metadata.model ?? null,
|
|
1054
|
-
requestedModel: metadata.requestedModel ?? null,
|
|
1055
|
-
temperature: metadata.temperature ?? null,
|
|
1056
|
-
seed: metadata.seed ?? null,
|
|
1057
|
-
responseHash: hashSha256(metadata.content),
|
|
1058
|
-
tokensIn: extractUsageToken(metadata.usage, ['prompt_tokens', 'input_tokens', 'tokens_in']),
|
|
1059
|
-
tokensOut: extractUsageToken(metadata.usage, ['completion_tokens', 'output_tokens', 'tokens_out']),
|
|
1060
|
-
cost: extractResponseCost(metadata.rawResponse, metadata.usage),
|
|
1061
|
-
cache: metadata.cache ?? null,
|
|
1062
|
-
});
|
|
1063
|
-
};
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
function extractUsageToken(usage, keys) {
|
|
1067
|
-
if (!usage || typeof usage !== 'object') return null;
|
|
1068
|
-
for (const key of keys) {
|
|
1069
|
-
const value = Number(usage[key]);
|
|
1070
|
-
if (Number.isFinite(value)) return value;
|
|
1071
|
-
}
|
|
1072
|
-
return null;
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
function extractResponseCost(rawResponse, fallbackUsage) {
|
|
1076
|
-
const usage = rawResponse?.usage && typeof rawResponse.usage === 'object'
|
|
1077
|
-
? rawResponse.usage
|
|
1078
|
-
: (fallbackUsage && typeof fallbackUsage === 'object' ? fallbackUsage : {});
|
|
1079
|
-
const candidates = [
|
|
1080
|
-
['usage.cost_usd', usage.cost_usd, 'USD'],
|
|
1081
|
-
['usage.total_cost_usd', usage.total_cost_usd, 'USD'],
|
|
1082
|
-
['usage.cost', usage.cost, usage.currency],
|
|
1083
|
-
['usage.total_cost', usage.total_cost, usage.currency],
|
|
1084
|
-
['cost_usd', rawResponse?.cost_usd, 'USD'],
|
|
1085
|
-
['cost', rawResponse?.cost, rawResponse?.currency],
|
|
1086
|
-
];
|
|
1087
|
-
|
|
1088
|
-
for (const [source, value, currency] of candidates) {
|
|
1089
|
-
const amount = Number(value);
|
|
1090
|
-
if (Number.isFinite(amount)) {
|
|
1091
|
-
return {
|
|
1092
|
-
amount,
|
|
1093
|
-
currency: currency || 'USD',
|
|
1094
|
-
source,
|
|
1095
|
-
};
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
return null;
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
function manifestResponseText(result, output) {
|
|
1102
|
-
if (result?.type === 'max-mode') return result.best?.result ?? output;
|
|
1103
|
-
if (typeof result?.finalText === 'string') return result.finalText;
|
|
1104
|
-
if (typeof result?.raw === 'string') return result.raw;
|
|
1105
|
-
if (typeof result === 'string') return result;
|
|
1106
|
-
return output;
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
function manifestIterationLog(result) {
|
|
1110
|
-
return Array.isArray(result?.log) ? result.log : null;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
function sumManifestCalls(calls, key) {
|
|
1114
|
-
const values = calls
|
|
1115
|
-
.map((call) => call[key])
|
|
1116
|
-
.filter((value) => value !== null && value !== undefined)
|
|
1117
|
-
.map((value) => Number(value))
|
|
1118
|
-
.filter((value) => Number.isFinite(value));
|
|
1119
|
-
if (values.length === 0) return null;
|
|
1120
|
-
return values.reduce((sum, value) => sum + value, 0);
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
function sumManifestCallCost(calls) {
|
|
1124
|
-
const costs = calls
|
|
1125
|
-
.map((call) => call.cost)
|
|
1126
|
-
.filter((cost) => cost && Number.isFinite(Number(cost.amount)));
|
|
1127
|
-
if (costs.length === 0) return null;
|
|
1128
|
-
|
|
1129
|
-
const currency = costs[0].currency || 'USD';
|
|
1130
|
-
if (!costs.every((cost) => (cost.currency || 'USD') === currency)) return null;
|
|
1131
|
-
return {
|
|
1132
|
-
amount: costs.reduce((sum, cost) => sum + Number(cost.amount), 0),
|
|
1133
|
-
currency,
|
|
1134
|
-
source: 'sum',
|
|
1135
|
-
};
|
|
1136
|
-
}
|
|
1137
1064
|
|
|
1138
1065
|
function applyScoreGate(result, output, gate, logger = createLogger()) {
|
|
1139
1066
|
const overall = extractScoreOverall(result, output);
|
|
1140
1067
|
if (overall === null) {
|
|
1141
|
-
throw new Error('
|
|
1068
|
+
throw new Error('score gate could not find a numeric `overall` value in --score output.');
|
|
1142
1069
|
}
|
|
1143
1070
|
if (overall > gate) {
|
|
1144
1071
|
logger.warn('score.gate_failed', { message: `[patina] score gate failed: overall ${overall} > ${gate}` });
|
|
@@ -1189,71 +1116,63 @@ function printBackendStatus() {
|
|
|
1189
1116
|
const list = listBackends();
|
|
1190
1117
|
const rows = list.map((b) => ({
|
|
1191
1118
|
name: b.name,
|
|
1119
|
+
kind: b.kind,
|
|
1120
|
+
selectWith: b.selectWith,
|
|
1121
|
+
defaultModel: b.defaultModel || '-',
|
|
1192
1122
|
available: b.available ? 'yes' : 'no',
|
|
1193
1123
|
authenticated: b.authenticated ? 'yes' : 'no',
|
|
1194
|
-
note: b
|
|
1124
|
+
note: `${backendSafetyNote(b)} ${backendStatusNote(b)}`.trim(),
|
|
1195
1125
|
}));
|
|
1196
1126
|
const widths = {
|
|
1197
1127
|
name: Math.max('Backend'.length, ...rows.map((r) => r.name.length)),
|
|
1128
|
+
kind: Math.max('Kind'.length, ...rows.map((r) => r.kind.length)),
|
|
1129
|
+
selectWith: Math.max('Select with'.length, ...rows.map((r) => r.selectWith.length)),
|
|
1130
|
+
defaultModel: Math.max('Default model'.length, ...rows.map((r) => r.defaultModel.length)),
|
|
1198
1131
|
available: Math.max('Available'.length, ...rows.map((r) => r.available.length)),
|
|
1199
1132
|
authenticated: Math.max('Authenticated'.length, ...rows.map((r) => r.authenticated.length)),
|
|
1200
1133
|
};
|
|
1201
1134
|
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
1202
1135
|
console.log(
|
|
1203
|
-
`${pad('Backend', widths.name)} ${pad('Available', widths.available)} ${pad('Authenticated', widths.authenticated)} Notes`
|
|
1136
|
+
`${pad('Backend', widths.name)} ${pad('Kind', widths.kind)} ${pad('Select with', widths.selectWith)} ${pad('Default model', widths.defaultModel)} ${pad('Available', widths.available)} ${pad('Authenticated', widths.authenticated)} Notes`
|
|
1204
1137
|
);
|
|
1205
1138
|
console.log(
|
|
1206
|
-
`${'-'.repeat(widths.name)} ${'-'.repeat(widths.available)} ${'-'.repeat(widths.authenticated)} -----`
|
|
1139
|
+
`${'-'.repeat(widths.name)} ${'-'.repeat(widths.kind)} ${'-'.repeat(widths.selectWith)} ${'-'.repeat(widths.defaultModel)} ${'-'.repeat(widths.available)} ${'-'.repeat(widths.authenticated)} -----`
|
|
1207
1140
|
);
|
|
1208
1141
|
for (const r of rows) {
|
|
1209
1142
|
console.log(
|
|
1210
|
-
`${pad(r.name, widths.name)} ${pad(r.available, widths.available)} ${pad(r.authenticated, widths.authenticated)} ${r.note}`
|
|
1143
|
+
`${pad(r.name, widths.name)} ${pad(r.kind, widths.kind)} ${pad(r.selectWith, widths.selectWith)} ${pad(r.defaultModel, widths.defaultModel)} ${pad(r.available, widths.available)} ${pad(r.authenticated, widths.authenticated)} ${r.note}`
|
|
1211
1144
|
);
|
|
1212
1145
|
}
|
|
1213
1146
|
}
|
|
1214
1147
|
|
|
1215
|
-
function
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
providerEnv: process.env[p.apiKeyEnv] ? 'set' : 'missing',
|
|
1221
|
-
note: `${p.apiKeyEnv} → ${p.baseURL}`,
|
|
1222
|
-
}));
|
|
1223
|
-
const widths = {
|
|
1224
|
-
name: Math.max('Provider'.length, ...rows.map((r) => r.name.length)),
|
|
1225
|
-
free: Math.max('Free tier'.length, ...rows.map((r) => r.free.length)),
|
|
1226
|
-
keySource: Math.max('Key source'.length, ...rows.map((r) => r.keySource.length)),
|
|
1227
|
-
providerEnv: Math.max('Provider env'.length, ...rows.map((r) => r.providerEnv.length)),
|
|
1228
|
-
};
|
|
1229
|
-
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
1230
|
-
console.log(
|
|
1231
|
-
`${pad('Provider', widths.name)} ${pad('Free tier', widths.free)} ${pad('Key source', widths.keySource)} ${pad('Provider env', widths.providerEnv)} Notes`
|
|
1232
|
-
);
|
|
1233
|
-
console.log(
|
|
1234
|
-
`${'-'.repeat(widths.name)} ${'-'.repeat(widths.free)} ${'-'.repeat(widths.keySource)} ${'-'.repeat(widths.providerEnv)} -----`
|
|
1235
|
-
);
|
|
1236
|
-
for (const r of rows) {
|
|
1237
|
-
console.log(
|
|
1238
|
-
`${pad(r.name, widths.name)} ${pad(r.free, widths.free)} ${pad(r.keySource, widths.keySource)} ${pad(r.providerEnv, widths.providerEnv)} ${r.note}`
|
|
1239
|
-
);
|
|
1148
|
+
function backendStatusNote(backend) {
|
|
1149
|
+
if (!backend.available) return backend.installHint || backend.authHint;
|
|
1150
|
+
if (backend.name === 'openai-http') return backend.authHint;
|
|
1151
|
+
if (backend.authenticated && backend.name === 'gemini-cli' && backend.authHint.startsWith('Authenticated via ')) {
|
|
1152
|
+
return backend.authHint;
|
|
1240
1153
|
}
|
|
1154
|
+
if (backend.authenticated) return 'ready';
|
|
1155
|
+
if (backend.loginCommand) return `${backend.authHint} Use \`patina auth login ${backend.name}\` for the guided flow.`;
|
|
1156
|
+
return backend.authHint;
|
|
1241
1157
|
}
|
|
1242
1158
|
|
|
1243
|
-
function
|
|
1244
|
-
|
|
1245
|
-
envVars: providerHttpKeyEnvVars(provider.apiKeyEnv),
|
|
1246
|
-
});
|
|
1247
|
-
return source.ok ? source.source : 'missing';
|
|
1159
|
+
function backendSafetyNote(backend) {
|
|
1160
|
+
return `cap=${formatLimit(backend.maxConcurrency)}, retries=${backend.maxRetries}, prompt=${backend.promptMode};`;
|
|
1248
1161
|
}
|
|
1249
1162
|
|
|
1250
|
-
function handleAuth(subArgs) {
|
|
1163
|
+
async function handleAuth(subArgs) {
|
|
1251
1164
|
const sub = subArgs[0] || 'status';
|
|
1252
1165
|
if (sub === 'status') {
|
|
1253
1166
|
printBackendStatus();
|
|
1254
1167
|
return;
|
|
1255
1168
|
}
|
|
1256
1169
|
if (sub === 'login') {
|
|
1170
|
+
const parsed = parseAuthLoginArgs(subArgs.slice(1));
|
|
1171
|
+
if (parsed.backendName) {
|
|
1172
|
+
await runAuthLogin(parsed);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1257
1176
|
console.log('To authenticate a backend, follow the per-backend instructions:\n');
|
|
1258
1177
|
for (const b of listBackends()) {
|
|
1259
1178
|
const status = b.authenticated ? '✓ already authenticated' : '✗ not authenticated';
|
|
@@ -1265,10 +1184,101 @@ function handleAuth(subArgs) {
|
|
|
1265
1184
|
throw inputError(
|
|
1266
1185
|
`unknown auth subcommand ${sub}`,
|
|
1267
1186
|
'Supported auth subcommands are status and login.',
|
|
1268
|
-
'Try `patina auth status` or `patina auth login`.'
|
|
1187
|
+
'Try `patina auth status`, `patina auth login`, or `patina auth login codex-cli`.'
|
|
1269
1188
|
);
|
|
1270
1189
|
}
|
|
1271
1190
|
|
|
1191
|
+
function parseAuthLoginArgs(args) {
|
|
1192
|
+
let assumeYes = false;
|
|
1193
|
+
let backendName = null;
|
|
1194
|
+
|
|
1195
|
+
for (const arg of args) {
|
|
1196
|
+
if (arg === '--yes' || arg === '-y') {
|
|
1197
|
+
assumeYes = true;
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
if (arg.startsWith('-')) {
|
|
1201
|
+
throw inputError(
|
|
1202
|
+
`unknown auth login option ${arg}`,
|
|
1203
|
+
'Only --yes/-y is supported for non-interactive confirmation.',
|
|
1204
|
+
'Run `patina auth login <backend> --yes` or omit --yes to confirm interactively.'
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
if (backendName) {
|
|
1208
|
+
throw inputError(
|
|
1209
|
+
'auth login expects at most one backend',
|
|
1210
|
+
`Received both ${backendName} and ${arg}.`,
|
|
1211
|
+
`Available backends are: ${listBackendNames().join(', ')}.`
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
backendName = arg;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
return { backendName, assumeYes };
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
async function runAuthLogin({ backendName, assumeYes }) {
|
|
1221
|
+
const backend = resolveBackend(backendName);
|
|
1222
|
+
if (typeof backend.login !== 'function') {
|
|
1223
|
+
throw inputError(
|
|
1224
|
+
`${backend.name} does not support interactive login`,
|
|
1225
|
+
backend.authHint ? backend.authHint() : 'This backend authenticates outside local CLI OAuth.',
|
|
1226
|
+
'Set PATINA_API_KEY, PATINA_API_KEY_FILE, or the provider-specific API key env var for HTTP backends.'
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (!backend.isAvailable()) {
|
|
1231
|
+
throw runtimeError(
|
|
1232
|
+
`${backend.name} CLI is not installed or not on PATH`,
|
|
1233
|
+
backend.installHint || backend.authHint(),
|
|
1234
|
+
'Install the CLI named above, then rerun this command.'
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const commandLabel = backend.loginCommand || backend.name;
|
|
1239
|
+
const confirmed = await confirmAuthLogin(commandLabel, { assumeYes });
|
|
1240
|
+
if (!confirmed) {
|
|
1241
|
+
console.log('Cancelled.');
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const wasAuthenticated = backend.isAuthenticated();
|
|
1246
|
+
await backend.login();
|
|
1247
|
+
const authenticated = backend.isAuthenticated();
|
|
1248
|
+
|
|
1249
|
+
if (authenticated) {
|
|
1250
|
+
console.log(`${backend.name}: authenticated.`);
|
|
1251
|
+
} else if (wasAuthenticated) {
|
|
1252
|
+
console.log(`${backend.name}: login command completed; previous authentication is still present.`);
|
|
1253
|
+
} else {
|
|
1254
|
+
console.log(`${backend.name}: login command completed, but patina could not confirm authentication yet.`);
|
|
1255
|
+
console.log(`→ ${backend.authHint()}`);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async function confirmAuthLogin(commandLabel, { assumeYes = false } = {}) {
|
|
1260
|
+
if (assumeYes) {
|
|
1261
|
+
console.log(`Run ${commandLabel}? yes (--yes)`);
|
|
1262
|
+
return true;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (!process.stdin.isTTY) {
|
|
1266
|
+
throw inputError(
|
|
1267
|
+
`cannot confirm ${commandLabel} in a non-interactive session`,
|
|
1268
|
+
'patina will not launch an interactive OAuth flow without explicit confirmation.',
|
|
1269
|
+
`Rerun with \`patina auth login <backend> --yes\` if you intentionally want to start ${commandLabel}.`
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1274
|
+
try {
|
|
1275
|
+
const answer = await rl.question(`Run ${commandLabel}? [Y/n] `);
|
|
1276
|
+
return answer.trim() === '' || /^y(es)?$/i.test(answer.trim());
|
|
1277
|
+
} finally {
|
|
1278
|
+
rl.close();
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1272
1282
|
// Self-invocation guard (#113): when run directly via `node src/cli.js ...`,
|
|
1273
1283
|
// run main(). When imported (e.g. by bin/patina.js or tests), just expose
|
|
1274
1284
|
// the exports.
|