@pugi/cli 0.1.0-beta.13 → 0.1.0-beta.14

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.
@@ -196,6 +196,25 @@ export class AnvilEngineLoopClient {
196
196
  // for the broader "misleading error" pattern.)
197
197
  try {
198
198
  const parsed = text ? JSON.parse(text) : null;
199
+ // 2026-05-27 dogfood cycle 2: distinct error code for the
200
+ // infra-side "PII scrubber down" case. Previously the engine
201
+ // server returned `privacy_strict_upstream_blocked` here even
202
+ // when the tenant was on BALANCED (the scrubber crash forced
203
+ // a fail-closed). Operators chased the wrong fix ("switch
204
+ // privacy") for hours. Server now emits
205
+ // `pii_scrubber_unavailable` — surface a distinct remediation
206
+ // that points at the infra side, not the operator's privacy
207
+ // posture.
208
+ if (parsed?.code === 'pii_scrubber_unavailable') {
209
+ return {
210
+ stop: 'error',
211
+ code: 'privacy_blocked',
212
+ message: parsed.message ?? 'PII scrubber unavailable; privacy filter refused dispatch.',
213
+ remediation: 'Infra-side issue (not your tenant privacy mode). Wait for ops to restore ' +
214
+ 'the PiiScrubberService, OR temporarily switch your tenant to permissive via ' +
215
+ '`pugi config set privacy=permissive`.',
216
+ };
217
+ }
199
218
  if (parsed?.code === 'privacy_strict_upstream_blocked' || parsed?.code === 'privacy_blocked') {
200
219
  return {
201
220
  stop: 'error',
@@ -763,6 +763,28 @@ function parseArgs(argv) {
763
763
  }
764
764
  flags.maxTurns = parsed;
765
765
  }
766
+ else if (arg.startsWith('--commit=')) {
767
+ // `pugi review --triple --commit <SHA>` activates the multi-
768
+ // provider routing path against a specific revision.
769
+ flags.commit = arg.slice('--commit='.length);
770
+ }
771
+ else if (arg === '--commit') {
772
+ const next = argv[index + 1];
773
+ if (!next)
774
+ throw new Error('--commit requires a SHA or ref');
775
+ flags.commit = next;
776
+ index += 1;
777
+ }
778
+ else if (arg.startsWith('--base=')) {
779
+ flags.base = arg.slice('--base='.length);
780
+ }
781
+ else if (arg === '--base') {
782
+ const next = argv[index + 1];
783
+ if (!next)
784
+ throw new Error('--base requires a ref');
785
+ flags.base = next;
786
+ index += 1;
787
+ }
766
788
  else {
767
789
  args.push(arg);
768
790
  }
@@ -830,6 +852,9 @@ async function help(_args, flags, _session) {
830
852
  '',
831
853
  'Review gate:',
832
854
  ' pugi review --triple Prepare the Anvil-backed triple-review gate.',
855
+ ' pugi review --triple --commit <SHA>',
856
+ ' 3-model consensus via Anvil (Anthropic · OpenAI · Google).',
857
+ ' Optional: --base <ref> | "<prompt>". Quota: 1 slot per call.',
833
858
  ' pugi review --consensus 3-model consensus review (codex · claude · deepseek).',
834
859
  ' Optional: --commit <sha> | --pr <num> | --branch <name>.',
835
860
  ' Exits 0 PASS · 1 WARN · 2 BLOCK.',
@@ -992,7 +1017,19 @@ export async function scaffoldPugiWorkspace(input) {
992
1017
  }, created, skipped);
993
1018
  writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), {
994
1019
  schema: 1,
995
- servers: [],
1020
+ // 2026-05-27 dogfood: `servers` MUST be an object keyed by server
1021
+ // name (z.record(mcpServerConfigSchema) in
1022
+ // apps/pugi-cli/src/core/mcp/registry.ts:51). A bare `[]` array
1023
+ // here passed schema validation на pugi init exit но crashed
1024
+ // the next dispatch with
1025
+ // "MCP config at .pugi/mcp.json failed validation:
1026
+ // servers: Expected object, received array"
1027
+ // and the operator's first command after `pugi init` printed an
1028
+ // error banner before the actual reply. Empty object matches the
1029
+ // schema default and keeps the file forwards-compatible with
1030
+ // `pugi mcp install <name> ...` which merges into the same
1031
+ // record shape.
1032
+ servers: {},
996
1033
  }, created, skipped);
997
1034
  writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
998
1035
  writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
@@ -1414,10 +1451,20 @@ async function review(args, flags, session) {
1414
1451
  // streaming UX and rubric-driven exit codes don't disturb the existing
1415
1452
  // pugi-cli surfaces that depend on the old shape.
1416
1453
  if (flags.consensus) {
1454
+ // 2026-05-27 (Codex r0 P1 on PR #489): pass the globally-parsed
1455
+ // --commit / --base flags to consensus so `pugi review --consensus
1456
+ // --commit X` reviews the requested SHA instead of silently falling
1457
+ // back to the working-tree diff. parseConsensusArgs gives the inline
1458
+ // args (`--commit Y` after the command name) precedence; the
1459
+ // fallback only fires when `args` does not carry the token.
1417
1460
  const exitCode = await runReviewConsensus(args, {
1418
1461
  cwd: root,
1419
1462
  config: resolveRuntimeConfig(),
1420
1463
  json: flags.json,
1464
+ flagsFallback: {
1465
+ ...(flags.commit ? { commit: flags.commit } : {}),
1466
+ ...(flags.base ? { base: flags.base } : {}),
1467
+ },
1421
1468
  emit: (line) => {
1422
1469
  if (!flags.json)
1423
1470
  process.stdout.write(line);
@@ -1429,6 +1476,15 @@ async function review(args, flags, session) {
1429
1476
  process.exitCode = exitCode;
1430
1477
  return;
1431
1478
  }
1479
+ if (flags.triple && flags.commit) {
1480
+ // CEO directive 2026-05-27: `pugi review --triple --commit <SHA>`
1481
+ // dispatches to the customer-facing 3-model consensus path through
1482
+ // Anvil's already-paid Anthropic / OpenAI / Google routes. Replaces
1483
+ // the dev-only Codex/Claude/Gemini OAuth CLIs the `/triple-review`
1484
+ // skill uses.
1485
+ await performTripleProviderReview(root, session, flags, prompt);
1486
+ return;
1487
+ }
1432
1488
  if (flags.triple && flags.remote) {
1433
1489
  await performRemoteTripleReview(root, session, flags, prompt);
1434
1490
  return;
@@ -1866,6 +1922,274 @@ async function performRemoteTripleReview(root, session, flags, prompt) {
1866
1922
  .join('\n'));
1867
1923
  process.exitCode = outcome.exitCode;
1868
1924
  }
1925
+ /**
1926
+ * `pugi review --triple --commit <SHA>` — customer-facing 3-model
1927
+ * consensus review via Anvil multi-provider routing.
1928
+ *
1929
+ * Dispatches the same diff to Anthropic / OpenAI / Google models
1930
+ * (routed through Anvil's already-paid fleet, NOT OAuth-bound dev
1931
+ * CLIs) and renders the per-reviewer verdict + cross-model
1932
+ * disagreement summary at the end. Quota: one `reviewPerMonth` slot
1933
+ * per call regardless of provider count — the controller-level
1934
+ * `@QuotaGated('reviewPerMonth')` decorator enforces single-slot
1935
+ * debit (see apps/admin-api/src/pugi/pugi.controller.ts).
1936
+ *
1937
+ * CEO directive 2026-05-27: replaces the dev-only `/triple-review`
1938
+ * skill's Codex/Claude/Gemini OAuth dependency with a customer-
1939
+ * runnable Pugi product surface. Dogfood loop: Pugi reviews Pugi PRs.
1940
+ */
1941
+ async function performTripleProviderReview(root, session, flags, prompt) {
1942
+ const config = resolveRuntimeConfig();
1943
+ const artifactDir = createArtifactDir(root, prompt || 'triple-providers');
1944
+ const requestPath = resolve(artifactDir, 'triple-review-request.json');
1945
+ const resultPath = resolve(artifactDir, 'triple-review-result.json');
1946
+ const summaryPath = resolve(artifactDir, 'triple-review.md');
1947
+ const toolCallId = recordToolCall(session, 'review:triple-providers', prompt || `review ${flags.commit ?? 'HEAD'} via providers`);
1948
+ // Resolve base ref. CLI flag wins over settings → so an operator
1949
+ // can target a specific integration branch without editing settings.
1950
+ const settings = loadSettings(root);
1951
+ const baseRef = flags.base ?? resolveBaseRef(root, settings) ?? 'origin/main';
1952
+ // Normalise both the commit and the base to short SHAs so the audit
1953
+ // log stores a stable reference even if branches move.
1954
+ const commitRef = flags.commit ?? 'HEAD';
1955
+ // 2026-05-27 (Codex r0 P2 on PR #489): safeGit returns '' on a bad ref
1956
+ // (it swallows the git exit code so callers don't have to wrap every
1957
+ // probe). Without an explicit refusal, a misspelled --commit or --base
1958
+ // produced an EMPTY diff that the gate then PASSED — operators saw a
1959
+ // green review for changes that were never reviewed. Resolve both refs
1960
+ // through `rev-parse --verify` first; an empty result is a hard error.
1961
+ const verifiedCommit = safeGit(root, ['rev-parse', '--verify', commitRef]).trim();
1962
+ if (!verifiedCommit) {
1963
+ throw new Error(`pugi review --triple: cannot resolve --commit '${commitRef}' — ` +
1964
+ `check the SHA or branch name. ` +
1965
+ `Refusing to submit an empty diff for review.`);
1966
+ }
1967
+ const verifiedBase = safeGit(root, ['rev-parse', '--verify', baseRef]).trim();
1968
+ if (!verifiedBase) {
1969
+ throw new Error(`pugi review --triple: cannot resolve --base '${baseRef}' — ` +
1970
+ `check the ref or set base via 'pugi config set review.base=<ref>'. ` +
1971
+ `Refusing to submit an empty diff for review.`);
1972
+ }
1973
+ const resolvedCommit = safeGit(root, ['rev-parse', '--short', commitRef]).trim() || commitRef;
1974
+ const mergeBase = safeGit(root, ['merge-base', baseRef, commitRef]).trim() || '';
1975
+ const diffRange = mergeBase || `${baseRef}..${commitRef}`;
1976
+ const diffArgs = ['diff', diffRange, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
1977
+ const diffStatArgs = ['diff', '--shortstat', diffRange, commitRef, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
1978
+ const diffPatch = safeGit(root, diffArgs);
1979
+ const diffStats = parseDiffStats(safeGit(root, diffStatArgs));
1980
+ const requestBody = pugiTripleReviewRequestSchema.parse({
1981
+ schema: 1,
1982
+ workspace: {
1983
+ rootName: root.split('/').at(-1) ?? 'workspace',
1984
+ gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
1985
+ gitHead: resolvedCommit || null,
1986
+ baseRef,
1987
+ dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
1988
+ },
1989
+ diffPatch,
1990
+ diffStats,
1991
+ prompt: prompt || undefined,
1992
+ locale: 'en-US',
1993
+ reviewerPersona: 'oes-dev',
1994
+ commit: resolvedCommit,
1995
+ modelProviders: ['claude', 'gpt', 'gemini'],
1996
+ });
1997
+ writeFileSync(requestPath, `${JSON.stringify(requestBody, null, 2)}\n`, {
1998
+ encoding: 'utf8',
1999
+ mode: 0o600,
2000
+ });
2001
+ registerArtifact(root, {
2002
+ id: artifactIdFromDir(artifactDir),
2003
+ kind: 'triple-review',
2004
+ path: relative(root, artifactDir),
2005
+ sessionId: session.id,
2006
+ createdAt: new Date().toISOString(),
2007
+ files: ['triple-review-request.json'],
2008
+ });
2009
+ if (!config) {
2010
+ const reason = 'No active Pugi credentials. Run `pugi login --token <PAT>` or set PUGI_API_KEY for CI use.';
2011
+ recordToolResult(session, toolCallId, 'error', reason);
2012
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
2013
+ prompt,
2014
+ requestPath: relative(root, requestPath),
2015
+ verdict: null,
2016
+ reason,
2017
+ response: null,
2018
+ }), { encoding: 'utf8', mode: 0o600 });
2019
+ writeOutput(flags, {
2020
+ status: 'auth_missing',
2021
+ request: relative(root, requestPath),
2022
+ summary: relative(root, summaryPath),
2023
+ }, [
2024
+ 'Pugi triple-provider review request prepared but not sent — no active credentials.',
2025
+ `Request: ${relative(root, requestPath)}`,
2026
+ `Run \`pugi login --token <PAT>\` (or export PUGI_API_KEY for CI) then retry \`pugi review --triple --commit ${resolvedCommit}\`.`,
2027
+ ].join('\n'));
2028
+ process.exitCode = 5;
2029
+ return;
2030
+ }
2031
+ const submitResult = await submitTripleReview(config, requestBody);
2032
+ if (submitResult.status !== 'ok') {
2033
+ const outcome = describeSubmitFailure(submitResult);
2034
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
2035
+ prompt,
2036
+ requestPath: relative(root, requestPath),
2037
+ verdict: null,
2038
+ reason: outcome.message,
2039
+ response: null,
2040
+ }), { encoding: 'utf8', mode: 0o600 });
2041
+ recordToolResult(session, toolCallId, 'error', outcome.message);
2042
+ writeOutput(flags, {
2043
+ status: submitResult.status,
2044
+ code: submitResult.code,
2045
+ message: outcome.message,
2046
+ request: relative(root, requestPath),
2047
+ summary: relative(root, summaryPath),
2048
+ }, [
2049
+ outcome.headline,
2050
+ `Request: ${relative(root, requestPath)}`,
2051
+ `Summary: ${relative(root, summaryPath)}`,
2052
+ outcome.next ? `Next: ${outcome.next}` : '',
2053
+ ]
2054
+ .filter(Boolean)
2055
+ .join('\n'));
2056
+ process.exitCode = outcome.exitCode;
2057
+ return;
2058
+ }
2059
+ const response = submitResult.response;
2060
+ persistTripleReviewResult(resultPath, response);
2061
+ writeFileSync(summaryPath, buildTripleReviewMarkdown({
2062
+ prompt,
2063
+ requestPath: relative(root, requestPath),
2064
+ verdict: response.verdict,
2065
+ reason: response.reason,
2066
+ response,
2067
+ }), { encoding: 'utf8', mode: 0o600 });
2068
+ recordToolResult(session, toolCallId, response.verdict === 'BLOCK' ? 'error' : 'success', `Verdict: ${response.verdict} (${response.reason})`);
2069
+ const verdictReport = renderTripleProviderVerdict({
2070
+ response,
2071
+ commit: resolvedCommit,
2072
+ baseRef,
2073
+ });
2074
+ writeOutput(flags, {
2075
+ status: 'completed',
2076
+ verdict: response.verdict,
2077
+ reason: response.reason,
2078
+ counts: response.counts,
2079
+ reviewerCount: response.reviewerCount,
2080
+ effectiveTier: response.effectiveTier,
2081
+ commit: resolvedCommit,
2082
+ baseRef,
2083
+ reviewers: response.reviewers.map((r) => ({
2084
+ provider: r.provider ?? null,
2085
+ model: r.model,
2086
+ declaredVerdict: r.declaredVerdict,
2087
+ findings: r.findings,
2088
+ latencyMs: r.latencyMs,
2089
+ tokensUsed: r.tokensUsed,
2090
+ error: r.error,
2091
+ })),
2092
+ result: relative(root, resultPath),
2093
+ summary: relative(root, summaryPath),
2094
+ }, verdictReport);
2095
+ if (response.verdict === 'BLOCK') {
2096
+ process.exitCode = 9;
2097
+ }
2098
+ else if (response.verdict === 'WARN') {
2099
+ process.exitCode = 1;
2100
+ }
2101
+ }
2102
+ /**
2103
+ * Pretty-printer for the `pugi review --triple --commit <SHA>` verdict.
2104
+ * Mirrors the `/triple-review` skill's verdict block (per-reviewer
2105
+ * counts table → final GATE line → per-reviewer verbatim → cross-
2106
+ * model disagreement summary → tokens/cost note) so the output is
2107
+ * familiar to operators who already use the dev-only skill.
2108
+ */
2109
+ export function renderTripleProviderVerdict(input) {
2110
+ const { response, commit, baseRef } = input;
2111
+ const divider = '═'.repeat(68);
2112
+ const subDivider = '─'.repeat(68);
2113
+ // Per-reviewer counts table.
2114
+ const reviewerRows = response.reviewers.map((reviewer) => {
2115
+ const c = { P0: 0, P1: 0, P2: 0, P3: 0 };
2116
+ for (const f of reviewer.findings)
2117
+ c[f.severity] += 1;
2118
+ const status = reviewer.error
2119
+ ? 'ERROR'
2120
+ : reviewer.declaredVerdict ?? 'UNKNOWN';
2121
+ const label = reviewer.provider
2122
+ ? reviewer.provider.toUpperCase().padEnd(8)
2123
+ : reviewer.model.slice(0, 8).padEnd(8);
2124
+ return ` ${label} ${pad(c.P0)} ${pad(c.P1)} ${pad(c.P2)} ${pad(c.P3)} ${status}`;
2125
+ });
2126
+ // Cross-model disagreement: list severities flagged by 1 of N but not
2127
+ // the others. Surfaces the "highest-signal moment" per the skill.
2128
+ const disagreements = [];
2129
+ const allFindings = response.reviewers.flatMap((r) => r.findings.map((f) => ({
2130
+ provider: r.provider ?? r.model,
2131
+ severity: f.severity,
2132
+ line: f.line,
2133
+ issue: f.issue,
2134
+ })));
2135
+ const p1Flaggers = new Set(response.reviewers
2136
+ .filter((r) => r.findings.some((f) => f.severity === 'P1'))
2137
+ .map((r) => r.provider ?? r.model));
2138
+ if (p1Flaggers.size === 1) {
2139
+ const sole = [...p1Flaggers][0];
2140
+ disagreements.push(`Only ${sole} flagged a P1 — examine the disagreement, often the highest-signal moment.`);
2141
+ }
2142
+ const p0Flaggers = new Set(response.reviewers
2143
+ .filter((r) => r.findings.some((f) => f.severity === 'P0'))
2144
+ .map((r) => r.provider ?? r.model));
2145
+ if (p0Flaggers.size > 0 && p0Flaggers.size < response.reviewers.length) {
2146
+ disagreements.push(`P0 flagged by ${[...p0Flaggers].join(', ')} but not ${response.reviewers
2147
+ .filter((r) => !p0Flaggers.has(r.provider ?? r.model))
2148
+ .map((r) => r.provider ?? r.model)
2149
+ .join(', ')} — verify the finding before merging.`);
2150
+ }
2151
+ // Tokens / cost summary. Tokens are best-effort (some providers
2152
+ // return null). Cost is a placeholder pending billing wire-up; we
2153
+ // surface the quota note inline so the operator knows it counts as
2154
+ // one slot, not three.
2155
+ const totalTokens = response.reviewers.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
2156
+ // Verbatim reviewer outputs. Each section gets a header so operators
2157
+ // can scroll quickly and copy any individual reviewer's text into
2158
+ // their own notes / triage doc.
2159
+ const reviewerSections = response.reviewers.map((reviewer) => {
2160
+ const label = reviewer.provider
2161
+ ? reviewer.provider.toUpperCase()
2162
+ : reviewer.model;
2163
+ const body = reviewer.error
2164
+ ? `(reviewer errored: ${reviewer.error})`
2165
+ : reviewer.rawContent.trim() || '(empty response)';
2166
+ return [subDivider, `${label} SAYS (${reviewer.model}):`, '', body].join('\n');
2167
+ });
2168
+ return [
2169
+ `PUGI TRIPLE-PROVIDER REVIEW — commit ${commit} vs ${baseRef}`,
2170
+ divider,
2171
+ '',
2172
+ ` P0 P1 P2 P3 Status`,
2173
+ ...reviewerRows,
2174
+ '',
2175
+ `GATE: ${response.verdict}`,
2176
+ `Reason: ${response.reason}`,
2177
+ '',
2178
+ ...reviewerSections,
2179
+ '',
2180
+ subDivider,
2181
+ 'CROSS-MODEL DISAGREEMENT:',
2182
+ disagreements.length === 0
2183
+ ? ' (none — all reviewers agreed within rubric tolerance)'
2184
+ : disagreements.map((d) => ` - ${d}`).join('\n'),
2185
+ '',
2186
+ `Tokens: ~${totalTokens} total across ${response.reviewers.length} reviewers`,
2187
+ 'Quota: charged as 1 review slot (multi-provider counts as a single call).',
2188
+ ].join('\n');
2189
+ }
2190
+ function pad(n) {
2191
+ return String(n).padStart(2, ' ');
2192
+ }
1869
2193
  function describeSubmitFailure(result) {
1870
2194
  switch (result.status) {
1871
2195
  case 'endpoint_missing':
@@ -40,8 +40,23 @@ import { aggregate, exitCodeFor, reviewerVerdictFromRaw, } from '../../core/cons
40
40
  * `--branch <name>` / `--branch=<name>`
41
41
  * `--base <ref>` / `--base=<ref>` (override default origin/main)
42
42
  */
43
- export function parseConsensusArgs(args) {
43
+ export function parseConsensusArgs(args,
44
+ /**
45
+ * 2026-05-27 (Codex r0 P1 on PR #489): cli.ts now parses --commit /
46
+ * --base in the GLOBAL flag pass for the new triple-provider path.
47
+ * Those tokens are consumed BEFORE this function sees `args`, so
48
+ * `pugi review --consensus --commit X` would silently fall back to
49
+ * the default diff and review the wrong changes. Pass the global
50
+ * flags through here so consensus picks them up when present.
51
+ * Inline `--commit`/`--base` tokens in args still win — explicit
52
+ * caller intent is preserved.
53
+ */
54
+ fallback) {
44
55
  const spec = {};
56
+ if (fallback?.commit)
57
+ spec.commit = fallback.commit;
58
+ if (fallback?.base)
59
+ spec.baseRef = fallback.base;
45
60
  for (let i = 0; i < args.length; i += 1) {
46
61
  const arg = args[i] ?? '';
47
62
  const equalsIdx = arg.indexOf('=');
@@ -119,7 +134,7 @@ export async function runReviewConsensus(args, ctx) {
119
134
  // exit 2 — same as BLOCK because the gate could not even run.
120
135
  let captured;
121
136
  try {
122
- const spec = parseConsensusArgs(args);
137
+ const spec = parseConsensusArgs(args, ctx.flagsFallback);
123
138
  captured = captureDiff({ ...spec, cwd: ctx.cwd });
124
139
  }
125
140
  catch (error) {
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
44
44
  * during import). When bumping the CLI version BOTH literals must be
45
45
  * updated; the release smoke-test (`pack:smoke`) verifies they agree.
46
46
  */
47
- export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.13');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.14');
48
48
  /**
49
49
  * Outbound: the CLI's installed semver. Read at request time by
50
50
  * `version-interceptor.ts` and injected on every `fetch` call.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.13",
3
+ "version": "0.1.0-beta.14",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -54,7 +54,7 @@
54
54
  "undici": "^8.3.0",
55
55
  "zod": "^3.23.0",
56
56
  "@pugi/personas": "0.1.2",
57
- "@pugi/sdk": "0.1.0-beta.13"
57
+ "@pugi/sdk": "0.1.0-beta.14"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.0.0",