From b565e44a220363925a05a77eaec15d820d3a2313 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Sat, 16 May 2026 12:06:34 +0200 Subject: [PATCH] feat: add claude-code llm backend with runtime port (#115) * docs: revise claude-code ingest backend spec * docs: keep claude-code spec focused on ingest * docs: expand claude-code spec to full llm parity * Refine claude-code backend spec after adversarial review iteration 1 * Refine claude-code backend spec after adversarial review iteration 2 * Refine claude-code backend spec after adversarial review iteration 3 * feat: recognize claude-code llm backend * feat: add ktx llm runtime port * feat: add claude-code llm runtime * feat: route non-agent llm calls through runtime * feat: run ingest agents through llm runtime * feat: support claude-code setup and status * test: verify claude-code backend runtime * docs: add claude-code backend v1 runtime plan * fix: close claude-code runtime isolation checks * fix: warn on claude-code prompt caching during setup * chore: verify claude-code v1 closure * docs: add claude-code backend v1 isolation closure plan * fix: update claude-code ingest setup guidance * docs: add claude-code backend v1 ingest guidance closure plan * docs: align claude-code isolation spec with sdk metadata * test: cover claude-code host discovery metadata * fix: tolerate claude-code host discovery metadata * docs: clarify claude-code host discovery metadata * docs: add claude-code auth-probe isolation fix plan * chore: prepare kaelio ktx rc1 release * chore: add semantic release workflow * fix: unblock ci checks * chore(release): 0.1.0-rc.1 * feat: add Claude Code model selection to setup * fix: keep git maintenance attached in local repos --- .github/workflows/release.yml | 45 +- .releaserc.cjs | 3 + README.md | 2 +- .../content/docs/cli-reference/ktx-setup.mdx | 16 +- .../content/docs/cli-reference/ktx-status.mdx | 4 + .../docs/getting-started/quickstart.mdx | 24 +- .../content/docs/guides/building-context.mdx | 5 + .../content/docs/guides/llm-configuration.mdx | 61 + docs-site/content/docs/guides/meta.json | 2 +- docs-site/next.config.mjs | 6 + docs/release.md | 99 + ...15-claude-code-auth-probe-isolation-fix.md | 678 +++++ ...code-backend-v1-ingest-guidance-closure.md | 160 ++ ...laude-code-backend-v1-isolation-closure.md | 575 ++++ ...26-05-15-claude-code-backend-v1-runtime.md | 2483 +++++++++++++++++ .../2026-05-15-claude-code-backend-design.md | 698 +++++ knip.json | 8 +- package.json | 14 +- .../cli/src/claude-code-prompt-caching.ts | 29 + packages/cli/src/commands/setup-commands.ts | 19 +- packages/cli/src/doctor.test.ts | 38 + packages/cli/src/index.test.ts | 35 + packages/cli/src/ingest.test-utils.ts | 62 +- packages/cli/src/ingest.test.ts | 6 +- packages/cli/src/ingest.ts | 10 +- packages/cli/src/setup-models.test.ts | 105 +- packages/cli/src/setup-models.ts | 143 +- packages/cli/src/setup.ts | 2 + packages/cli/src/status-project.ts | 57 +- packages/context/package.json | 1 + .../src/agent/agent-runner.service.test.ts | 9 +- .../context/src/agent/agent-runner.service.ts | 111 +- packages/context/src/core/git.service.test.ts | 9 +- packages/context/src/core/git.service.ts | 6 + .../local-ingest-acceptance.test.ts | 105 +- .../curator-pagination.service.ts | 7 +- .../src/ingest/ingest-bundle.runner.test.ts | 14 +- .../src/ingest/ingest-bundle.runner.ts | 120 +- .../src/ingest/local-bundle-ingest.test.ts | 177 +- .../src/ingest/local-bundle-runtime.test.ts | 45 +- .../src/ingest/local-bundle-runtime.ts | 77 +- packages/context/src/ingest/local-ingest.ts | 17 +- .../src/ingest/local-metabase-ingest.test.ts | 10 +- .../page-triage/page-triage.service.test.ts | 73 +- .../ingest/page-triage/page-triage.service.ts | 24 +- packages/context/src/ingest/ports.ts | 10 +- .../stages/build-reconcile-context.test.ts | 23 +- .../ingest/stages/build-reconcile-context.ts | 24 +- .../ingest/stages/build-wu-context.test.ts | 17 +- .../src/ingest/stages/build-wu-context.ts | 27 +- .../src/ingest/stages/stage-3-work-units.ts | 7 +- .../ingest/stages/stage-4-reconciliation.ts | 7 +- .../src/ingest/tools/tool-call-logger.ts | 13 +- .../ingest/tools/verification-ledger.tool.ts | 20 +- packages/context/src/llm/ai-sdk-runtime.ts | 164 ++ .../context/src/llm/claude-code-env.test.ts | 19 + packages/context/src/llm/claude-code-env.ts | 23 + .../src/llm/claude-code-models.test.ts | 17 + .../context/src/llm/claude-code-models.ts | 19 + .../src/llm/claude-code-runtime.test.ts | 464 +++ .../context/src/llm/claude-code-runtime.ts | 327 +++ packages/context/src/llm/generation.ts | 87 +- packages/context/src/llm/index.ts | 27 + packages/context/src/llm/local-config.ts | 34 +- .../src/llm/runtime-local-config.test.ts | 25 + packages/context/src/llm/runtime-port.ts | 75 + .../context/src/llm/runtime-tools.test.ts | 43 + packages/context/src/llm/runtime-tools.ts | 91 + packages/context/src/memory/local-memory.ts | 32 +- .../memory-agent.service.ingest.test.ts | 42 +- .../src/memory/memory-agent.service.ts | 21 +- packages/context/src/memory/types.ts | 7 +- packages/context/src/project/config.test.ts | 27 +- packages/context/src/project/config.ts | 6 +- .../src/scan/description-generation.test.ts | 82 +- .../src/scan/description-generation.ts | 12 +- packages/context/src/scan/index.ts | 1 - .../context/src/scan/local-enrichment.test.ts | 20 +- packages/context/src/scan/local-enrichment.ts | 49 +- packages/context/src/scan/local-scan.test.ts | 48 +- packages/context/src/scan/local-scan.ts | 16 +- .../src/scan/relationship-benchmarks.ts | 6 +- .../src/scan/relationship-discovery.test.ts | 59 +- .../src/scan/relationship-discovery.ts | 13 +- .../scan/relationship-llm-proposal.test.ts | 130 +- .../src/scan/relationship-llm-proposal.ts | 21 +- packages/context/src/tools/base-tool.ts | 19 + packages/llm/src/model-health.test.ts | 13 + packages/llm/src/model-provider.test.ts | 10 + packages/llm/src/model-provider.ts | 20 +- packages/llm/src/types.ts | 2 +- pnpm-lock.yaml | 2245 +++++++++++++++ release-policy.json | 11 +- scripts/build-public-npm-package.mjs | 9 +- scripts/build-public-npm-package.test.mjs | 10 +- scripts/check-boundaries.mjs | 2 +- scripts/check-boundaries.test.mjs | 1 + .../local-embeddings-runtime-smoke.test.mjs | 8 +- scripts/package-artifacts.mjs | 25 +- scripts/package-artifacts.test.mjs | 60 +- scripts/public-npm-release-metadata.mjs | 53 + scripts/publish-public-npm-package.test.mjs | 12 +- scripts/release-readiness.mjs | 31 +- scripts/release-readiness.test.mjs | 1 + scripts/release-workflow.test.mjs | 16 +- scripts/semantic-release-config.cjs | 176 ++ scripts/semantic-release-config.test.mjs | 53 + scripts/update-public-release-version.mjs | 78 + .../update-public-release-version.test.mjs | 107 + 109 files changed, 10218 insertions(+), 1093 deletions(-) create mode 100644 .releaserc.cjs create mode 100644 docs-site/content/docs/guides/llm-configuration.mdx create mode 100644 docs/release.md create mode 100644 docs/superpowers/plans/2026-05-15-claude-code-auth-probe-isolation-fix.md create mode 100644 docs/superpowers/plans/2026-05-15-claude-code-backend-v1-ingest-guidance-closure.md create mode 100644 docs/superpowers/plans/2026-05-15-claude-code-backend-v1-isolation-closure.md create mode 100644 docs/superpowers/plans/2026-05-15-claude-code-backend-v1-runtime.md create mode 100644 docs/superpowers/specs/2026-05-15-claude-code-backend-design.md create mode 100644 packages/cli/src/claude-code-prompt-caching.ts create mode 100644 packages/context/src/llm/ai-sdk-runtime.ts create mode 100644 packages/context/src/llm/claude-code-env.test.ts create mode 100644 packages/context/src/llm/claude-code-env.ts create mode 100644 packages/context/src/llm/claude-code-models.test.ts create mode 100644 packages/context/src/llm/claude-code-models.ts create mode 100644 packages/context/src/llm/claude-code-runtime.test.ts create mode 100644 packages/context/src/llm/claude-code-runtime.ts create mode 100644 packages/context/src/llm/runtime-local-config.test.ts create mode 100644 packages/context/src/llm/runtime-port.ts create mode 100644 packages/context/src/llm/runtime-tools.test.ts create mode 100644 packages/context/src/llm/runtime-tools.ts create mode 100644 scripts/public-npm-release-metadata.mjs create mode 100644 scripts/semantic-release-config.cjs create mode 100644 scripts/semantic-release-config.test.mjs create mode 100644 scripts/update-public-release-version.mjs create mode 100644 scripts/update-public-release-version.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36eaf49c..6732cf13 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,14 +3,27 @@ name: KTX Release on: workflow_dispatch: inputs: + release_kind: + description: "Release kind: rc publishes to next, stable publishes to latest" + required: true + type: choice + default: "rc" + options: + - rc + - stable + force_release: + description: "Force a patch release even if semantic-release finds no releasable commits" + required: false + type: boolean + default: false publish_live: - description: "Publish @kaelio/ktx to npm instead of running a dry-run" + description: "Create the release and publish @kaelio/ktx to npm instead of running a dry-run" required: true type: boolean default: false permissions: - contents: read + contents: write concurrency: group: ktx-release-${{ github.ref }} @@ -22,6 +35,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 @@ -34,6 +49,7 @@ jobs: node-version: "24" cache: "pnpm" cache-dependency-path: "pnpm-lock.yaml" + registry-url: "https://registry.npmjs.org" - name: Install TypeScript dependencies run: pnpm install --frozen-lockfile @@ -52,18 +68,19 @@ jobs: - name: Install Python dependencies run: uv sync --all-packages - - name: Build and verify artifacts - run: pnpm run artifacts:check - - - name: Check release readiness - run: pnpm run release:readiness - - - name: Dry-run npm publish + - name: Dry-run semantic release if: ${{ !inputs.publish_live }} - run: pnpm run release:npm-publish - - - name: Publish npm package - if: ${{ inputs.publish_live }} - run: pnpm run release:npm-publish -- --publish + run: pnpm run semantic-release:dry-run env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + KTX_RELEASE_KIND: ${{ inputs.release_kind }} + FORCE_RELEASE: ${{ inputs.force_release }} + + - name: Create semantic release + if: ${{ inputs.publish_live }} + run: pnpm run semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + KTX_RELEASE_KIND: ${{ inputs.release_kind }} + FORCE_RELEASE: ${{ inputs.force_release }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.releaserc.cjs b/.releaserc.cjs new file mode 100644 index 00000000..6a563d7d --- /dev/null +++ b/.releaserc.cjs @@ -0,0 +1,3 @@ +const { createReleaseConfig } = require('./scripts/semantic-release-config.cjs'); + +module.exports = createReleaseConfig(process.env); diff --git a/README.md b/README.md index 086499d0..d478e945 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ SQLite. ## Quick Start ```bash -npm install -g @kaelio/ktx +pnpm add --global @kaelio/ktx ktx setup ktx status ``` diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index 90d0b175..b4f50ea9 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -51,17 +51,21 @@ scripted project creation. They are not shown in `ktx setup --help`. | Flag | Description | |------|-------------| -| `--llm-backend ` | LLM backend: `anthropic` or `vertex` | +| `--llm-backend ` | LLM backend: `anthropic`, `vertex`, or `claude-code` | +| `--llm-backend claude-code` | Use the local Claude Code session for KTX LLM calls | +| `--llm-model ` | LLM model ID or backend model alias to validate and save | | `--anthropic-api-key-env ` | Environment variable containing the Anthropic API key | | `--anthropic-api-key-file ` | File containing the Anthropic API key | -| `--anthropic-model ` | Anthropic model ID to validate and save | +| `--anthropic-model ` | Legacy alias for `--llm-model` | | `--vertex-project ` | Vertex AI project ID, `env:NAME`, or `file:/path` reference | | `--vertex-location ` | Vertex AI location, `env:NAME`, or `file:/path` reference | | `--skip-llm` | Leave LLM setup incomplete | Choose only one Anthropic credential source. Anthropic credential flags are only valid with the Anthropic backend; Vertex flags are only valid with the Vertex -backend. +backend. The `claude-code` backend uses local Claude Code authentication instead +of Anthropic API key or Vertex flags. For Claude Code, `--llm-model` accepts +`sonnet`, `opus`, `haiku`, or a full Claude model ID. ### Embeddings @@ -142,6 +146,12 @@ ktx setup # Run setup for a specific project directory ktx setup --project-dir ./analytics +# Use Claude Code with Opus for KTX LLM calls +ktx setup \ + --project-dir ./analytics \ + --llm-backend claude-code \ + --llm-model opus + # Script a Postgres connection that reads its URL from the environment ktx setup \ --project-dir ./analytics \ diff --git a/docs-site/content/docs/cli-reference/ktx-status.mdx b/docs-site/content/docs/cli-reference/ktx-status.mdx index dae22857..c6a1b715 100644 --- a/docs-site/content/docs/cli-reference/ktx-status.mdx +++ b/docs-site/content/docs/cli-reference/ktx-status.mdx @@ -47,6 +47,10 @@ ktx status --project-dir ./analytics `ktx status` prints grouped doctor checks. Agents should use `ktx status --json --no-input` when they need to branch on readiness state. +For `llm.provider.backend: claude-code`, `ktx status` checks that the local +Claude Code session is usable. If auth fails, run the Claude Code CLI login +flow, then rerun `ktx status`. + ```json { "title": "KTX project doctor", diff --git a/docs-site/content/docs/getting-started/quickstart.mdx b/docs-site/content/docs/getting-started/quickstart.mdx index 84bf4611..09957e70 100644 --- a/docs-site/content/docs/getting-started/quickstart.mdx +++ b/docs-site/content/docs/getting-started/quickstart.mdx @@ -59,12 +59,13 @@ setup progress under `.ktx/setup/` and resumes from the remaining work. KTX uses a Claude model for ingest agents that turn schemas, SQL, BI metadata, and documents into semantic-layer sources and wiki context. -Setup supports two LLM provider paths: +Setup supports three LLM provider paths: | Provider | Use when | Credential model | |----------|----------|------------------| | Anthropic API | You have an Anthropic API key | `ANTHROPIC_API_KEY` or a local `file:` secret | | Google Vertex AI for Anthropic Claude | Your organization runs Claude through Google Cloud | Application Default Credentials plus Vertex project and location | +| Claude Code | You want KTX to use your local Claude Code session | Claude Code local authentication | For Anthropic API, setup can read the key from the environment or save a pasted key to `.ktx/secrets/anthropic-api-key`. `ktx.yaml` stores an `env:` or `file:` @@ -74,6 +75,27 @@ For Vertex AI, setup uses Google Application Default Credentials. It can read your active `gcloud` project, list visible projects, or accept explicit `--vertex-project` and `--vertex-location` values. +To use your local Claude Code session instead of an API key, set: + +```yaml +llm: + provider: + backend: claude-code + models: + default: sonnet + triage: haiku + candidateExtraction: sonnet + curator: sonnet + reconcile: sonnet + repair: sonnet +``` + +`claude-code` uses the Claude Code authentication already configured on your +machine. It doesn't use `ANTHROPIC_API_KEY`, Vertex credentials, AI Gateway +tokens, or Bedrock credentials. In non-interactive setup, pass +`--llm-model opus`, `--llm-model sonnet`, `--llm-model haiku`, or a full Claude +model ID to select the Claude Code model. + Setup checks the selected model before saving. Anthropic API setup fetches live Claude model choices when possible and falls back to bundled defaults if model discovery is unavailable. diff --git a/docs-site/content/docs/guides/building-context.mdx b/docs-site/content/docs/guides/building-context.mdx index 5fd288a6..584e9003 100644 --- a/docs-site/content/docs/guides/building-context.mdx +++ b/docs-site/content/docs/guides/building-context.mdx @@ -58,6 +58,11 @@ ktx ingest --all --deep Deep ingest needs LLM and embedding readiness. If those providers are not configured, run `ktx setup` or use `--fast`. +When you use `claude-code`, KTX still controls the tool surface for ingest and +memory capture. Claude Code built-in tools, discovered MCP servers, plugins, +skills, agents, and slash commands are not invokable by KTX agent loops unless +they are exact KTX MCP tools for the current run. + ## Query history PostgreSQL, BigQuery, and Snowflake can add query-history context. This helps diff --git a/docs-site/content/docs/guides/llm-configuration.mdx b/docs-site/content/docs/guides/llm-configuration.mdx new file mode 100644 index 00000000..054d0b58 --- /dev/null +++ b/docs-site/content/docs/guides/llm-configuration.mdx @@ -0,0 +1,61 @@ +--- +title: LLM configuration +description: Configure KTX LLM providers, model roles, and prompt caching. +--- + +KTX uses the top-level `llm` block in `ktx.yaml` for text generation, +structured extraction, and ingest or memory agent loops. + +## Backends + +Set `llm.provider.backend` to one of these values: + +- `anthropic`: Use the Anthropic API through `ANTHROPIC_API_KEY` or the + configured `api_key` reference. +- `vertex`: Use Vertex AI Anthropic models through Google Cloud credentials. +- `gateway`: Use AI Gateway-compatible Anthropic model ids. +- `claude-code`: Use your local Claude Code session through the Claude Agent + SDK. KTX removes provider-routing environment variables from Claude Code + child processes, so this backend doesn't silently fall back to + `ANTHROPIC_API_KEY`, Vertex, Gateway, or Bedrock credentials. + +## Claude Code + +Use aliases or full Claude model IDs in `llm.models`: + +```yaml +llm: + provider: + backend: claude-code + models: + default: sonnet + triage: haiku + candidateExtraction: sonnet + curator: sonnet + reconcile: sonnet + repair: sonnet +``` + +During setup, choose the Claude Code backend interactively or pass the model in +automation: + +```bash +ktx setup --llm-backend claude-code --llm-model opus --no-input +``` + +For Claude Code, `sonnet`, `opus`, and `haiku` map to the current KTX defaults. +You can also pass a full Claude model ID, such as `claude-opus-4-7`. + +`claude-code` keeps KTX tool boundaries intact. KTX exposes only the MCP tools +needed for the current KTX agent loop, disables Claude Code built-in tools, +keeps plugins empty, and denies every non-KTX tool request through +`canUseTool`. The Claude Agent SDK may still report host-discovered slash +commands, skills, and subagent names in init metadata; that metadata is not an +execution grant for KTX agent loops. + +## Prompt caching + +`llm.promptCaching` has partial parity on `claude-code`. KTX doesn't pass +Anthropic cache-control markers to the Claude Agent SDK. Status and doctor warn +when you configure prompt-cache TTL, tool, or history fields that the Claude +Agent SDK backend ignores. diff --git a/docs-site/content/docs/guides/meta.json b/docs-site/content/docs/guides/meta.json index 40b44438..2e9703ec 100644 --- a/docs-site/content/docs/guides/meta.json +++ b/docs-site/content/docs/guides/meta.json @@ -1,5 +1,5 @@ { "title": "Guides", "defaultOpen": true, - "pages": ["building-context", "writing-context", "serving-agents"] + "pages": ["building-context", "llm-configuration", "writing-context", "serving-agents"] } diff --git a/docs-site/next.config.mjs b/docs-site/next.config.mjs index a348acd0..3beb3073 100644 --- a/docs-site/next.config.mjs +++ b/docs-site/next.config.mjs @@ -15,6 +15,12 @@ const config = { }, async redirects() { return [ + { + source: "/docs", + destination: "/docs/getting-started/introduction", + permanent: false, + basePath: false, + }, { source: "/:path*", has: [{ type: "host", value: "docs.ktx.sh" }], diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 00000000..670a6a70 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,99 @@ +# KTX release runbook + +This runbook covers the maintainer workflow for publishing `@kaelio/ktx` to +npm through GitHub Actions. The workflow uses semantic-release to choose the +next version, update release metadata, publish the package, create the GitHub +release, and commit the release files back to the repository. + +## Release channels + +KTX has two npm release channels: + +- `rc` publishes prereleases such as `0.1.0-rc.2` to the npm `next` tag. +- `stable` publishes normal releases such as `0.1.0` to the npm `latest` tag. + +Run stable releases only from `main`. The workflow rejects stable releases from +other branches. + +## Prerequisites + +Before you publish, confirm these requirements: + +- The repository has an Actions secret named `NPM_TOKEN`. +- `NPM_TOKEN` is a granular npm token that can publish `@kaelio/ktx`. +- The token can publish non-interactively if the npm account or package uses + two-factor authentication for writes. +- The repository has a baseline semantic-release tag for the latest published + package version, such as `v0.1.0-rc.1`. + +If no baseline tag exists, semantic-release treats the run as the first release +and may choose a version that doesn't match the currently published package. + +## Dry-run a release + +Use a dry-run to verify the next version and generated release notes without +publishing to npm. + +1. Open **Actions** in GitHub. +2. Select **KTX Release**. +3. Select the branch to release from. +4. Set **release_kind** to `rc` or `stable`. +5. Leave **publish_live** set to `false`. +6. Optional: Set **force_release** to `true` when you need a patch release even + if semantic-release doesn't find a releasable commit. +7. Run the workflow. + +The dry-run uses the same semantic-release configuration as a live release. It +doesn't publish to npm and doesn't commit release files. + +## Publish an rc release + +Publish an rc release when you need a prerelease package for validation before +promoting to `latest`. + +1. Open **Actions** in GitHub. +2. Select **KTX Release**. +3. Select the branch to release from. +4. Set **release_kind** to `rc`. +5. Set **publish_live** to `true`. +6. Optional: Set **force_release** to `true`. +7. Run the workflow. + +The workflow publishes `@kaelio/ktx` with `--access public --tag next`, runs the +published package smoke test, creates a GitHub release, and commits +`CHANGELOG.md`, `package.json`, and `release-policy.json`. + +## Publish a stable release + +Publish a stable release from `main` after you have validated an rc package. + +1. Open **Actions** in GitHub. +2. Select **KTX Release**. +3. Select `main`. +4. Set **release_kind** to `stable`. +5. Set **publish_live** to `true`. +6. Optional: Set **force_release** to `true`. +7. Run the workflow. + +The workflow publishes `@kaelio/ktx` with `--access public --tag latest`, runs +the published package smoke test, creates a GitHub release, and commits the +release metadata. + +## Release metadata + +semantic-release calls `scripts/update-public-release-version.mjs` during the +prepare step. That script updates: + +- `package.json` with the semantic-release version. +- `release-policy.json` with `publicNpmPackageVersion`, npm publish settings, + and the published package smoke-test version. + +The artifact packaging and readiness scripts read `publicNpmPackageVersion` +from `release-policy.json`, so manual version edits in build scripts aren't +needed for rc releases. + +## Trusted Publishing follow-up + +This workflow uses `NPM_TOKEN` today. Move to npm Trusted Publishing after the +final publish command path is verified for the package manager and workflow +filename configured in npm package settings. diff --git a/docs/superpowers/plans/2026-05-15-claude-code-auth-probe-isolation-fix.md b/docs/superpowers/plans/2026-05-15-claude-code-auth-probe-isolation-fix.md new file mode 100644 index 00000000..cee6774f --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-claude-code-auth-probe-isolation-fix.md @@ -0,0 +1,678 @@ +# Claude Code Auth Probe Isolation Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the `claude-code` auth probe and runtime tolerate host-discovered +Claude Code init metadata while preserving KTX-owned tool, MCP, and plugin +restrictions. + +**Architecture:** Keep the existing Claude Code runtime and SDK option tuple. +Change the init-message assertion from "no host discovery appears" to "only the +KTX-controlled execution surface is active." Align the design spec and user docs +with the pinned SDK behavior: `settingSources: []` disables filesystem settings, +`skills: []` is a context filter, and deny-by-default `canUseTool` is the +runtime enforcement boundary. + +**Tech Stack:** TypeScript, pnpm, Vitest, Markdown, Fumadocs MDX, +`@anthropic-ai/claude-agent-sdk@0.3.142`. + +--- + +## Audit result + +The current strict isolation assertion is a v1-blocking bug. A real authenticated +Claude Code host can report non-empty `slash_commands`, `skills`, and `agents` +in the SDK init message even when KTX passes `settingSources: []`, `skills: []`, +`plugins: []`, `tools: []`, exact KTX MCP `allowedTools`, `disallowedTools`, and +deny-by-default `canUseTool`. + +Spec findings: + +- `docs/superpowers/specs/2026-05-15-claude-code-backend-design.md:45-47` + requires host-discovered capabilities not to expand the KTX agent-loop tool + surface. That requirement is about invocation, not necessarily about zero + diagnostic metadata in the init message. +- `docs/superpowers/specs/2026-05-15-claude-code-backend-design.md:254-265` + overreaches by asking the implementation to assert that unexpected + settings-derived commands, skills, agents, plugins, or MCP servers are + inactive from the SDK init message. In `@anthropic-ai/claude-agent-sdk@0.3.142`, + the available SDK controls cannot make `message.slash_commands`, + `message.skills`, or `message.agents` reliably empty on an authenticated host. +- `docs/superpowers/specs/2026-05-15-claude-code-backend-design.md:266-267` + says skills are disabled with `skills: []`. The pinned SDK type definitions + document `skills` as a context filter, not a sandbox. +- `docs/superpowers/specs/2026-05-15-claude-code-backend-design.md:543-545` + correctly requires the auth probe to pass the isolation option tuple and no + MCP servers. It does not require failing when host discovery metadata is + present. + +SDK evidence from +`node_modules/.pnpm/@anthropic-ai+claude-agent-sdk@0.3.142_zod@4.4.3/node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts`: + +- Lines `1686-1695`: `settingSources: []` disables filesystem settings only. +- Lines `1697-1718`: `skills: []` is a context filter; unlisted skills are + hidden from listing and rejected by the Skill tool, but files remain on disk. +- Lines `1202-1213`: `allowedTools` is auto-approval, while `canUseTool` is the + permission handler for controlling tool execution. +- Lines `1224-1228`: `disallowedTools` removes listed tools from context and + prevents use. +- Lines `1255-1264`: `tools: []` disables built-in tools. +- Lines `1545-1558`: `plugins` loads plugins when supplied; KTX supplies `[]`. +- Lines `3465-3489`: the init message reports `agents`, `tools`, + `mcp_servers`, `slash_commands`, `skills`, and `plugins`. + +Implemented plan audit: + +- `2026-05-15-claude-code-backend-v1-runtime.md` is implemented for config, + runtime port, SDK dependency, model aliases, environment scrubbing, Claude Code + text/object/agent execution, setup/status/doctor support, docs, and LLM + call-site migration. +- `2026-05-15-claude-code-backend-v1-isolation-closure.md` is implemented, but + it converted the spec's ambiguous "assert inactive" line into an impossible + assertion against non-empty `slash_commands`, `skills`, and `agents`. +- `2026-05-15-claude-code-backend-v1-ingest-guidance-closure.md` is implemented + for the ingest missing-LLM guidance and associated CLI/context tests. + +Remaining v1-blocking gaps: + +- `packages/context/src/llm/claude-code-runtime.ts:94-101` throws on + host-discovered slash commands, skills, and agents. +- `packages/context/src/llm/claude-code-runtime.test.ts:158-178` encodes the + wrong behavior by requiring the runtime to reject any init message with + discovered agents. +- The auth probe has no regression coverage for an authenticated host whose init + message reports non-empty `slash_commands`, `skills`, and `agents`. +- User docs under `docs-site/content/docs/guides/` say KTX "disables" skills, + agents, hooks, and slash commands. That wording is stronger than the SDK + contract and must be changed to "not invokable by KTX agent loops." + +Non-blocking gaps: + +- Same-step AI SDK tool-call repair parity remains out of scope for v1. +- OTEL telemetry parity remains out of scope for v1. +- Embedding parity remains out of scope because embeddings are configured + separately. +- Full prompt-caching parity remains out of scope. V1 keeps warning on ignored + prompt-cache fields and avoids AI SDK cache markers on the Claude Code path. + +Decision: + +- Choose option (a): relax the assertion in code and align the spec text. Do not + rely on an invented SDK mechanism. The pinned type definitions expose + `settingSources`, `skills`, `plugins`, `tools`, `allowedTools`, + `disallowedTools`, and `canUseTool`, but they do not expose a query option that + disables all host-discovered slash commands or user-level subagent names in the + init message. + +## File structure + +Modify these files: + +- `docs/superpowers/specs/2026-05-15-claude-code-backend-design.md` aligns the + design with the real SDK contract. +- `packages/context/src/llm/claude-code-runtime.test.ts` adds the failing + regression tests for auth probe and runtime init metadata. +- `packages/context/src/llm/claude-code-runtime.ts` relaxes init metadata checks + while tightening exact tool equality. +- `docs-site/content/docs/guides/llm-configuration.mdx` changes user docs from + "disabled" to "not invokable." +- `docs-site/content/docs/guides/building-context.mdx` applies the same + user-facing wording at the ingest guide boundary. + +### Task 1: Align the design spec with SDK reality + +**Files:** + +- Modify: `docs/superpowers/specs/2026-05-15-claude-code-backend-design.md` + +- [ ] **Step 1: Update the tool-boundary goal** + +Replace the goal bullet at lines `45-47` with: + +```markdown +- Preserve KTX's curated tool boundaries. Claude Code built-ins, + filesystem-discovered MCP servers, hooks, skills, plugins, agents, and slash + commands must not become invokable in KTX agent loops. The Agent SDK init + message may still report host-discovered slash commands, skills, and agents; + KTX treats that metadata as diagnostic only and restricts execution through + `tools: []`, exact KTX MCP `allowedTools`, `disallowedTools`, and + deny-by-default `canUseTool`. +``` + +- [ ] **Step 2: Replace the over-broad init assertion requirement** + +Replace the bullet at lines `254-265` with: + +```markdown +- Filesystem settings are not loaded. The SDK's documented default for an + omitted `settingSources` is `["user", "project", "local"]` + (`@anthropic-ai/claude-agent-sdk@0.3.142` `sdk.d.ts:1686-1695`), + which would inherit the user's Claude Code filesystem settings. Every KTX + `query()` call site - agent loops, text generation, object generation, and + the auth probe - MUST pass `settingSources: []` explicitly, along with + `skills: []`, `plugins: []`, `tools: []`, `persistSession: false`, and no + `mcpServers` entries other than the KTX MCP server (omitted entirely when + the call site does not expose tools). The implementation MUST assert from + the SDK init message that the controlled execution surface matches KTX's + expectations: + + - `message.tools` equals the exact generated KTX MCP tool ids for the current + call. + - `message.mcp_servers` equals the expected KTX MCP server set: `[]` when the + call exposes no tools, or `["ktx"]` when it does. + - `message.plugins` is empty. + + The implementation MUST NOT reject a run solely because + `message.slash_commands`, `message.skills`, or `message.agents` contain + host-discovered names. In `@anthropic-ai/claude-agent-sdk@0.3.142`, those + fields can report host discovery even when KTX passes the isolation options. + They are not part of the KTX execution surface when `tools: []`, + `allowedTools`, `disallowedTools`, and deny-by-default `canUseTool` are set. +``` + +- [ ] **Step 3: Replace the skills/plugin wording** + +Replace the bullets at lines `266-289` with: + +```markdown +- `skills: []` is a context filter in the pinned SDK + (`sdk.d.ts:1697-1718`): unlisted skills are hidden from the model's skill + listing and rejected by the Skill tool, but discovered skill names may still + appear in init metadata. KTX must still pass `skills: []`. +- Plugins are disabled with `plugins: []`, and the runtime asserts that + `message.plugins` is empty in the init message. +- Built-in tools are disabled by setting `tools: []`. The pinned SDK type + (`@anthropic-ai/claude-agent-sdk@0.3.142`, `sdk.d.ts:1255-1264`) documents + `tools` as the base set of built-in tools, with `[]` meaning "disable all + built-ins"; `tools` does not accept MCP tool ids and cannot be used to + restrict MCP availability. +- MCP tool availability is granted by registering the KTX MCP server through + `mcpServers`. The SDK does not document a wildcard like `mcp__ktx__*` for + any tool field; KTX must enumerate exact generated MCP tool ids of the form + `mcp__ktx__` (derived from the tool map handed to + `createSdkMcpServer`) wherever a list of tool ids is required. +- Pre-approval under `permissionMode: "dontAsk"` is configured by listing those + same exact `mcp__ktx__` ids in `allowedTools` (documented as + auto-allow without prompting). Treat `allowedTools` as auto-approval, not + restriction. +- Defense-in-depth restriction uses `canUseTool`. The KTX runtime supplies a + `canUseTool` handler that allows only tool names in the current KTX MCP tool + map and denies everything else, so host-discovered slash commands, skills, + agents, future SDK defaults, or a misconfigured MCP server cannot expand the + execution surface. +- `disallowedTools` MUST additionally list the current built-in tool names + (`Agent`, `Task`, `AskUserQuestion`, `Bash`, `Read`, `Edit`, `Write`, `Glob`, + `Grep`, `WebFetch`, `WebSearch`, `TodoWrite`) as redundant insurance. +``` + +- [ ] **Step 4: Update auth probe acceptance text** + +After the auth probe option list at lines `543-545`, add: + +```markdown + The auth probe MUST tolerate init messages with non-empty + `slash_commands`, `skills`, and `agents` when `message.tools` is empty, + `message.mcp_servers` is empty, `message.plugins` is empty, and the query + options contain the KTX isolation tuple. Host discovery metadata is not an + auth failure. +``` + +- [ ] **Step 5: Update verified evidence and open items** + +Replace lines `621-623` with: + +```markdown +- The Agent SDK skills docs say the `skills` option is a context filter rather + than a sandbox. KTX must pass `skills: []`, but must not assert that + `message.skills` is empty in the SDK init message. +``` + +Replace open item `8` at lines `648-649` with: + +```markdown +8. Write tests proving a raw built-in Claude Code tool request is denied, + host-discovered Skill/Agent/SlashCommand requests are denied by `canUseTool`, + and only exact `mcp__ktx__*` tools are allowed during KTX agent loops. +``` + +Replace open item `9` at lines `650-654` with: + +```markdown +9. Write a test that asserts every KTX-originated `query()` invocation + (agent loop, text generation, object generation, auth probe) is called + with `settingSources: []`, `skills: []`, `plugins: []`, `tools: []`, and + `persistSession: false`, by spying on the SDK entry point. The test must + fail if any path falls back to SDK defaults for those fields. The test must + also prove that non-empty host-discovered `slash_commands`, `skills`, and + `agents` in the init message do not fail the auth probe or runtime when the + controlled tool, MCP server, and plugin surfaces match KTX expectations. +``` + +- [ ] **Step 6: Commit the spec alignment** + +Run: + +```bash +git add docs/superpowers/specs/2026-05-15-claude-code-backend-design.md +git commit -m "docs: align claude-code isolation spec with sdk metadata" +``` + +Expected: the design spec no longer requires zero host-discovery metadata in +the SDK init message. + +### Task 2: Add regression tests for host-discovered init metadata + +**Files:** + +- Modify: `packages/context/src/llm/claude-code-runtime.test.ts` + +- [ ] **Step 1: Replace the invalid agent rejection test** + +In `packages/context/src/llm/claude-code-runtime.test.ts`, replace the test named +`rejects settings-derived agents and non-KTX MCP servers from init messages` +with these tests: + +```ts + it('treats host-discovered commands skills and agents as non-fatal init metadata for text and auth probe', async () => { + const hostDiscoveredInit = initMessage({ + slash_commands: ['/help', '/compact', '/clear', '/user-command'], + skills: ['pdf', 'docx'], + agents: ['claude', 'Explore', 'general-purpose'], + }); + const textQuery = vi.fn((_input: any) => + stream([hostDiscoveredInit, resultMessage({ result: 'hello' })]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query: textQuery, + env: { ANTHROPIC_API_KEY: 'sk-ant-test', PATH: '/usr/bin' }, // pragma: allowlist secret + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'say hello' })).resolves.toBe('hello'); + const textOptions = textQuery.mock.calls[0][0].options; + expect(textOptions).toMatchObject({ + settingSources: [], + skills: [], + plugins: [], + tools: [], + allowedTools: [], + permissionMode: 'dontAsk', + persistSession: false, + env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }), + }); + expect(textOptions.disallowedTools).toEqual(expect.arrayContaining(['Agent', 'Task', 'Bash'])); + expect(await textOptions.canUseTool('Agent', {}, { signal: new AbortController().signal, toolUseID: 'agent' })).toMatchObject({ + behavior: 'deny', + toolUseID: 'agent', + }); + expect(await textOptions.canUseTool('Skill', {}, { signal: new AbortController().signal, toolUseID: 'skill' })).toMatchObject({ + behavior: 'deny', + toolUseID: 'skill', + }); + expect( + await textOptions.canUseTool('SlashCommand', {}, { signal: new AbortController().signal, toolUseID: 'slash' }), + ).toMatchObject({ + behavior: 'deny', + toolUseID: 'slash', + }); + + const probeQuery = vi.fn((_input: any) => + stream([hostDiscoveredInit, resultMessage({ result: 'ok' })]), + ); + await expect( + runClaudeCodeAuthProbe({ + projectDir: '/tmp/project', + model: 'sonnet', + query: probeQuery, + env: { ANTHROPIC_AUTH_TOKEN: 'token', HOME: '/Users/test' }, + }), + ).resolves.toEqual({ ok: true }); + expect(probeQuery.mock.calls[0][0].options).toMatchObject({ + settingSources: [], + skills: [], + plugins: [], + tools: [], + allowedTools: [], + permissionMode: 'dontAsk', + persistSession: false, + env: expect.objectContaining({ HOME: '/Users/test' }), + }); + expect(probeQuery.mock.calls[0][0].options.env).not.toEqual( + expect.objectContaining({ ANTHROPIC_AUTH_TOKEN: 'token' }), + ); + }); + + it('allows host-discovered context during agent loops while requiring exact KTX MCP tools and servers', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage({ + tools: ['mcp__ktx__load_skill'], + mcp_servers: [{ name: 'ktx', status: 'connected' }], + slash_commands: ['/help', '/compact', '/clear'], + skills: ['memory-agent', 'doc-reader'], + agents: ['claude', 'Plan', 'Explore'], + }), + { + type: 'assistant', + message: { role: 'assistant', content: [] }, + parent_tool_use_id: null, + uuid: '00000000-0000-4000-8000-000000000006', + session_id: 'session-id', + } as unknown as SDKMessage, + resultMessage({ subtype: 'error_max_turns', is_error: true }), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + }); + + await expect( + runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + toolSet: { + load_skill: { + name: 'load_skill', + description: 'Load skill.', + inputSchema: z.object({ name: z.string() }), + execute: async () => ({ markdown: 'loaded' }), + }, + }, + stepBudget: 1, + telemetryTags: { operationName: 'test' }, + }), + ).resolves.toEqual({ stopReason: 'budget' }); + + const options = query.mock.calls[0][0].options; + expect(options.allowedTools).toEqual(['mcp__ktx__load_skill']); + expect(await options.canUseTool('mcp__ktx__load_skill', {}, { signal: new AbortController().signal, toolUseID: '1' })).toEqual({ + behavior: 'allow', + toolUseID: '1', + }); + expect(await options.canUseTool('Task', {}, { signal: new AbortController().signal, toolUseID: '2' })).toMatchObject({ + behavior: 'deny', + toolUseID: '2', + }); + expect(await options.canUseTool('Skill', {}, { signal: new AbortController().signal, toolUseID: '3' })).toMatchObject({ + behavior: 'deny', + toolUseID: '3', + }); + }); + + it('still rejects unexpected tools, missing KTX tools, plugins, and non-KTX MCP servers from init messages', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage({ + tools: ['Bash'], + mcp_servers: [{ name: 'filesystem', status: 'connected' }], + plugins: [{ name: 'host-plugin', path: '/tmp/plugin' }], + }), + resultMessage({ result: 'hello' }), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + }); + + await expect( + runtime.generateText({ + role: 'default', + prompt: 'say hello', + tools: { + load_skill: { + name: 'load_skill', + description: 'Load skill.', + inputSchema: z.object({ name: z.string() }), + execute: async () => ({ markdown: 'loaded' }), + }, + }, + }), + ).rejects.toThrow( + /Claude Code runtime isolation failed: .*tools=Bash.*missing_tools=mcp__ktx__load_skill.*mcp_servers=filesystem.*plugins=host-plugin/, + ); + }); +``` + +- [ ] **Step 2: Run the runtime test to verify it fails** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/claude-code-runtime.test.ts +``` + +Expected: FAIL. The first new test fails because `runClaudeCodeAuthProbe(...)` +returns `{ ok: false, ... }` and `generateText(...)` rejects when init metadata +contains non-empty `slash_commands`, `skills`, or `agents`. The second new test +fails because `runAgentLoop(...)` returns `{ stopReason: 'error', ... }` for the +same reason. + +- [ ] **Step 3: Commit the failing regression test** + +Run: + +```bash +git add packages/context/src/llm/claude-code-runtime.test.ts +git commit -m "test: cover claude-code host discovery metadata" +``` + +Expected: the commit contains tests that fail before the runtime assertion is +fixed. + +### Task 3: Relax init metadata assertions to the controlled execution surface + +**Files:** + +- Modify: `packages/context/src/llm/claude-code-runtime.ts` + +- [ ] **Step 1: Replace `assertInitIsolation`** + +In `packages/context/src/llm/claude-code-runtime.ts`, replace the full +`assertInitIsolation(...)` function with: + +```ts +function assertInitIsolation( + message: SDKMessage, + allowedToolIds: Set, + expectedMcpServerNames: Set, +): void { + if (message.type !== 'system' || message.subtype !== 'init') { + return; + } + const activeToolIds = new Set(message.tools); + const unexpectedTools = message.tools.filter((toolName) => !allowedToolIds.has(toolName)); + const missingTools = [...allowedToolIds].filter((toolName) => !activeToolIds.has(toolName)); + const activeMcpServerNames = message.mcp_servers.map((server) => server.name); + const unexpectedMcpServers = activeMcpServerNames.filter((name) => !expectedMcpServerNames.has(name)); + const missingMcpServers = [...expectedMcpServerNames].filter((name) => !activeMcpServerNames.includes(name)); + const unexpectedPlugins = message.plugins.map((plugin) => plugin.name); + if ( + unexpectedTools.length > 0 || + missingTools.length > 0 || + unexpectedMcpServers.length > 0 || + missingMcpServers.length > 0 || + unexpectedPlugins.length > 0 + ) { + throw new Error( + `Claude Code runtime isolation failed: tools=${unexpectedTools.join(',') || '(none)'} missing_tools=${ + missingTools.join(',') || '(none)' + } mcp_servers=${unexpectedMcpServers.join(',') || '(none)'} missing_mcp_servers=${ + missingMcpServers.join(',') || '(none)' + } plugins=${unexpectedPlugins.join(',') || '(none)'} host_slash_commands=${ + message.slash_commands.length + } host_skills=${message.skills.length} host_agents=${message.agents?.join(',') || '(none)'}`, + ); + } +} +``` + +This preserves strict checks for the KTX-controlled execution surface: + +- `message.tools` must exactly equal the generated KTX MCP tool ids for the + current call. +- `message.mcp_servers` must exactly equal the expected KTX MCP server names. +- `message.plugins` must be empty. + +It deliberately stops treating `message.slash_commands`, `message.skills`, and +`message.agents` as fatal because those fields can contain host-discovered +metadata that KTX cannot disable through the pinned SDK options. + +- [ ] **Step 2: Run the runtime test to verify it passes** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/claude-code-runtime.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Commit the runtime fix** + +Run: + +```bash +git add packages/context/src/llm/claude-code-runtime.ts packages/context/src/llm/claude-code-runtime.test.ts +git commit -m "fix: tolerate claude-code host discovery metadata" +``` + +Expected: the auth probe and runtime no longer fail solely because the SDK init +message reports host-discovered slash commands, skills, or agents. + +### Task 4: Correct user-facing docs wording + +**Files:** + +- Modify: `docs-site/content/docs/guides/llm-configuration.mdx` +- Modify: `docs-site/content/docs/guides/building-context.mdx` + +- [ ] **Step 1: Update the LLM configuration guide wording** + +In `docs-site/content/docs/guides/llm-configuration.mdx`, replace lines `39-41` +with: + +```mdx +`claude-code` keeps KTX tool boundaries intact. KTX exposes only the MCP tools +needed for the current KTX agent loop, disables Claude Code built-in tools, +keeps plugins empty, and denies every non-KTX tool request through +`canUseTool`. The Claude Agent SDK may still report host-discovered slash +commands, skills, and subagent names in init metadata; that metadata is not an +execution grant for KTX agent loops. +``` + +- [ ] **Step 2: Update the building context guide wording** + +In `docs-site/content/docs/guides/building-context.mdx`, replace lines `61-63` +with: + +```mdx +When you use `claude-code`, KTX still controls the tool surface for ingest and +memory capture. Claude Code built-in tools, discovered MCP servers, plugins, +skills, agents, and slash commands are not invokable by KTX agent loops unless +they are exact KTX MCP tools for the current run. +``` + +- [ ] **Step 3: Run docs tests** + +Run: + +```bash +pnpm --filter ktx-docs run test +``` + +Expected: PASS. + +- [ ] **Step 4: Commit docs wording** + +Run: + +```bash +git add docs-site/content/docs/guides/llm-configuration.mdx docs-site/content/docs/guides/building-context.mdx +git commit -m "docs: clarify claude-code host discovery metadata" +``` + +Expected: user docs describe invocation control rather than promising zero +host-discovery metadata. + +### Task 5: Final verification + +**Files:** + +- Verify: `docs/superpowers/specs/2026-05-15-claude-code-backend-design.md` +- Verify: `packages/context/src/llm/claude-code-runtime.ts` +- Verify: `packages/context/src/llm/claude-code-runtime.test.ts` +- Verify: `docs-site/content/docs/guides/llm-configuration.mdx` +- Verify: `docs-site/content/docs/guides/building-context.mdx` + +- [ ] **Step 1: Run targeted runtime tests** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/claude-code-runtime.test.ts src/llm/runtime-tools.test.ts src/llm/claude-code-env.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run package type-check** + +Run: + +```bash +pnpm --filter @ktx/context run type-check +``` + +Expected: PASS. + +- [ ] **Step 3: Run docs verification** + +Run: + +```bash +pnpm --filter ktx-docs run test +``` + +Expected: PASS. + +- [ ] **Step 4: Run dead-code checks** + +Run: + +```bash +pnpm run dead-code +``` + +Expected: PASS or only pre-existing unrelated findings. Investigate and fix any +finding caused by the runtime assertion or test changes. + +- [ ] **Step 5: Inspect git status** + +Run: + +```bash +git status --short +``` + +Expected: only files from this plan are modified, or the working tree is clean +if each task was committed. + +## Self-review + +- Spec coverage: This plan addresses the v1-blocking auth probe failure, + aligns the spec with the SDK contract, preserves the real KTX execution + boundary, and adds regression coverage for non-empty host-discovered + `slash_commands`, `skills`, and `agents` in both auth probe and runtime paths. +- Placeholder scan: No placeholder markers remain. Every code-changing step + includes exact file paths, code blocks, commands, and expected results. +- Type consistency: The plan uses existing names from the codebase: + `ClaudeCodeKtxLlmRuntime`, `runClaudeCodeAuthProbe`, `initMessage`, + `resultMessage`, `assertInitIsolation`, `mcpToolIds`, `KtxRuntimeToolSet`, and + `canUseTool`. diff --git a/docs/superpowers/plans/2026-05-15-claude-code-backend-v1-ingest-guidance-closure.md b/docs/superpowers/plans/2026-05-15-claude-code-backend-v1-ingest-guidance-closure.md new file mode 100644 index 00000000..5243ac31 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-claude-code-backend-v1-ingest-guidance-closure.md @@ -0,0 +1,160 @@ +# Claude Code Backend V1 Ingest Guidance Closure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the `ktx ingest` missing-LLM guidance treat `claude-code` as a first-class setup path and restore the CLI ingest test suite. + +**Architecture:** Keep the existing Claude Code runtime implementation unchanged. Update the single local-ingest guard message so users see both the local Claude Code setup path and the Anthropic API setup path, then align the context and CLI tests with that user-facing copy. + +**Tech Stack:** TypeScript, pnpm, Vitest. + +--- + +## Audit summary + +The May 15 Claude Code backend runtime and isolation plans are implemented for +the core runtime path: config accepts `claude-code`, runtime calls use +`KtxLlmRuntimePort`, Claude SDK calls pass isolation options and scrubbed env, +setup/status/doctor validate Claude Code auth, and docs describe the backend. + +One v1-blocking issue remains: `packages/context/src/ingest/local-bundle-runtime.ts` +lists `claude-code` in the missing-LLM guard line but still tells users only to +"Configure an Anthropic provider." The full CLI ingest test suite currently +fails because `packages/cli/src/ingest.test.ts` still expects the old provider +list without `claude-code`. This is v1-blocking because CI is red and the +fallback guidance is not first-class for the new backend. + +Non-blocking gaps from the original spec remain unchanged: + +- Same-step AI SDK tool-call repair parity is out of scope for the Claude Code + runtime. +- OTEL telemetry parity is out of scope for the Claude Code runtime. +- Embedding parity is out of scope because embeddings stay independently + configured. +- Full prompt-caching parity for tools, history, and per-section TTLs is out of + scope; v1 only needs no AI SDK cache markers on `claude-code` and explicit + warnings for ignored fields. + +## File structure + +Modify these files: + +- `packages/context/src/ingest/local-bundle-runtime.ts` owns the missing-LLM + guard message used by local ingest and MCP-triggered ingest. +- `packages/context/src/ingest/local-bundle-runtime.test.ts` verifies the guard + message at the context boundary. +- `packages/cli/src/ingest.test.ts` verifies the user-facing CLI output. + +No `docs-site/` update is required because the existing public docs already +document `claude-code` setup and ingest behavior; this plan only fixes an +inline runtime error message. + +### Task 1: Update ingest LLM setup guidance + +**Files:** + +- Modify: `packages/context/src/ingest/local-bundle-runtime.test.ts` +- Modify: `packages/cli/src/ingest.test.ts` +- Modify: `packages/context/src/ingest/local-bundle-runtime.ts` + +- [ ] **Step 1: Update the context guard-message test** + +In `packages/context/src/ingest/local-bundle-runtime.test.ts`, replace the +expected message in `requires an agent runner or configured local ingest LLM` +with this exact array: + +```ts +[ + 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.', + 'Configure a local Claude Code session or API-backed LLM, then rerun ingest:', + ` ktx setup --project-dir ${project.projectDir} --llm-backend claude-code --no-input`, + ` ktx setup --project-dir ${project.projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`, +].join('\n') +``` + +- [ ] **Step 2: Update the CLI ingest test** + +In `packages/cli/src/ingest.test.ts`, replace the stale provider-list +assertion in `prints provider setup guidance when a skip-llm setup project runs +ingest` with: + +```ts +expect(runIo.stderr()).toContain( + 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.', +); +expect(runIo.stderr()).toContain('Configure a local Claude Code session or API-backed LLM, then rerun ingest:'); +expect(runIo.stderr()).toContain(`ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`); +expect(runIo.stderr()).toContain( + `ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`, +); +``` + +- [ ] **Step 3: Run tests to verify the new expectations fail** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/ingest/local-bundle-runtime.test.ts +pnpm --filter @ktx/cli exec vitest run src/ingest.test.ts +``` + +Expected: both suites fail because the source message still says +`Configure an Anthropic provider, then rerun ingest:` and does not include the +Claude Code setup command. + +- [ ] **Step 4: Update the ingest guard message** + +In `packages/context/src/ingest/local-bundle-runtime.ts`, replace +`localIngestLlmProviderGuardMessage` with: + +```ts +function localIngestLlmProviderGuardMessage(projectDir: string): string { + return [ + 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.', + 'Configure a local Claude Code session or API-backed LLM, then rerun ingest:', + ` ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`, + ` ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`, + ].join('\n'); +} +``` + +- [ ] **Step 5: Run the targeted tests** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/ingest/local-bundle-runtime.test.ts +pnpm --filter @ktx/cli exec vitest run src/ingest.test.ts +``` + +Expected: both suites pass. + +- [ ] **Step 6: Run package type-checks** + +Run: + +```bash +pnpm --filter @ktx/context run type-check +pnpm --filter @ktx/cli run type-check +``` + +Expected: both commands pass. + +- [ ] **Step 7: Commit** + +Run: + +```bash +git add packages/context/src/ingest/local-bundle-runtime.ts packages/context/src/ingest/local-bundle-runtime.test.ts packages/cli/src/ingest.test.ts +git commit -m "fix: update claude-code ingest setup guidance" +``` + +## Self-review + +- Spec coverage: This plan closes the only remaining v1-blocking audit finding: + ingest setup guidance and CLI test expectations now include `claude-code` as + a first-class backend. +- Placeholder scan: No placeholders remain; every step includes exact paths, + code, commands, and expected output. +- Type consistency: The exact guard string is identical across the source and + both test updates. diff --git a/docs/superpowers/plans/2026-05-15-claude-code-backend-v1-isolation-closure.md b/docs/superpowers/plans/2026-05-15-claude-code-backend-v1-isolation-closure.md new file mode 100644 index 00000000..6295dd63 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-claude-code-backend-v1-isolation-closure.md @@ -0,0 +1,575 @@ +# Claude Code Backend V1 Isolation Closure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the remaining v1-blocking Claude Code backend gaps around SDK +init isolation assertions and setup-time prompt-caching warnings. + +**Architecture:** Keep the existing runtime port and Claude Code runtime. Add +the missing init-message checks inside the Claude runtime, then share the +prompt-caching warning formatter between status/doctor and setup so all +user-facing readiness flows report ignored Claude Code cache knobs consistently. + +**Tech Stack:** TypeScript, pnpm, Vitest, Zod, `@anthropic-ai/claude-agent-sdk@0.3.142`. + +--- + +## Audit Summary + +The May 15 Claude Code backend v1 plan is mostly implemented. Remaining +v1-blocking gaps from the original spec are: + +- `packages/context/src/llm/claude-code-runtime.ts` asserts init-message tools, + slash commands, skills, and plugins, but does not assert `agents` or + unexpected `mcp_servers`. The spec requires asserting that settings-derived + commands, skills, agents, plugins, and MCP servers are inactive. +- `packages/cli/src/setup-models.ts` validates Claude Code auth but does not + surface ignored `llm.promptCaching` fields during setup. The spec requires + setup, status, and doctor to surface ignored prompt-caching fields for the + `claude-code` backend. Status and doctor already warn. + +Non-blocking gaps: + +- Same-step tool-call repair parity remains out of scope for v1. +- OTEL telemetry parity remains out of scope for v1. +- Embedding parity remains out of scope because embeddings are configured + independently. +- Full prompt-caching parity for tools, history, and per-section TTLs remains + out of scope; v1 only needs explicit warnings and no AI SDK cache markers on + the Claude Code path. + +## File Structure + +Modify these files: + +- `packages/context/src/llm/claude-code-runtime.ts` adds complete init-message + isolation checks for agents and MCP servers. +- `packages/context/src/llm/claude-code-runtime.test.ts` adds regression tests + for rejected agents/MCP servers, object/agent env scrubbing, and callback + error handling. +- `packages/cli/src/claude-code-prompt-caching.ts` is created as the shared + formatter for ignored prompt-caching fields. +- `packages/cli/src/status-project.ts` imports the shared formatter instead of + keeping a local helper. +- `packages/cli/src/setup-models.ts` emits the shared warning when setup saves + `llm.provider.backend: claude-code` and existing prompt-caching fields are + present. +- `packages/cli/src/setup-models.test.ts` covers setup warning output. +- `packages/cli/src/doctor.test.ts` keeps coverage for doctor output using the + shared formatter. + +### Task 1: Complete Claude Code init isolation checks + +**Files:** + +- Modify: `packages/context/src/llm/claude-code-runtime.test.ts` +- Modify: `packages/context/src/llm/claude-code-runtime.ts` + +- [ ] **Step 1: Add failing isolation and runtime behavior tests** + +Add these tests inside `describe('ClaudeCodeKtxLlmRuntime', ...)` in +`packages/context/src/llm/claude-code-runtime.test.ts`: + +```ts + it('rejects settings-derived agents and non-KTX MCP servers from init messages', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage({ + agents: ['project-agent'], + mcp_servers: [{ name: 'filesystem', status: 'connected' }], + }), + resultMessage({ result: 'hello' }), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'say hello' })).rejects.toThrow( + /Claude Code runtime isolation failed: .*mcp_servers=filesystem.*agents=project-agent/, + ); + }); + + it('passes scrubbed env to object generation and agent loops', async () => { + const schema = z.object({ answer: z.string() }); + const objectQuery = vi.fn((_input: any) => + stream([initMessage(), resultMessage({ structured_output: { answer: 'yes' } })]), + ); + const objectRuntime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query: objectQuery, + env: { ANTHROPIC_API_KEY: 'sk-ant-test', AWS_PROFILE: 'prod', PATH: '/usr/bin' }, // pragma: allowlist secret + }); + + await expect(objectRuntime.generateObject({ role: 'default', prompt: 'json', schema })).resolves.toEqual({ + answer: 'yes', + }); + expect(objectQuery.mock.calls[0][0].options.env).toEqual( + expect.objectContaining({ PATH: '/usr/bin' }), + ); + expect(objectQuery.mock.calls[0][0].options.env).not.toEqual( + expect.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test', AWS_PROFILE: 'prod' }), // pragma: allowlist secret + ); + + const agentQuery = vi.fn((_input: any) => + stream([ + initMessage({ tools: ['mcp__ktx__load_skill'], mcp_servers: [{ name: 'ktx', status: 'connected' }] }), + { + type: 'assistant', + message: { role: 'assistant', content: [] }, + parent_tool_use_id: null, + uuid: '00000000-0000-4000-8000-000000000004', + session_id: 'session-id', + } as unknown as SDKMessage, + resultMessage({ subtype: 'error_max_turns', is_error: true }), + ]), + ); + const agentRuntime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query: agentQuery, + env: { ANTHROPIC_AUTH_TOKEN: 'token', CLAUDE_CODE_USE_VERTEX: '1', HOME: '/Users/test' }, + }); + + await agentRuntime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + toolSet: { + load_skill: { + name: 'load_skill', + description: 'Load skill.', + inputSchema: z.object({ name: z.string() }), + execute: async () => ({ markdown: 'loaded' }), + }, + }, + stepBudget: 1, + telemetryTags: { operationName: 'test' }, + }); + expect(agentQuery.mock.calls[0][0].options.env).toEqual(expect.objectContaining({ HOME: '/Users/test' })); + expect(agentQuery.mock.calls[0][0].options.env).not.toEqual( + expect.objectContaining({ ANTHROPIC_AUTH_TOKEN: 'token', CLAUDE_CODE_USE_VERTEX: '1' }), + ); + }); + + it('logs and ignores onStepFinish callback errors', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage(), + { + type: 'assistant', + message: { role: 'assistant', content: [] }, + parent_tool_use_id: null, + uuid: '00000000-0000-4000-8000-000000000005', + session_id: 'session-id', + } as unknown as SDKMessage, + resultMessage({ subtype: 'success', terminal_reason: 'completed' }), + ]), + ); + const logger = { + debug: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + logger, + }); + + await expect( + runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + toolSet: {}, + stepBudget: 1, + telemetryTags: { operationName: 'test' }, + onStepFinish: async () => { + throw new Error('callback exploded'); + }, + }), + ).resolves.toEqual({ stopReason: 'natural' }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('callback exploded')); + }); +``` + +- [ ] **Step 2: Run the Claude runtime test to verify it fails** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/claude-code-runtime.test.ts +``` + +Expected: FAIL because the new agents/MCP-server isolation test resolves +successfully instead of throwing. + +- [ ] **Step 3: Add expected MCP server metadata and complete init assertions** + +In `packages/context/src/llm/claude-code-runtime.ts`, replace +`assertInitIsolation` and add the helper below it: + +```ts +function assertInitIsolation( + message: SDKMessage, + allowedToolIds: Set, + expectedMcpServerNames: Set, +): void { + if (message.type !== 'system' || message.subtype !== 'init') { + return; + } + const unexpectedTools = message.tools.filter((toolName) => !allowedToolIds.has(toolName)); + const activeMcpServerNames = message.mcp_servers.map((server) => server.name); + const unexpectedMcpServers = activeMcpServerNames.filter((name) => !expectedMcpServerNames.has(name)); + const missingMcpServers = [...expectedMcpServerNames].filter((name) => !activeMcpServerNames.includes(name)); + const unexpectedAgents = message.agents ?? []; + if ( + unexpectedTools.length > 0 || + unexpectedMcpServers.length > 0 || + missingMcpServers.length > 0 || + message.slash_commands.length > 0 || + message.skills.length > 0 || + message.plugins.length > 0 || + unexpectedAgents.length > 0 + ) { + throw new Error( + `Claude Code runtime isolation failed: tools=${unexpectedTools.join(',') || '(none)'} mcp_servers=${ + unexpectedMcpServers.join(',') || '(none)' + } missing_mcp_servers=${missingMcpServers.join(',') || '(none)'} slash_commands=${ + message.slash_commands.length + } skills=${message.skills.length} plugins=${message.plugins.length} agents=${ + unexpectedAgents.join(',') || '(none)' + }`, + ); + } +} + +function expectedMcpServerNames(tools: KtxRuntimeToolSet | undefined): Set { + return tools && Object.keys(tools).length > 0 ? new Set(['ktx']) : new Set(); +} +``` + +Update `collectResult` parameters: + +```ts +async function collectResult(params: { + query: QueryFn; + prompt: string; + options: Options; + allowedToolIds: Set; + expectedMcpServerNames: Set; + onAssistantTurn?: () => Promise; +}): Promise { + let result: SDKResultMessage | undefined; + for await (const message of params.query({ prompt: params.prompt, options: params.options })) { + assertInitIsolation(message, params.allowedToolIds, params.expectedMcpServerNames); +``` + +Update the four `collectResult(...)` calls: + +```ts + const tools = input.tools ?? {}; + const result = await collectResult({ + query: this.runQuery, + prompt: [input.system, input.prompt].filter(Boolean).join('\n\n'), + options, + allowedToolIds: new Set(mcpToolIds(tools)), + expectedMcpServerNames: expectedMcpServerNames(input.tools), + }); +``` + +For `runAgentLoop(...)`, use: + +```ts + const result = await collectResult({ + query: this.runQuery, + prompt: params.userPrompt, + options: { ...options, systemPrompt: params.systemPrompt }, + allowedToolIds: new Set(mcpToolIds(params.toolSet)), + expectedMcpServerNames: expectedMcpServerNames(params.toolSet), + onAssistantTurn: async () => { +``` + +For `runClaudeCodeAuthProbe(...)`, use: + +```ts + const result = await collectResult({ + query: input.query ?? defaultQuery, + prompt: 'Reply with exactly: ok', + options, + allowedToolIds: new Set(), + expectedMcpServerNames: new Set(), + }); +``` + +- [ ] **Step 4: Run the Claude runtime test to verify it passes** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/claude-code-runtime.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add packages/context/src/llm/claude-code-runtime.ts packages/context/src/llm/claude-code-runtime.test.ts +git commit -m "fix: close claude-code runtime isolation checks" +``` + +### Task 2: Surface Claude Code prompt-caching warnings during setup + +**Files:** + +- Create: `packages/cli/src/claude-code-prompt-caching.ts` +- Modify: `packages/cli/src/status-project.ts` +- Modify: `packages/cli/src/setup-models.ts` +- Modify: `packages/cli/src/setup-models.test.ts` +- Modify: `packages/cli/src/doctor.test.ts` + +- [ ] **Step 1: Add failing setup warning test** + +Add this test to `packages/cli/src/setup-models.test.ts`: + +```ts + it('warns during Claude Code setup when existing prompt-caching fields will be ignored', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'llm:', + ' provider:', + ' backend: anthropic', + ' models:', + ' default: claude-sonnet-4-6', + ' promptCaching:', + ' enabled: true', + ' systemTtl: 1h', + ' toolsTtl: 1h', + ' historyTtl: 5m', + '', + ].join('\n'), + 'utf-8', + ); + const io = makeIo(); + + const result = await runKtxSetupAnthropicModelStep( + { + projectDir: tempDir, + inputMode: 'disabled', + llmBackend: 'claude-code', + skipLlm: false, + }, + io.io, + { + claudeCodeAuthProbe: async () => ({ ok: true as const }), + }, + ); + + expect(result.status).toBe('ready'); + expect(io.stderr()).toContain('claude-code ignores llm.promptCaching.systemTtl'); + expect(io.stderr()).toContain('Claude Agent SDK does not expose KTX prompt-cache TTL, tool, or history markers'); + }); +``` + +- [ ] **Step 2: Run setup tests to verify the new test fails** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-models.test.ts +``` + +Expected: FAIL because setup does not emit the ignored prompt-caching warning. + +- [ ] **Step 3: Create the shared prompt-caching warning helper** + +Create `packages/cli/src/claude-code-prompt-caching.ts`: + +```ts +import type { KtxProjectLlmConfig } from '@ktx/context/project'; + +const CLAUDE_CODE_IGNORED_PROMPT_CACHING_FIELDS = [ + 'systemTtl', + 'toolsTtl', + 'historyTtl', + 'vertexFallbackTo5m', +] as const; + +export function ignoredClaudeCodePromptCachingFields(config: KtxProjectLlmConfig): string[] { + if (config.provider.backend !== 'claude-code' || !config.promptCaching) { + return []; + } + return CLAUDE_CODE_IGNORED_PROMPT_CACHING_FIELDS.filter((key) => key in config.promptCaching).map( + (key) => `llm.promptCaching.${key}`, + ); +} + +export function formatClaudeCodePromptCachingWarning(fields: string[]): string | null { + if (fields.length === 0) { + return null; + } + return `claude-code ignores ${fields.join(', ')} because the Claude Agent SDK does not expose KTX prompt-cache TTL, tool, or history markers.`; +} + +export function formatClaudeCodePromptCachingFix(): string { + return 'Remove those promptCaching fields or use anthropic, vertex, or gateway when those cache knobs are required.'; +} +``` + +- [ ] **Step 4: Update status/doctor to use the shared helper** + +In `packages/cli/src/status-project.ts`, add: + +```ts +import { + formatClaudeCodePromptCachingFix, + formatClaudeCodePromptCachingWarning, + ignoredClaudeCodePromptCachingFields, +} from './claude-code-prompt-caching.js'; +``` + +Delete the local `ignoredClaudeCodePromptCachingFields(...)` function. + +Replace the warning block in `buildWarnings(...)` with: + +```ts + const warning = formatClaudeCodePromptCachingWarning(ignoredClaudeCodePromptCachingFields(config.llm)); + if (warning) { + warnings.push({ + message: warning, + fix: formatClaudeCodePromptCachingFix(), + }); + } +``` + +- [ ] **Step 5: Emit the setup warning before persisting Claude Code config** + +In `packages/cli/src/setup-models.ts`, add: + +```ts +import { + formatClaudeCodePromptCachingWarning, + ignoredClaudeCodePromptCachingFields, +} from './claude-code-prompt-caching.js'; +``` + +Inside the `backendChoice.backend === 'claude-code'` branch, immediately before +`await persistLlmConfig(...)`, add: + +```ts + const warning = formatClaudeCodePromptCachingWarning( + ignoredClaudeCodePromptCachingFields(buildProjectLlmConfig(project.config.llm, { backend: 'claude-code' }, model)), + ); + if (warning) { + io.stderr.write(`${warning}\n`); + } +``` + +- [ ] **Step 6: Run CLI tests** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-models.test.ts src/doctor.test.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +Run: + +```bash +git add packages/cli/src/claude-code-prompt-caching.ts packages/cli/src/status-project.ts packages/cli/src/setup-models.ts packages/cli/src/setup-models.test.ts packages/cli/src/doctor.test.ts +git commit -m "fix: warn on claude-code prompt caching during setup" +``` + +### Task 3: Final verification + +**Files:** + +- Verify: `packages/context/src/llm/claude-code-runtime.ts` +- Verify: `packages/cli/src/setup-models.ts` +- Verify: `packages/cli/src/status-project.ts` + +- [ ] **Step 1: Run targeted tests** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/claude-code-runtime.test.ts src/llm/runtime-tools.test.ts src/llm/claude-code-env.test.ts src/llm/claude-code-models.test.ts src/llm/runtime-local-config.test.ts +pnpm --filter @ktx/cli exec vitest run src/setup-models.test.ts src/doctor.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run package type-checks** + +Run: + +```bash +pnpm --filter @ktx/context run type-check +pnpm --filter @ktx/cli run type-check +``` + +Expected: PASS. + +- [ ] **Step 3: Run the LLM boundary audit** + +Run: + +```bash +rg -n "generateKtxText\\(|generateKtxObject\\(|new AgentRunnerService\\(|AgentRunnerService\\b|llmProvider\\b|getModel\\(|getModelByName\\(" packages/context/src packages/cli/src packages/llm/src --glob '!**/*.test.ts' +``` + +Expected: remaining matches are limited to: + +- `packages/llm/src/**` +- `packages/context/src/llm/ai-sdk-runtime.ts` +- `packages/context/src/llm/local-config.ts` +- `packages/context/src/agent/agent-runner.service.ts` +- type/export declarations that intentionally preserve the AI SDK adapter + boundary. + +- [ ] **Step 4: Run dead-code check** + +Run: + +```bash +pnpm run dead-code +``` + +Expected: PASS or only pre-existing unrelated findings. Investigate and fix +any finding caused by the new helper file. + +- [ ] **Step 5: Commit verification cleanup if needed** + +If verification required small cleanup, run: + +```bash +git add packages/context/src/llm/claude-code-runtime.ts packages/context/src/llm/claude-code-runtime.test.ts packages/cli/src/claude-code-prompt-caching.ts packages/cli/src/status-project.ts packages/cli/src/setup-models.ts packages/cli/src/setup-models.test.ts packages/cli/src/doctor.test.ts +git commit -m "chore: verify claude-code v1 closure" +``` + +If no files changed after verification, skip this commit. + +## Self-Review + +- Spec coverage: The plan closes the remaining v1-blocking isolation assertion + and setup-warning requirements from the original spec. +- Placeholder scan: No placeholders remain; every task includes file paths, + code, commands, and expected output. +- Type consistency: The helper names and runtime function signatures are used + consistently across tasks. diff --git a/docs/superpowers/plans/2026-05-15-claude-code-backend-v1-runtime.md b/docs/superpowers/plans/2026-05-15-claude-code-backend-v1-runtime.md new file mode 100644 index 00000000..9da58f86 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-claude-code-backend-v1-runtime.md @@ -0,0 +1,2483 @@ +# Claude Code Backend V1 Runtime Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `llm.provider.backend: claude-code` as a first-class KTX LLM backend for text generation, structured object generation, and agent-loop execution. + +**Architecture:** Keep `@ktx/llm` as the AI SDK provider package for `anthropic`, `vertex`, and `gateway`, and add a backend-neutral runtime port in `@ktx/context` for KTX operations. The AI SDK runtime wraps the existing provider behavior; the Claude Code runtime uses `@anthropic-ai/claude-agent-sdk@0.3.142` with explicit isolation options, a scrubbed environment, exact MCP tool ids, and KTX-owned tool descriptors. + +**Tech Stack:** TypeScript, pnpm, Vitest, AI SDK v6, Zod v4, `@anthropic-ai/claude-agent-sdk@0.3.142`, Commander, Fumadocs MDX. + +--- + +## Audit Result + +No implemented plan exists for the May 15 Claude Code backend spec. The latest +plans in `docs/superpowers/plans/` stop at May 14 research-agent MCP work, +which configures external agent clients but does not make `claude-code` a KTX +LLM backend. + +Current v1-blocking gaps: + +- `packages/context/src/project/config.ts` accepts only `none`, `anthropic`, + `vertex`, and `gateway`. +- `packages/llm/src/types.ts` defines `KtxLlmBackend` without `claude-code`. +- `@anthropic-ai/claude-agent-sdk` is not a workspace dependency. +- `packages/llm/src/model-provider.ts` falls through to gateway for unknown + non-`anthropic` and non-`vertex` backends instead of throwing. +- No `KtxLlmRuntimePort` exists, and LLM call sites still depend directly on + `KtxLlmProvider`, `AgentRunnerService`, `generateKtxText`, and + `generateKtxObject`. +- Agent-loop tools are still AI SDK `Tool` objects. Several inline tools return + bare strings or plain objects to the model path. +- `ktx setup`, `ktx status`, and doctor output do not understand + `claude-code` as an LLM provider or validate local Claude Code authentication. +- Docs do not describe `claude-code` as a local Claude Code session backend or + document prompt-caching divergence. + +Non-blocking gaps from the spec: + +- Same-step AI SDK tool-call repair parity can remain absent on the Claude Code + runtime. Schema/tool errors can surface as normal tool failures and + next-turn self-correction. +- OTEL telemetry parity can remain absent for the Claude Code runtime. +- Embedding parity is out of scope because embeddings stay configured under + `ingest.embeddings` and scan enrichment embedding settings. +- Session persistence for Claude Code debugging is out of scope for v1 because + the required runtime behavior sets `persistSession: false`. +- Full prompt-caching parity for tools, history, and per-section TTLs is out of + scope. V1 must only avoid AI-SDK cache markers on `claude-code` and warn when + users configure ignored prompt-caching fields. + +## File Structure + +Create these files: + +- `packages/context/src/llm/runtime-port.ts` defines `KtxLlmRuntimePort`, + text/object inputs, runtime tool descriptors, runtime tool outputs, and + `AgentRunnerPort`. +- `packages/context/src/llm/runtime-tools.ts` converts runtime descriptors to + AI SDK tools and Claude SDK MCP tools, normalizes markdown/structured output, + and rejects non-object tool schemas. +- `packages/context/src/llm/ai-sdk-runtime.ts` implements + `KtxLlmRuntimePort` for existing AI SDK backends. +- `packages/context/src/llm/claude-code-env.ts` owns the Claude Code + environment denylist and scrubber. +- `packages/context/src/llm/claude-code-models.ts` maps `sonnet`, `opus`, and + `haiku` aliases and validates full model ids. +- `packages/context/src/llm/claude-code-runtime.ts` implements text, object, + auth probe, and agent loops through the Claude Agent SDK. +- `packages/context/src/llm/runtime-local-config.test.ts`, + `packages/context/src/llm/runtime-tools.test.ts`, + `packages/context/src/llm/claude-code-env.test.ts`, + `packages/context/src/llm/claude-code-models.test.ts`, and + `packages/context/src/llm/claude-code-runtime.test.ts` cover the new runtime + boundary. + +Modify these files: + +- `packages/context/package.json` adds the pinned Claude Agent SDK dependency. +- `packages/llm/src/types.ts`, `packages/llm/src/model-provider.ts`, + `packages/llm/src/model-provider.test.ts`, and + `packages/llm/src/model-health.test.ts` add backend typing and explicit + unsupported-provider behavior. +- `packages/context/src/project/config.ts` and + `packages/context/src/project/config.test.ts` parse and serialize + `claude-code`. +- `packages/context/src/llm/local-config.ts` and + `packages/context/src/llm/index.ts` create and export the runtime factory. +- `packages/context/src/llm/generation.ts` makes `generateKtxText` and + `generateKtxObject` runtime-backed helpers. +- `packages/context/src/agent/agent-runner.service.ts` uses runtime tool + descriptors on the AI SDK path and exposes `AgentRunnerPort`. +- `packages/context/src/tools/base-tool.ts` adds `toRuntimeTool`. +- `packages/context/src/ingest/local-bundle-runtime.ts`, + `packages/context/src/ingest/local-ingest.ts`, + `packages/context/src/ingest/ports.ts`, + `packages/context/src/ingest/page-triage/page-triage.service.ts`, + `packages/context/src/ingest/stages/stage-3-work-units.ts`, + `packages/context/src/ingest/stages/stage-4-reconciliation.ts`, + `packages/context/src/ingest/context-candidates/curator-pagination.service.ts`, + `packages/context/src/ingest/ingest-bundle.runner.ts`, + `packages/context/src/ingest/stages/build-wu-context.ts`, and + `packages/context/src/ingest/stages/build-reconcile-context.ts` move local + ingest paths to the runtime boundary. +- `packages/context/src/memory/types.ts`, + `packages/context/src/memory/local-memory.ts`, and + `packages/context/src/memory/memory-agent.service.ts` move memory capture to + runtime-backed agent loops. +- `packages/context/src/scan/local-scan.ts`, + `packages/context/src/scan/local-enrichment.ts`, + `packages/context/src/scan/description-generation.ts`, and + `packages/context/src/scan/relationship-llm-proposal.ts` move scan + enrichment and relationship proposals to runtime text/object operations. +- `packages/context/src/mcp/local-project-ports.ts` passes runtime-backed local + ingest options into MCP-triggered ingest. +- `packages/cli/src/setup-commands.ts`, `packages/cli/src/setup-models.ts`, + `packages/cli/src/setup-models.test.ts`, `packages/cli/src/status-project.ts`, + and `packages/cli/src/doctor.test.ts` expose setup/status/doctor support. +- `docs-site/content/docs/getting-started/quickstart.mdx`, + `docs-site/content/docs/cli-reference/ktx-setup.mdx`, + `docs-site/content/docs/cli-reference/ktx-status.mdx`, + `docs-site/content/docs/guides/building-context.mdx`, + `docs-site/content/docs/guides/llm-configuration.mdx`, and + `docs-site/content/docs/guides/meta.json` describe the backend. + +### Task 1: Config, Dependency, and No-Fallback Guard + +**Files:** + +- Modify: `packages/context/package.json` +- Modify: `packages/context/src/project/config.ts` +- Modify: `packages/context/src/project/config.test.ts` +- Modify: `packages/llm/src/types.ts` +- Modify: `packages/llm/src/model-provider.ts` +- Modify: `packages/llm/src/model-provider.test.ts` +- Modify: `packages/llm/src/model-health.test.ts` + +- [ ] **Step 1: Write failing config and provider tests** + +Add this test to `packages/context/src/project/config.test.ts`: + +```ts +it('parses Claude Code as a first-class LLM backend', () => { + const config = parseKtxProjectConfig(` +llm: + provider: + backend: claude-code + models: + default: sonnet + triage: haiku + candidateExtraction: sonnet + curator: sonnet + reconcile: sonnet + repair: opus +`); + + expect(config.llm.provider.backend).toBe('claude-code'); + expect(config.llm.models).toEqual({ + default: 'sonnet', + triage: 'haiku', + candidateExtraction: 'sonnet', + curator: 'sonnet', + reconcile: 'sonnet', + repair: 'opus', + }); +}); +``` + +Add this test to `packages/llm/src/model-provider.test.ts`: + +```ts +it('throws instead of falling through when an unsupported LLM backend is passed to the AI SDK provider factory', () => { + expect(() => + createKtxLlmProvider({ + backend: 'claude-code', + modelSlots: { default: 'sonnet' }, + promptCaching: { enabled: false }, + }), + ).toThrow('claude-code is not an AI SDK LanguageModel backend'); +}); +``` + +Add this test to `packages/llm/src/model-health.test.ts`: + +```ts +it('reports claude-code as unsupported by the AI SDK health check', async () => { + const result = await runKtxLlmHealthCheck({ + backend: 'claude-code', + modelSlots: { default: 'sonnet' }, + promptCaching: { enabled: false }, + }); + + expect(result).toEqual({ + ok: false, + message: expect.stringContaining('claude-code is not an AI SDK LanguageModel backend'), + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/project/config.test.ts +pnpm --filter @ktx/llm exec vitest run src/model-provider.test.ts src/model-health.test.ts +``` + +Expected: the config test rejects `claude-code`, and the `@ktx/llm` tests fail +because `KtxLlmBackend` does not include `claude-code`. + +- [ ] **Step 3: Add the pinned SDK dependency** + +In `packages/context/package.json`, add this dependency inside +`dependencies`: + +```json +"@anthropic-ai/claude-agent-sdk": "0.3.142" +``` + +Run: + +```bash +pnpm install +``` + +Expected: `pnpm-lock.yaml` records `@anthropic-ai/claude-agent-sdk@0.3.142`. + +- [ ] **Step 4: Extend backend config and types** + +In `packages/context/src/project/config.ts`, update the backend list and +description: + +```ts +const KTX_LLM_BACKENDS = ['none', 'anthropic', 'vertex', 'gateway', 'claude-code'] as const; +``` + +```ts +.describe( + 'LLM provider backend. "none" disables LLM features; "anthropic" / "vertex" / "gateway" require the matching nested credentials block; "claude-code" uses the local Claude Code session.', +), +``` + +In `packages/llm/src/types.ts`, update the backend type: + +```ts +export type KtxLlmBackend = 'anthropic' | 'vertex' | 'gateway' | 'claude-code'; +``` + +- [ ] **Step 5: Make unsupported AI SDK provider backends explicit** + +In `packages/llm/src/model-provider.ts`, replace the gateway fallthrough in +`createModelFactory` with an explicit gateway branch and a final throw: + +```ts + if (config.backend === 'gateway') { + const gateway = (deps.createGateway ?? createGateway)({ + ...(config.gateway?.apiKey ? { apiKey: config.gateway.apiKey } : {}), + ...(config.gateway?.baseURL ? { baseURL: config.gateway.baseURL } : {}), + headers: { + 'anthropic-beta': ANTHROPIC_BETA_HEADER, + }, + }); + return (modelId) => gateway(modelId); + } + + throw new Error(`${config.backend} is not an AI SDK LanguageModel backend; use KtxLlmRuntimePort`); +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/project/config.test.ts +pnpm --filter @ktx/llm exec vitest run src/model-provider.test.ts src/model-health.test.ts +``` + +Expected: all selected tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add packages/context/package.json pnpm-lock.yaml packages/context/src/project/config.ts packages/context/src/project/config.test.ts packages/llm/src/types.ts packages/llm/src/model-provider.ts packages/llm/src/model-provider.test.ts packages/llm/src/model-health.test.ts +git commit -m "feat: recognize claude-code llm backend" +``` + +### Task 2: Runtime Port, Tool Descriptors, and AI SDK Adapter + +**Files:** + +- Create: `packages/context/src/llm/runtime-port.ts` +- Create: `packages/context/src/llm/runtime-tools.ts` +- Create: `packages/context/src/llm/ai-sdk-runtime.ts` +- Create: `packages/context/src/llm/runtime-tools.test.ts` +- Modify: `packages/context/src/tools/base-tool.ts` +- Modify: `packages/context/src/agent/agent-runner.service.ts` +- Modify: `packages/context/src/llm/generation.ts` +- Modify: `packages/context/src/llm/index.ts` + +- [ ] **Step 1: Write failing runtime tool tests** + +Create `packages/context/src/llm/runtime-tools.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { createAiSdkToolSet, createClaudeSdkTools, normalizeKtxRuntimeToolOutput } from './runtime-tools.js'; +import type { KtxRuntimeToolDescriptor } from './runtime-port.js'; + +describe('runtime tool descriptors', () => { + const descriptor: KtxRuntimeToolDescriptor<{ id: string }, { ok: boolean }> = { + name: 'read_thing', + description: 'Read one thing.', + inputSchema: z.object({ id: z.string() }), + execute: vi.fn(async (input) => ({ + markdown: `Read ${input.id}`, + structured: { ok: true }, + })), + }; + + it('normalizes string and object tool outputs into markdown plus optional structured payload', () => { + expect(normalizeKtxRuntimeToolOutput('plain text')).toEqual({ markdown: 'plain text' }); + expect(normalizeKtxRuntimeToolOutput({ markdown: 'shown', structured: { id: 1 } })).toEqual({ + markdown: 'shown', + structured: { id: 1 }, + }); + expect(normalizeKtxRuntimeToolOutput({ name: 'skill', content: 'body' })).toEqual({ + markdown: '```json\n{\n "name": "skill",\n "content": "body"\n}\n```', + structured: { name: 'skill', content: 'body' }, + }); + }); + + it('builds AI SDK tools that expose markdown to the model', async () => { + const tools = createAiSdkToolSet({ read_thing: descriptor }); + const output = await tools.read_thing.execute?.({ id: 'a' }, { toolCallId: 'call-1', messages: [] } as never); + const modelOutput = tools.read_thing.toModelOutput?.({ output } as never); + + expect(modelOutput).toEqual({ type: 'content', value: [{ type: 'text', text: 'Read a' }] }); + }); + + it('builds Claude SDK tools that return text content only', async () => { + const tools = createClaudeSdkTools({ read_thing: descriptor }); + const result = await tools[0].handler({ id: 'b' } as never, {}); + + expect(result).toEqual({ content: [{ type: 'text', text: 'Read b' }] }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/runtime-tools.test.ts +``` + +Expected: FAIL because `runtime-tools.ts` and `runtime-port.ts` do not exist. + +- [ ] **Step 3: Define the runtime port** + +Create `packages/context/src/llm/runtime-port.ts`: + +```ts +import type { KtxModelRole } from '@ktx/llm'; +import type { z } from 'zod'; + +export interface KtxRuntimeToolOutput { + markdown: string; + structured?: TOutput; +} + +export interface KtxRuntimeToolDescriptor { + name: string; + description: string; + inputSchema: z.ZodObject; + execute(input: TInput): Promise>; +} + +export type KtxRuntimeToolSet = Record; + +export type RunLoopStopReason = 'budget' | 'natural' | 'error'; + +export interface RunLoopStepInfo { + stepIndex: number; + stepBudget: number; +} + +export interface RunLoopParams { + modelRole: KtxModelRole; + systemPrompt: string; + userPrompt: string; + toolSet: KtxRuntimeToolSet; + stepBudget: number; + telemetryTags: Record; + onStepFinish?: (info: RunLoopStepInfo) => void | Promise; +} + +export interface RunLoopResult { + stopReason: RunLoopStopReason; + error?: Error; +} + +export interface KtxGenerateTextInput { + role: KtxModelRole; + prompt: string; + system?: string; + tools?: KtxRuntimeToolSet; + temperature?: number; +} + +export interface KtxGenerateObjectInput> { + role: KtxModelRole; + prompt: string; + system?: string; + tools?: KtxRuntimeToolSet; + temperature?: number; + schema: TSchema; +} + +export interface KtxLlmRuntimePort { + generateText(input: KtxGenerateTextInput): Promise; + generateObject>( + input: KtxGenerateObjectInput, + ): Promise; + runAgentLoop(params: RunLoopParams): Promise; +} + +export interface AgentRunnerPort { + runLoop(params: RunLoopParams): Promise; +} + +export class RuntimeAgentRunner implements AgentRunnerPort { + constructor(private readonly runtime: KtxLlmRuntimePort) {} + + runLoop(params: RunLoopParams): Promise { + return this.runtime.runAgentLoop(params); + } +} +``` + +- [ ] **Step 4: Implement runtime tool conversion** + +Create `packages/context/src/llm/runtime-tools.ts`: + +```ts +import { tool as aiTool, type ToolSet } from 'ai'; +import { + tool as claudeTool, + type SdkMcpToolDefinition, + type CallToolResult, +} from '@anthropic-ai/claude-agent-sdk'; +import type { z } from 'zod'; +import type { KtxRuntimeToolDescriptor, KtxRuntimeToolOutput, KtxRuntimeToolSet } from './runtime-port.js'; + +function isRuntimeOutput(value: unknown): value is KtxRuntimeToolOutput { + return Boolean(value && typeof value === 'object' && 'markdown' in value && typeof (value as { markdown?: unknown }).markdown === 'string'); +} + +export function normalizeKtxRuntimeToolOutput(value: unknown): KtxRuntimeToolOutput { + if (isRuntimeOutput(value)) { + return 'structured' in value + ? { markdown: value.markdown, structured: value.structured } + : { markdown: value.markdown }; + } + if (typeof value === 'string') { + return { markdown: value }; + } + return { + markdown: `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``, + structured: value, + }; +} + +function assertObjectSchema(name: string, schema: z.ZodType): asserts schema is z.ZodObject { + if (schema.def.type !== 'object') { + throw new Error(`KTX runtime tool "${name}" must use z.object input schema for claude-code`); + } +} + +export function createAiSdkToolSet(tools: KtxRuntimeToolSet = {}): ToolSet { + return Object.fromEntries( + Object.entries(tools).map(([name, descriptor]) => [ + name, + aiTool({ + description: descriptor.description, + inputSchema: descriptor.inputSchema, + execute: async (input) => descriptor.execute(input), + toModelOutput: ({ output }) => { + const normalized = normalizeKtxRuntimeToolOutput(output); + return { type: 'content', value: [{ type: 'text', text: normalized.markdown }] }; + }, + }), + ]), + ); +} + +export function createClaudeSdkTools(tools: KtxRuntimeToolSet = {}): Array> { + return Object.values(tools).map((descriptor) => { + assertObjectSchema(descriptor.name, descriptor.inputSchema); + const sdkTool = claudeTool( + descriptor.name, + descriptor.description, + descriptor.inputSchema.shape, + async (input): Promise => { + const normalized = normalizeKtxRuntimeToolOutput(await descriptor.execute(input)); + return { content: [{ type: 'text', text: normalized.markdown }] }; + }, + ); + return Object.assign(sdkTool, { handler: sdkTool.handler }); + }); +} + +export function mcpToolIds(tools: KtxRuntimeToolSet = {}): string[] { + return Object.keys(tools).map((name) => `mcp__ktx__${name}`); +} +``` + +- [ ] **Step 5: Add `BaseTool.toRuntimeTool`** + +In `packages/context/src/tools/base-tool.ts`, add this import: + +```ts +import type { KtxRuntimeToolDescriptor } from '../llm/runtime-port.js'; +import { normalizeKtxRuntimeToolOutput } from '../llm/runtime-tools.js'; +``` + +Add this method beside `toAiSdkTool`: + +```ts + toRuntimeTool(context: ToolContext): KtxRuntimeToolDescriptor { + const toolName = this.name; + return { + name: toolName, + description: this.description, + inputSchema: this.inputSchema as KtxRuntimeToolDescriptor['inputSchema'], + execute: async (params) => { + const callContext = { ...context }; + if (!callContext.userId) { + throw new Error('Authentication required: userId must be provided in ToolContext'); + } + const parsedInput = this.parseInput(params as Record); + return normalizeKtxRuntimeToolOutput(await this.call(parsedInput, callContext)); + }, + }; + } +``` + +- [ ] **Step 6: Implement the AI SDK runtime adapter** + +Create `packages/context/src/llm/ai-sdk-runtime.ts`: + +```ts +import { KtxMessageBuilder, splitKtxSystemMessages, type KtxLlmProvider } from '@ktx/llm'; +import { generateText, Output, stepCountIs, type FlexibleSchema } from 'ai'; +import type { z } from 'zod'; +import { noopLogger, type KtxLogger } from '../core/index.js'; +import { summarizeKtxLlmDebugRequest, type KtxLlmDebugRequestRecorder } from './debug-request-recorder.js'; +import { createAiSdkToolSet } from './runtime-tools.js'; +import type { + KtxGenerateObjectInput, + KtxGenerateTextInput, + KtxLlmRuntimePort, + RunLoopParams, + RunLoopResult, +} from './runtime-port.js'; + +export interface AiSdkKtxLlmRuntimeDeps { + llmProvider: KtxLlmProvider; + logger?: KtxLogger; + debugRequestRecorder?: KtxLlmDebugRequestRecorder; +} + +export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { + private readonly logger: KtxLogger; + + constructor(private readonly deps: AiSdkKtxLlmRuntimeDeps) { + this.logger = deps.logger ?? noopLogger; + } + + async generateText(input: KtxGenerateTextInput): Promise { + const model = this.deps.llmProvider.getModel(input.role); + if ((model as { provider?: string }).provider === 'deterministic') { + return `Deterministic description for ${input.prompt.slice(0, 64).trim() || 'data source'}`; + } + const tools = createAiSdkToolSet(input.tools ?? {}); + const built = new KtxMessageBuilder(this.deps.llmProvider).wrapSimple({ + system: input.system, + messages: [{ role: 'user', content: input.prompt }], + tools, + model, + }); + const split = splitKtxSystemMessages(built.messages); + const result = await generateText({ + model, + temperature: input.temperature ?? 0, + ...(split.system ? { system: split.system } : {}), + messages: split.messages, + tools: built.tools, + ...(Object.keys(tools).length > 0 + ? { + experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ + source: `ktx-${input.role}`, + }), + } + : {}), + }); + if (typeof result.text !== 'string') { + throw new Error('KTX LLM text generation returned no text'); + } + return result.text; + } + + async generateObject>( + input: KtxGenerateObjectInput, + ): Promise { + const model = this.deps.llmProvider.getModel(input.role); + const tools = createAiSdkToolSet(input.tools ?? {}); + const built = new KtxMessageBuilder(this.deps.llmProvider).wrapSimple({ + system: input.system, + messages: [{ role: 'user', content: input.prompt }], + tools, + model, + }); + const split = splitKtxSystemMessages(built.messages); + const result = await generateText({ + model, + temperature: input.temperature ?? 0, + ...(split.system ? { system: split.system } : {}), + messages: split.messages, + tools: built.tools, + ...(Object.keys(tools).length > 0 + ? { + experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ + source: `ktx-${input.role}`, + }), + } + : {}), + output: Output.object({ schema: input.schema as unknown as FlexibleSchema }), + }); + if (result.output == null) { + throw new Error('KTX LLM object generation returned no output'); + } + return result.output as TOutput; + } + + async runAgentLoop(params: RunLoopParams): Promise { + let stepIndex = 0; + try { + const model = this.deps.llmProvider.getModel(params.modelRole); + const tools = createAiSdkToolSet(params.toolSet); + const builder = new KtxMessageBuilder(this.deps.llmProvider); + const built = builder.wrapSimple({ + system: params.systemPrompt, + messages: [{ role: 'user', content: params.userPrompt }], + tools, + model, + }); + const promptMessages = splitKtxSystemMessages(built.messages); + await this.deps.debugRequestRecorder?.record( + summarizeKtxLlmDebugRequest({ + operationName: params.telemetryTags.operationName ?? 'ktx-agent-runner', + source: params.telemetryTags.source, + jobId: params.telemetryTags.jobId, + unitKey: params.telemetryTags.unitKey, + modelRole: params.modelRole, + modelId: (model as { modelId?: string }).modelId ?? params.modelRole, + messages: built.messages, + tools: built.tools as Record, + }), + ); + await generateText({ + model, + temperature: 0, + stopWhen: stepCountIs(params.stepBudget), + experimental_telemetry: this.deps.llmProvider.telemetryConfig(), + experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ + source: params.telemetryTags.operationName ?? 'ktx-agent-runner', + }), + ...(promptMessages.system ? { system: promptMessages.system } : {}), + messages: promptMessages.messages, + tools: built.tools, + onStepFinish: async () => { + stepIndex += 1; + if (!params.onStepFinish) return; + try { + await params.onStepFinish({ stepIndex, stepBudget: params.stepBudget }); + } catch (err) { + this.logger.warn(`[agent-runner] onStepFinish callback threw; ignoring: ${err instanceof Error ? err.message : String(err)}`); + } + }, + }); + return { stopReason: 'natural' }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.warn(`[agent-runner] loop failed: ${err.message}`); + return { stopReason: 'error', error: err }; + } + } +} +``` + +- [ ] **Step 7: Keep `AgentRunnerService` as the AI SDK class** + +In `packages/context/src/agent/agent-runner.service.ts`, import and re-export +the runtime loop types: + +```ts +import { AiSdkKtxLlmRuntime } from '../llm/ai-sdk-runtime.js'; +import type { AgentRunnerPort, RunLoopParams, RunLoopResult } from '../llm/runtime-port.js'; +export type { + AgentRunnerPort, + RunLoopParams, + RunLoopResult, + RunLoopStepInfo, + RunLoopStopReason, +} from '../llm/runtime-port.js'; +``` + +Then replace the existing implementation with delegation to +`AiSdkKtxLlmRuntime`: + +```ts +export class AgentRunnerService implements AgentRunnerPort { + private readonly runtime: AiSdkKtxLlmRuntime; + + constructor(deps: AgentRunnerServiceDeps) { + this.runtime = new AiSdkKtxLlmRuntime(deps); + } + + runLoop(params: RunLoopParams): Promise { + return this.runtime.runAgentLoop(params); + } +} +``` + +- [ ] **Step 8: Re-export the runtime API and adapt generation helpers** + +In `packages/context/src/llm/index.ts`, export the new modules: + +```ts +export { AiSdkKtxLlmRuntime } from './ai-sdk-runtime.js'; +export type { + AgentRunnerPort, + KtxGenerateObjectInput, + KtxGenerateTextInput, + KtxLlmRuntimePort, + KtxRuntimeToolDescriptor, + KtxRuntimeToolOutput, + KtxRuntimeToolSet, +} from './runtime-port.js'; +export { RuntimeAgentRunner } from './runtime-port.js'; +export { createAiSdkToolSet, createClaudeSdkTools, normalizeKtxRuntimeToolOutput } from './runtime-tools.js'; +``` + +In `packages/context/src/llm/generation.ts`, replace direct provider use with +runtime-backed helpers: + +```ts +import type { z } from 'zod'; +import type { KtxGenerateObjectInput, KtxGenerateTextInput, KtxLlmRuntimePort } from './runtime-port.js'; + +export async function generateKtxText(input: KtxGenerateTextInput & { runtime: KtxLlmRuntimePort }): Promise { + return input.runtime.generateText(input); +} + +export async function generateKtxObject>( + input: KtxGenerateObjectInput & { runtime: KtxLlmRuntimePort }, +): Promise { + return input.runtime.generateObject(input); +} +``` + +- [ ] **Step 9: Run runtime tests** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/runtime-tools.test.ts src/agent/agent-runner.service.test.ts +``` + +Expected: selected tests pass after call sites in tests use runtime tool +descriptors. + +- [ ] **Step 10: Commit** + +```bash +git add packages/context/src/llm/runtime-port.ts packages/context/src/llm/runtime-tools.ts packages/context/src/llm/ai-sdk-runtime.ts packages/context/src/llm/runtime-tools.test.ts packages/context/src/tools/base-tool.ts packages/context/src/agent/agent-runner.service.ts packages/context/src/llm/generation.ts packages/context/src/llm/index.ts packages/context/src/agent/agent-runner.service.test.ts +git commit -m "feat: add ktx llm runtime port" +``` + +### Task 3: Claude Code Runtime, Auth Boundary, and Stop Reasons + +**Files:** + +- Create: `packages/context/src/llm/claude-code-env.ts` +- Create: `packages/context/src/llm/claude-code-env.test.ts` +- Create: `packages/context/src/llm/claude-code-models.ts` +- Create: `packages/context/src/llm/claude-code-models.test.ts` +- Create: `packages/context/src/llm/claude-code-runtime.ts` +- Create: `packages/context/src/llm/claude-code-runtime.test.ts` +- Modify: `packages/context/src/llm/index.ts` + +- [ ] **Step 1: Write failing environment and model tests** + +Create `packages/context/src/llm/claude-code-env.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { CLAUDE_CODE_PROVIDER_ENV_DENYLIST, createKtxClaudeCodeEnv } from './claude-code-env.js'; + +describe('createKtxClaudeCodeEnv', () => { + it('strips provider-routing credentials from the Claude Code child environment', () => { + const seeded = Object.fromEntries(CLAUDE_CODE_PROVIDER_ENV_DENYLIST.map((key) => [key, `${key}-value`])); + const env = createKtxClaudeCodeEnv({ + ...seeded, + PATH: '/usr/bin', + HOME: '/Users/test', + }); + + for (const key of CLAUDE_CODE_PROVIDER_ENV_DENYLIST) { + expect(env).not.toHaveProperty(key); + } + expect(env.PATH).toBe('/usr/bin'); + expect(env.HOME).toBe('/Users/test'); + }); +}); +``` + +Create `packages/context/src/llm/claude-code-models.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { resolveClaudeCodeModel } from './claude-code-models.js'; + +describe('resolveClaudeCodeModel', () => { + it.each([ + ['sonnet', 'claude-sonnet-4-6'], + ['opus', 'claude-opus-4-7'], + ['haiku', 'claude-haiku-4-5'], + ['claude-sonnet-4-6', 'claude-sonnet-4-6'], + ])('maps %s to %s', (input, expected) => { + expect(resolveClaudeCodeModel(input)).toBe(expected); + }); + + it('rejects unsupported aliases', () => { + expect(() => resolveClaudeCodeModel('gpt-5')).toThrow('Unsupported Claude Code model'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/claude-code-env.test.ts src/llm/claude-code-models.test.ts +``` + +Expected: FAIL because the files do not exist. + +- [ ] **Step 3: Implement environment scrubbing** + +Create `packages/context/src/llm/claude-code-env.ts`: + +```ts +export const CLAUDE_CODE_PROVIDER_ENV_DENYLIST = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_VERTEX_PROJECT_ID', + 'CLOUD_ML_REGION', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_CLOUD_PROJECT', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'AWS_REGION', + 'AWS_PROFILE', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', +] as const; + +const DENYLIST = new Set(CLAUDE_CODE_PROVIDER_ENV_DENYLIST); + +export function createKtxClaudeCodeEnv(env: NodeJS.ProcessEnv = process.env): Record { + return Object.fromEntries(Object.entries(env).filter(([key]) => !DENYLIST.has(key))); +} +``` + +- [ ] **Step 4: Implement model alias resolution** + +Create `packages/context/src/llm/claude-code-models.ts`: + +```ts +const CLAUDE_CODE_MODEL_ALIASES: Record = { + sonnet: 'claude-sonnet-4-6', + opus: 'claude-opus-4-7', + haiku: 'claude-haiku-4-5', +}; + +const FULL_MODEL_ID = /^claude-(sonnet|opus|haiku)-[0-9]+-[0-9]+$/; + +export function resolveClaudeCodeModel(model: string): string { + const normalized = model.trim(); + const alias = CLAUDE_CODE_MODEL_ALIASES[normalized]; + if (alias) { + return alias; + } + if (FULL_MODEL_ID.test(normalized)) { + return normalized; + } + throw new Error(`Unsupported Claude Code model "${model}". Use sonnet, opus, haiku, or a claude-* model id.`); +} +``` + +- [ ] **Step 5: Run environment and model tests** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/claude-code-env.test.ts src/llm/claude-code-models.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Write failing runtime tests for isolation, text, objects, tools, and progress** + +Create `packages/context/src/llm/claude-code-runtime.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; +import { ClaudeCodeKtxLlmRuntime, mapClaudeCodeStopReason, runClaudeCodeAuthProbe } from './claude-code-runtime.js'; + +async function* stream(messages: SDKMessage[]): AsyncGenerator { + for (const message of messages) { + yield message; + } +} + +function initMessage(overrides: Partial> = {}): Extract { + return { + type: 'system', + subtype: 'init', + apiKeySource: 'none', // pragma: allowlist secret + claude_code_version: '0.3.142', + cwd: '/tmp/project', + tools: [], + mcp_servers: [], + model: 'claude-sonnet-4-6', + permissionMode: 'dontAsk', + slash_commands: [], + output_style: 'default', + skills: [], + plugins: [], + ...overrides, + }; +} + +function resultMessage(overrides: Partial> = {}): Extract { + return { + type: 'result', + subtype: 'success', + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + num_turns: 1, + result: 'ok', + stop_reason: null, + total_cost_usd: 0, + usage: {} as never, + modelUsage: {}, + permission_denials: [], + uuid: 'result-id', + session_id: 'session-id', + ...overrides, + }; +} + +describe('ClaudeCodeKtxLlmRuntime', () => { + it('passes isolation options and scrubbed env to text generation', async () => { + const query = vi.fn(() => stream([initMessage(), resultMessage({ result: 'hello' })])); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: { ANTHROPIC_API_KEY: 'sk-ant-test', PATH: '/usr/bin' }, // pragma: allowlist secret + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'say hello' })).resolves.toBe('hello'); + expect(query).toHaveBeenCalledWith({ + prompt: 'say hello', + options: expect.objectContaining({ + cwd: '/tmp/project', + model: 'claude-sonnet-4-6', + maxTurns: 1, + settingSources: [], + skills: [], + plugins: [], + tools: [], + allowedTools: [], + permissionMode: 'dontAsk', + persistSession: false, + env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }), + }), + }); + }); + + it('validates structured output with the caller schema', async () => { + const schema = z.object({ answer: z.string() }); + const query = vi.fn(() => stream([initMessage(), resultMessage({ structured_output: { answer: 'yes' } })])); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + }); + + await expect(runtime.generateObject({ role: 'default', prompt: 'json', schema })).resolves.toEqual({ answer: 'yes' }); + expect(query.mock.calls[0][0].options.outputFormat).toMatchObject({ + type: 'json_schema', + schema: expect.objectContaining({ type: 'object' }), + }); + }); + + it('registers only exact KTX MCP tool ids and denies non-KTX tools', async () => { + const query = vi.fn(() => + stream([ + initMessage({ tools: ['mcp__ktx__load_skill'], mcp_servers: [{ name: 'ktx', status: 'connected' }] }), + { type: 'assistant', message: { role: 'assistant', content: [] }, parent_tool_use_id: null, uuid: 'assistant-1', session_id: 'session-id' } as SDKMessage, + resultMessage({ subtype: 'error_max_turns', is_error: true }), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + }); + const onStepFinish = vi.fn(); + + await runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + toolSet: { + load_skill: { + name: 'load_skill', + description: 'Load skill.', + inputSchema: z.object({ name: z.string() }), + execute: async () => ({ markdown: 'loaded' }), + }, + }, + stepBudget: 1, + telemetryTags: { operationName: 'test' }, + onStepFinish, + }); + + const options = query.mock.calls[0][0].options; + expect(options.allowedTools).toEqual(['mcp__ktx__load_skill']); + expect(await options.canUseTool('mcp__ktx__load_skill', {}, { signal: new AbortController().signal, toolUseID: '1' })).toEqual({ behavior: 'allow' }); + expect(await options.canUseTool('Bash', {}, { signal: new AbortController().signal, toolUseID: '2' })).toMatchObject({ behavior: 'deny' }); + expect(onStepFinish).toHaveBeenCalledWith({ stepIndex: 1, stepBudget: 1 }); + }); + + it('maps max-turn terminal reasons to budget', () => { + expect(mapClaudeCodeStopReason(resultMessage({ subtype: 'error_max_turns' }))).toBe('budget'); + expect(mapClaudeCodeStopReason(resultMessage({ terminal_reason: 'max_turns' }))).toBe('budget'); + expect(mapClaudeCodeStopReason(resultMessage({ stop_reason: 'max_turns' }))).toBe('budget'); + expect(mapClaudeCodeStopReason(resultMessage({ subtype: 'success', terminal_reason: 'completed' }))).toBe('natural'); + expect(mapClaudeCodeStopReason(resultMessage({ subtype: 'error_during_execution' }))).toBe('error'); + }); + + it('auth probe uses isolation options and a scrubbed env', async () => { + const query = vi.fn(() => stream([initMessage(), resultMessage({ result: 'ok' })])); + + await expect(runClaudeCodeAuthProbe({ projectDir: '/tmp/project', model: 'sonnet', query, env: { ANTHROPIC_API_KEY: 'sk-ant-test' } })).resolves.toEqual({ ok: true }); // pragma: allowlist secret + expect(query.mock.calls[0][0].options).toMatchObject({ + settingSources: [], + skills: [], + plugins: [], + tools: [], + allowedTools: [], + persistSession: false, + env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }), + }); + }); +}); +``` + +- [ ] **Step 7: Run runtime tests to verify they fail** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/claude-code-runtime.test.ts +``` + +Expected: FAIL because `claude-code-runtime.ts` does not exist. + +- [ ] **Step 8: Implement Claude Code runtime** + +Create `packages/context/src/llm/claude-code-runtime.ts` with these exported +types and functions: + +```ts +import { + createSdkMcpServer, + query as defaultQuery, + type Options, + type SDKMessage, + type SDKResultMessage, +} from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { noopLogger, type KtxLogger } from '../core/index.js'; +import type { RunLoopParams, RunLoopResult, RunLoopStopReason } from './runtime-port.js'; +import { createKtxClaudeCodeEnv } from './claude-code-env.js'; +import { resolveClaudeCodeModel } from './claude-code-models.js'; +import { createClaudeSdkTools, mcpToolIds } from './runtime-tools.js'; +import type { KtxGenerateObjectInput, KtxGenerateTextInput, KtxLlmRuntimePort, KtxRuntimeToolSet } from './runtime-port.js'; + +type QueryFn = typeof defaultQuery; + +export interface ClaudeCodeKtxLlmRuntimeDeps { + projectDir: string; + modelSlots: { default: string } & Partial>; + query?: QueryFn; + env?: NodeJS.ProcessEnv; + logger?: KtxLogger; +} + +const BUILTIN_TOOLS = [ + 'Agent', + 'Task', + 'AskUserQuestion', + 'Bash', + 'Read', + 'Edit', + 'Write', + 'Glob', + 'Grep', + 'WebFetch', + 'WebSearch', + 'TodoWrite', +]; + +function isResult(message: SDKMessage): message is SDKResultMessage { + return message.type === 'result'; +} + +function resultError(result: SDKResultMessage): Error | undefined { + if (result.subtype === 'success') return undefined; + const details = result.errors.length > 0 ? `: ${result.errors.join('; ')}` : ''; + return new Error(`Claude Code query failed (${result.subtype})${details}`); +} + +export function mapClaudeCodeStopReason(result: SDKResultMessage): RunLoopStopReason { + if (result.subtype === 'error_max_turns') return 'budget'; + if (result.subtype === 'success') return result.terminal_reason && result.terminal_reason !== 'completed' ? result.terminal_reason === 'max_turns' ? 'budget' : 'error' : 'natural'; + if (result.terminal_reason === 'max_turns') return 'budget'; + if (result.stop_reason === 'max_turns') return 'budget'; + return 'error'; +} + +function jsonSchema(schema: z.ZodType): Record { + return z.toJSONSchema(schema, { target: 'draft-7' }) as Record; +} + +function modelForRole(modelSlots: ClaudeCodeKtxLlmRuntimeDeps['modelSlots'], role: string): string { + return resolveClaudeCodeModel(modelSlots[role] ?? modelSlots.default); +} + +function assertInitIsolation(message: SDKMessage, allowedToolIds: Set): void { + if (message.type !== 'system' || message.subtype !== 'init') return; + const unexpectedTools = message.tools.filter((tool) => !allowedToolIds.has(tool)); + if (unexpectedTools.length > 0 || message.slash_commands.length > 0 || message.skills.length > 0 || message.plugins.length > 0) { + throw new Error( + `Claude Code runtime isolation failed: tools=${unexpectedTools.join(',') || '(none)'} slash_commands=${message.slash_commands.length} skills=${message.skills.length} plugins=${message.plugins.length}`, + ); + } +} + +function baseOptions(input: { + projectDir: string; + model: string; + env: NodeJS.ProcessEnv | undefined; + maxTurns: number; + tools?: KtxRuntimeToolSet; +}): Options { + const toolIds = mcpToolIds(input.tools ?? {}); + const allowedToolIds = new Set(toolIds); + return { + cwd: input.projectDir, + model: input.model, + maxTurns: input.maxTurns, + settingSources: [], + skills: [], + plugins: [], + tools: [], + allowedTools: toolIds, + disallowedTools: BUILTIN_TOOLS, + canUseTool: async (toolName, _toolInput, options) => + allowedToolIds.has(toolName) + ? { behavior: 'allow', toolUseID: options.toolUseID } + : { + behavior: 'deny', + message: `KTX claude-code runtime only permits current KTX MCP tools; denied ${toolName}.`, + toolUseID: options.toolUseID, + }, + permissionMode: 'dontAsk', + persistSession: false, + env: createKtxClaudeCodeEnv(input.env), + ...(input.tools && Object.keys(input.tools).length > 0 + ? { mcpServers: { ktx: createSdkMcpServer({ name: 'ktx', tools: createClaudeSdkTools(input.tools) }) } } + : {}), + }; +} + +async function collectResult(params: { + query: QueryFn; + prompt: string; + options: Options; + allowedToolIds: Set; + onAssistantTurn?: () => Promise; +}): Promise { + let result: SDKResultMessage | undefined; + for await (const message of params.query({ prompt: params.prompt, options: params.options })) { + assertInitIsolation(message, params.allowedToolIds); + if (message.type === 'assistant' && message.parent_tool_use_id === null) { + await params.onAssistantTurn?.(); + } + if (isResult(message)) { + result = message; + } + } + if (!result) { + throw new Error('Claude Code query returned no result message'); + } + return result; +} + +export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort { + private readonly runQuery: QueryFn; + private readonly logger: KtxLogger; + + constructor(private readonly deps: ClaudeCodeKtxLlmRuntimeDeps) { + this.runQuery = deps.query ?? defaultQuery; + this.logger = deps.logger ?? noopLogger; + } + + async generateText(input: KtxGenerateTextInput): Promise { + const options = baseOptions({ + projectDir: this.deps.projectDir, + model: modelForRole(this.deps.modelSlots, input.role), + env: this.deps.env, + maxTurns: 1, + tools: input.tools, + }); + const result = await collectResult({ + query: this.runQuery, + prompt: [input.system, input.prompt].filter(Boolean).join('\n\n'), + options, + allowedToolIds: new Set(mcpToolIds(input.tools ?? {})), + }); + const error = resultError(result); + if (error) throw error; + return result.result; + } + + async generateObject>( + input: KtxGenerateObjectInput, + ): Promise { + const options = { + ...baseOptions({ + projectDir: this.deps.projectDir, + model: modelForRole(this.deps.modelSlots, input.role), + env: this.deps.env, + maxTurns: 1, + tools: input.tools, + }), + outputFormat: { type: 'json_schema' as const, schema: jsonSchema(input.schema as z.ZodType) }, + }; + const result = await collectResult({ + query: this.runQuery, + prompt: [input.system, input.prompt].filter(Boolean).join('\n\n'), + options, + allowedToolIds: new Set(mcpToolIds(input.tools ?? {})), + }); + const error = resultError(result); + if (error) throw error; + return (input.schema as z.ZodType).parse(result.structured_output); + } + + async runAgentLoop(params: RunLoopParams): Promise { + let stepIndex = 0; + try { + const options = baseOptions({ + projectDir: this.deps.projectDir, + model: modelForRole(this.deps.modelSlots, params.modelRole), + env: this.deps.env, + maxTurns: params.stepBudget, + tools: params.toolSet, + }); + const result = await collectResult({ + query: this.runQuery, + prompt: params.userPrompt, + options: { ...options, systemPrompt: params.systemPrompt }, + allowedToolIds: new Set(mcpToolIds(params.toolSet)), + onAssistantTurn: async () => { + stepIndex += 1; + if (!params.onStepFinish) return; + try { + await params.onStepFinish({ stepIndex, stepBudget: params.stepBudget }); + } catch (error) { + this.logger.warn(`[claude-code-runner] onStepFinish callback threw; ignoring: ${error instanceof Error ? error.message : String(error)}`); + } + }, + }); + const stopReason = mapClaudeCodeStopReason(result); + return { stopReason, ...(stopReason === 'error' && resultError(result) ? { error: resultError(result) } : {}) }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + return { stopReason: 'error', error: err }; + } + } +} + +export async function runClaudeCodeAuthProbe(input: { + projectDir: string; + model: string; + query?: QueryFn; + env?: NodeJS.ProcessEnv; +}): Promise<{ ok: true } | { ok: false; message: string }> { + try { + const options = baseOptions({ + projectDir: input.projectDir, + model: resolveClaudeCodeModel(input.model), + env: input.env, + maxTurns: 1, + }); + const result = await collectResult({ + query: input.query ?? defaultQuery, + prompt: 'Reply with exactly: ok', + options, + allowedToolIds: new Set(), + }); + const error = resultError(result); + if (error) throw error; + return { ok: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + message: `Claude Code authentication is not usable. Authenticate Claude Code locally with the Claude Code CLI, then rerun setup or the command. ${message}`, + }; + } +} +``` + +- [ ] **Step 9: Export Claude Code runtime modules** + +In `packages/context/src/llm/index.ts`, add: + +```ts +export { createKtxClaudeCodeEnv, CLAUDE_CODE_PROVIDER_ENV_DENYLIST } from './claude-code-env.js'; +export { resolveClaudeCodeModel } from './claude-code-models.js'; +export { ClaudeCodeKtxLlmRuntime, mapClaudeCodeStopReason, runClaudeCodeAuthProbe } from './claude-code-runtime.js'; +``` + +- [ ] **Step 10: Run runtime tests** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/claude-code-env.test.ts src/llm/claude-code-models.test.ts src/llm/claude-code-runtime.test.ts +``` + +Expected: all selected tests pass. + +- [ ] **Step 11: Commit** + +```bash +git add packages/context/src/llm/claude-code-env.ts packages/context/src/llm/claude-code-env.test.ts packages/context/src/llm/claude-code-models.ts packages/context/src/llm/claude-code-models.test.ts packages/context/src/llm/claude-code-runtime.ts packages/context/src/llm/claude-code-runtime.test.ts packages/context/src/llm/index.ts +git commit -m "feat: add claude-code llm runtime" +``` + +### Task 4: Local Runtime Factory and Non-Agent LLM Call Sites + +**Files:** + +- Create: `packages/context/src/llm/runtime-local-config.test.ts` +- Modify: `packages/context/src/llm/local-config.ts` +- Modify: `packages/context/src/llm/index.ts` +- Modify: `packages/context/src/ingest/page-triage/page-triage.service.ts` +- Modify: `packages/context/src/ingest/page-triage/page-triage.service.test.ts` +- Modify: `packages/context/src/scan/description-generation.ts` +- Modify: `packages/context/src/scan/description-generation.test.ts` +- Modify: `packages/context/src/scan/relationship-llm-proposal.ts` +- Modify: `packages/context/src/scan/relationship-llm-proposal.test.ts` +- Modify: `packages/context/src/scan/local-enrichment.ts` +- Modify: `packages/context/src/scan/local-scan.ts` +- Modify: `packages/context/src/scan/local-scan.test.ts` + +- [ ] **Step 1: Write failing local runtime factory tests** + +Create `packages/context/src/llm/runtime-local-config.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { createLocalKtxLlmProviderFromConfig, createLocalKtxLlmRuntimeFromConfig } from './local-config.js'; + +describe('local KTX LLM runtime config', () => { + it('creates a Claude Code runtime for claude-code backend without creating an AI SDK provider', () => { + const runtime = createLocalKtxLlmRuntimeFromConfig( + { + provider: { backend: 'claude-code' }, + models: { default: 'sonnet', triage: 'haiku' }, + }, + { env: {}, projectDir: '/tmp/project', createClaudeCodeRuntime: vi.fn((deps) => ({ deps })) }, + ); + + expect(runtime).toMatchObject({ deps: expect.objectContaining({ projectDir: '/tmp/project' }) }); + }); + + it('returns null from the AI SDK provider factory for claude-code backend', () => { + expect( + createLocalKtxLlmProviderFromConfig({ + provider: { backend: 'claude-code' }, + models: { default: 'sonnet' }, + }), + ).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/runtime-local-config.test.ts +``` + +Expected: FAIL because `createLocalKtxLlmRuntimeFromConfig` does not exist. + +- [ ] **Step 3: Implement the local runtime factory** + +In `packages/context/src/llm/local-config.ts`, extend `LocalConfigDeps`: + +```ts + projectDir?: string; + createClaudeCodeRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort; + createAiSdkRuntime?: (deps: { llmProvider: KtxLlmProvider }) => KtxLlmRuntimePort; +``` + +Add imports: + +```ts +import { AiSdkKtxLlmRuntime } from './ai-sdk-runtime.js'; +import { ClaudeCodeKtxLlmRuntime } from './claude-code-runtime.js'; +import type { KtxLlmRuntimePort } from './runtime-port.js'; +``` + +Update `createLocalKtxLlmProviderFromConfig`: + +```ts + const resolved = resolveLocalKtxLlmConfig(config, deps.env ?? process.env); + if (!resolved || resolved.backend === 'claude-code') { + return null; + } + return (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved); +``` + +Add `createLocalKtxLlmRuntimeFromConfig`: + +```ts +export function createLocalKtxLlmRuntimeFromConfig( + config: KtxProjectLlmConfig, + deps: LocalConfigDeps = {}, +): KtxLlmRuntimePort | null { + const resolved = resolveLocalKtxLlmConfig(config, deps.env ?? process.env); + if (!resolved) { + return null; + } + if (resolved.backend === 'claude-code') { + const projectDir = deps.projectDir; + if (!projectDir) { + throw new Error('projectDir is required when creating the claude-code LLM runtime'); + } + return (deps.createClaudeCodeRuntime ?? ((runtimeDeps) => new ClaudeCodeKtxLlmRuntime(runtimeDeps)))({ + projectDir, + modelSlots: resolved.modelSlots, + env: deps.env, + }); + } + const llmProvider = (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved); + return (deps.createAiSdkRuntime ?? ((runtimeDeps) => new AiSdkKtxLlmRuntime(runtimeDeps)))({ llmProvider }); +} +``` + +Export it from `packages/context/src/llm/index.ts`: + +```ts +export { createLocalKtxLlmRuntimeFromConfig } from './local-config.js'; +``` + +- [ ] **Step 4: Migrate page triage to runtime text generation** + +In `packages/context/src/ingest/page-triage/page-triage.service.ts`, replace +the dependency: + +```ts +import type { KtxLlmRuntimePort } from '../../llm/index.js'; +``` + +```ts + llmRuntime: KtxLlmRuntimePort; +``` + +Replace `callModel` with: + +```ts + private async callModel(params: { + operationName: 'page-triage' | 'light-extraction'; + system: string; + prompt: string; + sourceKey: string; + jobId: string; + unitKey: string; + }): Promise { + return this.deps.llmRuntime.generateText({ + role: 'triage', + system: params.system, + prompt: params.prompt, + temperature: 0, + }); + } +``` + +In `packages/context/src/ingest/page-triage/page-triage.service.test.ts`, replace +provider fakes with: + +```ts +const llmRuntime = { + generateText: vi.fn(async () => JSON.stringify({ action: 'keep', confidence: 0.9, reason: 'relevant' })), + generateObject: vi.fn(), + runAgentLoop: vi.fn(), +}; +``` + +Assert the runtime call: + +```ts +expect(llmRuntime.generateText).toHaveBeenCalledWith(expect.objectContaining({ role: 'triage' })); +``` + +- [ ] **Step 5: Migrate scan text and object generation** + +In `packages/context/src/scan/description-generation.ts`, replace the provider +field with: + +```ts +import type { KtxLlmRuntimePort } from '../llm/index.js'; +``` + +```ts + llmRuntime: KtxLlmRuntimePort; +``` + +Update `generateAiDescription`: + +```ts + const text = await generateKtxText({ + runtime: this.llmRuntime, + role: 'candidateExtraction', + system: prompt.system, + prompt: prompt.user, + temperature: this.settings.temperature, + }); +``` + +In `packages/context/src/scan/relationship-llm-proposal.ts`, change the input: + +```ts + llmRuntime: KtxLlmRuntimePort | null; +``` + +Remove `modelIsDeterministic` and skip only when `!input.llmRuntime`. Replace +the generation call: + +```ts + const generated = await generateKtxObject< + KtxRelationshipLlmProposalOutput, + typeof relationshipLlmProposalSchema + >({ + runtime: input.llmRuntime, + role: 'candidateExtraction', + system, + prompt, + schema: relationshipLlmProposalSchema, + }); +``` + +In `packages/context/src/scan/local-scan.ts`, create runtime providers for LLM +mode: + +```ts +const llmRuntime = createLocalKtxLlmRuntimeFromConfig(llmConfig, { + ...deps, + projectDir: deps.projectDir, +}); +``` + +Thread `llmRuntime` through `KtxLocalScanEnrichmentProviders`. + +- [ ] **Step 6: Update non-agent tests** + +In each changed test file, use this runtime fake when the test needs LLM output: + +```ts +const runtime = { + generateText: vi.fn(async () => 'Generated description'), + generateObject: vi.fn(async () => ({ relationships: [] })), + runAgentLoop: vi.fn(), +}; +``` + +Update assertions from `llmProvider.getModel` to the operation used: + +```ts +expect(runtime.generateText).toHaveBeenCalledWith(expect.objectContaining({ role: 'candidateExtraction' })); +expect(runtime.generateObject).toHaveBeenCalledWith(expect.objectContaining({ role: 'candidateExtraction' })); +``` + +- [ ] **Step 7: Run non-agent runtime tests** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/llm/runtime-local-config.test.ts src/ingest/page-triage/page-triage.service.test.ts src/scan/description-generation.test.ts src/scan/relationship-llm-proposal.test.ts src/scan/local-scan.test.ts +``` + +Expected: all selected tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add packages/context/src/llm/local-config.ts packages/context/src/llm/index.ts packages/context/src/llm/runtime-local-config.test.ts packages/context/src/ingest/page-triage/page-triage.service.ts packages/context/src/ingest/page-triage/page-triage.service.test.ts packages/context/src/scan/description-generation.ts packages/context/src/scan/description-generation.test.ts packages/context/src/scan/relationship-llm-proposal.ts packages/context/src/scan/relationship-llm-proposal.test.ts packages/context/src/scan/local-enrichment.ts packages/context/src/scan/local-scan.ts packages/context/src/scan/local-scan.test.ts +git commit -m "feat: route non-agent llm calls through runtime" +``` + +### Task 5: Agent Loops, Local Ingest, Memory, and MCP Ingest + +**Files:** + +- Modify: `packages/context/src/ingest/local-bundle-runtime.ts` +- Modify: `packages/context/src/ingest/local-bundle-runtime.test.ts` +- Modify: `packages/context/src/ingest/local-ingest.ts` +- Modify: `packages/context/src/ingest/ports.ts` +- Modify: `packages/context/src/ingest/stages/stage-3-work-units.ts` +- Modify: `packages/context/src/ingest/stages/stage-3-work-units.test.ts` +- Modify: `packages/context/src/ingest/stages/stage-4-reconciliation.ts` +- Modify: `packages/context/src/ingest/stages/stage-4-reconciliation.test.ts` +- Modify: `packages/context/src/ingest/context-candidates/curator-pagination.service.ts` +- Modify: `packages/context/src/ingest/ingest-bundle.runner.ts` +- Modify: `packages/context/src/ingest/stages/build-wu-context.ts` +- Modify: `packages/context/src/ingest/stages/build-reconcile-context.ts` +- Modify: `packages/context/src/memory/types.ts` +- Modify: `packages/context/src/memory/local-memory.ts` +- Modify: `packages/context/src/memory/memory-agent.service.ts` +- Modify: `packages/context/src/memory/memory-agent.service.ingest.test.ts` +- Modify: `packages/context/src/mcp/local-project-ports.ts` +- Modify: `packages/context/src/mcp/local-project-ports.test.ts` + +- [ ] **Step 1: Write failing runtime injection tests** + +Add this test to `packages/context/src/ingest/local-bundle-runtime.test.ts`: + +```ts +it('uses a runtime-backed agent runner when claude-code is configured', () => { + const runtime = { + generateText: vi.fn(), + generateObject: vi.fn(), + runAgentLoop: vi.fn(async () => ({ stopReason: 'natural' as const })), + }; + project.config.llm = { + provider: { backend: 'claude-code' }, + models: { default: 'sonnet' }, + }; + const createLlmRuntime = vi.fn(() => runtime); + + const created = createLocalBundleIngestRuntime({ + project, + adapters: [], + createLlmRuntime, + }); + + expect(created).toBeDefined(); + expect(createLlmRuntime).toHaveBeenCalledWith( + project.config.llm, + expect.objectContaining({ projectDir: project.projectDir }), + ); +}); +``` + +Add this test to `packages/context/src/memory/memory-agent.service.ingest.test.ts`: + +```ts +it('normalizes load_skill output to markdown while preserving structured payload', async () => { + const agentRunner = { + runLoop: vi.fn(async (params) => { + const result = await params.toolSet.load_skill.execute({ name: 'memory_agent' }); + expect(result.markdown).toContain('memory_agent'); + expect(result.structured).toMatchObject({ name: 'memory_agent' }); + return { stopReason: 'natural' as const }; + }), + }; + const mocks = buildMocks({ + agentRunner, + skillsRegistry: { + listSkills: vi.fn().mockResolvedValue([{ name: 'memory_agent', path: '/tmp/skills/memory_agent' }]), + buildSkillsPrompt: vi.fn().mockReturnValue(''), + getSkill: vi.fn().mockResolvedValue({ name: 'memory_agent', path: '/tmp/skills/memory_agent' }), + stripFrontmatter: vi.fn().mockReturnValue('Skill body'), + }, + }); + const svc = buildService(mocks); + + await svc.ingest(baseInput); + expect(agentRunner.runLoop).toHaveBeenCalled(); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/ingest/local-bundle-runtime.test.ts src/memory/memory-agent.service.ingest.test.ts +``` + +Expected: FAIL because runtime injection and runtime tool descriptors are not +wired through local ingest and memory. + +- [ ] **Step 3: Change agent-runner dependency types to the port** + +Replace imports of concrete `AgentRunnerService` where services only call +`runLoop`: + +```ts +import type { AgentRunnerPort } from '../llm/index.js'; +``` + +or, from nested ingest stage files: + +```ts +import type { AgentRunnerPort } from '../../llm/index.js'; +``` + +Change fields such as: + +```ts + agentRunner: AgentRunnerService; +``` + +to: + +```ts + agentRunner: AgentRunnerPort; +``` + +Apply this in `ports.ts`, `stage-3-work-units.ts`, +`stage-4-reconciliation.ts`, `curator-pagination.service.ts`, and +`memory/types.ts`. + +- [ ] **Step 4: Create runtime-backed local ingest runners** + +In `packages/context/src/ingest/local-bundle-runtime.ts`, add runtime factory +support: + +```ts +import { + createLocalKtxLlmRuntimeFromConfig, + RuntimeAgentRunner, + type KtxLlmRuntimePort, +} from '../llm/index.js'; +``` + +Extend `CreateLocalBundleIngestRuntimeOptions`: + +```ts + llmRuntime?: KtxLlmRuntimePort; + createLlmRuntime?: typeof createLocalKtxLlmRuntimeFromConfig; +``` + +Replace `resolveAgentRunner` with: + +```ts +function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): { + agentRunner: AgentRunnerPort; + llmRuntime?: KtxLlmRuntimePort; +} { + const llmRuntime = + options.llmRuntime ?? + (options.createLlmRuntime ?? createLocalKtxLlmRuntimeFromConfig)(options.project.config.llm, { + projectDir: options.project.projectDir, + env: process.env, + }) ?? + undefined; + + if (options.agentRunner) { + return { agentRunner: options.agentRunner, ...(llmRuntime ? { llmRuntime } : {}) }; + } + + if (!llmRuntime) { + throw new Error(localIngestLlmProviderGuardMessage(options.project.projectDir)); + } + + return { + agentRunner: new RuntimeAgentRunner(llmRuntime), + llmRuntime, + }; +} +``` + +Update the guard message: + +```ts +'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.' +``` + +Pass `llmRuntime` to `PageTriageService`: + +```ts + pageTriage: llmRuntime + ? new PageTriageService({ + store: contextStore, + llmRuntime, + settings: { + enabled: true, + maxConcurrency: 2, + lightExtractionEnabled: true, + classifierModel: null, + lightExtractionMaxCandidates: 5, + }, + promptService, + logger, + }) + : undefined, +``` + +- [ ] **Step 5: Normalize BaseTool factory outputs** + +In `packages/context/src/ingest/local-bundle-runtime.ts`, replace +`toAiSdkTool(context)` with `toRuntimeTool(context)`: + +```ts +return { + ...Object.fromEntries(this.tools.map((tool) => [tool.name, tool.toRuntimeTool(context)])), + ...this.sourceTools, +}; +``` + +In `packages/context/src/memory/local-memory.ts`, make the same replacement: + +```ts +return Object.fromEntries(this.tools.map((tool) => [tool.name, tool.toRuntimeTool(context)])); +``` + +Update `MemoryToolSetLike` in `packages/context/src/memory/types.ts`: + +```ts +toRuntimeTools(context: ToolContext): KtxRuntimeToolSet; +``` + +- [ ] **Step 6: Convert inline ingest and memory tools to runtime descriptors** + +In `packages/context/src/ingest/ingest-bundle.runner.ts`, replace each inline +`tool(...)` wrapper with a `KtxRuntimeToolDescriptor`. For `load_skill`, use: + +```ts +const loadSkillTool: KtxRuntimeToolSet = { + load_skill: { + name: 'load_skill', + description: + 'Load a skill to get specialized instructions. Call this when a skill listed in the system prompt matches the current task.', + inputSchema: z.object({ name: z.string() }), + execute: async ({ name }) => { + const skill = await this.deps.skillsRegistry.getSkill(name, 'memory_agent'); + if (!skill) { + const available = + (await this.deps.skillsRegistry.listSkills('memory_agent')).map((s) => s.name).join(', ') || '(none)'; + return { markdown: `Skill "${name}" not available. Available: ${available}` }; + } + const body = await readFile(join(skill.path, 'SKILL.md'), 'utf-8'); + if (!skillsLoadedPerWu.includes(skill.name)) { + skillsLoadedPerWu.push(skill.name); + } + const structured = { + name: skill.name, + skillDirectory: skill.path, + content: this.deps.skillsRegistry.stripFrontmatter(body), + }; + return { + markdown: `# ${structured.name}\n\n${structured.content}`, + structured, + }; + }, + }, +}; +``` + +Use the same shape for reconciliation `rcLoadSkill`, including +`skillDirectory` in the structured payload. + +In `packages/context/src/memory/memory-agent.service.ts`, use: + +```ts +const loadSkillTool: KtxRuntimeToolSet = { + load_skill: { + name: 'load_skill', + description: + 'Load a skill to get specialized instructions. Call this when a skill listed in the system prompt matches the current task.', + inputSchema: z.object({ + name: z.string().describe('The skill name as listed in the system prompt.'), + }), + execute: async ({ name }) => { + const skill = await this.deps.skillsRegistry.getSkill(name, 'memory_agent'); + if (!skill) { + const available = + (await this.deps.skillsRegistry.listSkills('memory_agent')).map((s) => s.name).join(', ') || '(none)'; + return { markdown: `Skill "${name}" not available to the memory agent. Available: ${available}` }; + } + try { + const body = await readFile(join(skill.path, 'SKILL.md'), 'utf-8'); + if (!skillsLoaded.includes(skill.name)) { + skillsLoaded.push(skill.name); + } + const structured = { + name: skill.name, + skillDirectory: skill.path, + content: this.deps.skillsRegistry.stripFrontmatter(body), + }; + return { + markdown: `# ${structured.name}\n\n${structured.content}`, + structured, + }; + } catch (error) { + return { markdown: `Error loading skill "${name}": ${error instanceof Error ? error.message : String(error)}` }; + } + }, + }, +}; +``` + +- [ ] **Step 7: Convert ingest tool helper factories** + +For every helper under `packages/context/src/ingest/tools/*.tool.ts` that +currently returns `tool({ ... })`, change the return type to +`KtxRuntimeToolDescriptor` and return: + +```ts +return { + name: '', + description: '', + inputSchema, + execute: async (input) => normalizeKtxRuntimeToolOutput(await existingExecution(input)), +}; +``` + +Apply this to: + +- `stage-diff.tool.ts` +- `stage-list.tool.ts` +- `eviction-list.tool.ts` +- `read-raw-file.tool.ts` +- `read-raw-span.tool.ts` +- `emit-conflict-resolution.tool.ts` +- `emit-eviction-decision.tool.ts` +- `emit-artifact-resolution.tool.ts` +- `emit-unmapped-fallback.tool.ts` +- `verification-ledger.tool.ts` +- `adapters/historic-sql/evidence-tool.ts` +- `adapters/looker/tools/looker-query-to-sl.tool.ts` + +- [ ] **Step 8: Pass runtime through local ingest and MCP trigger options** + +In `packages/context/src/ingest/local-ingest.ts`, add `llmRuntime?: KtxLlmRuntimePort` +to local ingest options and pass it into `createLocalBundleIngestRuntime`. + +In `packages/context/src/mcp/local-project-ports.ts`, pass +`options.localIngest?.llmRuntime` into `runLocalMetabaseIngest` and +`runLocalIngest`: + +```ts +llmRuntime: options.localIngest?.llmRuntime, +``` + +Update `packages/context/src/ingest/ports.ts` to expose `llmRuntime` beside +`agentRunner` only for local wiring. + +- [ ] **Step 9: Run agent-loop tests** + +Run: + +```bash +pnpm --filter @ktx/context exec vitest run src/ingest/stages/stage-3-work-units.test.ts src/ingest/stages/stage-4-reconciliation.test.ts src/ingest/local-bundle-runtime.test.ts src/memory/memory-agent.service.ingest.test.ts src/mcp/local-project-ports.test.ts +``` + +Expected: all selected tests pass, and test fakes call `runLoop` through +`AgentRunnerPort`. + +- [ ] **Step 10: Commit** + +```bash +git add packages/context/src/ingest/local-bundle-runtime.ts packages/context/src/ingest/local-bundle-runtime.test.ts packages/context/src/ingest/local-ingest.ts packages/context/src/ingest/ports.ts packages/context/src/ingest/stages/stage-3-work-units.ts packages/context/src/ingest/stages/stage-3-work-units.test.ts packages/context/src/ingest/stages/stage-4-reconciliation.ts packages/context/src/ingest/stages/stage-4-reconciliation.test.ts packages/context/src/ingest/context-candidates/curator-pagination.service.ts packages/context/src/ingest/ingest-bundle.runner.ts packages/context/src/ingest/stages/build-wu-context.ts packages/context/src/ingest/stages/build-reconcile-context.ts packages/context/src/ingest/tools packages/context/src/memory/types.ts packages/context/src/memory/local-memory.ts packages/context/src/memory/memory-agent.service.ts packages/context/src/memory/memory-agent.service.ingest.test.ts packages/context/src/mcp/local-project-ports.ts packages/context/src/mcp/local-project-ports.test.ts +git commit -m "feat: route agent loops through llm runtime" +``` + +### Task 6: Setup, Status, Doctor, Prompt-Caching Warnings, and Docs + +**Files:** + +- Modify: `packages/cli/src/setup-commands.ts` +- Modify: `packages/cli/src/setup-models.ts` +- Modify: `packages/cli/src/setup-models.test.ts` +- Modify: `packages/cli/src/status-project.ts` +- Modify: `packages/cli/src/doctor.test.ts` +- Modify: `docs-site/content/docs/getting-started/quickstart.mdx` +- Modify: `docs-site/content/docs/cli-reference/ktx-setup.mdx` +- Modify: `docs-site/content/docs/cli-reference/ktx-status.mdx` +- Modify: `docs-site/content/docs/guides/building-context.mdx` +- Create: `docs-site/content/docs/guides/llm-configuration.mdx` +- Modify: `docs-site/content/docs/guides/meta.json` + +- [ ] **Step 1: Write failing CLI tests** + +Add this test to `packages/cli/src/setup-models.test.ts`: + +```ts +it('configures Claude Code backend and validates local auth', async () => { + const io = makeIo(); + const authProbe = vi.fn(async () => ({ ok: true as const })); + + const result = await runKtxSetupAnthropicModelStep( + { + projectDir: tempDir, + inputMode: 'disabled', + llmBackend: 'claude-code', + skipLlm: false, + }, + io.io, + { claudeCodeAuthProbe: authProbe }, + ); + + expect(result.status).toBe('ready'); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.llm).toMatchObject({ + provider: { backend: 'claude-code' }, + models: { default: 'sonnet' }, + }); + expect(authProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'sonnet' })); +}); +``` + +Add this test to `packages/cli/src/doctor.test.ts`: + +```ts +it('reports Claude Code auth failures and ignored prompt-caching fields in project doctor output', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'llm:', + ' provider:', + ' backend: claude-code', + ' models:', + ' default: sonnet', + ' promptCaching:', + ' enabled: true', + ' systemTtl: 1h', + ' toolsTtl: 1h', + ' historyTtl: 5m', + '', + ].join('\n'), + 'utf-8', + ); + const testIo = makeIo(); + + await expect( + runKtxDoctor( + { command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, + testIo.io, + { + claudeCodeAuthProbe: async () => ({ + ok: false as const, + message: 'Authenticate Claude Code locally.', + }), + }, + ), + ).resolves.toBe(1); + + expect(testIo.stdout()).toContain('claude-code'); + expect(testIo.stdout()).toContain('Authenticate Claude Code locally'); + expect(testIo.stdout()).toContain('claude-code ignores llm.promptCaching'); +}); +``` + +- [ ] **Step 2: Run CLI tests to verify they fail** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-models.test.ts src/doctor.test.ts +``` + +Expected: FAIL because CLI backend parsing and status probing do not support +`claude-code`. + +- [ ] **Step 3: Add setup backend parsing and auth probe dependency** + +In `packages/cli/src/setup-models.ts`, update the backend type: + +```ts +export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code'; +``` + +Add the dependency: + +```ts + claudeCodeAuthProbe?: (input: { projectDir: string; model: string; env?: NodeJS.ProcessEnv }) => Promise<{ ok: true } | { ok: false; message: string }>; +``` + +Update `buildProjectLlmConfig` to accept Claude Code: + +```ts + | { backend: 'claude-code' }, +``` + +```ts + if (provider.backend === 'claude-code') { + return { + provider: { backend: 'claude-code' }, + models: { ...existing.models, default: model }, + promptCaching: existing.promptCaching, + }; + } +``` + +When `args.llmBackend === 'claude-code'`, set: + +```ts +const model = args.anthropicModel ?? 'sonnet'; +const probe = deps.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe; +const health = await probe({ projectDir: args.projectDir, model, env: deps.env ?? process.env }); +if (!health.ok) { + io.stderr.write(`${health.message}\n`); + return { status: 'failed', projectDir: args.projectDir }; +} +project.config.llm = buildProjectLlmConfig(project.config.llm, { backend: 'claude-code' }, model); +``` + +In `packages/cli/src/setup-commands.ts`, update the hidden parser to accept +`claude-code`: + +```ts +if (value === 'anthropic' || value === 'vertex' || value === 'claude-code') { + return value; +} +``` + +- [ ] **Step 4: Add status and doctor auth validation** + +In `packages/cli/src/status-project.ts`, extend `BuildProjectStatusOptions`: + +```ts + claudeCodeAuthProbe?: (input: { projectDir: string; model: string; env?: NodeJS.ProcessEnv }) => Promise<{ ok: true } | { ok: false; message: string }>; +``` + +Make `buildLlmStatus` async and add: + +```ts + if (backend === 'claude-code') { + const modelName = model ?? 'sonnet'; + const probe = options.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe; + const auth = await probe({ projectDir, model: modelName, env }); + if (auth.ok) { + return { backend, model: modelName, status: 'ok', detail: 'local Claude Code session authenticated' }; + } + return { + backend, + model: modelName, + status: 'fail', + detail: auth.message, + fix: 'Authenticate Claude Code locally with the Claude Code CLI, then rerun `ktx status`.', + }; + } +``` + +Add prompt-caching warnings: + +```ts +function ignoredClaudeCodePromptCachingFields(config: KtxProjectLlmConfig): string[] { + if (config.provider.backend !== 'claude-code' || !config.promptCaching) return []; + return Object.keys(config.promptCaching).map((key) => `llm.promptCaching.${key}`); +} +``` + +Append this warning in `buildWarnings`: + +```ts +const ignored = ignoredClaudeCodePromptCachingFields(config.llm); +if (ignored.length > 0) { + warnings.push({ + level: 'warn', + message: `claude-code ignores ${ignored.join(', ')} because the Claude Agent SDK does not expose KTX prompt-cache TTL, tool, or history markers.`, + fix: 'Remove those promptCaching fields or use anthropic, vertex, or gateway when those cache knobs are required.', + }); +} +``` + +- [ ] **Step 5: Update docs** + +In `docs-site/content/docs/getting-started/quickstart.mdx`, add this provider +example in the LLM setup section: + +````mdx +To use your local Claude Code session instead of an API key, set: + +```yaml +llm: + provider: + backend: claude-code + models: + default: sonnet + triage: haiku + candidateExtraction: sonnet + curator: sonnet + reconcile: sonnet + repair: sonnet +``` + +`claude-code` uses the Claude Code authentication already configured on your +machine. It doesn't use `ANTHROPIC_API_KEY`, Vertex credentials, AI Gateway +tokens, or Bedrock credentials. +```` + +In `docs-site/content/docs/cli-reference/ktx-setup.mdx`, document: + +```mdx +| `--llm-backend claude-code` | Use the local Claude Code session for KTX LLM calls | - | +``` + +In `docs-site/content/docs/cli-reference/ktx-status.mdx`, add: + +```mdx +For `llm.provider.backend: claude-code`, `ktx status` checks that the local +Claude Code session is usable. If auth fails, run the Claude Code CLI login +flow, then rerun `ktx status`. +``` + +In `docs-site/content/docs/guides/building-context.mdx`, add: + +```mdx +When you use `claude-code`, KTX still controls the tool surface for ingest and +memory capture. Claude Code built-in tools, discovered MCP servers, hooks, +skills, plugins, agents, and slash commands are not exposed to KTX agent loops. +``` + +Create `docs-site/content/docs/guides/llm-configuration.mdx`: + +````mdx +--- +title: LLM configuration +description: Configure KTX LLM providers, model roles, and prompt caching. +--- + +KTX uses the top-level `llm` block in `ktx.yaml` for text generation, +structured extraction, and ingest or memory agent loops. + +## Backends + +Set `llm.provider.backend` to one of these values: + +- `anthropic`: Use the Anthropic API through `ANTHROPIC_API_KEY` or the + configured `api_key` reference. +- `vertex`: Use Vertex AI Anthropic models through Google Cloud credentials. +- `gateway`: Use AI Gateway-compatible Anthropic model ids. +- `claude-code`: Use your local Claude Code session through the Claude Agent + SDK. KTX removes provider-routing environment variables from Claude Code + child processes, so this backend doesn't silently fall back to + `ANTHROPIC_API_KEY`, Vertex, Gateway, or Bedrock credentials. + +## Claude Code + +Use aliases or full Claude model ids in `llm.models`: + +```yaml +llm: + provider: + backend: claude-code + models: + default: sonnet + triage: haiku + candidateExtraction: sonnet + curator: sonnet + reconcile: sonnet + repair: sonnet +``` + +`claude-code` keeps KTX tool boundaries intact. KTX exposes only the MCP tools +needed for the current KTX agent loop and disables Claude Code built-in tools, +filesystem settings, skills, plugins, agents, hooks, and slash commands. + +## Prompt caching + +`llm.promptCaching` has partial parity on `claude-code`. KTX doesn't pass +Anthropic cache-control markers to the Claude Agent SDK. Status and doctor warn +when you configure prompt-cache TTL, tool, or history fields that the Claude +Agent SDK backend ignores. +```` + +In `docs-site/content/docs/guides/meta.json`, add the page: + +```json +{ + "title": "Guides", + "defaultOpen": true, + "pages": ["building-context", "llm-configuration", "writing-context", "serving-agents"] +} +``` + +- [ ] **Step 6: Run CLI and docs tests** + +Run: + +```bash +pnpm --filter @ktx/cli exec vitest run src/setup-models.test.ts src/doctor.test.ts +pnpm --filter ktx-docs run test +``` + +Expected: selected CLI tests and docs tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/setup-commands.ts packages/cli/src/setup-models.ts packages/cli/src/setup-models.test.ts packages/cli/src/status-project.ts packages/cli/src/doctor.test.ts docs-site/content/docs/getting-started/quickstart.mdx docs-site/content/docs/cli-reference/ktx-setup.mdx docs-site/content/docs/cli-reference/ktx-status.mdx docs-site/content/docs/guides/building-context.mdx docs-site/content/docs/guides/llm-configuration.mdx docs-site/content/docs/guides/meta.json +git commit -m "feat: support claude-code setup and status" +``` + +### Task 7: Repo-Wide Audit and Final Verification + +**Files:** + +- Modify: any files still found by the required grep audit. +- Modify: `knip.json` only if the new Agent SDK entrypoints are intentionally + dynamic and Knip cannot infer them. + +- [ ] **Step 1: Run the required LLM call-site audit** + +Run: + +```bash +rg -n "getModel\\(|generateKtxText\\(|generateKtxObject\\(|AgentRunnerService|llmProvider" packages/context packages/cli packages/llm -g '!**/dist/**' +``` + +Expected remaining allowed matches: + +- `packages/llm/src/model-provider.ts` and its tests for AI SDK provider + construction. +- `packages/llm/src/model-health.ts` and its tests for AI SDK health checks. +- `packages/context/src/llm/ai-sdk-runtime.ts` for the AI SDK runtime adapter. +- `packages/context/src/llm/local-config.ts` for provider construction on + non-`claude-code` backends. +- Test fakes where the test name explicitly covers AI SDK provider behavior. + +Every runtime call site under ingest, memory, MCP-triggered ingest, page +triage, scan description generation, and relationship LLM proposals must use +`KtxLlmRuntimePort` or `AgentRunnerPort`. + +- [ ] **Step 2: Fix disallowed audit matches** + +For every disallowed match from Step 1, replace the dependency with one of +these patterns: + +```ts +import type { KtxLlmRuntimePort } from '../llm/index.js'; +``` + +```ts +import type { AgentRunnerPort } from '../llm/index.js'; +``` + +Then call: + +```ts +await runtime.generateText({ role, system, prompt, temperature }); +await runtime.generateObject({ role, system, prompt, schema, temperature }); +await agentRunner.runLoop(params); +``` + +- [ ] **Step 3: Run targeted package tests** + +Run: + +```bash +pnpm --filter @ktx/llm run test +pnpm --filter @ktx/context run test +pnpm --filter @ktx/cli run test +pnpm --filter ktx-docs run test +``` + +Expected: all selected package tests pass. + +- [ ] **Step 4: Run type-checks** + +Run: + +```bash +pnpm run type-check +``` + +Expected: TypeScript compilation passes across packages. + +- [ ] **Step 5: Run build/export verification** + +Run: + +```bash +pnpm run build +``` + +Expected: all package builds pass and exports resolve. + +- [ ] **Step 6: Run dead-code checks** + +Run: + +```bash +pnpm run dead-code +``` + +Expected: Biome and Knip pass. If Knip reports the Agent SDK dependency as +unused despite static imports in `claude-code-runtime.ts`, inspect the report +and fix the import path or package dependency location before adding an ignore. + +- [ ] **Step 7: Run full workspace test** + +Run: + +```bash +pnpm run test +``` + +Expected: workspace tests pass. + +- [ ] **Step 8: Commit verification cleanup** + +```bash +git status --short +git add packages docs-site pnpm-lock.yaml knip.json +git commit -m "test: verify claude-code backend runtime" +``` + +Use `git add packages docs-site pnpm-lock.yaml knip.json` only after inspecting +`git status --short` and confirming every staged file belongs to this backend +implementation. + +## Self-Review + +- Spec coverage: This plan covers first-class config, runtime port, text + generation, structured object generation, agent loops, tool boundaries, exact + MCP ids, `canUseTool`, isolation options, scrubbed environment, auth probe, + stop reason mapping, `onStepFinish`, prompt-caching warnings, setup/status, + docs, and the required grep audit. +- V1-blocking coverage: All blocking gaps from the audit are assigned to Tasks + 1 through 7. +- Non-blocking exclusions: Same-step repair parity, OTEL parity, embedding + parity, persisted Claude sessions, and full prompt-caching parity are + intentionally left outside v1. diff --git a/docs/superpowers/specs/2026-05-15-claude-code-backend-design.md b/docs/superpowers/specs/2026-05-15-claude-code-backend-design.md new file mode 100644 index 00000000..ed139296 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-claude-code-backend-design.md @@ -0,0 +1,698 @@ +# Brainstorm: `claude-code` backend with full KTX LLM parity + +Adds a `claude-code` backend that gives KTX full parity with the existing +`ANTHROPIC_API_KEY`-based `anthropic` backend for **all KTX LLM calls**. The +backend uses `@anthropic-ai/claude-agent-sdk` and reuses the user's existing +local Claude Code authentication. Users select it in `ktx.yaml`. + +This is not an implementation plan. It is the revised design after expanding +the requirement from "`ktx ingest` works with Claude Code" to "every KTX LLM +call works with Claude Code." The follow-up implementation plan should be +written separately. + +## Core decision + +`claude-code` is a first-class global LLM backend. Any code path that currently +works with `llm.provider.backend: anthropic` must work with +`llm.provider.backend: claude-code`, unless it is not an LLM call at all. + +This includes: + +- Agent loops implemented through `AgentRunnerService.runLoop(...)`. +- Text generation through `generateKtxText(...)`. +- Structured object generation through `generateKtxObject(...)`. +- Local ingest and MCP-triggered local ingest flows. +- Page triage and light extraction. +- Context-candidate curation and reconciliation. +- Memory capture. +- Scan/enrichment internals and relationship LLM proposals. +- Future KTX LLM call sites that use the shared runtime boundary. + +Commands that do not use LLMs do not need special Claude Code behavior. There +must be no silent fallback from `claude-code` to gateway, Anthropic API-key +execution, or deterministic output. + +## Goals + +- Let a KTX user run all KTX LLM-backed behavior through their existing local + Claude Code session without provisioning `ANTHROPIC_API_KEY`, Vertex + credentials, or an AI Gateway key. +- Preserve the existing user-facing CLI and MCP behavior. `claude-code` changes + how LLM calls execute, not which KTX workflows exist. +- Preserve role-based model selection. `llm.models.default`, `triage`, + `candidateExtraction`, `curator`, `reconcile`, and `repair` remain the source + of model selection for every LLM call. +- Preserve KTX's curated tool boundaries. Claude Code built-ins, + filesystem-discovered MCP servers, hooks, skills, plugins, agents, and slash + commands must not become invokable in KTX agent loops. The Agent SDK init + message may still report host-discovered slash commands, skills, and agents; + KTX treats that metadata as diagnostic only and restricts execution through + `tools: []`, exact KTX MCP `allowedTools`, `disallowedTools`, and + deny-by-default `canUseTool`. +- Keep embeddings independent. Claude does not provide embeddings; users keep + configuring `ingest.embeddings` and scan/enrichment embeddings as they do + today. +- Fail fast with a clear message if local Claude Code authentication is not + usable. + +## Non-goals + +- **Embedding parity.** Embeddings remain separate from LLM execution. +- **Tool-call repair parity in the first pass.** The AI SDK runner uses + `experimental_repairToolCall` (`packages/llm/src/repair.ts:35-88`). The Claude + Agent SDK has no transparent same-step repair hook. MVP behavior is next-turn + self-correction from schema errors or a normal tool-failure count. +- **OTEL telemetry parity in the first pass.** The AI SDK runner uses + `experimental_telemetry`. The Agent SDK exposes hooks such as + `PostToolUseFailure` and `SessionEnd`, but no drop-in OTEL switch. MVP ships + without telemetry parity on this backend. +- **Productizing Claude subscription limits.** Documentation must frame this as + "use your own local Claude Code session," not as a third-party Claude Max or + Claude.ai product feature. + +## Approaches considered + +### Recommended: global LLM runtime port + +Introduce a backend-neutral KTX LLM runtime port for operations, not just model +construction: + +```ts +interface KtxLlmRuntimePort { + generateText(input: KtxGenerateTextInput): Promise; + generateObject(input: KtxGenerateObjectInput): Promise; + runAgentLoop(params: RunLoopParams): Promise; +} +``` + +The existing `anthropic`, `vertex`, and `gateway` backends implement the runtime +through the AI SDK and existing `KtxLlmProvider`. The new `claude-code` backend +implements the same runtime through `@anthropic-ai/claude-agent-sdk`. + +This is the recommended approach because KTX call sites need operations: +"generate text," "generate a structured object," and "run an agent loop." They +do not inherently need direct access to an AI SDK `LanguageModel`. The Agent SDK +is a session/agent API, not an AI SDK model factory, so the runtime port avoids +pretending those APIs are the same. + +### Rejected: fake AI SDK `LanguageModel` for Claude Code + +Trying to make Claude Code look like an AI SDK `LanguageModel` would be brittle. +The Agent SDK owns session execution, permissions, MCP tools, structured output, +and result messages. Those semantics do not map cleanly onto a normal +`getModel(...)` return value. + +### Rejected: branch at every call site + +Adding `if backend === "claude-code"` around each LLM call would work briefly +but would duplicate prompt wrapping, structured output handling, debug logging, +tool conversion, auth checks, and error mapping. It would also make future LLM +call sites easy to miss. + +## Architecture + +```text +ktx.yaml + llm.provider.backend: anthropic | vertex | gateway | claude-code + llm.models.: model alias or model ID + +createLocalKtxLlmRuntimeFromConfig(project.config.llm) + -> AiSdkKtxLlmRuntime + - wraps existing KtxLlmProvider + - generateText / Output.object / AgentRunnerService + -> ClaudeCodeKtxLlmRuntime + - uses @anthropic-ai/claude-agent-sdk query() + - implements text, object, and agent-loop operations + +All KTX LLM call sites + -> KtxLlmRuntimePort +``` + +The runtime is selected at the same boundaries that currently construct an +`llmProvider` or `AgentRunnerService`: + +- `packages/context/src/llm/local-config.ts` +- `packages/context/src/ingest/local-bundle-runtime.ts` +- `packages/context/src/memory/local-memory.ts` +- `packages/context/src/scan/local-scan.ts` +- `packages/context/src/mcp/local-project-ports.ts` +- Any CLI setup/status/doctor code that validates LLM readiness + +After the change, services should not need to know whether the configured +backend is AI SDK based or Claude Code based. They call the runtime operation +they need. + +## LLM call-site migration + +The implementation plan must migrate every current KTX LLM call site to the +runtime port: + +- `packages/context/src/llm/generation.ts`: `generateKtxText` and + `generateKtxObject` become runtime-backed helpers or are folded into the + runtime. +- `packages/context/src/agent/agent-runner.service.ts`: the AI SDK agent loop + becomes the AI SDK implementation of `runAgentLoop`. +- `packages/context/src/ingest/page-triage/page-triage.service.ts`: page triage + and light extraction depend on `KtxLlmRuntimePort`, not raw `KtxLlmProvider`. +- `packages/context/src/scan/description-generation.ts`: AI descriptions use + the runtime text-generation operation. +- `packages/context/src/scan/relationship-llm-proposal.ts`: relationship + proposals use the runtime object-generation operation. +- `packages/context/src/ingest/stages/stage-3-work-units.ts`, + `packages/context/src/ingest/stages/stage-4-reconciliation.ts`, + `packages/context/src/ingest/context-candidates/curator-pagination.service.ts`, + and `packages/context/src/memory/memory-agent.service.ts`: agent loops use the + runtime agent-loop operation or a thin `AgentRunnerPort` backed by it. +- Test helpers and MCP local project ports that inject `llmProvider` or + `agentRunner` must either inject the runtime port or use compatibility test + adapters during the migration. + +The plan must include a grep-based audit so new or overlooked `getModel(...)`, +`generateKtxText(...)`, `generateKtxObject(...)`, `AgentRunnerService`, and +`llmProvider` usages are either migrated or explicitly proven non-runtime. + +## Config design + +The config should make `claude-code` a first-class backend: + +```yaml +llm: + provider: + backend: claude-code + models: + default: sonnet + triage: haiku + candidateExtraction: sonnet + curator: sonnet + reconcile: sonnet + repair: sonnet +``` + +Implementation implications: + +- Extend `KTX_LLM_BACKENDS` in `packages/context/src/project/config.ts` and + `KtxLlmBackend` in `packages/llm/src/types.ts`. +- Update setup, status, doctor, schema generation, examples, and docs so + `claude-code` is understood everywhere `anthropic` is understood. +- Update `createKtxLlmProvider` / `createModelFactory` so unsupported backend + values throw instead of falling through to gateway. +- Keep `llm.models` as the per-role binding source. The Claude Code runtime maps + each KTX role to the configured model string for the current call. +- Define accepted model aliases, such as `sonnet`, `opus`, and `haiku`, and full + model IDs supported by the pinned SDK version. + +## Claude Agent SDK runtime behavior + +Every Agent SDK call must be isolated enough for KTX execution. Use explicit +options even when SDK defaults currently match the desired value. + +For agent loops with tools: + +```ts +query({ + prompt, + options: { + cwd: project.projectDir, + systemPrompt, + model: resolveModel(modelRole), + maxTurns: stepBudget, + settingSources: [], + skills: [], + plugins: [], + mcpServers: { ktx: createSdkMcpServer({ name: "ktx", tools }) }, + tools: [], + allowedTools: [/* exact mcp__ktx__ ids generated from the tool map */], + canUseTool: ktxCanUseTool, + permissionMode: "dontAsk", + persistSession: false, + env: ktxClaudeCodeEnv + } +}); +``` + +`ktxClaudeCodeEnv` is the controlled environment described in +"Agent SDK environment and auth boundary" below; it must be passed on every +KTX `query()` call. + +For plain text generation: + +- Use the same `query()` runtime with `maxTurns: 1`. +- Pass `settingSources: []`, `skills: []`, `plugins: []`, `tools: []`, + `permissionMode: "dontAsk"`, `persistSession: false`, and + `env: ktxClaudeCodeEnv`. +- Do not expose MCP tools unless the KTX call explicitly passed tools. +- Return the final result message text. + +For structured object generation: + +- Use the same `query()` runtime with the Agent SDK structured output option + for JSON schema output, plus the same isolation tuple including + `env: ktxClaudeCodeEnv`. +- Convert KTX Zod schemas at the runtime boundary. +- Parse and validate the returned object with the original KTX schema before + returning it to the caller. + +The plan must confirm the exact option names against the pinned SDK version, but +the required outcome is fixed: + +- Filesystem settings are not loaded. The SDK's documented default for an + omitted `settingSources` is `["user", "project", "local"]` + (`@anthropic-ai/claude-agent-sdk@0.3.142` `sdk.d.ts:1686-1695`), + which would inherit the user's Claude Code filesystem settings. Every KTX + `query()` call site - agent loops, text generation, object generation, and + the auth probe - MUST pass `settingSources: []` explicitly, along with + `skills: []`, `plugins: []`, `tools: []`, `persistSession: false`, and no + `mcpServers` entries other than the KTX MCP server (omitted entirely when + the call site does not expose tools). The implementation MUST assert from + the SDK init message that the controlled execution surface matches KTX's + expectations: + + - `message.tools` equals the exact generated KTX MCP tool ids for the current + call. + - `message.mcp_servers` equals the expected KTX MCP server set: `[]` when the + call exposes no tools, or `["ktx"]` when it does. + - `message.plugins` is empty. + + The implementation MUST NOT reject a run solely because + `message.slash_commands`, `message.skills`, or `message.agents` contain + host-discovered names. In `@anthropic-ai/claude-agent-sdk@0.3.142`, those + fields can report host discovery even when KTX passes the isolation options. + They are not part of the KTX execution surface when `tools: []`, + `allowedTools`, `disallowedTools`, and deny-by-default `canUseTool` are set. +- `skills: []` is a context filter in the pinned SDK + (`sdk.d.ts:1697-1718`): unlisted skills are hidden from the model's skill + listing and rejected by the Skill tool, but discovered skill names may still + appear in init metadata. KTX must still pass `skills: []`. +- Plugins are disabled with `plugins: []`, and the runtime asserts that + `message.plugins` is empty in the init message. +- Built-in tools are disabled by setting `tools: []`. The pinned SDK type + (`@anthropic-ai/claude-agent-sdk@0.3.142`, `sdk.d.ts`) documents `tools` as + the base set of built-in tools, with `[]` meaning "disable all built-ins"; + `tools` does not accept MCP tool ids and cannot be used to restrict MCP + availability. +- MCP tool availability is granted by registering the KTX MCP server through + `mcpServers`. The SDK does not document a wildcard like `mcp__ktx__*` for + any tool field; KTX must enumerate exact generated MCP tool ids of the form + `mcp__ktx__` (derived from the tool map handed to + `createSdkMcpServer`) wherever a list of tool ids is required. +- Pre-approval under `permissionMode: "dontAsk"` is configured by listing those + same exact `mcp__ktx__` ids in `allowedTools` (documented as + auto-allow without prompting). Treat `allowedTools` as auto-approval, not + restriction. +- Defense-in-depth restriction uses `canUseTool`. The KTX runtime supplies a + `canUseTool` handler that allows only tool names in the current KTX MCP tool + map and denies everything else, so host-discovered slash commands, skills, + agents, future SDK defaults, or a misconfigured MCP server cannot expand the + execution surface. +- `disallowedTools` MUST additionally list the current built-in tool names + (`Agent`, `Task`, `AskUserQuestion`, `Bash`, `Read`, `Edit`, `Write`, `Glob`, + `Grep`, `WebFetch`, `WebSearch`, `TodoWrite`) as redundant insurance. +- `cwd` is `project.projectDir`, resolved at startup via `resolveKtxProjectDir`, + not `process.cwd()`. +- Sessions are not persisted unless the plan identifies a concrete debugging + feature that needs persistence. + +## Agent SDK environment and auth boundary + +The Agent SDK's `query()` option `env` (`@anthropic-ai/claude-agent-sdk@0.3.142` +`sdk.d.ts:1265-1279`) is the environment passed to the Claude Code child +process and defaults to `process.env`. Without an explicit `env`, the SDK +inherits the parent's environment, including any `ANTHROPIC_API_KEY`, +`ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, gateway/AI-Gateway tokens, +`GOOGLE_APPLICATION_CREDENTIALS` / `CLOUD_ML_REGION` (Vertex), and +`AWS_*` (Bedrock) credentials — any of which can switch the Claude Code CLI's +authentication source to API-key or another provider, bypassing the user's +local Claude Code session. That would silently violate the core requirement +that `claude-code` runs through the user's existing local Claude Code session +and that there is no silent fallback to gateway, Anthropic API-key, or other +provider execution. + +Every `claude-code` `query()` call site - agent loops, text generation, +object generation, and the auth probe - MUST pass an explicit `env` +(`ktxClaudeCodeEnv`) constructed from `process.env` with the following +denylist removed: + +- `ANTHROPIC_API_KEY` +- `ANTHROPIC_AUTH_TOKEN` +- `ANTHROPIC_BASE_URL` +- `ANTHROPIC_MODEL` (provider-routing override) +- `ANTHROPIC_VERTEX_PROJECT_ID`, `CLOUD_ML_REGION`, + `GOOGLE_APPLICATION_CREDENTIALS`, `GOOGLE_CLOUD_PROJECT` +- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, + `AWS_REGION`, `AWS_PROFILE` +- `CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX` +- Any future provider-routing variables the pinned SDK version documents + +The denylist is the source of truth and lives next to the runtime constructor +so adding a variable is a single-file change. + +Acceptance criteria: + +- The constructed `ktxClaudeCodeEnv` does not contain any denylisted key, and + this is verified by a unit test that seeds each denylisted key in a fake + `process.env`. +- The auth probe fails with the same "authenticate Claude Code locally" + message even when `ANTHROPIC_API_KEY` (or any other denylisted credential) + is present in `process.env` and no valid local Claude Code session exists. +- Every KTX-originated `query()` invocation is spied to assert that `env` + was passed and that it does not contain any denylisted key; the test fails + if any code path falls back to the SDK default `process.env`. +- The "no silent fallback" rule is preserved end-to-end: a machine with + `ANTHROPIC_API_KEY` set but no local Claude Code authentication still fails + setup/status/doctor on `claude-code`. + +## Tool boundary + +Agent-loop tools cannot remain only raw AI SDK `Record` values if +two backends must consume them. The plan must define a backend-neutral tool +descriptor for the final tool map handed to an agent loop: + +```ts +interface KtxRuntimeToolDescriptor { + name: string; + description: string; + inputSchema: z.ZodObject; + execute(input: TInput): Promise>; +} + +interface KtxRuntimeToolOutput { + // What the model sees as the tool_result content. Always a markdown string; + // never a raw JS object. This matches BaseTool's existing + // `toModelOutput` contract (`packages/context/src/tools/base-tool.ts:154-162`) + // which sends only markdown to the LLM. + markdown: string; + // Out-of-band payload preserved for tool callers (transcripts, debug, + // verification ledger, downstream KTX consumers). Not sent to the model. + structured?: TOutput; +} +``` + +Every composed tool entry must produce this descriptor shape, including: + +- `BaseTool` outputs from factory toolsets, which already return + `{ markdown, structured }`. +- Source-specific raw tools such as `emit_historic_sql_evidence` in + `packages/context/src/ingest/local-bundle-runtime.ts`. +- Stage-local tools in `buildWuToolSet` and `buildReconcileToolSet`. +- Inline `load_skill`, read/raw/span, stage/diff, eviction, and emit tools in + `packages/context/src/ingest/ingest-bundle.runner.ts`. +- Memory-agent `load_skill` in + `packages/context/src/memory/memory-agent.service.ts`. +- The `withVerificationLedger` wrapping layer, whose markdown/structured + guard outputs (`packages/context/src/ingest/tools/verification-ledger.tool.ts:40-97`) + already match the contract. + +### Tool output contract + +The runtime defines a single output contract for both backends so the model +sees the same content regardless of provider: + +- **Model-visible content**: the `markdown` field, mapped to the Agent SDK + tool handler return as `{ content: [{ type: "text", text: markdown }] }` for + `claude-code`, and surfaced through the existing `toModelOutput` markdown + path for AI SDK backends. The model never sees raw JS objects. +- **Structured payload**: the optional `structured` field, preserved on the + in-process tool-result envelope for transcript/debug capture, the + verification ledger, and any KTX caller that introspects results. The + Claude adapter does not put structured JSON into model-visible content + unless an individual call site explicitly opts in. +- **Normalization of existing raw tools**: tools that today return a bare + string (e.g. `load_skill` "Skill not available" responses in + `packages/context/src/ingest/ingest-bundle.runner.ts:697-721` and + `:924-936`, and `packages/context/src/memory/memory-agent.service.ts:128-152`) + must be wrapped at the descriptor boundary so `markdown` is the string and + `structured` is omitted. Tools that today return a plain object (e.g. + skill payload `{ name, content, skillDirectory }`) must be wrapped so + `markdown` is a deterministic human-readable rendering (e.g. the skill + body with a header) and the original object is preserved on `structured`. + No KTX tool may return a raw object as the model-visible payload on the + Claude Code backend, because the Agent SDK MCP handler will otherwise + stringify it and drop the structured fields. +- **AI SDK parity**: the AI SDK adapter MUST preserve BaseTool's existing + `toModelOutput` markdown-only behavior. Migrating BaseTool-derived tools + to the descriptor must not start sending structured JSON to the model. + +The AI SDK adapter converts descriptors to `tool(...)` with a `toModelOutput` +that emits `markdown` only. The Claude Code adapter converts descriptors to +Agent SDK `tool(name, description, schema.shape, handler)` entries inside +`createSdkMcpServer(...)` and returns `{ content: [{ type: "text", text: +markdown }] }`. + +Non-object schemas are unsupported for `claude-code` and must be rejected at +startup with a clear error. In practice KTX tool inputs are already `z.object`. + +## Stop reasons and failures + +The Claude runner maps the SDK's typed `SDKResultMessage` (union of +`SDKResultSuccess` and `SDKResultError` in +`@anthropic-ai/claude-agent-sdk@0.3.142`, `sdk.d.ts`) to +`RunLoopStopReason = "budget" | "natural" | "error"`. The mapping must consider +three typed signals in this precedence order, because each successive signal +may be present where the previous one is absent: + +1. `subtype`: `"error_max_turns"` -> `"budget"`; `"success"` -> `"natural"`; + other error subtypes (`"error_during_execution"`, + `"error_max_budget_usd"`, `"error_max_structured_output_retries"`) -> + `"error"`. +2. `terminal_reason` (optional `TerminalReason` field on both success and + error results): `"max_turns"` -> `"budget"`; `"completed"` -> `"natural"`; + any other terminal reason such as `"blocking_limit"`, + `"rapid_refill_breaker"`, `"prompt_too_long"`, `"image_error"`, + `"model_error"`, `"aborted_streaming"`, `"aborted_tools"`, + `"stop_hook_prevented"`, `"hook_stopped"`, or `"tool_deferred"` -> + `"error"`. +3. The assistant message `stop_reason`: `"max_turns"` -> `"budget"`; any + other non-null unsuccessful stop reason -> `"error"`. + +A `max_turns` signal arriving through any of the three sources must map to +`"budget"`; the runner MUST NOT classify a max-turn termination as +`"natural"` or as a generic `"error"` because it was reported via +`terminal_reason` instead of `subtype`. + +`Stop` hooks are not the authoritative stop-reason source because they do not +carry the terminal reason. They remain useful for lifecycle logging. Tool failure +counting should use `PostToolUseFailure` and feed the same mechanism that +`stage-3-work-units.ts` checks through `toolFailureCount?(wu.unitKey)`. + +For text and object generation, SDK authentication, billing, rate-limit, +permission, max-turn, structured-output, and execution errors must map to the +same error surfaces that KTX uses for the Anthropic API-key backend. + +## Agent-loop progress callbacks + +`RunLoopParams.onStepFinish` +(`packages/context/src/agent/agent-runner.service.ts:20`) is part of the +current agent-loop contract. The AI SDK runner increments `stepIndex` on each +`generateText` step and invokes the callback +(`agent-runner.service.ts:83-97`). KTX consumers depend on this: +`packages/context/src/ingest/ingest-bundle.runner.ts:782` emits +`work_unit_step` events from it, and `:1036` / `:1089` update reconciliation +progress for the user-visible "Reconciling results · step N" status. + +The `claude-code` runner MUST preserve `onStepFinish` semantics: + +- It MUST invoke `onStepFinish` exactly once per assistant turn (i.e. once per + step the SDK reports), incrementing `stepIndex` starting at 1. +- The plan MUST name the concrete SDK stream event used as the step boundary + (the implementation plan picks one of the documented assistant/result + message events from the pinned SDK version and justifies it). The chosen + event must produce the same `stepIndex` count as the AI SDK runner for an + equivalent run: N tool-using turns yield N callbacks. +- Callback errors MUST be caught and logged at `warn` level without aborting + the loop, matching `agent-runner.service.ts:90-96`. +- `stepBudget` passed to the callback MUST equal the `maxTurns` configured on + the SDK `query()` call. + +Acceptance criteria: + +- A `claude-code` agent loop run with `stepBudget: N` produces N + `work_unit_step` events when the loop runs to budget. +- A reconciliation run under `claude-code` produces the same + `updateProgress` calls (count and `stepIndex / stepBudget` ratio) as the + Anthropic API-key backend for an equivalent fixture. +- An `onStepFinish` callback that throws does not surface the error as the + loop result. + +## Prompt caching parity + +`packages/llm/src/types.ts:44, :61` exposes `llm.promptCaching` as a config +field, and the AI SDK message builder +(`packages/llm/src/message-builder.ts:62-114, :141-218`) applies +`anthropic.cacheControl: { type: "ephemeral", ttl }` markers to the system +message, the last history message, and sorted tools, with TTLs split into +`systemTtl`, `toolsTtl`, and `historyTtl`. `model-provider.test.ts:276` +verifies caching is enabled by default with those three TTLs. + +The Agent SDK does not expose KTX's marker-based contract. The closest +mechanism is `systemPrompt: string[]` with +`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` (`sdk.d.ts:1746-1799`), which marks a static +prefix as cacheable but provides no per-tool, per-history, or per-TTL knobs. + +For the `claude-code` backend, the spec treats `llm.promptCaching` as +**partial parity**: + +- The Claude runtime MAY map a non-empty static system prefix to a cacheable + `systemPrompt` array using `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` when + `cacheSystem` is enabled in the resolved `KtxPromptCachingConfig`. The + implementation plan decides whether to ship this mapping in the first pass + or defer it. +- `cacheTools`, `cacheHistory`, and the `systemTtl` / `toolsTtl` / + `historyTtl` fields have no Agent SDK equivalent. The runtime MUST NOT + silently drop them: when a user sets non-default values under + `llm.promptCaching` and the backend is `claude-code`, status/doctor and the + setup wizard MUST surface that these fields are ignored on this backend. +- Docs under `docs-site/content/docs/` MUST document this divergence in the + same pages that describe `claude-code` setup, so users do not assume the + TTL/tool/history knobs apply. + +Acceptance criteria: + +- A `claude-code` runtime constructed from a config with default + `promptCaching` does not throw and does not pass KTX `cacheControl` + markers to the Agent SDK (the AI-SDK-only markers stay on the AI SDK + path). +- A `claude-code` runtime constructed from a config with non-default + `promptCaching` values yields a warning surfaced through doctor/status + output identifying the ignored fields. + +## Auth and setup + +`ktx setup`, status, and doctor flows must validate that Claude Code SDK auth is +usable, not just that `~/.claude/` exists. Acceptable validation strategies: + +- A minimal SDK probe call with `settingSources: []`, `skills: []`, + `plugins: []`, `tools: []`, `persistSession: false`, no `mcpServers`, + `env: ktxClaudeCodeEnv`, and `maxTurns: 1`. The probe MUST NOT rely on + the SDK's documented default for any of these fields, because the default + for `settingSources` is `["user", "project", "local"]` (loads filesystem + settings) and the default for `env` is `process.env` (can route auth + through `ANTHROPIC_API_KEY` or other provider credentials and hide a + missing local Claude Code session). See "Agent SDK environment and auth + boundary" above for the `env` denylist. + The auth probe MUST tolerate init messages with non-empty `slash_commands`, + `skills`, and `agents` when `message.tools` is empty, `message.mcp_servers` + is empty, `message.plugins` is empty, and the query options contain the KTX + isolation tuple. Host discovery metadata is not an auth failure. +- An SDK-provided account/auth status method if the pinned version exposes one. +- A docs-endorsed file-presence check only if the official SDK docs explicitly + state that it proves auth usability. + +Failure copy should tell the user to authenticate Claude Code locally with the +Claude Code CLI, then rerun setup or the command they attempted. + +## Documentation impact + +Docs updates are required because this changes user-visible setup and LLM +provider behavior: + +- `docs-site/content/docs/getting-started/quickstart.mdx` +- `docs-site/content/docs/cli-reference/ktx-setup.mdx` +- `docs-site/content/docs/guides/building-context.mdx` +- Any config reference page that documents `llm.provider.backend` +- Any status or doctor docs that describe LLM readiness + +The docs must say that `claude-code` uses the user's own local Claude Code +session. Do not describe it as a way for KTX to resell, pool, or productize +Claude subscription limits. + +## Verified evidence + +- Current `KtxLlmProvider` returns AI SDK `LanguageModel` instances and only + supports `anthropic`, `vertex`, and `gateway` + (`packages/llm/src/types.ts`, `packages/llm/src/model-provider.ts`). +- Project config currently accepts `llm.provider.backend: none | anthropic | + vertex | gateway` (`packages/context/src/project/config.ts`). +- `generateKtxText` and `generateKtxObject` are shared non-agent generation + helpers (`packages/context/src/llm/generation.ts`). +- `AgentRunnerService` is the shared AI SDK agent-loop implementation + (`packages/context/src/agent/agent-runner.service.ts`). +- Page triage and light extraction currently use raw `KtxLlmProvider` + (`packages/context/src/ingest/page-triage/page-triage.service.ts`). +- Scan/enrichment internals currently use `createLocalKtxLlmProviderFromConfig`, + `generateKtxText`, and `generateKtxObject` + (`packages/context/src/scan/local-scan.ts`, + `packages/context/src/scan/description-generation.ts`, + `packages/context/src/scan/relationship-llm-proposal.ts`). +- Local ingest and MCP local project ports inject `llmProvider` and + `agentRunner` today (`packages/context/src/ingest/local-bundle-runtime.ts`, + `packages/context/src/mcp/local-project-ports.ts`). +- The Agent SDK TypeScript reference (`@anthropic-ai/claude-agent-sdk@0.3.142`, + `sdk.d.ts:1690-1697` and the `sdk.mjs` runtime default + `["user","project","local"]`) documents `settingSources` **defaulting to + loading user, project, and local filesystem settings** when omitted; passing + `[]` is the explicit opt-out ("SDK isolation mode"). The same reference + documents `allowedTools` as auto-approval rather than restriction, + `canUseTool` as the programmatic permission handler, + `permissionMode: "dontAsk"`, `tools` as the base built-in set with `[]` + meaning "disable all built-ins" and no MCP-id support, `disallowedTools`, + `maxTurns`, `mcpServers`, `cwd`, `persistSession`, and SDK result/hook + message shapes. +- `SDKResultMessage = SDKResultSuccess | SDKResultError` in + `@anthropic-ai/claude-agent-sdk@0.3.142` (`sdk.d.ts`); both variants expose + an optional `terminal_reason: TerminalReason`, where `TerminalReason` + includes `'max_turns' | 'completed'` alongside other terminal reasons. +- The Agent SDK MCP docs and SDK examples (e.g. Context7 + `/nothflare/claude-agent-sdk-docs` custom-tools guide) show registering MCP + servers in `query()` options and listing exact `mcp____` ids + in `allowedTools`; no SDK doc or type currently documents a wildcard form. +- BaseTool's `toModelOutput` already sends only `markdown` to the model while + preserving structured output for callers + (`packages/context/src/tools/base-tool.ts:154-162`); some raw AI SDK tools + in `packages/context/src/ingest/ingest-bundle.runner.ts:697-721, :924-936` + and `packages/context/src/memory/memory-agent.service.ts:128-152` currently + return bare strings or plain objects and must be normalized at the + descriptor boundary so both backends preserve the contract. +- The Agent SDK skills docs say the `skills` option is a context filter rather + than a sandbox. KTX must pass `skills: []`, but must not assert that + `message.skills` is empty in the SDK init message. +- `Options.env` in `@anthropic-ai/claude-agent-sdk@0.3.142` + (`sdk.d.ts:1265-1279`) is the environment passed to the Claude Code + process and defaults to `process.env`. Without an explicit `env`, the SDK + inherits the parent environment, including any provider-routing variables + (`ANTHROPIC_API_KEY`, Vertex/Bedrock credentials, gateway tokens) that + could change the active authentication source of the Claude Code CLI and + hide a missing local Claude Code session. + +## Open items for the implementation plan + +1. Confirm exact TypeScript option names and result-message discriminants + against the pinned `@anthropic-ai/claude-agent-sdk` version. +2. Define the final `KtxLlmRuntimePort` file location and package exports. +3. Define model alias validation for `sonnet`, `opus`, `haiku`, and full model + IDs. +4. Define the auth probe and make setup/status/doctor report actionable + messages. +5. Run a repo-wide audit for all LLM call sites and migrate each one to the + runtime boundary. +6. Write tests proving `claude-code` works for text generation, structured + object generation, and agent-loop execution. +7. Write tests proving page triage, scan/enrichment internals, memory capture, + MCP-triggered local ingest, and normal local ingest all use the + `claude-code` runtime when configured. +8. Write tests proving a raw built-in Claude Code tool request is denied, + host-discovered Skill/Agent/SlashCommand requests are denied by `canUseTool`, + and only exact `mcp__ktx__*` tools are allowed during KTX agent loops. +9. Write a test that asserts every KTX-originated `query()` invocation + (agent loop, text generation, object generation, auth probe) is called + with `settingSources: []`, `skills: []`, `plugins: []`, `tools: []`, and + `persistSession: false`, by spying on the SDK entry point. The test must + fail if any path falls back to SDK defaults for those fields. The test must + also prove that non-empty host-discovered `slash_commands`, `skills`, and + `agents` in the init message do not fail the auth probe or runtime when the + controlled tool, MCP server, and plugin surfaces match KTX expectations. +10. Write a test that asserts `onStepFinish` is invoked the expected number + of times for a fixed-budget `claude-code` agent loop, including the + work-unit and reconciliation progress paths. +11. Write a test that asserts every KTX-originated `query()` invocation + (agent loop, text generation, object generation, auth probe) is called + with an explicit `env` and that none of the denylisted provider-routing + variables (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, + `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL`, `ANTHROPIC_VERTEX_PROJECT_ID`, + `CLOUD_ML_REGION`, `GOOGLE_APPLICATION_CREDENTIALS`, + `GOOGLE_CLOUD_PROJECT`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, + `AWS_SESSION_TOKEN`, `AWS_REGION`, `AWS_PROFILE`, + `CLAUDE_CODE_USE_BEDROCK`, `CLAUDE_CODE_USE_VERTEX`) are present in + that env, by seeding each variable in a fake `process.env`. The test + must also assert that the auth probe still fails when + `ANTHROPIC_API_KEY` is set in `process.env` but no local Claude Code + session exists. diff --git a/knip.json b/knip.json index 83a8fb1d..2fd48187 100644 --- a/knip.json +++ b/knip.json @@ -3,7 +3,13 @@ "workspaces": { ".": { "entry": ["scripts/**/*.mjs"], - "project": ["scripts/**/*.mjs"] + "project": ["scripts/**/*.mjs"], + "ignoreDependencies": [ + "@semantic-release/commit-analyzer", + "@semantic-release/github", + "@semantic-release/release-notes-generator", + "conventional-changelog-conventionalcommits" + ] }, "packages/cli": { "entry": [ diff --git a/package.json b/package.json index 5fa920ff..3941644b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ktx-workspace", - "version": "0.0.0-private", + "version": "0.1.0-rc.1", "description": "Workspace root for ktx packages", "private": true, "type": "module", @@ -30,10 +30,14 @@ "release:local-embeddings-smoke": "node scripts/local-embeddings-runtime-smoke.mjs --require-opt-in", "release:npm-publish": "node scripts/publish-public-npm-package.mjs", "release:readiness": "node scripts/release-readiness.mjs", + "release:update-version": "node scripts/update-public-release-version.mjs", "relationships:acquire-public-fixtures": "node scripts/acquire-public-benchmark-fixtures.mjs", "relationships:rebuild-public-snapshots": "node scripts/build-benchmark-snapshot.mjs --rebuild-all", "relationships:build-adventureworks-oltp": "node scripts/build-adventureworks-oltp-fixture.mjs", "relationships:verify-orbit": "node scripts/relationship-orbit-verification.mjs", + "semantic-release": "semantic-release", + "semantic-release:debug": "semantic-release --dry-run --debug", + "semantic-release:dry-run": "semantic-release --dry-run --no-ci", "smoke": "pnpm run build && pnpm --filter @ktx/cli run smoke", "test": "node --test scripts/*.test.mjs && pnpm --filter './packages/*' run test", "test:coverage": "pnpm run test:coverage:ts && pnpm run test:coverage:py", @@ -44,9 +48,17 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.15", + "@semantic-release/changelog": "^6.0.3", + "@semantic-release/commit-analyzer": "^13.0.1", + "@semantic-release/exec": "^7.1.0", + "@semantic-release/git": "^10.0.1", + "@semantic-release/github": "^12.0.8", + "@semantic-release/release-notes-generator": "^14.1.1", "@types/node": "^25.7.0", "better-sqlite3": "^12.10.0", + "conventional-changelog-conventionalcommits": "^9.3.1", "knip": "^6.12.2", + "semantic-release": "^25.0.3", "typescript": "^6.0.3", "yaml": "^2.9.0" }, diff --git a/packages/cli/src/claude-code-prompt-caching.ts b/packages/cli/src/claude-code-prompt-caching.ts new file mode 100644 index 00000000..78cd5764 --- /dev/null +++ b/packages/cli/src/claude-code-prompt-caching.ts @@ -0,0 +1,29 @@ +import type { KtxProjectLlmConfig } from '@ktx/context/project'; + +const CLAUDE_CODE_IGNORED_PROMPT_CACHING_FIELDS = [ + 'systemTtl', + 'toolsTtl', + 'historyTtl', + 'vertexFallbackTo5m', +] as const; + +export function ignoredClaudeCodePromptCachingFields(config: KtxProjectLlmConfig): string[] { + if (config.provider.backend !== 'claude-code' || !config.promptCaching) { + return []; + } + const promptCaching = config.promptCaching; + return CLAUDE_CODE_IGNORED_PROMPT_CACHING_FIELDS.filter((key) => key in promptCaching).map( + (key) => `llm.promptCaching.${key}`, + ); +} + +export function formatClaudeCodePromptCachingWarning(fields: string[]): string | null { + if (fields.length === 0) { + return null; + } + return `claude-code ignores ${fields.join(', ')} because the Claude Agent SDK does not expose KTX prompt-cache TTL, tool, or history markers.`; +} + +export function formatClaudeCodePromptCachingFix(): string { + return 'Remove those promptCaching fields or use anthropic, vertex, or gateway when those cache knobs are required.'; +} diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index a2b2a050..fbf366c9 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' { } function llmBackend(value: string): KtxSetupLlmBackend { - if (value === 'anthropic' || value === 'vertex') { + if (value === 'anthropic' || value === 'vertex' || value === 'claude-code') { return value; } throw new InvalidArgumentError(`invalid choice '${value}'`); @@ -97,6 +97,7 @@ function shouldShowSetupEntryMenu( llmBackend?: KtxSetupLlmBackend; anthropicApiKeyEnv?: string; anthropicApiKeyFile?: string; + llmModel?: string; anthropicModel?: string; vertexProject?: string; vertexLocation?: string; @@ -171,6 +172,7 @@ function shouldShowSetupEntryMenu( 'llmBackend', 'anthropicApiKeyEnv', 'anthropicApiKeyFile', + 'llmModel', 'anthropicModel', 'vertexProject', 'vertexLocation', @@ -237,6 +239,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo .addOption( new Option('--anthropic-api-key-file ', 'File containing the Anthropic API key').hideHelp(), ) + .addOption(new Option('--llm-model ', 'LLM model ID or backend model alias').hideHelp()) .addOption(new Option('--anthropic-model ', 'Anthropic model ID to validate and save').hideHelp()) .addOption(new Option('--vertex-project ', 'Google Vertex AI project ID, env:NAME, or file:/path').hideHelp()) .addOption(new Option('--vertex-location ', 'Google Vertex AI location, env:NAME, or file:/path').hideHelp()) @@ -362,12 +365,21 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo context.setExitCode(1); return; } - if (options.llmBackend === 'vertex' && (options.anthropicApiKeyEnv || options.anthropicApiKeyFile)) { + if (options.llmModel && options.anthropicModel) { + context.io.stderr.write('Choose only one LLM model flag: --llm-model or --anthropic-model.\n'); + context.setExitCode(1); + return; + } + if ( + options.llmBackend && + options.llmBackend !== 'anthropic' && + (options.anthropicApiKeyEnv || options.anthropicApiKeyFile) + ) { context.io.stderr.write('Anthropic API key flags are only valid with --llm-backend anthropic.\n'); context.setExitCode(1); return; } - if (options.llmBackend === 'anthropic' && (options.vertexProject || options.vertexLocation)) { + if (options.llmBackend && options.llmBackend !== 'vertex' && (options.vertexProject || options.vertexLocation)) { context.io.stderr.write('Vertex AI flags are only valid with --llm-backend vertex.\n'); context.setExitCode(1); return; @@ -423,6 +435,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo ...(options.llmBackend ? { llmBackend: options.llmBackend } : {}), ...(options.anthropicApiKeyEnv ? { anthropicApiKeyEnv: options.anthropicApiKeyEnv } : {}), ...(options.anthropicApiKeyFile ? { anthropicApiKeyFile: options.anthropicApiKeyFile } : {}), + ...(options.llmModel ? { llmModel: options.llmModel } : {}), ...(options.anthropicModel ? { anthropicModel: options.anthropicModel } : {}), ...(options.vertexProject ? { vertexProject: options.vertexProject } : {}), ...(options.vertexLocation ? { vertexLocation: options.vertexLocation } : {}), diff --git a/packages/cli/src/doctor.test.ts b/packages/cli/src/doctor.test.ts index daeb5b96..f08e9d2d 100644 --- a/packages/cli/src/doctor.test.ts +++ b/packages/cli/src/doctor.test.ts @@ -464,6 +464,44 @@ describe('runKtxDoctor', () => { delete process.env.OPENAI_API_KEY; }); + it('reports Claude Code auth failures and ignored prompt-caching fields in project doctor output', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'llm:', + ' provider:', + ' backend: claude-code', + ' models:', + ' default: sonnet', + ' promptCaching:', + ' enabled: true', + ' systemTtl: 1h', + ' toolsTtl: 1h', + ' historyTtl: 5m', + '', + ].join('\n'), + 'utf-8', + ); + const testIo = makeIo(); + + await expect( + runKtxDoctor( + { command: 'project', projectDir: tempDir, outputMode: 'plain', inputMode: 'disabled' }, + testIo.io, + { + claudeCodeAuthProbe: async () => ({ + ok: false as const, + message: 'Authenticate Claude Code locally.', + }), + }, + ), + ).resolves.toBe(1); + + expect(testIo.stdout()).toContain('claude-code'); + expect(testIo.stdout()).toContain('Authenticate Claude Code locally'); + expect(testIo.stdout()).toContain('claude-code ignores llm.promptCaching'); + }); + it('includes Postgres query-history readiness in project doctor output', async () => { process.env.ANTHROPIC_API_KEY = 'test-key'; // pragma: allowlist secret process.env.OPENAI_API_KEY = 'test-key'; // pragma: allowlist secret diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index 9c0a94fb..1d317b03 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -1074,6 +1074,41 @@ describe('runKtxCli', () => { ); }); + it('dispatches the provider-neutral LLM model setup flag to the setup runner', async () => { + const setup = vi.fn(async () => 0); + const setupIo = makeIo(); + + await expect( + runKtxCli( + [ + '--project-dir', + tempDir, + 'setup', + '--no-input', + '--llm-backend', + 'claude-code', + '--llm-model', + 'opus', + ], + setupIo.io, + { setup }, + ), + ).resolves.toBe(0); + + expect(setup).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'run', + projectDir: tempDir, + inputMode: 'disabled', + cliVersion: '0.0.0-private', + llmBackend: 'claude-code', + llmModel: 'opus', + skipLlm: false, + }), + setupIo.io, + ); + }); + it('rejects conflicting Anthropic credential setup flags', async () => { const setup = vi.fn(async () => 0); const setupIo = makeIo(); diff --git a/packages/cli/src/ingest.test-utils.ts b/packages/cli/src/ingest.test-utils.ts index 41affbb9..8b18716f 100644 --- a/packages/cli/src/ingest.test-utils.ts +++ b/packages/cli/src/ingest.test-utils.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { AgentRunnerService, type RunLoopParams } from '@ktx/context/agent'; +import type { AgentRunnerPort, RunLoopParams } from '@ktx/context'; import { KtxYamlMetabaseSourceStateReader, LocalMetabaseDiscoveryCache, @@ -255,8 +255,8 @@ export function failedLocalBundleRun(input: RunLocalIngestOptions, jobId: string }; } -export class CliLookerSlWritingAgentRunner extends AgentRunnerService { - override runLoop = vi.fn(async (params: RunLoopParams) => { +export class CliLookerSlWritingAgentRunner implements AgentRunnerPort { + runLoop = vi.fn(async (params: RunLoopParams) => { if ( params.telemetryTags?.operationName === 'ingest-bundle-wu' && params.telemetryTags?.unitKey === 'looker-explore-ecommerce-orders' @@ -265,53 +265,39 @@ export class CliLookerSlWritingAgentRunner extends AgentRunnerService { if (!ledger?.execute) { throw new Error('record_verification_ledger tool was not available to the Looker WorkUnit'); } - await ledger.execute( - { - summary: 'Test fixture verified Looker explore target identifiers before writing SL.', - verifiedIdentifiers: ['prod-warehouse', 'public.orders'], - unverifiedIdentifiers: [], - }, - { toolCallId: 'cli-looker-verification-ledger', messages: [] }, - ); + await ledger.execute({ + summary: 'Test fixture verified Looker explore target identifiers before writing SL.', + verifiedIdentifiers: ['prod-warehouse', 'public.orders'], + unverifiedIdentifiers: [], + }); const slWrite = params.toolSet.sl_write_source; if (!slWrite?.execute) { throw new Error('sl_write_source tool was not available to the Looker WorkUnit'); } - const result = await slWrite.execute( - { - connectionId: 'prod-warehouse', - sourceName: 'looker__ecommerce__orders', - source: { - name: 'looker__ecommerce__orders', - table: 'public.orders', - grain: ['id'], - columns: [ - { name: 'id', type: 'number' }, - { name: 'revenue', type: 'number' }, - ], - measures: [{ name: 'total_revenue', expr: 'sum(revenue)' }], - }, + const result = await slWrite.execute({ + connectionId: 'prod-warehouse', + sourceName: 'looker__ecommerce__orders', + source: { + name: 'looker__ecommerce__orders', + table: 'public.orders', + grain: ['id'], + columns: [ + { name: 'id', type: 'number' }, + { name: 'revenue', type: 'number' }, + ], + measures: [{ name: 'total_revenue', expr: 'sum(revenue)' }], }, - { toolCallId: 'cli-looker-sl-write', messages: [] }, - ); - if (!result.structured.success) { + }); + if (!(result.structured as { success?: boolean } | undefined)?.success) { throw new Error(result.markdown); } } return { stopReason: 'natural' as const }; }); - - constructor() { - super({ llmProvider: { getModel: () => ({}) as never } as never }); - } } -export class CliMetabaseAgentRunner extends AgentRunnerService { - override runLoop = vi.fn(async () => ({ stopReason: 'natural' as const })); - - constructor() { - super({ llmProvider: { getModel: () => ({}) as never } as never }); - } +export class CliMetabaseAgentRunner implements AgentRunnerPort { + runLoop = vi.fn(async () => ({ stopReason: 'natural' as const })); } export class CliMetabaseSourceAdapter implements SourceAdapter { diff --git a/packages/cli/src/ingest.test.ts b/packages/cli/src/ingest.test.ts index ad2b2494..d07ef12d 100644 --- a/packages/cli/src/ingest.test.ts +++ b/packages/cli/src/ingest.test.ts @@ -311,10 +311,12 @@ describe('runKtxIngest', () => { expect(runIo.stdout()).toBe(''); expect(runIo.stderr()).toContain( - 'ktx ingest requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.', + 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.', ); + expect(runIo.stderr()).toContain('Configure a local Claude Code session or API-backed LLM, then rerun ingest:'); + expect(runIo.stderr()).toContain(`ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`); expect(runIo.stderr()).toContain( - `ktx setup --project-dir ${projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`, + `ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`, ); }); diff --git a/packages/cli/src/ingest.ts b/packages/cli/src/ingest.ts index f2d82298..eb8e7757 100644 --- a/packages/cli/src/ingest.ts +++ b/packages/cli/src/ingest.ts @@ -86,11 +86,11 @@ export interface KtxIngestDeps { renderStoredMemoryFlow?: typeof renderMemoryFlowTui; startLiveMemoryFlow?: typeof startLiveMemoryFlowTui; env?: NodeJS.ProcessEnv; - localIngestOptions?: Pick< - RunLocalIngestOptions, - | 'agentRunner' - | 'llmProvider' - | 'memoryModel' + localIngestOptions?: Pick< + RunLocalIngestOptions, + | 'agentRunner' + | 'llmRuntime' + | 'memoryModel' | 'semanticLayerCompute' | 'queryExecutor' | 'logger' diff --git a/packages/cli/src/setup-models.test.ts b/packages/cli/src/setup-models.test.ts index fc41cf1d..e997eb82 100644 --- a/packages/cli/src/setup-models.test.ts +++ b/packages/cli/src/setup-models.test.ts @@ -61,7 +61,12 @@ function makePromptAdapter(options: { if (message.includes('LLM provider')) { providerPromptCount += 1; const nextProviderChoice = selectValues[0]; - if (nextProviderChoice === 'anthropic' || nextProviderChoice === 'vertex' || nextProviderChoice === 'back') { + if ( + nextProviderChoice === 'anthropic' || + nextProviderChoice === 'vertex' || + nextProviderChoice === 'claude-code' || + nextProviderChoice === 'back' + ) { return selectValues.shift() ?? nextProviderChoice; } if (options.credentialChoice === 'back' && providerPromptCount > 1) { @@ -180,6 +185,100 @@ describe('setup Anthropic model step', () => { ); }); + it('configures Claude Code backend and validates local auth', async () => { + const io = makeIo(); + const authProbe = vi.fn(async () => ({ ok: true as const })); + + const result = await runKtxSetupAnthropicModelStep( + { + projectDir: tempDir, + inputMode: 'disabled', + llmBackend: 'claude-code', + skipLlm: false, + }, + io.io, + { claudeCodeAuthProbe: authProbe }, + ); + + expect(result.status).toBe('ready'); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.llm).toMatchObject({ + provider: { backend: 'claude-code' }, + models: { default: 'sonnet' }, + }); + expect(authProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'sonnet' })); + }); + + it('prompts for the Claude Code model during interactive setup', async () => { + const io = makeIo(); + const prompts = makePromptAdapter({ selectValues: ['claude-code', 'opus'] }); + const authProbe = vi.fn(async () => ({ ok: true as const })); + + const result = await runKtxSetupAnthropicModelStep( + { projectDir: tempDir, inputMode: 'auto', skipLlm: false }, + io.io, + { prompts, claudeCodeAuthProbe: authProbe }, + ); + + expect(result.status).toBe('ready'); + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Which Claude Code model should KTX use?'), + options: [ + { value: 'sonnet', label: 'Claude Sonnet', hint: 'recommended' }, + { value: 'opus', label: 'Claude Opus' }, + { value: 'haiku', label: 'Claude Haiku' }, + { value: 'manual', label: 'Enter a Claude Code model ID manually' }, + { value: 'back', label: 'Back' }, + ], + }), + ); + const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')); + expect(config.llm).toMatchObject({ + provider: { backend: 'claude-code' }, + models: { default: 'opus' }, + }); + expect(authProbe).toHaveBeenCalledWith(expect.objectContaining({ projectDir: tempDir, model: 'opus' })); + }); + + it('warns during Claude Code setup when existing prompt-caching fields will be ignored', async () => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'llm:', + ' provider:', + ' backend: anthropic', + ' models:', + ' default: claude-sonnet-4-6', + ' promptCaching:', + ' enabled: true', + ' systemTtl: 1h', + ' toolsTtl: 1h', + ' historyTtl: 5m', + '', + ].join('\n'), + 'utf-8', + ); + const io = makeIo(); + + const result = await runKtxSetupAnthropicModelStep( + { + projectDir: tempDir, + inputMode: 'disabled', + llmBackend: 'claude-code', + skipLlm: false, + }, + io.io, + { + claudeCodeAuthProbe: async () => ({ ok: true as const }), + }, + ); + + expect(result.status).toBe('ready'); + expect(io.stderr()).toContain('claude-code ignores llm.promptCaching.systemTtl'); + expect(io.stderr()).toContain('Claude Agent SDK does not expose KTX prompt-cache TTL, tool, or history markers'); + }); + it('returns from Anthropic credential Back to provider selection', async () => { const prompts = makePromptAdapter({ selectValues: ['anthropic', 'back', 'back'] }); @@ -649,7 +748,7 @@ describe('setup Anthropic model step', () => { expect(io.stderr()).not.toContain('--skip-llm'); }); - it('does not recommend skipping when non-interactive setup is missing an Anthropic model', async () => { + it('does not recommend skipping when non-interactive setup is missing an LLM model', async () => { const io = makeIo(); const healthCheck = vi.fn(async () => ({ ok: true as const })); @@ -666,7 +765,7 @@ describe('setup Anthropic model step', () => { expect(result.status).toBe('missing-input'); expect(healthCheck).not.toHaveBeenCalled(); - expect(io.stderr()).toContain('Missing Anthropic model: pass --anthropic-model.'); + expect(io.stderr()).toContain('Missing LLM model: pass --llm-model.'); expect(io.stderr()).not.toContain('--skip-llm'); }); diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index 784a1d18..e8727f47 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -1,7 +1,7 @@ import { execFile } from 'node:child_process'; import { writeFile } from 'node:fs/promises'; import { promisify } from 'node:util'; -import { resolveLocalKtxLlmConfig } from '@ktx/context'; +import { resolveLocalKtxLlmConfig, runClaudeCodeAuthProbe } from '@ktx/context'; import { resolveKtxConfigReference } from '@ktx/context/core'; import { type KtxProjectConfig, @@ -11,6 +11,10 @@ import { serializeKtxProjectConfig, } from '@ktx/context/project'; import { type KtxLlmConfig, type KtxLlmHealthCheckResult, runKtxLlmHealthCheck } from '@ktx/llm'; +import { + formatClaudeCodePromptCachingWarning, + ignoredClaudeCodePromptCachingFields, +} from './claude-code-prompt-caching.js'; import { createClackSpinner, type KtxCliSpinner } from './clack.js'; import type { KtxCliIo } from './cli-runtime.js'; import { withTextInputNavigation } from './prompt-navigation.js'; @@ -32,6 +36,7 @@ export interface KtxSetupModelArgs { llmBackend?: KtxSetupLlmBackend; anthropicApiKeyEnv?: string; anthropicApiKeyFile?: string; + llmModel?: string; anthropicModel?: string; vertexProject?: string; vertexLocation?: string; @@ -53,7 +58,7 @@ export interface AnthropicModelChoice { recommended: boolean; } -export type KtxSetupLlmBackend = 'anthropic' | 'vertex'; +export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code'; export interface KtxSetupModelPromptAdapter { select(options: { message: string; options: KtxSetupPromptOption[] }): Promise; @@ -68,6 +73,11 @@ export interface KtxSetupModelDeps { prompts?: KtxSetupModelPromptAdapter; listModels?: (apiKey: string) => Promise; healthCheck?: (config: KtxLlmConfig) => Promise; + claudeCodeAuthProbe?: (input: { + projectDir: string; + model: string; + env?: NodeJS.ProcessEnv; + }) => Promise<{ ok: true } | { ok: false; message: string }>; readGcloudProject?: () => Promise; listGcloudProjects?: () => Promise; spinner?: () => KtxCliSpinner; @@ -91,6 +101,12 @@ const VERTEX_ANTHROPIC_MODELS: AnthropicModelChoice[] = [ { id: 'claude-opus-4-1', label: 'Claude Opus 4.1', recommended: false }, ]; +const CLAUDE_CODE_MODELS: AnthropicModelChoice[] = [ + { id: 'sonnet', label: 'Claude Sonnet', recommended: true }, + { id: 'opus', label: 'Claude Opus', recommended: false }, + { id: 'haiku', label: 'Claude Haiku', recommended: false }, +]; + const HIDDEN_ANTHROPIC_MODEL_PATTERNS = [ /^claude-sonnet-4$/i, /^claude-opus-4$/i, @@ -252,7 +268,7 @@ export function isKtxSetupLlmConfigReady(config: KtxProjectLlmConfig): boolean { return typeof resolved.vertex?.location === 'string' && resolved.vertex.location.trim().length > 0; } - return resolved.backend === 'anthropic' || resolved.backend === 'gateway'; + return resolved.backend === 'anthropic' || resolved.backend === 'gateway' || resolved.backend === 'claude-code'; } function hasUsableConfiguredLlm(config: KtxProjectConfig): boolean { @@ -263,9 +279,18 @@ function buildProjectLlmConfig( existing: KtxProjectLlmConfig, provider: | { backend: 'anthropic'; credentialRef: string } - | { backend: 'vertex'; vertex: { project?: string; location: string } }, + | { backend: 'vertex'; vertex: { project?: string; location: string } } + | { backend: 'claude-code' }, model: string, ): KtxProjectLlmConfig { + if (provider.backend === 'claude-code') { + return { + provider: { backend: 'claude-code' }, + models: { ...existing.models, default: model }, + promptCaching: existing.promptCaching, + }; + } + if (provider.backend === 'vertex') { return { provider: { @@ -453,12 +478,16 @@ function requestedBackend(args: KtxSetupModelArgs): KtxSetupLlmBackend | undefin if (args.vertexProject || args.vertexLocation) { return 'vertex'; } - if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.anthropicModel) { + if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.llmModel || args.anthropicModel) { return 'anthropic'; } return undefined; } +function requestedModel(args: KtxSetupModelArgs): string | undefined { + return args.llmModel ?? args.anthropicModel; +} + async function chooseBackend( args: KtxSetupModelArgs, io: KtxCliIo, @@ -480,16 +509,21 @@ async function chooseBackend( } const choice = await prompts.select({ message: 'Which LLM provider should KTX use?', - options: [ - { value: 'anthropic', label: 'Anthropic API' }, - { value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' }, - { value: 'back', label: 'Back' }, - ], + options: [ + { value: 'anthropic', label: 'Anthropic API' }, + { value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' }, + { value: 'claude-code', label: 'Local Claude Code session' }, + { value: 'back', label: 'Back' }, + ], }); if (choice === 'back') { return { status: 'back' }; } - return { status: 'ready', backend: choice === 'vertex' ? 'vertex' : 'anthropic', prompted: true }; + return { + status: 'ready', + backend: choice === 'vertex' || choice === 'claude-code' ? choice : 'anthropic', + prompted: true, + }; } function resolveProvidedVertexRef( @@ -708,11 +742,12 @@ async function chooseModel( io: KtxCliIo, deps: KtxSetupModelDeps, ): Promise { - if (args.anthropicModel) { - return { status: 'ready', model: args.anthropicModel }; + const providedModel = requestedModel(args); + if (providedModel) { + return { status: 'ready', model: providedModel }; } if (args.inputMode === 'disabled') { - io.stderr.write('Missing Anthropic model: pass --anthropic-model.\n'); + io.stderr.write('Missing LLM model: pass --llm-model.\n'); return { status: 'missing-input' }; } @@ -765,11 +800,12 @@ async function chooseModel( } async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: KtxSetupModelDeps): Promise { - if (args.anthropicModel) { - return { status: 'ready', model: args.anthropicModel }; + const providedModel = requestedModel(args); + if (providedModel) { + return { status: 'ready', model: providedModel }; } if (args.inputMode === 'disabled') { - io.stderr.write('Missing Anthropic model: pass --anthropic-model.\n'); + io.stderr.write('Missing LLM model: pass --llm-model.\n'); return { status: 'missing-input' }; } @@ -803,11 +839,50 @@ async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: Kt return { status: 'ready', model: choice }; } +async function chooseClaudeCodeModel(args: KtxSetupModelArgs, deps: KtxSetupModelDeps): Promise { + const providedModel = requestedModel(args); + if (providedModel) { + return { status: 'ready', model: providedModel }; + } + if (args.inputMode === 'disabled') { + return { status: 'ready', model: 'sonnet' }; + } + + const prompts = deps.prompts ?? createPromptAdapter(); + const choice = await prompts.select({ + message: `Which Claude Code model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`, + options: [ + ...CLAUDE_CODE_MODELS.map((model) => ({ + value: model.id, + label: model.label, + ...(model.recommended ? { hint: 'recommended' } : {}), + })), + { value: 'manual', label: 'Enter a Claude Code model ID manually' }, + { value: 'back', label: 'Back' }, + ], + }); + if (choice === 'back') { + return { status: 'back' }; + } + if (choice === 'manual') { + const manual = await prompts.text({ + message: withTextInputNavigation('Claude Code model ID'), + placeholder: CLAUDE_CODE_MODELS.find((model) => model.recommended)?.id ?? CLAUDE_CODE_MODELS[0]?.id, + }); + if (manual === undefined) { + return { status: 'back' }; + } + return manual.trim() ? { status: 'ready', model: manual.trim() } : { status: 'missing-input' }; + } + return { status: 'ready', model: choice }; +} + async function persistLlmConfig( projectDir: string, provider: | { backend: 'anthropic'; credentialRef: string } - | { backend: 'vertex'; vertex: { project?: string; location: string } }, + | { backend: 'vertex'; vertex: { project?: string; location: string } } + | { backend: 'claude-code' }, model: string, ): Promise { const project = await loadKtxProject({ projectDir }); @@ -853,6 +928,7 @@ export async function runKtxSetupAnthropicModelStep( !args.llmBackend && !args.anthropicApiKeyEnv && !args.anthropicApiKeyFile && + !args.llmModel && !args.anthropicModel && !args.vertexProject && !args.vertexLocation @@ -918,6 +994,37 @@ export async function runKtxSetupAnthropicModelStep( continue; } + if (backendChoice.backend === 'claude-code') { + const model = await chooseClaudeCodeModel(backendArgs, deps); + if (model.status === 'back' && backendChoice.prompted) { + attemptArgs = buildInteractiveRetryArgs(args); + continue; + } + if (model.status === 'invalid-credential') { + return { status: 'failed', projectDir: args.projectDir }; + } + if (model.status !== 'ready') { + return { status: model.status, projectDir: args.projectDir }; + } + const probe = deps.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe; + const health = await probe({ projectDir: args.projectDir, model: model.model, env: deps.env ?? process.env }); + if (!health.ok) { + io.stderr.write(`${health.message}\n`); + return { status: 'failed', projectDir: args.projectDir }; + } + const warning = formatClaudeCodePromptCachingWarning( + ignoredClaudeCodePromptCachingFields( + buildProjectLlmConfig(project.config.llm, { backend: 'claude-code' }, model.model), + ), + ); + if (warning) { + io.stderr.write(`${warning}\n`); + } + await persistLlmConfig(args.projectDir, { backend: 'claude-code' }, model.model); + io.stdout.write(`│ LLM ready: yes (${model.model})\n`); + return { status: 'ready', projectDir: args.projectDir }; + } + const credential = await chooseCredentialRef(backendArgs, io, deps); if (credential.status === 'back' && backendChoice.prompted) { attemptArgs = buildInteractiveRetryArgs(args); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index b295e912..0def5930 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -77,6 +77,7 @@ export type KtxSetupArgs = llmBackend?: KtxSetupLlmBackend; anthropicApiKeyEnv?: string; anthropicApiKeyFile?: string; + llmModel?: string; anthropicModel?: string; vertexProject?: string; vertexLocation?: string; @@ -547,6 +548,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup ...(args.llmBackend ? { llmBackend: args.llmBackend } : {}), ...(args.anthropicApiKeyEnv ? { anthropicApiKeyEnv: args.anthropicApiKeyEnv } : {}), ...(args.anthropicApiKeyFile ? { anthropicApiKeyFile: args.anthropicApiKeyFile } : {}), + ...(args.llmModel ? { llmModel: args.llmModel } : {}), ...(args.anthropicModel ? { anthropicModel: args.anthropicModel } : {}), ...(args.vertexProject ? { vertexProject: args.vertexProject } : {}), ...(args.vertexLocation ? { vertexLocation: args.vertexLocation } : {}), diff --git a/packages/cli/src/status-project.ts b/packages/cli/src/status-project.ts index 8c2f2445..76d55851 100644 --- a/packages/cli/src/status-project.ts +++ b/packages/cli/src/status-project.ts @@ -1,4 +1,5 @@ import { basename } from 'node:path'; +import { runClaudeCodeAuthProbe } from '@ktx/context'; import type { KtxConfigIssue, KtxLocalProject, @@ -8,6 +9,11 @@ import type { KtxProjectLlmConfig, } from '@ktx/context/project'; import type { PostgresPgssProbeResult } from '@ktx/context/ingest'; +import { + formatClaudeCodePromptCachingFix, + formatClaudeCodePromptCachingWarning, + ignoredClaudeCodePromptCachingFields, +} from './claude-code-prompt-caching.js'; import type { DoctorCheck } from './doctor.js'; import { bold as _bold, @@ -77,6 +83,12 @@ interface WarningItem { fix?: string; } +type ClaudeCodeAuthProbe = (input: { + projectDir: string; + model: string; + env?: NodeJS.ProcessEnv; +}) => Promise<{ ok: true } | { ok: false; message: string }>; + const PROJECT_READY_COMMANDS = KTX_NEXT_STEP_DIRECT_COMMANDS.map((step) => step.command); function isRecord(value: unknown): value is Record { @@ -134,7 +146,15 @@ function envHint(value: unknown): string | undefined { return undefined; } -function buildLlmStatus(config: KtxProjectLlmConfig, env: NodeJS.ProcessEnv): LlmStatus { +async function buildLlmStatus( + config: KtxProjectLlmConfig, + options: { + projectDir: string; + env: NodeJS.ProcessEnv; + claudeCodeAuthProbe?: ClaudeCodeAuthProbe; + }, +): Promise { + const env = options.env; const backend = config.provider.backend; const model = config.models?.default; if (backend === 'none') { @@ -186,6 +206,26 @@ function buildLlmStatus(config: KtxProjectLlmConfig, env: NodeJS.ProcessEnv): Ll fix: hint ? `Set ${hint}` : 'Set the gateway api_key or rerun `ktx setup`', }; } + if (backend === 'claude-code') { + const modelName = model ?? 'sonnet'; + const probe = options.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe; + const auth = await probe({ projectDir: options.projectDir, model: modelName, env }); + if (auth.ok) { + return { + backend, + model: modelName, + status: 'ok', + detail: 'local Claude Code session authenticated', + }; + } + return { + backend, + model: modelName, + status: 'fail', + detail: auth.message, + fix: 'Authenticate Claude Code locally with the Claude Code CLI, then rerun `ktx status`.', + }; + } return { backend, model, status: 'warn', detail: 'unknown LLM backend' }; } @@ -568,6 +608,14 @@ function buildWarnings( }); } + const warning = formatClaudeCodePromptCachingWarning(ignoredClaudeCodePromptCachingFields(config.llm)); + if (warning) { + warnings.push({ + message: warning, + fix: formatClaudeCodePromptCachingFix(), + }); + } + return warnings; } @@ -629,6 +677,7 @@ function buildVerdict( export interface BuildProjectStatusOptions { env?: NodeJS.ProcessEnv; postgresQueryHistoryProbe?: PostgresQueryHistoryProbe; + claudeCodeAuthProbe?: ClaudeCodeAuthProbe; configIssues?: KtxConfigIssue[]; } @@ -649,7 +698,11 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil const config = project.config; const configStatus = buildConfigStatus(options.configIssues); - const llm = buildLlmStatus(config.llm, env); + const llm = await buildLlmStatus(config.llm, { + projectDir: project.projectDir, + env, + claudeCodeAuthProbe: options.claudeCodeAuthProbe, + }); const embeddings = buildEmbeddingsStatus(config.ingest.embeddings, env); const storage = buildStorageStatus(config); const connections = Object.entries(config.connections).map(([name, conn]) => diff --git a/packages/context/package.json b/packages/context/package.json index 28ec5190..104b4e47 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -129,6 +129,7 @@ "type-check": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "0.3.142", "@ktx/llm": "workspace:*", "@looker/sdk": "^26.8.0", "@looker/sdk-node": "^26.8.0", diff --git a/packages/context/src/agent/agent-runner.service.test.ts b/packages/context/src/agent/agent-runner.service.test.ts index 3208bda7..dea9e325 100644 --- a/packages/context/src/agent/agent-runner.service.test.ts +++ b/packages/context/src/agent/agent-runner.service.test.ts @@ -55,7 +55,14 @@ describe('AgentRunnerService.runLoop', () => { expect(call.system).toEqual({ role: 'system', content: 'SYS' }); expect(call.messages).toEqual([{ role: 'user', content: 'USR' }]); expect(call.prompt).toBeUndefined(); - expect(call.tools).toEqual(tools); + expect(call.tools.noop).toEqual( + expect.objectContaining({ + description: 'noop', + inputSchema: {}, + execute: expect.any(Function), + toModelOutput: expect.any(Function), + }), + ); expect(call.stopWhen).toBe(17); expect(call.temperature).toBe(0); expect(call.experimental_repairToolCall).toBe(repairHandler); diff --git a/packages/context/src/agent/agent-runner.service.ts b/packages/context/src/agent/agent-runner.service.ts index 128818f9..a6f9fd59 100644 --- a/packages/context/src/agent/agent-runner.service.ts +++ b/packages/context/src/agent/agent-runner.service.ts @@ -1,33 +1,15 @@ -import { KtxMessageBuilder, splitKtxSystemMessages, type KtxLlmProvider, type KtxModelRole } from '@ktx/llm'; -import { generateText, stepCountIs, type TelemetrySettings, type Tool } from 'ai'; -import { noopLogger, type KtxLogger } from '../core/index.js'; -import { summarizeKtxLlmDebugRequest, type KtxLlmDebugRequestRecorder } from '../llm/index.js'; - -export type RunLoopStopReason = 'budget' | 'natural' | 'error'; - -export interface RunLoopStepInfo { - stepIndex: number; - stepBudget: number; -} - -export interface RunLoopParams { - modelRole: KtxModelRole; - systemPrompt: string; - userPrompt: string; - toolSet: Record; - stepBudget: number; - telemetryTags: Record; - onStepFinish?: (info: RunLoopStepInfo) => void | Promise; -} - -export interface RunLoopResult { - stopReason: RunLoopStopReason; - error?: Error; -} - -export interface AgentTelemetryPort { - createTelemetry(tags: Record): TelemetrySettings; -} +import type { KtxLlmProvider } from '@ktx/llm'; +import type { KtxLogger } from '../core/index.js'; +import { AiSdkKtxLlmRuntime, type AgentTelemetryPort } from '../llm/ai-sdk-runtime.js'; +import type { KtxLlmDebugRequestRecorder } from '../llm/debug-request-recorder.js'; +import type { AgentRunnerPort, RunLoopParams, RunLoopResult } from '../llm/runtime-port.js'; +export type { + RunLoopParams, + RunLoopResult, + RunLoopStepInfo, + RunLoopStopReason, +} from '../llm/runtime-port.js'; +export type { AgentTelemetryPort } from '../llm/ai-sdk-runtime.js'; export interface AgentRunnerServiceDeps { llmProvider: KtxLlmProvider; @@ -36,71 +18,14 @@ export interface AgentRunnerServiceDeps { logger?: KtxLogger; } -export class AgentRunnerService { - private readonly logger: KtxLogger; +export class AgentRunnerService implements AgentRunnerPort { + private readonly runtime: AiSdkKtxLlmRuntime; - constructor(private readonly deps: AgentRunnerServiceDeps) { - this.logger = deps.logger ?? noopLogger; + constructor(deps: AgentRunnerServiceDeps) { + this.runtime = new AiSdkKtxLlmRuntime(deps); } - async runLoop(params: RunLoopParams): Promise { - let stepIndex = 0; - try { - const model = this.deps.llmProvider.getModel(params.modelRole); - const builder = new KtxMessageBuilder(this.deps.llmProvider); - const built = builder.wrapSimple({ - system: params.systemPrompt, - messages: [{ role: 'user', content: params.userPrompt }], - tools: params.toolSet, - model, - }); - const promptMessages = splitKtxSystemMessages(built.messages); - - await this.deps.debugRequestRecorder?.record( - summarizeKtxLlmDebugRequest({ - operationName: params.telemetryTags.operationName ?? 'ktx-agent-runner', - source: params.telemetryTags.source, - jobId: params.telemetryTags.jobId, - unitKey: params.telemetryTags.unitKey, - modelRole: params.modelRole, - modelId: (model as { modelId?: string }).modelId ?? params.modelRole, - messages: built.messages, - tools: built.tools as Record, - }), - ); - - await generateText({ - model, - temperature: 0, - stopWhen: stepCountIs(params.stepBudget), - experimental_telemetry: this.deps.telemetry?.createTelemetry(params.telemetryTags), - experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ - source: params.telemetryTags.operationName ?? 'ktx-agent-runner', - }), - ...(promptMessages.system ? { system: promptMessages.system } : {}), - messages: promptMessages.messages, - tools: built.tools as Record, - onStepFinish: async () => { - stepIndex += 1; - if (!params.onStepFinish) { - return; - } - try { - await params.onStepFinish({ stepIndex, stepBudget: params.stepBudget }); - } catch (err) { - this.logger.warn( - `[agent-runner] onStepFinish callback threw; ignoring: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - }, - }); - return { stopReason: 'natural' }; - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.logger.warn(`[agent-runner] loop failed: ${err.message}`); - return { stopReason: 'error', error: err }; - } + runLoop(params: RunLoopParams): Promise { + return this.runtime.runAgentLoop(params); } } diff --git a/packages/context/src/core/git.service.test.ts b/packages/context/src/core/git.service.test.ts index ba1d9e0f..8ad74b22 100644 --- a/packages/context/src/core/git.service.test.ts +++ b/packages/context/src/core/git.service.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -52,6 +52,13 @@ describe('GitService', () => { const after = await service.revParseHead(); expect(after).toBe(before); }); + + it('keeps git auto-maintenance attached for deterministic cleanup', async () => { + const config = await readFile(join(tempDir, '.git', 'config'), 'utf-8'); + + expect(config).toMatch(/\[gc]\n\s+autoDetach = false/); + expect(config).toMatch(/\[maintenance]\n\s+autoDetach = false/); + }); }); describe('commitFile `created` flag', () => { diff --git a/packages/context/src/core/git.service.ts b/packages/context/src/core/git.service.ts index 8d05a089..7db4863b 100644 --- a/packages/context/src/core/git.service.ts +++ b/packages/context/src/core/git.service.ts @@ -105,6 +105,12 @@ export class GitService { this.logger.log('Initialized git repository'); } + // Keep any auto-maintenance triggered by writes in-process. Detached maintenance can + // keep object-pack directories alive briefly after awaited git commands complete, + // which makes temp-project cleanup flaky in CI. + await this.git.addConfig('gc.autoDetach', 'false'); + await this.git.addConfig('maintenance.autoDetach', 'false'); + // Ensure HEAD always resolves to a commit so callers (e.g., the memory-agent squash flow) // can rely on `revParseHead()` returning a SHA. Idempotent: skip if HEAD already exists. const head = await this.revParseHead(); diff --git a/packages/context/src/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts b/packages/context/src/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts index e610de76..81fa9d30 100644 --- a/packages/context/src/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts +++ b/packages/context/src/ingest/adapters/historic-sql/local-ingest-acceptance.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import YAML from 'yaml'; -import { AgentRunnerService } from '../../../agent/index.js'; +import type { AgentRunnerPort, RunLoopParams } from '../../../llm/index.js'; import { initKtxProject, loadKtxProject, type KtxLocalProject } from '../../../project/index.js'; import { type SqlAnalysisBatchItem, @@ -47,8 +47,8 @@ class AcceptanceHistoricSqlReader implements HistoricSqlReader { } } -class HistoricSqlAcceptanceAgentRunner extends AgentRunnerService { - override runLoop = vi.fn(async (params: any) => { +class HistoricSqlAcceptanceAgentRunner implements AgentRunnerPort { + runLoop = vi.fn(async (params: RunLoopParams) => { if (params.telemetryTags?.operationName !== 'ingest-bundle-wu') { return { stopReason: 'natural' as const }; } @@ -59,78 +59,65 @@ class HistoricSqlAcceptanceAgentRunner extends AgentRunnerService { } if (params.telemetryTags.unitKey === 'historic-sql-table-public-orders') { - const result = await emitEvidence.execute( - { - kind: 'table_usage', - table: 'public.orders', - rawPath: 'tables/public.orders.json', - usage: { - narrative: 'Analysts repeatedly inspect paid order lifecycle by customer segment.', - frequencyTier: 'high', - commonFilters: ['status'], - commonGroupBys: ['status', 'segment'], - commonJoins: [{ table: 'public.customers', on: ['customer_id', 'id'] }], - staleSince: null, - }, + const result = await emitEvidence.execute({ + kind: 'table_usage', + table: 'public.orders', + rawPath: 'tables/public.orders.json', + usage: { + narrative: 'Analysts repeatedly inspect paid order lifecycle by customer segment.', + frequencyTier: 'high', + commonFilters: ['status'], + commonGroupBys: ['status', 'segment'], + commonJoins: [{ table: 'public.customers', on: ['customer_id', 'id'] }], + staleSince: null, }, - { toolCallId: 'historic-sql-orders-usage' }, - ); - if (!String(result).includes('Recorded historic-SQL table_usage evidence')) { - throw new Error(`Unexpected orders evidence result: ${String(result)}`); + }); + if (!result.markdown.includes('Recorded historic-SQL table_usage evidence')) { + throw new Error(`Unexpected orders evidence result: ${result.markdown}`); } } if (params.telemetryTags.unitKey === 'historic-sql-table-public-customers') { - const result = await emitEvidence.execute( - { - kind: 'table_usage', - table: 'public.customers', - rawPath: 'tables/public.customers.json', - usage: { - narrative: 'Customers provide segment context for paid order lifecycle analysis.', - frequencyTier: 'mid', - commonFilters: [], - commonGroupBys: ['segment'], - commonJoins: [{ table: 'public.orders', on: ['id', 'customer_id'] }], - staleSince: null, - }, + const result = await emitEvidence.execute({ + kind: 'table_usage', + table: 'public.customers', + rawPath: 'tables/public.customers.json', + usage: { + narrative: 'Customers provide segment context for paid order lifecycle analysis.', + frequencyTier: 'mid', + commonFilters: [], + commonGroupBys: ['segment'], + commonJoins: [{ table: 'public.orders', on: ['id', 'customer_id'] }], + staleSince: null, }, - { toolCallId: 'historic-sql-customers-usage' }, - ); - if (!String(result).includes('Recorded historic-SQL table_usage evidence')) { - throw new Error(`Unexpected customers evidence result: ${String(result)}`); + }); + if (!result.markdown.includes('Recorded historic-SQL table_usage evidence')) { + throw new Error(`Unexpected customers evidence result: ${result.markdown}`); } } if (params.telemetryTags.unitKey === 'historic-sql-patterns-part-0001') { - const result = await emitEvidence.execute( - { - kind: 'pattern', - rawPath: 'patterns-input/part-0001.json', - pattern: { - slug: 'paid-order-lifecycle', - title: 'Paid Order Lifecycle', - narrative: 'Analysts join orders and customers to compare paid order lifecycle by segment.', - definitionSql: - 'select o.status, c.segment, count(*) from public.orders o join public.customers c on c.id = o.customer_id group by o.status, c.segment', - tablesInvolved: ['public.orders', 'public.customers'], - slRefs: ['orders', 'customers'], - constituentTemplateIds: ['pg:orders-lifecycle'], - }, + const result = await emitEvidence.execute({ + kind: 'pattern', + rawPath: 'patterns-input/part-0001.json', + pattern: { + slug: 'paid-order-lifecycle', + title: 'Paid Order Lifecycle', + narrative: 'Analysts join orders and customers to compare paid order lifecycle by segment.', + definitionSql: + 'select o.status, c.segment, count(*) from public.orders o join public.customers c on c.id = o.customer_id group by o.status, c.segment', + tablesInvolved: ['public.orders', 'public.customers'], + slRefs: ['orders', 'customers'], + constituentTemplateIds: ['pg:orders-lifecycle'], }, - { toolCallId: 'historic-sql-pattern' }, - ); - if (!String(result).includes('Recorded historic-SQL pattern evidence')) { - throw new Error(`Unexpected pattern evidence result: ${String(result)}`); + }); + if (!result.markdown.includes('Recorded historic-SQL pattern evidence')) { + throw new Error(`Unexpected pattern evidence result: ${result.markdown}`); } } return { stopReason: 'natural' as const }; }); - - constructor() { - super({ llmProvider: { getModel: () => ({}) as never } as never }); - } } function acceptanceSqlAnalysis(): SqlAnalysisPort { diff --git a/packages/context/src/ingest/context-candidates/curator-pagination.service.ts b/packages/context/src/ingest/context-candidates/curator-pagination.service.ts index d40bef9b..5130b20b 100644 --- a/packages/context/src/ingest/context-candidates/curator-pagination.service.ts +++ b/packages/context/src/ingest/context-candidates/curator-pagination.service.ts @@ -1,7 +1,6 @@ import type { KtxModelRole } from '@ktx/llm'; -import type { ToolSet } from 'ai'; -import type { AgentRunnerService } from '../../agent/index.js'; import { type KtxLogger, noopLogger } from '../../core/index.js'; +import type { AgentRunnerPort, KtxRuntimeToolSet } from '../../llm/index.js'; import type { MemoryAction } from '../../memory/index.js'; import type { ContextCandidateForDedup, CuratorPaginationPort, CuratorPaginationReport } from '../ports.js'; import type { @@ -38,7 +37,7 @@ export interface CuratorPaginationInput { modelRole: KtxModelRole; buildSystemPrompt: () => string; buildUserPrompt: (input: CuratorPaginationPromptInput) => string; - buildToolSet: (passNumber: number) => ToolSet; + buildToolSet: (passNumber: number) => KtxRuntimeToolSet; getReconciliationActions: () => MemoryAction[]; onStepFinish?: (info: { passNumber: number; stepIndex: number; stepBudget: number }) => void; } @@ -50,7 +49,7 @@ interface CuratorPaginationResult extends ReconciliationOutcome { export interface CuratorPaginationServiceDeps { store: ContextCandidateStorePort; - agentRunner: AgentRunnerService; + agentRunner: AgentRunnerPort; settings: CuratorPaginationSettings; logger?: KtxLogger; } diff --git a/packages/context/src/ingest/ingest-bundle.runner.test.ts b/packages/context/src/ingest/ingest-bundle.runner.test.ts index c73eb436..94c17100 100644 --- a/packages/context/src/ingest/ingest-bundle.runner.test.ts +++ b/packages/context/src/ingest/ingest-bundle.runner.test.ts @@ -200,7 +200,7 @@ const makeDeps = () => { const slValidator = { validateSingleSource: vi.fn().mockResolvedValue({ errors: [], warnings: [] }) }; const toolsetFactory = { createIngestWuToolset: vi.fn().mockReturnValue({ - toAiSdkTools: vi.fn().mockReturnValue({}), + toRuntimeTools: vi.fn().mockReturnValue({}), getAllTools: vi.fn().mockReturnValue([]), getToolNames: vi.fn().mockReturnValue([]), }), @@ -419,7 +419,7 @@ describe('IngestBundleRunner — Stages 1 → 7', () => { deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => { sessions.push(toolSession); return { - toAiSdkTools: vi.fn().mockReturnValue({}), + toRuntimeTools: vi.fn().mockReturnValue({}), getAllTools: vi.fn().mockReturnValue([]), getToolNames: vi.fn().mockReturnValue([]), }; @@ -591,7 +591,7 @@ describe('IngestBundleRunner — Stages 1 → 7', () => { deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => { currentToolSession = toolSession; return { - toAiSdkTools: vi.fn().mockReturnValue({}), + toRuntimeTools: vi.fn().mockReturnValue({}), getAllTools: vi.fn().mockReturnValue([]), getToolNames: vi.fn().mockReturnValue([]), }; @@ -663,7 +663,7 @@ describe('IngestBundleRunner — Stages 1 → 7', () => { deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => { currentToolSession = toolSession; return { - toAiSdkTools: vi.fn().mockReturnValue({}), + toRuntimeTools: vi.fn().mockReturnValue({}), getAllTools: vi.fn().mockReturnValue([]), getToolNames: vi.fn().mockReturnValue([]), }; @@ -834,7 +834,7 @@ describe('IngestBundleRunner — Stages 1 → 7', () => { it('stores memory-flow provenance and transcript summaries in the ingest report body', async () => { const deps = makeDeps(); deps.toolsetFactory.createIngestWuToolset.mockReturnValue({ - toAiSdkTools: vi.fn().mockReturnValue({ + toRuntimeTools: vi.fn().mockReturnValue({ read_raw_span: { description: 'read a raw span', inputSchema: {}, @@ -1376,7 +1376,7 @@ describe('IngestBundleRunner — Stages 1 → 7', () => { deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => { currentToolSession = toolSession; return { - toAiSdkTools: vi.fn().mockReturnValue({}), + toRuntimeTools: vi.fn().mockReturnValue({}), getAllTools: vi.fn().mockReturnValue([]), getToolNames: vi.fn().mockReturnValue([]), }; @@ -1933,7 +1933,7 @@ describe('IngestBundleRunner — Stages 1 → 7', () => { deps.toolsetFactory.createIngestWuToolset.mockImplementation((toolSession: any) => { currentToolSession = toolSession; return { - toAiSdkTools: vi.fn().mockReturnValue({}), + toRuntimeTools: vi.fn().mockReturnValue({}), getAllTools: vi.fn().mockReturnValue([]), getToolNames: vi.fn().mockReturnValue([]), }; diff --git a/packages/context/src/ingest/ingest-bundle.runner.ts b/packages/context/src/ingest/ingest-bundle.runner.ts index 33495736..75450ab8 100644 --- a/packages/context/src/ingest/ingest-bundle.runner.ts +++ b/packages/context/src/ingest/ingest-bundle.runner.ts @@ -1,9 +1,9 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { type Tool, tool } from 'ai'; import pLimit from 'p-limit'; import { z } from 'zod'; import { type KtxLogger, noopLogger } from '../core/index.js'; +import { createRuntimeToolDescriptorFromAiTool, type KtxRuntimeToolSet } from '../llm/index.js'; import type { CaptureSession, MemoryAction } from '../memory/index.js'; import type { SemanticLayerService, SemanticLayerSource, SlValidationDeps } from '../sl/index.js'; import { createTouchedSlSources, type ToolContext, type ToolSession } from '../tools/index.js'; @@ -694,8 +694,9 @@ export class IngestBundleRunner { }; const skillsLoadedPerWu: string[] = []; - const loadSkillTool: Record = { - load_skill: tool({ + const loadSkillTool: KtxRuntimeToolSet = { + load_skill: { + name: 'load_skill', description: 'Load a skill to get specialized instructions. Call this when a skill listed in the system prompt matches the current task.', inputSchema: z.object({ name: z.string() }), @@ -705,19 +706,23 @@ export class IngestBundleRunner { const available = (await this.deps.skillsRegistry.listSkills('memory_agent')).map((s) => s.name).join(', ') || '(none)'; - return `Skill "${name}" not available. Available: ${available}`; + return { markdown: `Skill "${name}" not available. Available: ${available}` }; } const body = await readFile(join(skill.path, 'SKILL.md'), 'utf-8'); if (!skillsLoadedPerWu.includes(skill.name)) { skillsLoadedPerWu.push(skill.name); } - return { + const structured = { name: skill.name, skillDirectory: skill.path, content: this.deps.skillsRegistry.stripFrontmatter(body), }; + return { + markdown: `# ${structured.name}\n\n${structured.content}`, + structured, + }; }, - }), + }, }; const priorProvenance = await this.deps.provenance.findLatestArtifactsForRawPaths( @@ -726,12 +731,15 @@ export class IngestBundleRunner { wu.rawFiles, ); const wuEmitUnmappedFallbackTool = { - emit_unmapped_fallback: createEmitUnmappedFallbackTool({ - stageIndex, - allowedPaths: new Set(wu.rawFiles), - tableRefExists: (tableRef) => - this.tableRefExistsInSemanticLayer(scopedSemanticLayerService, slConnectionIds, tableRef), - }), + emit_unmapped_fallback: createRuntimeToolDescriptorFromAiTool( + 'emit_unmapped_fallback', + createEmitUnmappedFallbackTool({ + stageIndex, + allowedPaths: new Set(wu.rawFiles), + tableRefExists: (tableRef) => + this.tableRefExistsInSemanticLayer(scopedSemanticLayerService, slConnectionIds, tableRef), + }), + ), }; const systemPrompt = buildWuSystemPrompt({ @@ -765,7 +773,7 @@ export class IngestBundleRunner { wu: wuInner, loadSkillTool, emitUnmappedFallbackTool: wuEmitUnmappedFallbackTool, - toolsetTools: wuToolset.toAiSdkTools(wuToolContext), + toolsetTools: wuToolset.toRuntimeTools(wuToolContext), }), join(transcriptDir, `${wuInner.unitKey}.jsonl`), wuInner.unitKey, @@ -921,53 +929,79 @@ export class IngestBundleRunner { ingest: ingestToolMetadata, session: rcToolSession, }; - const rcLoadSkill: Record = { - load_skill: tool({ + const rcLoadSkill: KtxRuntimeToolSet = { + load_skill: { + name: 'load_skill', description: 'Load a skill.', inputSchema: z.object({ name: z.string() }), execute: async ({ name }) => { const skill = await this.deps.skillsRegistry.getSkill(name, 'memory_agent'); if (!skill) { - return `Skill "${name}" not found`; + return { markdown: `Skill "${name}" not found` }; } const body = await readFile(join(skill.path, 'SKILL.md'), 'utf-8'); - return { name: skill.name, content: this.deps.skillsRegistry.stripFrontmatter(body) }; + const structured = { name: skill.name, content: this.deps.skillsRegistry.stripFrontmatter(body) }; + return { markdown: `# ${structured.name}\n\n${structured.content}`, structured }; }, - }), + }, }; const allStagedPaths = new Set([...currentHashes.keys()]); - const rcRawSpanTool = { read_raw_span: createReadRawSpanTool({ stagedDir, allowedPaths: allStagedPaths }) }; - const rcStageListTool = { stage_list: createStageListTool({ stageIndex }) }; - const rcStageDiffTool = { stage_diff: createStageDiffTool({ stageIndex }) }; + const rcRawSpanTool = { + read_raw_span: createRuntimeToolDescriptorFromAiTool( + 'read_raw_span', + createReadRawSpanTool({ stagedDir, allowedPaths: allStagedPaths }), + ), + }; + const rcStageListTool = { + stage_list: createRuntimeToolDescriptorFromAiTool('stage_list', createStageListTool({ stageIndex })), + }; + const rcStageDiffTool = { + stage_diff: createRuntimeToolDescriptorFromAiTool('stage_diff', createStageDiffTool({ stageIndex })), + }; const rcEvictionListTool = { - eviction_list: createEvictionListTool({ - provenance: this.deps.provenance, - connectionId: job.connectionId, - sourceKey: job.sourceKey, - deletedRawPaths: eviction?.deletedRawPaths ?? [], - }), + eviction_list: createRuntimeToolDescriptorFromAiTool( + 'eviction_list', + createEvictionListTool({ + provenance: this.deps.provenance, + connectionId: job.connectionId, + sourceKey: job.sourceKey, + deletedRawPaths: eviction?.deletedRawPaths ?? [], + }), + ), }; const rcEmitConflictResolutionTool = { - emit_conflict_resolution: createEmitConflictResolutionTool({ stageIndex }), + emit_conflict_resolution: createRuntimeToolDescriptorFromAiTool( + 'emit_conflict_resolution', + createEmitConflictResolutionTool({ stageIndex }), + ), }; const rcEmitEvictionDecisionTool = { - emit_eviction_decision: createEmitEvictionDecisionTool({ - stageIndex, - deletedRawPaths: eviction?.deletedRawPaths ?? [], - }), + emit_eviction_decision: createRuntimeToolDescriptorFromAiTool( + 'emit_eviction_decision', + createEmitEvictionDecisionTool({ + stageIndex, + deletedRawPaths: eviction?.deletedRawPaths ?? [], + }), + ), }; const rcEmitArtifactResolutionTool = { - emit_artifact_resolution: createEmitArtifactResolutionTool({ - stageIndex, - allowedPaths: allStagedPaths, - }), + emit_artifact_resolution: createRuntimeToolDescriptorFromAiTool( + 'emit_artifact_resolution', + createEmitArtifactResolutionTool({ + stageIndex, + allowedPaths: allStagedPaths, + }), + ), }; const rcEmitUnmappedFallbackTool = { - emit_unmapped_fallback: createEmitUnmappedFallbackTool({ - stageIndex, - allowedPaths: allStagedPaths, - tableRefExists: (tableRef) => this.tableRefExistsInSemanticLayer(rcScopedSl, slConnectionIds, tableRef), - }), + emit_unmapped_fallback: createRuntimeToolDescriptorFromAiTool( + 'emit_unmapped_fallback', + createEmitUnmappedFallbackTool({ + stageIndex, + allowedPaths: allStagedPaths, + tableRefExists: (tableRef) => this.tableRefExistsInSemanticLayer(rcScopedSl, slConnectionIds, tableRef), + }), + ), }; const reconcileBaseFraming = await this.deps.promptService.loadPrompt('memory_agent_bundle_ingest_reconcile'); @@ -1026,7 +1060,7 @@ export class IngestBundleRunner { emitArtifactResolutionTool: rcEmitArtifactResolutionTool, emitUnmappedFallbackTool: rcEmitUnmappedFallbackTool, readRawSpanTool: rcRawSpanTool, - toolsetTools: rcToolset.toAiSdkTools(rcToolContext), + toolsetTools: rcToolset.toRuntimeTools(rcToolContext), }), join(transcriptDir, 'reconcile.jsonl'), 'reconcile', @@ -1075,7 +1109,7 @@ export class IngestBundleRunner { emitArtifactResolutionTool: rcEmitArtifactResolutionTool, emitUnmappedFallbackTool: rcEmitUnmappedFallbackTool, readRawSpanTool: rcRawSpanTool, - toolsetTools: rcToolset.toAiSdkTools(rcToolContext), + toolsetTools: rcToolset.toRuntimeTools(rcToolContext), }), join(transcriptDir, 'reconcile.jsonl'), 'reconcile', diff --git a/packages/context/src/ingest/local-bundle-ingest.test.ts b/packages/context/src/ingest/local-bundle-ingest.test.ts index 79071134..44f25f2c 100644 --- a/packages/context/src/ingest/local-bundle-ingest.test.ts +++ b/packages/context/src/ingest/local-bundle-ingest.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Database from 'better-sqlite3'; import YAML from 'yaml'; -import { AgentRunnerService } from '../agent/index.js'; +import type { AgentRunnerPort, RunLoopParams } from '../llm/index.js'; import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../project/index.js'; import { makeLocalGitRepo } from '../test/make-local-git-repo.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -13,16 +13,12 @@ import { createDefaultLocalIngestAdapters, localPullConfigForAdapter } from './l import { getLocalIngestStatus, runLocalIngest } from './local-ingest.js'; import type { ChunkResult, DiffSet, SourceAdapter } from './types.js'; -class TestAgentRunner extends AgentRunnerService { - override runLoop = vi.fn().mockResolvedValue({ stopReason: 'natural' as const }); - - constructor() { - super({ llmProvider: { getModel: () => ({}) as never } as never }); - } +class TestAgentRunner implements AgentRunnerPort { + runLoop = vi.fn().mockResolvedValue({ stopReason: 'natural' as const }); } -class LookerSlWritingAgentRunner extends AgentRunnerService { - override runLoop = vi.fn(async (params: any) => { +class LookerSlWritingAgentRunner implements AgentRunnerPort { + runLoop = vi.fn(async (params: RunLoopParams) => { if ( params.telemetryTags?.operationName === 'ingest-bundle-wu' && params.telemetryTags?.unitKey === 'looker-explore-ecommerce-orders' @@ -31,130 +27,100 @@ class LookerSlWritingAgentRunner extends AgentRunnerService { if (!ledger?.execute) { throw new Error('record_verification_ledger tool was not available to the Looker WorkUnit'); } - await ledger.execute( - { - summary: 'Test fixture verified Looker explore target identifiers before writing SL.', - verifiedIdentifiers: ['prod-warehouse', 'public.orders'], - unverifiedIdentifiers: [], - }, - { toolCallId: 'looker-verification-ledger', messages: [] }, - ); + await ledger.execute({ + summary: 'Test fixture verified Looker explore target identifiers before writing SL.', + verifiedIdentifiers: ['prod-warehouse', 'public.orders'], + unverifiedIdentifiers: [], + }); const slWrite = params.toolSet.sl_write_source; if (!slWrite?.execute) { throw new Error('sl_write_source tool was not available to the Looker WorkUnit'); } - const result = await slWrite.execute( - { - connectionId: 'prod-warehouse', - sourceName: 'looker__ecommerce__orders', - source: { - name: 'looker__ecommerce__orders', - table: 'public.orders', - grain: ['id'], - columns: [ - { name: 'id', type: 'number' }, - { name: 'revenue', type: 'number' }, - ], - measures: [{ name: 'total_revenue', expr: 'sum(revenue)' }], - }, + const result = await slWrite.execute({ + connectionId: 'prod-warehouse', + sourceName: 'looker__ecommerce__orders', + source: { + name: 'looker__ecommerce__orders', + table: 'public.orders', + grain: ['id'], + columns: [ + { name: 'id', type: 'number' }, + { name: 'revenue', type: 'number' }, + ], + measures: [{ name: 'total_revenue', expr: 'sum(revenue)' }], }, - { toolCallId: 'looker-sl-write' }, - ); - if (!result.structured.success) { + }); + if (!(result.structured as { success?: boolean } | undefined)?.success) { throw new Error(result.markdown); } } return { stopReason: 'natural' as const }; }); - - constructor() { - super({ llmProvider: { getModel: () => ({}) as never } as never }); - } } -class WikiWritingAgentRunner extends AgentRunnerService { - override runLoop = vi.fn(async (params: any) => { +class WikiWritingAgentRunner implements AgentRunnerPort { + runLoop = vi.fn(async (params: RunLoopParams) => { if (params.telemetryTags?.operationName === 'ingest-bundle-wu') { const ledger = params.toolSet.record_verification_ledger; if (!ledger?.execute) { throw new Error('record_verification_ledger tool was not available to the WorkUnit'); } - await ledger.execute( - { - summary: 'Test fixture writes wiki-only context with no warehouse identifiers.', - verifiedIdentifiers: [], - unverifiedIdentifiers: [], - }, - { toolCallId: 'wiki-verification-ledger', messages: [] }, - ); + await ledger.execute({ + summary: 'Test fixture writes wiki-only context with no warehouse identifiers.', + verifiedIdentifiers: [], + unverifiedIdentifiers: [], + }); const wikiWrite = params.toolSet.wiki_write; if (!wikiWrite?.execute) { throw new Error('wiki_write tool was not available to the WorkUnit'); } - const result = await wikiWrite.execute( - { - key: 'orders_context', - summary: 'Orders source context', - content: 'Orders are purchase records used for revenue analysis.', - tags: ['orders'], - }, - { toolCallId: 'wiki-write' }, - ); - if (!result.structured.success) { + const result = await wikiWrite.execute({ + key: 'orders_context', + summary: 'Orders source context', + content: 'Orders are purchase records used for revenue analysis.', + tags: ['orders'], + }); + if (!(result.structured as { success?: boolean } | undefined)?.success) { throw new Error(result.markdown); } } return { stopReason: 'natural' as const }; }); - - constructor() { - super({ llmProvider: { getModel: () => ({}) as never } as never }); - } } -class WikiWritingWithRawPathAgentRunner extends AgentRunnerService { - override runLoop = vi.fn(async (params: any) => { +class WikiWritingWithRawPathAgentRunner implements AgentRunnerPort { + runLoop = vi.fn(async (params: RunLoopParams) => { if (params.telemetryTags?.operationName === 'ingest-bundle-wu') { const ledger = params.toolSet.record_verification_ledger; if (!ledger?.execute) { throw new Error('record_verification_ledger tool was not available to the WorkUnit'); } - await ledger.execute( - { - summary: 'Test fixture writes wiki-only context with explicit raw provenance and no warehouse identifiers.', - verifiedIdentifiers: [], - unverifiedIdentifiers: [], - }, - { toolCallId: 'wiki-raw-path-verification-ledger', messages: [] }, - ); + await ledger.execute({ + summary: 'Test fixture writes wiki-only context with explicit raw provenance and no warehouse identifiers.', + verifiedIdentifiers: [], + unverifiedIdentifiers: [], + }); const wikiWrite = params.toolSet.wiki_write; if (!wikiWrite?.execute) { throw new Error('wiki_write tool was not available to the WorkUnit'); } - const result = await wikiWrite.execute( - { - key: 'orders_context', - summary: 'Orders source context', - content: 'Orders are purchase records used for revenue analysis.', - tags: ['orders'], - rawPaths: ['orders/orders.json'], - }, - { toolCallId: 'wiki-write' }, - ); - if (!result.structured.success) { + const result = await wikiWrite.execute({ + key: 'orders_context', + summary: 'Orders source context', + content: 'Orders are purchase records used for revenue analysis.', + tags: ['orders'], + rawPaths: ['orders/orders.json'], + }); + if (!(result.structured as { success?: boolean } | undefined)?.success) { throw new Error(result.markdown); } } return { stopReason: 'natural' as const }; }); - - constructor() { - super({ llmProvider: { getModel: () => ({}) as never } as never }); - } } -class HistoricSqlEvidenceAgentRunner extends AgentRunnerService { - override runLoop = vi.fn(async (params: any) => { +class HistoricSqlEvidenceAgentRunner implements AgentRunnerPort { + runLoop = vi.fn(async (params: RunLoopParams) => { if ( params.telemetryTags?.operationName === 'ingest-bundle-wu' && params.telemetryTags?.unitKey === 'historic-sql-table-public-orders' @@ -163,31 +129,24 @@ class HistoricSqlEvidenceAgentRunner extends AgentRunnerService { if (!emitEvidence?.execute) { throw new Error('emit_historic_sql_evidence tool was not available to the historic-SQL WorkUnit'); } - const result = await emitEvidence.execute( - { - kind: 'table_usage', - table: 'public.orders', - rawPath: 'tables/public.orders.json', - usage: { - narrative: 'Orders are repeatedly queried by lifecycle status.', - frequencyTier: 'high', - commonFilters: ['status'], - commonJoins: [], - staleSince: null, - }, + const result = await emitEvidence.execute({ + kind: 'table_usage', + table: 'public.orders', + rawPath: 'tables/public.orders.json', + usage: { + narrative: 'Orders are repeatedly queried by lifecycle status.', + frequencyTier: 'high', + commonFilters: ['status'], + commonJoins: [], + staleSince: null, }, - { toolCallId: 'historic-sql-evidence' }, - ); - if (!String(result).includes('Recorded historic-SQL table_usage evidence')) { - throw new Error(`Unexpected historic-SQL evidence result: ${String(result)}`); + }); + if (!result.markdown.includes('Recorded historic-SQL table_usage evidence')) { + throw new Error(`Unexpected historic-SQL evidence result: ${result.markdown}`); } } return { stopReason: 'natural' as const }; }); - - constructor() { - super({ llmProvider: { getModel: () => ({}) as never } as never }); - } } class HistoricSqlEvidenceTestAdapter implements SourceAdapter { diff --git a/packages/context/src/ingest/local-bundle-runtime.test.ts b/packages/context/src/ingest/local-bundle-runtime.test.ts index bee28653..71e08817 100644 --- a/packages/context/src/ingest/local-bundle-runtime.test.ts +++ b/packages/context/src/ingest/local-bundle-runtime.test.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { AgentRunnerService } from '../agent/index.js'; +import type { AgentRunnerPort } from '../llm/index.js'; import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../project/index.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeSourceAdapter } from './adapters/fake/fake.adapter.js'; @@ -17,6 +17,10 @@ type RuntimeWithConnectionDeps = { }; }; +function testAgentRunner(): AgentRunnerPort { + return { runLoop: vi.fn().mockResolvedValue({ stopReason: 'natural' as const }) }; +} + describe('createLocalBundleIngestRuntime', () => { let tempDir: string; let project: KtxLocalProject; @@ -55,15 +59,42 @@ describe('createLocalBundleIngestRuntime', () => { }), ).toThrow( [ - 'ktx ingest requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.', - `Configure an Anthropic provider, then rerun ingest:`, - ` ktx setup --project-dir ${project.projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`, + 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.', + 'Configure a local Claude Code session or API-backed LLM, then rerun ingest:', + ` ktx setup --project-dir ${project.projectDir} --llm-backend claude-code --no-input`, + ` ktx setup --project-dir ${project.projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`, ].join('\n'), ); }); + it('uses a runtime-backed agent runner when claude-code is configured', () => { + const runtime = { + generateText: vi.fn(), + generateObject: vi.fn(), + runAgentLoop: vi.fn(async () => ({ stopReason: 'natural' as const })), + }; + project.config.llm = { + provider: { backend: 'claude-code' }, + models: { default: 'sonnet' }, + promptCaching: { enabled: false }, + }; + const createLlmRuntime = vi.fn(() => runtime); + + const created = createLocalBundleIngestRuntime({ + project, + adapters: [new FakeSourceAdapter()], + createLlmRuntime, + }); + + expect(created).toBeDefined(); + expect(createLlmRuntime).toHaveBeenCalledWith( + project.config.llm, + expect.objectContaining({ projectDir: project.projectDir }), + ); + }); + it('builds runner deps with local SQLite stores and context tools enabled', async () => { - const agentRunner = new AgentRunnerService({ llmProvider: { getModel: () => ({}) as never } as any }); + const agentRunner = testAgentRunner(); const runtime = createLocalBundleIngestRuntime({ project, @@ -94,7 +125,7 @@ describe('createLocalBundleIngestRuntime', () => { project_id: 'acme', dataset_id: 'warehouse', }; - const agentRunner = new AgentRunnerService({ llmProvider: { getModel: () => ({}) as never } as any }); + const agentRunner = testAgentRunner(); const runtime = createLocalBundleIngestRuntime({ project, @@ -114,7 +145,7 @@ describe('createLocalBundleIngestRuntime', () => { }); it('passes project connection config to local ingest query executors', async () => { - const agentRunner = new AgentRunnerService({ llmProvider: { getModel: () => ({}) as never } as any }); + const agentRunner = testAgentRunner(); const queryExecutor = { execute: vi.fn(async () => ({ headers: ['answer'], diff --git a/packages/context/src/ingest/local-bundle-runtime.ts b/packages/context/src/ingest/local-bundle-runtime.ts index 047b7ee6..4f52684e 100644 --- a/packages/context/src/ingest/local-bundle-runtime.ts +++ b/packages/context/src/ingest/local-bundle-runtime.ts @@ -1,20 +1,20 @@ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { KtxLlmProvider } from '@ktx/llm'; -import type { Tool } from 'ai'; import YAML from 'yaml'; -import type { AgentRunnerService } from '../agent/index.js'; -import { AgentRunnerService as DefaultAgentRunnerService } from '../agent/index.js'; import { localConnectionInfoFromConfig, type KtxSqlQueryExecutorPort } from '../connections/index.js'; import type { KtxEmbeddingPort, KtxLogger } from '../core/index.js'; import { noopLogger, SessionWorktreeService } from '../core/index.js'; import type { KtxSemanticLayerComputePort } from '../daemon/index.js'; import { - createJsonlKtxLlmDebugRequestRecorder, + createRuntimeToolDescriptorFromAiTool, createLocalKtxEmbeddingProviderFromConfig, - createLocalKtxLlmProviderFromConfig, + createLocalKtxLlmRuntimeFromConfig, KtxIngestEmbeddingPortAdapter, + RuntimeAgentRunner, + type AgentRunnerPort, + type KtxLlmRuntimePort, + type KtxRuntimeToolSet, } from '../llm/index.js'; import type { KtxLocalProject } from '../project/index.js'; import { ktxLocalStateDbPath } from '../project/index.js'; @@ -100,8 +100,9 @@ const LOCAL_SHAPE_WARNING = 'Local ingest validates semantic-layer YAML shape on export interface CreateLocalBundleIngestRuntimeOptions { project: KtxLocalProject; adapters: SourceAdapter[]; - agentRunner?: AgentRunnerService; - llmProvider?: KtxLlmProvider; + agentRunner?: AgentRunnerPort; + llmRuntime?: KtxLlmRuntimePort; + createLlmRuntime?: typeof createLocalKtxLlmRuntimeFromConfig; llmDebugRequestFile?: string; memoryModel?: string; semanticLayerCompute?: KtxSemanticLayerComputePort; @@ -456,12 +457,12 @@ class NoopKnowledgeEventPort implements KnowledgeEventPort { class LocalIngestToolSet implements IngestToolsetLike { constructor( private readonly tools: BaseTool[], - private readonly sourceTools: Record = {}, + private readonly sourceTools: KtxRuntimeToolSet = {}, ) {} - toAiSdkTools(context: ToolContext) { + toRuntimeTools(context: ToolContext): KtxRuntimeToolSet { return { - ...Object.fromEntries(this.tools.map((tool) => [tool.name, tool.toAiSdkTool(context)])), + ...Object.fromEntries(this.tools.map((tool) => [tool.name, tool.toRuntimeTool(context)])), ...this.sourceTools, }; } @@ -541,13 +542,16 @@ class LocalIngestToolsetFactory implements IngestToolsetFactoryPort { } createIngestWuToolset(session: ToolSession, options?: { includeContextEvidenceTools?: boolean }): IngestToolsetLike { - const sourceTools: Record = + const sourceTools: KtxRuntimeToolSet = session.ingest?.sourceKey === 'historic-sql' ? { - emit_historic_sql_evidence: createEmitHistoricSqlEvidenceTool({ - connectionId: session.connectionId, - session, - }), + emit_historic_sql_evidence: createRuntimeToolDescriptorFromAiTool( + 'emit_historic_sql_evidence', + createEmitHistoricSqlEvidenceTool({ + connectionId: session.connectionId, + session, + }), + ), } : {}; return new LocalIngestToolSet( @@ -571,36 +575,36 @@ function nextLocalJobId(): string { function localIngestLlmProviderGuardMessage(projectDir: string): string { return [ - 'ktx ingest requires llm.provider.backend: anthropic, vertex, or gateway, or an injected agentRunner.', - 'Configure an Anthropic provider, then rerun ingest:', - ` ktx setup --project-dir ${projectDir} --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`, + 'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.', + 'Configure a local Claude Code session or API-backed LLM, then rerun ingest:', + ` ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`, + ` ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`, ].join('\n'); } function resolveAgentRunner(options: CreateLocalBundleIngestRuntimeOptions): { - agentRunner: AgentRunnerService; - llmProvider?: KtxLlmProvider; + agentRunner: AgentRunnerPort; + llmRuntime?: KtxLlmRuntimePort; } { - const llmProvider = - options.llmProvider ?? createLocalKtxLlmProviderFromConfig(options.project.config.llm) ?? undefined; + const llmRuntime = + options.llmRuntime ?? + (options.createLlmRuntime ?? createLocalKtxLlmRuntimeFromConfig)(options.project.config.llm, { + projectDir: options.project.projectDir, + env: process.env, + }) ?? + undefined; if (options.agentRunner) { - return { agentRunner: options.agentRunner, ...(llmProvider ? { llmProvider } : {}) }; + return { agentRunner: options.agentRunner, ...(llmRuntime ? { llmRuntime } : {}) }; } - if (!llmProvider) { + if (!llmRuntime) { throw new Error(localIngestLlmProviderGuardMessage(options.project.projectDir)); } return { - agentRunner: new DefaultAgentRunnerService({ - llmProvider, - logger: options.logger ?? noopLogger, - ...(options.llmDebugRequestFile - ? { debugRequestRecorder: createJsonlKtxLlmDebugRequestRecorder(options.llmDebugRequestFile) } - : {}), - }), - llmProvider, + agentRunner: new RuntimeAgentRunner(llmRuntime), + llmRuntime, }; } @@ -627,7 +631,7 @@ export function createLocalBundleIngestRuntime( const knowledgeIndex = new LocalKnowledgeIndex(options.project, embedding); const knowledgeEvents = new NoopKnowledgeEventPort(); const wikiService = new KnowledgeWikiService(rootFileStore, embedding, knowledgeIndex, options.project.git, logger); - const { agentRunner, llmProvider } = resolveAgentRunner(options); + const { agentRunner, llmRuntime } = resolveAgentRunner(options); const promptService = new PromptService({ promptsDir, partials: [], logger }); const storage = new LocalIngestStorage(options.project); const registry = registerAdapters(options.adapters); @@ -681,10 +685,11 @@ export function createLocalBundleIngestRuntime( commitMessages: new LocalCommitMessagePort(), embedding, contextEvidenceIndex: new ContextEvidenceIndexService({ store: contextStore, embeddings: embedding, logger }), - pageTriage: llmProvider + llmRuntime, + pageTriage: llmRuntime ? new PageTriageService({ store: contextStore, - llmProvider, + llmRuntime, settings: { enabled: true, maxConcurrency: 2, diff --git a/packages/context/src/ingest/local-ingest.ts b/packages/context/src/ingest/local-ingest.ts index 6056f6ed..b64fdcb7 100644 --- a/packages/context/src/ingest/local-ingest.ts +++ b/packages/context/src/ingest/local-ingest.ts @@ -1,11 +1,10 @@ import { randomUUID } from 'node:crypto'; import { cp, mkdir, rm } from 'node:fs/promises'; import { isAbsolute, resolve } from 'node:path'; -import type { KtxLlmProvider } from '@ktx/llm'; -import type { AgentRunnerService } from '../agent/index.js'; import type { KtxSqlQueryExecutorPort } from '../connections/index.js'; import type { KtxLogger } from '../core/index.js'; import type { KtxSemanticLayerComputePort } from '../daemon/index.js'; +import type { AgentRunnerPort, KtxLlmRuntimePort } from '../llm/index.js'; import type { KtxLocalProject } from '../project/index.js'; import { ktxLocalStateDbPath } from '../project/index.js'; import { planMetabaseFanoutChildren } from './adapters/metabase/fanout-planner.js'; @@ -28,8 +27,8 @@ export interface RunLocalIngestOptions { trigger?: IngestTrigger; jobId?: string; memoryFlow?: MemoryFlowEventSink; - agentRunner?: AgentRunnerService; - llmProvider?: KtxLlmProvider; + agentRunner?: AgentRunnerPort; + llmRuntime?: KtxLlmRuntimePort; llmDebugRequestFile?: string; memoryModel?: string; semanticLayerCompute?: KtxSemanticLayerComputePort; @@ -41,7 +40,7 @@ export interface LocalIngestMcpOptions extends Pick< RunLocalIngestOptions, | 'agentRunner' - | 'llmProvider' + | 'llmRuntime' | 'memoryModel' | 'semanticLayerCompute' | 'queryExecutor' @@ -167,8 +166,8 @@ async function runScheduledPullJob(options: { trigger?: IngestTrigger; jobId?: string; memoryFlow?: MemoryFlowEventSink; - agentRunner?: AgentRunnerService; - llmProvider?: KtxLlmProvider; + agentRunner?: AgentRunnerPort; + llmRuntime?: KtxLlmRuntimePort; memoryModel?: string; semanticLayerCompute?: KtxSemanticLayerComputePort; queryExecutor?: KtxSqlQueryExecutorPort; @@ -221,7 +220,7 @@ export async function runLocalIngest(options: RunLocalIngestOptions): Promise[0]) => { +class TestAgentRunner implements AgentRunnerPort { + runLoop = vi.fn(async (params: RunLoopParams) => { if (params.userPrompt.includes('metabase-db-2')) { return { stopReason: 'error' as const, error: new Error('database 2 failed') }; } return { stopReason: 'natural' as const }; }); - - constructor() { - super({ llmProvider: { getModel: () => ({}) as never } as never }); - } } class FakeMetabaseSourceAdapter implements SourceAdapter { diff --git a/packages/context/src/ingest/page-triage/page-triage.service.test.ts b/packages/context/src/ingest/page-triage/page-triage.service.test.ts index 4fd57c42..6432347d 100644 --- a/packages/context/src/ingest/page-triage/page-triage.service.test.ts +++ b/packages/context/src/ingest/page-triage/page-triage.service.test.ts @@ -21,7 +21,11 @@ describe('PageTriageService', () => { }; let promptService: { loadPrompt: ReturnType Promise>> }; let adapter: { triageSupported: true; getTriageSignals: ReturnType }; - let generateTextMock: ReturnType; + let llmRuntime: { + generateText: ReturnType; + generateObject: ReturnType; + runAgentLoop: ReturnType; + }; beforeEach(async () => { stagedDir = await mkdtemp(join(tmpdir(), 'page-triage-')); @@ -88,31 +92,16 @@ describe('PageTriageService', () => { .fn<(name: string) => Promise>() .mockImplementation((name) => Promise.resolve(`prompt:${name}`)), }; - generateTextMock = vi.fn(); + llmRuntime = { + generateText: vi.fn(), + generateObject: vi.fn(), + runAgentLoop: vi.fn(), + }; service = new PageTriageService({ store: repository as any, - llmProvider: { - getModel: vi.fn().mockReturnValue('model'), - getModelByName: vi.fn(), - cacheMarker: vi.fn(), - repairToolCallHandler: vi.fn(), - thinkingProviderOptions: vi.fn(), - telemetryConfig: vi.fn(), - promptCachingConfig: vi.fn(() => ({ - enabled: false, - systemTtl: '1h', - toolsTtl: '1h', - historyTtl: '5m', - cacheSystem: true, - cacheTools: true, - cacheHistory: true, - vertexFallbackTo5m: false, - })), - activeBackend: vi.fn(() => 'anthropic'), - } as any, + llmRuntime: llmRuntime as any, settings: triageSettings, promptService: promptService as any, - generateText: generateTextMock as any, }); }); @@ -121,10 +110,10 @@ describe('PageTriageService', () => { }); it('writes light-lane candidates and keeps the page out of full WorkUnits', async () => { - generateTextMock - .mockResolvedValueOnce({ text: JSON.stringify({ lane: 'light', reason: 'short durable policy' }) } as any) - .mockResolvedValueOnce({ - text: JSON.stringify({ + llmRuntime.generateText + .mockResolvedValueOnce(JSON.stringify({ lane: 'light', reason: 'short durable policy' })) + .mockResolvedValueOnce( + JSON.stringify({ candidates: [ { candidateKey: 'support-handoff-owner', @@ -142,7 +131,7 @@ describe('PageTriageService', () => { }, ], }), - } as any); + ); const result = await service.triageRun({ stagedDir, @@ -171,6 +160,7 @@ describe('PageTriageService', () => { }); expect(result.fullRawPaths.has('pages/page-1/page.md')).toBe(false); expect(adapter.getTriageSignals).toHaveBeenCalledWith(stagedDir, 'page-1'); + expect(llmRuntime.generateText).toHaveBeenCalledWith(expect.objectContaining({ role: 'triage' })); expect(repository.setDocumentTriageLane).toHaveBeenCalledWith('run-1', 'pages/page-1/page.md', 'light'); expect(repository.insertCandidate).toHaveBeenCalledWith( expect.objectContaining({ @@ -225,23 +215,20 @@ describe('PageTriageService', () => { } return Promise.resolve(`prompt:${name}`); }); - generateTextMock + llmRuntime.generateText .mockImplementationOnce((args: any) => { - const systemMessage = args.system ?? args.messages.find((m: { role: string }) => m.role === 'system'); - const userMessage = args.messages.find((m: { role: string }) => m.role === 'user'); - const systemText = - typeof systemMessage === 'string' ? systemMessage : (systemMessage.content as string); - const userText = userMessage.content as string; + const systemText = args.system as string; + const userText = args.prompt as string; expect(systemText).toContain( 'Reusable templates and scripts are durable knowledge regardless of subject matter.', ); expect(systemText).toContain('Date-titled standups are still skip; named templates and scripts are not.'); expect(userText).toContain('Cold Call Script'); expect(userText).not.toContain('Reusable templates and scripts are durable knowledge'); - return { text: JSON.stringify({ lane: 'light', reason: 'reusable sales script' }) } as any; + return JSON.stringify({ lane: 'light', reason: 'reusable sales script' }); }) - .mockResolvedValueOnce({ - text: JSON.stringify({ + .mockResolvedValueOnce( + JSON.stringify({ candidates: [ { candidateKey: 'cold-call-script', @@ -259,7 +246,7 @@ describe('PageTriageService', () => { }, ], }), - } as any); + ); const result = await service.triageRun({ stagedDir, @@ -312,9 +299,7 @@ describe('PageTriageService', () => { 'utf-8', ); - generateTextMock.mockResolvedValue({ - text: JSON.stringify({ lane: 'full', reason: 'durable policy page' }), - } as any); + llmRuntime.generateText.mockResolvedValue(JSON.stringify({ lane: 'full', reason: 'durable policy page' })); const result = await service.triageRun({ stagedDir, @@ -351,7 +336,7 @@ describe('PageTriageService', () => { }); it('falls back to full when classifier output is malformed', async () => { - generateTextMock.mockResolvedValueOnce({ text: 'not-json' } as any); + llmRuntime.generateText.mockResolvedValueOnce('not-json'); const result = await service.triageRun({ stagedDir, @@ -370,8 +355,8 @@ describe('PageTriageService', () => { }); it('promotes a light page to full when light extraction fails', async () => { - generateTextMock - .mockResolvedValueOnce({ text: JSON.stringify({ lane: 'light', reason: 'short durable policy' }) } as any) + llmRuntime.generateText + .mockResolvedValueOnce(JSON.stringify({ lane: 'light', reason: 'short durable policy' })) .mockRejectedValueOnce(new Error('provider unavailable')); const result = await service.triageRun({ @@ -405,7 +390,7 @@ describe('PageTriageService', () => { }); expect(result).toEqual({ enabled: false, report: undefined, fullRawPaths: new Set(), warnings: [] }); - expect(generateTextMock).not.toHaveBeenCalled(); + expect(llmRuntime.generateText).not.toHaveBeenCalled(); expect(repository.setDocumentTriageLane).not.toHaveBeenCalled(); }); }); diff --git a/packages/context/src/ingest/page-triage/page-triage.service.ts b/packages/context/src/ingest/page-triage/page-triage.service.ts index 765b4c21..cb9ea471 100644 --- a/packages/context/src/ingest/page-triage/page-triage.service.ts +++ b/packages/context/src/ingest/page-triage/page-triage.service.ts @@ -1,11 +1,10 @@ import { createHash } from 'node:crypto'; import { readdir, readFile } from 'node:fs/promises'; import { dirname, join, relative } from 'node:path'; -import { KtxMessageBuilder, splitKtxSystemMessages, type KtxLlmProvider } from '@ktx/llm'; -import { generateText, type ToolSet } from 'ai'; import pLimit from 'p-limit'; import { z } from 'zod'; import { type KtxLogger, noopLogger } from '../../core/index.js'; +import type { KtxLlmRuntimePort } from '../../llm/index.js'; import type { PromptService } from '../../prompts/index.js'; import type { InsertContextCandidateInput } from '../context-candidates/index.js'; import type { JsonValue } from '../ports.js'; @@ -100,20 +99,17 @@ export interface PageTriageSettings { export interface PageTriageServiceDeps { store: PageTriageStorePort; - llmProvider: KtxLlmProvider; + llmRuntime: KtxLlmRuntimePort; settings: PageTriageSettings; promptService: PromptService; logger?: KtxLogger; - generateText?: typeof generateText; } export class PageTriageService { private readonly logger: KtxLogger; - private readonly runGenerateText: typeof generateText; constructor(private readonly deps: PageTriageServiceDeps) { this.logger = deps.logger ?? noopLogger; - this.runGenerateText = deps.generateText ?? generateText; } async triageRun(args: PageTriageRunArgs): Promise { @@ -339,22 +335,12 @@ export class PageTriageService { jobId: string; unitKey: string; }): Promise { - const model = this.deps.llmProvider.getModel('triage'); - const built = new KtxMessageBuilder(this.deps.llmProvider).wrapSimple({ + return this.deps.llmRuntime.generateText({ + role: 'triage', system: params.system, - messages: [{ role: 'user', content: params.prompt }], - tools: {}, - model, - }); - const split = splitKtxSystemMessages(built.messages); - const result = await this.runGenerateText({ - model, + prompt: params.prompt, temperature: 0, - ...(split.system ? { system: split.system } : {}), - messages: split.messages, - tools: built.tools as ToolSet, }); - return result.text; } private async buildClassifierSystem(): Promise { diff --git a/packages/context/src/ingest/ports.ts b/packages/context/src/ingest/ports.ts index fbb2451e..6f0e9f1e 100644 --- a/packages/context/src/ingest/ports.ts +++ b/packages/context/src/ingest/ports.ts @@ -1,8 +1,7 @@ -import type { ToolSet } from 'ai'; import type { KtxModelRole } from '@ktx/llm'; -import type { AgentRunnerService } from '../agent/index.js'; import type { KtxEmbeddingPort } from '../core/embedding.js'; import type { GitService, KtxFileStorePort, KtxLogger, SessionOutcome } from '../core/index.js'; +import type { AgentRunnerPort, KtxLlmRuntimePort, KtxRuntimeToolSet } from '../llm/index.js'; import type { CaptureSession, MemoryAction, MemoryKnowledgeSlRefsPort } from '../memory/index.js'; import type { PromptService } from '../prompts/index.js'; import type { SkillsRegistryService } from '../skills/index.js'; @@ -163,7 +162,7 @@ export interface IngestCommitMessagePort { } export interface IngestToolsetLike { - toAiSdkTools(context: ToolContext): ToolSet; + toRuntimeTools(context: ToolContext): KtxRuntimeToolSet; } export interface IngestToolsetFactoryPort { @@ -315,7 +314,7 @@ export interface CuratorPaginationPort { items: ReconcileCandidateForPrompt[]; runState: ReconcilePromptRunState; }) => string; - buildToolSet: (passNumber: number) => ToolSet; + buildToolSet: (passNumber: number) => KtxRuntimeToolSet; getReconciliationActions: () => MemoryAction[]; onStepFinish?: (info: { passNumber: number; stepIndex: number; stepBudget: number }) => void; }): Promise; @@ -350,7 +349,8 @@ export interface IngestBundleRunnerDeps { registry: SourceAdapterRegistryPort; diffSetService: DiffSetComputerPort; sessionWorktreeService: IngestSessionWorktreePort; - agentRunner: AgentRunnerService; + agentRunner: AgentRunnerPort; + llmRuntime?: KtxLlmRuntimePort; gitService: GitService; lockingService: IngestLockPort; storage: IngestStoragePort; diff --git a/packages/context/src/ingest/stages/build-reconcile-context.test.ts b/packages/context/src/ingest/stages/build-reconcile-context.test.ts index 9ac95356..8de7611a 100644 --- a/packages/context/src/ingest/stages/build-reconcile-context.test.ts +++ b/packages/context/src/ingest/stages/build-reconcile-context.test.ts @@ -141,26 +141,17 @@ describe('buildReconcileToolSet', () => { toolsetTools: { sl_write_source: { description: 'sl write', inputSchema: {} as any, execute: slWrite } as any }, }); - const correction = await toolSet.sl_write_source.execute?.( - { connectionId: 'warehouse', sourceName: 'accounts' }, - { toolCallId: 't1' } as any, - ); + const correction = await toolSet.sl_write_source.execute?.({ connectionId: 'warehouse', sourceName: 'accounts' }); expect(slWrite).not.toHaveBeenCalled(); expect(correction).toMatchObject({ structured: { success: false, reason: 'verification_ledger_required' } }); - await toolSet.record_verification_ledger.execute?.( - { - summary: 'Verified warehouse.accounts with entity_details.', - verifiedIdentifiers: ['warehouse.accounts'], - unverifiedIdentifiers: [], - }, - { toolCallId: 't2' } as any, - ); - const written = await toolSet.sl_write_source.execute?.( - { connectionId: 'warehouse', sourceName: 'accounts' }, - { toolCallId: 't3' } as any, - ); + await toolSet.record_verification_ledger.execute?.({ + summary: 'Verified warehouse.accounts with entity_details.', + verifiedIdentifiers: ['warehouse.accounts'], + unverifiedIdentifiers: [], + }); + const written = await toolSet.sl_write_source.execute?.({ connectionId: 'warehouse', sourceName: 'accounts' }); expect(slWrite).toHaveBeenCalledTimes(1); expect(written).toMatchObject({ structured: { success: true } }); diff --git a/packages/context/src/ingest/stages/build-reconcile-context.ts b/packages/context/src/ingest/stages/build-reconcile-context.ts index 9533acbd..366eeb4f 100644 --- a/packages/context/src/ingest/stages/build-reconcile-context.ts +++ b/packages/context/src/ingest/stages/build-reconcile-context.ts @@ -1,5 +1,5 @@ -import type { Tool, ToolSet } from 'ai'; import { buildCanonicalPinsPromptBlock, type CanonicalPin } from '../canonical-pins.js'; +import type { KtxRuntimeToolSet } from '../../llm/index.js'; import { createVerificationLedgerState, VERIFICATION_LEDGER_PROMPT, @@ -181,19 +181,19 @@ export function buildReconcileUserPrompt( } export interface ReconcileToolSetInput { - loadSkillTool: Record; - stageListTool: Record; - stageDiffTool: Record; - evictionListTool: Record; - emitConflictResolutionTool: Record; - emitEvictionDecisionTool: Record; - emitArtifactResolutionTool: Record; - emitUnmappedFallbackTool: Record; - readRawSpanTool: Record; - toolsetTools: ToolSet; + loadSkillTool: KtxRuntimeToolSet; + stageListTool: KtxRuntimeToolSet; + stageDiffTool: KtxRuntimeToolSet; + evictionListTool: KtxRuntimeToolSet; + emitConflictResolutionTool: KtxRuntimeToolSet; + emitEvictionDecisionTool: KtxRuntimeToolSet; + emitArtifactResolutionTool: KtxRuntimeToolSet; + emitUnmappedFallbackTool: KtxRuntimeToolSet; + readRawSpanTool: KtxRuntimeToolSet; + toolsetTools: KtxRuntimeToolSet; } -export function buildReconcileToolSet(input: ReconcileToolSetInput): ToolSet { +export function buildReconcileToolSet(input: ReconcileToolSetInput): KtxRuntimeToolSet { const state = createVerificationLedgerState(); return withVerificationLedger( { diff --git a/packages/context/src/ingest/stages/build-wu-context.test.ts b/packages/context/src/ingest/stages/build-wu-context.test.ts index db17154e..81c0c923 100644 --- a/packages/context/src/ingest/stages/build-wu-context.test.ts +++ b/packages/context/src/ingest/stages/build-wu-context.test.ts @@ -87,21 +87,18 @@ describe('buildWuToolSet', () => { toolsetTools: { wiki_write: { description: 'write', inputSchema: {} as any, execute: wikiWrite } as any }, }); - const correction = await toolSet.wiki_write.execute?.({ key: 'customer-rules' }, { toolCallId: 't1' } as any); + const correction = await toolSet.wiki_write.execute?.({ key: 'customer-rules' }); expect(wikiWrite).not.toHaveBeenCalled(); expect(correction).toMatchObject({ structured: { success: false, reason: 'verification_ledger_required' } }); expect(String((correction as any).markdown)).toContain('record_verification_ledger'); - await toolSet.record_verification_ledger.execute?.( - { - summary: 'No warehouse identifiers will be emitted in this wiki write.', - verifiedIdentifiers: [], - unverifiedIdentifiers: [], - }, - { toolCallId: 't2' } as any, - ); - const written = await toolSet.wiki_write.execute?.({ key: 'customer-rules' }, { toolCallId: 't3' } as any); + await toolSet.record_verification_ledger.execute?.({ + summary: 'No warehouse identifiers will be emitted in this wiki write.', + verifiedIdentifiers: [], + unverifiedIdentifiers: [], + }); + const written = await toolSet.wiki_write.execute?.({ key: 'customer-rules' }); expect(wikiWrite).toHaveBeenCalledTimes(1); expect(written).toMatchObject({ structured: { success: true } }); diff --git a/packages/context/src/ingest/stages/build-wu-context.ts b/packages/context/src/ingest/stages/build-wu-context.ts index bfa1bd9c..14c2912b 100644 --- a/packages/context/src/ingest/stages/build-wu-context.ts +++ b/packages/context/src/ingest/stages/build-wu-context.ts @@ -1,6 +1,6 @@ -import type { Tool, ToolSet } from 'ai'; import { buildCanonicalPinsPromptBlock, type CanonicalPin } from '../canonical-pins.js'; import { createLookerQueryToSlTool } from '../adapters/looker/tools/looker-query-to-sl.tool.js'; +import { createRuntimeToolDescriptorFromAiTool, type KtxRuntimeToolSet } from '../../llm/index.js'; import type { IngestProvenanceRow } from '../ports.js'; import { createReadRawFileTool } from '../tools/read-raw-file.tool.js'; import { createReadRawSpanTool } from '../tools/read-raw-span.tool.js'; @@ -88,12 +88,12 @@ export interface BuildWuToolSetInput { sourceKey?: string; stagedDir: string; wu: WorkUnit; - loadSkillTool: Record; - emitUnmappedFallbackTool: Record; - toolsetTools: ToolSet; + loadSkillTool: KtxRuntimeToolSet; + emitUnmappedFallbackTool: KtxRuntimeToolSet; + toolsetTools: KtxRuntimeToolSet; } -function withoutWriteSlTools(toolset: ToolSet, wu: WorkUnit): ToolSet { +function withoutWriteSlTools(toolset: KtxRuntimeToolSet, wu: WorkUnit): KtxRuntimeToolSet { if (!wu.slDisallowed) { return toolset; } @@ -103,9 +103,12 @@ function withoutWriteSlTools(toolset: ToolSet, wu: WorkUnit): ToolSet { return next; } -export function buildWuToolSet(input: BuildWuToolSetInput): ToolSet { +export function buildWuToolSet(input: BuildWuToolSetInput): KtxRuntimeToolSet { const allowedPaths = new Set([...input.wu.rawFiles, ...input.wu.dependencyPaths]); - const lookerTools: ToolSet = input.sourceKey === 'looker' ? { looker_query_to_sl: createLookerQueryToSlTool() } : {}; + const lookerTools: KtxRuntimeToolSet = + input.sourceKey === 'looker' + ? { looker_query_to_sl: createRuntimeToolDescriptorFromAiTool('looker_query_to_sl', createLookerQueryToSlTool()) } + : {}; const state = createVerificationLedgerState(); return withVerificationLedger( withoutWriteSlTools( @@ -114,8 +117,14 @@ export function buildWuToolSet(input: BuildWuToolSetInput): ToolSet { ...lookerTools, ...input.loadSkillTool, ...input.emitUnmappedFallbackTool, - read_raw_file: createReadRawFileTool({ stagedDir: input.stagedDir, allowedPaths }), - read_raw_span: createReadRawSpanTool({ stagedDir: input.stagedDir, allowedPaths }), + read_raw_file: createRuntimeToolDescriptorFromAiTool( + 'read_raw_file', + createReadRawFileTool({ stagedDir: input.stagedDir, allowedPaths }), + ), + read_raw_span: createRuntimeToolDescriptorFromAiTool( + 'read_raw_span', + createReadRawSpanTool({ stagedDir: input.stagedDir, allowedPaths }), + ), }, input.wu, ), diff --git a/packages/context/src/ingest/stages/stage-3-work-units.ts b/packages/context/src/ingest/stages/stage-3-work-units.ts index b6e64f86..1e089726 100644 --- a/packages/context/src/ingest/stages/stage-3-work-units.ts +++ b/packages/context/src/ingest/stages/stage-3-work-units.ts @@ -1,6 +1,5 @@ -import type { AgentRunnerService } from '@ktx/context/agent'; import type { KtxModelRole } from '@ktx/llm'; -import type { Tool } from 'ai'; +import type { AgentRunnerPort, KtxRuntimeToolSet } from '@ktx/context'; import type { CaptureSession, MemoryAction } from '../../memory/index.js'; import { listTouchedSlSources, type TouchedSlSource } from '../../tools/index.js'; import type { WorkUnit } from '../types.js'; @@ -14,12 +13,12 @@ export interface TouchedValidationResult { export interface WorkUnitExecutionDeps { sessionWorktreeGit: { revParseHead(): Promise }; - agentRunner: AgentRunnerService; + agentRunner: AgentRunnerPort; validateTouchedSources: (touched: TouchedSlSource[]) => Promise; resetHardTo: (targetSha: string) => Promise; buildSystemPrompt: (wu: WorkUnit) => string; buildUserPrompt: (wu: WorkUnit) => string; - buildToolSet: (wu: WorkUnit) => Record; + buildToolSet: (wu: WorkUnit) => KtxRuntimeToolSet; captureSession: CaptureSession; sessionActions: MemoryAction[]; modelRole: KtxModelRole; diff --git a/packages/context/src/ingest/stages/stage-4-reconciliation.ts b/packages/context/src/ingest/stages/stage-4-reconciliation.ts index 00bb2d2c..59f3235e 100644 --- a/packages/context/src/ingest/stages/stage-4-reconciliation.ts +++ b/packages/context/src/ingest/stages/stage-4-reconciliation.ts @@ -1,16 +1,15 @@ -import type { AgentRunnerService } from '@ktx/context/agent'; +import type { AgentRunnerPort, KtxRuntimeToolSet } from '@ktx/context'; import type { KtxModelRole } from '@ktx/llm'; -import type { ToolSet } from 'ai'; import type { EvictionUnit } from '../types.js'; import type { StageIndex } from './stage-index.types.js'; export interface ReconciliationContext { stageIndex: StageIndex; evictionUnit: EvictionUnit | undefined; - agentRunner: AgentRunnerService; + agentRunner: AgentRunnerPort; buildSystemPrompt: (idx: StageIndex, ev: EvictionUnit | undefined) => string; buildUserPrompt: (idx: StageIndex, ev: EvictionUnit | undefined) => string; - buildToolSet: () => ToolSet; + buildToolSet: () => KtxRuntimeToolSet; modelRole: KtxModelRole; stepBudget: number; sourceKey: string; diff --git a/packages/context/src/ingest/tools/tool-call-logger.ts b/packages/context/src/ingest/tools/tool-call-logger.ts index 5a4aefde..c91a74c7 100644 --- a/packages/context/src/ingest/tools/tool-call-logger.ts +++ b/packages/context/src/ingest/tools/tool-call-logger.ts @@ -1,6 +1,6 @@ import { appendFile, mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; -import type { ToolExecuteFunction, ToolExecutionOptions, ToolSet } from 'ai'; +import type { KtxRuntimeToolSet } from '../../llm/index.js'; export interface ToolCallLogEntry { ts: string; @@ -31,7 +31,7 @@ interface ToolCallLoggerOptions { * sequential (`generateText` awaits each tool result), so per-WU files are * effectively single-writer and lines land in call order. */ -export function wrapToolsWithLogger( +export function wrapToolsWithLogger( tools: T, logFilePath: string, wuKey: string, @@ -44,17 +44,13 @@ export function wrapToolsWithLogger( wrapped[name] = original; continue; } - const wrappedExecute: ToolExecuteFunction = async ( - input: unknown, - opts: ToolExecutionOptions, - ) => { + const wrappedExecute = async (input: unknown) => { const start = Date.now(); try { - const output = await (originalExecute as ToolExecuteFunction)(input, opts); + const output = await originalExecute(input); const entry: ToolCallLogEntry = { ts: new Date().toISOString(), wuKey, - toolCallId: opts.toolCallId, toolName: name, durationMs: Date.now() - start, input, @@ -67,7 +63,6 @@ export function wrapToolsWithLogger( const entry: ToolCallLogEntry = { ts: new Date().toISOString(), wuKey, - toolCallId: opts.toolCallId, toolName: name, durationMs: Date.now() - start, input, diff --git a/packages/context/src/ingest/tools/verification-ledger.tool.ts b/packages/context/src/ingest/tools/verification-ledger.tool.ts index af27b58d..7dd3b56c 100644 --- a/packages/context/src/ingest/tools/verification-ledger.tool.ts +++ b/packages/context/src/ingest/tools/verification-ledger.tool.ts @@ -1,5 +1,5 @@ -import { tool, type ToolExecuteFunction, type ToolExecutionOptions, type ToolSet } from 'ai'; import { z } from 'zod'; +import type { KtxRuntimeToolDescriptor, KtxRuntimeToolSet } from '../../llm/index.js'; const verificationLedgerInputSchema = z.object({ summary: z.string().min(1).max(2000), @@ -37,22 +37,19 @@ export function createVerificationLedgerState(): VerificationLedgerState { return { entries: [] }; } -export function withVerificationLedger(tools: ToolSet, state: VerificationLedgerState): ToolSet { - const wrapped: ToolSet = {}; +export function withVerificationLedger(tools: KtxRuntimeToolSet, state: VerificationLedgerState): KtxRuntimeToolSet { + const wrapped: KtxRuntimeToolSet = {}; for (const [name, original] of Object.entries(tools)) { if (!WRITE_TOOL_NAMES.has(name) || typeof original.execute !== 'function') { wrapped[name] = original; continue; } const originalExecute = original.execute; - const guardedExecute: ToolExecuteFunction = async ( - input: unknown, - opts: ToolExecutionOptions, - ) => { + const guardedExecute = async (input: unknown) => { if (state.entries.length === 0) { return verificationRequiredOutput(name); } - return (originalExecute as ToolExecuteFunction)(input, opts); + return originalExecute(input); }; wrapped[name] = { ...original, execute: guardedExecute }; } @@ -60,8 +57,9 @@ export function withVerificationLedger(tools: ToolSet, state: VerificationLedger return wrapped; } -function createRecordVerificationLedgerTool(state: VerificationLedgerState) { - return tool({ +function createRecordVerificationLedgerTool(state: VerificationLedgerState): KtxRuntimeToolDescriptor { + return { + name: 'record_verification_ledger', description: 'Record the pre-write verification ledger required by loaded ingest skills. Call this before wiki/SL/fallback writes to state what was verified, which tool calls support it, and what remains intentionally unverified.', inputSchema: verificationLedgerInputSchema, @@ -78,7 +76,7 @@ function createRecordVerificationLedgerTool(state: VerificationLedgerState) { structured: { success: true, entry }, }; }, - }); + }; } function verificationRequiredOutput(toolName: string) { diff --git a/packages/context/src/llm/ai-sdk-runtime.ts b/packages/context/src/llm/ai-sdk-runtime.ts new file mode 100644 index 00000000..f6201813 --- /dev/null +++ b/packages/context/src/llm/ai-sdk-runtime.ts @@ -0,0 +1,164 @@ +import { KtxMessageBuilder, splitKtxSystemMessages, type KtxLlmProvider } from '@ktx/llm'; +import { generateText, Output, stepCountIs, type FlexibleSchema, type TelemetrySettings, type ToolSet } from 'ai'; +import type { z } from 'zod'; +import { noopLogger, type KtxLogger } from '../core/index.js'; +import { summarizeKtxLlmDebugRequest, type KtxLlmDebugRequestRecorder } from './debug-request-recorder.js'; +import { createAiSdkToolSet } from './runtime-tools.js'; +import type { + KtxGenerateObjectInput, + KtxGenerateTextInput, + KtxLlmRuntimePort, + RunLoopParams, + RunLoopResult, +} from './runtime-port.js'; + +export interface AgentTelemetryPort { + createTelemetry(tags: Record): TelemetrySettings; +} + +export interface AiSdkKtxLlmRuntimeDeps { + llmProvider: KtxLlmProvider; + telemetry?: AgentTelemetryPort; + logger?: KtxLogger; + debugRequestRecorder?: KtxLlmDebugRequestRecorder; +} + +function hasTools(tools: Record): boolean { + return Object.keys(tools).length > 0; +} + +export class AiSdkKtxLlmRuntime implements KtxLlmRuntimePort { + private readonly logger: KtxLogger; + + constructor(private readonly deps: AiSdkKtxLlmRuntimeDeps) { + this.logger = deps.logger ?? noopLogger; + } + + async generateText(input: KtxGenerateTextInput): Promise { + const model = this.deps.llmProvider.getModel(input.role); + if ((model as { provider?: string }).provider === 'deterministic') { + return `Deterministic description for ${input.prompt.slice(0, 64).trim() || 'data source'}`; + } + const tools = createAiSdkToolSet(input.tools ?? {}); + const built = new KtxMessageBuilder(this.deps.llmProvider).wrapSimple({ + system: input.system, + messages: [{ role: 'user', content: input.prompt }], + tools, + model, + }); + const split = splitKtxSystemMessages(built.messages); + const result = await generateText({ + model, + temperature: input.temperature ?? 0, + ...(split.system ? { system: split.system } : {}), + messages: split.messages, + tools: built.tools as ToolSet, + ...(hasTools(tools) + ? { + experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ + source: `ktx-${input.role}`, + }), + } + : {}), + }); + if (typeof result.text !== 'string') { + throw new Error('KTX LLM text generation returned no text'); + } + return result.text; + } + + async generateObject>( + input: KtxGenerateObjectInput, + ): Promise { + const model = this.deps.llmProvider.getModel(input.role); + const tools = createAiSdkToolSet(input.tools ?? {}); + const built = new KtxMessageBuilder(this.deps.llmProvider).wrapSimple({ + system: input.system, + messages: [{ role: 'user', content: input.prompt }], + tools, + model, + }); + const split = splitKtxSystemMessages(built.messages); + const result = await generateText({ + model, + temperature: input.temperature ?? 0, + ...(split.system ? { system: split.system } : {}), + messages: split.messages, + tools: built.tools as ToolSet, + ...(hasTools(tools) + ? { + experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ + source: `ktx-${input.role}`, + }), + } + : {}), + output: Output.object({ schema: input.schema as unknown as FlexibleSchema }), + }); + if (result.output == null) { + throw new Error('KTX LLM object generation returned no output'); + } + return result.output as TOutput; + } + + async runAgentLoop(params: RunLoopParams): Promise { + let stepIndex = 0; + try { + const model = this.deps.llmProvider.getModel(params.modelRole); + const tools = createAiSdkToolSet(params.toolSet); + const builder = new KtxMessageBuilder(this.deps.llmProvider); + const built = builder.wrapSimple({ + system: params.systemPrompt, + messages: [{ role: 'user', content: params.userPrompt }], + tools, + model, + }); + const promptMessages = splitKtxSystemMessages(built.messages); + + await this.deps.debugRequestRecorder?.record( + summarizeKtxLlmDebugRequest({ + operationName: params.telemetryTags.operationName ?? 'ktx-agent-runner', + source: params.telemetryTags.source, + jobId: params.telemetryTags.jobId, + unitKey: params.telemetryTags.unitKey, + modelRole: params.modelRole, + modelId: (model as { modelId?: string }).modelId ?? params.modelRole, + messages: built.messages, + tools: built.tools as Record, + }), + ); + + await generateText({ + model, + temperature: 0, + stopWhen: stepCountIs(params.stepBudget), + experimental_telemetry: this.deps.telemetry?.createTelemetry(params.telemetryTags) ?? this.deps.llmProvider.telemetryConfig(), + experimental_repairToolCall: this.deps.llmProvider.repairToolCallHandler({ + source: params.telemetryTags.operationName ?? 'ktx-agent-runner', + }), + ...(promptMessages.system ? { system: promptMessages.system } : {}), + messages: promptMessages.messages, + tools: built.tools as ToolSet, + onStepFinish: async () => { + stepIndex += 1; + if (!params.onStepFinish) { + return; + } + try { + await params.onStepFinish({ stepIndex, stepBudget: params.stepBudget }); + } catch (err) { + this.logger.warn( + `[agent-runner] onStepFinish callback threw; ignoring: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }, + }); + return { stopReason: 'natural' }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.warn(`[agent-runner] loop failed: ${err.message}`); + return { stopReason: 'error', error: err }; + } + } +} diff --git a/packages/context/src/llm/claude-code-env.test.ts b/packages/context/src/llm/claude-code-env.test.ts new file mode 100644 index 00000000..19cbd1ff --- /dev/null +++ b/packages/context/src/llm/claude-code-env.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { CLAUDE_CODE_PROVIDER_ENV_DENYLIST, createKtxClaudeCodeEnv } from './claude-code-env.js'; + +describe('createKtxClaudeCodeEnv', () => { + it('strips provider-routing credentials from the Claude Code child environment', () => { + const seeded = Object.fromEntries(CLAUDE_CODE_PROVIDER_ENV_DENYLIST.map((key) => [key, `${key}-value`])); + const env = createKtxClaudeCodeEnv({ + ...seeded, + PATH: '/usr/bin', + HOME: '/Users/test', + }); + + for (const key of CLAUDE_CODE_PROVIDER_ENV_DENYLIST) { + expect(env).not.toHaveProperty(key); + } + expect(env.PATH).toBe('/usr/bin'); + expect(env.HOME).toBe('/Users/test'); + }); +}); diff --git a/packages/context/src/llm/claude-code-env.ts b/packages/context/src/llm/claude-code-env.ts new file mode 100644 index 00000000..285113e4 --- /dev/null +++ b/packages/context/src/llm/claude-code-env.ts @@ -0,0 +1,23 @@ +export const CLAUDE_CODE_PROVIDER_ENV_DENYLIST = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_VERTEX_PROJECT_ID', + 'CLOUD_ML_REGION', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_CLOUD_PROJECT', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'AWS_REGION', + 'AWS_PROFILE', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', +] as const; + +const DENYLIST = new Set(CLAUDE_CODE_PROVIDER_ENV_DENYLIST); + +export function createKtxClaudeCodeEnv(env: NodeJS.ProcessEnv = process.env): Record { + return Object.fromEntries(Object.entries(env).filter(([key]) => !DENYLIST.has(key))); +} diff --git a/packages/context/src/llm/claude-code-models.test.ts b/packages/context/src/llm/claude-code-models.test.ts new file mode 100644 index 00000000..482e6af8 --- /dev/null +++ b/packages/context/src/llm/claude-code-models.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { resolveClaudeCodeModel } from './claude-code-models.js'; + +describe('resolveClaudeCodeModel', () => { + it.each([ + ['sonnet', 'claude-sonnet-4-6'], + ['opus', 'claude-opus-4-7'], + ['haiku', 'claude-haiku-4-5'], + ['claude-sonnet-4-6', 'claude-sonnet-4-6'], + ])('maps %s to %s', (input, expected) => { + expect(resolveClaudeCodeModel(input)).toBe(expected); + }); + + it('rejects unsupported aliases', () => { + expect(() => resolveClaudeCodeModel('gpt-5')).toThrow('Unsupported Claude Code model'); + }); +}); diff --git a/packages/context/src/llm/claude-code-models.ts b/packages/context/src/llm/claude-code-models.ts new file mode 100644 index 00000000..7676409b --- /dev/null +++ b/packages/context/src/llm/claude-code-models.ts @@ -0,0 +1,19 @@ +const CLAUDE_CODE_MODEL_ALIASES: Record = { + sonnet: 'claude-sonnet-4-6', + opus: 'claude-opus-4-7', + haiku: 'claude-haiku-4-5', +}; + +const FULL_MODEL_ID = /^claude-(sonnet|opus|haiku)-[0-9]+-[0-9]+$/; + +export function resolveClaudeCodeModel(model: string): string { + const normalized = model.trim(); + const alias = CLAUDE_CODE_MODEL_ALIASES[normalized]; + if (alias) { + return alias; + } + if (FULL_MODEL_ID.test(normalized)) { + return normalized; + } + throw new Error(`Unsupported Claude Code model "${model}". Use sonnet, opus, haiku, or a claude-* model id.`); +} diff --git a/packages/context/src/llm/claude-code-runtime.test.ts b/packages/context/src/llm/claude-code-runtime.test.ts new file mode 100644 index 00000000..f69c5d75 --- /dev/null +++ b/packages/context/src/llm/claude-code-runtime.test.ts @@ -0,0 +1,464 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; +import { ClaudeCodeKtxLlmRuntime, mapClaudeCodeStopReason, runClaudeCodeAuthProbe } from './claude-code-runtime.js'; + +async function* stream(messages: SDKMessage[]): AsyncGenerator { + for (const message of messages) { + yield message; + } +} + +function initMessage(overrides: Partial> = {}): Extract< + SDKMessage, + { type: 'system'; subtype: 'init' } +> { + return { + type: 'system', + subtype: 'init', + apiKeySource: 'none' as never, // pragma: allowlist secret + claude_code_version: '0.3.142', + cwd: '/tmp/project', + tools: [], + mcp_servers: [], + model: 'claude-sonnet-4-6', + permissionMode: 'dontAsk', + slash_commands: [], + output_style: 'default', + skills: [], + plugins: [], + uuid: '00000000-0000-4000-8000-000000000001', + session_id: 'session-id', + ...overrides, + }; +} + +function resultMessage(overrides: Partial> = {}): Extract< + SDKMessage, + { type: 'result' } +> { + return { + type: 'result', + subtype: 'success', + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + num_turns: 1, + result: 'ok', + stop_reason: null, + total_cost_usd: 0, + usage: {} as never, + modelUsage: {}, + permission_denials: [], + errors: [], + uuid: '00000000-0000-4000-8000-000000000002', + session_id: 'session-id', + ...overrides, + } as Extract; +} + +describe('ClaudeCodeKtxLlmRuntime', () => { + it('passes isolation options and scrubbed env to text generation', async () => { + const query = vi.fn((_input: any) => stream([initMessage(), resultMessage({ result: 'hello' })])); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: { ANTHROPIC_API_KEY: 'sk-ant-test', PATH: '/usr/bin' }, // pragma: allowlist secret + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'say hello' })).resolves.toBe('hello'); + expect(query).toHaveBeenCalledWith({ + prompt: 'say hello', + options: expect.objectContaining({ + cwd: '/tmp/project', + model: 'claude-sonnet-4-6', + maxTurns: 1, + settingSources: [], + skills: [], + plugins: [], + tools: [], + allowedTools: [], + permissionMode: 'dontAsk', + persistSession: false, + env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }), + }), + }); + }); + + it('validates structured output with the caller schema', async () => { + const schema = z.object({ answer: z.string() }); + const query = vi.fn((_input: any) => stream([initMessage(), resultMessage({ structured_output: { answer: 'yes' } })])); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + }); + + await expect(runtime.generateObject({ role: 'default', prompt: 'json', schema })).resolves.toEqual({ answer: 'yes' }); + expect(query.mock.calls[0][0].options.outputFormat).toMatchObject({ + type: 'json_schema', + schema: expect.objectContaining({ type: 'object' }), + }); + }); + + it('registers only exact KTX MCP tool ids and denies non-KTX tools', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage({ tools: ['mcp__ktx__load_skill'], mcp_servers: [{ name: 'ktx', status: 'connected' }] }), + { + type: 'assistant', + message: { role: 'assistant', content: [] }, + parent_tool_use_id: null, + uuid: '00000000-0000-4000-8000-000000000003', + session_id: 'session-id', + } as unknown as SDKMessage, + resultMessage({ subtype: 'error_max_turns', is_error: true }), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + }); + const onStepFinish = vi.fn(); + + await runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + toolSet: { + load_skill: { + name: 'load_skill', + description: 'Load skill.', + inputSchema: z.object({ name: z.string() }), + execute: async () => ({ markdown: 'loaded' }), + }, + }, + stepBudget: 1, + telemetryTags: { operationName: 'test' }, + onStepFinish, + }); + + const options = query.mock.calls[0][0].options; + expect(options.allowedTools).toEqual(['mcp__ktx__load_skill']); + expect(await options.canUseTool('mcp__ktx__load_skill', {}, { signal: new AbortController().signal, toolUseID: '1' })).toEqual({ + behavior: 'allow', + toolUseID: '1', + }); + expect(await options.canUseTool('Bash', {}, { signal: new AbortController().signal, toolUseID: '2' })).toMatchObject({ + behavior: 'deny', + toolUseID: '2', + }); + expect(onStepFinish).toHaveBeenCalledWith({ stepIndex: 1, stepBudget: 1 }); + }); + + it('treats host-discovered commands skills and agents as non-fatal init metadata for text and auth probe', async () => { + const hostDiscoveredInit = initMessage({ + slash_commands: ['/help', '/compact', '/clear', '/user-command'], + skills: ['pdf', 'docx'], + agents: ['claude', 'Explore', 'general-purpose'], + }); + const textQuery = vi.fn((_input: any) => stream([hostDiscoveredInit, resultMessage({ result: 'hello' })])); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query: textQuery, + env: { ANTHROPIC_API_KEY: 'sk-ant-test', PATH: '/usr/bin' }, // pragma: allowlist secret + }); + + await expect(runtime.generateText({ role: 'default', prompt: 'say hello' })).resolves.toBe('hello'); + const textOptions = textQuery.mock.calls[0][0].options; + expect(textOptions).toMatchObject({ + settingSources: [], + skills: [], + plugins: [], + tools: [], + allowedTools: [], + permissionMode: 'dontAsk', + persistSession: false, + env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }), + }); + expect(textOptions.disallowedTools).toEqual(expect.arrayContaining(['Agent', 'Task', 'Bash'])); + expect(await textOptions.canUseTool('Agent', {}, { signal: new AbortController().signal, toolUseID: 'agent' })).toMatchObject({ + behavior: 'deny', + toolUseID: 'agent', + }); + expect(await textOptions.canUseTool('Skill', {}, { signal: new AbortController().signal, toolUseID: 'skill' })).toMatchObject({ + behavior: 'deny', + toolUseID: 'skill', + }); + expect( + await textOptions.canUseTool('SlashCommand', {}, { signal: new AbortController().signal, toolUseID: 'slash' }), + ).toMatchObject({ + behavior: 'deny', + toolUseID: 'slash', + }); + + const probeQuery = vi.fn((_input: any) => stream([hostDiscoveredInit, resultMessage({ result: 'ok' })])); + await expect( + runClaudeCodeAuthProbe({ + projectDir: '/tmp/project', + model: 'sonnet', + query: probeQuery, + env: { ANTHROPIC_AUTH_TOKEN: 'token', HOME: '/Users/test' }, + }), + ).resolves.toEqual({ ok: true }); + expect(probeQuery.mock.calls[0][0].options).toMatchObject({ + settingSources: [], + skills: [], + plugins: [], + tools: [], + allowedTools: [], + permissionMode: 'dontAsk', + persistSession: false, + env: expect.objectContaining({ HOME: '/Users/test' }), + }); + expect(probeQuery.mock.calls[0][0].options.env).not.toEqual( + expect.objectContaining({ ANTHROPIC_AUTH_TOKEN: 'token' }), + ); + }); + + it('allows host-discovered context during agent loops while requiring exact KTX MCP tools and servers', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage({ + tools: ['mcp__ktx__load_skill'], + mcp_servers: [{ name: 'ktx', status: 'connected' }], + slash_commands: ['/help', '/compact', '/clear'], + skills: ['memory-agent', 'doc-reader'], + agents: ['claude', 'Plan', 'Explore'], + }), + { + type: 'assistant', + message: { role: 'assistant', content: [] }, + parent_tool_use_id: null, + uuid: '00000000-0000-4000-8000-000000000006', + session_id: 'session-id', + } as unknown as SDKMessage, + resultMessage({ subtype: 'error_max_turns', is_error: true }), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + }); + + await expect( + runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + toolSet: { + load_skill: { + name: 'load_skill', + description: 'Load skill.', + inputSchema: z.object({ name: z.string() }), + execute: async () => ({ markdown: 'loaded' }), + }, + }, + stepBudget: 1, + telemetryTags: { operationName: 'test' }, + }), + ).resolves.toEqual({ stopReason: 'budget' }); + + const options = query.mock.calls[0][0].options; + expect(options.allowedTools).toEqual(['mcp__ktx__load_skill']); + expect(await options.canUseTool('mcp__ktx__load_skill', {}, { signal: new AbortController().signal, toolUseID: '1' })).toEqual({ + behavior: 'allow', + toolUseID: '1', + }); + expect(await options.canUseTool('Task', {}, { signal: new AbortController().signal, toolUseID: '2' })).toMatchObject({ + behavior: 'deny', + toolUseID: '2', + }); + expect(await options.canUseTool('Skill', {}, { signal: new AbortController().signal, toolUseID: '3' })).toMatchObject({ + behavior: 'deny', + toolUseID: '3', + }); + }); + + it('still rejects unexpected tools, missing KTX tools, plugins, and non-KTX MCP servers from init messages', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage({ + tools: ['Bash'], + mcp_servers: [{ name: 'filesystem', status: 'connected' }], + plugins: [{ name: 'host-plugin', path: '/tmp/plugin' }], + }), + resultMessage({ result: 'hello' }), + ]), + ); + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + }); + + await expect( + runtime.generateText({ + role: 'default', + prompt: 'say hello', + tools: { + load_skill: { + name: 'load_skill', + description: 'Load skill.', + inputSchema: z.object({ name: z.string() }), + execute: async () => ({ markdown: 'loaded' }), + }, + }, + }), + ).rejects.toThrow( + /Claude Code runtime isolation failed: .*tools=Bash.*missing_tools=mcp__ktx__load_skill.*mcp_servers=filesystem.*plugins=host-plugin/, + ); + }); + + it('passes scrubbed env to object generation and agent loops', async () => { + const schema = z.object({ answer: z.string() }); + const objectQuery = vi.fn((_input: any) => + stream([initMessage(), resultMessage({ structured_output: { answer: 'yes' } })]), + ); + const objectRuntime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query: objectQuery, + env: { ANTHROPIC_API_KEY: 'sk-ant-test', AWS_PROFILE: 'prod', PATH: '/usr/bin' }, // pragma: allowlist secret + }); + + await expect(objectRuntime.generateObject({ role: 'default', prompt: 'json', schema })).resolves.toEqual({ + answer: 'yes', + }); + expect(objectQuery.mock.calls[0][0].options.env).toEqual(expect.objectContaining({ PATH: '/usr/bin' })); + expect(objectQuery.mock.calls[0][0].options.env).not.toEqual( + expect.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test', AWS_PROFILE: 'prod' }), // pragma: allowlist secret + ); + + const agentQuery = vi.fn((_input: any) => + stream([ + initMessage({ tools: ['mcp__ktx__load_skill'], mcp_servers: [{ name: 'ktx', status: 'connected' }] }), + { + type: 'assistant', + message: { role: 'assistant', content: [] }, + parent_tool_use_id: null, + uuid: '00000000-0000-4000-8000-000000000004', + session_id: 'session-id', + } as unknown as SDKMessage, + resultMessage({ subtype: 'error_max_turns', is_error: true }), + ]), + ); + const agentRuntime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query: agentQuery, + env: { ANTHROPIC_AUTH_TOKEN: 'token', CLAUDE_CODE_USE_VERTEX: '1', HOME: '/Users/test' }, + }); + + await agentRuntime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + toolSet: { + load_skill: { + name: 'load_skill', + description: 'Load skill.', + inputSchema: z.object({ name: z.string() }), + execute: async () => ({ markdown: 'loaded' }), + }, + }, + stepBudget: 1, + telemetryTags: { operationName: 'test' }, + }); + expect(agentQuery.mock.calls[0][0].options.env).toEqual(expect.objectContaining({ HOME: '/Users/test' })); + expect(agentQuery.mock.calls[0][0].options.env).not.toEqual( + expect.objectContaining({ ANTHROPIC_AUTH_TOKEN: 'token', CLAUDE_CODE_USE_VERTEX: '1' }), + ); + }); + + it('logs and ignores onStepFinish callback errors', async () => { + const query = vi.fn((_input: any) => + stream([ + initMessage(), + { + type: 'assistant', + message: { role: 'assistant', content: [] }, + parent_tool_use_id: null, + uuid: '00000000-0000-4000-8000-000000000005', + session_id: 'session-id', + } as unknown as SDKMessage, + resultMessage({ subtype: 'success', terminal_reason: 'completed' }), + ]), + ); + const logger = { + debug: vi.fn(), + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const runtime = new ClaudeCodeKtxLlmRuntime({ + projectDir: '/tmp/project', + modelSlots: { default: 'sonnet' }, + query, + env: {}, + logger, + }); + + await expect( + runtime.runAgentLoop({ + modelRole: 'default', + systemPrompt: 'system', + userPrompt: 'user', + toolSet: {}, + stepBudget: 1, + telemetryTags: { operationName: 'test' }, + onStepFinish: async () => { + throw new Error('callback exploded'); + }, + }), + ).resolves.toEqual({ stopReason: 'natural' }); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('callback exploded')); + }); + + it('maps max-turn terminal reasons to budget', () => { + expect(mapClaudeCodeStopReason(resultMessage({ subtype: 'error_max_turns' }))).toBe('budget'); + expect(mapClaudeCodeStopReason(resultMessage({ terminal_reason: 'max_turns' }))).toBe('budget'); + expect(mapClaudeCodeStopReason(resultMessage({ stop_reason: 'max_turns' }))).toBe('budget'); + expect(mapClaudeCodeStopReason(resultMessage({ subtype: 'success', terminal_reason: 'completed' }))).toBe('natural'); + expect(mapClaudeCodeStopReason(resultMessage({ subtype: 'error_during_execution' }))).toBe('error'); + }); + + it('auth probe uses isolation options and a scrubbed env', async () => { + const query = vi.fn((_input: any) => stream([initMessage(), resultMessage({ result: 'ok' })])); + + await expect( + runClaudeCodeAuthProbe({ projectDir: '/tmp/project', model: 'sonnet', query, env: { ANTHROPIC_API_KEY: 'sk-ant-test' } }), // pragma: allowlist secret + ).resolves.toEqual({ ok: true }); + expect(query.mock.calls[0][0].options).toMatchObject({ + settingSources: [], + skills: [], + plugins: [], + tools: [], + allowedTools: [], + persistSession: false, + env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }), + }); + }); + + it('reports unsupported Claude Code models without framing them as auth failures', async () => { + await expect( + runClaudeCodeAuthProbe({ + projectDir: '/tmp/project', + model: 'gpt-5', + query: vi.fn(), + env: {}, + }), + ).resolves.toEqual({ + ok: false, + message: 'Unsupported Claude Code model "gpt-5". Use sonnet, opus, haiku, or a claude-* model id.', + }); + }); +}); diff --git a/packages/context/src/llm/claude-code-runtime.ts b/packages/context/src/llm/claude-code-runtime.ts new file mode 100644 index 00000000..5d8edf26 --- /dev/null +++ b/packages/context/src/llm/claude-code-runtime.ts @@ -0,0 +1,327 @@ +import { + createSdkMcpServer, + query as defaultQuery, + type Options, + type SDKMessage, + type SDKResultMessage, +} from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { noopLogger, type KtxLogger } from '../core/index.js'; +import { createKtxClaudeCodeEnv } from './claude-code-env.js'; +import { resolveClaudeCodeModel } from './claude-code-models.js'; +import { createClaudeSdkTools, mcpToolIds } from './runtime-tools.js'; +import type { + KtxGenerateObjectInput, + KtxGenerateTextInput, + KtxLlmRuntimePort, + KtxRuntimeToolSet, + RunLoopParams, + RunLoopResult, + RunLoopStopReason, +} from './runtime-port.js'; + +type QueryFn = (params: Parameters[0]) => AsyncIterable; + +export interface ClaudeCodeKtxLlmRuntimeDeps { + projectDir: string; + modelSlots: { default: string } & Partial>; + query?: QueryFn; + env?: NodeJS.ProcessEnv; + logger?: KtxLogger; +} + +const BUILTIN_TOOLS = [ + 'Agent', + 'Task', + 'AskUserQuestion', + 'Bash', + 'Read', + 'Edit', + 'Write', + 'Glob', + 'Grep', + 'WebFetch', + 'WebSearch', + 'TodoWrite', +]; + +function isResult(message: SDKMessage): message is SDKResultMessage { + return message.type === 'result'; +} + +function resultError(result: SDKResultMessage): Error | undefined { + if (result.subtype === 'success') { + return undefined; + } + const details = result.errors.length > 0 ? `: ${result.errors.join('; ')}` : ''; + return new Error(`Claude Code query failed (${result.subtype})${details}`); +} + +export function mapClaudeCodeStopReason(result: SDKResultMessage): RunLoopStopReason { + if (result.subtype === 'error_max_turns') { + return 'budget'; + } + if (result.terminal_reason === 'max_turns' || result.stop_reason === 'max_turns') { + return 'budget'; + } + if (result.subtype === 'success') { + return result.terminal_reason && result.terminal_reason !== 'completed' ? 'error' : 'natural'; + } + return 'error'; +} + +function jsonSchema(schema: z.ZodType): Record { + return z.toJSONSchema(schema, { target: 'draft-7' }) as Record; +} + +function modelForRole(modelSlots: ClaudeCodeKtxLlmRuntimeDeps['modelSlots'], role: string): string { + return resolveClaudeCodeModel(modelSlots[role] ?? modelSlots.default); +} + +function assertInitIsolation( + message: SDKMessage, + allowedToolIds: Set, + expectedMcpServerNames: Set, +): void { + if (message.type !== 'system' || message.subtype !== 'init') { + return; + } + const activeToolIds = new Set(message.tools); + const unexpectedTools = message.tools.filter((toolName) => !allowedToolIds.has(toolName)); + const missingTools = [...allowedToolIds].filter((toolName) => !activeToolIds.has(toolName)); + const activeMcpServerNames = message.mcp_servers.map((server) => server.name); + const unexpectedMcpServers = activeMcpServerNames.filter((name) => !expectedMcpServerNames.has(name)); + const missingMcpServers = [...expectedMcpServerNames].filter((name) => !activeMcpServerNames.includes(name)); + const unexpectedPlugins = message.plugins.map((plugin) => plugin.name); + if ( + unexpectedTools.length > 0 || + missingTools.length > 0 || + unexpectedMcpServers.length > 0 || + missingMcpServers.length > 0 || + unexpectedPlugins.length > 0 + ) { + throw new Error( + `Claude Code runtime isolation failed: tools=${unexpectedTools.join(',') || '(none)'} missing_tools=${ + missingTools.join(',') || '(none)' + } mcp_servers=${unexpectedMcpServers.join(',') || '(none)'} missing_mcp_servers=${ + missingMcpServers.join(',') || '(none)' + } plugins=${unexpectedPlugins.join(',') || '(none)'} host_slash_commands=${ + message.slash_commands.length + } host_skills=${message.skills.length} host_agents=${message.agents?.join(',') || '(none)'}`, + ); + } +} + +function expectedMcpServerNames(tools: KtxRuntimeToolSet | undefined): Set { + return tools && Object.keys(tools).length > 0 ? new Set(['ktx']) : new Set(); +} + +function baseOptions(input: { + projectDir: string; + model: string; + env: NodeJS.ProcessEnv | undefined; + maxTurns: number; + tools?: KtxRuntimeToolSet; +}): Options { + const toolIds = mcpToolIds(input.tools ?? {}); + const allowedToolIds = new Set(toolIds); + return { + cwd: input.projectDir, + model: input.model, + maxTurns: input.maxTurns, + settingSources: [], + skills: [], + plugins: [], + tools: [], + allowedTools: toolIds, + disallowedTools: BUILTIN_TOOLS, + canUseTool: async (toolName, _toolInput, options) => + allowedToolIds.has(toolName) + ? { behavior: 'allow', toolUseID: options.toolUseID } + : { + behavior: 'deny', + message: `KTX claude-code runtime only permits current KTX MCP tools; denied ${toolName}.`, + toolUseID: options.toolUseID, + }, + permissionMode: 'dontAsk', + persistSession: false, + env: createKtxClaudeCodeEnv(input.env), + ...(input.tools && Object.keys(input.tools).length > 0 + ? { mcpServers: { ktx: createSdkMcpServer({ name: 'ktx', tools: createClaudeSdkTools(input.tools) }) } } + : {}), + }; +} + +async function collectResult(params: { + query: QueryFn; + prompt: string; + options: Options; + allowedToolIds: Set; + expectedMcpServerNames: Set; + onAssistantTurn?: () => Promise; +}): Promise { + let result: SDKResultMessage | undefined; + for await (const message of params.query({ prompt: params.prompt, options: params.options })) { + assertInitIsolation(message, params.allowedToolIds, params.expectedMcpServerNames); + if (message.type === 'assistant' && message.parent_tool_use_id === null) { + await params.onAssistantTurn?.(); + } + if (isResult(message)) { + result = message; + } + } + if (!result) { + throw new Error('Claude Code query returned no result message'); + } + return result; +} + +export class ClaudeCodeKtxLlmRuntime implements KtxLlmRuntimePort { + private readonly runQuery: QueryFn; + private readonly logger: KtxLogger; + + constructor(private readonly deps: ClaudeCodeKtxLlmRuntimeDeps) { + this.runQuery = deps.query ?? defaultQuery; + this.logger = deps.logger ?? noopLogger; + } + + async generateText(input: KtxGenerateTextInput): Promise { + const options = baseOptions({ + projectDir: this.deps.projectDir, + model: modelForRole(this.deps.modelSlots, input.role), + env: this.deps.env, + maxTurns: 1, + tools: input.tools, + }); + const result = await collectResult({ + query: this.runQuery, + prompt: [input.system, input.prompt].filter(Boolean).join('\n\n'), + options, + allowedToolIds: new Set(mcpToolIds(input.tools ?? {})), + expectedMcpServerNames: expectedMcpServerNames(input.tools), + }); + const error = resultError(result); + if (error) { + throw error; + } + if (result.subtype !== 'success') { + throw new Error(`Claude Code query failed (${result.subtype})`); + } + return result.result; + } + + async generateObject>( + input: KtxGenerateObjectInput, + ): Promise { + const options = { + ...baseOptions({ + projectDir: this.deps.projectDir, + model: modelForRole(this.deps.modelSlots, input.role), + env: this.deps.env, + maxTurns: 1, + tools: input.tools, + }), + outputFormat: { type: 'json_schema' as const, schema: jsonSchema(input.schema as z.ZodType) }, + }; + const result = await collectResult({ + query: this.runQuery, + prompt: [input.system, input.prompt].filter(Boolean).join('\n\n'), + options, + allowedToolIds: new Set(mcpToolIds(input.tools ?? {})), + expectedMcpServerNames: expectedMcpServerNames(input.tools), + }); + const error = resultError(result); + if (error) { + throw error; + } + if (result.subtype !== 'success') { + throw new Error(`Claude Code query failed (${result.subtype})`); + } + return (input.schema as z.ZodType).parse(result.structured_output); + } + + async runAgentLoop(params: RunLoopParams): Promise { + let stepIndex = 0; + try { + const options = baseOptions({ + projectDir: this.deps.projectDir, + model: modelForRole(this.deps.modelSlots, params.modelRole), + env: this.deps.env, + maxTurns: params.stepBudget, + tools: params.toolSet, + }); + const result = await collectResult({ + query: this.runQuery, + prompt: params.userPrompt, + options: { ...options, systemPrompt: params.systemPrompt }, + allowedToolIds: new Set(mcpToolIds(params.toolSet)), + expectedMcpServerNames: expectedMcpServerNames(params.toolSet), + onAssistantTurn: async () => { + stepIndex += 1; + if (!params.onStepFinish) { + return; + } + try { + await params.onStepFinish({ stepIndex, stepBudget: params.stepBudget }); + } catch (error) { + this.logger.warn( + `[claude-code-runner] onStepFinish callback threw; ignoring: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }, + }); + const stopReason = mapClaudeCodeStopReason(result); + const error = resultError(result); + return { stopReason, ...(stopReason === 'error' && error ? { error } : {}) }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + return { stopReason: 'error', error: err }; + } + } +} + +export async function runClaudeCodeAuthProbe(input: { + projectDir: string; + model: string; + query?: QueryFn; + env?: NodeJS.ProcessEnv; +}): Promise<{ ok: true } | { ok: false; message: string }> { + let model: string; + try { + model = resolveClaudeCodeModel(input.model); + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + + try { + const options = baseOptions({ + projectDir: input.projectDir, + model, + env: input.env, + maxTurns: 1, + }); + const result = await collectResult({ + query: input.query ?? defaultQuery, + prompt: 'Reply with exactly: ok', + options, + allowedToolIds: new Set(), + expectedMcpServerNames: new Set(), + }); + const error = resultError(result); + if (error) { + throw error; + } + return { ok: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + message: `Claude Code authentication is not usable. Authenticate Claude Code locally with the Claude Code CLI, then rerun setup or the command. ${message}`, + }; + } +} diff --git a/packages/context/src/llm/generation.ts b/packages/context/src/llm/generation.ts index 7cb11d58..91019a09 100644 --- a/packages/context/src/llm/generation.ts +++ b/packages/context/src/llm/generation.ts @@ -1,85 +1,12 @@ -import { KtxMessageBuilder, splitKtxSystemMessages, type KtxLlmProvider, type KtxModelRole } from '@ktx/llm'; -import { generateText, Output, type FlexibleSchema, type ToolSet } from 'ai'; +import type { z } from 'zod'; +import type { KtxGenerateObjectInput, KtxGenerateTextInput, KtxLlmRuntimePort } from './runtime-port.js'; -type GenerateTextInput = Parameters[0]; -type GenerateTextFn = (input: GenerateTextInput) => Promise<{ text?: string; output?: unknown }>; - -function hasTools(tools: ToolSet): boolean { - return Object.keys(tools).length > 0; +export async function generateKtxText(input: KtxGenerateTextInput & { runtime: KtxLlmRuntimePort }): Promise { + return input.runtime.generateText(input); } -interface GenerateKtxTextInput { - llmProvider: KtxLlmProvider; - role: KtxModelRole; - prompt: string; - system?: string; - tools?: ToolSet; - temperature?: number; - generateText?: GenerateTextFn; -} - -export async function generateKtxText(input: GenerateKtxTextInput): Promise { - const model = input.llmProvider.getModel(input.role); - if ((model as { provider?: string }).provider === 'deterministic') { - return `Deterministic description for ${input.prompt.slice(0, 64).trim() || 'data source'}`; - } - const built = new KtxMessageBuilder(input.llmProvider).wrapSimple({ - system: input.system, - messages: [{ role: 'user', content: input.prompt }], - tools: input.tools ?? {}, - model, - }); - const split = splitKtxSystemMessages(built.messages); - const result = await (input.generateText ?? generateText)({ - model, - temperature: input.temperature ?? 0, - ...(split.system ? { system: split.system } : {}), - messages: split.messages, - tools: built.tools as ToolSet, - ...(hasTools(built.tools as ToolSet) - ? { - experimental_repairToolCall: input.llmProvider.repairToolCallHandler({ - source: `ktx-${input.role}`, - }), - } - : {}), - }); - if (typeof result.text !== 'string') { - throw new Error('KTX LLM text generation returned no text'); - } - return result.text; -} - -export async function generateKtxObject( - input: GenerateKtxTextInput & { schema: TSchema }, +export async function generateKtxObject>( + input: KtxGenerateObjectInput & { runtime: KtxLlmRuntimePort }, ): Promise { - const model = input.llmProvider.getModel(input.role); - const built = new KtxMessageBuilder(input.llmProvider).wrapSimple({ - system: input.system, - messages: [{ role: 'user', content: input.prompt }], - tools: input.tools ?? {}, - model, - }); - const split = splitKtxSystemMessages(built.messages); - const result = await (input.generateText ?? generateText)({ - model, - temperature: input.temperature ?? 0, - ...(split.system ? { system: split.system } : {}), - messages: split.messages, - tools: built.tools as ToolSet, - ...(hasTools(built.tools as ToolSet) - ? { - experimental_repairToolCall: input.llmProvider.repairToolCallHandler({ - source: `ktx-${input.role}`, - }), - } - : {}), - output: Output.object({ - schema: input.schema as FlexibleSchema, - }), - }); - if (result.output == null) { - throw new Error('KTX LLM object generation returned no output'); - } - return result.output as TOutput; + return input.runtime.generateObject(input); } diff --git a/packages/context/src/llm/index.ts b/packages/context/src/llm/index.ts index c9f039b8..64d5a26e 100644 --- a/packages/context/src/llm/index.ts +++ b/packages/context/src/llm/index.ts @@ -1,5 +1,31 @@ export { KtxIngestEmbeddingPortAdapter, KtxScanEmbeddingPortAdapter } from './embedding-port.js'; +export { AiSdkKtxLlmRuntime } from './ai-sdk-runtime.js'; +export type { AgentTelemetryPort, AiSdkKtxLlmRuntimeDeps } from './ai-sdk-runtime.js'; +export { createKtxClaudeCodeEnv, CLAUDE_CODE_PROVIDER_ENV_DENYLIST } from './claude-code-env.js'; +export { resolveClaudeCodeModel } from './claude-code-models.js'; +export { ClaudeCodeKtxLlmRuntime, mapClaudeCodeStopReason, runClaudeCodeAuthProbe } from './claude-code-runtime.js'; export { generateKtxObject, generateKtxText } from './generation.js'; +export type { + AgentRunnerPort, + KtxGenerateObjectInput, + KtxGenerateTextInput, + KtxLlmRuntimePort, + KtxRuntimeToolDescriptor, + KtxRuntimeToolOutput, + KtxRuntimeToolSet, + RunLoopParams, + RunLoopResult, + RunLoopStepInfo, + RunLoopStopReason, +} from './runtime-port.js'; +export { RuntimeAgentRunner } from './runtime-port.js'; +export { + createAiSdkToolSet, + createClaudeSdkTools, + createRuntimeToolDescriptorFromAiTool, + createRuntimeToolSetFromAiSdkTools, + normalizeKtxRuntimeToolOutput, +} from './runtime-tools.js'; export type { KtxLlmDebugProviderOptionsEntry, KtxLlmDebugRequest, @@ -15,6 +41,7 @@ export { MANAGED_SENTENCE_TRANSFORMERS_BASE_URL_ENV, createLocalKtxEmbeddingProviderFromConfig, createLocalKtxLlmProviderFromConfig, + createLocalKtxLlmRuntimeFromConfig, resolveLocalKtxEmbeddingConfig, resolveLocalKtxLlmConfig, } from './local-config.js'; diff --git a/packages/context/src/llm/local-config.ts b/packages/context/src/llm/local-config.ts index e2ee45e0..4b04e99b 100644 --- a/packages/context/src/llm/local-config.ts +++ b/packages/context/src/llm/local-config.ts @@ -9,11 +9,17 @@ import { } from '@ktx/llm'; import { resolveKtxConfigReference } from '../core/config-reference.js'; import type { KtxProjectEmbeddingConfig, KtxProjectLlmConfig } from '../project/config.js'; +import { AiSdkKtxLlmRuntime } from './ai-sdk-runtime.js'; +import { ClaudeCodeKtxLlmRuntime } from './claude-code-runtime.js'; +import type { KtxLlmRuntimePort } from './runtime-port.js'; interface LocalConfigDeps { env?: NodeJS.ProcessEnv; + projectDir?: string; createKtxLlmProvider?: typeof createKtxLlmProvider; createKtxEmbeddingProvider?: typeof createKtxEmbeddingProvider; + createClaudeCodeRuntime?: (deps: ConstructorParameters[0]) => KtxLlmRuntimePort; + createAiSdkRuntime?: (deps: { llmProvider: KtxLlmProvider }) => KtxLlmRuntimePort; } export const MANAGED_SENTENCE_TRANSFORMERS_BASE_URL = 'managed:local-embeddings'; @@ -106,7 +112,33 @@ export function createLocalKtxLlmProviderFromConfig( deps: LocalConfigDeps = {}, ): KtxLlmProvider | null { const resolved = resolveLocalKtxLlmConfig(config, deps.env ?? process.env); - return resolved ? (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved) : null; + if (!resolved || resolved.backend === 'claude-code') { + return null; + } + return (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved); +} + +export function createLocalKtxLlmRuntimeFromConfig( + config: KtxProjectLlmConfig, + deps: LocalConfigDeps = {}, +): KtxLlmRuntimePort | null { + const resolved = resolveLocalKtxLlmConfig(config, deps.env ?? process.env); + if (!resolved) { + return null; + } + if (resolved.backend === 'claude-code') { + const projectDir = deps.projectDir; + if (!projectDir) { + throw new Error('projectDir is required when creating the claude-code LLM runtime'); + } + return (deps.createClaudeCodeRuntime ?? ((runtimeDeps) => new ClaudeCodeKtxLlmRuntime(runtimeDeps)))({ + projectDir, + modelSlots: resolved.modelSlots, + env: deps.env, + }); + } + const llmProvider = (deps.createKtxLlmProvider ?? createKtxLlmProvider)(resolved); + return (deps.createAiSdkRuntime ?? ((runtimeDeps) => new AiSdkKtxLlmRuntime(runtimeDeps)))({ llmProvider }); } function resolveSentenceTransformersBaseUrl( diff --git a/packages/context/src/llm/runtime-local-config.test.ts b/packages/context/src/llm/runtime-local-config.test.ts new file mode 100644 index 00000000..e5516ffa --- /dev/null +++ b/packages/context/src/llm/runtime-local-config.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createLocalKtxLlmProviderFromConfig, createLocalKtxLlmRuntimeFromConfig } from './local-config.js'; + +describe('local KTX LLM runtime config', () => { + it('creates a Claude Code runtime for claude-code backend without creating an AI SDK provider', () => { + const runtime = createLocalKtxLlmRuntimeFromConfig( + { + provider: { backend: 'claude-code' }, + models: { default: 'sonnet', triage: 'haiku' }, + }, + { env: {}, projectDir: '/tmp/project', createClaudeCodeRuntime: vi.fn((deps) => ({ deps }) as never) }, + ); + + expect(runtime).toMatchObject({ deps: expect.objectContaining({ projectDir: '/tmp/project' }) }); + }); + + it('returns null from the AI SDK provider factory for claude-code backend', () => { + expect( + createLocalKtxLlmProviderFromConfig({ + provider: { backend: 'claude-code' }, + models: { default: 'sonnet' }, + }), + ).toBeNull(); + }); +}); diff --git a/packages/context/src/llm/runtime-port.ts b/packages/context/src/llm/runtime-port.ts new file mode 100644 index 00000000..6fddfd80 --- /dev/null +++ b/packages/context/src/llm/runtime-port.ts @@ -0,0 +1,75 @@ +import type { KtxModelRole } from '@ktx/llm'; +import type { z } from 'zod'; + +export interface KtxRuntimeToolOutput { + markdown: string; + structured?: TOutput; +} + +export interface KtxRuntimeToolDescriptor { + name: string; + description: string; + inputSchema: z.ZodObject; + execute(input: TInput): Promise>; +} + +export type KtxRuntimeToolSet = Record; + +export type RunLoopStopReason = 'budget' | 'natural' | 'error'; + +export interface RunLoopStepInfo { + stepIndex: number; + stepBudget: number; +} + +export interface RunLoopParams { + modelRole: KtxModelRole; + systemPrompt: string; + userPrompt: string; + toolSet: KtxRuntimeToolSet; + stepBudget: number; + telemetryTags: Record; + onStepFinish?: (info: RunLoopStepInfo) => void | Promise; +} + +export interface RunLoopResult { + stopReason: RunLoopStopReason; + error?: Error; +} + +export interface KtxGenerateTextInput { + role: KtxModelRole; + prompt: string; + system?: string; + tools?: KtxRuntimeToolSet; + temperature?: number; +} + +export interface KtxGenerateObjectInput> { + role: KtxModelRole; + prompt: string; + system?: string; + tools?: KtxRuntimeToolSet; + temperature?: number; + schema: TSchema; +} + +export interface KtxLlmRuntimePort { + generateText(input: KtxGenerateTextInput): Promise; + generateObject>( + input: KtxGenerateObjectInput, + ): Promise; + runAgentLoop(params: RunLoopParams): Promise; +} + +export interface AgentRunnerPort { + runLoop(params: RunLoopParams): Promise; +} + +export class RuntimeAgentRunner implements AgentRunnerPort { + constructor(private readonly runtime: KtxLlmRuntimePort) {} + + runLoop(params: RunLoopParams): Promise { + return this.runtime.runAgentLoop(params); + } +} diff --git a/packages/context/src/llm/runtime-tools.test.ts b/packages/context/src/llm/runtime-tools.test.ts new file mode 100644 index 00000000..c1276d7d --- /dev/null +++ b/packages/context/src/llm/runtime-tools.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { createAiSdkToolSet, createClaudeSdkTools, normalizeKtxRuntimeToolOutput } from './runtime-tools.js'; +import type { KtxRuntimeToolDescriptor } from './runtime-port.js'; + +describe('runtime tool descriptors', () => { + const descriptor: KtxRuntimeToolDescriptor<{ id: string }, { ok: boolean }> = { + name: 'read_thing', + description: 'Read one thing.', + inputSchema: z.object({ id: z.string() }), + execute: vi.fn(async (input) => ({ + markdown: `Read ${input.id}`, + structured: { ok: true }, + })), + }; + + it('normalizes string and object tool outputs into markdown plus optional structured payload', () => { + expect(normalizeKtxRuntimeToolOutput('plain text')).toEqual({ markdown: 'plain text' }); + expect(normalizeKtxRuntimeToolOutput({ markdown: 'shown', structured: { id: 1 } })).toEqual({ + markdown: 'shown', + structured: { id: 1 }, + }); + expect(normalizeKtxRuntimeToolOutput({ name: 'skill', content: 'body' })).toEqual({ + markdown: '```json\n{\n "name": "skill",\n "content": "body"\n}\n```', + structured: { name: 'skill', content: 'body' }, + }); + }); + + it('builds AI SDK tools that expose markdown to the model', async () => { + const tools = createAiSdkToolSet({ read_thing: descriptor }); + const output = await tools.read_thing.execute?.({ id: 'a' }, { toolCallId: 'call-1', messages: [] } as never); + const modelOutput = tools.read_thing.toModelOutput?.({ output } as never); + + expect(modelOutput).toEqual({ type: 'text', value: 'Read a' }); + }); + + it('builds Claude SDK tools that return text content only', async () => { + const tools = createClaudeSdkTools({ read_thing: descriptor }); + const result = await tools[0].handler({ id: 'b' } as never, {}); + + expect(result).toEqual({ content: [{ type: 'text', text: 'Read b' }] }); + }); +}); diff --git a/packages/context/src/llm/runtime-tools.ts b/packages/context/src/llm/runtime-tools.ts new file mode 100644 index 00000000..ab2e088d --- /dev/null +++ b/packages/context/src/llm/runtime-tools.ts @@ -0,0 +1,91 @@ +import { tool as aiTool, type Tool, type ToolSet } from 'ai'; +import { tool as claudeTool, type SdkMcpToolDefinition } from '@anthropic-ai/claude-agent-sdk'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { KtxRuntimeToolDescriptor, KtxRuntimeToolOutput, KtxRuntimeToolSet } from './runtime-port.js'; + +function isRuntimeOutput(value: unknown): value is KtxRuntimeToolOutput { + return Boolean( + value && + typeof value === 'object' && + 'markdown' in value && + typeof (value as { markdown?: unknown }).markdown === 'string', + ); +} + +export function normalizeKtxRuntimeToolOutput(value: unknown): KtxRuntimeToolOutput { + if (isRuntimeOutput(value)) { + return 'structured' in value ? { markdown: value.markdown, structured: value.structured } : { markdown: value.markdown }; + } + if (typeof value === 'string') { + return { markdown: value }; + } + return { + markdown: `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``, + structured: value, + }; +} + +function assertObjectSchema(name: string, schema: z.ZodType): asserts schema is z.ZodObject { + if (!(schema instanceof z.ZodObject)) { + throw new Error(`KTX runtime tool "${name}" must use z.object input schema for claude-code`); + } +} + +export function createAiSdkToolSet(tools: KtxRuntimeToolSet = {}): ToolSet { + return Object.fromEntries( + Object.entries(tools).map(([name, descriptor]) => [ + name, + aiTool({ + description: descriptor.description, + inputSchema: descriptor.inputSchema, + execute: async (input) => descriptor.execute(input), + toModelOutput: ({ output }) => { + const normalized = normalizeKtxRuntimeToolOutput(output); + return { type: 'text', value: normalized.markdown }; + }, + }), + ]), + ); +} + +export function createClaudeSdkTools(tools: KtxRuntimeToolSet = {}): Array> { + return Object.values(tools).map((descriptor) => { + assertObjectSchema(descriptor.name, descriptor.inputSchema); + return claudeTool( + descriptor.name, + descriptor.description, + descriptor.inputSchema.shape, + async (input): Promise => { + const normalized = normalizeKtxRuntimeToolOutput(await descriptor.execute(input)); + return { content: [{ type: 'text', text: normalized.markdown }] }; + }, + ); + }); +} + +export function mcpToolIds(tools: KtxRuntimeToolSet = {}): string[] { + return Object.keys(tools).map((name) => `mcp__ktx__${name}`); +} + +export function createRuntimeToolDescriptorFromAiTool(name: string, aiSdkTool: Tool): KtxRuntimeToolDescriptor { + return { + name, + description: aiSdkTool.description ?? '', + inputSchema: aiSdkTool.inputSchema as KtxRuntimeToolDescriptor['inputSchema'], + execute: async (input) => { + if (typeof aiSdkTool.execute !== 'function') { + throw new Error(`KTX runtime tool "${name}" has no execute function`); + } + return normalizeKtxRuntimeToolOutput( + await aiSdkTool.execute(input as never, { toolCallId: `runtime-${name}` } as never), + ); + }, + }; +} + +export function createRuntimeToolSetFromAiSdkTools(tools: ToolSet = {}): KtxRuntimeToolSet { + return Object.fromEntries( + Object.entries(tools).map(([name, aiSdkTool]) => [name, createRuntimeToolDescriptorFromAiTool(name, aiSdkTool as Tool)]), + ); +} diff --git a/packages/context/src/memory/local-memory.ts b/packages/context/src/memory/local-memory.ts index c1b1e4bd..a83c7a08 100644 --- a/packages/context/src/memory/local-memory.ts +++ b/packages/context/src/memory/local-memory.ts @@ -1,13 +1,17 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { KtxLlmProvider } from '@ktx/llm'; import YAML from 'yaml'; -import { AgentRunnerService } from '../agent/index.js'; import { localConnectionInfoFromConfig } from '../connections/index.js'; import type { KtxEmbeddingPort, KtxFileStorePort, KtxFileWriteResult } from '../core/index.js'; import { type KtxLogger, noopLogger, SessionWorktreeService } from '../core/index.js'; import type { KtxSemanticLayerComputePort } from '../daemon/index.js'; -import { createLocalKtxLlmProviderFromConfig } from '../llm/index.js'; +import { + createLocalKtxLlmRuntimeFromConfig, + RuntimeAgentRunner, + type AgentRunnerPort, + type KtxLlmRuntimePort, + type KtxRuntimeToolSet, +} from '../llm/index.js'; import type { KtxLocalProject } from '../project/index.js'; import { PromptService } from '../prompts/index.js'; import { SkillsRegistryService } from '../skills/index.js'; @@ -63,8 +67,8 @@ const LOCAL_AUTHOR = { name: 'KTX Local', email: 'local@ktx.local' }; const LOCAL_SHAPE_WARNING = 'Local memory ingest validates semantic-layer YAML shape only.'; export interface CreateLocalProjectMemoryIngestOptions { - llmProvider?: KtxLlmProvider; - agentRunner?: AgentRunnerService; + llmRuntime?: KtxLlmRuntimePort; + agentRunner?: AgentRunnerPort; memoryModel?: string; semanticLayerCompute?: KtxSemanticLayerComputePort; queryExecutor?: { execute(input: { connectionId: string; sql: string; maxRows?: number }): Promise }; @@ -89,7 +93,8 @@ export function createLocalProjectMemoryIngest( const slSearchService = new SlSearchService(embedding, slSourcesRepository, logger); const wikiService = new KnowledgeWikiService(rootFileStore, embedding, knowledgeIndex, project.git, logger); const authorResolver = new LocalAuthorResolver(); - const llmProvider = options.llmProvider ?? createLocalKtxLlmProviderFromConfig(project.config.llm); + const llmRuntime = + options.llmRuntime ?? createLocalKtxLlmRuntimeFromConfig(project.config.llm, { projectDir: project.projectDir }); const toolsetFactory = new LocalMemoryToolsetFactory({ project, embedding, @@ -104,10 +109,7 @@ export function createLocalProjectMemoryIngest( }); const agentRunner = options.agentRunner ?? - new AgentRunnerService({ - llmProvider: requireLlmProvider(llmProvider), - logger, - }); + new RuntimeAgentRunner(requireLlmRuntime(llmRuntime)); const memoryAgent = new MemoryAgentService({ settings: { knowledge: { userScopedKnowledgeEnabled: false }, @@ -143,11 +145,11 @@ export function createLocalProjectMemoryIngest( }); } -function requireLlmProvider(provider: KtxLlmProvider | null | undefined): KtxLlmProvider { - if (!provider) { +function requireLlmRuntime(runtime: KtxLlmRuntimePort | null | undefined): KtxLlmRuntimePort { + if (!runtime) { throw new Error('createLocalProjectMemoryIngest requires llm.provider.backend or an injected agentRunner'); } - return provider; + return runtime; } class LocalMemoryFileStore implements MemoryFileStorePort { @@ -386,8 +388,8 @@ class LocalShapeOnlySlValidator implements SlValidatorPort { class LocalMemoryToolSet implements MemoryToolSetLike { constructor(private readonly tools: BaseTool[]) {} - toAiSdkTools(context: ToolContext) { - return Object.fromEntries(this.tools.map((tool) => [tool.name, tool.toAiSdkTool(context)])); + toRuntimeTools(context: ToolContext): KtxRuntimeToolSet { + return Object.fromEntries(this.tools.map((tool) => [tool.name, tool.toRuntimeTool(context)])); } } diff --git a/packages/context/src/memory/memory-agent.service.ingest.test.ts b/packages/context/src/memory/memory-agent.service.ingest.test.ts index 2df4140c..e9985c20 100644 --- a/packages/context/src/memory/memory-agent.service.ingest.test.ts +++ b/packages/context/src/memory/memory-agent.service.ingest.test.ts @@ -1,3 +1,6 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Module-level mock for 'ai' so generateText is a stub. This file is separate from @@ -15,7 +18,6 @@ import { MemoryAgentService } from './memory-agent.service.js'; interface BuiltMocks { appSettings: any; - llmProvider: any; prompt: any; eventTracker: any; telemetry: any; @@ -63,7 +65,6 @@ const buildMocks = (overrides: Partial = {}): BuiltMocks => { llm: { memoryIngestionModel: 'test-model' }, }, }, - llmProvider: { getModel: vi.fn().mockReturnValue({}) }, prompt: { loadPrompt: vi.fn().mockResolvedValue('base framing') }, eventTracker: { trackEvent: vi.fn(), createTelemetryIntegration: vi.fn().mockReturnValue(undefined) }, telemetry: { @@ -124,11 +125,11 @@ const buildMocks = (overrides: Partial = {}): BuiltMocks => { slValidator: { validateSingleSource: vi.fn().mockResolvedValue({ errors: [], warnings: [] }) }, toolsetFactory: { createIngestWuToolset: vi.fn().mockReturnValue({ - toAiSdkTools: vi.fn().mockReturnValue({}), + toRuntimeTools: vi.fn().mockReturnValue({}), getAllTools: vi.fn().mockReturnValue([]), }), createToolset: vi.fn().mockReturnValue({ - toAiSdkTools: vi.fn().mockReturnValue({}), + toRuntimeTools: vi.fn().mockReturnValue({}), getAllTools: vi.fn().mockReturnValue([]), }), }, @@ -241,6 +242,39 @@ describe('MemoryAgentService.ingest — session-branch orchestration', () => { expect(result.commitHash).toBe('cafebabe'); }); + it('normalizes load_skill output to markdown while preserving structured payload', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'ktx-memory-skill-')); + const skillDir = join(tempDir, 'memory_agent'); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, 'SKILL.md'), '---\nname: memory_agent\n---\nSkill body', 'utf-8'); + try { + const agentRunner = { + runLoop: vi.fn(async (params: any) => { + const result = await params.toolSet.load_skill.execute({ name: 'memory_agent' }); + expect(result.markdown).toContain('memory_agent'); + expect(result.structured).toMatchObject({ name: 'memory_agent' }); + return { stopReason: 'natural' as const }; + }), + }; + const mocks = buildMocks({ + agentRunner, + skillsRegistry: { + listSkills: vi.fn().mockResolvedValue([{ name: 'memory_agent', path: skillDir }]), + buildSkillsPrompt: vi.fn().mockReturnValue(''), + getSkill: vi.fn().mockResolvedValue({ name: 'memory_agent', path: skillDir }), + stripFrontmatter: vi.fn().mockReturnValue('Skill body'), + }, + }); + const svc = buildService(mocks); + + await svc.ingest(baseInput); + + expect(agentRunner.runLoop).toHaveBeenCalled(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + it('logs prompt debug output when KTX_MEMORY_AGENT_DEBUG_PROMPTS is enabled', async () => { const previousDebugPrompts = process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS; const mocks = buildMocks(); diff --git a/packages/context/src/memory/memory-agent.service.ts b/packages/context/src/memory/memory-agent.service.ts index d7e86d3d..7821f2b9 100644 --- a/packages/context/src/memory/memory-agent.service.ts +++ b/packages/context/src/memory/memory-agent.service.ts @@ -1,10 +1,10 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { tool } from 'ai'; import * as YAML from 'yaml'; import { z } from 'zod'; import { type KtxLogger, noopLogger } from '../core/index.js'; +import type { KtxRuntimeToolSet } from '../llm/index.js'; import { revertSourceToPreHead, type SemanticLayerSource, @@ -125,8 +125,9 @@ export class MemoryAgentService { session: toolSession, }; - const loadSkillTool = { - load_skill: tool({ + const loadSkillTool: KtxRuntimeToolSet = { + load_skill: { + name: 'load_skill', description: 'Load a skill to get specialized instructions. Call this when a skill listed in the system prompt matches the current task.', inputSchema: z.object({ @@ -137,23 +138,27 @@ export class MemoryAgentService { if (!skill) { const available = (await this.deps.skillsRegistry.listSkills('memory_agent')).map((s) => s.name).join(', ') || '(none)'; - return `Skill "${name}" not available to the memory agent. Available: ${available}`; + return { markdown: `Skill "${name}" not available to the memory agent. Available: ${available}` }; } try { const body = await readFile(join(skill.path, 'SKILL.md'), 'utf-8'); if (!skillsLoaded.includes(skill.name)) { skillsLoaded.push(skill.name); } - return { + const structured = { name: skill.name, skillDirectory: skill.path, content: this.deps.skillsRegistry.stripFrontmatter(body), }; + return { + markdown: `# ${structured.name}\n\n${structured.content}`, + structured, + }; } catch (e) { - return `Error loading skill "${name}": ${e instanceof Error ? e.message : String(e)}`; + return { markdown: `Error loading skill "${name}": ${e instanceof Error ? e.message : String(e)}` }; } }, - }), + }, }; const skillNames: string[] = [...DEFAULT_SKILL_NAMES]; @@ -212,7 +217,7 @@ export class MemoryAgentService { modelRole: 'candidateExtraction', systemPrompt, userPrompt: prompt, - toolSet: { ...toolset.toAiSdkTools(toolContext), ...loadSkillTool }, + toolSet: { ...toolset.toRuntimeTools(toolContext), ...loadSkillTool }, stepBudget, telemetryTags: { operationName: 'memory-agent-ingest', diff --git a/packages/context/src/memory/types.ts b/packages/context/src/memory/types.ts index 207eb238..bb2f3bad 100644 --- a/packages/context/src/memory/types.ts +++ b/packages/context/src/memory/types.ts @@ -1,5 +1,4 @@ -import type { Tool } from 'ai'; -import type { AgentRunnerService } from '../agent/index.js'; +import type { AgentRunnerPort, KtxRuntimeToolSet } from '../llm/index.js'; import type { GitService, KtxFileStorePort, KtxLogger, SessionWorktreeService } from '../core/index.js'; import type { PromptService } from '../prompts/index.js'; import type { SkillsRegistryService } from '../skills/index.js'; @@ -118,7 +117,7 @@ export interface MemoryCommitMessagePort { export interface MemoryFileStorePort extends KtxFileStorePort, MemoryCommitMessagePort {} export interface MemoryToolSetLike { - toAiSdkTools(context: ToolContext): Record; + toRuntimeTools(context: ToolContext): KtxRuntimeToolSet; } export interface MemoryToolsetFactoryPort { @@ -150,7 +149,7 @@ export interface MemoryAgentServiceDeps { slSourcesRepository: SlSourcesIndexPort; sessionWorktreeService: SessionWorktreeService; semanticLayerSourceReconciler: MemorySlSourceReconcilerPort; - agentRunner: AgentRunnerService; + agentRunner: AgentRunnerPort; slValidator: SlValidatorPort; toolsetFactory: MemoryToolsetFactoryPort; telemetry?: MemoryTelemetryPort; diff --git a/packages/context/src/project/config.test.ts b/packages/context/src/project/config.test.ts index 164282eb..3967b363 100644 --- a/packages/context/src/project/config.test.ts +++ b/packages/context/src/project/config.test.ts @@ -180,6 +180,31 @@ llm: }); }); + it('parses Claude Code as a first-class LLM backend', () => { + const config = parseKtxProjectConfig(` +llm: + provider: + backend: claude-code + models: + default: sonnet + triage: haiku + candidateExtraction: sonnet + curator: sonnet + reconcile: sonnet + repair: opus +`); + + expect(config.llm.provider.backend).toBe('claude-code'); + expect(config.llm.models).toEqual({ + default: 'sonnet', + triage: 'haiku', + candidateExtraction: 'sonnet', + curator: 'sonnet', + reconcile: 'sonnet', + repair: 'opus', + }); + }); + it('parses gateway LLM, OpenAI scan embeddings, and sentence-transformers ingest embeddings', () => { const config = parseKtxProjectConfig(` llm: @@ -497,7 +522,7 @@ describe('generateKtxProjectConfigJsonSchema', () => { const llm = (schema.properties as Record }>).llm; const provider = llm?.properties?.provider as { properties?: Record }; const backend = provider?.properties?.backend as { enum?: readonly string[] }; - expect(backend?.enum).toEqual(['none', 'anthropic', 'vertex', 'gateway']); + expect(backend?.enum).toEqual(['none', 'anthropic', 'vertex', 'gateway', 'claude-code']); const storage = (schema.properties as Record }>).storage; const state = storage?.properties?.state as { enum?: readonly string[] }; diff --git a/packages/context/src/project/config.ts b/packages/context/src/project/config.ts index 178721c4..912c31de 100644 --- a/packages/context/src/project/config.ts +++ b/packages/context/src/project/config.ts @@ -3,7 +3,7 @@ import YAML from 'yaml'; import * as z from 'zod'; import { connectionConfigSchema } from './driver-schemas.js'; -const KTX_LLM_BACKENDS = ['none', 'anthropic', 'vertex', 'gateway'] as const; +const KTX_LLM_BACKENDS = ['none', 'anthropic', 'vertex', 'gateway', 'claude-code'] as const; const KTX_EMBEDDING_BACKENDS = ['none', 'deterministic', 'openai', 'sentence-transformers'] as const; const KTX_PROMPT_CACHE_TTLS = ['5m', '1h'] as const; const KTX_ENRICHMENT_MODES = ['none', 'deterministic', 'llm'] as const; @@ -46,7 +46,9 @@ const llmProviderSchema = z backend: z .enum(KTX_LLM_BACKENDS) .default('none') - .describe('LLM provider backend. "none" disables LLM features; "anthropic" / "vertex" / "gateway" require the matching nested credentials block.'), + .describe( + 'LLM provider backend. "none" disables LLM features; "anthropic" / "vertex" / "gateway" require the matching nested credentials block; "claude-code" uses the local Claude Code session.', + ), vertex: vertexProviderSchema.optional().describe('Vertex AI credentials, used when backend is "vertex".'), anthropic: apiCredentialsSchema.optional().describe('Anthropic API credentials, used when backend is "anthropic".'), gateway: apiCredentialsSchema.optional().describe('AI Gateway credentials, used when backend is "gateway".'), diff --git a/packages/context/src/scan/description-generation.test.ts b/packages/context/src/scan/description-generation.test.ts index 8ffd3b5c..e47d32be 100644 --- a/packages/context/src/scan/description-generation.test.ts +++ b/packages/context/src/scan/description-generation.test.ts @@ -31,46 +31,32 @@ function createCache(initial: Record = {}): KtxDescriptionCacheP function createLlmProvider(text = 'generated description') { vi.mocked(generateText).mockResolvedValue({ text } as never); return { - getModel: vi.fn().mockReturnValue({ modelId: 'claude-sonnet-4-6', provider: 'anthropic' }), - getModelByName: vi.fn(), - cacheMarker: vi.fn(), - repairToolCallHandler: vi.fn(), - thinkingProviderOptions: vi.fn(), - telemetryConfig: vi.fn(), - promptCachingConfig: vi.fn(() => ({ - enabled: false, - systemTtl: '1h', - toolsTtl: '1h', - historyTtl: '5m', - cacheSystem: true, - cacheTools: true, - cacheHistory: true, - vertexFallbackTo5m: false, - })), - activeBackend: vi.fn(() => 'anthropic'), + generateText: vi.fn(async (input) => { + const result = await generateText({ + system: input.system ? { role: 'system', content: input.system } : undefined, + messages: [{ role: 'user', content: input.prompt }], + temperature: input.temperature, + } as never); + return result.text; + }), + generateObject: vi.fn(), + runAgentLoop: vi.fn(), } as any; } function createFailingLlmProvider(message = 'timeout exceeded when trying to connect') { vi.mocked(generateText).mockRejectedValue(new Error(message) as never); return { - getModel: vi.fn().mockReturnValue({ modelId: 'claude-sonnet-4-6', provider: 'anthropic' }), - getModelByName: vi.fn(), - cacheMarker: vi.fn(), - repairToolCallHandler: vi.fn(), - thinkingProviderOptions: vi.fn(), - telemetryConfig: vi.fn(), - promptCachingConfig: vi.fn(() => ({ - enabled: false, - systemTtl: '1h', - toolsTtl: '1h', - historyTtl: '5m', - cacheSystem: true, - cacheTools: true, - cacheHistory: true, - vertexFallbackTo5m: false, - })), - activeBackend: vi.fn(() => 'anthropic'), + generateText: vi.fn(async (input) => { + const result = await generateText({ + system: input.system ? { role: 'system', content: input.system } : undefined, + messages: [{ role: 'user', content: input.prompt }], + temperature: input.temperature, + } as never); + return result.text; + }), + generateObject: vi.fn(), + runAgentLoop: vi.fn(), } as any; } @@ -158,10 +144,10 @@ describe('KTX description prompt builders', () => { describe('KtxDescriptionGenerator', () => { it('generates column descriptions with pre-fetched values, cache hits, and word-limit metadata', async () => { const cache = createCache({ 'warehouse.public.orders.cached_status': 'Cached status description' }); - const llmProvider = createLlmProvider('Payment state'); + const llmRuntime = createLlmProvider('Payment state'); const connector = createConnector(); const generator = new KtxDescriptionGenerator({ - llmProvider, + llmRuntime, cache, settings: { columnMaxWords: 12, @@ -222,7 +208,7 @@ describe('KtxDescriptionGenerator', () => { it('samples through the connector when column values are not pre-fetched', async () => { const connector = createConnector(); const generator = new KtxDescriptionGenerator({ - llmProvider: createLlmProvider('Current order state'), + llmRuntime: createLlmProvider('Current order state'), settings: { columnMaxWords: 12, tableMaxWords: 18, @@ -271,7 +257,7 @@ describe('KtxDescriptionGenerator', () => { })), }; const generator = new KtxDescriptionGenerator({ - llmProvider: createLlmProvider('Generated through sampler'), + llmRuntime: createLlmProvider('Generated through sampler'), settings: { columnMaxWords: 12, tableMaxWords: 18, @@ -310,7 +296,7 @@ describe('KtxDescriptionGenerator', () => { const cache = createCache(); const connector = createConnector(); const generator = new KtxDescriptionGenerator({ - llmProvider: createFailingLlmProvider(), + llmRuntime: createFailingLlmProvider(), cache, settings: { columnMaxWords: 12, @@ -355,7 +341,7 @@ describe('KtxDescriptionGenerator', () => { const cache = createCache(); const connector = createConnector(); const generator = new KtxDescriptionGenerator({ - llmProvider: createLlmProvider('Commerce orders'), + llmRuntime: createLlmProvider('Commerce orders'), cache, settings: { columnMaxWords: 12, @@ -424,7 +410,7 @@ describe('KtxDescriptionGenerator resilience', () => { const logger = createLogger(); const warnings: Array<{ code: string; table?: string }> = []; const generator = new KtxDescriptionGenerator({ - llmProvider: createLlmProvider('Commerce orders'), + llmRuntime: createLlmProvider('Commerce orders'), logger, onWarning: (warning) => warnings.push({ code: warning.code, ...(warning.table ? { table: warning.table } : {}) }), settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24, concurrencyLimit: 2 }, @@ -455,7 +441,7 @@ describe('KtxDescriptionGenerator resilience', () => { const logger = createLogger(); const warnings: Array<{ code: string; table?: string; metadata?: Record }> = []; const generator = new KtxDescriptionGenerator({ - llmProvider: createLlmProvider('Customer reference data'), + llmRuntime: createLlmProvider('Customer reference data'), logger, onWarning: (warning) => warnings.push({ @@ -503,7 +489,7 @@ describe('KtxDescriptionGenerator resilience', () => { }; const warnings: string[] = []; const generator = new KtxDescriptionGenerator({ - llmProvider: createFailingLlmProvider(), + llmRuntime: createFailingLlmProvider(), onWarning: (warning) => warnings.push(warning.code), settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 }, }); @@ -528,7 +514,7 @@ describe('KtxDescriptionGenerator resilience', () => { }; const warnings: string[] = []; const generator = new KtxDescriptionGenerator({ - llmProvider: createLlmProvider('Orders mart'), + llmRuntime: createLlmProvider('Orders mart'), onWarning: (warning) => warnings.push(warning.code), settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 }, }); @@ -562,7 +548,7 @@ describe('KtxDescriptionGenerator resilience', () => { }; const warnings: string[] = []; const generator = new KtxDescriptionGenerator({ - llmProvider: createLlmProvider('should not be called'), + llmRuntime: createLlmProvider('should not be called'), onWarning: (warning) => warnings.push(warning.code), settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 }, }); @@ -588,7 +574,7 @@ describe('KtxDescriptionGenerator resilience', () => { }; const logger = createLogger(); const generator = new KtxDescriptionGenerator({ - llmProvider: createLlmProvider('Payment lifecycle state'), + llmRuntime: createLlmProvider('Payment lifecycle state'), logger, settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 }, }); @@ -625,7 +611,7 @@ describe('KtxDescriptionGenerator resilience', () => { sampleColumn, }; const generator = new KtxDescriptionGenerator({ - llmProvider: createLlmProvider('Customer reference identifier'), + llmRuntime: createLlmProvider('Customer reference identifier'), settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 }, }); @@ -657,7 +643,7 @@ describe('KtxDescriptionGenerator resilience', () => { }; vi.mocked(generateText).mockClear(); const generator = new KtxDescriptionGenerator({ - llmProvider: createLlmProvider('should not be called'), + llmRuntime: createLlmProvider('should not be called'), settings: { columnMaxWords: 12, tableMaxWords: 18, dataSourceMaxWords: 24 }, }); diff --git a/packages/context/src/scan/description-generation.ts b/packages/context/src/scan/description-generation.ts index 184827ff..ba61fb62 100644 --- a/packages/context/src/scan/description-generation.ts +++ b/packages/context/src/scan/description-generation.ts @@ -1,5 +1,4 @@ -import type { KtxLlmProvider } from '@ktx/llm'; -import { generateKtxText } from '../llm/index.js'; +import type { KtxLlmRuntimePort } from '../llm/index.js'; import type { KtxColumnSampleInput, KtxColumnSampleResult, @@ -120,7 +119,7 @@ export interface KtxGenerateDataSourceDescriptionInput { } export interface KtxDescriptionGeneratorOptions { - llmProvider: KtxLlmProvider; + llmRuntime: KtxLlmRuntimePort; cache?: KtxDescriptionCachePort; logger?: KtxScanLoggerPort; onWarning?: (warning: KtxScanWarning) => void; @@ -400,14 +399,14 @@ Data source type: ${input.dataSourceType}`; } export class KtxDescriptionGenerator { - private readonly llmProvider: KtxLlmProvider; + private readonly llmRuntime: KtxLlmRuntimePort; private readonly cache?: KtxDescriptionCachePort; private readonly logger?: KtxScanLoggerPort; private readonly onWarning?: (warning: KtxScanWarning) => void; private readonly settings: ResolvedKtxDescriptionGenerationSettings; constructor(options: KtxDescriptionGeneratorOptions) { - this.llmProvider = options.llmProvider; + this.llmRuntime = options.llmRuntime; this.cache = options.cache; this.logger = options.logger; this.onWarning = options.onWarning; @@ -779,8 +778,7 @@ export class KtxDescriptionGenerator { private async generateAiDescription(prompt: KtxDescriptionPrompt, _operationName: string): Promise { try { - const text = await generateKtxText({ - llmProvider: this.llmProvider, + const text = await this.llmRuntime.generateText({ role: 'candidateExtraction', system: prompt.system, prompt: prompt.user, diff --git a/packages/context/src/scan/index.ts b/packages/context/src/scan/index.ts index 4360fec7..1eecdfeb 100644 --- a/packages/context/src/scan/index.ts +++ b/packages/context/src/scan/index.ts @@ -264,7 +264,6 @@ export type { } from './relationship-graph-resolver.js'; export { resolveKtxRelationshipGraph } from './relationship-graph-resolver.js'; export type { - KtxRelationshipLlmProposalGenerateText, KtxRelationshipLlmProposalResult, KtxRelationshipLlmProposalSettings, ProposeKtxRelationshipCandidatesWithLlmInput, diff --git a/packages/context/src/scan/local-enrichment.test.ts b/packages/context/src/scan/local-enrichment.test.ts index f0ddd448..72307bc4 100644 --- a/packages/context/src/scan/local-enrichment.test.ts +++ b/packages/context/src/scan/local-enrichment.test.ts @@ -356,7 +356,7 @@ describe('local scan enrichment', () => { it('honors scan relationship config when LLM proposals are disabled', async () => { const providers = createDeterministicLocalScanEnrichmentProviders({ embeddingDimensions: 3 }); - const getModel = vi.fn(() => ({ modelId: 'provider/language-model', provider: 'gateway' })); + const generateObject = vi.fn(); const result = await runLocalScanEnrichment({ connectionId: 'warehouse', mode: 'relationships', @@ -365,9 +365,9 @@ describe('local scan enrichment', () => { context: { runId: 'scan-run-llm-disabled' }, providers: { ...providers, - llm: { - ...providers.llm, - getModel: getModel as never, + llmRuntime: { + ...providers.llmRuntime, + generateObject: generateObject as never, }, }, relationshipSettings: { @@ -378,7 +378,7 @@ describe('local scan enrichment', () => { }); expect(result.summary.llmRelationshipValidation).toBe('skipped'); - expect(getModel).not.toHaveBeenCalledWith('candidateExtraction'); + expect(generateObject).not.toHaveBeenCalled(); }); it('skips relationship detection when scan relationships are disabled', async () => { @@ -628,7 +628,7 @@ describe('local scan enrichment', () => { connector: scanConnector, context: { runId: 'scan-run-batched-embeddings' }, providers: { - llm: deterministicProviders.llm, + llmRuntime: deterministicProviders.llmRuntime, embedding: { dimensions: 3, maxBatchSize: 2, @@ -658,7 +658,7 @@ describe('local scan enrichment', () => { providerIdentity: { provider: 'deterministic', embeddingDimensions: 6 }, }); - const getModel = vi.spyOn(providers.llm, 'getModel'); + const generateText = vi.spyOn(providers.llmRuntime, 'generateText'); const embedBatch = vi.spyOn(providers.embedding, 'embedBatch'); const second = await runLocalScanEnrichment({ connectionId: 'warehouse', @@ -676,7 +676,7 @@ describe('local scan enrichment', () => { expect(first.state.resumedStages).toEqual([]); expect(second.state.resumedStages).toEqual(['descriptions', 'embeddings', 'relationships']); expect(second.state.completedStages).toEqual(['descriptions', 'embeddings', 'relationships']); - expect(getModel).not.toHaveBeenCalled(); + expect(generateText).not.toHaveBeenCalled(); expect(embedBatch).not.toHaveBeenCalled(); expect(second.descriptionUpdates).toEqual(first.descriptionUpdates); expect(second.embeddingUpdates).toEqual(first.embeddingUpdates); @@ -711,7 +711,7 @@ describe('local scan enrichment', () => { tables: [{ ...firstTable, name: 'customers' }], })), }; - const getModel = vi.spyOn(providers.llm, 'getModel'); + const generateText = vi.spyOn(providers.llmRuntime, 'generateText'); const result = await runLocalScanEnrichment({ connectionId: 'warehouse', @@ -727,7 +727,7 @@ describe('local scan enrichment', () => { expect(result.state.resumedStages).toEqual([]); expect(result.state.completedStages).toEqual(['descriptions', 'embeddings', 'relationships']); - expect(getModel).toHaveBeenCalled(); + expect(generateText).toHaveBeenCalled(); }); it('runs providerless enriched scans as relationship-only discovery enrichment', async () => { diff --git a/packages/context/src/scan/local-enrichment.ts b/packages/context/src/scan/local-enrichment.ts index e6a9976b..839b6fc9 100644 --- a/packages/context/src/scan/local-enrichment.ts +++ b/packages/context/src/scan/local-enrichment.ts @@ -1,5 +1,5 @@ -import type { KtxLlmProvider } from '@ktx/llm'; import pLimit from 'p-limit'; +import type { KtxLlmRuntimePort } from '../llm/index.js'; import { buildDefaultKtxProjectConfig, type KtxScanRelationshipConfig } from '../project/config.js'; import { type KtxDescriptionColumnTable, KtxDescriptionGenerator } from './description-generation.js'; import { buildKtxColumnEmbeddingText } from './embedding-text.js'; @@ -49,7 +49,7 @@ export interface DeterministicLocalScanEnrichmentProviderOptions { } export interface KtxLocalScanEnrichmentProviders { - llm: KtxLlmProvider; + llmRuntime: KtxLlmRuntimePort; embedding: KtxEmbeddingPort; } @@ -190,7 +190,7 @@ export function createDeterministicLocalScanEnrichmentProviders( const dimensions = options.embeddingDimensions ?? 8; const maxBatchSize = options.maxBatchSize ?? 64; return { - llm: deterministicLlmProvider(), + llmRuntime: deterministicLlmRuntime(), embedding: { dimensions, maxBatchSize, @@ -201,41 +201,16 @@ export function createDeterministicLocalScanEnrichmentProviders( }; } -function deterministicLlmProvider(): KtxLlmProvider { - const model = { modelId: 'deterministic-scan', provider: 'deterministic' }; +function deterministicLlmRuntime(): KtxLlmRuntimePort { return { - getModel() { - return model as ReturnType; + async generateText(input) { + return `Deterministic description for ${input.prompt.slice(0, 64).trim() || 'data source'}`; }, - getModelByName() { - return model as ReturnType; + async generateObject() { + return { pkCandidates: [], fkCandidates: [] } as never; }, - cacheMarker() { - return undefined; - }, - repairToolCallHandler() { - throw new Error('deterministic scan provider does not support tool-call repair'); - }, - thinkingProviderOptions() { - return {}; - }, - telemetryConfig() { - return undefined; - }, - promptCachingConfig() { - return { - enabled: false, - systemTtl: '1h', - toolsTtl: '1h', - historyTtl: '5m', - cacheSystem: true, - cacheTools: true, - cacheHistory: true, - vertexFallbackTo5m: false, - }; - }, - activeBackend() { - return 'gateway'; + async runAgentLoop() { + return { stopReason: 'natural' }; }, }; } @@ -324,7 +299,7 @@ async function generateDescriptions(input: { }): Promise { const warningSink = input.warnings; const generator = new KtxDescriptionGenerator({ - llmProvider: input.providers.llm, + llmRuntime: input.providers.llmRuntime, ...(input.context.logger ? { logger: input.context.logger } : {}), ...(warningSink ? { @@ -643,7 +618,7 @@ export async function runLocalScanEnrichment( schema, context: input.context, settings: relationshipSettings, - llmProvider: input.providers?.llm ?? null, + llmRuntime: input.providers?.llmRuntime ?? null, }); await relationshipProgress?.update( diff --git a/packages/context/src/scan/local-scan.test.ts b/packages/context/src/scan/local-scan.test.ts index 40c8e225..16fab098 100644 --- a/packages/context/src/scan/local-scan.test.ts +++ b/packages/context/src/scan/local-scan.test.ts @@ -1,10 +1,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { KtxLlmProvider } from '@ktx/llm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import YAML from 'yaml'; import type { SourceAdapter } from '../ingest/index.js'; +import type { KtxLlmRuntimePort } from '../llm/index.js'; import { initKtxProject, type KtxLocalProject, loadKtxProject } from '../project/index.js'; import { filterSnapshotTables, getLocalScanReport, getLocalScanStatus, resolveEnabledTables, runLocalScan } from './local-scan.js'; import type { KtxQueryResult, KtxReadOnlyQueryInput, KtxSchemaSnapshot, KtxSchemaTable } from './types.js'; @@ -79,25 +79,11 @@ function relationshipSqlResult( throw new Error(`Unexpected relationship SQL: ${input.sql}`); } -function deterministicLlmProvider(): KtxLlmProvider { +function deterministicLlmRuntime(): KtxLlmRuntimePort { return { - getModel: () => ({ provider: 'deterministic', modelId: 'deterministic' }) as never, - getModelByName: () => ({ provider: 'deterministic', modelId: 'deterministic' }) as never, - cacheMarker: () => undefined, - repairToolCallHandler: (() => undefined) as never, - thinkingProviderOptions: () => ({}), - telemetryConfig: () => undefined, - promptCachingConfig: () => ({ - enabled: false, - systemTtl: '1h', - toolsTtl: '1h', - historyTtl: '5m', - cacheSystem: true, - cacheTools: true, - cacheHistory: true, - vertexFallbackTo5m: false, - }), - activeBackend: () => 'gateway', + generateText: vi.fn(async (input) => `Deterministic description for ${input.prompt.slice(0, 64).trim() || 'data source'}`), + generateObject: vi.fn(async () => ({ pkCandidates: [], fkCandidates: [] }) as never), + runAgentLoop: vi.fn(), }; } @@ -571,7 +557,7 @@ describe('local scan', () => { llmProposals: false, maxLlmTablesPerBatch: 7, }; - const getModel = vi.fn(() => ({ modelId: 'provider/language-model', provider: 'gateway' })); + const generateObject = vi.fn(async () => ({ pkCandidates: [], fkCandidates: [] })); const connector = { id: 'test:warehouse', driver: 'postgres' as const, @@ -650,9 +636,9 @@ describe('local scan', () => { detectRelationships: true, connector, enrichmentProviders: { - llm: { - ...deterministicLlmProvider(), - getModel: getModel as never, + llmRuntime: { + ...deterministicLlmRuntime(), + generateObject: generateObject as never, }, embedding: { dimensions: 8, @@ -668,7 +654,7 @@ describe('local scan', () => { expect(result.report.relationships.accepted).toBe(1); expect(result.report.enrichment.llmRelationshipValidation).toBe('skipped'); - expect(getModel).not.toHaveBeenCalledWith('candidateExtraction'); + expect(generateObject).not.toHaveBeenCalled(); }); it('accepts no-declared-constraint relationships and writes relationship artifacts', async () => { @@ -1206,7 +1192,7 @@ describe('local scan', () => { mode: 'enriched', connector, enrichmentProviders: { - llm: deterministicLlmProvider(), + llmRuntime: deterministicLlmRuntime(), embedding: { dimensions: 8, maxBatchSize: 64, @@ -1314,7 +1300,7 @@ describe('local scan', () => { return { values: ['1'], nullCount: 0, distinctCount: 1 }; }, }; - const llm = deterministicLlmProvider(); + const llmRuntime = deterministicLlmRuntime(); const first = await runLocalScan({ project, @@ -1323,7 +1309,7 @@ describe('local scan', () => { mode: 'enriched', connector, enrichmentProviders: { - llm, + llmRuntime, embedding: { dimensions: 8, maxBatchSize: 64, @@ -1344,7 +1330,7 @@ describe('local scan', () => { }); expect(first.report.enrichment.embeddings).toBe('failed'); - const getModel = vi.spyOn(llm, 'getModel'); + const generateObject = vi.spyOn(llmRuntime, 'generateObject'); const retry = await runLocalScan({ project, adapters: [fetchOnlyAdapter()], @@ -1352,7 +1338,7 @@ describe('local scan', () => { mode: 'enriched', connector, enrichmentProviders: { - llm, + llmRuntime, embedding: { dimensions: 8, maxBatchSize: 64, @@ -1373,8 +1359,8 @@ describe('local scan', () => { failedStages: [], }); expect(retry.report.enrichment.embeddings).toBe('completed'); - expect(getModel).toHaveBeenCalledTimes(1); - expect(getModel).toHaveBeenCalledWith('candidateExtraction'); + expect(generateObject).toHaveBeenCalledTimes(1); + expect(generateObject).toHaveBeenCalledWith(expect.objectContaining({ role: 'candidateExtraction' })); expect(embeddingAttempts).toBe(2); const reportPath = retry.report.artifactPaths.reportPath; diff --git a/packages/context/src/scan/local-scan.ts b/packages/context/src/scan/local-scan.ts index 362c3b2c..f9ac77d3 100644 --- a/packages/context/src/scan/local-scan.ts +++ b/packages/context/src/scan/local-scan.ts @@ -8,7 +8,7 @@ import { } from '../ingest/index.js'; import { createLocalKtxEmbeddingProviderFromConfig, - createLocalKtxLlmProviderFromConfig, + createLocalKtxLlmRuntimeFromConfig, KtxScanEmbeddingPortAdapter, } from '../llm/index.js'; import type { KtxProjectLlmConfig, KtxScanEnrichmentConfig, KtxScanRelationshipConfig } from '../project/config.js'; @@ -150,6 +150,7 @@ interface LocalScanEnrichmentProviderDeps { createKtxLlmProvider?: typeof createKtxLlmProvider; createKtxEmbeddingProvider?: typeof createKtxEmbeddingProvider; env?: NodeJS.ProcessEnv; + projectDir?: string; } export function createLocalScanEnrichmentProvidersFromConfig( @@ -165,14 +166,17 @@ export function createLocalScanEnrichmentProvidersFromConfig( return null; } - const llm = createLocalKtxLlmProviderFromConfig(llmConfig, deps); + const llmRuntime = createLocalKtxLlmRuntimeFromConfig(llmConfig, { + ...deps, + projectDir: deps.projectDir, + }); const embeddingProvider = createLocalKtxEmbeddingProviderFromConfig(config.embeddings, deps); - if (!llm || !embeddingProvider) { + if (!llmRuntime || !embeddingProvider) { return null; } return { - llm, + llmRuntime, embedding: new KtxScanEmbeddingPortAdapter(embeddingProvider), }; } @@ -378,7 +382,9 @@ export async function runLocalScan(options: RunLocalScanOptions): Promise model as ReturnType), - getModelByName: vi.fn(() => model as ReturnType), - cacheMarker: vi.fn(), - repairToolCallHandler: vi.fn(), - thinkingProviderOptions: vi.fn(() => ({})), - telemetryConfig: vi.fn(() => undefined), - promptCachingConfig: vi.fn( - () => - ({ - enabled: false, - systemTtl: '1h', - toolsTtl: '1h', - historyTtl: '5m', - cacheSystem: true, - cacheTools: true, - cacheHistory: true, - vertexFallbackTo5m: false, - }) as ReturnType, - ), - activeBackend: vi.fn(() => 'anthropic' as ReturnType), + generateText: vi.fn(), + generateObject: vi.fn(async () => output) as KtxLlmRuntimePort['generateObject'], + runAgentLoop: vi.fn(), }; } @@ -505,21 +487,19 @@ describe('production relationship discovery', () => { INSERT INTO customers (id) VALUES (1), (2); INSERT INTO orders (id, buyer_ref) VALUES (10, 1), (11, 2); `); - const generateText = vi.fn(async () => ({ - output: { - pkCandidates: [{ table: 'customers', column: 'id', confidence: 0.91, rationale: 'Unique customer key.' }], - fkCandidates: [ - { - fromTable: 'orders', - fromColumn: 'buyer_ref', - toTable: 'customers', - toColumn: 'id', - confidence: 0.89, - rationale: 'Buyer reference values align with customer identifiers.', - }, - ], - }, - })); + const llmOutput = { + pkCandidates: [{ table: 'customers', column: 'id', confidence: 0.91, rationale: 'Unique customer key.' }], + fkCandidates: [ + { + fromTable: 'orders', + fromColumn: 'buyer_ref', + toTable: 'customers', + toColumn: 'id', + confidence: 0.89, + rationale: 'Buyer reference values align with customer identifiers.', + }, + ], + }; const result = await discoverKtxRelationships({ connectionId: 'warehouse', @@ -528,8 +508,7 @@ describe('production relationship discovery', () => { schema: snapshotToKtxEnrichedSchema(llmOnlyRelationshipSnapshot()), context: { runId: 'llm-relationship-orchestrator' }, settings: relationshipSettings(), - llmProvider: llmProvider(), - generateText, + llmRuntime: llmRuntime(llmOutput), }); expect(result.llmRelationshipValidation).toBe('completed'); diff --git a/packages/context/src/scan/relationship-discovery.ts b/packages/context/src/scan/relationship-discovery.ts index b1af1492..ce6dfba9 100644 --- a/packages/context/src/scan/relationship-discovery.ts +++ b/packages/context/src/scan/relationship-discovery.ts @@ -1,4 +1,4 @@ -import type { KtxLlmProvider } from '@ktx/llm'; +import type { KtxLlmRuntimePort } from '../llm/index.js'; import type { KtxScanRelationshipConfig } from '../project/config.js'; import type { KtxEnrichedRelationship, KtxEnrichedSchema, KtxRelationshipUpdate } from './enrichment-types.js'; import { @@ -15,10 +15,7 @@ import { type KtxResolvedRelationshipDiscoveryCandidate, resolveKtxRelationshipGraph, } from './relationship-graph-resolver.js'; -import { - type KtxRelationshipLlmProposalGenerateText, - proposeKtxRelationshipCandidatesWithLlm, -} from './relationship-llm-proposal.js'; +import { proposeKtxRelationshipCandidatesWithLlm } from './relationship-llm-proposal.js'; import { createKtxRelationshipProfileCache, type KtxRelationshipProfileArtifact, @@ -42,8 +39,7 @@ export interface DiscoverKtxRelationshipsInput { schema: KtxEnrichedSchema; context: KtxScanContext; settings: KtxScanRelationshipConfig; - llmProvider?: KtxLlmProvider | null; - generateText?: KtxRelationshipLlmProposalGenerateText; + llmRuntime?: KtxLlmRuntimePort | null; } export interface DiscoverKtxRelationshipsResult { @@ -246,11 +242,10 @@ export async function discoverKtxRelationships( connectionId: input.connectionId, schema: input.schema, profile, - llmProvider: input.llmProvider ?? null, + llmRuntime: input.llmRuntime ?? null, settings: { maxTablesPerBatch: input.settings.maxLlmTablesPerBatch, }, - generateText: input.generateText, }) : { candidates: [], warnings: [], llmCalls: 0, summary: 'skipped' as const }; const candidates = mergeKtxRelationshipDiscoveryCandidates([ diff --git a/packages/context/src/scan/relationship-llm-proposal.test.ts b/packages/context/src/scan/relationship-llm-proposal.test.ts index ed43ff02..1aa994de 100644 --- a/packages/context/src/scan/relationship-llm-proposal.test.ts +++ b/packages/context/src/scan/relationship-llm-proposal.test.ts @@ -1,32 +1,14 @@ -import type { KtxLlmProvider } from '@ktx/llm'; import { describe, expect, it, vi } from 'vitest'; +import type { KtxLlmRuntimePort } from '../llm/index.js'; import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from './enrichment-types.js'; import type { KtxRelationshipProfileArtifact } from './relationship-profiling.js'; import { proposeKtxRelationshipCandidatesWithLlm } from './relationship-llm-proposal.js'; -function llmProvider(provider = 'anthropic'): KtxLlmProvider { - const model = { modelId: 'claude-sonnet-4-6', provider }; +function llmRuntime(output?: unknown): KtxLlmRuntimePort { return { - getModel: vi.fn(() => model as ReturnType), - getModelByName: vi.fn(() => model as ReturnType), - cacheMarker: vi.fn(), - repairToolCallHandler: vi.fn(), - thinkingProviderOptions: vi.fn(() => ({})), - telemetryConfig: vi.fn(() => undefined), - promptCachingConfig: vi.fn( - () => - ({ - enabled: false, - systemTtl: '1h', - toolsTtl: '1h', - historyTtl: '5m', - cacheSystem: true, - cacheTools: true, - cacheHistory: true, - vertexFallbackTo5m: false, - }) as ReturnType, - ), - activeBackend: vi.fn(() => provider as ReturnType), + generateText: vi.fn(), + generateObject: vi.fn(async () => output) as KtxLlmRuntimePort['generateObject'], + runAgentLoop: vi.fn(), }; } @@ -125,28 +107,25 @@ function profile(): KtxRelationshipProfileArtifact { describe('relationship LLM proposals', () => { it('maps valid structured FK proposals into review candidates with rationale evidence', async () => { - const generateText = vi.fn(async () => ({ - output: { - pkCandidates: [{ table: 'customers', column: 'id', confidence: 0.94, rationale: 'Unique customer identifier.' }], - fkCandidates: [ - { - fromTable: 'orders', - fromColumn: 'buyer_ref', - toTable: 'customers', - toColumn: 'id', - confidence: 0.88, - rationale: 'Buyer reference values match customer identifiers.', - }, - ], - }, - })); + const runtime = llmRuntime({ + pkCandidates: [{ table: 'customers', column: 'id', confidence: 0.94, rationale: 'Unique customer identifier.' }], + fkCandidates: [ + { + fromTable: 'orders', + fromColumn: 'buyer_ref', + toTable: 'customers', + toColumn: 'id', + confidence: 0.88, + rationale: 'Buyer reference values match customer identifiers.', + }, + ], + }); const result = await proposeKtxRelationshipCandidatesWithLlm({ connectionId: 'warehouse', schema: schema(), profile: profile(), - llmProvider: llmProvider(), - generateText, + llmRuntime: runtime, }); expect(result.summary).toBe('completed'); @@ -164,42 +143,27 @@ describe('relationship LLM proposals', () => { reasons: ['llm_proposal', 'llm_pk_proposal'], }, }); - expect(generateText).toHaveBeenCalledWith( + expect(runtime.generateObject).toHaveBeenCalledWith( expect.objectContaining({ - system: expect.objectContaining({ - role: 'system', - content: expect.stringContaining('You are helping KTX review possible SQL relationships'), - }), - messages: expect.arrayContaining([ - expect.objectContaining({ - role: 'user', - content: expect.stringContaining('"tables"'), - }), - ]), + role: 'candidateExtraction', + system: expect.stringContaining('You are helping KTX review possible SQL relationships'), + prompt: expect.stringContaining('"tables"'), }), ); - const call = ( - generateText.mock.calls as unknown as Array<[{ messages: Array<{ role: string; content: string }> }]> - )[0]?.[0]; - const userMessage = call?.messages.find((m) => m.role === 'user'); - expect(userMessage?.content).not.toContain('You are helping KTX review possible SQL relationships'); - expect(call?.messages.some((m) => m.role === 'system')).toBe(false); + const call = vi.mocked(runtime.generateObject).mock.calls[0]?.[0]; + expect(call?.prompt).not.toContain('You are helping KTX review possible SQL relationships'); }); - it('skips deterministic providers without calling generateText', async () => { - const generateText = vi.fn(); - + it('skips when no runtime is configured', async () => { const result = await proposeKtxRelationshipCandidatesWithLlm({ connectionId: 'warehouse', schema: schema(), profile: profile(), - llmProvider: llmProvider('deterministic'), - generateText, + llmRuntime: null, }); expect(result).toMatchObject({ candidates: [], llmCalls: 0, summary: 'skipped' }); expect(result.warnings).toEqual([]); - expect(generateText).not.toHaveBeenCalled(); }); it('returns recoverable warnings for invalid references and generation failures', async () => { @@ -207,22 +171,19 @@ describe('relationship LLM proposals', () => { connectionId: 'warehouse', schema: schema(), profile: profile(), - llmProvider: llmProvider(), - generateText: vi.fn(async () => ({ - output: { - pkCandidates: [], - fkCandidates: [ - { - fromTable: 'orders', - fromColumn: 'missing_column', - toTable: 'customers', - toColumn: 'id', - confidence: 0.7, - rationale: 'Invalid source column.', - }, - ], - }, - })), + llmRuntime: llmRuntime({ + pkCandidates: [], + fkCandidates: [ + { + fromTable: 'orders', + fromColumn: 'missing_column', + toTable: 'customers', + toColumn: 'id', + confidence: 0.7, + rationale: 'Invalid source column.', + }, + ], + }), }); expect(invalidReference.candidates).toEqual([]); expect(invalidReference.summary).toBe('completed'); @@ -235,10 +196,13 @@ describe('relationship LLM proposals', () => { connectionId: 'warehouse', schema: schema(), profile: profile(), - llmProvider: llmProvider(), - generateText: vi.fn(async () => { - throw new Error('model unavailable'); - }), + llmRuntime: { + generateText: vi.fn(), + generateObject: vi.fn(async () => { + throw new Error('model unavailable'); + }), + runAgentLoop: vi.fn(), + }, }); expect(failed).toMatchObject({ candidates: [], llmCalls: 1, summary: 'failed' }); expect(failed.warnings[0]).toMatchObject({ diff --git a/packages/context/src/scan/relationship-llm-proposal.ts b/packages/context/src/scan/relationship-llm-proposal.ts index 83718aba..ccf33e0f 100644 --- a/packages/context/src/scan/relationship-llm-proposal.ts +++ b/packages/context/src/scan/relationship-llm-proposal.ts @@ -1,7 +1,5 @@ -import type { KtxLlmProvider } from '@ktx/llm'; -import type { generateText } from 'ai'; import { z } from 'zod'; -import { generateKtxObject } from '../llm/index.js'; +import { generateKtxObject, type KtxLlmRuntimePort } from '../llm/index.js'; import type { KtxEnrichedColumn, KtxEnrichedSchema, KtxEnrichedTable } from './enrichment-types.js'; import { normalizeKtxRelationshipName, @@ -32,10 +30,6 @@ const relationshipLlmProposalSchema = z.object({ }); type KtxRelationshipLlmProposalOutput = z.infer; -type GenerateTextInput = Parameters[0]; -export type KtxRelationshipLlmProposalGenerateText = ( - input: GenerateTextInput, -) => Promise<{ text?: string; output?: unknown }>; export interface KtxRelationshipLlmProposalSettings { maxTablesPerBatch: number; @@ -48,9 +42,8 @@ export interface ProposeKtxRelationshipCandidatesWithLlmInput { connectionId: string; schema: KtxEnrichedSchema; profile: KtxRelationshipProfileArtifact; - llmProvider: KtxLlmProvider | null; + llmRuntime: KtxLlmRuntimePort | null; settings?: Partial; - generateText?: KtxRelationshipLlmProposalGenerateText; } export interface KtxRelationshipLlmProposalResult { @@ -77,11 +70,6 @@ function clampConfidence(value: number): number { return Number(Math.max(0, Math.min(1, value)).toFixed(3)); } -function modelIsDeterministic(llmProvider: KtxLlmProvider): boolean { - const model = llmProvider.getModel('candidateExtraction'); - return (model as { provider?: string }).provider === 'deterministic'; -} - function findTable(schema: KtxEnrichedSchema, name: string): KtxEnrichedTable | null { const normalized = name.toLowerCase(); return schema.tables.find((table) => table.ref.name.toLowerCase() === normalized) ?? null; @@ -238,7 +226,7 @@ function generationFailureWarning(error: unknown): KtxScanWarning { export async function proposeKtxRelationshipCandidatesWithLlm( input: ProposeKtxRelationshipCandidatesWithLlmInput, ): Promise { - if (!input.llmProvider || modelIsDeterministic(input.llmProvider)) { + if (!input.llmRuntime) { return { candidates: [], warnings: [], llmCalls: 0, summary: 'skipped' }; } @@ -256,12 +244,11 @@ export async function proposeKtxRelationshipCandidatesWithLlm( KtxRelationshipLlmProposalOutput, typeof relationshipLlmProposalSchema >({ - llmProvider: input.llmProvider, + runtime: input.llmRuntime, role: 'candidateExtraction', system, prompt, schema: relationshipLlmProposalSchema, - generateText: input.generateText, }); const output = relationshipLlmProposalSchema.parse(generated); const mapped = mapValidProposals(input.schema, output, settings); diff --git a/packages/context/src/tools/base-tool.ts b/packages/context/src/tools/base-tool.ts index 0566a0ca..faf27e59 100644 --- a/packages/context/src/tools/base-tool.ts +++ b/packages/context/src/tools/base-tool.ts @@ -1,6 +1,8 @@ import { tool } from 'ai'; import { z, type ZodType } from 'zod'; import { noopLogger, type KtxLogger } from '../core/index.js'; +import type { KtxRuntimeToolDescriptor } from '../llm/runtime-port.js'; +import { normalizeKtxRuntimeToolOutput } from '../llm/runtime-tools.js'; import type { IngestToolMetadata, ToolSession } from './tool-session.js'; export interface ToolOutput { @@ -164,6 +166,23 @@ export abstract class BaseTool { }); } + toRuntimeTool(context: ToolContext): KtxRuntimeToolDescriptor { + const toolName = this.name; + return { + name: toolName, + description: this.description, + inputSchema: this.inputSchema as unknown as KtxRuntimeToolDescriptor['inputSchema'], + execute: async (params) => { + const callContext = { ...context }; + if (!callContext.userId) { + throw new Error('Authentication required: userId must be provided in ToolContext'); + } + const parsedInput = this.parseInput(params as Record); + return normalizeKtxRuntimeToolOutput(await this.call(parsedInput, callContext)); + }, + }; + } + parseInput(input: Record): z.infer { return this.inputSchema.parse(input); } diff --git a/packages/llm/src/model-health.test.ts b/packages/llm/src/model-health.test.ts index 8752b09e..8cf7a7ee 100644 --- a/packages/llm/src/model-health.test.ts +++ b/packages/llm/src/model-health.test.ts @@ -61,4 +61,17 @@ describe('KTX LLM health check', () => { message: '401 invalid x-api-key [redacted]', }); }); + + it('reports claude-code as unsupported by the AI SDK health check', async () => { + const result = await runKtxLlmHealthCheck({ + backend: 'claude-code', + modelSlots: { default: 'sonnet' }, + promptCaching: { enabled: false }, + }); + + expect(result).toEqual({ + ok: false, + message: expect.stringContaining('claude-code is not an AI SDK LanguageModel backend'), + }); + }); }); diff --git a/packages/llm/src/model-provider.test.ts b/packages/llm/src/model-provider.test.ts index 961b0fdb..1a61d0a1 100644 --- a/packages/llm/src/model-provider.test.ts +++ b/packages/llm/src/model-provider.test.ts @@ -302,4 +302,14 @@ describe('createKtxLlmProvider', () => { expect(provider.promptCachingConfig().enabled).toBe(false); expect(provider.cacheMarker('1h', 'claude-sonnet-4-6')).toBeUndefined(); }); + + it('throws instead of falling through when an unsupported LLM backend is passed to the AI SDK provider factory', () => { + expect(() => + createKtxLlmProvider({ + backend: 'claude-code', + modelSlots: { default: 'sonnet' }, + promptCaching: { enabled: false }, + }), + ).toThrow('claude-code is not an AI SDK LanguageModel backend'); + }); }); diff --git a/packages/llm/src/model-provider.ts b/packages/llm/src/model-provider.ts index 392a16e1..86b9270b 100644 --- a/packages/llm/src/model-provider.ts +++ b/packages/llm/src/model-provider.ts @@ -175,14 +175,18 @@ class DefaultKtxLlmProvider implements KtxLlmProvider { return (modelId) => vertex(modelId); } - const gateway = (deps.createGateway ?? createGateway)({ - ...(config.gateway?.apiKey ? { apiKey: config.gateway.apiKey } : {}), - ...(config.gateway?.baseURL ? { baseURL: config.gateway.baseURL } : {}), - headers: { - 'anthropic-beta': ANTHROPIC_BETA_HEADER, - }, - }); - return (modelId) => gateway(modelId); + if (config.backend === 'gateway') { + const gateway = (deps.createGateway ?? createGateway)({ + ...(config.gateway?.apiKey ? { apiKey: config.gateway.apiKey } : {}), + ...(config.gateway?.baseURL ? { baseURL: config.gateway.baseURL } : {}), + headers: { + 'anthropic-beta': ANTHROPIC_BETA_HEADER, + }, + }); + return (modelId) => gateway(modelId); + } + + throw new Error(`${config.backend} is not an AI SDK LanguageModel backend; use KtxLlmRuntimePort`); } } diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts index a477b2f2..b91aec25 100644 --- a/packages/llm/src/types.ts +++ b/packages/llm/src/types.ts @@ -3,7 +3,7 @@ import type { LanguageModel, TelemetrySettings, ToolCallRepairFunction, ToolSet export const KTX_MODEL_ROLES = ['default', 'triage', 'candidateExtraction', 'curator', 'reconcile', 'repair'] as const; export type KtxModelRole = (typeof KTX_MODEL_ROLES)[number]; -export type KtxLlmBackend = 'anthropic' | 'vertex' | 'gateway'; +export type KtxLlmBackend = 'anthropic' | 'vertex' | 'gateway' | 'claude-code'; export type KtxPromptCacheTtl = '5m' | '1h'; export type KtxJsonValue = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39b25c9b..094c1deb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,15 +15,39 @@ importers: '@biomejs/biome': specifier: ^2.4.15 version: 2.4.15 + '@semantic-release/changelog': + specifier: ^6.0.3 + version: 6.0.3(semantic-release@25.0.3(typescript@6.0.3)) + '@semantic-release/commit-analyzer': + specifier: ^13.0.1 + version: 13.0.1(semantic-release@25.0.3(typescript@6.0.3)) + '@semantic-release/exec': + specifier: ^7.1.0 + version: 7.1.0(semantic-release@25.0.3(typescript@6.0.3)) + '@semantic-release/git': + specifier: ^10.0.1 + version: 10.0.1(semantic-release@25.0.3(typescript@6.0.3)) + '@semantic-release/github': + specifier: ^12.0.8 + version: 12.0.8(semantic-release@25.0.3(typescript@6.0.3)) + '@semantic-release/release-notes-generator': + specifier: ^14.1.1 + version: 14.1.1(semantic-release@25.0.3(typescript@6.0.3)) '@types/node': specifier: ^24.3.0 version: 24.12.2 better-sqlite3: specifier: ^12.10.0 version: 12.10.0 + conventional-changelog-conventionalcommits: + specifier: ^9.3.1 + version: 9.3.1 knip: specifier: ^6.12.2 version: 6.12.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + semantic-release: + specifier: ^25.0.3 + version: 25.0.3(typescript@6.0.3) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -315,6 +339,9 @@ importers: packages/context: dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: 0.3.142 + version: 0.3.142(zod@4.4.3) '@ktx/llm': specifier: workspace:* version: link:../llm @@ -425,6 +452,18 @@ importers: packages: + '@actions/core@3.0.1': + resolution: {integrity: sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==} + + '@actions/exec@3.0.0': + resolution: {integrity: sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==} + + '@actions/http-client@4.0.1': + resolution: {integrity: sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==} + + '@actions/io@3.0.2': + resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@ai-sdk/anthropic@3.0.77': resolution: {integrity: sha512-ML8C2M1YvPA1ulEx4TiyF0k1xvC2ikEiPBIC1PPQ0a5xELUGrO2lAaEzsTEoJ+eCeDd8PSBuFJjs+r+9yIwQXA==} engines: {node: '>=18'} @@ -478,6 +517,65 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.142': + resolution: {integrity: sha512-yBHOiRqJ8JcD9OAMGJALbypaD3u3K8hyUmcnZ+91AHJtymzWxuMkVi4IY1qp8L5jzkKeTnvYfCspzkbiHLuYWg==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.142': + resolution: {integrity: sha512-/a/bVMjvAl3gNzWiPIgynYktTYckTcp4YAacV/2F4Jd8XeCV0+DMQW7OFeR+3fnPcBg/8kcOAVYfLZXDExqO1w==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.142': + resolution: {integrity: sha512-KZuwSupNJovnMJ7MZxjp1Qq0yu7rAmbzO4Zlmr3jtKDU95t2kgs3c6j4evzQDCgTQMlwH8QTSV4mItDGxlYEbg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.142': + resolution: {integrity: sha512-QKG553PSbIcQ5KLvnl2ekfy5lTyU3dW/X5fDQlRLv4YHNHnqf2o7scJ6eUdfaVTQdIZ+Pa7SNN3bsvVs4bNjQw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.142': + resolution: {integrity: sha512-QkDwLMsdYO7n/i1zPCt5YZIet5u+Eo07UpF9UX5yD7bnwRZKDe22L6LVVwiLLjeTO0fTz+uNY7w9/XOYQMlxUQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.142': + resolution: {integrity: sha512-o1QZmCNRL5BFTc14KEvT23Fxm1jNv0aa0e9T0OZUjua0oW8DRpri3HKvDEM36qEGWUOANBG7h6Ca/KNqxaTnYg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.142': + resolution: {integrity: sha512-x8lbY1m7E/BiFF0Gu/Mx9lkD/zW3vBr3viw0GYNuqY9GYHfLOX9+l9H8C+INeGzB4+ibG8+xD2pnRhWdQxuvUg==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.142': + resolution: {integrity: sha512-NpNxdiCEUNjjwvBltpDnkgdjVQ+nRsALpfM1Pe4GhnYiOkTk/TvjMZUuA2qGh0F8KyF0FbqzUsi0uXIgojJT5w==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.142': + resolution: {integrity: sha512-k1xBon6ov0PT/vZNf+Z+SuAqmylGJU/+a+h/k04MW5cBbzOIwiVcGFRTGJ/qbY5pcboJbLtts/yBwSu9AvSipg==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.93.0': + resolution: {integrity: sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -789,6 +887,10 @@ packages: resolution: {integrity: sha512-SriLPKezypIsiZ+TtlFfE46uuBIap2HeaQVS78e1P7rz5OSbq0rsd52WE1mC5f7vAeLiXqv7I7oRhL3WFZEw3Q==} engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -802,6 +904,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -882,6 +988,10 @@ packages: resolution: {integrity: sha512-jjCrddI+e2OVXGh/MQY92K9r8Z/iwqaZtUXNI/MfZ/y9VGYwfbQsXRzp4Jv6w4Hgxvr4sLcz9YwIvkCBQ6X/mw==} engines: {node: '>=16'} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} @@ -1435,6 +1545,54 @@ packages: resolution: {integrity: sha512-X+T+hzaQFleOUGm4xUOUm51pOpdZ1+6T4BsRjGlcdEOTJLNkUEv8nZATq9O3ZY4NQEgICc0qwQ0I25OdYprX0w==} engines: {node: '>=18'} + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.6': + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.3': + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-retry@8.1.0': + resolution: {integrity: sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=7' + + '@octokit/plugin-throttling@11.0.3': + resolution: {integrity: sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': ^7.0.0 + + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.9': + resolution: {integrity: sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1684,6 +1842,18 @@ packages: cpu: [x64] os: [win32] + '@pnpm/config.env-replace@1.1.0': + resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} + engines: {node: '>=12.22.0'} + + '@pnpm/network.ca-file@1.0.2': + resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} + engines: {node: '>=12.22.0'} + + '@pnpm/npm-conf@3.0.2': + resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} + engines: {node: '>=12'} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2147,6 +2317,59 @@ packages: '@rolldown/pluginutils@1.0.0-rc.17': resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@semantic-release/changelog@6.0.3': + resolution: {integrity: sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag==} + engines: {node: '>=14.17'} + peerDependencies: + semantic-release: '>=18.0.0' + + '@semantic-release/commit-analyzer@13.0.1': + resolution: {integrity: sha512-wdnBPHKkr9HhNhXOhZD5a2LNl91+hs8CC2vsAVYxtZH3y0dV3wKn+uZSN61rdJQZ8EGxzWB3inWocBHV9+u/CQ==} + engines: {node: '>=20.8.1'} + peerDependencies: + semantic-release: '>=20.1.0' + + '@semantic-release/error@3.0.0': + resolution: {integrity: sha512-5hiM4Un+tpl4cKw3lV4UgzJj+SmfNIDCLLw0TepzQxz9ZGV5ixnqkzIVF+3tp0ZHgcMKE+VNGHJjEeyFG2dcSw==} + engines: {node: '>=14.17'} + + '@semantic-release/error@4.0.0': + resolution: {integrity: sha512-mgdxrHTLOjOddRVYIYDo0fR3/v61GNN1YGkfbrjuIKg/uMgCd+Qzo3UAXJ+woLQQpos4pl5Esuw5A7AoNlzjUQ==} + engines: {node: '>=18'} + + '@semantic-release/exec@7.1.0': + resolution: {integrity: sha512-4ycZ2atgEUutspPZ2hxO6z8JoQt4+y/kkHvfZ1cZxgl9WKJId1xPj+UadwInj+gMn2Gsv+fLnbrZ4s+6tK2TFQ==} + engines: {node: '>=20.8.1'} + peerDependencies: + semantic-release: '>=24.1.0' + + '@semantic-release/git@10.0.1': + resolution: {integrity: sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==} + engines: {node: '>=14.17'} + peerDependencies: + semantic-release: '>=18.0.0' + + '@semantic-release/github@12.0.8': + resolution: {integrity: sha512-tej5AAgK5X9wHRoDmYhecMXEHEkFeGOY1XsEblKxu8pIQwahzf1STYyr7iPU6Lpbg6C5I3N2w/ocXrBo+L7jhw==} + engines: {node: ^22.14.0 || >= 24.10.0} + peerDependencies: + semantic-release: '>=24.1.0' + + '@semantic-release/npm@13.1.5': + resolution: {integrity: sha512-Hq5UxzoatN3LHiq2rTsWS54nCdqJHlsssGERCo8WlvdfFA9LoN0vO+OuKVSjtNapIc/S8C2LBj206wKLHg62mg==} + engines: {node: ^22.14.0 || >= 24.10.0} + peerDependencies: + semantic-release: '>=20.1.0' + + '@semantic-release/release-notes-generator@14.1.1': + resolution: {integrity: sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==} + engines: {node: '>=20.8.1'} + peerDependencies: + semantic-release: '>=20.1.0' + '@shikijs/core@4.0.2': resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} engines: {node: '>=20'} @@ -2184,6 +2407,18 @@ packages: '@simple-git/argv-parser@1.1.1': resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@smithy/chunked-blob-reader-native@4.2.3': resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} engines: {node: '>=18.0.0'} @@ -2543,6 +2778,9 @@ packages: '@types/node@24.12.2': resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -2637,6 +2875,18 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + aggregate-error@5.0.0: + resolution: {integrity: sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw==} + engines: {node: '>=18'} + ai@6.0.180: resolution: {integrity: sha512-tOyRbwD0PEjMZKGvYQcTsv95K2zktwwNhQ49QOUAh0g8MNprO7ELIO1SgANMuPc0BFtkP6Ny6OAdjq3TtxLCbQ==} engines: {node: '>=18'} @@ -2658,21 +2908,42 @@ packages: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argv-formatter@1.0.0: + resolution: {integrity: sha512-F2+Hkm9xFaRg+GkaNnbwXNDV5O6pnCFEmqyhvfC/Ic5LbgOWjJh3L+mN/s91rxVL3znE7DYVpW0GJFT+4YBgWw==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + arrify@2.0.1: resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} engines: {node: '>=8'} @@ -2738,6 +3009,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + better-sqlite3@12.10.0: resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} @@ -2771,6 +3045,9 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + bottleneck@2.19.5: + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -2781,6 +3058,10 @@ packages: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -2806,6 +3087,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} @@ -2816,10 +3101,22 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2842,6 +3139,14 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + clean-stack@5.3.0: + resolution: {integrity: sha512-9ngPTOhYGQqNVSfeJkYXHmF7AGWp4/nN5D/QqNQs3Dvxd1Kk/WpjHfNujKHYUQ/5CoGyOyFNoWSPk5afzP0QVg==} + engines: {node: '>=14.16'} + cli-boxes@4.0.1: resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} engines: {node: '>=18.20 <19 || >=20.10'} @@ -2850,6 +3155,15 @@ packages: resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + cli-truncate@6.0.0: resolution: {integrity: sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==} engines: {node: '>=22'} @@ -2857,6 +3171,13 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2868,10 +3189,23 @@ packages: collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + color-convert@3.1.3: resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} engines: {node: '>=14.6'} + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@2.1.0: resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} engines: {node: '>=12.20'} @@ -2899,12 +3233,18 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -2913,6 +3253,36 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} + + conventional-changelog-writer@8.4.0: + resolution: {integrity: sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==} + engines: {node: '>=18'} + hasBin: true + + conventional-commits-filter@5.0.0: + resolution: {integrity: sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + + convert-hrtime@5.0.0: + resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} + engines: {node: '>=12'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2928,14 +3298,30 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-random-string@4.0.0: + resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} + engines: {node: '>=12'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3001,10 +3387,21 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} @@ -3014,6 +3411,15 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emojilib@2.4.0: + resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + enabled@2.0.0: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} @@ -3032,10 +3438,21 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + env-ci@11.2.0: + resolution: {integrity: sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA==} + engines: {node: ^18.17 || >=20.6.1} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -3069,9 +3486,17 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -3124,6 +3549,18 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -3149,6 +3586,9 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3197,13 +3637,37 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@2.0.0: + resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} + engines: {node: '>=4'} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + + find-up@2.1.0: + resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} + engines: {node: '>=4'} + + find-versions@6.0.0: + resolution: {integrity: sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA==} + engines: {node: '>=18'} + fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -3254,6 +3718,10 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -3375,6 +3843,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function-timeout@1.0.2: + resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} + engines: {node: '>=18'} + gaxios@7.1.4: resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} engines: {node: '>=18'} @@ -3390,6 +3862,10 @@ packages: resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} engines: {node: '>= 4'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.5.0: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} @@ -3406,9 +3882,24 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + git-log-parser@1.2.1: + resolution: {integrity: sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ==} + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -3431,6 +3922,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -3439,6 +3933,10 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3482,6 +3980,9 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + homedir-polyfill@1.0.3: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} @@ -3490,6 +3991,18 @@ packages: resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==} engines: {node: '>=16.9.0'} + hook-std@4.0.0: + resolution: {integrity: sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ==} + engines: {node: '>=20'} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} @@ -3507,10 +4020,30 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy-agent@9.0.0: + resolution: {integrity: sha512-FcF8VhXYLQcxWCnt/cCpT2apKsRDUGeVEeMqGu4HSTu29U8Yw0TLOjdYIlDsYk3IkUh+taX4IDWpPcCqKDhCjA==} + engines: {node: '>= 20'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + https-proxy-agent@9.0.0: + resolution: {integrity: sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==} + engines: {node: '>= 20'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3518,10 +4051,29 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-from-esm@2.0.0: + resolution: {integrity: sha512-YVt14UZCgsX1vZQ3gKjkWVdBdHQ6eu3MPU1TBgL1H5orXe2+jWD006WCPPtOuwlQm10NuzOW5WawiF1Q9veW8g==} + engines: {node: '>=18.20'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + indent-string@5.0.0: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -3575,6 +4127,9 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -3588,6 +4143,10 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} @@ -3605,6 +4164,14 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -3619,6 +4186,18 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -3627,9 +4206,16 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + issue-parser@7.0.2: + resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==} + engines: {node: ^18.17 || >=20.6.1} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -3642,6 +4228,10 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + java-properties@1.0.2: + resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==} + engines: {node: '>= 0.6.0'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -3655,6 +4245,9 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -3662,6 +4255,16 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-parse-better-errors@1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -3671,6 +4274,12 @@ packages: json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-with-bigint@3.5.8: + resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -3763,6 +4372,26 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-json-file@4.0.0: + resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} + engines: {node: '>=4'} + + locate-path@2.0.0: + resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==} + engines: {node: '>=4'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.capitalize@4.2.1: + resolution: {integrity: sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -3784,6 +4413,12 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.uniqby@4.7.0: + resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + logform@2.7.0: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} @@ -3803,6 +4438,13 @@ packages: js-yaml: optional: true + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + lru.min@1.1.4: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} @@ -3818,6 +4460,10 @@ packages: magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + make-asynchronous@1.1.0: + resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} + engines: {node: '>=18'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -3829,6 +4475,17 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked-terminal@7.3.0: + resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} + engines: {node: '>=16.0.0'} + peerDependencies: + marked: '>=1 <16' + + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3885,10 +4542,17 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -3994,6 +4658,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -4010,10 +4678,19 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -4074,6 +4751,9 @@ packages: peerDependencies: '@types/node': ^24.3.0 + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + named-placeholders@1.1.6: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} @@ -4096,6 +4776,9 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nerf-dart@1.0.0: + resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -4132,10 +4815,109 @@ packages: engines: {node: '>=10.5.0'} deprecated: Use your platform's native DOMException instead + node-emoji@2.2.0: + resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} + engines: {node: '>=18'} + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + + normalize-package-data@8.0.0: + resolution: {integrity: sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + normalize-url@9.0.0: + resolution: {integrity: sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==} + engines: {node: '>=20'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + npm@11.14.1: + resolution: {integrity: sha512-aopNZ0eEl6LbxoFcrXLmTEPzNBNxfiQnVgR9RmJBqzm+5h5pFoOmRljpRJbsXxocBeSl7GLcx3MoDf2UlEOjZw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + bundledDependencies: + - '@isaacs/string-locale-compare' + - '@npmcli/arborist' + - '@npmcli/config' + - '@npmcli/fs' + - '@npmcli/map-workspaces' + - '@npmcli/metavuln-calculator' + - '@npmcli/package-json' + - '@npmcli/promise-spawn' + - '@npmcli/redact' + - '@npmcli/run-script' + - '@sigstore/tuf' + - abbrev + - archy + - cacache + - chalk + - ci-info + - fastest-levenshtein + - fs-minipass + - glob + - graceful-fs + - hosted-git-info + - ini + - init-package-json + - is-cidr + - json-parse-even-better-errors + - libnpmaccess + - libnpmdiff + - libnpmexec + - libnpmfund + - libnpmorg + - libnpmpack + - libnpmpublish + - libnpmsearch + - libnpmteam + - libnpmversion + - make-fetch-happen + - minimatch + - minipass + - minipass-pipeline + - ms + - node-gyp + - nopt + - npm-audit-report + - npm-install-checks + - npm-package-arg + - npm-pick-manifest + - npm-profile + - npm-registry-fetch + - npm-user-validate + - p-map + - pacote + - parse-conflict-json + - proc-log + - qrcode-terminal + - read + - semver + - spdx-expression-parse + - ssri + - supports-color + - tar + - text-table + - tiny-relative-date + - treeverse + - validate-npm-package-name + - which + oauth4webapi@3.8.6: resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} @@ -4164,6 +4946,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -4197,17 +4983,86 @@ packages: oxc-resolver@11.19.1: resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} + p-each-series@3.0.0: + resolution: {integrity: sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw==} + engines: {node: '>=12'} + + p-event@6.0.1: + resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} + engines: {node: '>=16.17'} + + p-filter@4.1.0: + resolution: {integrity: sha512-37/tPdZ3oJwHaS3gNJdenCDB3Tz26i9sjhnguBtvN0vYlRIiDNnvTWkuh+0hETV9rLPdJ3rlL3yVOYPIAnM8rw==} + engines: {node: '>=18'} + + p-limit@1.3.0: + resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} + engines: {node: '>=4'} + p-limit@7.3.0: resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} engines: {node: '>=20'} + p-locate@2.0.0: + resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} + engines: {node: '>=4'} + + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + + p-reduce@2.1.0: + resolution: {integrity: sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw==} + engines: {node: '>=8'} + + p-reduce@3.0.0: + resolution: {integrity: sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q==} + engines: {node: '>=12'} + + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + + p-try@1.0.0: + resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} + engines: {node: '>=4'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@4.0.0: + resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} + engines: {node: '>=4'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse-passwd@1.0.0: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -4219,6 +5074,10 @@ packages: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-expression-matcher@1.5.0: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} @@ -4231,9 +5090,17 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -4279,14 +5146,26 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + pkg-conf@2.1.0: + resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} + engines: {node: '>=4'} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -4317,6 +5196,13 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} @@ -4324,6 +5210,9 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -4396,6 +5285,25 @@ packages: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} engines: {node: '>=0.10.0'} + read-package-up@11.0.0: + resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} + engines: {node: '>=18'} + + read-package-up@12.0.0: + resolution: {integrity: sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==} + engines: {node: '>=20'} + + read-pkg@10.1.0: + resolution: {integrity: sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==} + engines: {node: '>=20'} + + read-pkg@9.0.1: + resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} + engines: {node: '>=18'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -4431,6 +5339,10 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + registry-auth-token@5.1.1: + resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} + engines: {node: '>=14'} + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} @@ -4455,10 +5367,22 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -4483,6 +5407,9 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -4499,6 +5426,15 @@ packages: scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + semantic-release@25.0.3: + resolution: {integrity: sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==} + engines: {node: ^22.14.0 || >= 24.10.0} + hasBin: true + + semver-regex@4.0.5: + resolution: {integrity: sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw==} + engines: {node: '>=12'} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} @@ -4553,6 +5489,14 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + signale@1.4.0: + resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==} + engines: {node: '>=6'} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -4568,6 +5512,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + skin-tone@2.0.0: + resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} + engines: {node: '>=8'} + slice-ansi@9.0.0: resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} engines: {node: '>=22'} @@ -4597,6 +5545,24 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spawn-error-forwarder@1.0.0: + resolution: {integrity: sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g==} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + split2@1.0.0: + resolution: {integrity: sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -4625,26 +5591,60 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stream-combiner2@1.1.1: + resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} + stream-events@1.0.5: resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string-width@8.2.1: resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-ansi@7.1.2: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} @@ -4678,10 +5678,22 @@ packages: babel-plugin-macros: optional: true + super-regex@1.1.0: + resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} + engines: {node: '>=18'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -4715,6 +5727,14 @@ packages: resolution: {integrity: sha512-Xj0ZAQ0CeuQn6UxCDPLbFRlgcSTUEyO3+wiepr2grjIjyL/lMMs1Z4OwXn8kLvn/V1OuaEP0UY7Na6UDNNsYrQ==} engines: {node: '>=18'} + temp-dir@3.0.0: + resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} + engines: {node: '>=14.16'} + + tempy@3.2.0: + resolution: {integrity: sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==} + engines: {node: '>=14.16'} + terminal-size@4.0.1: resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} engines: {node: '>=18'} @@ -4722,6 +5742,20 @@ packages: text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + + time-span@5.1.0: + resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} + engines: {node: '>=12'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4741,6 +5775,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -4748,6 +5786,10 @@ packages: toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + traverse@0.6.8: + resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} + engines: {node: '>= 0.4'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4758,12 +5800,31 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + + type-fest@1.4.0: + resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} + engines: {node: '>=10'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-fest@5.6.0: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} @@ -4789,9 +5850,37 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@6.25.0: + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + engines: {node: '>=18.17'} + + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + + unicode-emoji-modifier-base@1.0.0: + resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} + engines: {node: '>=4'} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unique-string@3.0.0: + resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} + engines: {node: '>=12'} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -4813,10 +5902,21 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + url-join@5.0.0: + resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -4840,6 +5940,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -4948,6 +6051,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4977,6 +6083,14 @@ packages: resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} engines: {node: '>=20'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -5000,15 +6114,39 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -5025,6 +6163,22 @@ packages: snapshots: + '@actions/core@3.0.1': + dependencies: + '@actions/exec': 3.0.0 + '@actions/http-client': 4.0.1 + + '@actions/exec@3.0.0': + dependencies: + '@actions/io': 3.0.2 + + '@actions/http-client@4.0.1': + dependencies: + tunnel: 0.0.6 + undici: 6.25.0 + + '@actions/io@3.0.2': {} + '@ai-sdk/anthropic@3.0.77(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -5086,6 +6240,54 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.142': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.142': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.142': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.142': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.142': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.142': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.142': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.142': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.142(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.142 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.142 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.142 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.142 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.142 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.142 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.142 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.142 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + + '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -6000,6 +7202,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -6008,6 +7216,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.29.2': {} + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -6068,6 +7278,9 @@ snapshots: dependencies: '@clickhouse/client-common': 1.18.4 + '@colors/colors@1.5.0': + optional: true + '@colors/colors@1.6.0': {} '@commander-js/extra-typings@14.0.0(commander@14.0.3)': @@ -6449,6 +7662,7 @@ snapshots: '@ktx/context@file:packages/context(js-yaml@4.1.1)': dependencies: + '@anthropic-ai/claude-agent-sdk': 0.3.142(zod@4.4.3) '@ktx/llm': file:packages/llm(zod@4.4.3) '@looker/sdk': 26.8.0 '@looker/sdk-node': 26.8.0 @@ -6474,6 +7688,7 @@ snapshots: '@ktx/context@file:packages/context(js-yaml@4.1.1)(ws@8.20.0)': dependencies: + '@anthropic-ai/claude-agent-sdk': 0.3.142(zod@4.4.3) '@ktx/llm': file:packages/llm(ws@8.20.0)(zod@4.4.3) '@looker/sdk': 26.8.0 '@looker/sdk-node': 26.8.0 @@ -6630,6 +7845,67 @@ snapshots: '@notionhq/client@5.21.0': {} + '@octokit/auth-token@6.0.0': {} + + '@octokit/core@7.0.6': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.9 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.3': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.3': + dependencies: + '@octokit/request': 10.0.9 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/plugin-retry@8.1.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + bottleneck: 2.19.5 + + '@octokit/plugin-throttling@11.0.3(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + bottleneck: 2.19.5 + + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.9': + dependencies: + '@octokit/endpoint': 11.0.3 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + content-type: 2.0.0 + fast-content-type-parse: 3.0.0 + json-with-bigint: 3.5.8 + universal-user-agent: 7.0.3 + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + '@opentelemetry/api@1.9.0': {} '@orama/orama@3.1.18': {} @@ -6767,6 +8043,18 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.19.1': optional: true + '@pnpm/config.env-replace@1.1.0': {} + + '@pnpm/network.ca-file@1.0.2': + dependencies: + graceful-fs: 4.2.10 + + '@pnpm/npm-conf@3.0.2': + dependencies: + '@pnpm/config.env-replace': 1.1.0 + '@pnpm/network.ca-file': 1.0.2 + config-chain: 1.1.13 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -7174,6 +8462,116 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.17': {} + '@sec-ant/readable-stream@0.4.1': {} + + '@semantic-release/changelog@6.0.3(semantic-release@25.0.3(typescript@6.0.3))': + dependencies: + '@semantic-release/error': 3.0.0 + aggregate-error: 3.1.0 + fs-extra: 11.3.5 + lodash: 4.18.1 + semantic-release: 25.0.3(typescript@6.0.3) + + '@semantic-release/commit-analyzer@13.0.1(semantic-release@25.0.3(typescript@6.0.3))': + dependencies: + conventional-changelog-angular: 8.3.1 + conventional-changelog-writer: 8.4.0 + conventional-commits-filter: 5.0.0 + conventional-commits-parser: 6.4.0 + debug: 4.4.3 + import-from-esm: 2.0.0 + lodash-es: 4.18.1 + micromatch: 4.0.8 + semantic-release: 25.0.3(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + + '@semantic-release/error@3.0.0': {} + + '@semantic-release/error@4.0.0': {} + + '@semantic-release/exec@7.1.0(semantic-release@25.0.3(typescript@6.0.3))': + dependencies: + '@semantic-release/error': 4.0.0 + aggregate-error: 3.1.0 + debug: 4.4.3 + execa: 9.6.1 + lodash-es: 4.18.1 + parse-json: 8.3.0 + semantic-release: 25.0.3(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + + '@semantic-release/git@10.0.1(semantic-release@25.0.3(typescript@6.0.3))': + dependencies: + '@semantic-release/error': 3.0.0 + aggregate-error: 3.1.0 + debug: 4.4.3 + dir-glob: 3.0.1 + execa: 5.1.1 + lodash: 4.18.1 + micromatch: 4.0.8 + p-reduce: 2.1.0 + semantic-release: 25.0.3(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + + '@semantic-release/github@12.0.8(semantic-release@25.0.3(typescript@6.0.3))': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-retry': 8.1.0(@octokit/core@7.0.6) + '@octokit/plugin-throttling': 11.0.3(@octokit/core@7.0.6) + '@semantic-release/error': 4.0.0 + aggregate-error: 5.0.0 + debug: 4.4.3 + dir-glob: 3.0.1 + http-proxy-agent: 9.0.0 + https-proxy-agent: 9.0.0 + issue-parser: 7.0.2 + lodash-es: 4.18.1 + mime: 4.1.0 + p-filter: 4.1.0 + semantic-release: 25.0.3(typescript@6.0.3) + tinyglobby: 0.2.16 + undici: 7.25.0 + url-join: 5.0.0 + transitivePeerDependencies: + - supports-color + + '@semantic-release/npm@13.1.5(semantic-release@25.0.3(typescript@6.0.3))': + dependencies: + '@actions/core': 3.0.1 + '@semantic-release/error': 4.0.0 + aggregate-error: 5.0.0 + env-ci: 11.2.0 + execa: 9.6.1 + fs-extra: 11.3.5 + lodash-es: 4.18.1 + nerf-dart: 1.0.0 + normalize-url: 9.0.0 + npm: 11.14.1 + rc: 1.2.8 + read-pkg: 10.1.0 + registry-auth-token: 5.1.1 + semantic-release: 25.0.3(typescript@6.0.3) + semver: 7.7.4 + tempy: 3.2.0 + + '@semantic-release/release-notes-generator@14.1.1(semantic-release@25.0.3(typescript@6.0.3))': + dependencies: + conventional-changelog-angular: 8.3.1 + conventional-changelog-writer: 8.4.0 + conventional-commits-filter: 5.0.0 + conventional-commits-parser: 6.4.0 + debug: 4.4.3 + import-from-esm: 2.0.0 + lodash-es: 4.18.1 + read-package-up: 11.0.0 + semantic-release: 25.0.3(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + '@shikijs/core@4.0.2': dependencies: '@shikijs/primitive': 4.0.2 @@ -7220,6 +8618,12 @@ snapshots: dependencies: '@simple-git/args-pathspec': 1.0.3 + '@simple-libs/stream-utils@1.2.0': {} + + '@sindresorhus/is@4.6.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/chunked-blob-reader-native@4.2.3': dependencies: '@smithy/util-base64': 4.3.2 @@ -7694,6 +9098,8 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/normalize-package-data@2.4.4': {} + '@types/pg@8.20.0': dependencies: '@types/node': 24.12.2 @@ -7802,6 +9208,18 @@ snapshots: agent-base@7.1.4: {} + agent-base@9.0.0: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + aggregate-error@5.0.0: + dependencies: + clean-stack: 5.3.0 + indent-string: 5.0.0 + ai@6.0.180(zod@4.4.3): dependencies: '@ai-sdk/gateway': 3.0.114(zod@4.4.3) @@ -7825,16 +9243,32 @@ snapshots: dependencies: environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} + argparse@2.0.1: {} + argv-formatter@1.0.0: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + array-ify@1.0.0: {} + arrify@2.0.1: {} arrify@3.0.0: {} @@ -7891,6 +9325,8 @@ snapshots: baseline-browser-mapping@2.10.29: {} + before-after-hook@4.0.0: {} + better-sqlite3@12.10.0: dependencies: bindings: 1.5.0 @@ -7937,6 +9373,8 @@ snapshots: transitivePeerDependencies: - supports-color + bottleneck@2.19.5: {} + bowser@2.14.1: {} brace-expansion@1.1.14: @@ -7948,6 +9386,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + buffer-equal-constant-time@1.0.1: {} buffer@5.7.1: @@ -7976,14 +9418,29 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + callsites@3.1.0: {} + caniuse-lite@1.0.30001792: {} ccount@2.0.1: {} chai@6.2.2: {} + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@5.6.2: {} + char-regex@1.0.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -8002,12 +9459,33 @@ snapshots: dependencies: clsx: 2.1.1 + clean-stack@2.2.0: {} + + clean-stack@5.3.0: + dependencies: + escape-string-regexp: 5.0.0 + cli-boxes@4.0.1: {} cli-cursor@4.0.0: dependencies: restore-cursor: 4.0.0 + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.0 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + cli-truncate@6.0.0: dependencies: slice-ansi: 9.0.0 @@ -8015,6 +9493,18 @@ snapshots: client-only@0.0.1: {} + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrap-ansi: 9.0.2 + clsx@2.1.1: {} code-excerpt@4.0.0: @@ -8023,10 +9513,22 @@ snapshots: collapse-white-space@2.1.0: {} + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + color-convert@3.1.3: dependencies: color-name: 2.1.0 + color-name@1.1.3: {} + + color-name@1.1.4: {} + color-name@2.1.0: {} color-string@2.1.4: @@ -8048,14 +9550,51 @@ snapshots: commander@14.0.3: {} + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + compute-scroll-into-view@3.1.1: {} concat-map@0.0.1: {} + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + content-disposition@1.1.0: {} content-type@1.0.5: {} + content-type@2.0.0: {} + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-writer@8.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + conventional-commits-filter: 5.0.0 + handlebars: 4.7.9 + meow: 13.2.0 + semver: 7.7.4 + + conventional-commits-filter@5.0.0: {} + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + convert-hrtime@5.0.0: {} + convert-source-map@2.0.0: {} convert-to-spaces@2.0.1: {} @@ -8064,17 +9603,32 @@ snapshots: cookie@0.7.2: {} + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 + cosmiconfig@9.0.1(typescript@6.0.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 6.0.3 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + crypto-random-string@4.0.0: + dependencies: + type-fest: 1.4.0 + csstype@3.2.3: {} data-uri-to-buffer@4.0.1: {} @@ -8118,12 +9672,24 @@ snapshots: dependencies: dequal: 2.0.3 + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + duplexify@4.1.3: dependencies: end-of-stream: 1.4.5 @@ -8137,6 +9703,12 @@ snapshots: ee-first@1.1.1: {} + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emojilib@2.4.0: {} + enabled@2.0.0: {} encodeurl@2.0.0: {} @@ -8152,8 +9724,19 @@ snapshots: entities@6.0.1: {} + env-ci@11.2.0: + dependencies: + execa: 8.0.1 + java-properties: 1.0.2 + + env-paths@2.2.1: {} + environment@1.1.0: {} + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -8216,8 +9799,12 @@ snapshots: '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 + escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@2.0.0: {} escape-string-regexp@5.0.0: {} @@ -8271,6 +9858,45 @@ snapshots: dependencies: eventsource-parser: 3.0.8 + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + expand-template@2.0.3: {} expand-tilde@2.0.2: @@ -8319,6 +9945,8 @@ snapshots: extend@3.0.2: {} + fast-content-type-parse@3.0.0: {} + fast-deep-equal@3.1.3: {} fast-string-truncated-width@3.0.3: {} @@ -8363,8 +9991,20 @@ snapshots: fflate@0.8.2: {} + figures@2.0.0: + dependencies: + escape-string-regexp: 1.0.5 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -8376,6 +10016,17 @@ snapshots: transitivePeerDependencies: - supports-color + find-up-simple@1.0.1: {} + + find-up@2.1.0: + dependencies: + locate-path: 2.0.0 + + find-versions@6.0.0: + dependencies: + semver-regex: 4.0.5 + super-regex: 1.1.0 + fn.name@1.1.0: {} follow-redirects@1.16.0: {} @@ -8411,6 +10062,12 @@ snapshots: fs-constants@1.0.0: {} + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -8516,6 +10173,8 @@ snapshots: function-bind@1.1.2: {} + function-timeout@1.0.2: {} + gaxios@7.1.4: dependencies: extend: 3.0.2 @@ -8538,6 +10197,8 @@ snapshots: generic-pool@3.9.0: {} + get-caller-file@2.0.5: {} + get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: @@ -8560,10 +10221,28 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-stream@6.0.1: {} + + get-stream@8.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 + git-log-parser@1.2.1: + dependencies: + argv-formatter: 1.0.0 + spawn-error-forwarder: 1.0.0 + split2: 1.0.0 + stream-combiner2: 1.1.1 + through2: 2.0.5 + traverse: 0.6.8 + github-from-package@0.0.0: {} github-slugger@2.0.0: {} @@ -8592,6 +10271,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.10: {} + graceful-fs@4.2.11: {} handlebars@4.7.9: @@ -8603,6 +10284,8 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + has-flag@3.0.0: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -8723,12 +10406,24 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + highlight.js@10.7.3: {} + homedir-polyfill@1.0.3: dependencies: parse-passwd: 1.0.0 hono@4.12.15: {} + hook-std@4.0.0: {} + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.3.6 + html-entities@2.6.0: {} html-escaper@2.0.2: {} @@ -8750,6 +10445,13 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy-agent@9.0.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -8757,14 +10459,45 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@9.0.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@5.0.0: {} + + human-signals@8.0.1: {} + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 ieee754@1.2.1: {} + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-from-esm@2.0.0: + dependencies: + debug: 4.4.3 + import-meta-resolve: 4.2.0 + transitivePeerDependencies: + - supports-color + + import-meta-resolve@4.2.0: {} + + indent-string@4.0.0: {} + indent-string@5.0.0: {} + index-to-position@1.2.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -8827,12 +10560,16 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-arrayish@0.2.1: {} + is-decimal@2.0.1: {} is-docker@2.2.1: {} is-docker@3.0.0: {} + is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.5.0 @@ -8845,6 +10582,10 @@ snapshots: dependencies: is-docker: 3.0.0 + is-number@7.0.0: {} + + is-obj@2.0.0: {} + is-plain-obj@4.1.0: {} is-promise@4.0.0: {} @@ -8853,6 +10594,12 @@ snapshots: is-stream@2.0.1: {} + is-stream@3.0.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + is-wsl@2.2.0: dependencies: is-docker: 2.2.1 @@ -8861,8 +10608,18 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isexe@2.0.0: {} + issue-parser@7.0.2: + dependencies: + lodash.capitalize: 4.2.1 + lodash.escaperegexp: 4.1.2 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.uniqby: 4.7.0 + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -8876,6 +10633,8 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + java-properties@1.0.2: {} + jiti@2.7.0: {} jose@6.2.2: {} @@ -8884,6 +10643,8 @@ snapshots: js-tokens@10.0.0: {} + js-tokens@4.0.0: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -8892,12 +10653,29 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-parse-better-errors@1.0.2: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} json-schema@0.4.0: {} + json-with-bigint@3.5.8: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -8993,6 +10771,26 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} + + load-json-file@4.0.0: + dependencies: + graceful-fs: 4.2.11 + parse-json: 4.0.0 + pify: 3.0.0 + strip-bom: 3.0.0 + + locate-path@2.0.0: + dependencies: + p-locate: 2.0.0 + path-exists: 3.0.0 + + lodash-es@4.18.1: {} + + lodash.capitalize@4.2.1: {} + + lodash.escaperegexp@4.1.2: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -9007,6 +10805,10 @@ snapshots: lodash.once@4.1.1: {} + lodash.uniqby@4.7.0: {} + + lodash@4.18.1: {} + logform@2.7.0: dependencies: '@colors/colors': 1.6.0 @@ -9029,6 +10831,10 @@ snapshots: optionalDependencies: js-yaml: 4.1.1 + lru-cache@10.4.3: {} + + lru-cache@11.3.6: {} + lru.min@1.1.4: {} lucide-react@1.14.0(react@19.2.6): @@ -9045,6 +10851,12 @@ snapshots: '@babel/types': 7.29.0 source-map-js: 1.2.1 + make-asynchronous@1.1.0: + dependencies: + p-event: 6.0.1 + type-fest: 4.41.0 + web-worker: 1.5.0 + make-dir@4.0.0: dependencies: semver: 7.7.4 @@ -9053,6 +10865,19 @@ snapshots: markdown-table@3.0.4: {} + marked-terminal@7.3.0(marked@15.0.12): + dependencies: + ansi-escapes: 7.3.0 + ansi-regex: 6.2.2 + chalk: 5.6.2 + cli-highlight: 2.1.11 + cli-table3: 0.6.5 + marked: 15.0.12 + node-emoji: 2.2.0 + supports-hyperlinks: 3.2.0 + + marked@15.0.12: {} + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -9220,8 +11045,12 @@ snapshots: media-typer@1.1.0: {} + meow@13.2.0: {} + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -9486,6 +11315,11 @@ snapshots: transitivePeerDependencies: - supports-color + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -9498,8 +11332,12 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@4.1.0: {} + mimic-fn@2.1.0: {} + mimic-fn@4.0.0: {} + mimic-response@3.1.0: {} minimalistic-assert@1.0.1: {} @@ -9572,6 +11410,12 @@ snapshots: named-placeholders: 1.1.6 sql-escaper: 1.3.3 + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + named-placeholders@1.1.6: dependencies: lru.min: 1.1.4 @@ -9586,6 +11430,8 @@ snapshots: neo-async@2.6.2: {} + nerf-dart@1.0.0: {} + next-themes@0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: react: 19.2.6 @@ -9622,12 +11468,48 @@ snapshots: node-domexception@1.0.0: {} + node-emoji@2.2.0: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.7.4 + validate-npm-package-license: 3.0.4 + + normalize-package-data@8.0.0: + dependencies: + hosted-git-info: 9.0.3 + semver: 7.7.4 + validate-npm-package-license: 3.0.4 + + normalize-url@9.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + npm@11.14.1: {} + oauth4webapi@3.8.6: {} object-assign@4.1.1: {} @@ -9652,6 +11534,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -9732,10 +11618,42 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' + p-each-series@3.0.0: {} + + p-event@6.0.1: + dependencies: + p-timeout: 6.1.4 + + p-filter@4.1.0: + dependencies: + p-map: 7.0.4 + + p-limit@1.3.0: + dependencies: + p-try: 1.0.0 + p-limit@7.3.0: dependencies: yocto-queue: 1.2.2 + p-locate@2.0.0: + dependencies: + p-limit: 1.3.0 + + p-map@7.0.4: {} + + p-reduce@2.1.0: {} + + p-reduce@3.0.0: {} + + p-timeout@6.1.4: {} + + p-try@1.0.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -9746,8 +11664,36 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-json@4.0.0: + dependencies: + error-ex: 1.3.4 + json-parse-better-errors: 1.0.2 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + parse-ms@4.0.0: {} + parse-passwd@1.0.0: {} + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -9756,14 +11702,20 @@ snapshots: patch-console@2.0.0: {} + path-exists@3.0.0: {} + path-expression-matcher@1.5.0: {} path-is-absolute@1.0.1: {} path-key@3.1.1: {} + path-key@4.0.0: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} + pathe@2.0.3: {} pegjs@0.10.0: {} @@ -9805,10 +11757,19 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.4: {} + pify@3.0.0: {} + pkce-challenge@5.0.1: {} + pkg-conf@2.1.0: + dependencies: + find-up: 2.1.0 + load-json-file: 4.0.0 + postcss@8.4.31: dependencies: nanoid: 3.3.11 @@ -9846,10 +11807,18 @@ snapshots: tar-fs: 2.1.4 tunnel-agent: 0.6.0 + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + process-nextick-args@2.0.1: {} + process@0.11.10: {} property-information@7.1.0: {} + proto-list@1.2.4: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -9921,6 +11890,44 @@ snapshots: react@19.2.6: {} + read-package-up@11.0.0: + dependencies: + find-up-simple: 1.0.1 + read-pkg: 9.0.1 + type-fest: 4.41.0 + + read-package-up@12.0.0: + dependencies: + find-up-simple: 1.0.1 + read-pkg: 10.1.0 + type-fest: 5.6.0 + + read-pkg@10.1.0: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 8.0.0 + parse-json: 8.3.0 + type-fest: 5.6.0 + unicorn-magic: 0.4.0 + + read-pkg@9.0.1: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 6.0.2 + parse-json: 8.3.0 + type-fest: 4.41.0 + unicorn-magic: 0.1.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -9976,6 +11983,10 @@ snapshots: dependencies: regex-utilities: 2.3.0 + registry-auth-token@5.1.1: + dependencies: + '@pnpm/npm-conf': 3.0.2 + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.4 @@ -10040,8 +12051,14 @@ snapshots: transitivePeerDependencies: - supports-color + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} restore-cursor@4.0.0: @@ -10089,6 +12106,8 @@ snapshots: run-applescript@7.1.0: {} + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-stable-stringify@2.5.0: {} @@ -10101,6 +12120,42 @@ snapshots: dependencies: compute-scroll-into-view: 3.1.1 + semantic-release@25.0.3(typescript@6.0.3): + dependencies: + '@semantic-release/commit-analyzer': 13.0.1(semantic-release@25.0.3(typescript@6.0.3)) + '@semantic-release/error': 4.0.0 + '@semantic-release/github': 12.0.8(semantic-release@25.0.3(typescript@6.0.3)) + '@semantic-release/npm': 13.1.5(semantic-release@25.0.3(typescript@6.0.3)) + '@semantic-release/release-notes-generator': 14.1.1(semantic-release@25.0.3(typescript@6.0.3)) + aggregate-error: 5.0.0 + cosmiconfig: 9.0.1(typescript@6.0.3) + debug: 4.4.3 + env-ci: 11.2.0 + execa: 9.6.1 + figures: 6.1.0 + find-versions: 6.0.0 + get-stream: 6.0.1 + git-log-parser: 1.2.1 + hook-std: 4.0.0 + hosted-git-info: 9.0.3 + import-from-esm: 2.0.0 + lodash-es: 4.18.1 + marked: 15.0.12 + marked-terminal: 7.3.0(marked@15.0.12) + micromatch: 4.0.8 + p-each-series: 3.0.0 + p-reduce: 3.0.0 + read-package-up: 12.0.0 + resolve-from: 5.0.0 + semver: 7.7.4 + signale: 1.4.0 + yargs: 18.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + semver-regex@4.0.5: {} + semver@7.7.4: {} send@1.2.1: @@ -10211,6 +12266,14 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + + signale@1.4.0: + dependencies: + chalk: 2.4.2 + figures: 2.0.0 + pkg-conf: 2.1.0 + simple-concat@1.0.1: {} simple-get@4.0.1: @@ -10233,6 +12296,10 @@ snapshots: sisteransi@1.0.5: {} + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + slice-ansi@9.0.0: dependencies: ansi-styles: 6.2.3 @@ -10287,6 +12354,26 @@ snapshots: space-separated-tokens@2.0.2: {} + spawn-error-forwarder@1.0.0: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + split2@1.0.0: + dependencies: + through2: 2.0.5 + split2@4.2.0: {} sprintf-js@1.1.3: {} @@ -10305,17 +12392,38 @@ snapshots: std-env@4.1.0: {} + stream-combiner2@1.1.1: + dependencies: + duplexer2: 0.1.4 + readable-stream: 2.3.8 + stream-events@1.0.5: dependencies: stubs: 3.0.0 stream-shift@1.0.3: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.1.2 + string-width@8.2.1: dependencies: get-east-asian-width: 1.5.0 strip-ansi: 7.1.2 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -10325,10 +12433,22 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-ansi@7.1.2: dependencies: ansi-regex: 6.2.2 + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-final-newline@4.0.0: {} + strip-json-comments@2.0.1: {} strip-json-comments@5.0.3: {} @@ -10350,10 +12470,25 @@ snapshots: client-only: 0.0.1 react: 19.2.6 + super-regex@1.1.0: + dependencies: + function-timeout: 1.0.2 + make-asynchronous: 1.1.0 + time-span: 5.1.0 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + tagged-tag@1.0.0: {} tailwind-merge@3.6.0: {} @@ -10420,10 +12555,36 @@ snapshots: transitivePeerDependencies: - supports-color + temp-dir@3.0.0: {} + + tempy@3.2.0: + dependencies: + is-stream: 3.0.0 + temp-dir: 3.0.0 + type-fest: 2.19.0 + unique-string: 3.0.0 + terminal-size@4.0.1: {} text-hex@1.0.0: {} + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + + time-span@5.1.0: + dependencies: + convert-hrtime: 5.0.0 + tinybench@2.9.0: {} tinyexec@1.1.1: {} @@ -10437,22 +12598,38 @@ snapshots: tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toidentifier@1.0.1: {} toml@3.0.0: {} + traverse@0.6.8: {} + trim-lines@3.0.1: {} triple-beam@1.4.1: {} trough@2.2.0: {} + ts-algebra@2.0.0: {} + tslib@2.8.1: {} tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 + tunnel@0.0.6: {} + + type-fest@1.4.0: {} + + type-fest@2.19.0: {} + + type-fest@4.41.0: {} + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -10472,6 +12649,18 @@ snapshots: undici-types@7.16.0: {} + undici@6.25.0: {} + + undici@7.25.0: {} + + unicode-emoji-modifier-base@1.0.0: {} + + unicorn-magic@0.1.0: {} + + unicorn-magic@0.3.0: {} + + unicorn-magic@0.4.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -10482,6 +12671,10 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unique-string@3.0.0: + dependencies: + crypto-random-string: 4.0.0 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -10514,8 +12707,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + universal-user-agent@7.0.3: {} + + universalify@2.0.1: {} + unpipe@1.0.0: {} + url-join@5.0.0: {} + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.6): dependencies: react: 19.2.6 @@ -10533,6 +12732,11 @@ snapshots: util-deprecate@1.0.2: {} + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + vary@1.1.2: {} vfile-location@5.0.3: @@ -10599,6 +12803,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-worker@1.5.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -10640,6 +12846,18 @@ snapshots: string-width: 8.2.1 strip-ansi: 7.1.2 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} ws@8.20.0: {} @@ -10650,10 +12868,37 @@ snapshots: xtend@4.0.2: {} + y18n@5.0.8: {} + yaml@2.9.0: {} + yargs-parser@20.2.9: {} + + yargs-parser@22.0.0: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yocto-queue@1.2.2: {} + yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} zod-to-json-schema@3.25.2(zod@4.4.3): diff --git a/release-policy.json b/release-policy.json index 2eba9eaf..2bcbd4f7 100644 --- a/release-policy.json +++ b/release-policy.json @@ -1,21 +1,26 @@ { "schemaVersion": 1, + "publicNpmPackageVersion": "0.1.0-rc.1", "releaseMode": "npm-public-release-ready", "npm": { "publish": true, "registry": null, "access": "public", "tag": "next", - "packages": ["@kaelio/ktx"] + "packages": [ + "@kaelio/ktx" + ] }, "python": { "publish": false, "repository": null, - "packages": ["kaelio-ktx"] + "packages": [ + "kaelio-ktx" + ] }, "publishedPackageSmoke": { "packageName": "@kaelio/ktx", - "version": "0.1.0-rc.0", + "version": "0.1.0-rc.1", "registry": null }, "runtimeInstaller": { diff --git a/scripts/build-public-npm-package.mjs b/scripts/build-public-npm-package.mjs index 9df91c69..0e34ae6d 100644 --- a/scripts/build-public-npm-package.mjs +++ b/scripts/build-public-npm-package.mjs @@ -6,10 +6,15 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; +import { + PUBLIC_NPM_PACKAGE_NAME, + publicNpmPackageVersion, +} from './public-npm-release-metadata.mjs'; + const execFileAsync = promisify(execFile); -export const PUBLIC_NPM_PACKAGE_NAME = '@kaelio/ktx'; -export const PUBLIC_NPM_PACKAGE_VERSION = '0.1.0-rc.0'; +export const PUBLIC_NPM_PACKAGE_VERSION = publicNpmPackageVersion(); +export { PUBLIC_NPM_PACKAGE_NAME }; export function publicNpmPackageTarballName(version = PUBLIC_NPM_PACKAGE_VERSION) { return `kaelio-ktx-${version}.tgz`; diff --git a/scripts/build-public-npm-package.test.mjs b/scripts/build-public-npm-package.test.mjs index c68c2aed..4afe1de5 100644 --- a/scripts/build-public-npm-package.test.mjs +++ b/scripts/build-public-npm-package.test.mjs @@ -142,9 +142,9 @@ describe('publicNpmPackageLayout', () => { it('uses the first public npm release version for the tarball name', () => { const layout = publicNpmPackageLayout('/repo/ktx'); - assert.equal(PUBLIC_NPM_PACKAGE_VERSION, '0.1.0-rc.0'); - assert.equal(publicNpmPackageTarballName(), 'kaelio-ktx-0.1.0-rc.0.tgz'); - assert.equal(layout.tarballPath, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz'); + assert.equal(PUBLIC_NPM_PACKAGE_VERSION, '0.1.0-rc.1'); + assert.equal(publicNpmPackageTarballName(), 'kaelio-ktx-0.1.0-rc.1.tgz'); + assert.equal(layout.tarballPath, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz'); }); }); @@ -211,7 +211,7 @@ describe('publicNpmPackageJson', () => { ); assert.equal(packageJson.name, PUBLIC_NPM_PACKAGE_NAME); - assert.equal(packageJson.version, '0.1.0-rc.0'); + assert.equal(packageJson.version, '0.1.0-rc.1'); assert.equal(packageJson.private, false); assert.deepEqual(packageJson.bin, { ktx: './dist/bin.js' }); assert.deepEqual(packageJson.dependencies, { commander: '14.0.3' }); @@ -267,7 +267,7 @@ describe('publicNpmPackCommand', () => { '--config.node-linker=hoisted', 'pack', '--out', - '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz', + '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', ], cwd: '/repo/ktx/dist/public-npm-package', }); diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index 9f2953e7..12001ae5 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -9,7 +9,7 @@ const runtimeAssetPatterns = [/^packages\/[^/]+\/prompts\/.+\.md$/, /^packages\/ const identifierSkipPrefixes = ['docs/', 'docs-site/', 'examples/', 'python/ktx-sl/plans/', 'python/ktx-sl/openspec/']; const identifierAllowPatterns = [ /^packages\/cli\/src\/(?:index|managed-local-embeddings|managed-python-command|managed-python-daemon|managed-python-runtime|runtime)(?:\.test)?\.ts$/, - /^scripts\/(?:build-public-npm-package|build-python-runtime-wheel|local-embeddings-runtime-smoke|package-artifacts|publish-public-npm-package|published-package-smoke|release-readiness)(?:\.test)?\.mjs$/, + /^scripts\/(?:build-public-npm-package|build-python-runtime-wheel|local-embeddings-runtime-smoke|package-artifacts|public-npm-release-metadata|publish-public-npm-package|published-package-smoke|release-readiness)(?:\.test)?\.mjs$/, ]; const forbiddenIdentifierTerms = ['kae' + 'lio', 'Kae' + 'lio', 'KAE' + 'LIO_']; diff --git a/scripts/check-boundaries.test.mjs b/scripts/check-boundaries.test.mjs index 9d5bf6f9..29ff2df2 100644 --- a/scripts/check-boundaries.test.mjs +++ b/scripts/check-boundaries.test.mjs @@ -77,6 +77,7 @@ describe('scanFileContent', () => { assert.equal(scanFileContent('scripts/local-embeddings-runtime-smoke.mjs', `@${name}/ktx`).length, 0); assert.equal(scanFileContent('scripts/package-artifacts.test.mjs', `${name}-ktx`).length, 0); + assert.equal(scanFileContent('scripts/public-npm-release-metadata.mjs', `@${name}/ktx`).length, 0); assert.equal(scanFileContent('scripts/publish-public-npm-package.test.mjs', `@${name}/ktx`).length, 0); assert.equal(scanFileContent('packages/cli/src/managed-python-runtime.ts', `${name}_ktx`).length, 0); }); diff --git a/scripts/local-embeddings-runtime-smoke.test.mjs b/scripts/local-embeddings-runtime-smoke.test.mjs index b4ec7fd9..95ee119c 100644 --- a/scripts/local-embeddings-runtime-smoke.test.mjs +++ b/scripts/local-embeddings-runtime-smoke.test.mjs @@ -36,8 +36,8 @@ describe('localEmbeddingsSmokeOptIn', () => { describe('publicKtxTarballName', () => { it('selects the public @kaelio/ktx tarball name', () => { assert.equal( - publicKtxTarballName(['kaelio-ktx-0.1.0-rc.0.tgz', 'ignore-me.tgz']), - 'kaelio-ktx-0.1.0-rc.0.tgz', + publicKtxTarballName(['kaelio-ktx-0.1.0-rc.1.tgz', 'ignore-me.tgz']), + 'kaelio-ktx-0.1.0-rc.1.tgz', ); }); @@ -50,7 +50,7 @@ describe('publicKtxTarballName', () => { it('fails when multiple public package tarballs are present', () => { assert.throws( - () => publicKtxTarballName(['kaelio-ktx-0.1.0-rc.0.tgz', 'kaelio-ktx-0.2.0.tgz']), + () => publicKtxTarballName(['kaelio-ktx-0.1.0-rc.1.tgz', 'kaelio-ktx-0.2.0.tgz']), /Expected exactly one @kaelio\/ktx tarball/, ); }); @@ -60,7 +60,7 @@ describe('expectedPublicKtxVersionPattern', () => { it('matches the public package version and rejects the private workspace version', () => { const pattern = expectedPublicKtxVersionPattern(); - assert.match('@kaelio/ktx 0.1.0-rc.0\n', pattern); + assert.match('@kaelio/ktx 0.1.0-rc.1\n', pattern); assert.doesNotMatch('@kaelio/ktx 0.0.0-private\n', pattern); }); }); diff --git a/scripts/package-artifacts.mjs b/scripts/package-artifacts.mjs index 88654c55..1a43ddf2 100644 --- a/scripts/package-artifacts.mjs +++ b/scripts/package-artifacts.mjs @@ -14,9 +14,9 @@ import { } from './build-python-runtime-wheel.mjs'; import { PUBLIC_NPM_PACKAGE_NAME, - PUBLIC_NPM_PACKAGE_VERSION, publicNpmPackageTarballName, } from './build-public-npm-package.mjs'; +import { publicNpmPackageVersion } from './public-npm-release-metadata.mjs'; export { RUNTIME_WHEEL_DISTRIBUTION_NAME, @@ -45,24 +45,27 @@ function scriptRootDir() { return resolve(dirname(fileURLToPath(import.meta.url)), '..'); } -function npmPackageTarballName(packageName) { +function npmPackageTarballName(packageName, version) { if (packageName !== PUBLIC_NPM_PACKAGE_NAME) { throw new Error(`Unsupported npm artifact package: ${packageName}`); } - return publicNpmPackageTarballName(PUBLIC_NPM_PACKAGE_VERSION); + return publicNpmPackageTarballName(version); } -function npmPackageTarballs(npmDir) { +function npmPackageTarballs(npmDir, version) { return Object.fromEntries( - NPM_ARTIFACT_PACKAGES.map((packageInfo) => [packageInfo.name, join(npmDir, npmPackageTarballName(packageInfo.name))]), + NPM_ARTIFACT_PACKAGES.map((packageInfo) => [ + packageInfo.name, + join(npmDir, npmPackageTarballName(packageInfo.name, version)), + ]), ); } -export function packageArtifactLayout(rootDir = scriptRootDir()) { +export function packageArtifactLayout(rootDir = scriptRootDir(), version = publicNpmPackageVersion(rootDir)) { const artifactDir = join(rootDir, 'dist', 'artifacts'); const npmDir = join(artifactDir, 'npm'); const pythonDir = join(artifactDir, 'python'); - const npmTarballs = npmPackageTarballs(npmDir); + const npmTarballs = npmPackageTarballs(npmDir, version); return { rootDir, @@ -170,7 +173,7 @@ function releaseMetadataEntry({ ecosystem, packageName, packageRoot, packageVers }; } -async function readNpmPackageMetadata(rootDir, packageInfo) { +async function readNpmPackageMetadata(rootDir, packageInfo, version) { const packageJson = await readJson(join(rootDir, packageInfo.packageRoot, 'package.json')); const expectedSourceName = packageInfo.name === PUBLIC_NPM_PACKAGE_NAME ? '@ktx/cli' : packageInfo.name; if (packageJson.name !== expectedSourceName) { @@ -183,14 +186,14 @@ async function readNpmPackageMetadata(rootDir, packageInfo) { ecosystem: 'npm', packageName: packageInfo.name, packageRoot: packageInfo.packageRoot, - packageVersion: isPublicKtxPackage ? PUBLIC_NPM_PACKAGE_VERSION : packageJson.version, + packageVersion: isPublicKtxPackage ? version : packageJson.version, privatePackage: isPublicKtxPackage ? false : packageJson.private === true, }); } -export async function packageReleaseMetadata(rootDir = scriptRootDir()) { +export async function packageReleaseMetadata(rootDir = scriptRootDir(), version = publicNpmPackageVersion(rootDir)) { const npmPackages = await Promise.all( - NPM_ARTIFACT_PACKAGES.map((packageInfo) => readNpmPackageMetadata(rootDir, packageInfo)), + NPM_ARTIFACT_PACKAGES.map((packageInfo) => readNpmPackageMetadata(rootDir, packageInfo, version)), ); return [ diff --git a/scripts/package-artifacts.test.mjs b/scripts/package-artifacts.test.mjs index 7b0fc948..95eb6c0c 100644 --- a/scripts/package-artifacts.test.mjs +++ b/scripts/package-artifacts.test.mjs @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; +import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs'; import { CLI_PYTHON_ASSET_MANIFEST, INTERNAL_NPM_WORKSPACE_PACKAGES, @@ -32,6 +33,35 @@ async function writeJson(path, value) { } async function writeReleaseMetadataInputs(root) { + await writeJson(join(root, 'release-policy.json'), { + schemaVersion: 1, + publicNpmPackageVersion: PUBLIC_NPM_PACKAGE_VERSION, + releaseMode: 'ci-artifact-only', + npm: { + publish: false, + registry: null, + access: 'public', + tag: 'next', + packages: ['@kaelio/ktx'], + }, + python: { + publish: false, + repository: null, + packages: ['kaelio-ktx'], + }, + publishedPackageSmoke: { + packageName: '@kaelio/ktx', + version: PUBLIC_NPM_PACKAGE_VERSION, + registry: null, + }, + runtimeInstaller: { + uvStrategy: 'path-prerequisite', + bootstrapUv: false, + missingUvBehavior: 'focused-error', + }, + requiredBeforePublishing: ['Choose public release version.'], + }); + for (const packageInfo of INTERNAL_NPM_WORKSPACE_PACKAGES) { await mkdir(join(root, packageInfo.packageRoot), { recursive: true }); await writeJson(join(root, packageInfo.packageRoot, 'package.json'), { @@ -64,19 +94,19 @@ async function writeUploadableArtifactFixtures(layout) { describe('packageArtifactLayout', () => { it('uses stable artifact paths under ktx/dist/artifacts', () => { - const layout = packageArtifactLayout('/repo/ktx'); + const layout = packageArtifactLayout('/repo/ktx', PUBLIC_NPM_PACKAGE_VERSION); assert.equal(layout.artifactDir, '/repo/ktx/dist/artifacts'); assert.equal(layout.npmDir, '/repo/ktx/dist/artifacts/npm'); assert.equal(layout.pythonDir, '/repo/ktx/dist/artifacts/python'); - assert.equal(layout.cliTarball, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz'); + assert.equal(layout.cliTarball, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz'); assert.deepEqual(Object.keys(layout.npmTarballs), ['@kaelio/ktx']); }); }); describe('buildArtifactCommands', () => { it('builds TypeScript packages in parallel topology, then the runtime wheel, then packs npm artifacts', () => { - const layout = packageArtifactLayout('/repo/ktx'); + const layout = packageArtifactLayout('/repo/ktx', PUBLIC_NPM_PACKAGE_VERSION); const commands = buildArtifactCommands(layout); assert.deepEqual( @@ -101,7 +131,7 @@ describe('packageReleaseMetadata', () => { ecosystem: 'npm', packageName: '@kaelio/ktx', packageRoot: 'packages/cli', - packageVersion: '0.1.0-rc.0', + packageVersion: '0.1.0-rc.1', private: false, releaseMode: 'ci-artifact-only', }, @@ -147,7 +177,7 @@ describe('findPythonArtifacts', () => { describe('artifact manifest', () => { it('writes release metadata, source revision, checksums, and byte counts for every uploadable artifact', async () => { const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-manifest-test-')); - const layout = packageArtifactLayout(root); + const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION); try { await writeReleaseMetadataInputs(root); await writeUploadableArtifactFixtures(layout); @@ -167,7 +197,7 @@ describe('artifact manifest', () => { ecosystem: 'npm', packageName: '@kaelio/ktx', packageRoot: 'packages/cli', - packageVersion: '0.1.0-rc.0', + packageVersion: '0.1.0-rc.1', private: false, releaseMode: 'ci-artifact-only', }, @@ -202,8 +232,8 @@ describe('artifact manifest', () => { artifactKind: 'tarball', ecosystem: 'npm', packageName: '@kaelio/ktx', - packageVersion: '0.1.0-rc.0', - path: 'npm/kaelio-ktx-0.1.0-rc.0.tgz', + packageVersion: '0.1.0-rc.1', + path: 'npm/kaelio-ktx-0.1.0-rc.1.tgz', }, ], ); @@ -228,7 +258,7 @@ describe('artifact manifest', () => { ], ); - const npmEntry = manifest.files.find((file) => file.path === 'npm/kaelio-ktx-0.1.0-rc.0.tgz'); + const npmEntry = manifest.files.find((file) => file.path === 'npm/kaelio-ktx-0.1.0-rc.1.tgz'); assert.ok(npmEntry); assert.equal(npmEntry.bytes, Buffer.byteLength('@kaelio/ktx-tarball')); assert.equal(npmEntry.sha256, createHash('sha256').update('@kaelio/ktx-tarball').digest('hex')); @@ -244,7 +274,7 @@ describe('artifact manifest', () => { describe('verifyArtifactManifest', () => { it('accepts a schema version 2 manifest that matches the artifact directory', async () => { const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-verify-manifest-test-')); - const layout = packageArtifactLayout(root); + const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION); try { await writeReleaseMetadataInputs(root); await writeUploadableArtifactFixtures(layout); @@ -266,7 +296,7 @@ describe('verifyArtifactManifest', () => { it('rejects a manifest when a file checksum has drifted', async () => { const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-checksum-drift-test-')); - const layout = packageArtifactLayout(root); + const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION); try { await writeReleaseMetadataInputs(root); await writeUploadableArtifactFixtures(layout); @@ -286,7 +316,7 @@ describe('verifyArtifactManifest', () => { it('rejects a manifest with an unsafe artifact path', async () => { const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-path-test-')); - const layout = packageArtifactLayout(root); + const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION); try { await writeReleaseMetadataInputs(root); await writeUploadableArtifactFixtures(layout); @@ -304,7 +334,7 @@ describe('verifyArtifactManifest', () => { it('rejects a manifest from the wrong source revision when one is required', async () => { const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-revision-test-')); - const layout = packageArtifactLayout(root); + const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION); try { await writeReleaseMetadataInputs(root); await writeUploadableArtifactFixtures(layout); @@ -328,7 +358,7 @@ describe('verifyArtifactManifest', () => { describe('copyRuntimeWheelAssets', () => { it('copies the runtime wheel and checksum manifest into CLI assets', async () => { const root = await mkdtemp(join(tmpdir(), 'ktx-runtime-assets-test-')); - const layout = packageArtifactLayout(root); + const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION); try { await mkdir(layout.pythonDir, { recursive: true }); await writeFile( @@ -399,7 +429,7 @@ describe('standalone Python artifact cleanup', () => { describe('verification snippets', () => { it('pins the smoke project to the public package artifact', () => { - const layout = packageArtifactLayout('/repo/ktx'); + const layout = packageArtifactLayout('/repo/ktx', PUBLIC_NPM_PACKAGE_VERSION); const packageJson = npmSmokePackageJson(layout); assert.deepEqual(packageJson.dependencies, { diff --git a/scripts/public-npm-release-metadata.mjs b/scripts/public-npm-release-metadata.mjs new file mode 100644 index 00000000..a8d4fa43 --- /dev/null +++ b/scripts/public-npm-release-metadata.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const PUBLIC_NPM_PACKAGE_NAME = '@kaelio/ktx'; +export const PUBLIC_NPM_RELEASE_TAGS = new Set(['latest', 'next']); + +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +function scriptRootDir() { + return resolve(dirname(fileURLToPath(import.meta.url)), '..'); +} + +export function releasePolicyPath(rootDir = scriptRootDir()) { + return join(rootDir, 'release-policy.json'); +} + +function readJsonSync(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +export function assertPublicNpmPackageVersion(version) { + if (typeof version !== 'string' || !SEMVER_PATTERN.test(version)) { + throw new Error(`Invalid public npm package version: ${version}`); + } + return version; +} + +export function assertPublicNpmReleaseTag(tag) { + if (!PUBLIC_NPM_RELEASE_TAGS.has(tag)) { + throw new Error(`Invalid public npm release tag: ${tag}`); + } + return tag; +} + +export function readPublicNpmReleaseMetadata(rootDir = scriptRootDir()) { + const policy = readJsonSync(releasePolicyPath(rootDir)); + const version = assertPublicNpmPackageVersion(policy.publicNpmPackageVersion); + const tag = assertPublicNpmReleaseTag(policy.npm?.tag); + + return { + packageName: PUBLIC_NPM_PACKAGE_NAME, + version, + tag, + }; +} + +export function publicNpmPackageVersion(rootDir = scriptRootDir()) { + return readPublicNpmReleaseMetadata(rootDir).version; +} diff --git a/scripts/publish-public-npm-package.test.mjs b/scripts/publish-public-npm-package.test.mjs index 704a8b16..d622ffe2 100644 --- a/scripts/publish-public-npm-package.test.mjs +++ b/scripts/publish-public-npm-package.test.mjs @@ -13,7 +13,7 @@ const readyReport = { npmPublishEnabled: true, npmPublish: { packageName: '@kaelio/ktx', - version: '0.1.0-rc.0', + version: '0.1.0-rc.1', access: 'public', tag: 'next', registry: null, @@ -51,14 +51,14 @@ describe('requireNpmPublicReleaseReady', () => { describe('buildNpmPublishCommand', () => { it('builds a dry-run pnpm publish command by default', () => { assert.deepEqual( - buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz', readyReport.npmPublish, { + buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', readyReport.npmPublish, { live: false, }), { command: 'pnpm', args: [ 'publish', - '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz', + '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', '--access', 'public', '--tag', @@ -73,12 +73,12 @@ describe('buildNpmPublishCommand', () => { it('omits dry-run only for explicit live publish', () => { assert.deepEqual( - buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz', readyReport.npmPublish, { + buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', readyReport.npmPublish, { live: true, }).args, [ 'publish', - '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz', + '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', '--access', 'public', '--tag', @@ -94,7 +94,7 @@ describe('buildNpmPublishCommand', () => { }; assert.deepEqual( - buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.0.tgz', publish, { live: false }).env, + buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', publish, { live: false }).env, { npm_config_registry: 'https://registry.npmjs.org/' }, ); }); diff --git a/scripts/release-readiness.mjs b/scripts/release-readiness.mjs index 6b15e83e..bcb66dc8 100644 --- a/scripts/release-readiness.mjs +++ b/scripts/release-readiness.mjs @@ -5,7 +5,7 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { packageArtifactLayout, packageReleaseMetadata, verifyArtifactManifest } from './package-artifacts.mjs'; -import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs'; +import { assertPublicNpmPackageVersion, publicNpmPackageVersion } from './public-npm-release-metadata.mjs'; import { readPublishedPackageSmokeConfig } from './published-package-smoke-config.mjs'; function scriptRootDir() { @@ -138,6 +138,8 @@ export function validateReleasePolicy(policy) { throw new Error(`Unsupported release policy schemaVersion: ${policy.schemaVersion}`); } assertSupportedReleaseMode(policy.releaseMode); + assertString(policy.publicNpmPackageVersion, 'Release policy publicNpmPackageVersion'); + assertPublicNpmPackageVersion(policy.publicNpmPackageVersion); assertPlainObject(policy.npm, 'Release policy npm'); assertPlainObject(policy.python, 'Release policy python'); assertPlainObject(policy.publishedPackageSmoke, 'Release policy publishedPackageSmoke'); @@ -202,7 +204,7 @@ function publishedPackageSmokeGate(policy) { }; } -function assertNonPublishingArtifactPolicy(policy, metadata) { +function assertNonPublishingArtifactPolicy(policy, metadata, publicPackageVersion) { const policyLabel = policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE ? 'ci-artifact-only policy' : `${policy.releaseMode} policy`; @@ -232,8 +234,8 @@ function assertNonPublishingArtifactPolicy(policy, metadata) { if (entry.private !== false) { throw new Error(`${policyLabel} npm package @kaelio/ktx must be publishable when npm.publish is false`); } - if (entry.packageVersion !== PUBLIC_NPM_PACKAGE_VERSION) { - throw new Error(`${policyLabel} npm package @kaelio/ktx must use public version ${PUBLIC_NPM_PACKAGE_VERSION}`); + if (entry.packageVersion !== publicPackageVersion) { + throw new Error(`${policyLabel} npm package @kaelio/ktx must use public version ${publicPackageVersion}`); } } else if (entry.private !== true) { throw new Error(`${policyLabel} npm package ${entry.packageName} must remain private`); @@ -244,7 +246,7 @@ function assertNonPublishingArtifactPolicy(policy, metadata) { } } -function assertNpmPublicReleaseReadyPolicy(policy, metadata) { +function assertNpmPublicReleaseReadyPolicy(policy, metadata, publicPackageVersion) { if (policy.npm.publish !== true) { throw new Error('npm-public-release-ready policy requires npm.publish true'); } @@ -265,29 +267,30 @@ function assertNpmPublicReleaseReadyPolicy(policy, metadata) { if (npmMetadata.private !== false) { throw new Error('npm-public-release-ready policy requires @kaelio/ktx to be publishable'); } - if (npmMetadata.packageVersion !== PUBLIC_NPM_PACKAGE_VERSION) { + if (npmMetadata.packageVersion !== publicPackageVersion) { throw new Error( - `npm-public-release-ready policy expected @kaelio/ktx ${PUBLIC_NPM_PACKAGE_VERSION}, got ${npmMetadata.packageVersion}`, + `npm-public-release-ready policy expected @kaelio/ktx ${publicPackageVersion}, got ${npmMetadata.packageVersion}`, ); } if (policy.publishedPackageSmoke.packageName !== '@kaelio/ktx') { throw new Error('npm-public-release-ready policy requires publishedPackageSmoke.packageName @kaelio/ktx'); } - if (policy.publishedPackageSmoke.version !== PUBLIC_NPM_PACKAGE_VERSION) { - throw new Error(`npm-public-release-ready policy requires publishedPackageSmoke.version ${PUBLIC_NPM_PACKAGE_VERSION}`); + if (policy.publishedPackageSmoke.version !== publicPackageVersion) { + throw new Error(`npm-public-release-ready policy requires publishedPackageSmoke.version ${publicPackageVersion}`); } } export async function releaseReadinessReport(rootDir = scriptRootDir()) { const policy = validateReleasePolicy(await readReleasePolicy(rootDir)); - const layout = packageArtifactLayout(rootDir); + const publicPackageVersion = publicNpmPackageVersion(rootDir); + const layout = packageArtifactLayout(rootDir, publicPackageVersion); const manifest = await verifyArtifactManifest(layout); - const metadata = await packageReleaseMetadata(rootDir); + const metadata = await packageReleaseMetadata(rootDir, publicPackageVersion); if (policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) { - assertNpmPublicReleaseReadyPolicy(policy, metadata); + assertNpmPublicReleaseReadyPolicy(policy, metadata, publicPackageVersion); } else { - assertNonPublishingArtifactPolicy(policy, metadata); + assertNonPublishingArtifactPolicy(policy, metadata, publicPackageVersion); } return { @@ -303,7 +306,7 @@ export async function releaseReadinessReport(rootDir = scriptRootDir()) { policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE ? { packageName: '@kaelio/ktx', - version: PUBLIC_NPM_PACKAGE_VERSION, + version: publicPackageVersion, access: policy.npm.access, tag: policy.npm.tag, registry: policy.npm.registry, diff --git a/scripts/release-readiness.test.mjs b/scripts/release-readiness.test.mjs index ee7113c0..38fcfe20 100644 --- a/scripts/release-readiness.test.mjs +++ b/scripts/release-readiness.test.mjs @@ -50,6 +50,7 @@ function releasePolicy(overrides = {}) { return { schemaVersion: 1, + publicNpmPackageVersion: PUBLIC_NPM_PACKAGE_VERSION, releaseMode: 'ci-artifact-only', npm: { publish: false, diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index 7e313c9c..8e8d6df0 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -3,17 +3,23 @@ import { readFile } from 'node:fs/promises'; import { describe, it } from 'node:test'; describe('release workflow', () => { - it('publishes only from manual dispatch with an explicit live input', async () => { + it('runs semantic-release only from manual dispatch with explicit release inputs', async () => { const workflow = await readFile(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8'); assert.match(workflow, /^name: KTX Release$/m); assert.match(workflow, /^ workflow_dispatch:$/m); + assert.match(workflow, /release_kind:/); + assert.match(workflow, /options:\n - rc\n - stable/); + assert.match(workflow, /force_release:/); assert.match(workflow, /publish_live:/); assert.match(workflow, /default: false/); - assert.match(workflow, /pnpm run artifacts:check/); - assert.match(workflow, /pnpm run release:readiness/); - assert.match(workflow, /pnpm run release:npm-publish$/m); - assert.match(workflow, /pnpm run release:npm-publish -- --publish/); + assert.match(workflow, /^ contents: write$/m); + assert.match(workflow, /fetch-depth: 0/); + assert.match(workflow, /registry-url: "https:\/\/registry\.npmjs\.org"/); + assert.match(workflow, /pnpm run semantic-release:dry-run/); + assert.match(workflow, /pnpm run semantic-release$/m); + assert.match(workflow, /KTX_RELEASE_KIND: \$\{\{ inputs.release_kind \}\}/); + assert.match(workflow, /FORCE_RELEASE: \$\{\{ inputs.force_release \}\}/); assert.match(workflow, /NODE_AUTH_TOKEN: \$\{\{ secrets.NPM_TOKEN \}\}/); assert.doesNotMatch(workflow, /^ push:/m); assert.doesNotMatch(workflow, /^ pull_request:/m); diff --git a/scripts/semantic-release-config.cjs b/scripts/semantic-release-config.cjs new file mode 100644 index 00000000..85df2620 --- /dev/null +++ b/scripts/semantic-release-config.cjs @@ -0,0 +1,176 @@ +const releaseRules = [ + { breaking: true, release: 'minor' }, + { revert: true, release: 'patch' }, + { type: 'feat', release: 'minor' }, + { type: 'feature', release: 'minor' }, + { type: 'enhancement', release: 'minor' }, + { type: 'fix', release: 'patch' }, + { type: 'bug', release: 'patch' }, + { type: 'bugfix', release: 'patch' }, + { type: 'patch', release: 'patch' }, + { type: 'perf', release: 'patch' }, + { type: 'performance', release: 'patch' }, + { type: 'optimization', release: 'patch' }, + { type: 'security', release: 'patch' }, + { type: 'vulnerability', release: 'patch' }, + { type: 'deps', release: 'patch' }, + { type: 'dependencies', release: 'patch' }, + { type: 'upgrade', release: 'patch' }, + { type: 'update', release: 'patch' }, + { type: 'style', release: 'patch' }, + { type: 'refactor', release: 'patch' }, + { type: 'refactoring', release: 'patch' }, + { type: 'cleanup', release: 'patch' }, + { type: 'test', release: 'patch' }, + { type: 'tests', release: 'patch' }, + { type: 'testing', release: 'patch' }, + { type: 'build', release: 'patch' }, + { type: 'ci', release: 'patch' }, + { type: 'cd', release: 'patch' }, + { type: 'config', release: 'patch' }, + { type: 'workflow', release: 'patch' }, + { type: 'pipeline', release: 'patch' }, + { type: 'chore', release: 'patch' }, + { type: 'docs', release: 'patch' }, + { type: 'documentation', release: 'patch' }, + { type: 'breaking', release: 'minor' }, + { type: 'breaking-change', release: 'minor' }, + { type: 'major', release: 'minor' }, +]; + +const releaseNoteTypes = [ + { type: 'feat', section: 'Features', hidden: false }, + { type: 'feature', section: 'Features', hidden: false }, + { type: 'fix', section: 'Bug Fixes', hidden: false }, + { type: 'bug', section: 'Bug Fixes', hidden: false }, + { type: 'bugfix', section: 'Bug Fixes', hidden: false }, + { type: 'perf', section: 'Performance Improvements', hidden: false }, + { type: 'performance', section: 'Performance Improvements', hidden: false }, + { type: 'optimization', section: 'Performance Improvements', hidden: false }, + { type: 'security', section: 'Security', hidden: false }, + { type: 'vulnerability', section: 'Security', hidden: false }, + { type: 'deps', section: 'Dependencies', hidden: false }, + { type: 'dependencies', section: 'Dependencies', hidden: false }, + { type: 'upgrade', section: 'Dependencies', hidden: false }, + { type: 'update', section: 'Dependencies', hidden: false }, + { type: 'docs', section: 'Documentation', hidden: false }, + { type: 'documentation', section: 'Documentation', hidden: false }, + { type: 'style', section: 'Styling', hidden: false }, + { type: 'refactor', section: 'Code Refactoring', hidden: false }, + { type: 'refactoring', section: 'Code Refactoring', hidden: false }, + { type: 'cleanup', section: 'Code Refactoring', hidden: false }, + { type: 'test', section: 'Tests', hidden: false }, + { type: 'tests', section: 'Tests', hidden: false }, + { type: 'testing', section: 'Tests', hidden: false }, + { type: 'build', section: 'Build System', hidden: false }, + { type: 'ci', section: 'Continuous Integration', hidden: false }, + { type: 'cd', section: 'Continuous Integration', hidden: false }, + { type: 'config', section: 'Configuration', hidden: false }, + { type: 'workflow', section: 'Continuous Integration', hidden: false }, + { type: 'pipeline', section: 'Continuous Integration', hidden: false }, + { type: 'chore', section: 'Other Changes', hidden: false }, + { type: 'breaking', section: 'BREAKING CHANGES', hidden: false }, + { type: 'breaking-change', section: 'BREAKING CHANGES', hidden: false }, + { type: 'major', section: 'BREAKING CHANGES', hidden: false }, +]; + +function currentBranch(env) { + return env.GITHUB_REF_NAME || env.INPUT_BRANCH || 'main'; +} + +function releaseKind(env) { + return env.KTX_RELEASE_KIND || env.INPUT_RELEASE_KIND || 'rc'; +} + +function releaseTag(kind) { + return kind === 'rc' ? 'next' : 'latest'; +} + +function releaseBranches(env = process.env) { + const branch = currentBranch(env); + const kind = releaseKind(env); + + if (kind === 'rc') { + return [{ name: branch, prerelease: 'rc', channel: 'next' }]; + } + + if (kind === 'stable') { + if (branch !== 'main') { + throw new Error(`Stable KTX releases must run from main, got ${branch}`); + } + return ['main']; + } + + throw new Error(`Unsupported KTX_RELEASE_KIND: ${kind}`); +} + +function createReleaseConfig(env = process.env) { + const kind = releaseKind(env); + const tag = releaseTag(kind); + + return { + tagFormat: 'v${version}', + branches: releaseBranches(env), + plugins: [ + [ + '@semantic-release/commit-analyzer', + { + releaseRules, + }, + ], + [ + '@semantic-release/exec', + { + analyzeCommitsCmd: 'node -e "console.log(process.env.FORCE_RELEASE === \'true\' ? \'patch\' : \'\')"', + }, + ], + [ + '@semantic-release/release-notes-generator', + { + preset: 'conventionalcommits', + presetConfig: { + types: releaseNoteTypes, + }, + }, + ], + '@semantic-release/changelog', + [ + '@semantic-release/exec', + { + prepareCmd: [ + `node scripts/update-public-release-version.mjs "\${nextRelease.version}" "${tag}"`, + 'pnpm run artifacts:check', + 'pnpm run release:readiness', + ].join(' && '), + publishCmd: [ + 'pnpm run release:npm-publish -- --publish', + 'pnpm run release:published-smoke', + ].join(' && '), + }, + ], + [ + '@semantic-release/git', + { + assets: ['CHANGELOG.md', 'package.json', 'release-policy.json'], + message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', + }, + ], + [ + '@semantic-release/github', + { + successComment: false, + failComment: false, + failTitle: false, + releasedLabels: false, + }, + ], + ], + }; +} + +module.exports = { + createReleaseConfig, + releaseBranches, + releaseKind, + releaseTag, +}; diff --git a/scripts/semantic-release-config.test.mjs b/scripts/semantic-release-config.test.mjs new file mode 100644 index 00000000..f61d3498 --- /dev/null +++ b/scripts/semantic-release-config.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { describe, it } from 'node:test'; + +const require = createRequire(import.meta.url); +const { createReleaseConfig, releaseBranches, releaseKind, releaseTag } = require('./semantic-release-config.cjs'); + +function releaseExecOptions(config) { + return config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].prepareCmd)[1]; +} + +describe('semantic-release config', () => { + it('configures manual rc releases on the selected branch with next channel', () => { + assert.equal(releaseKind({ KTX_RELEASE_KIND: 'rc' }), 'rc'); + assert.equal(releaseTag('rc'), 'next'); + assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'release-candidate' }), [ + { name: 'release-candidate', prerelease: 'rc', channel: 'next' }, + ]); + + const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'release-candidate' }); + assert.match( + releaseExecOptions(config).prepareCmd, + /update-public-release-version\.mjs "\$\{nextRelease\.version\}" "next"/, + ); + }); + + it('configures stable releases only from main with latest tag', () => { + assert.equal(releaseKind({ KTX_RELEASE_KIND: 'stable' }), 'stable'); + assert.equal(releaseTag('stable'), 'latest'); + assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' }), ['main']); + + const config = createReleaseConfig({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' }); + assert.match( + releaseExecOptions(config).prepareCmd, + /update-public-release-version\.mjs "\$\{nextRelease\.version\}" "latest"/, + ); + }); + + it('rejects stable releases from non-main branches', () => { + assert.throws( + () => releaseBranches({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'feature/release-test' }), + /Stable KTX releases must run from main, got feature\/release-test/, + ); + }); + + it('keeps the force-release patch escape hatch', () => { + const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'main' }); + const analyzeExec = config.plugins.find( + (plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].analyzeCommitsCmd, + ); + assert.match(analyzeExec[1].analyzeCommitsCmd, /FORCE_RELEASE === 'true' \? 'patch' : ''/); + }); +}); diff --git a/scripts/update-public-release-version.mjs b/scripts/update-public-release-version.mjs new file mode 100644 index 00000000..27adbe2d --- /dev/null +++ b/scripts/update-public-release-version.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { + PUBLIC_NPM_PACKAGE_NAME, + assertPublicNpmPackageVersion, + assertPublicNpmReleaseTag, + releasePolicyPath, +} from './public-npm-release-metadata.mjs'; + +function scriptRootDir() { + return resolve(dirname(fileURLToPath(import.meta.url)), '..'); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +async function writeJson(path, value) { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +export async function updatePublicReleaseVersion(rootDir, version, tag) { + const safeVersion = assertPublicNpmPackageVersion(version); + const safeTag = assertPublicNpmReleaseTag(tag); + + const packageJsonPath = join(rootDir, 'package.json'); + const packageJson = await readJson(packageJsonPath); + packageJson.version = safeVersion; + await writeJson(packageJsonPath, packageJson); + + const policyPath = releasePolicyPath(rootDir); + const policy = await readJson(policyPath); + policy.publicNpmPackageVersion = safeVersion; + policy.releaseMode = 'npm-public-release-ready'; + policy.requiredBeforePublishing = []; + policy.npm = { + ...policy.npm, + publish: true, + registry: policy.npm?.registry ?? null, + access: 'public', + tag: safeTag, + packages: [PUBLIC_NPM_PACKAGE_NAME], + }; + policy.publishedPackageSmoke = { + ...policy.publishedPackageSmoke, + packageName: PUBLIC_NPM_PACKAGE_NAME, + version: safeVersion, + }; + await writeJson(policyPath, policy); + + return { + version: safeVersion, + tag: safeTag, + }; +} + +async function main() { + const [version, tag] = process.argv.slice(2); + if (!version || !tag) { + throw new Error('Usage: node scripts/update-public-release-version.mjs '); + } + + const result = await updatePublicReleaseVersion(scriptRootDir(), version, tag); + process.stdout.write(`Updated ${PUBLIC_NPM_PACKAGE_NAME} release metadata to ${result.version} (${result.tag})\n`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + await main(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/update-public-release-version.test.mjs b/scripts/update-public-release-version.test.mjs new file mode 100644 index 00000000..1bf68eee --- /dev/null +++ b/scripts/update-public-release-version.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +import { updatePublicReleaseVersion } from './update-public-release-version.mjs'; + +async function writeJson(path, value) { + await mkdir(join(path, '..'), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +async function writeReleaseFixture(root) { + await writeJson(join(root, 'package.json'), { + name: 'ktx-workspace', + version: '0.0.0-private', + private: true, + }); + await writeJson(join(root, 'release-policy.json'), { + schemaVersion: 1, + publicNpmPackageVersion: '0.1.0-rc.1', + releaseMode: 'ci-artifact-only', + npm: { + publish: false, + registry: null, + access: 'public', + tag: 'next', + packages: ['@kaelio/ktx'], + }, + python: { + publish: false, + repository: null, + packages: ['kaelio-ktx'], + }, + publishedPackageSmoke: { + packageName: '@kaelio/ktx', + version: '0.1.0-rc.1', + registry: null, + }, + runtimeInstaller: { + uvStrategy: 'path-prerequisite', + bootstrapUv: false, + missingUvBehavior: 'focused-error', + }, + requiredBeforePublishing: ['Choose public release version.'], + }); +} + +describe('updatePublicReleaseVersion', () => { + it('updates package and release policy metadata for rc releases', async () => { + const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-test-')); + try { + await writeReleaseFixture(root); + + await updatePublicReleaseVersion(root, '0.1.0-rc.2', 'next'); + + assert.equal((await readJson(join(root, 'package.json'))).version, '0.1.0-rc.2'); + assert.deepEqual(await readJson(join(root, 'release-policy.json')), { + schemaVersion: 1, + publicNpmPackageVersion: '0.1.0-rc.2', + releaseMode: 'npm-public-release-ready', + npm: { + publish: true, + registry: null, + access: 'public', + tag: 'next', + packages: ['@kaelio/ktx'], + }, + python: { + publish: false, + repository: null, + packages: ['kaelio-ktx'], + }, + publishedPackageSmoke: { + packageName: '@kaelio/ktx', + version: '0.1.0-rc.2', + registry: null, + }, + runtimeInstaller: { + uvStrategy: 'path-prerequisite', + bootstrapUv: false, + missingUvBehavior: 'focused-error', + }, + requiredBeforePublishing: [], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('rejects invalid versions and tags', async () => { + const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-invalid-test-')); + try { + await writeReleaseFixture(root); + + await assert.rejects(() => updatePublicReleaseVersion(root, 'not a version', 'next'), /Invalid public npm package version/); + await assert.rejects(() => updatePublicReleaseVersion(root, '0.2.0', 'canary'), /Invalid public npm release tag/); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +});