ktx/docs/superpowers/plans/2026-05-11-public-npm-release-handoff.md
Andrey Avtomonov 9dad936ac7
feat: npm-managed Python runtime for @kaelio/ktx (#7)
* docs: add npm managed python runtime design

* build: add bundled python runtime wheel builder

* build: make local embedding dependencies optional

* build: bundle python runtime wheel in cli artifacts

* build: track bundled python runtime release artifact

* test: verify bundled python runtime wheel

* docs: add plan for bundled python runtime wheel

* test: cover managed python runtime lifecycle

* feat: add managed python runtime installer

* feat: add runtime command runner

* feat: expose runtime management commands

* test: verify managed python runtime commands

* docs: add plan for managed python runtime installer

* feat: add managed python command helper

* feat: use managed runtime for sl query compute

* feat: route sl query managed runtime policy

* docs: add plan for managed runtime sl query integration

* feat: add managed runtime daemon metadata

* feat: manage python daemon lifecycle

* feat: add runtime daemon start stop commands

* fix: verify managed runtime daemon lifecycle

* docs: add plan for managed runtime daemon lifecycle

* feat: add managed local embeddings config marker

* feat: add managed local embeddings daemon helper

* feat: use managed runtime for local embedding setup

* feat: pass managed runtime policy through setup

* docs: add plan for managed local embeddings runtime

* feat: read CLI package metadata dynamically

* feat: assemble public kaelio ktx npm package

* feat: release one public kaelio ktx npm artifact

* test: cover public kaelio ktx package invocations

* chore: verify public kaelio ktx package artifacts

* docs: add plan for public kaelio ktx npm package

* test: verify managed runtime in public package smoke

* test: finalize managed runtime release smoke

* docs: add plan for managed runtime release smoke

* test: specify local embeddings release smoke

* feat: add local embeddings runtime smoke

* chore: register local embeddings smoke

* fix: verify local embeddings smoke

* fix: restore artifact smoke python env helper

* docs: add plan for managed local embeddings release smoke

* refactor: share managed runtime install policy parsing

* feat: use managed runtime for agent semantic queries

* feat: use managed runtime for MCP semantic compute

* docs: add plan for managed agent and MCP semantic runtime

* feat(cli): add managed daemon HTTP helpers

* feat(cli): route local adapters through managed daemon

* feat(cli): use managed daemon for ingest helpers

* feat(cli): pass managed daemon options to scan

* feat(context): pass MCP ingest pull config options

* feat(cli): pass managed daemon options to serve ingest

* test: verify managed local ingest daemon runtime

* docs: add plan for managed local ingest daemon runtime

* docs: align managed runtime examples

* docs: add plan for managed runtime docs cleanup

* test: cover published package runtime smoke commands

* test: validate published package smoke outputs

* docs: add plan for published package runtime smoke

* build: stamp public npm package version

* release: add npm public release policy

* release: add guarded npm publish script

* release: document public npm release handoff

* docs: add plan for public npm release handoff

* test: cover managed runtime prune in package smoke

* docs: document managed runtime prune

* docs: add plan for managed runtime prune smoke and docs

* chore: encode uv runtime prerequisite policy

* fix: clarify missing uv runtime error

* docs: document uv runtime prerequisite

* docs: add plan for uv runtime prerequisite contract

* refactor: limit release artifacts to public package runtime

* chore: align release policy with bundled runtime wheel

* docs: describe single public runtime artifact surface

* test: verify single public runtime artifact contract

* docs: add plan for single public runtime artifact cleanup

* fix: align local embeddings smoke with public version

* docs: add plan for local embeddings smoke public version

* release: soft-launch as @kaelio/ktx@0.1.0-rc.0 on next tag

Publish target moves to the pre-release version 0.1.0-rc.0 under the next
dist-tag so npm install @kaelio/ktx (which resolves to latest) does not
pick up the soft-launch build. Users opt in via @kaelio/ktx@next.

* Fix release script boundary checks

* Remove PostHog from public package bundle
2026-05-11 15:50:34 +02:00

39 KiB

Public NPM Release Handoff 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: Turn the remaining npm-managed Python runtime release gap into a guarded public @kaelio/ktx npm release handoff for version 0.1.0.

Architecture: Keep one public npm package and keep Python packages unpublished. The public package builder stamps the assembled @kaelio/ktx package as 0.1.0, release readiness accepts a publish-ready policy only when all blocking decisions are encoded, and a new publish script performs a dry-run by default before any live registry publish.

Tech Stack: Node 22 ESM scripts, node:test, pnpm 10 publish, JSON release policy, GitHub Actions workflow validation.


Spec trace and current state

This plan follows docs/superpowers/specs/2026-05-11-npm-managed-python-runtime-design.md.

The existing plan files that reference that spec are:

  • docs/superpowers/plans/2026-05-11-bundled-python-runtime-wheel.md
  • docs/superpowers/plans/2026-05-11-managed-agent-mcp-semantic-runtime.md
  • docs/superpowers/plans/2026-05-11-managed-local-embeddings-release-smoke.md
  • docs/superpowers/plans/2026-05-11-managed-local-embeddings-runtime.md
  • docs/superpowers/plans/2026-05-11-managed-local-ingest-daemon-runtime.md
  • docs/superpowers/plans/2026-05-11-managed-python-runtime-command-integration.md
  • docs/superpowers/plans/2026-05-11-managed-python-runtime-daemon-lifecycle.md
  • docs/superpowers/plans/2026-05-11-managed-python-runtime-installer.md
  • docs/superpowers/plans/2026-05-11-managed-python-runtime-release-smoke.md
  • docs/superpowers/plans/2026-05-11-managed-runtime-docs-and-postgres-smoke-cleanup.md
  • docs/superpowers/plans/2026-05-11-public-kaelio-ktx-npm-package.md
  • docs/superpowers/plans/2026-05-11-published-package-managed-runtime-smoke.md

All twelve are implemented in the current tree: their referenced source and test files exist, and the runtime command, daemon, package artifact, published-package smoke, local-embedding smoke, and README markers are present.

The remaining release gap is explicit in release-policy.json: the repository still uses ci-artifact-only, npm.publish is false, and the README states that registry publishing is disabled. This plan changes that to a guarded handoff for the first public npm release while leaving Python registry publication disabled because the spec says KTX-owned Python code ships inside the npm package as a bundled wheel for this release.

File structure

  • Modify scripts/build-public-npm-package.mjs: make the assembled public npm package version and tarball name 0.1.0 instead of 0.0.0-private.
  • Modify scripts/build-public-npm-package.test.mjs: cover public version stamping and the versioned tarball path.
  • Modify scripts/package-artifacts.mjs: make artifact metadata report @kaelio/ktx as version 0.1.0.
  • Modify scripts/package-artifacts.test.mjs: update artifact manifest, metadata, runtime smoke, and demo smoke expectations for the public tarball.
  • Modify scripts/local-embeddings-runtime-smoke.test.mjs: update public tarball selection coverage for kaelio-ktx-0.1.0.tgz.
  • Modify scripts/release-readiness.mjs: add the npm-public-release-ready release mode and policy validation.
  • Modify scripts/release-readiness.test.mjs: cover the publish-ready policy and validation failures.
  • Modify release-policy.json: encode the first public npm release handoff.
  • Create scripts/publish-public-npm-package.mjs: verify readiness and run pnpm publish in dry-run mode by default.
  • Create scripts/publish-public-npm-package.test.mjs: cover publish command construction and policy gating.
  • Modify package.json: add release:npm-publish.
  • Create .github/workflows/release.yml: add a manual dry-run/live publish workflow for the public npm tarball.
  • Create scripts/release-workflow.test.mjs: validate that the release workflow is manual, uses pnpm, runs readiness checks, and gates live publish.
  • Modify README.md: replace the disabled publishing note with the guarded handoff commands.

Task 1: Stamp public npm artifacts as 0.1.0

Files:

  • Modify: scripts/build-public-npm-package.mjs

  • Modify: scripts/build-public-npm-package.test.mjs

  • Modify: scripts/package-artifacts.mjs

  • Modify: scripts/package-artifacts.test.mjs

  • Modify: scripts/local-embeddings-runtime-smoke.test.mjs

  • Step 1: Write failing public version tests

In scripts/build-public-npm-package.test.mjs, extend the import from ./build-public-npm-package.mjs so it includes PUBLIC_NPM_PACKAGE_VERSION and publicNpmPackageTarballName:

import {
  PUBLIC_BUNDLED_WORKSPACE_PACKAGES,
  PUBLIC_NPM_PACKAGE_NAME,
  PUBLIC_NPM_PACKAGE_VERSION,
  collectPublicDependencies,
  createPublicNpmPackageTree,
  publicNpmPackageJson,
  publicNpmPackageLayout,
  publicNpmPackageTarballName,
  publicNpmPackCommand,
} from './build-public-npm-package.mjs';

Replace the publicNpmPackageLayout test expectation with:

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');
    assert.equal(publicNpmPackageTarballName(), 'kaelio-ktx-0.1.0.tgz');
    assert.equal(layout.tarballPath, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz');
  });
});

In the publicNpmPackageJson test, add this assertion after the package name assertion:

assert.equal(packageJson.version, '0.1.0');

In the publicNpmPackCommand test, replace the tarball assertion block with:

assert.deepEqual(publicNpmPackCommand(layout), {
  command: 'pnpm',
  args: [
    '--config.node-linker=hoisted',
    'pack',
    '--out',
    '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz',
  ],
  cwd: '/repo/ktx/dist/public-npm-package',
});
  • Step 2: Run public package tests to verify failure

Run:

node --test scripts/build-public-npm-package.test.mjs

Expected: FAIL. The failure mentions at least one stale kaelio-ktx-0.0.0-private.tgz or 0.0.0-private public package version expectation.

  • Step 3: Implement public version stamping

In scripts/build-public-npm-package.mjs, replace the current public version constants and layout helper with:

export const PUBLIC_NPM_PACKAGE_NAME = '@kaelio/ktx';
export const PUBLIC_NPM_PACKAGE_VERSION = '0.1.0';

export function publicNpmPackageTarballName(version = PUBLIC_NPM_PACKAGE_VERSION) {
  return `kaelio-ktx-${version}.tgz`;
}

Replace publicNpmPackageLayout with:

export function publicNpmPackageLayout(rootDir = scriptRootDir(), version = PUBLIC_NPM_PACKAGE_VERSION) {
  return {
    rootDir,
    packageVersion: version,
    cliPackageRoot: join(rootDir, 'packages', 'cli'),
    packRoot: join(rootDir, 'dist', 'public-npm-package'),
    npmDir: join(rootDir, 'dist', 'artifacts', 'npm'),
    tarballPath: join(rootDir, 'dist', 'artifacts', 'npm', publicNpmPackageTarballName(version)),
  };
}

Change publicNpmPackageJson so it accepts the public version explicitly:

export function publicNpmPackageJson(cliPackageJson, dependencies, version = PUBLIC_NPM_PACKAGE_VERSION) {
  return {
    name: PUBLIC_NPM_PACKAGE_NAME,
    version,
    description: 'Standalone KTX context layer for database agents',
    private: false,
    type: 'module',
    engines: cliPackageJson.engines ?? { node: '>=22.0.0' },
    bin: { ktx: './dist/bin.js' },
    main: cliPackageJson.main ?? 'dist/index.js',
    types: cliPackageJson.types ?? 'dist/index.d.ts',
    exports: cliPackageJson.exports ?? {
      '.': {
        types: './dist/index.d.ts',
        import: './dist/index.js',
        default: './dist/index.js',
      },
      './package.json': './package.json',
    },
    files: ['dist', 'assets'],
    dependencies,
    bundledDependencies: PUBLIC_BUNDLED_WORKSPACE_PACKAGES,
    license: cliPackageJson.license ?? 'Apache-2.0',
    repository: {
      type: 'git',
      url: 'git+https://github.com/kaelio/ktx.git',
    },
    bugs: {
      url: 'https://github.com/kaelio/ktx/issues',
    },
    homepage: 'https://github.com/kaelio/ktx#readme',
  };
}

In copyCliPackage, pass the layout version:

await writeJson(
  join(layout.packRoot, 'package.json'),
  publicNpmPackageJson(cliPackageJson, dependencies, layout.packageVersion),
);

In createPublicNpmPackageTree, return the versioned package JSON:

return {
  layout,
  packageJson: publicNpmPackageJson(cliPackageJson, dependencies, layout.packageVersion),
  bundledPackages: PUBLIC_BUNDLED_WORKSPACE_PACKAGES,
};
  • Step 4: Run public package tests to verify pass

Run:

node --test scripts/build-public-npm-package.test.mjs

Expected: PASS.

  • Step 5: Write failing artifact metadata tests

In scripts/package-artifacts.test.mjs, replace expectations that use the public npm tarball or package version:

assert.equal(layout.cliTarball, '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz');
{
  ecosystem: 'npm',
  packageName: '@kaelio/ktx',
  packageRoot: 'packages/cli',
  packageVersion: '0.1.0',
  private: false,
  releaseMode: 'ci-artifact-only',
}
{
  ecosystem: 'npm',
  packageName: '@kaelio/ktx',
  packageVersion: '0.1.0',
  path: 'npm/kaelio-ktx-0.1.0.tgz',
  bytes: Buffer.byteLength('@kaelio/ktx-tarball'),
  sha256: createHash('sha256').update('@kaelio/ktx-tarball').digest('hex'),
}

In the runtime smoke source expectation, replace:

requireOutput('ktx public package version', version, /@kaelio\/ktx 0\.1\.0/);

In scripts/local-embeddings-runtime-smoke.test.mjs, replace the public tarball selection assertion with:

assert.equal(
  publicKtxTarballName(['kaelio-ktx-0.1.0.tgz', 'ignore-me.tgz']),
  'kaelio-ktx-0.1.0.tgz',
);
  • Step 6: Run artifact tests to verify failure

Run:

node --test scripts/package-artifacts.test.mjs scripts/local-embeddings-runtime-smoke.test.mjs

Expected: FAIL. The failure mentions stale artifact metadata or tarball expectations for 0.0.0-private.

  • Step 7: Implement artifact metadata versioning

In scripts/package-artifacts.mjs, change the build-public import to:

import {
  PUBLIC_NPM_PACKAGE_NAME,
  PUBLIC_NPM_PACKAGE_VERSION,
  publicNpmPackageTarballName,
} from './build-public-npm-package.mjs';

Replace npmPackageTarballName with:

function npmPackageTarballName(packageName) {
  if (packageName === PUBLIC_NPM_PACKAGE_NAME) {
    return publicNpmPackageTarballName(PUBLIC_NPM_PACKAGE_VERSION);
  }
  return `${packageName.replace('@ktx/', 'ktx-')}-${PACKAGE_VERSION}.tgz`;
}

In readNpmPackageMetadata, return the public package version for @kaelio/ktx:

  const isPublicKtxPackage = packageInfo.name === PUBLIC_NPM_PACKAGE_NAME;
  return releaseMetadataEntry({
    ecosystem: 'npm',
    packageName: packageInfo.name,
    packageRoot: packageInfo.packageRoot,
    packageVersion: isPublicKtxPackage ? PUBLIC_NPM_PACKAGE_VERSION : packageJson.version,
    privatePackage: isPublicKtxPackage ? false : packageJson.private === true,
  });

In npmRuntimeSmokeSource, replace the version output regex with:

requireOutput('ktx public package version', version, /@kaelio\/ktx 0\.1\.0/);
  • Step 8: Run artifact tests to verify pass

Run:

node --test scripts/package-artifacts.test.mjs scripts/local-embeddings-runtime-smoke.test.mjs

Expected: PASS.

  • Step 9: Commit public version stamping

Run:

git add scripts/build-public-npm-package.mjs scripts/build-public-npm-package.test.mjs scripts/package-artifacts.mjs scripts/package-artifacts.test.mjs scripts/local-embeddings-runtime-smoke.test.mjs
git commit -m "build: stamp public npm package version"

Expected: commit created.

Task 2: Add publish-ready release policy validation

Files:

  • Modify: scripts/release-readiness.mjs

  • Modify: scripts/release-readiness.test.mjs

  • Modify: release-policy.json

  • Step 1: Write failing release readiness tests

In scripts/release-readiness.test.mjs, add PUBLIC_NPM_PACKAGE_VERSION to the imports from ./build-public-npm-package.mjs:

import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs';

Update releasePolicy() so the default npm block includes publish settings:

npm: {
  publish: false,
  registry: null,
  access: 'public',
  tag: 'latest',
  packages: ['@kaelio/ktx'],
  ...npmOverrides,
},

In each existing releaseReadinessReport expected object for ci-artifact-only and published-package-smoke-required, add:

npmPublish: null,

Place it after publishedPackageSmokeGate and before blockedPublishingDecisions.

In writeReleaseMetadataInputs, keep internal workspace package versions private. The public package version comes from artifact metadata:

version: '0.0.0-private',
private: true,

Add this test after the existing reports required published package smoke when release mode requires it test:

it('accepts the npm public release ready policy', async () => {
  const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-test-'));
  try {
    await writeReadyFixture(root, {
      policy: releasePolicy({
        releaseMode: 'npm-public-release-ready',
        npm: {
          publish: true,
          registry: null,
          access: 'public',
          tag: 'latest',
        },
        publishedPackageSmoke: {
          packageName: '@kaelio/ktx',
          version: PUBLIC_NPM_PACKAGE_VERSION,
          registry: null,
        },
        requiredBeforePublishing: [],
      }),
    });

    const report = await releaseReadinessReport(root);

    assert.deepEqual(report, {
      schemaVersion: 1,
      releaseMode: 'npm-public-release-ready',
      sourceRevision: 'abc123',
      npmPublishEnabled: true,
      pythonPublishEnabled: false,
      packageNames: ['@kaelio/ktx', 'ktx-sl', 'ktx-daemon', 'kaelio-ktx'],
      publishedPackageSmokeGate: {
        status: 'required',
        script: 'pnpm run release:published-smoke',
        reason: 'Run the published package smoke after the npm package is published.',
        configSource: 'release-policy',
        packageName: '@kaelio/ktx',
        version: PUBLIC_NPM_PACKAGE_VERSION,
        registry: null,
      },
      npmPublish: {
        packageName: '@kaelio/ktx',
        version: PUBLIC_NPM_PACKAGE_VERSION,
        access: 'public',
        tag: 'latest',
        registry: null,
      },
      blockedPublishingDecisions: [],
    });
  } finally {
    await rm(root, { recursive: true, force: true });
  }
});

Add this validation test:

it('rejects npm public release ready mode when npm publish is disabled', async () => {
  const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-disabled-test-'));
  try {
    await writeReadyFixture(root, {
      policy: releasePolicy({
        releaseMode: 'npm-public-release-ready',
        npm: {
          publish: false,
          registry: null,
          access: 'public',
          tag: 'latest',
        },
        publishedPackageSmoke: {
          packageName: '@kaelio/ktx',
          version: PUBLIC_NPM_PACKAGE_VERSION,
          registry: null,
        },
        requiredBeforePublishing: [],
      }),
    });

    await assert.rejects(
      () => releaseReadinessReport(root),
      /npm-public-release-ready policy requires npm.publish true/,
    );
  } finally {
    await rm(root, { recursive: true, force: true });
  }
});

Add this validation test:

it('rejects npm public release ready mode when Python publishing is enabled', async () => {
  const root = await mkdtemp(join(tmpdir(), 'ktx-npm-public-ready-python-test-'));
  try {
    await writeReadyFixture(root, {
      policy: releasePolicy({
        releaseMode: 'npm-public-release-ready',
        npm: {
          publish: true,
          registry: null,
          access: 'public',
          tag: 'latest',
        },
        python: {
          publish: true,
          repository: 'pypi',
        },
        publishedPackageSmoke: {
          packageName: '@kaelio/ktx',
          version: PUBLIC_NPM_PACKAGE_VERSION,
          registry: null,
        },
        requiredBeforePublishing: [],
      }),
    });

    await assert.rejects(
      () => releaseReadinessReport(root),
      /npm-public-release-ready policy keeps python.publish false/,
    );
  } finally {
    await rm(root, { recursive: true, force: true });
  }
});
  • Step 2: Run release readiness tests to verify failure

Run:

node --test scripts/release-readiness.test.mjs

Expected: FAIL with Unsupported release policy releaseMode: npm-public-release-ready or missing npm.access validation.

  • Step 3: Implement publish-ready policy validation

In scripts/release-readiness.mjs, import the public package version:

import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs';

Add the release mode constant and include it in SUPPORTED_RELEASE_MODES:

const NPM_PUBLIC_RELEASE_READY_MODE = 'npm-public-release-ready';
const SUPPORTED_RELEASE_MODES = new Set([
  CI_ARTIFACT_ONLY_RELEASE_MODE,
  PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE,
  NPM_PUBLIC_RELEASE_READY_MODE,
]);

Add string validators for the npm publish settings:

function assertNpmAccess(value) {
  if (value !== 'public') {
    throw new Error('Release policy npm.access must be public');
  }
}

function assertNpmTag(value) {
  assertString(value, 'Release policy npm.tag');
  if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value)) {
    throw new Error(`Invalid Release policy npm.tag: ${value}`);
  }
}

In validateReleasePolicy, validate the new npm fields after assertNullableString(policy.npm.registry, 'Release policy npm.registry');:

  assertNpmAccess(policy.npm.access);
  assertNpmTag(policy.npm.tag);

Replace assertRequiredBeforePublishing with:

function assertRequiredBeforePublishing(policy) {
  assertStringArray(policy.requiredBeforePublishing, 'Release policy requiredBeforePublishing');

  if (policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE && policy.requiredBeforePublishing.length === 0) {
    throw new Error('Release policy requiredBeforePublishing must list the remaining publishing decisions');
  }

  if (
    (policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE ||
      policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) &&
    policy.requiredBeforePublishing.length > 0
  ) {
    throw new Error(`${policy.releaseMode} release mode requires requiredBeforePublishing to be empty`);
  }
}

Replace publishedPackageSmokeGate with:

function publishedPackageSmokeGate(policy) {
  const config = readPublishedPackageSmokeConfig({}, [], policy.publishedPackageSmoke);

  if (
    (policy.releaseMode === PUBLISHED_PACKAGE_SMOKE_REQUIRED_RELEASE_MODE ||
      policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) &&
    !config.enabled
  ) {
    throw new Error(`${policy.releaseMode} release mode requires release-policy.json publishedPackageSmoke.packageName`);
  }

  const base =
    policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE
      ? {
          status: 'not_required',
          reason: 'Published package smoke remains pending until release-policy.json enables npm registry publishing.',
        }
      : policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE
        ? {
            status: 'required',
            reason: 'Run the published package smoke after the npm package is published.',
          }
        : {
            status: 'required',
            reason: 'Run the published package smoke before accepting the hybrid-search release.',
          };

  return {
    ...base,
    script: 'pnpm run release:published-smoke',
    configSource: config.enabled ? config.configSource : null,
    packageName: config.enabled ? config.packageName : null,
    version: config.enabled ? config.packageVersion : policy.publishedPackageSmoke.version,
    registry: config.enabled ? (config.registry ?? null) : policy.publishedPackageSmoke.registry,
  };
}

Add this function below assertNonPublishingArtifactPolicy:

function assertNpmPublicReleaseReadyPolicy(policy, metadata) {
  if (policy.npm.publish !== true) {
    throw new Error('npm-public-release-ready policy requires npm.publish true');
  }
  if (policy.python.publish !== false) {
    throw new Error('npm-public-release-ready policy keeps python.publish false');
  }
  if (policy.python.repository !== null) {
    throw new Error('npm-public-release-ready policy keeps python.repository null');
  }

  assertSameMembers(policy.npm.packages, ['@kaelio/ktx'], 'Release policy npm.packages');
  assertSameMembers(policy.python.packages, metadataNames(metadata, 'python'), 'Release policy python.packages');

  const npmMetadata = metadata.find((entry) => entry.ecosystem === 'npm' && entry.packageName === '@kaelio/ktx');
  if (!npmMetadata) {
    throw new Error('npm-public-release-ready policy requires @kaelio/ktx artifact 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) {
    throw new Error(
      `npm-public-release-ready policy expected @kaelio/ktx ${PUBLIC_NPM_PACKAGE_VERSION}, 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}`,
    );
  }
}

Inside assertNonPublishingArtifactPolicy, replace the npm package version suffix check with public-package-aware validation:

      if (isPublicKtxPackage) {
        if (entry.packageVersion !== PUBLIC_NPM_PACKAGE_VERSION) {
          throw new Error(
            `${policyLabel} npm package @kaelio/ktx must use public version ${PUBLIC_NPM_PACKAGE_VERSION}`,
          );
        }
      } else if (!entry.packageVersion.endsWith('-private')) {
        throw new Error(`${policyLabel} npm package ${entry.packageName} must use a private version suffix`);
      }

In releaseReadinessReport, replace the unconditional assertNonPublishingArtifactPolicy(policy, metadata); call with:

  if (policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) {
    assertNpmPublicReleaseReadyPolicy(policy, metadata);
  } else {
    assertNonPublishingArtifactPolicy(policy, metadata);
  }

Add npmPublish to the returned report:

    npmPublish:
      policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE
        ? {
            packageName: '@kaelio/ktx',
            version: PUBLIC_NPM_PACKAGE_VERSION,
            access: policy.npm.access,
            tag: policy.npm.tag,
            registry: policy.npm.registry,
          }
        : null,

Update the text output so it prints the npm publish target when present:

  if (report.npmPublish) {
    process.stdout.write(
      `NPM publish target: ${report.npmPublish.packageName}@${report.npmPublish.version} (${report.npmPublish.tag})\n`,
    );
  } else {
    process.stdout.write('Registry publishing remains disabled by release-policy.json.\n');
  }
  • Step 4: Update release policy

Replace release-policy.json with:

{
  "schemaVersion": 1,
  "releaseMode": "npm-public-release-ready",
  "npm": {
    "publish": true,
    "registry": null,
    "access": "public",
    "tag": "latest",
    "packages": ["@kaelio/ktx"]
  },
  "python": {
    "publish": false,
    "repository": null,
    "packages": ["ktx-sl", "ktx-daemon", "kaelio-ktx"]
  },
  "publishedPackageSmoke": {
    "packageName": "@kaelio/ktx",
    "version": "0.1.0",
    "registry": null
  },
  "requiredBeforePublishing": []
}
  • Step 5: Run release readiness tests to verify pass

Run:

node --test scripts/release-readiness.test.mjs

Expected: PASS.

  • Step 6: Commit release policy validation

Run:

git add scripts/release-readiness.mjs scripts/release-readiness.test.mjs release-policy.json
git commit -m "release: add npm public release policy"

Expected: commit created.

Task 3: Add guarded npm publish script

Files:

  • Create: scripts/publish-public-npm-package.test.mjs

  • Create: scripts/publish-public-npm-package.mjs

  • Modify: package.json

  • Step 1: Write failing publish script tests

Create scripts/publish-public-npm-package.test.mjs with:

import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { describe, it } from 'node:test';

import {
  buildNpmPublishCommand,
  requireNpmPublicReleaseReady,
  resolvePublishMode,
} from './publish-public-npm-package.mjs';

const readyReport = {
  releaseMode: 'npm-public-release-ready',
  npmPublishEnabled: true,
  npmPublish: {
    packageName: '@kaelio/ktx',
    version: '0.1.0',
    access: 'public',
    tag: 'latest',
    registry: null,
  },
};

describe('resolvePublishMode', () => {
  it('dry-runs by default', () => {
    assert.deepEqual(resolvePublishMode([]), { live: false });
  });

  it('requires an explicit flag for live publish', () => {
    assert.deepEqual(resolvePublishMode(['--publish']), { live: true });
  });
});

describe('requireNpmPublicReleaseReady', () => {
  it('accepts the npm public release ready report', () => {
    assert.equal(requireNpmPublicReleaseReady(readyReport), readyReport.npmPublish);
  });

  it('rejects artifact-only reports', () => {
    assert.throws(
      () =>
        requireNpmPublicReleaseReady({
          releaseMode: 'ci-artifact-only',
          npmPublishEnabled: false,
          npmPublish: null,
        }),
      /release-policy.json must use npm-public-release-ready before publishing/,
    );
  });
});

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.tgz', readyReport.npmPublish, { live: false }), {
      command: 'pnpm',
      args: [
        'publish',
        '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz',
        '--access',
        'public',
        '--tag',
        'latest',
        '--dry-run',
      ],
      env: {},
    });
  });

  it('omits dry-run only for explicit live publish', () => {
    assert.deepEqual(buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz', readyReport.npmPublish, { live: true }).args, [
      'publish',
      '/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz',
      '--access',
      'public',
      '--tag',
      'latest',
    ]);
  });

  it('uses npm_config_registry when a registry is configured', () => {
    const publish = {
      ...readyReport.npmPublish,
      registry: 'https://registry.npmjs.org/',
    };

    assert.deepEqual(
      buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0.tgz', publish, { live: false }).env,
      { npm_config_registry: 'https://registry.npmjs.org/' },
    );
  });
});

describe('package script', () => {
  it('registers release:npm-publish', async () => {
    const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));

    assert.equal(packageJson.scripts['release:npm-publish'], 'node scripts/publish-public-npm-package.mjs');
  });
});
  • Step 2: Run publish script tests to verify failure

Run:

node --test scripts/publish-public-npm-package.test.mjs

Expected: FAIL with Cannot find module for scripts/publish-public-npm-package.mjs.

  • Step 3: Implement publish script

Create scripts/publish-public-npm-package.mjs with:

#!/usr/bin/env node

import { execFile } from 'node:child_process';
import { access } from 'node:fs/promises';
import { promisify } from 'node:util';
import { pathToFileURL } from 'node:url';

import { packageArtifactLayout } from './package-artifacts.mjs';
import { releaseReadinessReport } from './release-readiness.mjs';

const execFileAsync = promisify(execFile);

export function resolvePublishMode(args = process.argv.slice(2)) {
  return { live: args.includes('--publish') };
}

export function requireNpmPublicReleaseReady(report) {
  if (report.releaseMode !== 'npm-public-release-ready' || report.npmPublishEnabled !== true || !report.npmPublish) {
    throw new Error('release-policy.json must use npm-public-release-ready before publishing');
  }
  return report.npmPublish;
}

export function buildNpmPublishCommand(tarballPath, publish, mode) {
  return {
    command: 'pnpm',
    args: [
      'publish',
      tarballPath,
      '--access',
      publish.access,
      '--tag',
      publish.tag,
      ...(mode.live ? [] : ['--dry-run']),
    ],
    env: publish.registry ? { npm_config_registry: publish.registry } : {},
  };
}

async function assertFileExists(path) {
  try {
    await access(path);
  } catch {
    throw new Error(`Missing npm tarball: ${path}. Run pnpm run artifacts:check first.`);
  }
}

async function runPublishCommand(command) {
  process.stdout.write(`$ ${command.command} ${command.args.join(' ')}\n`);
  await execFileAsync(command.command, command.args, {
    env: { ...process.env, ...command.env },
    encoding: 'utf8',
    maxBuffer: 1024 * 1024 * 20,
  });
}

export async function publishPublicNpmPackage(options = {}) {
  const rootDir = options.rootDir;
  const mode = options.mode ?? resolvePublishMode(options.args);
  const report = await releaseReadinessReport(rootDir);
  const publish = requireNpmPublicReleaseReady(report);
  const layout = packageArtifactLayout(rootDir);
  const tarballPath = layout.cliTarball;

  await assertFileExists(tarballPath);
  const command = buildNpmPublishCommand(tarballPath, publish, mode);
  await runPublishCommand(command);

  process.stdout.write(
    mode.live
      ? `Published ${publish.packageName}@${publish.version} with tag ${publish.tag}\n`
      : `Dry-run verified ${publish.packageName}@${publish.version} with tag ${publish.tag}\n`,
  );
}

async function main() {
  await publishPublicNpmPackage({ args: process.argv.slice(2) });
}

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;
  }
}
  • Step 4: Add the package script

In root package.json, add this script after release:local-embeddings-smoke:

"release:npm-publish": "node scripts/publish-public-npm-package.mjs",
  • Step 5: Run publish script tests to verify pass

Run:

node --test scripts/publish-public-npm-package.test.mjs

Expected: PASS.

  • Step 6: Run a dry-run publish after artifacts are built

Run:

pnpm run artifacts:check
pnpm run release:npm-publish

Expected: PASS. The publish command includes --dry-run, and the final line is:

Dry-run verified @kaelio/ktx@0.1.0 with tag latest
  • Step 7: Commit publish script

Run:

git add scripts/publish-public-npm-package.mjs scripts/publish-public-npm-package.test.mjs package.json
git commit -m "release: add guarded npm publish script"

Expected: commit created.

Task 4: Add manual release workflow and docs

Files:

  • Create: .github/workflows/release.yml

  • Create: scripts/release-workflow.test.mjs

  • Modify: README.md

  • Step 1: Write failing workflow tests

Create scripts/release-workflow.test.mjs with:

import assert from 'node:assert/strict';
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 () => {
    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, /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, /NODE_AUTH_TOKEN: \$\{\{ secrets.NPM_TOKEN \}\}/);
    assert.doesNotMatch(workflow, /^  push:/m);
    assert.doesNotMatch(workflow, /^  pull_request:/m);
  });
});
  • Step 2: Run workflow tests to verify failure

Run:

node --test scripts/release-workflow.test.mjs

Expected: FAIL because .github/workflows/release.yml does not exist.

  • Step 3: Add the release workflow

Create .github/workflows/release.yml with:

name: KTX Release

on:
  workflow_dispatch:
    inputs:
      publish_live:
        description: "Publish @kaelio/ktx to npm instead of running a dry-run"
        required: true
        type: boolean
        default: false

permissions:
  contents: read

concurrency:
  group: ktx-release-${{ github.ref }}
  cancel-in-progress: false

jobs:
  npm-public-release:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Setup pnpm
        uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
        with:
          run_install: false

      - name: Setup Node.js
        uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
        with:
          node-version: "24"
          cache: "pnpm"
          cache-dependency-path: "pnpm-lock.yaml"

      - name: Install TypeScript dependencies
        run: pnpm install --frozen-lockfile

      - name: Setup Python
        uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
        with:
          python-version: "3.13"

      - name: Setup uv
        uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
        with:
          enable-cache: true
          cache-dependency-glob: "uv.lock"

      - 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
        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
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
  • Step 4: Update release docs

In README.md, replace the current ## Release status section with:

## Release status

This repository builds one public npm artifact named `@kaelio/ktx`. The first
public npm handoff is policy-gated through `release-policy.json`, which keeps
Python package publishing disabled because KTX-owned Python code ships inside
the npm package as a bundled wheel.

Build local package artifacts and verify the guarded dry-run publish path with:

```bash
source .venv/bin/activate
pnpm run artifacts:check
pnpm run release:readiness
pnpm run release:npm-publish

Run the live npm publish only from the manual KTX Release workflow with the publish_live input enabled after the NPM_TOKEN secret is configured.


- [ ] **Step 5: Run workflow and README checks**

Run:

```bash
node --test scripts/release-workflow.test.mjs scripts/examples-docs.test.mjs

Expected: PASS.

  • Step 6: Commit workflow and docs

Run:

git add .github/workflows/release.yml scripts/release-workflow.test.mjs README.md
git commit -m "release: document public npm release handoff"

Expected: commit created.

Task 5: Final verification

Files:

  • Verify: scripts/*.test.mjs

  • Verify: packages/cli/src/*

  • Verify: README.md

  • Verify: release-policy.json

  • Step 1: Run focused script tests

Run:

node --test scripts/build-public-npm-package.test.mjs scripts/package-artifacts.test.mjs scripts/local-embeddings-runtime-smoke.test.mjs scripts/release-readiness.test.mjs scripts/publish-public-npm-package.test.mjs scripts/published-package-smoke.test.mjs scripts/release-workflow.test.mjs scripts/examples-docs.test.mjs

Expected: PASS.

  • Step 2: Run workspace type and package checks

Run:

pnpm run type-check
pnpm run artifacts:check

Expected: PASS. The artifact build creates dist/artifacts/npm/kaelio-ktx-0.1.0.tgz.

  • Step 3: Run release readiness and dry-run publish

Run:

pnpm run release:readiness
pnpm run release:npm-publish

Expected: PASS. release:readiness prints KTX release mode: npm-public-release-ready, and release:npm-publish prints Dry-run verified @kaelio/ktx@0.1.0 with tag latest.

  • Step 4: Run pre-commit for changed files

Run:

uv run pre-commit run --files scripts/build-public-npm-package.mjs scripts/build-public-npm-package.test.mjs scripts/package-artifacts.mjs scripts/package-artifacts.test.mjs scripts/local-embeddings-runtime-smoke.test.mjs scripts/release-readiness.mjs scripts/release-readiness.test.mjs scripts/publish-public-npm-package.mjs scripts/publish-public-npm-package.test.mjs scripts/release-workflow.test.mjs release-policy.json package.json README.md .github/workflows/release.yml

Expected: PASS. If pre-commit is unavailable because the local uv version or pre-commit environment is missing, report that explicitly and keep the script tests, pnpm run type-check, pnpm run artifacts:check, pnpm run release:readiness, and pnpm run release:npm-publish results as the closest checks.

  • Step 5: Confirm the worktree is clean

Run:

git status --short

Expected: no output. If there are uncommitted tracked changes, inspect them and commit only files from this plan with the exact task commit commands above.

Success criteria

  • @kaelio/ktx artifact metadata and tarball names use version 0.1.0.
  • release-policy.json encodes npm-public-release-ready, npm.publish: true, and python.publish: false.
  • pnpm run release:npm-publish performs a dry-run by default.
  • Live npm publishing requires pnpm run release:npm-publish -- --publish or the manual KTX Release workflow with publish_live enabled.
  • Published-package smoke remains the post-publication proof for npx @kaelio/ktx, local npx ktx, and global ktx invocation modes.
  • No Python package publication is added for this release.

Self-review

  • Spec coverage: this plan covers the remaining public npm handoff gap while preserving the bundled Python wheel model and single npm package surface.
  • Placeholder scan: no open placeholders or deferred implementation notes are present.
  • Type consistency: the release mode name is consistently npm-public-release-ready; the public npm version is consistently 0.1.0; the publish script consumes the npmPublish report shape produced by release-readiness.mjs.