@cat-factory/executor-harness 1.66.0 → 1.70.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/dist/job.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseValidationChecksSpec, } from './validation-checks.js';
2
2
  import { parseReproductionSpec, } from './reproduction-proof.js';
3
+ import { parseMcpServerSpecs, parseSkillSpecs, } from './agent-capabilities.js';
3
4
  function str(value, path) {
4
5
  if (typeof value !== 'string' || value.length === 0) {
5
6
  throw new Error(`Invalid job: '${path}' must be a non-empty string`);
@@ -95,6 +96,8 @@ function parseHarnessAuth(o) {
95
96
  harness,
96
97
  proxyBaseUrl: str(o.proxyBaseUrl, 'proxyBaseUrl'),
97
98
  sessionToken: str(o.sessionToken, 'sessionToken'),
99
+ // Opt-IN, so a backend that doesn't serve the phase route (or predates it) is the default.
100
+ ...(o.proxyPhasePath === true ? { proxyPhasePath: true } : {}),
98
101
  };
99
102
  }
100
103
  /**
@@ -411,78 +414,6 @@ function parseContextFiles(value) {
411
414
  }
412
415
  return files;
413
416
  }
414
- /**
415
- * Sanitize a skill resource's relative path: keep the subdirectory structure (so
416
- * `templates/report.md` materialises nested) but reject anything that could escape the skill
417
- * directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
418
- * for an unsafe path (the resource is then dropped).
419
- */
420
- function sanitizeSkillRelPath(value) {
421
- if (typeof value !== 'string')
422
- return undefined;
423
- const segments = value.replace(/\\/g, '/').split('/');
424
- const clean = [];
425
- for (const seg of segments) {
426
- if (seg === '' || seg === '.')
427
- continue;
428
- if (seg === '..')
429
- return undefined;
430
- // Same character class as a context-file name, per segment.
431
- const c = seg.replace(/[^A-Za-z0-9._-]/g, '');
432
- if (!c || c === '.' || c === '..' || c.startsWith('.'))
433
- return undefined;
434
- clean.push(c);
435
- }
436
- return clean.length ? clean.join('/') : undefined;
437
- }
438
- /**
439
- * Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
440
- * purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
441
- * default keeps the skill installable rather than dropping it — which, on the claude-code path,
442
- * would leave the prompt pointing at a skill that was never installed (a blind run).
443
- */
444
- const FALLBACK_SKILL_NAME = 'skill';
445
- /** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
446
- function sanitizeSkillName(value) {
447
- if (typeof value !== 'string')
448
- return undefined;
449
- const base = value.replace(/\\/g, '/').split('/').pop() ?? '';
450
- const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '');
451
- if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.'))
452
- return undefined;
453
- return cleaned;
454
- }
455
- /** Validate the optional `skill` field, or undefined when absent/malformed. */
456
- function parseSkillSpec(value) {
457
- if (typeof value !== 'object' || value === null)
458
- return undefined;
459
- const o = value;
460
- const instructions = typeof o.instructions === 'string' ? o.instructions : undefined;
461
- // No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
462
- // folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
463
- // directory, so fall back to a safe default rather than dropping the whole skill.
464
- if (!instructions)
465
- return undefined;
466
- const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME;
467
- const description = typeof o.description === 'string' ? o.description : '';
468
- const resources = [];
469
- if (Array.isArray(o.resources)) {
470
- const used = new Set();
471
- for (const entry of o.resources) {
472
- if (typeof entry !== 'object' || entry === null)
473
- continue;
474
- const e = entry;
475
- const relPath = sanitizeSkillRelPath(e.relPath);
476
- if (!relPath || used.has(relPath))
477
- continue;
478
- if (typeof e.content !== 'string')
479
- continue;
480
- used.add(relPath);
481
- resources.push({ relPath, content: e.content });
482
- }
483
- }
484
- return { name, description, instructions, resources };
485
- }
486
417
  /** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
487
418
  function parseAgentInfraSpec(value) {
488
419
  if (typeof value !== 'object' || value === null)
@@ -669,7 +600,8 @@ export function parseAgentJob(input) {
669
600
  bootstrap: parseAgentBootstrapSpec(o.bootstrap),
670
601
  contextFiles: parseContextFiles(o.contextFiles),
671
602
  packageRegistries: parsePackageRegistries(o.packageRegistries),
672
- skill: parseSkillSpec(o.skill),
603
+ skills: parseSkillSpecs(o.skills),
604
+ mcpServers: parseMcpServerSpecs(o.mcpServers),
673
605
  testSecrets: parseTestSecrets(o.testSecrets),
674
606
  guardLimits: parseGuardLimits(o.guardLimits),
675
607
  validation: parseValidationSpec(o.validation),
@@ -731,7 +663,7 @@ function parseAgentPrSpec(raw) {
731
663
  * literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
732
664
  */
733
665
  function assembleAgentJob(o, mode, agentField, parts) {
734
- const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, validationChecks, reproduction, reviewPrNumber, } = parts;
666
+ const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skills, mcpServers, testSecrets, guardLimits, validation, validationChecks, reproduction, reviewPrNumber, } = parts;
735
667
  const repo = (o.repo ?? {});
736
668
  return {
737
669
  jobId: str(o.jobId, 'jobId'),
@@ -748,7 +680,8 @@ function assembleAgentJob(o, mode, agentField, parts) {
748
680
  ...(output ? { output } : {}),
749
681
  ...(contextFiles.length ? { contextFiles } : {}),
750
682
  ...(packageRegistries.length ? { packageRegistries } : {}),
751
- ...(skill ? { skill } : {}),
683
+ ...(skills ? { skills } : {}),
684
+ ...(mcpServers ? { mcpServers } : {}),
752
685
  ...(testSecrets.length ? { testSecrets } : {}),
753
686
  ...(infra ? { infra } : {}),
754
687
  ...(pr ? { pr } : {}),
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { readEffortReport } from './effort.js';
5
5
  import { log } from './logger.js';
6
- import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
6
+ import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, phasedProxyBaseUrl, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
7
7
  import { mergeGuardLimits, progressGuardLimitsFromEnv, } from './progress-guard.js';
8
8
  import { runSubscriptionHarness } from './agent-runner.js';
9
9
  // The thin base every container agent shares: an ephemeral working directory, and
@@ -142,14 +142,14 @@ export async function runAgentInWorkspace(spec, opts = {}) {
142
142
  // harness paths; kept out of the agent's commits via a local git exclude entry.
143
143
  const contextFiles = spec.contextFiles ?? [];
144
144
  await materializeContextFiles(spec.dir, contextFiles);
145
- // Repo-sourced skill (slice 2): claude-code installs it natively into its ISOLATED config dir,
146
- // so it reads from there. Everything else reads the checkout, so materialise the skill's
147
- // resources under `.cat-context/skill/` (its instructions are folded into the prompt by the
148
- // backend) — Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install
149
- // into (the runner refuses to write a repo's skill into the developer's own `~/.claude`; see
150
- // `runClaudeCode`). A resource-free skill is a no-op here.
151
- if (spec.skill && !installsSkillNatively(spec)) {
152
- await materializeSkillResources(spec.dir, spec.skill);
145
+ // Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
146
+ // there. Everything else reads the checkout, so materialise each skill's resources under
147
+ // `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
148
+ // Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install into (the
149
+ // runner refuses to write a skill into the developer's own `~/.claude`; see `runClaudeCode`).
150
+ // Resource-free skills are a no-op here.
151
+ if (spec.skills?.length && !installsSkillNatively(spec)) {
152
+ await materializeSkillResources(spec.dir, spec.skills);
153
153
  }
154
154
  // Subscription harnesses (Claude Code / Codex) authenticate with the leased
155
155
  // token and talk direct to the vendor — no proxy config, no AGENTS.md. The
@@ -169,7 +169,8 @@ export async function runAgentInWorkspace(spec, opts = {}) {
169
169
  ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
170
170
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
171
171
  ...(spec.ambientAuth ? { ambientAuth: true } : {}),
172
- ...(spec.skill ? { skill: spec.skill } : {}),
172
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
173
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
173
174
  ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
174
175
  signal: opts.signal,
175
176
  // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
@@ -220,7 +221,15 @@ export async function runAgentInWorkspace(spec, opts = {}) {
220
221
  hasBlueprints,
221
222
  ...(spec.multiRepo ? { multiRepo: true } : {}),
222
223
  });
223
- await writePiModelsConfig({ model: spec.model, proxyBaseUrl });
224
+ // Pi's calls are metered server-side by the LLM proxy, which sees only an HTTP request — so
225
+ // the phase this pass runs under is carried on the URL it is pointed at. Resolved per pass
226
+ // (this whole function re-runs for every repair round), which is what makes a repair round's
227
+ // spend distinguishable from the first pass's. Only when the BACKEND said it serves that
228
+ // route, since a runner pool or `LOCAL_HARNESS_IMAGE` can pair this image with an older one.
229
+ await writePiModelsConfig({
230
+ model: spec.model,
231
+ proxyBaseUrl: phasedProxyBaseUrl(proxyBaseUrl, opts.currentPhase?.(), spec.proxyPhasePath),
232
+ });
224
233
  const { signal, onActivity, onProgress, onSpan } = opts;
225
234
  const piOutcome = await runPi({
226
235
  cwd: spec.dir,
@@ -240,12 +249,12 @@ export async function runAgentInWorkspace(spec, opts = {}) {
240
249
  return withEffortReport(spec.dir, piOutcome);
241
250
  }
242
251
  /**
243
- * Whether the claude-code runner will install this run's repo-sourced skill natively (into the
244
- * CLI's config dir) rather than the caller materialising it into the checkout. True ONLY for a
252
+ * Whether the claude-code runner will install this run's skills natively (into the CLI's config
253
+ * dir) rather than the caller materialising them into the checkout. True ONLY for a
245
254
  * leased-credential claude-code run, which gets a throwaway per-run config home. An AMBIENT run
246
- * uses the developer's own `~/.claude`, which the runner will not write a repo's skill into —
247
- * it would outlive the run in their personal setup, and two concurrent jobs carrying same-named
248
- * skills from different repos would overwrite each other's.
255
+ * uses the developer's own `~/.claude`, which the runner will not write a skill into — it would
256
+ * outlive the run in their personal setup, and two concurrent jobs carrying same-named skills
257
+ * would overwrite each other's.
249
258
  */
250
259
  export function installsSkillNatively(spec) {
251
260
  return spec.harness === 'claude-code' && !spec.ambientAuth;
package/dist/pi.js CHANGED
@@ -24,6 +24,63 @@ import { ProgressGuard, progressGuardLimitsFromEnv, toolCallSignal, } from './pr
24
24
  * {@link runDiagnostics} flagging the rare case where even 32k is not enough.
25
25
  */
26
26
  export const PI_MAX_OUTPUT_TOKENS = 32_768;
27
+ /**
28
+ * Longest phase label the backend keeps. Mirrors kernel's `MAX_PHASE_CHARS`; see
29
+ * {@link normalizeProxyPhase} for why this is a copy rather than an import.
30
+ */
31
+ const MAX_PHASE_CHARS = 32;
32
+ /**
33
+ * Normalise a phase label to what the backend will actually store: trimmed, lowercased,
34
+ * `[a-z0-9-]` only, bounded. `''` when the label is not a phase at all.
35
+ *
36
+ * A deliberate COPY of kernel's `normalizeCallPhase` — the container image is built from `src/`
37
+ * plus typescript alone, so the harness can carry no runtime dependency on a workspace package
38
+ * (the same constraint that forced `src/host-markdown.ts`). A copy that can drift is worse than
39
+ * no copy: if the harness rejected a label the backend would have accepted, the call would take
40
+ * the plain path and land unattributed, and if it accepted one the backend rejects it would
41
+ * spend a request on a segment destined for `''`. `test/llm-phase.conformity.test.ts` pins the
42
+ * two to identical verdicts over a corpus, so the alphabet can only be changed in both.
43
+ */
44
+ export function normalizeProxyPhase(phase) {
45
+ if (typeof phase !== 'string')
46
+ return '';
47
+ const trimmed = phase.trim().toLowerCase();
48
+ if (!trimmed || trimmed.length > MAX_PHASE_CHARS)
49
+ return '';
50
+ return /^[a-z0-9-]+$/.test(trimmed) ? trimmed : '';
51
+ }
52
+ /**
53
+ * Point Pi's provider at the phase-tagged completions path for the pass about to run, so the
54
+ * backend can stamp WHICH slice of the run spent each call (the agent's own loop vs a pre-PR
55
+ * validation repair round vs a reproduction-proof repair round) — see
56
+ * `docs/initiatives/token-burn-instrumentation.md`. The harness drives those loops, so it is
57
+ * the only component that knows; reconstructing the boundary downstream from wall-clock
58
+ * timestamps is exactly the brittle inference this avoids.
59
+ *
60
+ * A URL segment because the harness does not make these requests: Pi does, from a config whose
61
+ * only per-run knobs are the base URL and the token — there is no per-request header to set.
62
+ *
63
+ * `supported` is the BACKEND's declaration that it serves the phase-tagged route, carried on the
64
+ * job body exactly as `webSearch` carries "point the search tool at my `/web-search`". Without it
65
+ * this function would encode a routing shape the receiving backend may not have: a runner pool
66
+ * pins its OWN harness image (`RunnerPoolManifest`), and `LOCAL_HARNESS_IMAGE` overrides the
67
+ * recommended pin outright, so "the image and the backend are a matched set" holds for the
68
+ * Cloudflare deployment and nowhere else. An image ahead of its backend would 404 EVERY model
69
+ * call — a dead run, not degraded telemetry. Absent/false ⇒ the plain path, and the calls land
70
+ * in the backend's unattributed slice.
71
+ *
72
+ * Pure so the join is unit-testable without spawning anything.
73
+ */
74
+ export function phasedProxyBaseUrl(proxyBaseUrl, phase, supported) {
75
+ if (!supported)
76
+ return proxyBaseUrl;
77
+ // A label the backend would discard would be sent only to be thrown away, so send the plain
78
+ // path instead — the call is then honestly unattributed rather than attributed to nothing.
79
+ const normalized = normalizeProxyPhase(phase);
80
+ if (!normalized)
81
+ return proxyBaseUrl;
82
+ return `${proxyBaseUrl.replace(/\/+$/, '')}/phase/${normalized}`;
83
+ }
27
84
  /** Write the Pi provider config that routes all model calls through the proxy. */
28
85
  export async function writePiModelsConfig(opts) {
29
86
  const dir = join(homedir(), '.pi', 'agent');
@@ -203,26 +260,34 @@ export async function materializeContextFiles(cwd, files) {
203
260
  // No writable .git/info; the files simply stay untracked (still not auto-added on most flows).
204
261
  }
205
262
  }
206
- /** Subdirectory of {@link CONTEXT_DIR} where a repo-sourced skill's resources are materialised. */
263
+ /** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
207
264
  export const SKILL_CONTEXT_SUBDIR = 'skill';
208
265
  /**
209
- * Materialise a repo-sourced skill's RESOURCE files under `.cat-context/skill/` in the checkout
210
- * (repo-sourced Claude Skills, slice 2) — the path for every run that does NOT get a native
211
- * install: Pi, codex, and ambient claude-code (no isolated `CLAUDE_CONFIG_DIR` to install into).
212
- * Their agents read the checkout, and the skill's instructions are folded into their prompt by the
213
- * backend (`renderSkillForHarness`, which keys off ambient auth as well as the harness). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
214
- * dirs are created as needed. Kept out of the agent's commits via the same `.cat-context/` git
215
- * exclude entry. A skill with no resource bodies is a no-op.
266
+ * Materialise the run's skills' RESOURCE files under `.cat-context/skill/<name>/` in the checkout
267
+ * — the path for every run that does NOT get a native install: Pi, codex, and ambient claude-code
268
+ * (no isolated `CLAUDE_CONFIG_DIR` to install into). Their agents read the checkout, and the
269
+ * skills' instructions are folded into their prompt by the backend (`renderSkillsForHarness`,
270
+ * which keys off ambient auth as well as the harness).
271
+ *
272
+ * Each skill gets its OWN subdirectory: several skills can apply to one run (a step's pick plus
273
+ * the kind's declared playbooks), and a flat directory would let two skills' `templates/report.md`
274
+ * overwrite each other — silently handing the agent the wrong template. The names were sanitized
275
+ * to a single safe path segment at the job boundary, as were the resource sub-paths (no
276
+ * traversal), so nested dirs are created as needed. Kept out of the agent's commits via the same
277
+ * `.cat-context/` git exclude entry. Skills with no resource bodies are a no-op.
216
278
  */
217
- export async function materializeSkillResources(cwd, skill) {
218
- if (!skill.resources.length)
279
+ export async function materializeSkillResources(cwd, skills) {
280
+ const withResources = skills.filter((s) => s.resources.length);
281
+ if (!withResources.length)
219
282
  return;
220
- const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR);
221
- await mkdir(dir, { recursive: true });
222
- for (const r of skill.resources) {
223
- const dest = join(dir, r.relPath);
224
- await mkdir(dirname(dest), { recursive: true });
225
- await writeFile(dest, r.content, 'utf8');
283
+ for (const skill of withResources) {
284
+ const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR, skill.name);
285
+ await mkdir(dir, { recursive: true });
286
+ for (const r of skill.resources) {
287
+ const dest = join(dir, r.relPath);
288
+ await mkdir(dirname(dest), { recursive: true });
289
+ await writeFile(dest, r.content, 'utf8');
290
+ }
226
291
  }
227
292
  const gitRoot = await findGitRoot(cwd);
228
293
  if (!gitRoot)
package/dist/runner.js CHANGED
@@ -224,9 +224,17 @@ export class JobRegistry {
224
224
  // instance for its terminal result, so both channels carry the same `seq` and the
225
225
  // backend mints one stable row id per call.
226
226
  call.seq = entry.callMetricSeq++;
227
+ // …and the phase the job is in RIGHT NOW, which is what spent the call: the handlers
228
+ // mark `validation-repair` / `reproduction-repair` around each repair pass, so a
229
+ // looped run's telemetry says which loop the tokens went to instead of filing every
230
+ // turn under one undifferentiated "agent"
231
+ // (`docs/initiatives/token-burn-instrumentation.md`). Stamped at EMIT time, not at
232
+ // drain time: a poll can land long after the phase moved on.
233
+ call.phase = phase;
227
234
  entry.callMetricBuffer.push(call);
228
235
  },
229
236
  onPhase: (next) => markPhase(next),
237
+ currentPhase: () => phase,
230
238
  log: jobLog,
231
239
  });
232
240
  markPhase('done');
@@ -1,6 +1,6 @@
1
1
  import { redact, redactSecrets, secretsToRedact } from './redact.js';
2
2
  import { log } from './logger.js';
3
- import { PI_MAX_OUTPUT_TOKENS } from './pi.js';
3
+ import { PI_MAX_OUTPUT_TOKENS, phasedProxyBaseUrl } from './pi.js';
4
4
  // A reusable abstraction for the "agent returns a structured JSON document as its
5
5
  // final assistant message" pattern (requirements, blueprint, merger — and any future
6
6
  // kind). An agent of this kind emits its result as text, not a tool call, and the
@@ -31,6 +31,12 @@ const REPAIR_SYSTEM = 'You repair malformed JSON. You are given text that was me
31
31
  'JSON object but does not parse. Return ONLY the corrected JSON object: no prose, ' +
32
32
  'no markdown code fences, no commentary, and never repeat or duplicate any tokens. ' +
33
33
  'Preserve the original content faithfully; only fix the JSON structure.';
34
+ /**
35
+ * The run phase a structured-output repair call is billed to. A constant, not a `currentPhase`
36
+ * read: this call is made by the harness itself (the agent has already finished and left text
37
+ * that won't parse), so it belongs to no pass the registry marks.
38
+ */
39
+ const STRUCTURED_REPAIR_PHASE = 'structured-repair';
34
40
  /**
35
41
  * Largest immediately-repeated run length we look for. The corruption duplicates
36
42
  * whole model tokens, which carry whitespace/punctuation context and run to ~10-15
@@ -195,7 +201,12 @@ async function callRepair(badText, spec, access) {
195
201
  if (!access.proxyBaseUrl || !access.sessionToken) {
196
202
  throw new Error('structured-output repair requires the LLM proxy (Pi harness)');
197
203
  }
198
- const url = `${access.proxyBaseUrl.replace(/\/+$/, '')}/chat/completions`;
204
+ // A repair round is its own slice of the run's burn, not part of the agent's loop that
205
+ // produced the unparseable text — and unlike the phases the registry marks, this call is made
206
+ // by the HARNESS itself, so its phase is a constant rather than a read of `currentPhase`
207
+ // (docs/initiatives/token-burn-instrumentation.md).
208
+ const repairBaseUrl = phasedProxyBaseUrl(access.proxyBaseUrl, STRUCTURED_REPAIR_PHASE, access.proxyPhasePath);
209
+ const url = `${repairBaseUrl.replace(/\/+$/, '')}/chat/completions`;
199
210
  const messages = [
200
211
  { role: 'system', content: REPAIR_SYSTEM },
201
212
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.66.0",
3
+ "version": "1.70.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,9 +26,9 @@
26
26
  "hono": "^4.12.32",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/kernel": "0.175.0",
30
- "@cat-factory/server": "0.165.0",
31
- "@cat-factory/spend": "0.12.104"
29
+ "@cat-factory/kernel": "0.179.0",
30
+ "@cat-factory/server": "0.167.0",
31
+ "@cat-factory/spend": "0.12.108"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",