diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 510d39fb..31bc995b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,14 +88,14 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-eval - ref: 76969e551d54c7898f11253175e0da9652e0c4a8 # @tangle-network/agent-eval@0.146.0 + ref: e07a6a4772424107e9831bd9e05512e52134e33b # @tangle-network/agent-eval@0.147.0 path: .cohort/agent-eval persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-knowledge - ref: 9a1079dd2727f5cfbf8d36a0c4e4a2e4efd21bed # @tangle-network/agent-knowledge@8.0.6 + ref: db9b0789ea0f780588b0f84dd331f3e9caf18d5d # @tangle-network/agent-knowledge@8.0.8 path: .cohort/agent-knowledge persist-credentials: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e1ff1f26..3847f968 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -143,7 +143,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-eval - ref: 76969e551d54c7898f11253175e0da9652e0c4a8 # @tangle-network/agent-eval@0.146.0 + ref: e07a6a4772424107e9831bd9e05512e52134e33b # @tangle-network/agent-eval@0.147.0 path: .cohort/agent-eval persist-credentials: false @@ -151,7 +151,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-knowledge - ref: 9a1079dd2727f5cfbf8d36a0c4e4a2e4efd21bed # @tangle-network/agent-knowledge@8.0.6 + ref: db9b0789ea0f780588b0f84dd331f3e9caf18d5d # @tangle-network/agent-knowledge@8.0.8 path: .cohort/agent-knowledge persist-credentials: false diff --git a/bench/package.json b/bench/package.json index bb4497f8..60cbce2d 100644 --- a/bench/package.json +++ b/bench/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-bench", - "version": "0.8.13", + "version": "0.8.14", "type": "module", "description": "Benchmark adapters and execution for agent-runtime across coding, tool-use, RAG, memory, browser, and terminal tasks.", "repository": { @@ -37,7 +37,7 @@ "typecheck:public": "tsc -p tsconfig.public.json", "verify:package": "pnpm run verify:package:static && node scripts/verify-packed-consumer.mjs", "verify:package:local-runtime": "pnpm run verify:package:static && node scripts/verify-packed-consumer.mjs --local-runtime", - "verify:package:static": "pnpm run build && publint && attw --pack --profile esm-only .", + "verify:package:static": "pnpm run build && publint && attw --pack --profile esm-only . && node ../scripts/check-published-ranges.mjs bench", "wait:published-dependencies": "node scripts/wait-for-published-dependencies.mjs", "verify:pier": "tsx scripts/verify-pier-pair.mts" }, @@ -45,7 +45,7 @@ "@tangle-network/agent-eval": "catalog:", "@tangle-network/agent-interface": "catalog:", "@tangle-network/agent-knowledge": "catalog:", - "@tangle-network/agent-runtime": "workspace:*", + "@tangle-network/agent-runtime": "workspace:^", "@tangle-network/sandbox": "catalog:" }, "devDependencies": { diff --git a/docs/STABILITY.md b/docs/STABILITY.md index b15fa581..f8e0371f 100644 --- a/docs/STABILITY.md +++ b/docs/STABILITY.md @@ -38,3 +38,19 @@ Promotion is a normal PR: flip the tags (per-symbol and module-level), add the C A `@stable` symbol is demoted back to `@experimental`, or removed, only through a deprecation cycle: the release that announces it adds `@deprecated` (naming the replacement or the reason) while the symbol keeps working, the CHANGELOG entry names the symbol and the migration path, and removal lands no earlier than the next minor release after the announcement. An `@experimental` symbol needs none of that — it can be reshaped or removed in any release with a CHANGELOG line — which is exactly why the default is experimental and the stable set is enumerated, not implied. + +## What this package declares about its first-party dependencies + +Every `@tangle-network/*` specifier this package publishes is a **range**, never one exact version. +An exact pin is not a compatibility statement. +It names one version and refuses every other, so a consumer that already holds a later cohort member installs a **second physical copy** of the pinned package. +Two copies of `@tangle-network/agent-interface` in one tree means two class identities and `instanceof` answering false across the seam. + +The range shape follows the depended-on package's own versioning, the same rule a peer range follows: + +- **From 1.0.0** — a caret, such as `^1.0.0`. A minor is additive there, so one copy holds across later minors. +- **Below 1.0.0** — the narrower window `>=X.Y.Z **GENERATED** from `@tangle-network/agent-runtime@0.139.0` and `@tangle-network/agent-eval@0.146.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. +> **GENERATED** from `@tangle-network/agent-runtime@0.140.0` and `@tangle-network/agent-eval@0.147.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. ## 1. agent-runtime — own public surface diff --git a/docs/canonical-api.md b/docs/canonical-api.md index e6fe769f..bfc65698 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -4,9 +4,9 @@ Generated signatures and the complete export list live in docs/api/. Run pnpm docs:freshness after editing this file. --> -> **Version 0.139.0.** +> **Version 0.140.0.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. -> `agent-eval` must satisfy `>=0.146.0 <0.147.0`. +> `agent-eval` must satisfy `>=0.147.0 <0.148.0`. > `sandbox` must satisfy `>=0.27.1 <0.28.0`. > Portable profile and tool-part types come from `@tangle-network/agent-interface` `^1.0.0`. > diff --git a/package.json b/package.json index a31f431a..a5841e43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.139.0", + "version": "0.140.0", "description": "Shared task-lifecycle skeleton for agents: a recursive loop kernel for chat turns, one-shot tasks, and multi-attempt loops, with trace capture and eval-gated self-improvement. Domain behavior lives in adapters; scoring and ship-gates in @tangle-network/agent-eval.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": { @@ -170,7 +170,7 @@ "license": "MIT", "packageManager": "pnpm@11.17.0", "peerDependencies": { - "@tangle-network/agent-eval": ">=0.146.0 <0.147.0", + "@tangle-network/agent-eval": ">=0.147.0 <0.148.0", "@tangle-network/agent-interface": "^1.0.0", "@tangle-network/sandbox": ">=0.27.1 <0.28.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23f27b83..7cb66563 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,25 +13,25 @@ catalogs: specifier: 1.30.0 version: 1.30.0 '@tangle-network/agent-core': - specifier: 0.9.4 + specifier: '>=0.9.4 <0.10.0' version: 0.9.4 '@tangle-network/agent-eval': - specifier: 0.146.0 - version: 0.146.0 + specifier: '>=0.147.0 <0.148.0' + version: 0.147.0 '@tangle-network/agent-interface': - specifier: 1.0.0 + specifier: ^1.0.0 version: 1.0.0 '@tangle-network/agent-knowledge': - specifier: 8.0.6 - version: 8.0.6 + specifier: ^8.0.8 + version: 8.0.8 '@tangle-network/agent-profile-materialize': - specifier: 0.16.0 + specifier: '>=0.16.0 <0.17.0' version: 0.16.0 '@tangle-network/agent-trace-contract': specifier: ^1.0.2 version: 1.0.2 '@tangle-network/sandbox': - specifier: 0.27.1 + specifier: '>=0.27.1 <0.28.0' version: 0.27.1 '@types/node': specifier: 26.1.1 @@ -58,7 +58,7 @@ importers: version: 0.9.4(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 8.0.6(@tangle-network/agent-eval@0.146.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@1.0.0) + version: 8.0.8(@tangle-network/agent-eval@0.147.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@1.0.0) '@tangle-network/agent-profile-materialize': specifier: 'catalog:' version: 0.16.0(@tangle-network/agent-interface@1.0.0) @@ -80,7 +80,7 @@ importers: version: 1.30.0(supports-color@10.2.2)(zod@4.4.3) '@tangle-network/agent-eval': specifier: 'catalog:' - version: 0.146.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.147.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': specifier: 'catalog:' version: 1.0.0 @@ -128,15 +128,15 @@ importers: dependencies: '@tangle-network/agent-eval': specifier: 'catalog:' - version: 0.146.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + version: 0.147.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': specifier: 'catalog:' version: 1.0.0 '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 8.0.6(@tangle-network/agent-eval@0.146.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@1.0.0) + version: 8.0.8(@tangle-network/agent-eval@0.147.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@1.0.0) '@tangle-network/agent-runtime': - specifier: workspace:* + specifier: workspace:^ version: link:.. '@tangle-network/sandbox': specifier: 'catalog:' @@ -1132,20 +1132,20 @@ packages: '@modelcontextprotocol/sdk': optional: true - '@tangle-network/agent-eval@0.146.0': - resolution: {integrity: sha512-r/bQTDWMyhghLGJvxyAz3qUpE8Hrm7U5nXWb4NUZFP+zF7+cjS6U0VJN/0vRyuP40/1bDwp+hhucHNxQ/HJb+Q==} + '@tangle-network/agent-eval@0.147.0': + resolution: {integrity: sha512-ToZ1UvEJY1H9WqkhPcOPlDLgaLL1XkEbE8cepCMbKdbbWMKYLcZhj45J6s6Db6XzuKMNre+IPUoOwfXFsdm2KQ==} engines: {node: '>=20'} hasBin: true '@tangle-network/agent-interface@1.0.0': resolution: {integrity: sha512-rhXoscOE1TwkLvHg3tfqdumz++kcDRf7c5GRSTBnJkKLVypwvXB5yQJCB4aTUTrOCJxJMqowm2VPOmlTrUszOQ==} - '@tangle-network/agent-knowledge@8.0.6': - resolution: {integrity: sha512-15ClCc7tb5Hz1Xe0miMbjw9l0iDQ/Th/mZME9AXc2J51BVQhimO+tqMxR1e9j8ijM1hg2Ox/FLfNv0VQWXrikA==} + '@tangle-network/agent-knowledge@8.0.8': + resolution: {integrity: sha512-BAYb2lBRoFuI8Ko54m1p4UtFqAI14rkIxNVatTABosF69cqATZ91ZhkingXFyCOzJhUsKIDxVsITXCyafw8YxQ==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.146.0 <0.147.0' + '@tangle-network/agent-eval': '>=0.147.0 <0.148.0' '@tangle-network/agent-interface': ^1.0.0 '@tangle-network/agent-profile-materialize@0.16.0': @@ -1194,6 +1194,12 @@ packages: '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/tar-stream@3.1.4': resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} @@ -3177,7 +3183,7 @@ snapshots: optionalDependencies: '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) - '@tangle-network/agent-eval@0.146.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': + '@tangle-network/agent-eval@0.147.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3))': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) '@hono/node-server': 2.0.12(hono@4.12.32) @@ -3197,10 +3203,11 @@ snapshots: spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-knowledge@8.0.6(@tangle-network/agent-eval@0.146.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@1.0.0)': + '@tangle-network/agent-knowledge@8.0.8(@tangle-network/agent-eval@0.147.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)))(@tangle-network/agent-interface@1.0.0)': dependencies: - '@tangle-network/agent-eval': 0.146.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) + '@tangle-network/agent-eval': 0.147.0(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.4.3)) '@tangle-network/agent-interface': 1.0.0 + '@types/proper-lockfile': 4.1.4 proper-lockfile: 4.1.2 zod: 4.4.3 @@ -3241,6 +3248,12 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + + '@types/retry@0.12.5': {} + '@types/tar-stream@3.1.4': dependencies: '@types/node': 26.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e4237277..4af9fca3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,14 +18,14 @@ allowBuilds: catalog: '@arethetypeswrong/cli': 0.18.5 '@modelcontextprotocol/sdk': 1.30.0 - '@tangle-network/agent-core': 0.9.4 + '@tangle-network/agent-core': '>=0.9.4 <0.10.0' '@types/node': 26.1.1 - '@tangle-network/agent-eval': 0.146.0 - '@tangle-network/agent-interface': 1.0.0 - '@tangle-network/agent-knowledge': 8.0.6 - '@tangle-network/agent-profile-materialize': 0.16.0 + '@tangle-network/agent-eval': '>=0.147.0 <0.148.0' + '@tangle-network/agent-interface': ^1.0.0 + '@tangle-network/agent-knowledge': ^8.0.8 + '@tangle-network/agent-profile-materialize': '>=0.16.0 <0.17.0' '@tangle-network/agent-trace-contract': ^1.0.2 - '@tangle-network/sandbox': 0.27.1 + '@tangle-network/sandbox': '>=0.27.1 <0.28.0' publint: 0.3.22 tsdown: 0.22.14 tsx: 4.23.1 diff --git a/scripts/check-published-ranges.mjs b/scripts/check-published-ranges.mjs new file mode 100644 index 00000000..1588294a --- /dev/null +++ b/scripts/check-published-ranges.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * Fail a workspace package that would publish an exact first-party version pin. + * + * An exact pin is not a compatibility statement. It names one version and + * refuses every other, so a consumer that already holds a later cohort member + * installs a SECOND physical copy of the pinned package. Two copies of + * `@tangle-network/agent-interface` in one tree means two class identities, two + * module registries, and `instanceof` answering false across the seam. + * + * The pin is rarely written by hand. A `catalog:` entry and a `workspace:*` + * specifier are both replaced by an exact version when the package is packed, + * so the source manifest looks clean and only the PACKED manifest carries the + * defect. This check therefore packs and reads the archive, never the source. + * + * Usage: node scripts/check-published-ranges.mjs [package-directory ...] + * Defaults to every publishable package in the workspace. + */ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parse as parseYaml } from 'yaml' +import { + assertFirstPartyRangeSpecs, + assertPublishableDependencySpecs, +} from './lib/packed-package-test.mjs' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +function workspacePackageDirectories() { + const workspacePath = join(repoRoot, 'pnpm-workspace.yaml') + const directories = [repoRoot] + if (!existsSync(workspacePath)) return directories + const workspace = parseYaml(readFileSync(workspacePath, 'utf8')) + for (const entry of workspace?.packages ?? []) { + if (typeof entry !== 'string' || entry.includes('*')) { + throw new Error(`pnpm-workspace.yaml entry is not a plain directory: ${String(entry)}`) + } + directories.push(resolve(repoRoot, entry)) + } + return directories +} + +function packedManifest(packageDirectory) { + const scratch = mkdtempSync(join(tmpdir(), 'agent-runtime-published-ranges-')) + try { + execFileSync('pnpm', ['pack', '--pack-destination', scratch], { + cwd: packageDirectory, + env: { ...process.env, npm_config_ignore_scripts: 'true' }, + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 10 * 1024 * 1024, + }) + const archives = readdirSync(scratch).filter((name) => name.endsWith('.tgz')) + if (archives.length !== 1) { + throw new Error( + `${packageDirectory} produced ${archives.length} archives, expected exactly one`, + ) + } + return JSON.parse( + execFileSync('tar', ['-xOzf', join(scratch, archives[0]), 'package/package.json'], { + encoding: 'utf8', + maxBuffer: 4 * 1024 * 1024, + }), + ) + } finally { + rmSync(scratch, { recursive: true, force: true }) + } +} + +const requested = process.argv.slice(2).map((entry) => resolve(repoRoot, entry)) +const directories = requested.length > 0 ? requested : workspacePackageDirectories() +const failures = [] + +for (const directory of directories) { + const manifestPath = join(directory, 'package.json') + if (!existsSync(manifestPath)) throw new Error(`no package.json at ${directory}`) + const source = JSON.parse(readFileSync(manifestPath, 'utf8')) + if (source.private === true) continue + const manifest = packedManifest(directory) + try { + assertPublishableDependencySpecs(manifest) + assertFirstPartyRangeSpecs(manifest) + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)) + continue + } + const firstParty = Object.entries({ + ...(manifest.dependencies ?? {}), + ...(manifest.peerDependencies ?? {}), + }) + .filter(([name]) => name.startsWith('@tangle-network/')) + .map(([name, spec]) => `${name}@${spec}`) + .sort() + process.stdout.write( + `${manifest.name}@${manifest.version} packed from ${relative(repoRoot, directory) || '.'}: ${ + firstParty.length > 0 ? firstParty.join(', ') : 'no first-party dependencies' + }\n`, + ) +} + +if (failures.length > 0) { + process.stderr.write(`${failures.join('\n\n')}\n`) + process.exitCode = 1 +} diff --git a/scripts/lib/packed-package-test.mjs b/scripts/lib/packed-package-test.mjs index efb2511f..2c3ad88d 100644 --- a/scripts/lib/packed-package-test.mjs +++ b/scripts/lib/packed-package-test.mjs @@ -65,9 +65,72 @@ export function caretAdmits(range, version) { return minor * 1_000_000 + patch >= floorMinor * 1_000_000 + floorPatch } +const exactVersion = /^\d+\.\d+\.\d+(?:[-+].*)?$/ + +/** True when a specifier names one version and admits no other. */ +export function isExactVersionSpec(spec) { + return typeof spec === 'string' && exactVersion.test(spec.trim()) +} + +/** + * The cohort range a specifier states. + * + * A range states itself. An exact version states no range at all, so it is read + * as the range its own versioning earns — the shape `expectedPeerRange` returns. + */ +export function cohortRange(spec) { + if (typeof spec !== 'string' || spec.length === 0) { + throw new Error(`cannot read a cohort range from ${String(spec)}`) + } + return isExactVersionSpec(spec) ? expectedPeerRange(spec.trim()) : spec +} + +/** A `>=floor =(\d+)\.(\d+)\.(\d+)\s+<(\d+)\.(\d+)\.(\d+)$/.exec(range) + const found = /^(\d+)\.(\d+)\.(\d+)/.exec(version) + if (window === null || found === null) return false + const parts = window.slice(1).map(Number) + const order = ([major, minor, patch]) => + major * 1_000_000_000_000 + minor * 1_000_000 + patch + const target = order(found.slice(1).map(Number)) + return target >= order(parts.slice(0, 3)) && target < order(parts.slice(3)) +} + +/** A range admits a version through either supported cohort shape. */ +export function rangeAdmits(range, version) { + return caretAdmits(range, version) || windowAdmits(range, version) +} + +/** + * The published first-party specifiers a consumer resolves against. + * + * An exact version here forces a second physical copy of the named package for + * every consumer that already holds a later one, so every first-party + * specifier a consumer can read must be a range. + */ +export function assertFirstPartyRangeSpecs(packageJson, scope = '@tangle-network/') { + const packageName = + typeof packageJson.name === 'string' ? packageJson.name : 'packed package' + const exact = [] + for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + for (const [name, spec] of Object.entries(packageJson[section] ?? {})) { + if (!name.startsWith(scope)) continue + if (isExactVersionSpec(spec)) exact.push(`${section}.${name} = ${spec}`) + } + } + if (exact.length > 0) { + throw new Error( + `${packageName} publishes exact first-party version pins, which duplicate the package for every consumer already holding a later one:\n${exact + .map((entry) => ` ${entry}`) + .join('\n')}\nDeclare a range instead: a caret from 1.0.0, or ">=X.Y.Z { + it('reads a bare version as exact', () => { + expect(isExactVersionSpec('1.0.0')).toBe(true) + expect(isExactVersionSpec('0.145.21')).toBe(true) + expect(isExactVersionSpec('1.0.0-rc.1')).toBe(true) + }) + + it('reads every range shape as not exact', () => { + expect(isExactVersionSpec('^1.0.0')).toBe(false) + expect(isExactVersionSpec('>=0.27.1 <0.28.0')).toBe(false) + expect(isExactVersionSpec('~1.0.0')).toBe(false) + expect(isExactVersionSpec('catalog:')).toBe(false) + expect(isExactVersionSpec('workspace:^')).toBe(false) + }) +}) + +describe('cohortRange', () => { + it('returns a range unchanged', () => { + expect(cohortRange('^1.0.0')).toBe('^1.0.0') + expect(cohortRange('>=0.27.1 <0.28.0')).toBe('>=0.27.1 <0.28.0') + }) + + it('derives the range an exact version earns', () => { + expect(cohortRange('1.2.3')).toBe('^1.2.3') + expect(cohortRange('0.27.1')).toBe('>=0.27.1 <0.28.0') + }) +}) + +describe('rangeAdmits', () => { + it('admits inside a caret range and refuses the next major', () => { + expect(rangeAdmits('^1.0.0', '1.4.2')).toBe(true) + expect(rangeAdmits('^1.2.0', '1.1.9')).toBe(false) + expect(rangeAdmits('^1.0.0', '2.0.0')).toBe(false) + }) + + it('admits inside a minor window and refuses the next minor', () => { + expect(rangeAdmits('>=0.145.21 <0.146.0', '0.145.22')).toBe(true) + expect(rangeAdmits('>=0.145.21 <0.146.0', '0.145.20')).toBe(false) + expect(rangeAdmits('>=0.145.21 <0.146.0', '0.146.0')).toBe(false) + }) + + it('refuses an exact specifier, which states no range', () => { + expect(rangeAdmits('0.145.21', '0.145.21')).toBe(false) + }) +}) + +describe('assertFirstPartyRangeSpecs', () => { + it('accepts a manifest whose first-party specifiers are all ranges', () => { + expect(() => + assertFirstPartyRangeSpecs({ + name: '@tangle-network/agent-bench', + dependencies: { + '@tangle-network/agent-interface': '^1.0.0', + '@tangle-network/agent-eval': '>=0.145.21 <0.146.0', + 'tar-stream': '3.2.0', + }, + peerDependencies: { '@tangle-network/sandbox': '>=0.27.1 <0.28.0' }, + }), + ).not.toThrow() + }) + + it('names every exact first-party pin it refuses', () => { + let message = '' + try { + assertFirstPartyRangeSpecs({ + name: '@tangle-network/agent-bench', + dependencies: { + '@tangle-network/agent-interface': '1.0.0', + '@tangle-network/agent-runtime': '0.137.0', + }, + peerDependencies: { '@tangle-network/sandbox': '^0.27.1' }, + }) + } catch (error) { + message = error instanceof Error ? error.message : String(error) + } + expect(message).toContain('dependencies.@tangle-network/agent-interface = 1.0.0') + expect(message).toContain('dependencies.@tangle-network/agent-runtime = 0.137.0') + expect(message).not.toContain('sandbox') + }) + + it('ignores a third-party exact pin, which duplicates nothing first-party', () => { + expect(() => + assertFirstPartyRangeSpecs({ + name: '@tangle-network/agent-runtime', + dependencies: { 'tar-stream': '3.2.0' }, + }), + ).not.toThrow() + }) +}) diff --git a/scripts/verify-official-optimizers.mjs b/scripts/verify-official-optimizers.mjs index 54ef5173..1ca90a76 100644 --- a/scripts/verify-official-optimizers.mjs +++ b/scripts/verify-official-optimizers.mjs @@ -11,9 +11,11 @@ import { import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { parse as parseYaml } from 'yaml' import { assertPeerMatchesDevelopmentDependency, - expectedPeerRange, + cohortRange, + rangeAdmits, requiredPackedDevelopmentDependency, requiredPackedPackageVersion, } from './lib/packed-package-test.mjs' @@ -33,6 +35,8 @@ const workspaceAgentKnowledgeVersion = installedPackageVersion( '@tangle-network/agent-knowledge', ) const workspaceSandboxVersion = installedPackageVersion(repoRoot, '@tangle-network/sandbox') +const workspaceCatalog = + parseYaml(readFileSync(join(repoRoot, 'pnpm-workspace.yaml'), 'utf8'))?.catalog ?? {} const agentEvalVersion = workspaceAgentEvalVersion const officialOptimizerEnv = { AGENT_EVAL_EXPECTED_BRIDGE_VERSION: agentEvalVersion, @@ -42,21 +46,12 @@ const officialOptimizerEnv = { } const tempRoot = mkdtempSync(join(tmpdir(), 'agent-runtime-official-')) -assertVersion( - packageJson.peerDependencies?.['@tangle-network/agent-eval'], - expectedPeerRange(agentEvalVersion), - '@tangle-network/agent-eval peer dependency', -) -assertVersion( - packageJson.peerDependencies?.['@tangle-network/agent-interface'], - expectedPeerRange(workspaceAgentInterfaceVersion), - '@tangle-network/agent-interface peer dependency', -) -assertVersion( - packageJson.peerDependencies?.['@tangle-network/sandbox'], - expectedPeerRange(workspaceSandboxVersion), - '@tangle-network/sandbox peer dependency', -) +// The catalog states the cohort range once. A peer range repeats it, and the +// installed version must be one the range admits. +assertCohortRange('@tangle-network/agent-eval', agentEvalVersion) +assertCohortRange('@tangle-network/agent-interface', workspaceAgentInterfaceVersion) +assertCohortRange('@tangle-network/sandbox', workspaceSandboxVersion) +assertCatalogAdmits('@tangle-network/agent-knowledge', workspaceAgentKnowledgeVersion) try { run('pnpm', ['build'], repoRoot) @@ -106,22 +101,22 @@ try { ) assertVersion( packedAgentEvalVersion, - workspaceAgentEvalVersion, + catalogRange('@tangle-network/agent-eval'), 'packed @tangle-network/agent-eval development dependency', ) assertVersion( packedAgentInterfaceVersion, - workspaceAgentInterfaceVersion, + catalogRange('@tangle-network/agent-interface'), 'packed @tangle-network/agent-interface development dependency', ) assertVersion( packedSandboxVersion, - workspaceSandboxVersion, + catalogRange('@tangle-network/sandbox'), 'packed @tangle-network/sandbox development dependency', ) assertVersion( requiredPackedDependency(packedPackageJson, '@tangle-network/agent-knowledge'), - workspaceAgentKnowledgeVersion, + catalogRange('@tangle-network/agent-knowledge'), 'packed @tangle-network/agent-knowledge dependency', ) writeFileSync( @@ -163,10 +158,18 @@ try { ) assertInstalledVersion(appDir, '@tangle-network/agent-runtime', packageJson.version) - assertInstalledVersion(appDir, '@tangle-network/agent-eval', packedAgentEvalVersion) - assertInstalledVersion(appDir, '@tangle-network/agent-interface', packedAgentInterfaceVersion) - assertInstalledVersion(appDir, '@tangle-network/sandbox', packedSandboxVersion) - assertInstalledVersion(appDir, '@tangle-network/agent-knowledge', workspaceAgentKnowledgeVersion) + assertInstalledAdmitted(appDir, '@tangle-network/agent-eval', packedAgentEvalVersion) + assertInstalledAdmitted( + appDir, + '@tangle-network/agent-interface', + packedAgentInterfaceVersion, + ) + assertInstalledAdmitted(appDir, '@tangle-network/sandbox', packedSandboxVersion) + assertInstalledAdmitted( + appDir, + '@tangle-network/agent-knowledge', + catalogRange('@tangle-network/agent-knowledge'), + ) const installedKnowledge = readJson( join(appDir, 'node_modules', '@tangle-network', 'agent-knowledge', 'package.json'), ) @@ -298,6 +301,39 @@ function assertInstalledVersion(appDir, packageName, expected) { assertVersion(actual, expected, `installed ${packageName}`) } +function assertInstalledAdmitted(appDir, packageName, range) { + const actual = installedPackageVersion(appDir, packageName) + if (!rangeAdmits(range, actual)) { + throw new Error(`installed ${packageName}@${actual} is outside its declared range ${range}`) + } +} + +function catalogRange(packageName) { + const spec = workspaceCatalog[packageName] + if (typeof spec !== 'string' || spec.length === 0) { + throw new Error(`pnpm-workspace.yaml has no catalog entry for ${packageName}`) + } + return cohortRange(spec) +} + +function assertCohortRange(packageName, installedVersion) { + assertVersion( + packageJson.peerDependencies?.[packageName], + catalogRange(packageName), + `${packageName} peer dependency range`, + ) + assertCatalogAdmits(packageName, installedVersion) +} + +function assertCatalogAdmits(packageName, installedVersion) { + const range = catalogRange(packageName) + if (!rangeAdmits(range, installedVersion)) { + throw new Error( + `installed ${packageName}@${installedVersion} is outside the catalog range ${range}`, + ) + } +} + function installedPackageVersion(root, packageName) { const packageJson = readJson(join(root, 'node_modules', ...packageName.split('/'), 'package.json')) if (typeof packageJson.version !== 'string' || packageJson.version.length === 0) { diff --git a/scripts/verify-package-exports.mjs b/scripts/verify-package-exports.mjs index 9ee737b7..ecc2a9f7 100644 --- a/scripts/verify-package-exports.mjs +++ b/scripts/verify-package-exports.mjs @@ -15,6 +15,8 @@ import { assertPeerMatchesDevelopmentDependency, assertPublishableDependencySpecs, createStrictNodeConsumerTsconfig, + isExactVersionSpec, + rangeAdmits, requiredPackedDevelopmentDependency, requiredPackedPackageVersion, } from './lib/packed-package-test.mjs' @@ -822,10 +824,13 @@ function assertSingleFirstPartyPackageVersion(scopeDir, packageName, expectedVer if (installations.length === 0) { throw new Error(`packed consumer did not install ${packageName}`) } - if ( - installations.length !== 1 || - installations[0].packageJson.version !== expectedVersion - ) { + // A packed catalog specifier is a range below 1.0, so the installed copy is + // proven by admission; only an exact specifier names one version to match. + const installedVersion = installations[0].packageJson.version + const admits = isExactVersionSpec(expectedVersion) + ? installedVersion === expectedVersion + : rangeAdmits(expectedVersion, installedVersion) + if (installations.length !== 1 || !admits) { throw new Error( [ `packed consumer must load exactly one ${packageName}@${expectedVersion}; found ${installations.length} installed path(s)`, diff --git a/scripts/verify-packed-cohort.mjs b/scripts/verify-packed-cohort.mjs index 2bdeed07..80693f32 100644 --- a/scripts/verify-packed-cohort.mjs +++ b/scripts/verify-packed-cohort.mjs @@ -19,10 +19,11 @@ import { basename, delimiter, dirname, join, relative, resolve, sep } from 'node import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' import { + assertFirstPartyRangeSpecs, assertPeerMatchesDevelopmentDependency, - caretAdmits, assertPublishableDependencySpecs, createStrictNodeConsumerTsconfig, + rangeAdmits, requiredPackedDevelopmentDependency, } from './lib/packed-package-test.mjs' @@ -299,6 +300,7 @@ function buildAndPack({ ) } assertPublishableDependencySpecs(packedPackageJson) + assertFirstPartyRangeSpecs(packedPackageJson) return { name: packageName, @@ -599,12 +601,12 @@ function assertCohortPackageContracts({ } function assertExactDependency(owner, dependency) { - // A dependency that states a compatibility promise from 1.0.0 is declared as - // a caret range, so the check is admission rather than equality. The packed - // install below still proves the tree resolves one physical copy. + // A first-party dependency is declared as a range, so the check is admission + // rather than equality. The packed install below still proves the tree + // resolves one physical copy. const declared = owner.packageJson.dependencies?.[dependency.name] if (declared === dependency.version) return - if (caretAdmits(declared, dependency.version)) return + if (rangeAdmits(declared, dependency.version)) return throw new Error( `${owner.name} requires ${dependency.name}@${declared}, packed ${dependency.version}`, ) diff --git a/src/testing/fixtures/agent-improvement-proposal.json b/src/testing/fixtures/agent-improvement-proposal.json index 3b430b8e..760a72bd 100644 --- a/src/testing/fixtures/agent-improvement-proposal.json +++ b/src/testing/fixtures/agent-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt"], - "digest": "sha256:8681ef365ff523163b585e6bec22b7e6b0de652647675763d97da8ecb0c423b4", + "digest": "sha256:4bf3e675e8862056f6f348c60228a75e9569b64041738f93dc35d17fd5fecd15", "evaluation": { "decision": { "contributingChecks": [ @@ -4882,7 +4882,7 @@ ], "metadata": { "fixture": "agent-improvement-proposal", - "runtimeVersion": "0.139.0" + "runtimeVersion": "0.140.0" }, "objectives": [ { @@ -4993,8 +4993,8 @@ "baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09", "candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693", "kind": "agent-eval-loop", - "recordDigest": "sha256:b5fc7a2d1c5ed0a168afdb6393ab9aa7090da6db00f11278f2ab39766963aa97", - "runId": "agent-runtime-0.139.0-proposal-fixture", + "recordDigest": "sha256:74084a3450b84ff752aa829eb85cb02460a12950ea030a73996449e5aecf048e", + "runId": "agent-runtime-0.140.0-proposal-fixture", "schema": "agent-candidate-experiment" } }, @@ -5021,5 +5021,5 @@ ], "kind": "agent-improvement-proposal", "proposedAt": "2026-07-10T01:00:00.000Z", - "runId": "agent-runtime-0.139.0-proposal-fixture" + "runId": "agent-runtime-0.140.0-proposal-fixture" } diff --git a/src/testing/fixtures/agent-profile-improvement-proposal.json b/src/testing/fixtures/agent-profile-improvement-proposal.json index 88722142..7b4a6931 100644 --- a/src/testing/fixtures/agent-profile-improvement-proposal.json +++ b/src/testing/fixtures/agent-profile-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt", "skills"], - "digest": "sha256:35fd03f0e77f5f251219ee6c4e552590d50ba92a222da20b75d2a98cdbb29854", + "digest": "sha256:e406db83e3a7e2c4bedeb0ba3ead467d312abe132f979f16ba9deb4537ddd58f", "evaluation": { "decision": { "contributingChecks": [ @@ -1715,7 +1715,7 @@ ], "metadata": { "fixture": "agent-profile-improvement-proposal", - "runtimeVersion": "0.139.0" + "runtimeVersion": "0.140.0" }, "objectives": [ { @@ -1826,7 +1826,7 @@ "baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704", "candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9", "kind": "agent-eval-loop", - "recordDigest": "sha256:07205e57acf8fbe5927cb53da608ce553093efa116deebbcef1ca117e0bc53c1", + "recordDigest": "sha256:6facdaee084e14e4beb044d0fc5a109f60b71cdd3e845721f7da721adbf096c4", "runId": "profile-improvement-1", "schema": "agent-profile-improvement-experiment" }