Merge worktree/schedule-explicit-at into worktree/schedule-fixed-rate
This commit is contained in:
+1
-1
@@ -1,3 +1,3 @@
|
||||
# AGENTS.md — Repository scripts
|
||||
|
||||
Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation at the owning gate boundary instead of a shared platform layer.
|
||||
Gate scripts invoke pnpm shell-free, normalize repository-relative glob paths to `/` at ingestion, and keep platform adaptation in the gate that needs it instead of a shared platform layer.
|
||||
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto'
|
||||
import { basename } from 'node:path'
|
||||
import { AGENT_NOTE_CLASSES } from './agent-note-tree.ts'
|
||||
|
||||
/** Versioned shape of the frozen-content manifest. */
|
||||
/** Versioned fields in the frozen-content manifest. */
|
||||
export interface ArchiveManifest {
|
||||
version: 1
|
||||
files: Readonly<Record<string, string>>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve, sep } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
@@ -16,8 +16,8 @@ const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** The closure manifest whose dependencies define the executable. */
|
||||
const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg'
|
||||
/** The app entry inside the deployed closure. */
|
||||
const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js'
|
||||
/** The closed-runtime app entry inside the deployed closure. */
|
||||
const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js'
|
||||
const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg'
|
||||
/** Default Node major; SEA mode requires at least Node 22. */
|
||||
const DEFAULT_NODE_RANGE = 'node24'
|
||||
@@ -28,6 +28,8 @@ const OUT_DIR = 'dist-exe'
|
||||
const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime'
|
||||
/** The deployed closure doubles as the node-mode carrier. */
|
||||
const PYTHON_NODE_SUBDIR = 'node'
|
||||
/** Legacy deploy may hoist peer-specialized workspace packages back here. */
|
||||
const DEPLOY_SOURCE_NODE_MODULES = 'python/sdk-runtime/node_modules'
|
||||
/** Documentation excluded from the generated runtime directory. */
|
||||
const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml']
|
||||
|
||||
@@ -256,6 +258,8 @@ class SingleExeBuild {
|
||||
'--config.link-workspace-packages=true',
|
||||
this.staging,
|
||||
])
|
||||
await this.restoreLegacyHoists()
|
||||
await this.materializeStagedLinks()
|
||||
if (this.cli.dryRun) {
|
||||
for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`)
|
||||
} else {
|
||||
@@ -263,6 +267,94 @@ class SingleExeBuild {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore direct packages that pnpm's legacy hoister places beside the deploy
|
||||
* source instead of in the target. The runtime manifest supplies every peer,
|
||||
* so package-local node_modules trees are omitted to preserve one flat Cordis
|
||||
* instance and a symlink-free packaged payload.
|
||||
*/
|
||||
private async restoreLegacyHoists(): Promise<void> {
|
||||
if (this.cli.dryRun) {
|
||||
console.log('build-exe-for-python-sdk: [dry-run] restore direct dependencies omitted by legacy deploy')
|
||||
return
|
||||
}
|
||||
const manifestPath = join(this.staging, 'package.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
const sourceNodeModules = resolve(root, DEPLOY_SOURCE_NODE_MODULES)
|
||||
const restored: string[] = []
|
||||
for (const dependency of Object.keys(manifest.dependencies ?? {}).sort()) {
|
||||
const destination = join(this.staging, 'node_modules', dependency)
|
||||
if (existsSync(destination)) continue
|
||||
const source = join(sourceNodeModules, dependency)
|
||||
if (!existsSync(source)) {
|
||||
throw new Error(
|
||||
`build-exe-for-python-sdk: deployed dependency ${dependency} is absent from both ${destination} and ${source}.`,
|
||||
)
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
const nestedNodeModules = join(source, 'node_modules')
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
|
||||
})
|
||||
restored.push(dependency)
|
||||
}
|
||||
const stillMissing = Object.keys(manifest.dependencies ?? {})
|
||||
.filter(dependency => !existsSync(join(this.staging, 'node_modules', dependency)))
|
||||
if (stillMissing.length > 0) {
|
||||
throw new Error(`build-exe-for-python-sdk: staged dependencies remain missing: ${stillMissing.join(', ')}.`)
|
||||
}
|
||||
if (restored.length > 0) {
|
||||
console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace deploy-time package links with files and reject any remaining link. */
|
||||
private async materializeStagedLinks(): Promise<void> {
|
||||
if (this.cli.dryRun) {
|
||||
console.log('build-exe-for-python-sdk: [dry-run] materialize staged package links')
|
||||
return
|
||||
}
|
||||
const nodeModules = join(this.staging, 'node_modules')
|
||||
let remaining = await this.findSymlink(nodeModules)
|
||||
while (remaining !== undefined) {
|
||||
const segments = remaining.slice(nodeModules.length + 1).split(sep)
|
||||
const binIndex = segments.lastIndexOf('.bin')
|
||||
if (binIndex >= 0) {
|
||||
await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true })
|
||||
remaining = await this.findSymlink(nodeModules)
|
||||
continue
|
||||
}
|
||||
const destination = remaining
|
||||
const source = await realpath(destination)
|
||||
const nestedNodeModules = join(source, 'node_modules')
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
|
||||
})
|
||||
remaining = await this.findSymlink(nodeModules)
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the first symbolic link below a directory, if one exists. */
|
||||
private async findSymlink(directory: string): Promise<string | undefined> {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
const metadata = await lstat(path)
|
||||
if (metadata.isSymbolicLink()) return path
|
||||
if (metadata.isDirectory()) {
|
||||
const nested = await this.findSymlink(path)
|
||||
if (nested !== undefined) return nested
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Add the executable entry and pkg assets to the staged manifest. */
|
||||
async injectPkgConfig(): Promise<void> {
|
||||
const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } }
|
||||
|
||||
@@ -17,6 +17,8 @@ from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_DISTRIBUTION = "deepseek-harness-sdk"
|
||||
RUNTIME_DISTRIBUTION = "deepseek-harness-runtime-bin"
|
||||
PLATFORMS = {
|
||||
"linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"),
|
||||
"linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"),
|
||||
@@ -41,6 +43,8 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
version = repository_version()
|
||||
validate_release_tag(args.tag, version)
|
||||
# Wheels carry the PEP 440 spelling; the tag keeps the repository spelling.
|
||||
wheel_version = pep440_version(version)
|
||||
if args.package == "runtime" and (args.platform is None or args.runtime_exe is None):
|
||||
parser.error("runtime builds require --platform and --runtime-exe")
|
||||
if args.package == "sdk" and (args.platform is not None or args.runtime_exe is not None):
|
||||
@@ -51,19 +55,19 @@ def main() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-python-release-") as temporary:
|
||||
staging = Path(temporary) / args.package
|
||||
if args.package == "sdk":
|
||||
stage_sdk(staging, version)
|
||||
stage_sdk(staging, wheel_version)
|
||||
environment = None
|
||||
expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl"
|
||||
expected = output_dir / f"deepseek_harness_sdk-{wheel_version}-py3-none-any.whl"
|
||||
else:
|
||||
platform_tag, executable_name = PLATFORMS[args.platform]
|
||||
stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name)
|
||||
stage_runtime(staging, wheel_version, args.runtime_exe.resolve(), executable_name)
|
||||
environment = {"DSH_RUNTIME_PLATFORM_TAG": platform_tag}
|
||||
expected = output_dir / f"deepseek_harness_runtime_bin-{version}-py3-none-{platform_tag}.whl"
|
||||
expected = output_dir / f"deepseek_harness_runtime_bin-{wheel_version}-py3-none-{platform_tag}.whl"
|
||||
command = ["uv", "build", "--wheel", "--out-dir", str(output_dir), str(staging)]
|
||||
subprocess.run(command, cwd=ROOT, env=None if environment is None else {**os.environ, **environment}, check=True)
|
||||
if not expected.is_file():
|
||||
raise RuntimeError(f"build did not produce expected wheel: {expected}")
|
||||
verify_wheel(expected, args.package, version, None if args.platform is None else PLATFORMS[args.platform])
|
||||
verify_wheel(expected, args.package, wheel_version, None if args.platform is None else PLATFORMS[args.platform])
|
||||
print(expected)
|
||||
|
||||
|
||||
@@ -74,13 +78,35 @@ def repository_version(root: Path = ROOT) -> str:
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"could not read repository version from {package_json}") from error
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+", version) is None:
|
||||
if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?", version) is None:
|
||||
raise ValueError(
|
||||
f"{package_json} version must be stable X.Y.Z, got {version!r}"
|
||||
f"{package_json} version must be X.Y.Z with an optional prerelease segment, got {version!r}"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def pep440_version(version: str) -> str:
|
||||
"""The Python spelling of a repository version.
|
||||
|
||||
A release candidate is `0.0.1-rc.1` in the repository and `0.0.1rc1` under
|
||||
PEP 440. Build backends normalize to the latter, so the wheel filename and
|
||||
metadata carry it: comparing them against the repository spelling would
|
||||
reject every prerelease build.
|
||||
"""
|
||||
stable, separator, prerelease = version.partition("-")
|
||||
if not separator:
|
||||
return stable
|
||||
match = re.fullmatch(r"(a|b|c|rc|alpha|beta|pre|preview)\.?(\d+)", prerelease)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
f"prerelease segment {prerelease!r} has no PEP 440 spelling; use rc.N, alpha.N, or beta.N"
|
||||
)
|
||||
identifier = {"alpha": "a", "beta": "b", "c": "rc", "pre": "rc", "preview": "rc"}.get(
|
||||
match.group(1), match.group(1)
|
||||
)
|
||||
return f"{stable}{identifier}{match.group(2)}"
|
||||
|
||||
|
||||
def validate_release_tag(tag: str | None, version: str) -> None:
|
||||
if tag is None:
|
||||
return
|
||||
@@ -160,6 +186,11 @@ def verify_wheel(
|
||||
raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}")
|
||||
if metadata.get("Version") != version:
|
||||
raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}")
|
||||
expected_distribution = SDK_DISTRIBUTION if package == "sdk" else RUNTIME_DISTRIBUTION
|
||||
if metadata.get("Name") != expected_distribution:
|
||||
raise RuntimeError(
|
||||
f"{wheel} has distribution name {metadata.get('Name')}, expected {expected_distribution}"
|
||||
)
|
||||
runtime_files = [
|
||||
name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name
|
||||
]
|
||||
@@ -177,7 +208,7 @@ def verify_wheel(
|
||||
raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}")
|
||||
if package == "sdk":
|
||||
requirements = metadata.get_all("Requires-Dist") or []
|
||||
expected_requirement = f"deepseek-harness-runtime-bin=={version}"
|
||||
expected_requirement = f"{RUNTIME_DISTRIBUTION}=={version}"
|
||||
if expected_requirement not in requirements:
|
||||
raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}")
|
||||
|
||||
|
||||
@@ -97,14 +97,14 @@ function repositoryState(root: string): Record<string, string> {
|
||||
}
|
||||
|
||||
describe('change-scope', () => {
|
||||
it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => {
|
||||
it('uses an explicit base on a fresh branch without a same-name remote and after its first push', { timeout: 20_000 }, () => {
|
||||
const { root } = fixture()
|
||||
git(root, ['switch', '-c', 'feature'])
|
||||
git(root, ['branch', '--set-upstream-to=origin/master'])
|
||||
const headSha = commit(root, 'feature.txt', 'feature\n')
|
||||
|
||||
const fresh = jsonReport(root, 'origin/master')
|
||||
expect(fresh.repositoryRoot).toBe(realpathSync(root))
|
||||
expect(realpathSync.native(fresh.repositoryRoot)).toBe(realpathSync.native(root))
|
||||
expect(fresh.resolved).toEqual({
|
||||
baseSha: git(root, ['rev-parse', 'origin/master']),
|
||||
headSha,
|
||||
@@ -122,7 +122,7 @@ describe('change-scope', () => {
|
||||
const { root } = fixture('worktree ')
|
||||
const report = jsonReport(root, 'HEAD')
|
||||
|
||||
expect(report.repositoryRoot).toBe(realpathSync(root))
|
||||
expect(realpathSync.native(report.repositoryRoot)).toBe(realpathSync.native(root))
|
||||
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
|
||||
})
|
||||
|
||||
|
||||
@@ -21,15 +21,15 @@ const workspaceGlobs = [
|
||||
{ dir: 'apps', depth: 1 },
|
||||
] as const
|
||||
const vendoredPackages = new Set([
|
||||
'cordis',
|
||||
'cosmokit',
|
||||
'schemastery',
|
||||
'@cordisjs/plugin-loader',
|
||||
'@cordisjs/plugin-include',
|
||||
'@cordisjs/plugin-group',
|
||||
'@cordisjs/plugin-timer',
|
||||
'@cordisjs/plugin-hmr',
|
||||
'@cordisjs/plugin-logger-console',
|
||||
'@deepseek-ai/cordis',
|
||||
'@deepseek-ai/cosmokit',
|
||||
'@deepseek-ai/schemastery',
|
||||
'@deepseek-ai/cordis-plugin-loader',
|
||||
'@deepseek-ai/cordis-plugin-include',
|
||||
'@deepseek-ai/cordis-plugin-group',
|
||||
'@deepseek-ai/cordis-plugin-timer',
|
||||
'@deepseek-ai/cordis-plugin-hmr',
|
||||
'@deepseek-ai/cordis-plugin-logger-console',
|
||||
])
|
||||
const publicLandlockPackages = new Set([
|
||||
'@deepseek-ai/node-addon-landlock-run',
|
||||
@@ -41,6 +41,14 @@ const publicationSourceAllowlist: Readonly<Record<string, readonly string[]>> =
|
||||
'@deepseek-ai/node-addon-landlock-run': ['src/main.c'],
|
||||
}
|
||||
const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness.git'
|
||||
/**
|
||||
* Source home the published packages point consumers at. It differs from
|
||||
* {@link repositoryUrl}, which the Landlock packages keep because npm resolves
|
||||
* their trusted publishing against the repository that runs the workflow.
|
||||
*/
|
||||
const publishedRepositoryUrl = 'git+https://github.com/deepseek-ai/deepseek-harness.git'
|
||||
/** Directories whose packages this repository publishes: one release member each. */
|
||||
const releaseMemberDirectory = /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/
|
||||
|
||||
const localArtifactDirs = new Set(['node_modules'])
|
||||
const appPackageFiles: Readonly<Record<string, readonly string[]>> = {
|
||||
@@ -72,6 +80,8 @@ interface PackageManifest {
|
||||
repository?: { type?: string; url?: string; directory?: string }
|
||||
peerDependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
dependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
/** One workspace manifest and its repo-relative path. */
|
||||
@@ -119,19 +129,21 @@ function workspaceManifests(): WorkspaceManifest[] {
|
||||
}
|
||||
|
||||
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
// Profile bundles publish their dsh.bundle.patch layer beside the lib.
|
||||
'@deepseek-ai/dsh-base': ['cordis.patch.yml'],
|
||||
// Profile bundles publish their dsh.bundle.patch layer beside the lib;
|
||||
// dsh-base also ships the win32 shell platform layer the launcher reads.
|
||||
'@deepseek-ai/dsh-base': ['cordis.patch.yml', 'windows.cordis.patch.yml'],
|
||||
'@deepseek-ai/dsh-web-app': ['cordis.patch.yml'],
|
||||
'@deepseek-ai/dsh-headless': ['cordis.patch.yml'],
|
||||
'@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
|
||||
'@deepseek-ai/dsh-helper': ['lib/assets'],
|
||||
// The Python runtime uses a distinct closed-resolution bin; the public CLI
|
||||
// keeps config-owned bare-package resolution through lib/bin.js.
|
||||
'@deepseek-ai/dsh-jsonrpc-demo': ['lib/packaged-bin.js'],
|
||||
// The argv-prefix runner entry ships beside the lib as its own bundle;
|
||||
// sandbox-local resolves it through the package's ./runner export. tsdown
|
||||
// also shares its generated FFI code through a hashed runtime chunk.
|
||||
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'],
|
||||
'@deepseek-ai/dsh-skill-badge': ['assets'],
|
||||
'@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'],
|
||||
'@deepseek-ai/dsh-scripts': [
|
||||
'lib/dev/tsdown-config.js',
|
||||
'lib/local-plugin-loader-hooks.js',
|
||||
'lib/assets',
|
||||
],
|
||||
}
|
||||
|
||||
function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {
|
||||
@@ -157,6 +169,9 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
|
||||
// web-react's store subpath ships its own bundle (single-entry builds; no shared chunk).
|
||||
...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [],
|
||||
// A surface bundle's startup row is its own bundle: the Loader imports it
|
||||
// as a row module, so it cannot ride inside the package entry.
|
||||
...exportDefault(manifest, './startup') === './lib/startup.js' ? ['lib/startup.js'] : [],
|
||||
...extras,
|
||||
// Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js —
|
||||
// browser-safe source channels rehomed off src so plain Node can import
|
||||
@@ -221,8 +236,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
if (manifest.private === true) {
|
||||
errors.push(`${label}: published Landlock package must not set "private": true`)
|
||||
}
|
||||
if (manifest.publishConfig?.access !== 'public') {
|
||||
errors.push(`${label}: published Landlock package must set publishConfig.access to "public"`)
|
||||
if (manifest.publishConfig?.access !== 'restricted') {
|
||||
errors.push(`${label}: published Landlock package must set publishConfig.access to "restricted"`)
|
||||
}
|
||||
const expectedDirectory = dir
|
||||
if (manifest.repository?.type !== 'git'
|
||||
@@ -230,6 +245,21 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
|| manifest.repository.directory !== expectedDirectory) {
|
||||
errors.push(`${label}: published Landlock package repository must use ${repositoryUrl} with directory ${expectedDirectory} for trusted publishing`)
|
||||
}
|
||||
} else if (releaseMemberDirectory.test(dir)) {
|
||||
// Release members state that they are publishable: npm refuses a private
|
||||
// package, the scope is published privately, and the repository field is
|
||||
// how a consumer of a private package finds its source.
|
||||
if (manifest.private === true) {
|
||||
errors.push(`${label}: release member must not set "private": true`)
|
||||
}
|
||||
if (manifest.publishConfig?.access !== 'restricted') {
|
||||
errors.push(`${label}: release member must set publishConfig.access to "restricted"`)
|
||||
}
|
||||
if (manifest.repository?.type !== 'git'
|
||||
|| manifest.repository.url !== publishedRepositoryUrl
|
||||
|| manifest.repository.directory !== dir) {
|
||||
errors.push(`${label}: release member repository must use ${publishedRepositoryUrl} with directory ${dir}`)
|
||||
}
|
||||
} else if (manifest.private !== true) {
|
||||
errors.push(`${label}: package.json must set "private": true`)
|
||||
}
|
||||
@@ -267,13 +297,13 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
}
|
||||
|
||||
if (dir.startsWith('packages/') && manifest.name?.startsWith('@deepseek-ai/dsh-')) {
|
||||
const peer = manifest.peerDependencies?.cordis
|
||||
const dev = manifest.devDependencies?.cordis
|
||||
const peer = manifest.peerDependencies?.['@deepseek-ai/cordis']
|
||||
const dev = manifest.devDependencies?.['@deepseek-ai/cordis']
|
||||
|
||||
if (!peer) errors.push(`${label}: cordis must be a peerDependency`)
|
||||
if (!dev) errors.push(`${label}: cordis must also be a devDependency`)
|
||||
if (!peer) errors.push(`${label}: @deepseek-ai/cordis must be a peerDependency`)
|
||||
if (!dev) errors.push(`${label}: @deepseek-ai/cordis must also be a devDependency`)
|
||||
if (peer && dev && peer !== dev) {
|
||||
errors.push(`${label}: cordis peer (${peer}) and dev (${dev}) ranges must match`)
|
||||
errors.push(`${label}: @deepseek-ai/cordis peer (${peer}) and dev (${dev}) ranges must match`)
|
||||
}
|
||||
if (manifest.version !== repositoryVersion) {
|
||||
errors.push(`${label}: package.json version must match root version ${repositoryVersion ?? '(missing)'}`)
|
||||
@@ -342,13 +372,44 @@ function checkHierarchyShape(): string[] {
|
||||
}
|
||||
|
||||
function checkRepositoryVersion(): string[] {
|
||||
if (repositoryVersion && /^\d+\.\d+\.\d+$/.test(repositoryVersion)) return []
|
||||
return ['package.json: version must be stable X.Y.Z']
|
||||
// The root carries the dsh release family's version, so a prerelease such as
|
||||
// 0.0.1-rc.1 is a valid state between `release:dsh` and its publication.
|
||||
if (repositoryVersion && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(repositoryVersion)) return []
|
||||
return ['package.json: version must be X.Y.Z with an optional prerelease segment']
|
||||
}
|
||||
|
||||
/** Dependency sections whose ranges reach a published tarball or a local install. */
|
||||
const dependencySections = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const
|
||||
|
||||
/**
|
||||
* Require the `workspace:` protocol for every reference to a workspace member.
|
||||
*
|
||||
* A hand-written range says nothing about the version the workspace actually
|
||||
* carries, and `pnpm pack` leaves it alone: `^0.0.1` published from version
|
||||
* `0.0.2` names a version that does not exist. The protocol makes pack
|
||||
* substitute the member's real version, so no release step rewrites ranges.
|
||||
* @param manifests - every workspace manifest.
|
||||
* @returns One error per reference that names a workspace member without the protocol.
|
||||
*/
|
||||
function checkWorkspaceProtocol(manifests: readonly WorkspaceManifest[]): string[] {
|
||||
const members = new Set(manifests.map(entry => entry.manifest.name).filter(name => name !== undefined))
|
||||
const errors: string[] = []
|
||||
for (const { dir, manifest } of manifests) {
|
||||
for (const section of dependencySections) {
|
||||
for (const [name, range] of Object.entries(manifest[section] ?? {})) {
|
||||
if (!members.has(name) || range.startsWith('workspace:')) continue
|
||||
errors.push(`${manifest.name ?? dir}: ${section}.${name} must use the workspace: protocol, got ${range}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const manifests = workspaceManifests()
|
||||
const errors = [
|
||||
...checkRepositoryVersion(),
|
||||
...workspaceManifests().flatMap(checkWorkspace),
|
||||
...manifests.flatMap(checkWorkspace),
|
||||
...checkWorkspaceProtocol(manifests),
|
||||
...checkHierarchyShape(),
|
||||
...collectProjectReferenceFaceViolations(root),
|
||||
]
|
||||
|
||||
+79
-15
@@ -27,41 +27,76 @@ describe('CI workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Wine blocking while native Windows reports independently', () => {
|
||||
it('keeps a required Wine Windows job, a non-blocking native Windows job with failover, and a master-only standby', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/ci.yml')
|
||||
if (!isRecord(workflow.jobs)
|
||||
|| !isRecord(workflow.jobs.windows)
|
||||
|| !isRecord(workflow.jobs['windows-native'])
|
||||
|| !isRecord(workflow.jobs['wine-apt-cache'])
|
||||
|| !isRecord(workflow.jobs['serial-windows'])
|
||||
|| !isRecord(workflow.jobs['all-checks-passed'])) {
|
||||
throw new TypeError('CI workflow must define Wine, native Windows, and aggregate jobs')
|
||||
throw new TypeError('CI workflow must define windows, windows-native, wine-apt-cache, serial-windows, and all-checks-passed jobs')
|
||||
}
|
||||
|
||||
const windows = workflow.jobs.windows
|
||||
const windowsNative = workflow.jobs['windows-native']
|
||||
const wineAptCache = workflow.jobs['wine-apt-cache']
|
||||
const serialWindows = workflow.jobs['serial-windows']
|
||||
const aggregate = workflow.jobs['all-checks-passed']
|
||||
if (!Array.isArray(windows.steps) || !Array.isArray(windowsNative.steps) || !Array.isArray(aggregate.needs)) {
|
||||
throw new TypeError('Windows jobs must define steps and the aggregate must define needs')
|
||||
if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) {
|
||||
throw new TypeError('Windows job must define steps and the aggregate must define needs')
|
||||
}
|
||||
const nativeCommandSteps = windowsNative.steps.filter((step): step is Record<string, unknown> & { run: string } => (
|
||||
const commandSteps = windows.steps.filter((step): step is Record<string, unknown> & { run: string } => (
|
||||
isRecord(step) && typeof step.run === 'string'
|
||||
))
|
||||
|
||||
// Required PR job: Wine on ubuntu-latest, runs wine-windows-gates.sh.
|
||||
expect(windows['runs-on']).toBe('ubuntu-latest')
|
||||
expect(windows.name).toBe('windows node 24 / wine blocking')
|
||||
expect(windows.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(JSON.stringify(windows)).toContain('bash scripts/wine-windows-gates.sh')
|
||||
expect(workflow.jobs).toHaveProperty('wine-apt-cache')
|
||||
expect(windowsNative['runs-on']).toBe('windows-2025')
|
||||
expect(commandSteps.some(step => step.run.includes('wine-windows-gates.sh'))).toBe(true)
|
||||
|
||||
// windows-native: non-blocking native job with failover, runs windows-complete.
|
||||
expect(typeof windowsNative['runs-on']).toBe('string')
|
||||
expect(windowsNative['runs-on']).toContain('DSH_CI_FAILOVER')
|
||||
expect(windowsNative['runs-on']).toContain('self-hosted')
|
||||
expect(windowsNative['runs-on']).toContain('dsh-win-ci')
|
||||
expect(windowsNative['runs-on']).toContain('dsh-windows-2025-16core')
|
||||
expect(windowsNative.name).toBe('windows node 24 / native complete')
|
||||
expect(windowsNative['timeout-minutes']).toBe(60)
|
||||
expect(windowsNative.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(windowsNative).not.toHaveProperty('continue-on-error')
|
||||
expect(nativeCommandSteps).toHaveLength(3)
|
||||
expect(nativeCommandSteps.every(step => step.shell === 'pwsh')).toBe(true)
|
||||
const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record<string, unknown> & { run: string } => (
|
||||
isRecord(step) && typeof step.run === 'string'
|
||||
))
|
||||
expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete')
|
||||
expect(JSON.stringify(windowsNative)).not.toMatch(/wine/i)
|
||||
|
||||
// wine-apt-cache: master-only, seeds the Wine apt cache.
|
||||
expect(wineAptCache.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
|
||||
expect(wineAptCache['runs-on']).toBe('ubuntu-latest')
|
||||
|
||||
// serial-windows: master-only standby, self-hosted, non-blocking.
|
||||
expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
|
||||
expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows'])
|
||||
expect(serialWindows.name).toBe('serial / windows (self-hosted standby)')
|
||||
|
||||
// Aggregate: Wine `windows` required, native `windows-native` excluded.
|
||||
expect(aggregate.needs).toContain('windows')
|
||||
expect(aggregate.needs).not.toContain('windows-native')
|
||||
expect(aggregate.needs).not.toContain('serial-windows')
|
||||
})
|
||||
|
||||
it('keeps supported LSP source under native Windows coverage', () => {
|
||||
const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')
|
||||
|
||||
expect(config).not.toContain('packages/lsp/lsp-local/src/connection.ts')
|
||||
expect(config).not.toContain('packages/lsp/lsp-local/src/index.ts')
|
||||
expect(config).not.toContain('packages/lsp/lsp-local/src/instance.ts')
|
||||
})
|
||||
|
||||
it('keeps every Vitest project process-isolated on native Windows', () => {
|
||||
const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')
|
||||
|
||||
expect(config).not.toContain("pool: process.platform === 'win32' ? 'threads' : 'forks'")
|
||||
expect(config.match(/pool: 'forks'/g)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,20 +128,42 @@ describe('E2B e2e workflow', () => {
|
||||
})
|
||||
|
||||
describe('Issue lifecycle workflow', () => {
|
||||
it('uses review signals instead of rerunning when a draft becomes ready', () => {
|
||||
it('uses explicit review handoff events without rerunning when a draft becomes ready', () => {
|
||||
const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')
|
||||
const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request')
|
||||
const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review')
|
||||
const lifecycleJob = workflowJob(lifecycle, 'lifecycle')
|
||||
const policy = loadWorkflow('.github/workflows/issue-policy.yml')
|
||||
const policyPullRequest = workflowEvent(policy, 'pull_request')
|
||||
|
||||
expect(lifecyclePullRequest.types).not.toContain('ready_for_review')
|
||||
expect(lifecyclePullRequest.types).toContain('review_requested')
|
||||
expect(lifecycleReview.types).toContain('submitted')
|
||||
expect(lifecycleReview.types).toEqual(['submitted'])
|
||||
expect(lifecycleJob.if).toBe(
|
||||
"${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}",
|
||||
)
|
||||
expect(policyPullRequest.types).toContain('ready_for_review')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Git hooks', () => {
|
||||
it('leaves frozen Agent Note sidecars to the archive verifier', () => {
|
||||
const lefthook = loadWorkflow('lefthook.yml')
|
||||
|
||||
for (const hookName of ['pre-commit', 'pre-merge-commit']) {
|
||||
const hook = lefthook[hookName]
|
||||
if (!isRecord(hook) || !Array.isArray(hook.jobs)) {
|
||||
throw new TypeError(`lefthook must define ${hookName} jobs`)
|
||||
}
|
||||
const pairing: unknown = hook.jobs.find(
|
||||
(job: unknown) => isRecord(job) && job.name === 'translation pairing (staged records)',
|
||||
)
|
||||
|
||||
expect(pairing).toMatchObject({ exclude: ['.agents/notes/archived/**'] })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function loadWorkflow(path: string): Record<string, unknown> {
|
||||
const workflow: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8'))
|
||||
if (!isRecord(workflow)) throw new TypeError(`${path} must define a workflow`)
|
||||
@@ -120,6 +177,13 @@ function workflowEvent(workflow: Record<string, unknown>, event: string): Record
|
||||
return workflow.on[event]
|
||||
}
|
||||
|
||||
function workflowJob(workflow: Record<string, unknown>, job: string): Record<string, unknown> {
|
||||
if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs[job])) {
|
||||
throw new TypeError(`workflow must define the ${job} job`)
|
||||
}
|
||||
return workflow.jobs[job]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Pins shared client-bundle preset contracts: the module-edge purity gate and
|
||||
* Pins shared client-bundle preset rules: the module-edge purity gate and
|
||||
* the physical watch dependencies hidden behind virtual CSS Modules.
|
||||
*/
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -97,9 +97,9 @@ describe('client bundle purity gate', () => {
|
||||
|
||||
it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
|
||||
const dshClientChannels = CLIENT_EXTERNALS.filter(
|
||||
const clientChannels = CLIENT_EXTERNALS.filter(
|
||||
entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client'))
|
||||
expect(dshClientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
|
||||
expect(clientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Regression coverage for source declarations owned by the client test aggregate. */
|
||||
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -14,6 +14,7 @@ function clientCssDeclarations(): string[] {
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts'))
|
||||
.filter(existsSync)
|
||||
.map(file => file.replaceAll(sep, '/'))
|
||||
.sort()
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ describe('client TypeScript aggregate', () => {
|
||||
}
|
||||
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root)
|
||||
const loaded = parsed.fileNames
|
||||
.map(file => file.replaceAll(sep, '/'))
|
||||
.filter(file => file.endsWith('/src/css-modules.d.ts'))
|
||||
.sort()
|
||||
expect(loaded).toEqual(clientCssDeclarations())
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface CordisCoreApiPage {
|
||||
sections: CordisCoreApiSection[]
|
||||
}
|
||||
|
||||
/** Explicit editorial grouping for the pinned Cordis core surface. */
|
||||
/** Explicit editorial grouping for the pinned Cordis core API. */
|
||||
export const CORDIS_CORE_API_PAGES: CordisCoreApiPage[] = [
|
||||
{
|
||||
out: 'docs/cordis-api/context.md',
|
||||
|
||||
@@ -11,12 +11,12 @@ import ts from 'typescript'
|
||||
|
||||
/** Cheap textual prefilter for a cordis module merge, quote-style agnostic
|
||||
* (the AST match below reads `stmt.name.text` and never sees the quotes). */
|
||||
const MERGE_HEAD = /declare module ['"](?:cordis|\.\/context\.ts)['"]/
|
||||
const MERGE_HEAD = /declare module ['"](?:@deepseek-ai\/cordis|\.\/context\.ts)['"]/
|
||||
|
||||
/**
|
||||
* Parse every file matching `patterns` (repo-relative, sorted, `/`-normalized)
|
||||
* that textually contains a cordis module merge, yielding one entry per merge
|
||||
* BLOCK — a file may legally hold several `declare module 'cordis'` blocks
|
||||
* BLOCK — a file may legally hold several `declare module '@deepseek-ai/cordis'` blocks
|
||||
* (the Typert analyzer reads them all), so the exhaustiveness scan must too.
|
||||
* Files without a merge are skipped.
|
||||
* @param scanRoot - Repository root the patterns are resolved against.
|
||||
@@ -39,14 +39,14 @@ export function contextMergeFiles(
|
||||
return out
|
||||
}
|
||||
|
||||
/** Every cordis module-merge body in `sf`: `declare module 'cordis'` (harness
|
||||
/** Every cordis module-merge body in `sf`: `declare module '@deepseek-ai/cordis'` (harness
|
||||
* packages) or `declare module './context.ts'` (vendor core), in source order.
|
||||
* Module-local: consumers walk blocks through {@link contextMergeFiles}. */
|
||||
function cordisModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
|
||||
const bodies: ts.ModuleBlock[] = []
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
|
||||
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
|
||||
if (stmt.name.text !== '@deepseek-ai/cordis' && stmt.name.text !== './context.ts') continue
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) bodies.push(stmt.body)
|
||||
}
|
||||
return bodies
|
||||
@@ -60,7 +60,7 @@ export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `key: Type` property a `declare module 'cordis'` Context merge
|
||||
* Every `key: Type` property a `declare module '@deepseek-ai/cordis'` Context merge
|
||||
* declares in one module body.
|
||||
* @param body - The cordis module augmentation block.
|
||||
* @param sf - Owning source file (for text extraction).
|
||||
@@ -79,9 +79,9 @@ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<stri
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event name a `declare module 'cordis'` Events merge declares in one
|
||||
* Every event name a `declare module '@deepseek-ai/cordis'` Events merge declares in one
|
||||
* module body. Names are the literal member keys (`'agent/created'`), read
|
||||
* from method and property members alike so a declaration shape the projector
|
||||
* from method and property members alike so a declaration form the projector
|
||||
* would reject still enters the exhaustiveness scan.
|
||||
* @param body - The cordis module augmentation block.
|
||||
* @param sf - Owning source file (for computed-name text extraction).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Heavy suites the coverage aggregate runs uninstrumented in a parallel gate.
|
||||
* Membership contract: a suite qualifies only when every coverage-measured
|
||||
* Membership rule: a suite qualifies only when every coverage-measured
|
||||
* file it executes in-process (`coverage.include` spans package src trees;
|
||||
* typert generator src is threshold-excluded in vitest.config.ts) is already
|
||||
* fully covered by other suites, so removing it from the instrumented run
|
||||
|
||||
+21
-2
@@ -1,9 +1,28 @@
|
||||
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { TsdownBundle } from 'tsdown'
|
||||
import { watchClientPlugins } from './dev-web.ts'
|
||||
import { discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
|
||||
|
||||
it('discovers dsh.client packages with sibling roles', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-'))
|
||||
try {
|
||||
const current = join(root, 'packages', 'client', 'current')
|
||||
await mkdir(current, { recursive: true })
|
||||
await writeFile(join(current, 'package.json'), JSON.stringify({
|
||||
dsh: {
|
||||
bundle: { patch: './cordis.patch.yml' },
|
||||
client: { platform: 'web' },
|
||||
profile: { bundles: [] },
|
||||
},
|
||||
}))
|
||||
|
||||
expect(discoverPluginDirs(root)).toEqual(['packages/client/current'])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rebuilds a client-plugin bundle after its source changes', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-'))
|
||||
|
||||
+9
-7
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Watch-build for client-plugin HMR: runs every dshClient plugin package
|
||||
* Watch-build for client-plugin HMR: runs every `dsh.client` plugin package
|
||||
* through the tsdown JS API in watch mode. Reload signaling is not this
|
||||
* script's business — the host webserver stat-polls the bundles it serves and
|
||||
* broadcasts `rebuilt` frames itself (`dsh web --dev`), so any process that
|
||||
* broadcasts `rebuilt` frames itself (`dsh web`), so any process that
|
||||
* rewrites `lib/client.js` files triggers reloads; this script is merely the
|
||||
* convenient way to keep them all rebuilt on source change.
|
||||
*
|
||||
@@ -27,7 +27,7 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
/**
|
||||
* Discover the watch workspace by declaration: every packages/<group>/<name>
|
||||
* whose package.json carries `dshClient` with platform "web" is a client
|
||||
* whose package.json carries `dsh.client` with platform "web" is a client
|
||||
* plugin bundle emitter. Scanned once at startup — a package added while
|
||||
* watching means restarting this script.
|
||||
* @param root - repository root containing the grouped package directories.
|
||||
@@ -36,8 +36,10 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
export function discoverPluginDirs(root = repoRoot): string[] {
|
||||
const dirs: string[] = []
|
||||
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
|
||||
const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
|
||||
if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
|
||||
const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as {
|
||||
dsh?: { client?: { platform?: unknown } }
|
||||
}
|
||||
if (manifest.dsh?.client?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
@@ -88,7 +90,7 @@ const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(re
|
||||
if (isMain) {
|
||||
const pluginDirs = discoverPluginDirs()
|
||||
if (pluginDirs.length === 0) {
|
||||
console.error('dev-web: no dshClient (platform "web") packages found under packages/')
|
||||
console.error('dev-web: no dsh.client (platform "web") packages found under packages/')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -106,7 +108,7 @@ if (isMain) {
|
||||
|
||||
await watchClientPlugins(repoRoot, pluginDirs, pollInterval)
|
||||
console.log(
|
||||
`dev-web: watching ${String(pluginDirs.length)} dshClient plugin packages`
|
||||
`dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
|
||||
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"AGENTS.md": 1782,
|
||||
"AGENTS.md": 1900,
|
||||
"docs/AGENTS.md": 1320,
|
||||
"docs/architecture.md": 2174,
|
||||
"docs/architecture.md": 2400,
|
||||
"docs/cordis-primer.md": 600,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 1150,
|
||||
"examples/AGENTS.md": 310,
|
||||
"packages/AGENTS.md": 675,
|
||||
"packages/README.md": 942
|
||||
"packages/README.md": 994
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Map one workspace source alias target to its declaration-build target. */
|
||||
export function builtDeclarationPath(candidate: string): string {
|
||||
// Two workspace shapes exist: whole-package entries end in /src, subpath
|
||||
// Two workspace path forms exist: whole-package entries end in /src, subpath
|
||||
// wildcards (apiproxy's browser-safe /api and /client channels) in /src/*.
|
||||
if (candidate.endsWith('/src')) {
|
||||
return `${candidate.slice(0, -'/src'.length)}/lib/types`
|
||||
|
||||
@@ -221,7 +221,7 @@ const { primary: all, derivatives } = partitionPairedMarkdownDerivatives(
|
||||
const checked = all.filter(b => b.kind === 'check')
|
||||
const ignored = all.filter(b => b.kind === 'ignore')
|
||||
// Only compile-eligible fences belong in the opt-out ratio; every other skipped
|
||||
// kind has an independent verifier named in BlockKind's contract above.
|
||||
// kind has an independent verifier named in the BlockKind rules above.
|
||||
const ratioDenominator = checked.length + ignored.length
|
||||
|
||||
if (checked.length === 0) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Generate `docs/config-catalog.md` from package entry points, config types,
|
||||
* JSDoc, and static Schemastery schemas. Every package must classify, referenced
|
||||
* types must resolve without collisions, and every enumerable schema path must
|
||||
* exist on the declared config type. External and dynamic shapes stay unknown;
|
||||
* exist on the declared config type. External and dynamic types stay unknown;
|
||||
* declared runtime-only fields need not appear in the schema. `--check` verifies
|
||||
* the committed artifact.
|
||||
*/
|
||||
@@ -220,7 +220,7 @@ interface World {
|
||||
}
|
||||
|
||||
/** How a schema key path fared against the declared config type: definitely
|
||||
* present, definitely absent, or crossing a shape the walk cannot enumerate
|
||||
* present, definitely absent, or crossing a type the walk cannot enumerate
|
||||
* (only `missing` is a violation — `unknown` must never mis-report). */
|
||||
type PathLookup = 'found' | 'missing' | 'unknown'
|
||||
|
||||
@@ -307,7 +307,7 @@ const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNul
|
||||
|
||||
/**
|
||||
* Walk a schema key path against a declared type. This is a PRESENCE check,
|
||||
* not a shape check: it answers "does the declared config type have a member
|
||||
* not a runtime value check: it answers "does the declared config type have a member
|
||||
* here", resolving interfaces (heritage included), type aliases, literals,
|
||||
* intersections, unions, arrays, indexed access, pass-through utility
|
||||
* wrappers, and type references across package-local and workspace imports.
|
||||
@@ -412,7 +412,7 @@ function unwrapExpr(expr: ts.Expression): ts.Expression {
|
||||
* Statically walk a schemastery schema expression to its key paths plus the
|
||||
* packages whose schemas an intersect composes. A key path is the top-level
|
||||
* key or a nested path through object/array compositions (`agents[].id`).
|
||||
* Handles the shapes the repo declares — `z.object({…})` (possibly behind
|
||||
* Handles the declaration forms the repo uses — `z.object({…})` (possibly behind
|
||||
* chained calls) and `z.intersect([X.Config, …])` — and hard-errors on
|
||||
* anything else, so a schema the walk cannot see fails the gate instead of
|
||||
* silently thinning it. Nested values that are neither `object` nor `array`
|
||||
@@ -521,7 +521,7 @@ function findSchemaExpr(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null):
|
||||
function findInject(ctx: FileCtx, pluginClass: ts.ClassDeclaration | null, violations: string[]): string[] {
|
||||
const fromArray = (expr: ts.Expression, where: string): string[] => {
|
||||
if (!ts.isArrayLiteralExpression(expr)) {
|
||||
violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new shape.`)
|
||||
violations.push(`${where}: inject is not a plain string-array literal; teach the generator the new declaration form.`)
|
||||
return []
|
||||
}
|
||||
return expr.elements.map(el => ts.isStringLiteral(el) ? el.text : el.getText(ctx.sf))
|
||||
@@ -727,7 +727,7 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
|
||||
}
|
||||
|
||||
// Fold composed schemas' key paths in, then check each path against the type.
|
||||
// Only a definite miss fails; shapes the walk cannot enumerate stay unknown.
|
||||
// Only a definite miss fails; types the walk cannot enumerate stay unknown.
|
||||
const byName = new Map(entries.map(e => [e.pkg, e]))
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue
|
||||
@@ -819,7 +819,7 @@ export function render(entries: CatalogEntry[]): string {
|
||||
'',
|
||||
'# Plugin Config Catalog',
|
||||
'',
|
||||
'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the generated `cordis-surface` region on each [subsystem page](subsystems/core.md), the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [subsystems/](subsystems/core.md) documents the types these declarations reference.',
|
||||
'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the generated Cordis API region on each [subsystem page](subsystems/core.md), the model-facing tool schemas are the [tool catalog](tool-catalog.md), and [subsystems/](subsystems/core.md) documents the types these declarations reference.',
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.',
|
||||
'',
|
||||
@@ -832,7 +832,7 @@ export function render(entries: CatalogEntry[]): string {
|
||||
lines.push(
|
||||
'## Loadable plugins with no config',
|
||||
'',
|
||||
'These load from a `cordis.yml` entry with no `config:` block; they declare no config surface.',
|
||||
'These load from a `cordis.yml` entry with no `config:` block; they declare no configuration API.',
|
||||
'',
|
||||
...entries.filter(e => e.kind === 'no-config').map(e => renderTerse(e, '')),
|
||||
'',
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('cordis-walk scan reach', () => {
|
||||
const dir = join(root, 'packages/client/ui-x/src/client')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'index.ts'), [
|
||||
"declare module 'cordis' {",
|
||||
"declare module '@deepseek-ai/cordis' {",
|
||||
' interface Events {',
|
||||
" 'x/changed'(): void",
|
||||
' }',
|
||||
@@ -153,12 +153,12 @@ describe('cordis-walk scan reach', () => {
|
||||
// backstop must not stop at the first one, skip the double-quoted legal
|
||||
// form, or ignore .tsx sources.
|
||||
writeFileSync(join(dir, 'split.ts'), [
|
||||
"declare module 'cordis' {",
|
||||
"declare module '@deepseek-ai/cordis' {",
|
||||
' interface Context {',
|
||||
' first: FirstService',
|
||||
' }',
|
||||
'}',
|
||||
'declare module "cordis" {',
|
||||
'declare module "@deepseek-ai/cordis" {',
|
||||
' interface Events {',
|
||||
" 'second/changed'(): void",
|
||||
' }',
|
||||
@@ -167,7 +167,7 @@ describe('cordis-walk scan reach', () => {
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'view.tsx'), [
|
||||
"declare module 'cordis' {",
|
||||
"declare module '@deepseek-ai/cordis' {",
|
||||
' interface Context {',
|
||||
' fromTsx: TsxService',
|
||||
' }',
|
||||
@@ -189,7 +189,7 @@ describe('cordis-walk scan reach', () => {
|
||||
|
||||
it('reads string-literal and identifier member names from an Events merge', () => {
|
||||
const sf = ts.createSourceFile('x.ts', [
|
||||
"declare module 'cordis' {",
|
||||
"declare module '@deepseek-ai/cordis' {",
|
||||
' interface Events {',
|
||||
" 'scope/list'(items: string[]): void",
|
||||
' plain(): void',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Generate the per-subsystem Cordis service/event reference regions from the
|
||||
* Typert catalog projection. Every harness `ctx.<key>` service and event scope
|
||||
* maps to exactly one `docs/subsystems/` page through the curated tables below;
|
||||
* the generator injects each page's surface between its GENERATED markers —
|
||||
* the generator injects each page's Cordis API reference between its GENERATED markers —
|
||||
* byte-identically into both language sides of the pair — and re-records a
|
||||
* pair's `.i18n.yaml` only when nothing outside the region changed. The
|
||||
* projection enforces event modes, JSDoc parameter/return completeness, and
|
||||
@@ -40,13 +40,15 @@ export { REGION_BEGIN, REGION_END }
|
||||
* The owning subsystems page for every harness `ctx.<key>` service the
|
||||
* projection discovers. Fail-closed both ways: a discovered key absent here
|
||||
* and an entry whose key the projection no longer discovers are both hard
|
||||
* errors, so the partition can never silently drift from the service surface.
|
||||
* errors, so the partition can never silently drift from the service API.
|
||||
*/
|
||||
export const SERVICE_PAGE: Record<string, string> = {
|
||||
agentLoop: 'core.md',
|
||||
agentDefaultModel: 'core.md',
|
||||
agentPresets: 'core.md',
|
||||
agents: 'core.md',
|
||||
approval: 'approval.md',
|
||||
attachments: 'attachment.md',
|
||||
bash: 'bash.md',
|
||||
bashEnv: 'bash.md',
|
||||
clientModuleHost: 'client-modules.md',
|
||||
@@ -61,6 +63,7 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
httpServer: 'http-server.md',
|
||||
invariants: 'invariants.md',
|
||||
llm: 'llm-streaming.md',
|
||||
messageFeedback: 'feedback.md',
|
||||
permission: 'permission.md',
|
||||
planMode: 'plan.md',
|
||||
pty: 'pty.md',
|
||||
@@ -97,42 +100,42 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
/**
|
||||
* Context keys declared in `interface Context` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The scan that enforces this list reads EVERY `declare module 'cordis'`
|
||||
* The scan that enforces this list reads EVERY `declare module '@deepseek-ai/cordis'`
|
||||
* Context merge under `packages/x/x/src/**` — any depth, not only root
|
||||
* `index.ts` files with a same-named service class — so a new service can
|
||||
* never silently join this blind spot: it either enters {@link SERVICE_PAGE}
|
||||
* or names itself here. Client-face keys (the projection analyzes the host
|
||||
* face only) name the package README that owns their surface.
|
||||
* face only) name the package README that owns their API.
|
||||
* TODO(cordis-catalog-interface-services): the interface-typed and
|
||||
* non-index-declared entries would all render once the projection resolves a
|
||||
* Context key through its declaring file's imports to the class declaration.
|
||||
*/
|
||||
export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle',
|
||||
configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns the launcher contract',
|
||||
launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns the launcher contract',
|
||||
appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract',
|
||||
cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract',
|
||||
configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract',
|
||||
launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract',
|
||||
dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract',
|
||||
headlessIo: 'not a service: launcher-provided root accessor value (HeadlessIo | undefined) for the headless bundle runner — packages/bundle/headless/README.md owns the launcher contract',
|
||||
launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns the launcher contract',
|
||||
lsp: 'interface-typed (LspService); implementing class Lsp is not the declared type name — packages/lsp/lsp/README.md owns the surface',
|
||||
apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the surface',
|
||||
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the surface',
|
||||
connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the surface',
|
||||
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the surface',
|
||||
command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the surface',
|
||||
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the surface',
|
||||
conversationEvents: 'client-side interface-typed registry — packages/client/runtime/README.md owns the surface',
|
||||
conversationViews: 'client-side interface-typed registry — packages/client/runtime/README.md owns the surface',
|
||||
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the surface',
|
||||
locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the surface',
|
||||
models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface',
|
||||
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the surface',
|
||||
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the surface',
|
||||
sessionHistory: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the surface',
|
||||
slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the surface',
|
||||
workspaces: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns this launcher contract',
|
||||
lsp: 'interface-typed (LspService); implementing class Lsp is not the declared type name — packages/lsp/lsp/README.md owns the API',
|
||||
apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the API',
|
||||
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the API',
|
||||
connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the API',
|
||||
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the API',
|
||||
command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the API',
|
||||
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API',
|
||||
conversationEvents: 'client-side interface-typed registry — packages/client/runtime/README.md owns the API',
|
||||
conversationViews: 'client-side interface-typed registry — packages/client/runtime/README.md owns the API',
|
||||
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the API',
|
||||
locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the API',
|
||||
models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the API',
|
||||
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the API',
|
||||
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the API',
|
||||
slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the API',
|
||||
slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the API',
|
||||
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API',
|
||||
workspaces: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the API',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,7 +169,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
* Event names declared in `interface Events` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The mirror of {@link SERVICE_WALK_EXEMPTIONS} for events: an independent
|
||||
* scan reads EVERY `declare module 'cordis'` Events merge under
|
||||
* scan reads EVERY `declare module '@deepseek-ai/cordis'` Events merge under
|
||||
* `packages/x/x/src/**`, so a declared event either renders onto a subsystems
|
||||
* page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes
|
||||
* silently. Keys are full event names, not scopes: client-face events share
|
||||
@@ -174,18 +177,19 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
* so a scope-level exemption would mask a host-face regression.
|
||||
*/
|
||||
export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
'commands/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the surface',
|
||||
'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the surface',
|
||||
'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slash/input-insert-text': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
|
||||
'slots/changed': 'client-face slot invalidation signal — packages/client/runtime/README.md owns the surface',
|
||||
'theme/change': 'client-face theme switch signal — packages/client/ui-theme/README.md owns the surface',
|
||||
'commands/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API',
|
||||
'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the API',
|
||||
'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API',
|
||||
'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API',
|
||||
'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API',
|
||||
'session/preset-changed': 'client-face per-session catalog invalidation signal — packages/client/runtime/README.md owns the API',
|
||||
'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the API',
|
||||
'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API',
|
||||
'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API',
|
||||
'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API',
|
||||
'slash/input-insert-text': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API',
|
||||
'slots/changed': 'client-face slot invalidation signal — packages/client/runtime/README.md owns the API',
|
||||
'theme/change': 'client-face theme switch signal — packages/client/ui-theme/README.md owns the API',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,6 +229,25 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ResolvedRetryPolicy: 'llm-streaming.md',
|
||||
Message: 'llm-streaming.md',
|
||||
MessageSource: 'llm-streaming.md',
|
||||
MessageFeedbackDeleteRequest: 'feedback.md',
|
||||
MessageFeedbackDeleteResult: 'feedback.md',
|
||||
MessageFeedbackDeleteValue: 'feedback.md',
|
||||
MessageFeedbackFailure: 'feedback.md',
|
||||
MessageFeedbackItem: 'feedback.md',
|
||||
MessageFeedbackListRequest: 'feedback.md',
|
||||
MessageFeedbackListResult: 'feedback.md',
|
||||
MessageFeedbackListValue: 'feedback.md',
|
||||
MessageFeedbackNoteBlank: 'feedback.md',
|
||||
MessageFeedbackNoteTooLarge: 'feedback.md',
|
||||
MessageFeedbackPutRequest: 'feedback.md',
|
||||
MessageFeedbackPutResult: 'feedback.md',
|
||||
MessageFeedbackRating: 'feedback.md',
|
||||
MessageFeedbackRejected: 'feedback.md',
|
||||
MessageFeedbackSessionNotFound: 'feedback.md',
|
||||
MessageFeedbackSuccess: 'feedback.md',
|
||||
MessageFeedbackTargetNotFound: 'feedback.md',
|
||||
MessageFeedbackVersion: 'feedback.md',
|
||||
MessageFeedbackVersionConflict: 'feedback.md',
|
||||
UserMessage: 'session.md',
|
||||
PreStepDecision: 'core.md',
|
||||
PreStepContext: 'core.md',
|
||||
@@ -242,6 +265,9 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ApprovalPolicy: 'approval.md',
|
||||
ApprovalRequest: 'approval.md',
|
||||
ApprovalService: 'approval.md',
|
||||
ImageAttachmentRef: 'attachment.md',
|
||||
SaveImageAttachment: 'attachment.md',
|
||||
StoredImageAttachment: 'attachment.md',
|
||||
BashExecRequest: 'bash.md',
|
||||
BashExecSpec: 'bash.md',
|
||||
BashProcess: 'bash.md',
|
||||
@@ -282,7 +308,6 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
CommandDescriptor: 'commands.md',
|
||||
CommandId: 'commands.md',
|
||||
CommandResult: 'commands.md',
|
||||
CommandSurface: 'commands.md',
|
||||
LlmAdapter: 'llm-streaming.md',
|
||||
PreparedLlmCall: 'llm-streaming.md',
|
||||
LlmService: 'llm-streaming.md',
|
||||
@@ -295,6 +320,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SessionLocation: 'persistence.md',
|
||||
SessionPreparation: 'persistence.md',
|
||||
SessionPersistenceSnapshot: 'persistence.md',
|
||||
SessionRawArtifact: 'persistence.md',
|
||||
ConfinedArgv: 'sandbox.md',
|
||||
SandboxExecutionPolicy: 'sandbox.md',
|
||||
SandboxMode: 'sandbox.md',
|
||||
@@ -346,6 +372,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SkillProvider: 'skills.md',
|
||||
SkillProviderObservation: 'skills.md',
|
||||
SkillRegistration: 'skills.md',
|
||||
SkillViewOptions: 'skills.md',
|
||||
SkillSummary: 'skills.md',
|
||||
SaveTextSpill: 'spill.md',
|
||||
SpillRef: 'spill.md',
|
||||
@@ -376,6 +403,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
TaskRead: 'tasks.md',
|
||||
TaskSnapshot: 'tasks.md',
|
||||
TaskStart: 'tasks.md',
|
||||
TasksChangedListener: 'tasks.md',
|
||||
TokenMeasurement: 'token-meter.md',
|
||||
CodeDispatchLog: 'tools.md',
|
||||
PostToolDecision: 'tools.md',
|
||||
@@ -388,6 +416,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ToolExecutionResult: 'tools.md',
|
||||
ToolExecutionToken: 'tools.md',
|
||||
ToolGuard: 'tools.md',
|
||||
ToolPresentationMode: 'tools.md',
|
||||
ToolRegistry: 'tools.md',
|
||||
ToolRestriction: 'tools.md',
|
||||
ToolSchema: 'tools.md',
|
||||
@@ -453,6 +482,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
'Promise',
|
||||
'Record',
|
||||
'Readonly',
|
||||
'Uint8Array',
|
||||
])
|
||||
|
||||
/** Project types deliberately documented outside the subsystems catalog. */
|
||||
@@ -462,6 +492,9 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
||||
ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
||||
InsertTextRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
|
||||
AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md',
|
||||
AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md',
|
||||
PresetMetadata: 'preset display text is owned by packages/preset/agent-presets/README.md',
|
||||
BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts',
|
||||
BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
|
||||
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
|
||||
@@ -472,13 +505,13 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
'z.core.ToJSONSchemaParams': 'zod projection parameters are owned by the zod v4 API',
|
||||
TypeRTDisposer: 'TypeRT lifecycle contract is owned by packages/typert/type-meta/README.md',
|
||||
InvokeRemoteRequest: 'gateway invocation contract is owned by packages/api/gateway/README.md',
|
||||
LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
|
||||
LocaleDict: 'service-local dictionary fields are owned by packages/client/i18n/src/index.ts',
|
||||
ThemeTokens: 'service-local token dictionary is owned by packages/client/ui-theme/src/index.ts',
|
||||
Translate: 'service-local bound translator is owned by packages/client/i18n/src/index.ts',
|
||||
WebUpgradeRoute:
|
||||
'upgrade route registration contract is owned by packages/host/webserver/src/index.ts',
|
||||
InvariantRegistration: 'service-local lifecycle handle is owned by packages/support/invariants/README.md',
|
||||
KnobState: 'projection unit state shape is owned by packages/interaction/permission/README.md',
|
||||
KnobState: 'projection unit state fields are owned by packages/interaction/permission/README.md',
|
||||
PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission/src/types.ts',
|
||||
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
|
||||
Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md',
|
||||
@@ -528,8 +561,8 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
|
||||
|
||||
/**
|
||||
* Splice a page's generated cordis-surface region into its Markdown content.
|
||||
* The page must contain exactly one cordis-surface region (the markers are
|
||||
* Splice a page's generated Cordis API region into its Markdown content.
|
||||
* The page must contain exactly one `cordis-surface` marker region (the markers are
|
||||
* part of the hand-owned page skeleton once, then owned by the generator);
|
||||
* zero or several is a partition error the caller reports with the page path.
|
||||
* The match is on THIS generator's exact markers, not the generic region
|
||||
@@ -575,7 +608,7 @@ export interface WalkPartitionMaps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge the rendered surface and the independent AST scan against the curated
|
||||
* Judge the rendered API and the independent AST scan against the curated
|
||||
* partition maps, fail-closed in both directions for services AND events: a
|
||||
* rendered key/scope must be mapped to a page, a mapped key/scope must still
|
||||
* render, and — the backstop — a DECLARED key/event the projection cannot see
|
||||
@@ -583,7 +616,7 @@ export interface WalkPartitionMaps {
|
||||
* direction guards the scan itself: everything rendered must also be declared
|
||||
* to the scan, so a scan blind spot cannot decay silently. Pure so the
|
||||
* acceptance paths are provable without running the projection.
|
||||
* @param input - rendered surface plus the declared-key/event scans.
|
||||
* @param input - rendered API plus the declared-key/event scans.
|
||||
* @param maps - the curated page maps and walk exemptions.
|
||||
* @returns one message per violation, empty when the partition holds.
|
||||
*/
|
||||
@@ -634,7 +667,7 @@ export function walkPartitionProblems(input: WalkPartitionInput, maps: WalkParti
|
||||
// in a Context/Events merge the scan must also reach, so a rendered key or
|
||||
// event the scan cannot see means the SCAN regressed (glob, prefilter, or
|
||||
// block walk) — a partial blind spot that exemption staleness alone would
|
||||
// never surface.
|
||||
// never appear.
|
||||
for (const key of input.renderedKeys.keys()) {
|
||||
if (!input.declaredKeys.has(key)) problems.push(`ctx.${key} is rendered by the projection but the independent scan finds no Context merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
|
||||
}
|
||||
@@ -741,7 +774,7 @@ export function maybeRecordPair(pageRel: string, before: Map<string, Buffer>, sc
|
||||
// after review, never silently by regeneration.
|
||||
return false
|
||||
}
|
||||
// The record must be exactly the well-formed two-entry shape for THIS pair;
|
||||
// The record must contain exactly the two valid entries for THIS pair;
|
||||
// a malformed or renamed-key sidecar is the pairing gate's problem to
|
||||
// report, never something regeneration silently repairs into validity.
|
||||
const recorded = parsePairMeta(meta)
|
||||
|
||||
+55
-20
@@ -62,6 +62,7 @@ type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service'
|
||||
|
||||
const GROUP_ORDER = [
|
||||
'util',
|
||||
'attachment',
|
||||
'llm',
|
||||
'core',
|
||||
'typert',
|
||||
@@ -95,6 +96,15 @@ const GROUP_ORDER = [
|
||||
]
|
||||
|
||||
const SERVICE_ROLES: ServiceRole[] = [
|
||||
{
|
||||
key: 'attachments',
|
||||
pkg: 'attachment',
|
||||
title: 'Durable binary attachment storage',
|
||||
mode: 'seam',
|
||||
implementations: ['attachment-local'],
|
||||
consumers: ['host-runtime', 'llm-pi-ai'],
|
||||
note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.',
|
||||
},
|
||||
{
|
||||
key: 'llm',
|
||||
pkg: 'llm',
|
||||
@@ -125,7 +135,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'session',
|
||||
title: 'In-memory session store',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'],
|
||||
consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'],
|
||||
note: 'Owns append-only Session instances and emits the durable session event feed.',
|
||||
},
|
||||
{
|
||||
@@ -157,7 +167,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Durable session persistence seam',
|
||||
mode: 'seam',
|
||||
implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'],
|
||||
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'],
|
||||
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'],
|
||||
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
|
||||
},
|
||||
{
|
||||
@@ -201,9 +211,16 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
pkg: 'storage-domain',
|
||||
title: 'Domain data facility',
|
||||
mode: 'core',
|
||||
consumers: ['workspace'],
|
||||
consumers: ['workspace', 'message-feedback'],
|
||||
note: 'Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state.',
|
||||
},
|
||||
{
|
||||
key: 'messageFeedback',
|
||||
pkg: 'message-feedback',
|
||||
title: 'Lifecycle-bound message feedback',
|
||||
mode: 'core',
|
||||
note: 'Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry.',
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
pkg: 'workspace',
|
||||
@@ -258,7 +275,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Human question/answer seam',
|
||||
mode: 'seam',
|
||||
consumers: ['tool-ask-user'],
|
||||
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
|
||||
note: 'UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
|
||||
},
|
||||
{
|
||||
key: 'planMode',
|
||||
@@ -267,6 +284,13 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'core',
|
||||
note: 'Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions.',
|
||||
},
|
||||
{
|
||||
key: 'agentPresets',
|
||||
pkg: 'agent-presets',
|
||||
title: 'Per-session agent composition',
|
||||
mode: 'core',
|
||||
note: 'Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm.',
|
||||
},
|
||||
{
|
||||
key: 'commands',
|
||||
pkg: 'commands',
|
||||
@@ -313,7 +337,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Default Agent model selection',
|
||||
mode: 'core',
|
||||
consumers: ['headless', 'host-apiproxy'],
|
||||
note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner.',
|
||||
note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.',
|
||||
},
|
||||
{
|
||||
key: 'agentLoop',
|
||||
@@ -371,7 +395,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'seam',
|
||||
implementations: ['pty-local'],
|
||||
consumers: ['tool-pty'],
|
||||
note: 'The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model surface.',
|
||||
note: 'The registry owns exact-Agent session identity and cleanup; backends own terminal mechanics, while tool-pty exposes the owner-scoped model tools.',
|
||||
},
|
||||
{
|
||||
key: 'sandbox',
|
||||
@@ -452,7 +476,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'seam',
|
||||
implementations: ['tasks-local'],
|
||||
consumers: ['tool-bash', 'tool-pty', 'tool-subagent', 'tool-tasks'],
|
||||
note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry.',
|
||||
note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing controller that reads, lists, and kills it; tasks-local is the process-local registry.',
|
||||
},
|
||||
{
|
||||
key: 'web',
|
||||
@@ -495,7 +519,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Client plugin graph host',
|
||||
mode: 'core',
|
||||
consumers: ['hmr'],
|
||||
note: 'Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers.',
|
||||
note: 'Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers.',
|
||||
},
|
||||
{
|
||||
key: 'workflows',
|
||||
@@ -504,7 +528,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'seam',
|
||||
implementations: ['workflow-workerthread'],
|
||||
consumers: ['tool-workflow', 'tool-ralph'],
|
||||
note: 'One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.',
|
||||
note: 'One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents.',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -675,7 +699,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string
|
||||
lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`)
|
||||
lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`)
|
||||
if (pluginName === '@deepseek-ai/dsh-acp-demo') {
|
||||
lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`)
|
||||
lines.push(` ${appNode} --> ${nodeId('entrypoint', 'acp')}["@deepseek-ai/dsh-acp<br/>automation-only JSON-RPC stdio<br/>fresh sessions created by client"]`)
|
||||
}
|
||||
lines.push(
|
||||
` ${agentCore} --> ${nodeId('spine', 'llm')}["ctx.llm"]`,
|
||||
@@ -727,7 +751,18 @@ type CallSiteIndex = Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExp
|
||||
*/
|
||||
const EVENT_API_METHODS = new Set(['on', 'once', 'emit', 'parallel', 'serial', 'waterfall', 'dispatch'])
|
||||
|
||||
/** Collect event dispatch/listener relations from real cross-file receiver types. */
|
||||
/**
|
||||
* Collect event dispatch/listener relations from real cross-file receiver types.
|
||||
*
|
||||
* TODO: the program is seeded from the host aggregate alone (ts-project.ts
|
||||
* documents why: one program cannot hold both faces' Context merges), so a
|
||||
* Client package enters only when a host file imports it. Client-face
|
||||
* listeners on client-face events are therefore under-reported —
|
||||
* `connection/reset` omits `ui-skill`/`ui-agent-preset`, `models/changed`
|
||||
* omits `ui-model`, `session/preset-changed` omits `ui-skill`. Closing it
|
||||
* needs a second Client program whose relations merge into these, not a
|
||||
* wider seed.
|
||||
*/
|
||||
export class EventRelationCollector {
|
||||
private readonly relations = new Map<string, EventRelation>()
|
||||
private readonly fileCallSites = new Map<ts.SourceFile, CallSiteIndex>()
|
||||
@@ -787,7 +822,7 @@ export class EventRelationCollector {
|
||||
* Return every indexed call resolving to one local helper declaration.
|
||||
* Fast path: when every same-file reference to the non-exported helper is
|
||||
* provably a direct callee, module scoping confines all of its calls to that
|
||||
* file, so only that file is indexed. Any other reference shape may alias
|
||||
* file, so only that file is indexed. Any other reference form may alias
|
||||
* the function value outward, so the original full package-source index
|
||||
* decides instead.
|
||||
*/
|
||||
@@ -1127,7 +1162,7 @@ function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): strin
|
||||
lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
|
||||
}
|
||||
// Every declared event needs a dispatcher: zero means dead vocabulary or an
|
||||
// unrecognized semantic dispatch shape. Listener-free extension points remain
|
||||
// unrecognized semantic dispatch form. Listener-free extension points remain
|
||||
// valid. Client-declared events are exempt: the relation scan seeds the HOST
|
||||
// aggregate program only (host+client cannot share one program — the cordis
|
||||
// Context merges collide), so client dispatch sites are structurally
|
||||
@@ -1140,8 +1175,8 @@ function renderEventRelations(pkgs: Pkg[], events: readonly EventEntry[]): strin
|
||||
if (undispatched.length > 0) {
|
||||
throw new Error(
|
||||
`event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} `
|
||||
+ `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch shape the semantic scan misses `
|
||||
+ '(teach scripts/gen-doc-graphs.ts the shape)',
|
||||
+ `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch form the semantic scan misses `
|
||||
+ '(teach scripts/gen-doc-graphs.ts that form)',
|
||||
)
|
||||
}
|
||||
const declared = new Set(events.map(event => event.name))
|
||||
@@ -1234,9 +1269,9 @@ function renderLifecycle(): string {
|
||||
'',
|
||||
'`dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
|
||||
'',
|
||||
'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later boundary claims their next-step batch.',
|
||||
'The returned `agent/pre-step` decision is authoritative; listeners wrapping `next()` preserve downstream messages unless replacement is intentional. Steering and injected context pass through the same waterfall after a later claim operation takes their next-step batch.',
|
||||
'',
|
||||
'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination surface for queue/status, prompt interception, request shaping, steering, continuation, and errors.',
|
||||
'SDK users that need replayable transcript data should consume `session/event`; `agent/*` is the live coordination API for queue/status, prompt interception, request construction, steering, continuation, and errors.',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
].join('\n')
|
||||
@@ -1246,7 +1281,7 @@ function renderToolPipeline(): string {
|
||||
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
|
||||
return [
|
||||
...generatedHeader('Tool Execution Pipeline'),
|
||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards, definition-owned `finalizeContent`, and `tools/result` are the owner-enforced boundaries around them.',
|
||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `finalizeContent` and `tools/result` run afterward.',
|
||||
'',
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
@@ -1300,7 +1335,7 @@ function renderToolPipeline(): string {
|
||||
' allResults --> context',
|
||||
'```',
|
||||
'',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, surface denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
].join('\n')
|
||||
@@ -1352,7 +1387,7 @@ function renderIndex(docs: GraphDoc[]): string {
|
||||
const maintenance = 'mixed: each linked page declares generated, hybrid, or curated mode'
|
||||
return [
|
||||
...generatedHeader('Documentation Graph Index'),
|
||||
'These diagrams are the relationship layer above the generated catalogs. Use them to navigate package topology, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type shapes still live in the [subsystem pages](subsystems/core.md) (types + the generated `cordis-surface` regions) and [tool-catalog.md](tool-catalog.md).',
|
||||
'These diagrams show relationships that the generated catalogs do not. Use them to find package relationships, capability seams, event flow, model-facing tools, app composition, and runtime lifecycle paths. Exact signatures and type definitions still live in the [subsystem pages](subsystems/core.md) (types + the generated Cordis API regions) and [tool-catalog.md](tool-catalog.md).',
|
||||
'',
|
||||
'The process decision behind this index is recorded in [the documentation graph Agent Note](../.agents/notes/archived/process/2026-07-03-documentation-graph-atlas.md).',
|
||||
'',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/persistence-catalog.md'
|
||||
const OUT_RUNTIME_TYPES = 'packages/core/session/src/known-event-types.ts'
|
||||
|
||||
/** The fenced-block info string for generated declaration blocks (skipped by
|
||||
* doc-typecheck, since their imported types are not standalone-compilable). */
|
||||
@@ -360,7 +361,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
|
||||
'',
|
||||
'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).',
|
||||
'',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
|
||||
'',
|
||||
'## Event envelope',
|
||||
'',
|
||||
@@ -383,31 +384,79 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** CLI entry: default writes the catalog, `--check` fails if the committed copy
|
||||
/**
|
||||
* Render the runtime known-vocabulary module: every event type the packages in
|
||||
* this repo can write, as a generated `ReadonlySet` the read path checks
|
||||
* unknown-type refusal against (`SessionEvent.ignorable` contract).
|
||||
*/
|
||||
export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string {
|
||||
const names = [...new Set(events.map(e => e.name))].sort()
|
||||
return [
|
||||
'/**',
|
||||
' * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run',
|
||||
' * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by',
|
||||
' * `pnpm run verify-persistence-catalog`, part of `doc-sync`).',
|
||||
' * @module @deepseek-ai/dsh-session/known-event-types',
|
||||
' */',
|
||||
'',
|
||||
'/**',
|
||||
' * Every `SessionEventMap` member declared in this repository — the event',
|
||||
' * vocabulary this build understands. The persistence read path refuses to',
|
||||
' * interpret a log containing a type outside this set unless the event',
|
||||
' * carries the envelope\'s `ignorable` marker (see `SessionEvent.ignorable`',
|
||||
' * in `./types.ts`): such a log was likely written by a newer harness, and',
|
||||
' * silently skipping a required event would reconstruct a wrong session.',
|
||||
' * Downstream (out-of-repo) plugin events are outside this list by',
|
||||
' * construction; a registration surface for them is deferred until such a',
|
||||
' * consumer exists.',
|
||||
' */',
|
||||
'export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([',
|
||||
...names.map(name => ` '${name}',`),
|
||||
'])',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** One generated artifact: repo-relative target and its freshly-rendered content. */
|
||||
interface GeneratedArtifact {
|
||||
readonly out: string
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
/** CLI entry: default writes the artifacts, `--check` fails if a committed copy
|
||||
* is stale. Guarded behind an entry-point check so importing this module for
|
||||
* tests neither regenerates the committed file nor calls process.exit. */
|
||||
* tests neither regenerates the committed files nor calls process.exit. */
|
||||
function main(): void {
|
||||
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes())
|
||||
const events = annotateSurface(collectLogEvents(), collectSurfaceEventTypes())
|
||||
const artifacts: GeneratedArtifact[] = [
|
||||
{ out: OUT, content: render(events, collectEventEnvelopeTypes()) },
|
||||
{ out: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
|
||||
]
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(resolve(root, OUT), 'utf8')
|
||||
} catch {
|
||||
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
|
||||
// file is not a state this repo produces. Either way the remedy is the
|
||||
// same — regenerate — so treat a read failure as "stale".
|
||||
committed = null
|
||||
}
|
||||
if (committed === content) {
|
||||
console.log(`gen-persistence-catalog: ${OUT} is up to date.`)
|
||||
const stale = artifacts.filter((artifact) => {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(resolve(root, artifact.out), 'utf8')
|
||||
} catch {
|
||||
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
|
||||
// file is not a state this repo produces. Either way the remedy is the
|
||||
// same — regenerate — so treat a read failure as "stale".
|
||||
committed = null
|
||||
}
|
||||
return committed !== artifact.content
|
||||
})
|
||||
if (stale.length === 0) {
|
||||
console.log(`gen-persistence-catalog: ${artifacts.map(a => a.out).join(', ')} are up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-persistence-catalog: ${OUT} is stale. Run \`pnpm run gen-persistence-catalog\` and commit ${OUT}.`)
|
||||
console.error(`gen-persistence-catalog: ${stale.map(a => a.out).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
writeFileSync(resolve(root, OUT), content)
|
||||
console.log(`gen-persistence-catalog: wrote ${OUT}.`)
|
||||
for (const artifact of artifacts) {
|
||||
writeFileSync(resolve(root, artifact.out), artifact.content)
|
||||
console.log(`gen-persistence-catalog: wrote ${artifact.out}.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Run only when invoked as a script, not when imported by a test.
|
||||
|
||||
@@ -309,14 +309,14 @@ class ScopedEventGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
/** Return whether an Events interface is inside declare module 'cordis'. */
|
||||
/** Return whether an Events interface is inside declare module '@deepseek-ai/cordis'. */
|
||||
function isCordisModuleInterface(node: ts.InterfaceDeclaration): boolean {
|
||||
const block = node.parent
|
||||
const declaration = block.parent
|
||||
return ts.isModuleBlock(block)
|
||||
&& ts.isModuleDeclaration(declaration)
|
||||
&& ts.isStringLiteral(declaration.name)
|
||||
&& declaration.name.text === 'cordis'
|
||||
&& declaration.name.text === '@deepseek-ai/cordis'
|
||||
}
|
||||
|
||||
/** Return whether a parameter is the explicit TypeScript this receiver. */
|
||||
|
||||
@@ -134,13 +134,17 @@ describe('parseVendoredRows', () => {
|
||||
const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
|
||||
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
expect(rows).toContainEqual({ npmName: 'cordis', upstream: 'https://github.com/cordiverse/cordis' })
|
||||
expect(rows).toContainEqual({
|
||||
npmName: '@deepseek-ai/cordis',
|
||||
upstreamName: 'cordis',
|
||||
upstream: 'https://github.com/cordiverse/cordis',
|
||||
})
|
||||
// The upstream column carries a trailing package path for some rows; it is not part of the URL.
|
||||
expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true)
|
||||
})
|
||||
|
||||
it('yields nothing when the table shape changes, so the generator fails loud', () => {
|
||||
expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([])
|
||||
it('yields nothing when the table columns change, so the generator fails loud', () => {
|
||||
expect(parseVendoredRows('| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('covers every vendored directory, so no package can drop out of the notices', () => {
|
||||
@@ -215,7 +219,7 @@ describe('parsePyprojectRequirements', () => {
|
||||
].join('\n'))).toEqual(['pydantic', 'tomli', 'pytest'])
|
||||
})
|
||||
|
||||
it('accepts dependency-group includes and rejects unsupported requirement shapes', () => {
|
||||
it('accepts dependency-group includes and rejects unsupported requirement forms', () => {
|
||||
expect(parsePyprojectRequirements('[dependency-groups]\nbase = ["pytest"]\nall = [{ include-group = "base" }]\n'))
|
||||
.toEqual(['pytest'])
|
||||
expect(() => parsePyprojectRequirements('[project]\ndependencies = "pytest"\n')).toThrow(/must be an array/)
|
||||
@@ -227,7 +231,7 @@ describe('collectPythonDependencies', () => {
|
||||
it('excludes normalized local project names without exempting a third-party prefix', () => {
|
||||
const pyprojects = [
|
||||
'[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n',
|
||||
'[project]\nname = "deepseek-harness"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n',
|
||||
'[project]\nname = "deepseek-harness-sdk"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n',
|
||||
]
|
||||
expect(() => collectPythonDependencies(pyprojects)).toThrow(
|
||||
'python dependency deepseek-unrelated is missing from PYTHON_METADATA',
|
||||
|
||||
@@ -27,8 +27,8 @@ const ALL_KINDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'p
|
||||
* root manifest), test infrastructure, the documentation site, the runnable
|
||||
* demo leaves, and the native launcher's build workspace. A runtime
|
||||
* declaration by anything outside these areas is a disclosure-relevant
|
||||
* runtime dependency, because `scripts/install.sh` installs the repository
|
||||
* itself and any plugin package can be mounted from a user's `cordis.yml`.
|
||||
* runtime dependency because any plugin package can be mounted from a user's
|
||||
* `cordis.yml`.
|
||||
*/
|
||||
const DEV_ONLY_AREAS = [
|
||||
'package.json',
|
||||
@@ -83,7 +83,7 @@ const OVERRIDES: Record<string, { license?: string; repo?: string }> = {
|
||||
* the generator fails when a manifest names a package this map misses.
|
||||
*/
|
||||
const PYTHON_METADATA: Record<string, { license: string; repo: string; role: string }> = {
|
||||
pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness`' },
|
||||
pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness-sdk`' },
|
||||
hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' },
|
||||
pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' },
|
||||
}
|
||||
@@ -369,7 +369,7 @@ function collectNpmDeps(): ExternalDep[] {
|
||||
*/
|
||||
export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<string>): Map<string, boolean> {
|
||||
const tiers = new Map<string, boolean>()
|
||||
// `tsx` is runtime by fiat: `bin/dsh` execs the CLI through its ESM hook.
|
||||
// `tsx` is runtime by fiat: the root source-run scripts execute through its ESM hook.
|
||||
tiers.set('tsx', true)
|
||||
for (const [path, manifest] of manifests) {
|
||||
const devOnly = DEV_ONLY_AREAS.some(area => (area.endsWith('/') ? path.startsWith(area) : path === area))
|
||||
@@ -387,6 +387,8 @@ export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<st
|
||||
/** A vendored package row parsed out of the `vendor/README.md` manifest table. */
|
||||
export interface VendoredRow {
|
||||
npmName: string
|
||||
/** The name this package carries upstream; MIT attribution names the fork's origin, not our scope. */
|
||||
upstreamName: string
|
||||
upstream: string
|
||||
}
|
||||
|
||||
@@ -398,11 +400,12 @@ export interface VendoredRow {
|
||||
export function parseVendoredRows(text: string): VendoredRow[] {
|
||||
const rows: VendoredRow[] = []
|
||||
for (const line of text.split('\n')) {
|
||||
const match = /^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \S+ \| (https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$/.exec(line)
|
||||
const match = new RegExp(String.raw`^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \x60([^\x60]+)\x60 \| \S+ \| `
|
||||
+ String.raw`(https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$`).exec(line)
|
||||
if (match === null) continue
|
||||
const [, npmName, upstream] = match
|
||||
if (npmName === undefined || upstream === undefined) continue
|
||||
rows.push({ npmName, upstream })
|
||||
const [, npmName, upstreamName, upstream] = match
|
||||
if (npmName === undefined || upstreamName === undefined || upstream === undefined) continue
|
||||
rows.push({ npmName, upstreamName, upstream })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -475,7 +478,7 @@ function collectPythonRequirementArray(
|
||||
}
|
||||
}
|
||||
|
||||
/** Read an optional TOML table and reject a present value of another shape. */
|
||||
/** Read an optional TOML table and reject a present non-table value. */
|
||||
function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: string): TomlTableWithoutBigInt | undefined {
|
||||
if (value === undefined || isTomlTable(value)) return value
|
||||
throw new Error(`gen-third-party-notices: ${location} must be a table.`)
|
||||
@@ -487,7 +490,7 @@ function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location:
|
||||
* `[build-system]`, `dependencies` under `[project]`, and every key under
|
||||
* `[project.optional-dependencies]` and `[dependency-groups]`. A TOML parser
|
||||
* owns comments, quoted keys, escapes, and array boundaries; unsupported
|
||||
* requirement shapes fail instead of disappearing from the notices.
|
||||
* requirement forms fail instead of disappearing from the notices.
|
||||
* @param text - the complete `pyproject.toml` contents.
|
||||
* @returns the local project name and declared requirement names.
|
||||
*/
|
||||
@@ -696,15 +699,15 @@ The complete npm transitive closure, including the Landlock launcher workspace,
|
||||
|
||||
## Vendored source (\`vendor/\`)
|
||||
|
||||
The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream \`LICENSE\` file. Exact upstream commits and local modifications are recorded in [\`vendor/README.md\`](vendor/README.md).
|
||||
The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the \`@deepseek-ai\` scope. All are MIT-licensed; each directory preserves its upstream \`LICENSE\` file. Exact upstream commits and local modifications are recorded in [\`vendor/README.md\`](vendor/README.md).
|
||||
|
||||
| Package | Upstream | License |
|
||||
| --- | --- | --- |
|
||||
${vendored.map(row => `| \`${row.npmName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')}
|
||||
| Package | Upstream name | Upstream | License |
|
||||
| --- | --- | --- | --- |
|
||||
${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')}
|
||||
|
||||
## Runtime npm dependencies
|
||||
|
||||
External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default.
|
||||
External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default.
|
||||
|
||||
${renderNpmTable(runtimeDeps)}
|
||||
|
||||
|
||||
+48
-19
@@ -8,7 +8,7 @@
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -24,13 +24,15 @@ import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
|
||||
import WebService from '@deepseek-ai/dsh-web'
|
||||
import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
|
||||
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider, SubagentReportDelivery } from '@deepseek-ai/dsh-subagent'
|
||||
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
|
||||
import * as ToolSubagentListAgents from '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
||||
import * as ToolSubagentReport from '@deepseek-ai/dsh-tool-subagent-report'
|
||||
@@ -61,6 +63,29 @@ import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
|
||||
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
|
||||
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
/** Attachment seam marker that makes the attachments-conditional `read_image` schema harvestable. */
|
||||
class CatalogAttachmentStore extends AttachmentStore {
|
||||
readonly imageLimits: ImageAttachmentLimits = Object.freeze({
|
||||
maxImageBytes: 1,
|
||||
maxImagesPerMessage: 1,
|
||||
maxMessageImageBytes: 1,
|
||||
maxImagePixels: 1,
|
||||
mediaTypes: Object.freeze(['image/png'] as const),
|
||||
})
|
||||
|
||||
override validateImage(_input: SaveImageAttachment): Promise<void> {
|
||||
return Promise.reject(new Error('gen-tool-catalog: attachment validation is unreachable during schema harvest'))
|
||||
}
|
||||
|
||||
override saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
return Promise.reject(new Error('gen-tool-catalog: attachment writes are unreachable during schema harvest'))
|
||||
}
|
||||
|
||||
override readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
return Promise.reject(new Error('gen-tool-catalog: attachment reads are unreachable during schema harvest'))
|
||||
}
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/tool-catalog.md'
|
||||
|
||||
@@ -121,7 +146,7 @@ interface ToolPackage {
|
||||
* name to its own source.
|
||||
*/
|
||||
source: string | Readonly<Record<string, string>>
|
||||
/** Services or owning runtime surfaces the package requires at execution time. */
|
||||
/** Services or owning runtimes the package requires at execution time. */
|
||||
requires: string[]
|
||||
/** Session events or other visible state the tools write or affect. */
|
||||
writes: string[]
|
||||
@@ -135,14 +160,14 @@ interface ToolPackage {
|
||||
/**
|
||||
* Config for the caller's `ToolRegistry` mount. The registry itself ships a
|
||||
* model-facing tool (`run_code`, registered under a non-native `mode`), so
|
||||
* ITS catalog entry boots the registry in the mode that surfaces it;
|
||||
* ITS catalog entry boots the registry in the mode that exposes it;
|
||||
* every other entry uses the default (native) registry.
|
||||
*/
|
||||
toolsConfig?: ToolsConfig
|
||||
/**
|
||||
* A deployment note rendered after the package's tools, for a fact that
|
||||
* booting the package alone cannot show. The registered tool NAME can be a
|
||||
* load-time config (`tool-subagent`'s `toolName`), so one package may surface
|
||||
* load-time config (`tool-subagent`'s `toolName`), so one package may appear
|
||||
* under several names across deployments — the boot yields the package
|
||||
* DEFAULT, and this note records the shipped alternatives the model sees.
|
||||
*/
|
||||
@@ -225,7 +250,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolPwsh)
|
||||
},
|
||||
note:
|
||||
'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus the sandbox surface — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\\...` paths and `$env:NAME` variables.',
|
||||
'The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\\...` paths and `$env:NAME` variables.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-cordis',
|
||||
@@ -263,22 +288,24 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolStrReplaceEditor)
|
||||
},
|
||||
note:
|
||||
'Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface.',
|
||||
'Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal API.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs',
|
||||
dir: 'tool-fs',
|
||||
source: 'packages/fs/tool-fs/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful mutation', 'tool/result'],
|
||||
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt', 'ctx.attachments (read_image registration)', 'ctx.llm + an image-capable route (read_image execution)'],
|
||||
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful file operation', 'durable attachment (read_image)', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tool needs `fs`; the bare provider is sufficient because policy
|
||||
// changes behavior, not schema shape.
|
||||
// changes behavior, not schema shape. The catalog seam marker opts into
|
||||
// the attachments-conditional read_image schema without attachment I/O.
|
||||
await ctx.plugin(LocalFileSystem)
|
||||
await ctx.plugin(CatalogAttachmentStore)
|
||||
await ctx.plugin(ToolFs)
|
||||
},
|
||||
note:
|
||||
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.',
|
||||
'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-fs-search',
|
||||
@@ -418,7 +445,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolSubagent, { provider: 'mock' })
|
||||
},
|
||||
note:
|
||||
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`.',
|
||||
'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped compositions load this package once per subagent backend, so the model additionally sees `subagent_fork` bound to the fork backend. Each instance\'s description and `run_in_background` parameter follow its own `backgroundMode` and `enableRunInBackground`, so the two shipped schemas are not identical: `subagent` is `continuable`, while `subagent_fork` stays `one-shot` — see `packages/bundle/base/cordis.patch.yml` and `examples/acp-agent/cordis.yml`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
@@ -446,20 +473,22 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-report',
|
||||
dir: 'tool-subagent-report',
|
||||
source: 'packages/subagent/tool-subagent-report/src/index.ts',
|
||||
requires: ['ctx.subagents', 'a live continuable in-process child Agent'],
|
||||
requires: ['ctx.subagents', 'ctx.systemPrompt', 'a live continuable in-process child Agent'],
|
||||
writes: ['tool/call', 'tool/result', 'a user-role message in the direct parent session'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
const { reportDelivery } = ToolSubagentReport.Config({}) as { reportDelivery: SubagentReportDelivery }
|
||||
await mountCatalogChildScope(ctx, (childCtx) => {
|
||||
ToolSubagentReport.installReportTool(childCtx, ctx, 'quiet')
|
||||
ToolSubagentReport.installReportTool(childCtx, ctx, reportDelivery)
|
||||
})
|
||||
},
|
||||
scope: ctx => catalogChildScopes.get(ctx) as Agent,
|
||||
note:
|
||||
'Registered per continuable in-process child rather than globally, so this schema is visible only '
|
||||
+ 'inside such a child and survives its global `toolFilter`. The parent-facing `send_message` tool '
|
||||
+ 'is installed independently.',
|
||||
+ 'inside such a child and survives its global `toolFilter`. The same contribution installs the '
|
||||
+ 'child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing '
|
||||
+ '`send_message` tool is installed independently.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-tasks',
|
||||
@@ -472,7 +501,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
await ctx.plugin(ToolTasks)
|
||||
},
|
||||
note:
|
||||
'The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers\' `ctx.tasks.start()`.',
|
||||
'The kind-agnostic background-task controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers\' `ctx.tasks.start()`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-todo',
|
||||
@@ -633,11 +662,11 @@ export function render(catalog: ToolCatalog): string {
|
||||
'',
|
||||
'# Tool Schema Catalog',
|
||||
'',
|
||||
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [subsystem pages](subsystems/core.md) (the types plus each page\'s generated `cordis-surface` wiring region) — this page is the *tools* the agent is offered.',
|
||||
'Every model-facing tool a shipped plugin contributes to `ctx.tools`: the `name`, `description`, and JSON-Schema `parameters` the model receives via the system-prompt assembly. It complements the [subsystem pages](subsystems/core.md) (the types plus each page\'s generated Cordis API region) — this page is the *tools* the agent is offered.',
|
||||
'',
|
||||
'This file is GENERATED and verified fresh by `pnpm run verify-tool-catalog` (part of `doc-sync`) — do not edit it by hand. Unlike the cordis catalog (a pure source-AST pass), this generator BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`, because a tool schema is not statically knowable (runtime-spread enums, concatenated descriptions, config-driven names, raw-JSON-Schema MCP tools). A completeness guard globs `packages/*/tool-*` and fails if any package is missing from the generator\'s boot manifest, so a new tool cannot be silently undocumented. See [the tool-schema-catalog Agent Note](../.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md).',
|
||||
'',
|
||||
'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config, except where a Config field is REQUIRED with no default — there the generator must choose, and the per-package note records which branch this page shows. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may surface a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.',
|
||||
'Scope: shipped product tools under `packages/*/tool-*`, each booted with its DEFAULT config, except where a Config field is REQUIRED with no default — there the generator must choose, and the per-package note records which branch this page shows. The registered tool NAME can be a load-time config (e.g. `tool-subagent`\'s `toolName`), so a deployment may expose a package under a different or additional name — a per-package note records those shipped aliases where they exist. The `examples/` demo tools (e.g. `echo`) are excluded, matching the cordis catalog\'s packages-only scope.',
|
||||
'',
|
||||
'## Tool Package Map',
|
||||
'',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* the narrowest safe granularity — code-fence-only splice, changed
|
||||
* Markdown units, heading sections, whole document — and `--apply` writes
|
||||
* the computed counterpart for pairs whose change is code-fence-only.
|
||||
* The briefing contract lives in `scripts/translation-brief.ts`; the
|
||||
* The briefing rules live in `scripts/translation-brief.ts`; the
|
||||
* consuming workflow is `.agents/skills/dsh-translate-docs/SKILL.md`.
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
// Regression drive for the unified hero composer:
|
||||
// cold start with zero workspaces -> create a workspace -> type. Asserts the
|
||||
// composer textarea is the SAME DOM node across the disabled->live flip (a
|
||||
// remount drops the __heroMark marker property) — the session-maybe
|
||||
// composer.bar contract.
|
||||
//
|
||||
// Prereqs: `pnpm run build`, then a fresh server against empty state:
|
||||
// rm -rf .storages && DSH_HOME=$(mktemp -d) node --experimental-transform-types \
|
||||
// --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web --port 44285 \
|
||||
// --workspace-root $(mktemp -d)
|
||||
// Run: node scripts/hero-composer-dom-continuity.mjs
|
||||
// (BASE_URL overrides the target; screenshots land in .artifacts/.)
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
// playwright is a devDependency of apps/web only — resolve through its tree.
|
||||
const require = createRequire(new URL('../apps/web/package.json', import.meta.url))
|
||||
const { chromium } = require('playwright')
|
||||
|
||||
const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:44285'
|
||||
const SHOTS = new URL('../.artifacts/screenshots/0729-0357-hero-unify/', import.meta.url).pathname
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
|
||||
page.on('console', msg => { if (msg.type() === 'error') console.log('[console.error]', msg.text()) })
|
||||
page.on('pageerror', err => { console.log('[pageerror]', err.message) })
|
||||
|
||||
await page.goto(BASE)
|
||||
await page.waitForSelector('textarea', { timeout: 20000 })
|
||||
await page.screenshot({ path: SHOTS + '01-cold-start.png' })
|
||||
|
||||
const initial = await page.evaluate(() => {
|
||||
const boxes = [...document.querySelectorAll('textarea')]
|
||||
boxes.forEach((b, i) => { b.__heroMark = 'alive-' + i })
|
||||
return boxes.map(b => ({ disabled: b.disabled, placeholder: b.placeholder }))
|
||||
})
|
||||
console.log('cold-start textareas:', JSON.stringify(initial))
|
||||
|
||||
// Open the picker and create a workspace by name (typed-input flow). The name
|
||||
// must be unique per registry; keystrokes go through pressSequentially so the
|
||||
// dialog's React onChange enables the submit button.
|
||||
await page.getByRole('button', { name: 'Choose workspace' }).click()
|
||||
await page.getByText('Create a new workspace').click()
|
||||
await page.screenshot({ path: SHOTS + '03-create-form.png' })
|
||||
const nameBox = page.getByPlaceholder('Workspace name')
|
||||
await nameBox.click()
|
||||
const wsName = 'proj-' + Date.now().toString(36)
|
||||
await nameBox.pressSequentially(wsName, { delay: 30 })
|
||||
await page.locator('button:text-is("Create workspace")').click()
|
||||
|
||||
// Wait for the composer to go live (placeholder flips, textarea enabled).
|
||||
await page.waitForFunction(() => {
|
||||
const box = document.querySelector('textarea')
|
||||
return box !== null && !box.disabled
|
||||
}, { timeout: 20000 })
|
||||
await page.screenshot({ path: SHOTS + '04-live.png' })
|
||||
|
||||
const after = await page.evaluate(() => {
|
||||
const boxes = [...document.querySelectorAll('textarea')]
|
||||
return boxes.map(b => ({
|
||||
mark: b.__heroMark ?? 'REMOUNTED',
|
||||
disabled: b.disabled,
|
||||
placeholder: b.placeholder,
|
||||
}))
|
||||
})
|
||||
console.log('post-pick textareas:', JSON.stringify(after))
|
||||
|
||||
// Type into the live composer.
|
||||
await page.locator('textarea').first().fill('hello from acceptance run')
|
||||
const typed = await page.evaluate(() => document.querySelector('textarea')?.value)
|
||||
console.log('typed value:', JSON.stringify(typed))
|
||||
await page.screenshot({ path: SHOTS + '05-typed.png' })
|
||||
|
||||
const survived = after.length === 1 && after[0].mark === 'alive-0'
|
||||
console.log(survived
|
||||
? 'DOM-CONTINUITY: PASS (same textarea node across cold-start -> live)'
|
||||
: 'DOM-CONTINUITY: FAIL ' + JSON.stringify(after))
|
||||
await browser.close()
|
||||
process.exit(survived && typed === 'hello from acceptance run' ? 0 : 1)
|
||||
@@ -610,7 +610,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('sibling dormant worktree config')
|
||||
expect(result.stderr).toContain(linkedConfig)
|
||||
expect(result.stderr).toContain(JSON.stringify(linkedConfig))
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks)
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
#!/bin/sh
|
||||
# dsh one-line installer.
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/scripts/install.sh | sh
|
||||
#
|
||||
# It clones the harness under ~/.dsh/source (the master clone at
|
||||
# ~/.dsh/source/master), adds a per-install staging worktree at
|
||||
# ~/.dsh/source/staging-<timestamp> on branch dsh-staging/<timestamp>, checks
|
||||
# host dependencies (git, Node, pnpm) and offers to install a missing pnpm, runs
|
||||
# `pnpm install`, points the stable `~/.dsh/source/current` symlink
|
||||
# at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`,
|
||||
# records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot,
|
||||
# builds the repository artifacts, and launches the Web UI. Keeping every
|
||||
# checkout under ~/.dsh/source keeps successive
|
||||
# upgrades in one place instead of scattered sibling clones, and lets staging
|
||||
# worktrees share the master clone's object store. The PATH symlink resolves through
|
||||
# `current`, so an upgrade repoints one stable symlink instead of relinking PATH:
|
||||
# the `dsh` on PATH never moves and can never dangle.
|
||||
#
|
||||
# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather
|
||||
# than `curl ... | sh`) it never clones and never touches that working tree;
|
||||
# DSH_REF is ignored. Instead it *adopts* the checkout: `git rev-parse
|
||||
# --git-common-dir` resolves the repository behind it (for a linked worktree that
|
||||
# is the real clone, not the worktree), and a fresh staging worktree branched
|
||||
# from the checkout's HEAD lands in the source container beside `current`. The
|
||||
# container owns staging worktrees and `current`; the clone is discovered, not
|
||||
# owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one
|
||||
# layout and stay upgradable. Adoption carries committed work only: the staging
|
||||
# worktree branches from HEAD, so uncommitted changes stay in the checkout.
|
||||
# Setting DSH_SOURCE to a different directory opts back into the normal
|
||||
# clone/worktree path.
|
||||
#
|
||||
# Adopting an arbitrary clone leaves the container not self-contained: its
|
||||
# staging worktrees hold an absolute gitdir pointer into that clone, so deleting
|
||||
# it breaks them. `git worktree list` in that clone is the record of which
|
||||
# worktrees depend on it.
|
||||
#
|
||||
# When run through `curl | sh` the script text arrives on stdin, so every
|
||||
# prompt and the final launch read the controlling terminal (/dev/tty) directly;
|
||||
# with no terminal the script prints the manual next steps instead.
|
||||
#
|
||||
# Overridable via environment:
|
||||
# DSH_REF branch or tag to clone/checkout (default: master)
|
||||
# DSH_REPO clone URL (default: the GitHub repo)
|
||||
# DSH_SOURCE source container directory (default: ~/.dsh/source)
|
||||
# DSH_MASTER master clone directory (default: $DSH_SOURCE/master)
|
||||
# DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current)
|
||||
# DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin)
|
||||
# DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh)
|
||||
set -eu
|
||||
|
||||
DSH_REF=${DSH_REF:-master}
|
||||
DSH_REPO=${DSH_REPO:-https://github.com/deepseek-ai/deepseek-harness-sdk.git}
|
||||
# DSH_SOURCE is the staging-worktree container and the default home of `current`.
|
||||
# DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE,
|
||||
# while adoption discovers an existing clone anywhere on disk. Remember whether
|
||||
# DSH_SOURCE was explicit so a different path selects clone mode.
|
||||
if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi
|
||||
DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source}
|
||||
DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master}
|
||||
# The stable symlink the PATH launcher resolves through: PATH/dsh ->
|
||||
# current/bin/dsh -> <staging>/bin/dsh. Installs and upgrades repoint `current`;
|
||||
# the PATH target remains current/bin/dsh.
|
||||
DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current}
|
||||
DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin}
|
||||
# One UTC basic timestamp names this install's staging branch and worktree.
|
||||
DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP
|
||||
DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP
|
||||
|
||||
# --- path helpers ---------------------------------------------------------------
|
||||
# Every path comparison below runs on physical paths. Git always reports resolved
|
||||
# paths, so comparing one against an unresolved path disagrees whenever a symlink
|
||||
# sits anywhere above the checkout — a symlinked home directory is enough, and
|
||||
# macOS reaches every mktemp path that way through /var -> private/var. The
|
||||
# mismatch silently misclassifies an existing managed install as a foreign clone
|
||||
# and builds a second container beside the real one.
|
||||
# `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+.
|
||||
#
|
||||
# A not-yet-created directory (the container on a fresh install) has no physical
|
||||
# path. Falling back here rather than at each call site keeps every caller a
|
||||
# plain assignment, so no site can compare against an empty path by forgetting
|
||||
# its own fallback.
|
||||
resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; }
|
||||
|
||||
# --- in-repo detection ---------------------------------------------------------
|
||||
# Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell
|
||||
# name and no file path resolves; running a checked-out copy (`sh
|
||||
# scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose
|
||||
# parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present),
|
||||
# this is in-repo mode: never clone, never touch that working tree. An explicit
|
||||
# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path.
|
||||
IN_REPO=0
|
||||
DSH_CHECKOUT=''
|
||||
if [ -f "$0" ]; then
|
||||
_self_dir=$(resolve_dir "$(dirname -- "$0")")
|
||||
if [ -n "$_self_dir" ]; then
|
||||
# Physical without its own resolve_dir: dirname is textual, so trimming a
|
||||
# resolved path leaves one. The comparison below depends on that.
|
||||
_repo_root=$(dirname -- "$_self_dir")
|
||||
if [ "$(basename -- "$_self_dir")" = scripts ] \
|
||||
&& [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then
|
||||
# Compare the explicit DSH_SOURCE physically: an unresolved but equivalent
|
||||
# path must still count as "the caller meant this checkout".
|
||||
_src_resolved=$(resolve_dir "$DSH_SOURCE")
|
||||
if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ]; then
|
||||
IN_REPO=1
|
||||
DSH_CHECKOUT=$_repo_root
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- terminal-aware prompting --------------------------------------------------
|
||||
# stdin is the piped script, so read the controlling terminal for input.
|
||||
if { true </dev/tty; } 2>/dev/null; then
|
||||
HAS_TTY=1
|
||||
# Restore terminal echo on exit or interrupt: ask_secret disables echo between
|
||||
# its stty toggles, and dash (a common `sh`) does not run an EXIT trap when the
|
||||
# shell is killed by a signal, so the fatal signals need their own handler. A
|
||||
# successful run ends in exec, which replaces this process and drops the traps.
|
||||
trap 'stty echo </dev/tty 2>/dev/null || true' EXIT
|
||||
trap 'stty echo </dev/tty 2>/dev/null || true; exit 130' INT TERM HUP
|
||||
else
|
||||
HAS_TTY=0
|
||||
fi
|
||||
|
||||
# Colour only when writing to a terminal.
|
||||
if [ -t 1 ]; then
|
||||
B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m')
|
||||
GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RST=$(printf '\033[0m')
|
||||
else
|
||||
B=''; DIM=''; RED=''; GRN=''; YEL=''; RST=''
|
||||
fi
|
||||
|
||||
info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$1"; }
|
||||
step() { printf '\n%s==>%s %s%s%s\n' "$GRN" "$RST" "$B" "$1" "$RST"; }
|
||||
warn() { printf '%s warn%s %s\n' "$YEL" "$RST" "$1" >&2; }
|
||||
die() { printf '%serror%s %s\n' "$RED" "$RST" "$1" >&2; exit 1; }
|
||||
|
||||
# ask PROMPT [DEFAULT] -> answer on stdout (plain-text line).
|
||||
ask() {
|
||||
[ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
|
||||
printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
|
||||
IFS= read -r _ans </dev/tty || _ans=''
|
||||
[ -n "$_ans" ] || _ans=${2:-}
|
||||
printf '%s' "$_ans"
|
||||
}
|
||||
|
||||
# ask_secret PROMPT -> answer on stdout, with terminal echo suppressed.
|
||||
ask_secret() {
|
||||
[ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell"
|
||||
printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty
|
||||
stty -echo </dev/tty 2>/dev/null || true
|
||||
IFS= read -r _sec </dev/tty || _sec=''
|
||||
stty echo </dev/tty 2>/dev/null || true
|
||||
printf '\n' >/dev/tty
|
||||
printf '%s' "$_sec"
|
||||
}
|
||||
|
||||
# confirm PROMPT [Y] -> exit 0 on yes. Default is no unless second arg is "Y".
|
||||
confirm() {
|
||||
_def=${2:-N}
|
||||
if [ "$HAS_TTY" != 1 ]; then
|
||||
[ "$_def" = Y ] # non-interactive: take the default
|
||||
return
|
||||
fi
|
||||
if [ "$_def" = Y ]; then _hint='[Y/n]'; else _hint='[y/N]'; fi
|
||||
printf '%s%s%s %s ' "$B" "$1" "$RST" "$_hint" >/dev/tty
|
||||
IFS= read -r _r </dev/tty || _r=''
|
||||
[ -n "$_r" ] || _r=$_def
|
||||
case "$_r" in [yY]|[yY][eE][sS]) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}"
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
printf '%scheckout %s%s\n' "$DIM" "$DSH_CHECKOUT" "$RST"
|
||||
else
|
||||
printf '%smaster %s @ %s%s\n' "$DIM" "$DSH_MASTER" "$DSH_REF" "$RST"
|
||||
printf '%sstaging %s%s\n' "$DIM" "$DSH_STAGING" "$RST"
|
||||
printf '%scurrent %s%s\n' "$DIM" "$DSH_CURRENT" "$RST"
|
||||
fi
|
||||
|
||||
# --- 1. dependency check -------------------------------------------------------
|
||||
step "Checking dependencies"
|
||||
|
||||
command -v git >/dev/null 2>&1 || die "git is required but not found. Install git, then re-run."
|
||||
info "git ... ok"
|
||||
|
||||
# Node ^22.19.0 || >=24.0.0 (see the root package.json "engines" field).
|
||||
node_ok() {
|
||||
command -v node >/dev/null 2>&1 || return 1
|
||||
_v=$(node -v 2>/dev/null) || return 1
|
||||
_v=${_v#v}
|
||||
_major=${_v%%.*}
|
||||
_rest=${_v#*.}
|
||||
_minor=${_rest%%.*}
|
||||
case "$_major" in ''|*[!0-9]*) return 1 ;; esac
|
||||
case "$_minor" in ''|*[!0-9]*) _minor=0 ;; esac
|
||||
[ "$_major" -ge 24 ] && return 0
|
||||
[ "$_major" -eq 22 ] && [ "$_minor" -ge 19 ] && return 0
|
||||
return 1
|
||||
}
|
||||
if node_ok; then
|
||||
info "node $(node -v) ... ok"
|
||||
else
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
die "Node $(node -v) is unsupported. dsh needs ^22.19.0 || >=24.0.0 — upgrade Node, then re-run."
|
||||
fi
|
||||
die "Node is required but not found. Install Node ^22.19.0 || >=24, then re-run."
|
||||
fi
|
||||
|
||||
# pnpm is the only dependency we offer to install for you.
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
info "pnpm $(pnpm --version) ... ok"
|
||||
else
|
||||
warn "pnpm is not installed."
|
||||
if confirm "Install pnpm now?" Y; then
|
||||
if command -v corepack >/dev/null 2>&1 && corepack enable pnpm >/dev/null 2>&1; then
|
||||
info "enabled pnpm via corepack"
|
||||
elif command -v npm >/dev/null 2>&1 && npm install -g pnpm >/dev/null 2>&1; then
|
||||
info "installed pnpm via npm"
|
||||
else
|
||||
die "could not install pnpm automatically. Install it (https://pnpm.io/installation), then re-run."
|
||||
fi
|
||||
command -v pnpm >/dev/null 2>&1 || die "pnpm still not on PATH after install. Open a new shell, then re-run."
|
||||
else
|
||||
die "pnpm is required. Install it (https://pnpm.io/installation), then re-run."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. resolve the repository and lay out the staging worktree ---------------
|
||||
# The source container owns staging worktrees and `current`; the repository is
|
||||
# *discovered*, not owned. A curl install discovers it by cloning to $DSH_MASTER;
|
||||
# in-repo adoption discovers it from the checkout. Both then run one shared
|
||||
# worktree/exclude/lock path, so an arbitrary clone and a managed install
|
||||
# converge on the same layout.
|
||||
#
|
||||
# REPO_COMMON is the shared git directory every worktree of the repository
|
||||
# points at; REPO_ROOT is the working tree that owns it (the master clone).
|
||||
REPO_COMMON=''
|
||||
REPO_ROOT=''
|
||||
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
step "Using existing checkout at $DSH_CHECKOUT"
|
||||
info "running from inside the repo — never cloning, and DSH_REF is ignored"
|
||||
|
||||
# Resolve the repository behind the checkout. --git-common-dir returns the
|
||||
# SHARED git dir, so a linked worktree resolves to the real clone rather than
|
||||
# itself; it is relative for a plain clone, so anchor it before resolving.
|
||||
# Require the resolved git dir to exist: resolve_dir echoes its argument back
|
||||
# for a missing path, so test the directory rather than the returned string.
|
||||
if _common=$(git -C "$DSH_CHECKOUT" rev-parse --git-common-dir 2>/dev/null) && [ -n "$_common" ]; then
|
||||
case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac
|
||||
[ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common")
|
||||
fi
|
||||
[ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it."
|
||||
REPO_ROOT=$(dirname -- "$REPO_COMMON")
|
||||
|
||||
# Reuse the container when the repository already lives inside it (the normal
|
||||
# managed install re-running its own script); otherwise treat that clone as
|
||||
# its own master and keep worktrees in the default container.
|
||||
_src_resolved=$(resolve_dir "$DSH_SOURCE")
|
||||
case "$REPO_ROOT/" in
|
||||
"$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;;
|
||||
*) info "adopting clone $REPO_ROOT as its own master" ;;
|
||||
esac
|
||||
DSH_MASTER=$REPO_ROOT
|
||||
else
|
||||
step "Fetching source into $DSH_MASTER"
|
||||
if [ -d "$DSH_MASTER/.git" ]; then
|
||||
info "existing master clone found — updating"
|
||||
git -C "$DSH_MASTER" fetch origin "$DSH_REF"
|
||||
# Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not
|
||||
# origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
|
||||
# the re-run idempotent whether or not DSH_REF changed since the last install.
|
||||
git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD
|
||||
else
|
||||
mkdir -p "$DSH_SOURCE"
|
||||
git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER"
|
||||
fi
|
||||
# Physical on both branches: REPO_ROOT is compared against resolved paths
|
||||
# below, and REPO_COMMON stays symmetric with it so neither can be read as
|
||||
# carrying a different kind of path.
|
||||
REPO_COMMON=$(resolve_dir "$DSH_MASTER/.git")
|
||||
REPO_ROOT=$(resolve_dir "$DSH_MASTER")
|
||||
fi
|
||||
|
||||
step "Adding staging worktree at $DSH_STAGING"
|
||||
[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run."
|
||||
mkdir -p "$DSH_SOURCE"
|
||||
# The staging worktree owns the branch dsh runs from; the repository stays as
|
||||
# the fetch/upgrade base and is never a launcher target. A clone install
|
||||
# branches from the ref it just fetched; adoption branches from the checkout's
|
||||
# HEAD so the contributor's committed work is what runs.
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
|
||||
else
|
||||
git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \
|
||||
|| git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
|
||||
fi
|
||||
# Exclude the per-worktree merge lock in the shared git dir's info/exclude,
|
||||
# which every linked worktree inherits.
|
||||
_exclude="$REPO_COMMON/info/exclude"
|
||||
if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then
|
||||
printf '.agents/merge.lock\n' >>"$_exclude"
|
||||
fi
|
||||
mkdir -p "$DSH_STAGING/.agents"
|
||||
: >"$DSH_STAGING/.agents/merge.lock"
|
||||
|
||||
# --- 3. install dependencies (no build; the launcher runs from source) --------
|
||||
step "Installing dependencies with pnpm (this can take a while)"
|
||||
( cd "$DSH_STAGING" && pnpm install )
|
||||
|
||||
[ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?"
|
||||
|
||||
# --- 4. put `dsh` on PATH ------------------------------------------------------
|
||||
# Every install goes through a stable `current` symlink so an upgrade repoints
|
||||
# one symlink (current -> new worktree) and the PATH launcher never moves:
|
||||
# PATH/dsh -> current/bin/dsh -> <staging>/bin/dsh.
|
||||
step "Linking dsh into $DSH_BIN_DIR"
|
||||
mkdir -p "$DSH_BIN_DIR"
|
||||
# The launcher must resolve to a staging worktree, never to the repository
|
||||
# itself: an upgrade repoints `current`, so aliasing it onto the master clone
|
||||
# would make every upgrade rewrite the fetch/upgrade base. Compare physical
|
||||
# paths — a symlinked or unresolved path would slip past a string compare.
|
||||
_staging_resolved=$(resolve_dir "$DSH_STAGING")
|
||||
[ "$_staging_resolved" = "$REPO_ROOT" ] \
|
||||
&& die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree."
|
||||
# Point `current` at this staging worktree with `ln -sfn`: -f replaces an
|
||||
# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing
|
||||
# an existing symlink-to-directory and dropping the new link *inside* the old
|
||||
# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir
|
||||
# symlink the same way. The swap is one unlink+symlink pair on a local fs; the
|
||||
# installer holds no other process racing this path.
|
||||
ln -sfn "$DSH_STAGING" "$DSH_CURRENT"
|
||||
info "pointed $DSH_CURRENT -> $DSH_STAGING"
|
||||
DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh
|
||||
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
|
||||
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$DSH_BIN_DIR:"*) ON_PATH=1 ;;
|
||||
*) ON_PATH=0 ;;
|
||||
esac
|
||||
if [ "$ON_PATH" = 0 ]; then
|
||||
warn "$DSH_BIN_DIR is not on your PATH."
|
||||
_line="export PATH=\"$DSH_BIN_DIR:\$PATH\""
|
||||
_rc=''
|
||||
_sh=${SHELL:-} # SHELL may be unset; word-removal on an unset var trips set -u under dash.
|
||||
case "${_sh##*/}" in
|
||||
zsh) _rc="$HOME/.zshrc" ;;
|
||||
bash) _rc="$HOME/.bashrc" ;;
|
||||
esac
|
||||
if [ -n "$_rc" ] && [ -f "$_rc" ] && grep -qF "$_line" "$_rc" 2>/dev/null; then
|
||||
info "$_rc already exports $DSH_BIN_DIR — open a new shell to pick it up"
|
||||
elif [ -n "$_rc" ] && confirm "Add it to $_rc?" Y; then
|
||||
printf '\n# Added by the dsh installer\n%s\n' "$_line" >>"$_rc"
|
||||
info "updated $_rc — run 'source $_rc' or open a new shell to pick it up"
|
||||
else
|
||||
warn "add this line to your shell profile yourself:"
|
||||
printf ' %s\n' "$_line"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 5. credentials ------------------------------------------------------------
|
||||
# Mirror app-boot's resolveDshHome precedence ($DSH_HOME, else ~/.dsh) so creds land where dsh reads them.
|
||||
if [ -n "${DSH_HOME:-}" ]; then
|
||||
CONF="$DSH_HOME"
|
||||
else
|
||||
CONF="$HOME/.dsh"
|
||||
fi
|
||||
ENV_FILE="$CONF/.env"
|
||||
|
||||
step "Configuring credentials"
|
||||
if [ -f "$ENV_FILE" ] && grep -q '^DEEPSEEK_API_KEY=' "$ENV_FILE" 2>/dev/null; then
|
||||
info "DEEPSEEK_API_KEY already set in $ENV_FILE"
|
||||
if ! confirm "Replace it?" N; then
|
||||
SKIP_CREDS=1
|
||||
fi
|
||||
fi
|
||||
if [ "${SKIP_CREDS:-0}" != 1 ]; then
|
||||
if [ "$HAS_TTY" = 1 ]; then
|
||||
API_KEY=$(ask_secret "DeepSeek API key (input hidden):")
|
||||
if [ -z "$API_KEY" ]; then
|
||||
warn "no key entered — skipping. Set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
|
||||
else
|
||||
BASE_URL=$(ask "DeepSeek base URL (optional, Enter to skip):")
|
||||
mkdir -p "$CONF"
|
||||
# The installer owns exactly the two DEEPSEEK_* lines; any other lines the
|
||||
# user keeps in this .env are preserved. The rewrite happens in a subshell
|
||||
# so umask 077 (which closes the create-time permission race) does not leak
|
||||
# into the exec'd dsh, and lands atomically via a same-dir temp + mv.
|
||||
_tmp="$ENV_FILE.dsh.$$"
|
||||
(
|
||||
umask 077
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
grep -v -e '^DEEPSEEK_API_KEY=' -e '^DEEPSEEK_BASE_URL=' "$ENV_FILE" >"$_tmp" || true
|
||||
else
|
||||
: >"$_tmp"
|
||||
fi
|
||||
printf 'DEEPSEEK_API_KEY=%s\n' "$API_KEY" >>"$_tmp"
|
||||
if [ -n "$BASE_URL" ]; then printf 'DEEPSEEK_BASE_URL=%s\n' "$BASE_URL" >>"$_tmp"; fi
|
||||
)
|
||||
mv "$_tmp" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE" 2>/dev/null || true
|
||||
info "wrote $ENV_FILE"
|
||||
fi
|
||||
else
|
||||
warn "no terminal for credential input — set DEEPSEEK_API_KEY in $ENV_FILE before using dsh."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 6. build and launch the Web interface -------------------------------------
|
||||
step "Done"
|
||||
if [ "$HAS_TTY" = 1 ]; then
|
||||
step "Building DeepSeek Harness for Web UI"
|
||||
( cd "$DSH_STAGING" && pnpm run build )
|
||||
info "launching Web UI — run 'dsh web' anytime to start again"
|
||||
exec "$DSH_BIN_DIR/dsh" web </dev/tty
|
||||
else
|
||||
info "install complete. Build and start the Web UI with:"
|
||||
printf ' (cd %s && pnpm run build)\n' "$DSH_STAGING"
|
||||
printf ' %s web\n' "$DSH_BIN_DIR/dsh"
|
||||
fi
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Shared JSDoc parsing and completeness checks for the Cordis, persistence,
|
||||
* and config catalogs and the export-surface gate.
|
||||
* and config catalogs and the exported-API gate.
|
||||
*/
|
||||
|
||||
import ts from 'typescript'
|
||||
@@ -124,7 +124,7 @@ export function parseTags(raw: string): { params: Map<string, string>; returns:
|
||||
* binding-pattern parameters, and reject stale tags. Exempt parameters may
|
||||
* still be documented.
|
||||
* @param where - the offender label violations open with, e.g. `event 'x' (file:1)`.
|
||||
* @param surface - surface noun used in binding-pattern diagnostics.
|
||||
* @param apiKind - API kind used in binding-pattern diagnostics.
|
||||
* @param parameters - the declaration's parameter list.
|
||||
* @param tags - the parsed `@param` name→description map from parseTags.
|
||||
* @param sf - source file used to render binding patterns.
|
||||
@@ -133,7 +133,7 @@ export function parseTags(raw: string): { params: Map<string, string>; returns:
|
||||
*/
|
||||
export function checkParams(
|
||||
where: string,
|
||||
surface: string,
|
||||
apiKind: string,
|
||||
parameters: readonly ts.ParameterDeclaration[],
|
||||
tags: Map<string, string>,
|
||||
sf: ts.SourceFile,
|
||||
@@ -142,7 +142,7 @@ export function checkParams(
|
||||
): void {
|
||||
for (const p of parameters) {
|
||||
if (!ts.isIdentifier(p.name)) {
|
||||
violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the ${surface} surface needs simple identifier parameters so @param can name them.`)
|
||||
violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the ${apiKind} API needs simple identifier parameters so @param can name them.`)
|
||||
continue
|
||||
}
|
||||
if (isExempt(p)) continue
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Profile {
|
||||
// A one-time audit against eslint.config.mjs blob 696b08282885296830189fdafe7051a356806fc2
|
||||
// mapped @typescript-eslint/* to typescript/* and four extension rules to their
|
||||
// Oxlint core equivalents. These fingerprints pin the resulting repository
|
||||
// contract; they do not re-evaluate that deleted baseline or track its preset.
|
||||
// snapshot; they do not re-evaluate that deleted baseline or track its preset.
|
||||
const profiles = {
|
||||
source: {
|
||||
count: 88,
|
||||
@@ -84,7 +84,7 @@ describe('Oxlint repository rule fingerprint', () => {
|
||||
}
|
||||
const overrides: readonly unknown[] = parsed.overrides
|
||||
|
||||
it('pins the complete override shape', () => {
|
||||
it('pins every override field', () => {
|
||||
expect(overrides).toHaveLength(8)
|
||||
})
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
|
||||
rm(configPath, { force: true }),
|
||||
])
|
||||
}
|
||||
})
|
||||
}, 20_000)
|
||||
|
||||
it('accepts an ignored-only staged selection', () => {
|
||||
const result = runOxlint([
|
||||
|
||||
@@ -49,7 +49,7 @@ function fixture(options: {
|
||||
},
|
||||
files: ['lib/index.js', 'lib/invariant.js'],
|
||||
peerDependencies: options.invariantDependency === false ? {} : {
|
||||
'@deepseek-ai/dsh-invariants': '^0.0.1',
|
||||
'@deepseek-ai/dsh-invariants': 'workspace:^',
|
||||
},
|
||||
devDependencies: options.invariantDependency === false ? {} : {
|
||||
'@deepseek-ai/dsh-invariants': 'workspace:^',
|
||||
|
||||
@@ -19,7 +19,7 @@ interface PackageManifest {
|
||||
devDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
/** One package and the files participating in its invariant publication contract. */
|
||||
/** One package and the files participating in its invariant publication rules. */
|
||||
export interface PackageInvariantOwner {
|
||||
readonly dir: string
|
||||
readonly manifestPath: string
|
||||
@@ -53,7 +53,7 @@ export function packageInvariantOwners(root: string): PackageInvariantOwner[] {
|
||||
})
|
||||
}
|
||||
|
||||
/** Return all violations of the package-invariant companion contract. */
|
||||
/** Return all violations of the package-invariant companion rules. */
|
||||
export function collectPackageInvariantViolations(root: string): PackageInvariantViolation[] {
|
||||
const violations: PackageInvariantViolation[] = []
|
||||
for (const owner of packageInvariantOwners(root)) {
|
||||
@@ -96,11 +96,11 @@ function checkManifest(
|
||||
addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
|
||||
}
|
||||
if (owner.packageName === '@deepseek-ai/dsh-invariants') return
|
||||
if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== '^0.0.1') {
|
||||
if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
|
||||
addViolation(
|
||||
violations,
|
||||
owner.manifestPath,
|
||||
'@deepseek-ai/dsh-invariants must be a ^0.0.1 peerDependency',
|
||||
'@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency',
|
||||
)
|
||||
}
|
||||
if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
|
||||
|
||||
@@ -19,7 +19,7 @@ fi
|
||||
archive="${RUNNER_TEMP}/bubblewrap_${BUBBLEWRAP_VERSION}_amd64.deb"
|
||||
root="${RUNNER_TEMP}/dsh-bubblewrap"
|
||||
|
||||
curl --fail --silent --show-error --location --retry 3 --output "$archive" "$BUBBLEWRAP_URL"
|
||||
curl --fail --silent --show-error --location --retry 3 --retry-all-errors --output "$archive" "$BUBBLEWRAP_URL"
|
||||
printf '%s %s\n' "$BUBBLEWRAP_SHA256" "$archive" | sha256sum --check --status
|
||||
mkdir -p "$root"
|
||||
dpkg-deb --extract "$archive" "$root"
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('rewriteMarkdown', () => {
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe(
|
||||
'[B](./reference/b.md#part) '
|
||||
+ '[source](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/packages/tool.ts#L2) '
|
||||
+ '[source](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/packages/tool.ts#L2) '
|
||||
+ '[web](https://example.com)\n',
|
||||
)
|
||||
})
|
||||
@@ -130,7 +130,7 @@ describe('rewriteMarkdown', () => {
|
||||
pages,
|
||||
repoRoot: root,
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe('\n')
|
||||
})).toBe('\n')
|
||||
})
|
||||
|
||||
it('hands an image to the placer and uses the URL it returns', () => {
|
||||
@@ -209,7 +209,7 @@ describe('rewriteMarkdown', () => {
|
||||
repositoryRef: 'abc123',
|
||||
})).toBe(
|
||||
'[title](./reference/b.md "b.md") '
|
||||
+ '[escaped](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/docs/x(y).md)\n',
|
||||
+ '[escaped](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/docs/x(y).md)\n',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
|
||||
|
||||
const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness-sdk'
|
||||
const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const generatedRoot = resolve(root, 'website/.generated')
|
||||
|
||||
@@ -209,7 +209,7 @@ function githubTarget(
|
||||
image: boolean,
|
||||
): string {
|
||||
const path = repoPath(absPath, repoRoot)
|
||||
if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/${repositoryRef}/${path}${suffix}`
|
||||
if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/${repositoryRef}/${path}${suffix}`
|
||||
const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob'
|
||||
const lineSuffix = line === undefined ? suffix : `#L${line}`
|
||||
return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}`
|
||||
|
||||
@@ -258,7 +258,9 @@ class WorkspacePackageSet {
|
||||
const name = expectString(manifest, 'name', manifestPath)
|
||||
const version = expectString(manifest, 'version', manifestPath)
|
||||
const isVendored = manifestPath.startsWith('vendor/')
|
||||
if (!isVendored && !name.startsWith('@deepseek-ai/')) {
|
||||
// Vendored packages are rescoped too (vendor/README.md), so publication
|
||||
// never carries an upstream name that would squat it on the registry.
|
||||
if (!name.startsWith('@deepseek-ai/')) {
|
||||
throw new Error(`${manifestPath} must name an @deepseek-ai package`)
|
||||
}
|
||||
if (name === '@deepseek-ai/dsh-root') {
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Bump one release family's version and commit it, so the published version is
|
||||
* readable from the repository rather than derived inside CI
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*
|
||||
* The dsh family shares one version across its members and the workspace root:
|
||||
* `major`, `minor`, `patch`, or an explicit `x.y.z` (including a prerelease such
|
||||
* as `0.0.1-rc.1`). The vendored family has one version line per package and
|
||||
* publishes only what changed since that package's own `vendor-<package>-v*`
|
||||
* tag, which is the record of the commit it last published from.
|
||||
*
|
||||
* The version lands in the manifests, the lockfile follows, and a human creates
|
||||
* the tag after the commit merges. CI never writes to the repository.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join, matchesGlob } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts'
|
||||
import { attempt, capture, isEntry } from './process.ts'
|
||||
|
||||
/** Files npm publishes whether or not `files` lists them. */
|
||||
const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const
|
||||
|
||||
/**
|
||||
* Inputs that decide what a built payload contains. A package whose `files`
|
||||
* selects `lib/` publishes build output that git does not track, so a change to
|
||||
* the sources or the build configuration changes the tarball while no published
|
||||
* path appears in the diff.
|
||||
*/
|
||||
const BUILD_INPUTS = ['src/**', 'tsconfig*.json', 'tsdown.config.*', 'build.config.*'] as const
|
||||
|
||||
/** Release types the dsh family accepts besides an explicit version. */
|
||||
const RELEASE_TYPES = ['major', 'minor', 'patch'] as const
|
||||
|
||||
/** The workspace root manifest, which carries the dsh family's version. */
|
||||
const ROOT_MANIFEST = 'package.json'
|
||||
|
||||
/** One manifest the bump rewrites, and the tag its new version will carry. */
|
||||
interface PlannedVersion {
|
||||
/** Repository-relative manifest path. */
|
||||
readonly manifestPath: string
|
||||
/** Label for the log line. */
|
||||
readonly label: string
|
||||
/** The version the manifest currently carries. */
|
||||
readonly from: string
|
||||
/** The version to write. */
|
||||
readonly to: string
|
||||
/** The tag this version publishes from, or undefined for the workspace root. */
|
||||
readonly tag: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a version into its release numbers, discarding any prerelease segment.
|
||||
* @param version - the current version.
|
||||
* @returns Major, minor, and patch.
|
||||
*/
|
||||
function releaseNumbers(version: string): [number, number, number] {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version)
|
||||
if (match === null) throw new Error(`cannot read release numbers from version ${version}`)
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])]
|
||||
}
|
||||
|
||||
/**
|
||||
* Order two versions by their release numbers alone.
|
||||
* @param left - one version.
|
||||
* @param right - the other version.
|
||||
* @returns Negative when `left` is lower, positive when higher, zero when equal.
|
||||
*/
|
||||
function compareReleaseNumbers(left: string, right: string): number {
|
||||
const [leftMajor, leftMinor, leftPatch] = releaseNumbers(left)
|
||||
const [rightMajor, rightMinor, rightPatch] = releaseNumbers(right)
|
||||
return leftMajor - rightMajor || leftMinor - rightMinor || leftPatch - rightPatch
|
||||
}
|
||||
|
||||
/**
|
||||
* The prerelease segment of a version, or undefined when it has none.
|
||||
* @param version - the version to read.
|
||||
* @returns The segment after the first `-`.
|
||||
*/
|
||||
function prereleaseOf(version: string): string | undefined {
|
||||
const index = version.indexOf('-')
|
||||
return index === -1 ? undefined : version.slice(index + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Order two versions by semver precedence.
|
||||
*
|
||||
* Git's version sort cannot stand in for this: `--sort=v:refname` places
|
||||
* `4.0.1-rc.1` above `4.0.1`, while semver gives a prerelease lower precedence
|
||||
* than the release it precedes. Prerelease identifiers compare field by field,
|
||||
* numeric fields numerically, so `rc.10` outranks `rc.1`.
|
||||
* @param left - one version.
|
||||
* @param right - the other version.
|
||||
* @returns Negative when `left` is lower, positive when higher, zero when equal.
|
||||
*/
|
||||
export function compareVersions(left: string, right: string): number {
|
||||
const numbers = compareReleaseNumbers(left, right)
|
||||
if (numbers !== 0) return numbers
|
||||
const leftPre = prereleaseOf(left)
|
||||
const rightPre = prereleaseOf(right)
|
||||
if (leftPre === undefined || rightPre === undefined) {
|
||||
if (leftPre === rightPre) return 0
|
||||
return leftPre === undefined ? 1 : -1
|
||||
}
|
||||
const leftFields = leftPre.split('.')
|
||||
const rightFields = rightPre.split('.')
|
||||
for (let index = 0; index < Math.max(leftFields.length, rightFields.length); index += 1) {
|
||||
const leftField = leftFields[index]
|
||||
const rightField = rightFields[index]
|
||||
// A shorter identifier list has lower precedence when all its fields match.
|
||||
if (leftField === undefined) return -1
|
||||
if (rightField === undefined) return 1
|
||||
if (leftField === rightField) continue
|
||||
const leftNumeric = /^\d+$/.test(leftField)
|
||||
const rightNumeric = /^\d+$/.test(rightField)
|
||||
if (leftNumeric && rightNumeric) return Number(leftField) - Number(rightField)
|
||||
// Numeric fields have lower precedence than alphanumeric ones.
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
|
||||
return leftField < rightField ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The next dsh version.
|
||||
* @param current - the family's current shared version.
|
||||
* @param request - `major`, `minor`, `patch`, or an explicit version.
|
||||
* @returns The target version.
|
||||
*/
|
||||
function nextSharedVersion(current: string, request: string): string {
|
||||
if (!RELEASE_TYPES.includes(request as typeof RELEASE_TYPES[number])) {
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(request)) {
|
||||
throw new Error(`usage: release:dsh <major|minor|patch|x.y.z>, got ${request}`)
|
||||
}
|
||||
return request
|
||||
}
|
||||
const [major, minor, patch] = releaseNumbers(current)
|
||||
if (request === 'major') return `${String(major + 1)}.0.0`
|
||||
if (request === 'minor') return `${String(major)}.${String(minor + 1)}.0`
|
||||
return `${String(major)}.${String(minor)}.${String(patch + 1)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The version a vendored package publishes next.
|
||||
*
|
||||
* The baseline is the higher of the manifest version and the last published
|
||||
* version: a vendor re-sync restores upstream's version, which is lower than
|
||||
* what this repository already published, and incrementing that would name a
|
||||
* version the registry already carries.
|
||||
*
|
||||
* A prerelease does not consume its own release numbers. Publishing
|
||||
* `4.0.1-rc.1` leaves `4.0.1` free, so the next stable version is `4.0.1`
|
||||
* rather than `4.0.2`, and a second prerelease keeps those numbers too.
|
||||
* @param current - the package's manifest version.
|
||||
* @param published - the version its newest tag names, when it has one.
|
||||
* @param prerelease - prerelease identifier to append, for a rehearsal publication.
|
||||
* @returns The target version.
|
||||
*/
|
||||
export function nextVendorVersion(
|
||||
current: string,
|
||||
published: string | undefined,
|
||||
prerelease?: string,
|
||||
): string {
|
||||
const ahead = published !== undefined && compareReleaseNumbers(published, current) > 0
|
||||
const baseline = ahead ? published : current
|
||||
const [major, minor, patch] = releaseNumbers(baseline)
|
||||
// Reuse the numbers when the published version that set them is a prerelease
|
||||
// of them; increment when a stable release already holds them.
|
||||
const reuse = ahead && published.includes('-')
|
||||
const numbers = reuse
|
||||
? `${String(major)}.${String(minor)}.${String(patch)}`
|
||||
: `${String(major)}.${String(minor)}.${String(patch + 1)}`
|
||||
return prerelease === undefined ? numbers : `${numbers}-${prerelease}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a repository-relative path reaches the member's published payload.
|
||||
* @param member - the member the path belongs to.
|
||||
* @param path - repository-relative path.
|
||||
* @returns True when `files`, npm's always-published set, or a build input selects it.
|
||||
*/
|
||||
export function reachesPayload(member: ReleaseMember, path: string): boolean {
|
||||
const relative = path.slice(member.directory.length + 1)
|
||||
const files = member.manifest.files
|
||||
const selected = Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
const built = selected.some(pattern => pattern.startsWith('lib'))
|
||||
const patterns = [...ALWAYS_PUBLISHED, ...selected, ...built ? BUILD_INPUTS : []]
|
||||
return patterns.some(pattern =>
|
||||
matchesGlob(relative, pattern) || matchesGlob(relative, `${pattern}/**`) || relative === pattern)
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest version a member published, read from its tags.
|
||||
* @param family - the member's family.
|
||||
* @param member - the member.
|
||||
* @returns The version, or undefined when the member never published.
|
||||
*/
|
||||
function lastPublishedVersion(family: ReleaseFamily, member: ReleaseMember): string | undefined {
|
||||
const prefix = family.tagPrefixFor(member)
|
||||
const versions = capture('git', ['tag', '--list', `${prefix}*`])
|
||||
.split('\n').filter(line => line !== '').map(tag => tag.slice(prefix.length))
|
||||
if (versions.length === 0) return undefined
|
||||
return versions.reduce((newest, candidate) => compareVersions(candidate, newest) > 0 ? candidate : newest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the registry carries the version a tag names.
|
||||
*
|
||||
* A tag is a commit pointer, not proof of publication: a tag pushed for a
|
||||
* publication that then failed would otherwise read as "already published" and
|
||||
* skip the package indefinitely. Querying a private package needs credentials,
|
||||
* so an unauthenticated machine reports the gap instead of failing.
|
||||
* @param name - package name.
|
||||
* @param version - the version the tag names.
|
||||
*/
|
||||
function confirmPublished(name: string, version: string): void {
|
||||
const result = attempt('npm', ['view', `${name}@${version}`, 'version'])
|
||||
if (result.status === 0) return
|
||||
const output = `${result.stdout}${result.stderr}`
|
||||
if (output.includes('ENEEDAUTH') || output.includes('E401') || output.includes('E403')) {
|
||||
console.log(`release bump: cannot reach the registry for ${name}@${version}; skipping the tag check`)
|
||||
return
|
||||
}
|
||||
if (output.includes('E404') || output.includes('404 Not Found')) {
|
||||
throw new Error(
|
||||
`${name}@${version} is tagged but absent from the registry.`
|
||||
+ '\nThe tag was pushed for a publication that did not complete: re-run that publish, or delete the tag.',
|
||||
)
|
||||
}
|
||||
throw new Error(`npm view ${name}@${version} failed:\n${output}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a version into a manifest, preserving formatting and key order.
|
||||
* @param root - repository root.
|
||||
* @param manifestPath - repository-relative manifest path.
|
||||
* @param from - the version the manifest currently carries.
|
||||
* @param to - the target version.
|
||||
*/
|
||||
function writeVersion(root: string, manifestPath: string, from: string, to: string): void {
|
||||
const path = join(root, manifestPath)
|
||||
const text = readFileSync(path, 'utf8')
|
||||
const line = `"version": "${from}"`
|
||||
if (!text.includes(line)) throw new Error(`${manifestPath}: cannot locate ${line}`)
|
||||
writeFileSync(path, text.replace(line, `"version": "${to}"`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the workspace root version.
|
||||
* @param root - repository root.
|
||||
* @returns The root manifest version.
|
||||
*/
|
||||
function rootVersion(root: string): string {
|
||||
const manifest: unknown = JSON.parse(readFileSync(join(root, ROOT_MANIFEST), 'utf8'))
|
||||
const version = (manifest as Record<string, unknown>).version
|
||||
if (typeof version !== 'string') throw new Error('package.json must declare a string version')
|
||||
return version
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan the dsh family's rewrite: one version for every member and the root.
|
||||
* @param family - the dsh family.
|
||||
* @param root - repository root.
|
||||
* @param members - the family's members.
|
||||
* @param request - `major`, `minor`, `patch`, or an explicit version.
|
||||
* @returns The manifests to rewrite and the shared target version.
|
||||
*/
|
||||
function planShared(
|
||||
family: ReleaseFamily,
|
||||
root: string,
|
||||
members: readonly ReleaseMember[],
|
||||
request: string,
|
||||
): { planned: PlannedVersion[]; version: string } {
|
||||
const [first] = members
|
||||
if (first === undefined) throw new Error(`release family ${family.id} has no members`)
|
||||
const version = nextSharedVersion(first.version, request)
|
||||
// The workspace root carries the family version too: the workspace constraint
|
||||
// requires every member's version to equal the root's.
|
||||
const planned: PlannedVersion[] = [
|
||||
{ manifestPath: ROOT_MANIFEST, label: ROOT_MANIFEST, from: rootVersion(root), to: version, tag: undefined },
|
||||
]
|
||||
for (const member of members) {
|
||||
planned.push({
|
||||
manifestPath: join(member.directory, 'package.json'),
|
||||
label: member.directory,
|
||||
from: member.version,
|
||||
to: version,
|
||||
tag: family.tagFor({ ...member, version }),
|
||||
})
|
||||
}
|
||||
return { planned, version }
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan the vendored family's rewrite: every package whose payload changed since
|
||||
* it last published.
|
||||
* @param family - the vendored family.
|
||||
* @param members - the family's members.
|
||||
* @param prerelease - prerelease identifier to append, for a rehearsal publication.
|
||||
* @returns The manifests to rewrite.
|
||||
*/
|
||||
function planPerPackage(
|
||||
family: ReleaseFamily,
|
||||
members: readonly ReleaseMember[],
|
||||
prerelease: string | undefined,
|
||||
): PlannedVersion[] {
|
||||
const planned: PlannedVersion[] = []
|
||||
for (const member of members) {
|
||||
const published = lastPublishedVersion(family, member)
|
||||
if (published !== undefined) {
|
||||
confirmPublished(member.name, published)
|
||||
const since = `${family.tagPrefixFor(member)}${published}`
|
||||
const changed = capture('git', ['diff', '--name-only', `${since}..HEAD`, '--', member.directory])
|
||||
.split('\n').filter(line => line !== '')
|
||||
if (!changed.some(path => reachesPayload(member, path))) continue
|
||||
}
|
||||
const to = nextVendorVersion(member.version, published, prerelease)
|
||||
planned.push({
|
||||
manifestPath: join(member.directory, 'package.json'),
|
||||
label: member.directory,
|
||||
from: member.version,
|
||||
to,
|
||||
tag: family.tagFor({ ...member, version: to }),
|
||||
})
|
||||
}
|
||||
return planned
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump the family named by `--family` and commit; `--dry-run` only reports the
|
||||
* plan. `--prerelease rc.1` makes the vendored family publish a rehearsal
|
||||
* version, which never takes the stable dist-tag.
|
||||
*/
|
||||
function main(): void {
|
||||
const { values, positionals } = parseArgs({
|
||||
options: {
|
||||
family: { type: 'string' },
|
||||
prerelease: { type: 'string' },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
if (values.family === undefined) throw new Error('usage: bump.ts --family <dsh|vendor> [version]')
|
||||
|
||||
const family = releaseFamily(values.family)
|
||||
const root = process.cwd()
|
||||
const members = family.members(root)
|
||||
family.verifyVersions(members)
|
||||
|
||||
let planned: PlannedVersion[]
|
||||
let sharedVersion: string | undefined
|
||||
if (family.id === 'dsh') {
|
||||
const request = positionals[0]
|
||||
if (request === undefined) throw new Error('usage: release:dsh <major|minor|patch|x.y.z>')
|
||||
if (values.prerelease !== undefined) {
|
||||
throw new Error('release:dsh takes the prerelease in its version argument, as in 0.0.1-rc.1')
|
||||
}
|
||||
const shared = planShared(family, root, members, request)
|
||||
planned = shared.planned
|
||||
sharedVersion = shared.version
|
||||
} else {
|
||||
if (positionals.length > 0) throw new Error('release:vendor takes no version: each package increments its own patch')
|
||||
if (values.prerelease !== undefined && !/^[0-9A-Za-z.-]+$/.test(values.prerelease)) {
|
||||
throw new Error(`--prerelease must be a semver prerelease identifier, got ${values.prerelease}`)
|
||||
}
|
||||
planned = planPerPackage(family, members, values.prerelease)
|
||||
}
|
||||
|
||||
if (planned.length === 0) {
|
||||
console.log(`release bump: family ${family.id}, nothing changed since publication`)
|
||||
return
|
||||
}
|
||||
|
||||
const dryRun = values['dry-run']
|
||||
if (!dryRun) {
|
||||
for (const entry of planned) writeVersion(root, entry.manifestPath, entry.from, entry.to)
|
||||
capture('pnpm', ['install', '--lockfile-only'])
|
||||
}
|
||||
|
||||
const summary = sharedVersion
|
||||
?? planned.map(entry => `${entry.label.replace('vendor/', '')} ${entry.to}`).join(', ')
|
||||
console.log(`release bump: family ${family.id} -> ${summary}`)
|
||||
for (const entry of planned) console.log(` ${entry.label}: ${entry.from} -> ${entry.to}`)
|
||||
|
||||
if (dryRun) {
|
||||
console.log('release bump: dry run, nothing written')
|
||||
return
|
||||
}
|
||||
capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => entry.manifestPath)])
|
||||
capture('git', ['commit', '-m', `release(${family.id}): ${summary}`])
|
||||
console.log('release bump: committed. After this merges to master, tag it:')
|
||||
for (const tag of [...new Set(planned.map(entry => entry.tag).filter(tag => tag !== undefined))]) {
|
||||
console.log(` git tag ${tag} <merge commit> && git push origin ${tag}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
@@ -0,0 +1,176 @@
|
||||
/** Release family discovery, publish order, tag naming, and the bump judgements. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { releaseFamily, type ReleaseMember } from './families.ts'
|
||||
import { compareVersions, nextVendorVersion, reachesPayload } from './bump.ts'
|
||||
|
||||
/**
|
||||
* A release member standing in for a manifest on disk.
|
||||
* @param directory - repository-relative package directory.
|
||||
* @param name - package name.
|
||||
* @param manifest - manifest fields the subject reads.
|
||||
* @returns The member.
|
||||
*/
|
||||
function member(directory: string, name: string, manifest: Record<string, unknown> = {}): ReleaseMember {
|
||||
return { directory, name, version: '0.0.1', manifest }
|
||||
}
|
||||
|
||||
describe('release families', () => {
|
||||
it('names one tag for the whole dsh family and one per vendored package', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const vendor = releaseFamily('vendor')
|
||||
const cli = member('apps/cli', '@deepseek-ai/dsh')
|
||||
const cordis = { ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' }
|
||||
|
||||
expect(dsh.tagFor(cli)).toBe('dsh-v0.0.1')
|
||||
expect(vendor.tagFor(cordis)).toBe('vendor-cordis-v4.0.1')
|
||||
// The prefix is constructed, not recovered from a tag: a version with a
|
||||
// hyphen would defeat any suffix-stripping.
|
||||
expect(vendor.tagPrefixFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v')
|
||||
expect(vendor.tagFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v4.0.0-rc.7')
|
||||
})
|
||||
|
||||
it('rejects a family whose members disagree on the shared version', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [member('apps/cli', '@deepseek-ai/dsh'), { ...member('apps/web', '@deepseek-ai/dsh-frontend'), version: '0.0.2' }]
|
||||
|
||||
expect(() => { dsh.verifyVersions(members) }).toThrow(/must share one version/)
|
||||
expect(() => { dsh.verifyVersions([members[0]!]) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts independent vendored versions and rejects an unpublishable one', () => {
|
||||
const vendor = releaseFamily('vendor')
|
||||
const members = [
|
||||
{ ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' },
|
||||
{ ...member('vendor/cosmokit', '@deepseek-ai/cosmokit'), version: '1.8.2' },
|
||||
]
|
||||
|
||||
expect(() => { vendor.verifyVersions(members) }).not.toThrow()
|
||||
expect(() => { vendor.verifyVersions([{ ...members[0]!, version: 'latest' }]) }).toThrow(/unpublishable version/)
|
||||
})
|
||||
|
||||
it('publishes a dependency before its consumer, and orders ties by name', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [
|
||||
member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { dependencies: { '@deepseek-ai/dsh-library': 'workspace:^' } }),
|
||||
member('packages/a/library', '@deepseek-ai/dsh-library'),
|
||||
member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
|
||||
]
|
||||
|
||||
expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([
|
||||
'@deepseek-ai/dsh-library',
|
||||
'@deepseek-ai/dsh-consumer',
|
||||
'@deepseek-ai/dsh-zebra',
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a runtime dependency cycle instead of emitting an arbitrary order', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [
|
||||
member('packages/a/left', '@deepseek-ai/dsh-left', { dependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }),
|
||||
member('packages/a/right', '@deepseek-ai/dsh-right', { dependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }),
|
||||
]
|
||||
|
||||
expect(() => { dsh.publishOrder(members) }).toThrow(/dependency cycle/)
|
||||
})
|
||||
|
||||
it('applies the harness payload policy to dsh and keeps upstream payloads for vendored packages', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const vendor = releaseFamily('vendor')
|
||||
const harness = member('packages/a/library', '@deepseek-ai/dsh-library')
|
||||
const vendored = member('vendor/cordis', '@deepseek-ai/cordis')
|
||||
|
||||
expect(() => { dsh.validatePayload(harness, ['package/lib/index.js', 'package/src/index.ts']) })
|
||||
.toThrow(/publishes source file/)
|
||||
expect(() => { vendor.validatePayload(vendored, ['package/lib/index.js', 'package/src/index.ts']) }).not.toThrow()
|
||||
expect(() => { vendor.validatePayload(vendored, []) }).toThrow(/empty tarball/)
|
||||
})
|
||||
|
||||
it('drives the installed entry only for the family that publishes one', () => {
|
||||
expect(releaseFamily('dsh').installedEntry).toEqual({ packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' })
|
||||
expect(releaseFamily('vendor').installedEntry).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an unknown family identifier', () => {
|
||||
expect(() => { releaseFamily('native') }).toThrow(/unknown release family/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vendored version baseline', () => {
|
||||
it('drops an upstream prerelease segment and increments the patch', () => {
|
||||
expect(nextVendorVersion('4.0.0-rc.7', undefined)).toBe('4.0.1')
|
||||
expect(nextVendorVersion('1.0.0-rc.5', undefined)).toBe('1.0.1')
|
||||
expect(nextVendorVersion('1.8.1', undefined)).toBe('1.8.2')
|
||||
})
|
||||
|
||||
it('increments from the last published version when a re-sync restored a lower one', () => {
|
||||
// Upstream moved rc.7 -> rc.8 after this repository published 4.0.1;
|
||||
// incrementing the manifest alone would name 4.0.1 a second time.
|
||||
expect(nextVendorVersion('4.0.0-rc.8', '4.0.1')).toBe('4.0.2')
|
||||
expect(nextVendorVersion('4.1.0', '4.0.1')).toBe('4.1.1')
|
||||
})
|
||||
|
||||
it('appends a rehearsal prerelease without consuming its release numbers', () => {
|
||||
// A rehearsal burns 4.0.1-rc.1 and leaves 4.0.1 free, so the stable release
|
||||
// that follows takes those same numbers instead of skipping to 4.0.2.
|
||||
expect(nextVendorVersion('4.0.0-rc.7', undefined, 'rc.1')).toBe('4.0.1-rc.1')
|
||||
expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1', 'rc.2')).toBe('4.0.1-rc.2')
|
||||
expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1')).toBe('4.0.1')
|
||||
expect(nextVendorVersion('4.0.0-rc.7', '4.0.1')).toBe('4.0.2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('version precedence', () => {
|
||||
it('ranks a release above the prerelease it follows', () => {
|
||||
// git --sort=v:refname disagrees, placing 4.0.1-rc.1 above 4.0.1, which is
|
||||
// why the newest published version is chosen here rather than by git.
|
||||
expect(compareVersions('4.0.1', '4.0.1-rc.1')).toBeGreaterThan(0)
|
||||
expect(compareVersions('4.0.1-rc.1', '4.0.1')).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('compares numeric prerelease fields numerically', () => {
|
||||
expect(compareVersions('4.0.1-rc.10', '4.0.1-rc.1')).toBeGreaterThan(0)
|
||||
expect(compareVersions('4.0.1-rc.2', '4.0.1-rc.10')).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('ranks a numeric field below an alphanumeric one, and a shorter list below a longer', () => {
|
||||
expect(compareVersions('4.0.1-1', '4.0.1-alpha')).toBeLessThan(0)
|
||||
expect(compareVersions('4.0.1-rc', '4.0.1-rc.1')).toBeLessThan(0)
|
||||
expect(compareVersions('4.0.2', '4.0.1')).toBeGreaterThan(0)
|
||||
expect(compareVersions('4.0.1-rc.1', '4.0.1-rc.1')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('payload change judgement', () => {
|
||||
const sourceShipping = member('vendor/cosmokit', '@deepseek-ai/cosmokit', {
|
||||
files: ['lib/index.js', 'lib/types/**/*.d.ts', 'src'],
|
||||
})
|
||||
const buildOutputOnly = member('vendor/cordis', '@deepseek-ai/cordis', {
|
||||
files: ['lib/index.js', 'lib/types/**/*.d.ts', 'bin.js'],
|
||||
})
|
||||
|
||||
it('counts the manifest and the files npm always publishes', () => {
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/package.json')).toBe(true)
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.md')).toBe(true)
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/src/index.ts')).toBe(true)
|
||||
})
|
||||
|
||||
it('counts build inputs for a package whose payload is build output', () => {
|
||||
// cordis publishes lib/ only, and lib/ is not tracked: without this, a real
|
||||
// source change reads as "nothing changed" and the next publish fails on a
|
||||
// version whose bytes moved.
|
||||
expect(reachesPayload(buildOutputOnly, 'vendor/cordis/src/context.ts')).toBe(true)
|
||||
expect(reachesPayload(buildOutputOnly, 'vendor/cordis/tsconfig.json')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores paths no tarball carries', () => {
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/tests/unit.spec.ts')).toBe(false)
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/CHANGELOG.md')).toBe(false)
|
||||
// The README pattern is deliberately loose: over-reporting a change costs one
|
||||
// unnecessary patch bump, while under-reporting fails the next publish on a
|
||||
// version whose bytes moved.
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.i18n.yaml')).toBe(true)
|
||||
expect(reachesPayload(member('packages/a/library', '@deepseek-ai/dsh-library', { files: ['lib/index.js'] }),
|
||||
'packages/a/library/tests/library.spec.ts')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* The three independent publish sequences this repository releases from
|
||||
* (`packages/` + `apps/`, `vendor/`, and `native/`) and the two this module
|
||||
* owns: `dsh` and `vendor`. Each family carries its own version baseline, tag
|
||||
* naming, and publish set, so releasing one never republishes another
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*
|
||||
* The family dimension lives here only. A new sequence adds a subclass and a
|
||||
* `releaseFamilies()` entry; nothing else in the release scripts branches on it.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { hasTypeRTRemoteNavigation, validateTarballPayload } from '../publication-payload.ts'
|
||||
|
||||
/** Dependency sections that constrain publish order: a consumer must publish after its dependency. */
|
||||
const ORDER_SECTIONS = ['dependencies', 'optionalDependencies'] as const
|
||||
|
||||
/** The workspace root manifest, which is never a release member. */
|
||||
const WORKSPACE_ROOT_PACKAGE = '@deepseek-ai/dsh-root'
|
||||
|
||||
/** One publishable package of a release family. */
|
||||
export interface ReleaseMember {
|
||||
/** Repository-relative package directory, for example `packages/core/session`. */
|
||||
readonly directory: string
|
||||
/** Package name from its manifest. */
|
||||
readonly name: string
|
||||
/** Package version from its manifest. */
|
||||
readonly version: string
|
||||
/** The parsed manifest, for payload policy and publication checks. */
|
||||
readonly manifest: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse a JSON file.
|
||||
* @param path - absolute file path.
|
||||
* @returns The parsed object.
|
||||
*/
|
||||
function readManifest(path: string): Record<string, unknown> {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`${path} is not a JSON object`)
|
||||
}
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a required string field.
|
||||
* @param manifest - parsed manifest.
|
||||
* @param field - field name.
|
||||
* @param context - manifest path for the error message.
|
||||
* @returns The field value.
|
||||
*/
|
||||
function requireString(manifest: Record<string, unknown>, field: string, context: string): string {
|
||||
const value = manifest[field]
|
||||
if (typeof value !== 'string' || value === '') throw new Error(`${context} must declare a string ${field}`)
|
||||
return value
|
||||
}
|
||||
|
||||
/** The executable a family's installed artifacts are driven through. */
|
||||
export interface InstalledEntry {
|
||||
/** Package that carries the executable. */
|
||||
readonly packageName: string
|
||||
/** Path to the executable inside that package. */
|
||||
readonly binPath: string
|
||||
}
|
||||
|
||||
/** A release sequence: its members, its version baseline, and its tag naming. */
|
||||
export abstract class ReleaseFamily {
|
||||
/** Workflow-facing identifier, also the `--family` argument. */
|
||||
abstract readonly id: string
|
||||
|
||||
/** Glob patterns, relative to the repository root, that select this family's manifests. */
|
||||
abstract readonly patterns: readonly string[]
|
||||
|
||||
/** Git tag prefix this family publishes from. */
|
||||
abstract readonly tagPrefix: string
|
||||
|
||||
/**
|
||||
* Discover this family's members.
|
||||
* @param root - repository root.
|
||||
* @returns Members sorted by directory, with names validated and deduplicated.
|
||||
*/
|
||||
members(root: string): ReleaseMember[] {
|
||||
const manifestPaths = globSync([...this.patterns], { cwd: root }).sort()
|
||||
if (manifestPaths.length === 0) throw new Error(`release family ${this.id} matched no manifests`)
|
||||
|
||||
const members: ReleaseMember[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const manifestPath of manifestPaths) {
|
||||
const normalized = manifestPath.replaceAll('\\', '/')
|
||||
const manifest = readManifest(resolve(root, manifestPath))
|
||||
const name = requireString(manifest, 'name', normalized)
|
||||
const version = requireString(manifest, 'version', normalized)
|
||||
if (name === WORKSPACE_ROOT_PACKAGE) throw new Error(`${normalized} selected the workspace root`)
|
||||
if (!name.startsWith('@deepseek-ai/')) throw new Error(`${normalized} must name an @deepseek-ai package`)
|
||||
if (seen.has(name)) throw new Error(`${name} appears twice in release family ${this.id}`)
|
||||
seen.add(name)
|
||||
members.push({
|
||||
directory: normalized.slice(0, normalized.length - '/package.json'.length),
|
||||
name,
|
||||
version,
|
||||
manifest,
|
||||
})
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
/**
|
||||
* Order members so every package publishes after the family members it depends on.
|
||||
* @param members - this family's members.
|
||||
* @returns The same members in publish order; ties break by name for determinism.
|
||||
*/
|
||||
publishOrder(members: readonly ReleaseMember[]): ReleaseMember[] {
|
||||
const byName = new Map(members.map(member => [member.name, member]))
|
||||
const ordered: ReleaseMember[] = []
|
||||
const placed = new Set<string>()
|
||||
const visiting = new Set<string>()
|
||||
|
||||
const visit = (member: ReleaseMember, path: readonly string[]): void => {
|
||||
if (placed.has(member.name)) return
|
||||
if (visiting.has(member.name)) {
|
||||
throw new Error(`dependency cycle in release family ${this.id}: ${[...path, member.name].join(' -> ')}`)
|
||||
}
|
||||
visiting.add(member.name)
|
||||
for (const dependency of this.orderEdges(member, byName)) {
|
||||
visit(dependency, [...path, member.name])
|
||||
}
|
||||
visiting.delete(member.name)
|
||||
placed.add(member.name)
|
||||
ordered.push(member)
|
||||
}
|
||||
|
||||
for (const member of [...members].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
visit(member, [])
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
/**
|
||||
* The family members one member depends on at runtime.
|
||||
* @param member - the dependent member.
|
||||
* @param byName - every family member by package name.
|
||||
* @returns Dependencies inside this family, sorted by name.
|
||||
*/
|
||||
private orderEdges(member: ReleaseMember, byName: ReadonlyMap<string, ReleaseMember>): ReleaseMember[] {
|
||||
const edges: ReleaseMember[] = []
|
||||
for (const section of ORDER_SECTIONS) {
|
||||
const dependencies = member.manifest[section]
|
||||
if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue
|
||||
for (const name of Object.keys(dependencies)) {
|
||||
const dependency = byName.get(name)
|
||||
if (dependency !== undefined && dependency.name !== member.name) edges.push(dependency)
|
||||
}
|
||||
}
|
||||
return edges.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert this family's version baseline holds across its members.
|
||||
* @param members - this family's members.
|
||||
*/
|
||||
abstract verifyVersions(members: readonly ReleaseMember[]): void
|
||||
|
||||
/**
|
||||
* The tag prefix a member's versions are tagged under. Every tag for that
|
||||
* member starts with it, which is how the last published version is found.
|
||||
* @param member - the member being published.
|
||||
* @returns The prefix, ending in `-v`.
|
||||
*/
|
||||
abstract tagPrefixFor(member: ReleaseMember): string
|
||||
|
||||
/**
|
||||
* The tag a member publishes from.
|
||||
* @param member - the member being published.
|
||||
* @returns The full tag name, without `refs/tags/`.
|
||||
*/
|
||||
tagFor(member: ReleaseMember): string {
|
||||
return `${this.tagPrefixFor(member)}${member.version}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check what a member's packed tarball carries.
|
||||
* @param member - the packed member.
|
||||
* @param files - every path inside its tarball.
|
||||
*/
|
||||
abstract validatePayload(member: ReleaseMember, files: readonly string[]): void
|
||||
|
||||
/**
|
||||
* The executable that proves this family's artifacts install and run, or
|
||||
* `undefined` for a family that publishes no executable.
|
||||
*/
|
||||
abstract readonly installedEntry: InstalledEntry | undefined
|
||||
}
|
||||
|
||||
/** `packages/*` and `apps/*`: one shared version across the whole family. */
|
||||
class DshFamily extends ReleaseFamily {
|
||||
readonly id = 'dsh'
|
||||
readonly patterns = ['packages/*/*/package.json', 'apps/*/package.json'] as const
|
||||
readonly tagPrefix = 'dsh-v'
|
||||
|
||||
/**
|
||||
* Require one version across the family, the way a single tag can name it.
|
||||
* @param members - this family's members.
|
||||
*/
|
||||
verifyVersions(members: readonly ReleaseMember[]): void {
|
||||
const versions = new Set(members.map(member => member.version))
|
||||
if (versions.size !== 1) {
|
||||
const detail = members.map(member => `${member.directory}: ${member.version}`).join('\n')
|
||||
throw new Error(`dsh release members must share one version:\n${detail}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single family prefix: every member shares one version, so one tag names it.
|
||||
* @returns `dsh-v`.
|
||||
*/
|
||||
tagPrefixFor(): string {
|
||||
return this.tagPrefix
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject source and declaration-map members, the repository's publication policy.
|
||||
* @param member - the packed member.
|
||||
* @param files - every path inside its tarball.
|
||||
*/
|
||||
validatePayload(member: ReleaseMember, files: readonly string[]): void {
|
||||
validateTarballPayload(files, member.name, {
|
||||
typeRTRemoteNavigation: hasTypeRTRemoteNavigation(member.manifest),
|
||||
})
|
||||
}
|
||||
|
||||
readonly installedEntry = { packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' }
|
||||
}
|
||||
|
||||
/** `vendor/*`: every package keeps its own version line, so every package has its own tag. */
|
||||
class VendorFamily extends ReleaseFamily {
|
||||
readonly id = 'vendor'
|
||||
readonly patterns = ['vendor/*/package.json'] as const
|
||||
readonly tagPrefix = 'vendor-'
|
||||
|
||||
/**
|
||||
* Accept independent versions; only reject a version this repository cannot publish.
|
||||
* @param members - this family's members.
|
||||
*/
|
||||
verifyVersions(members: readonly ReleaseMember[]): void {
|
||||
for (const member of members) {
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(member.version)) {
|
||||
throw new Error(`${member.directory} has an unpublishable version: ${member.version}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefix per member, because one vendor release can carry several versions.
|
||||
* @param member - the member being published.
|
||||
* @returns `vendor-<unscoped name>-v`.
|
||||
*/
|
||||
tagPrefixFor(member: ReleaseMember): string {
|
||||
return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v`
|
||||
}
|
||||
|
||||
/**
|
||||
* Require the payload the vendored manifest declares, including upstream's
|
||||
* `src` tree and declaration maps.
|
||||
*
|
||||
* The harness policy that rejects both does not apply here: these manifests
|
||||
* export `./src/*` for source navigation, so dropping `src` would publish a
|
||||
* package whose export map points at absent files. What must hold instead is
|
||||
* that every path the manifest selects is present, which `files` already
|
||||
* decides and `pnpm pack` already enforces.
|
||||
* @param member - the packed member.
|
||||
* @param files - every path inside its tarball.
|
||||
*/
|
||||
validatePayload(member: ReleaseMember, files: readonly string[]): void {
|
||||
if (files.length === 0) throw new Error(`${member.name} packed an empty tarball`)
|
||||
}
|
||||
|
||||
/** No installed-entry probe: these are libraries a consumer imports, with no executable. */
|
||||
readonly installedEntry = undefined
|
||||
}
|
||||
|
||||
/** Every release family this module owns, in workflow order. */
|
||||
function releaseFamilies(): readonly ReleaseFamily[] {
|
||||
return [new DshFamily(), new VendorFamily()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a family by its `--family` identifier.
|
||||
* @param id - family identifier.
|
||||
* @returns The family.
|
||||
*/
|
||||
export function releaseFamily(id: string): ReleaseFamily {
|
||||
const family = releaseFamilies().find(candidate => candidate.id === id)
|
||||
if (family === undefined) {
|
||||
const known = releaseFamilies().map(candidate => candidate.id).join(', ')
|
||||
throw new Error(`unknown release family ${id}; expected one of ${known}`)
|
||||
}
|
||||
return family
|
||||
}
|
||||
|
||||
/**
|
||||
* The npm tarball filename `pnpm pack` writes for a member.
|
||||
* @param member - the packed member.
|
||||
* @returns The tarball filename.
|
||||
*/
|
||||
export function tarballName(member: ReleaseMember): string {
|
||||
const unscoped = member.name.startsWith('@') ? member.name.slice(1).replace('/', '-') : member.name
|
||||
return `${unscoped}-${member.version}.tgz`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Pack one release family's whole publish set into a single directory, in
|
||||
* publish order, and record that order for the publish step.
|
||||
*
|
||||
* The pack step is the release boundary: it runs without credentials, produces
|
||||
* every tarball from one commit, and hands the publish step exactly those bytes
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts'
|
||||
import { isEntry, run } from './process.ts'
|
||||
import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts'
|
||||
|
||||
/** Where pack output lands when `--out` is omitted. */
|
||||
const DEFAULT_OUTPUT = 'dist/npm'
|
||||
|
||||
/**
|
||||
* Pack one member and check what its tarball carries.
|
||||
* @param family - the release family being packed.
|
||||
* @param member - the member to pack.
|
||||
* @param destination - absolute output directory.
|
||||
* @returns The tarball filename.
|
||||
*/
|
||||
function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): string {
|
||||
run('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination])
|
||||
|
||||
const filename = tarballName(member)
|
||||
const tarball = join(destination, filename)
|
||||
if (!existsSync(tarball)) throw new Error(`${member.name} produced no tarball at ${tarball}`)
|
||||
family.validatePayload(member, tarballFiles(tarball))
|
||||
return filename
|
||||
}
|
||||
|
||||
/** Pack the family named by `--family` into `--out`. */
|
||||
function main(): void {
|
||||
const { values } = parseArgs({
|
||||
options: { family: { type: 'string' }, out: { type: 'string' } },
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.family === undefined) throw new Error('usage: pack.ts --family <dsh|vendor> [--out dist/npm]')
|
||||
|
||||
const family = releaseFamily(values.family)
|
||||
const root = process.cwd()
|
||||
const destination = resolve(root, values.out ?? DEFAULT_OUTPUT)
|
||||
const members = family.publishOrder(family.members(root))
|
||||
family.verifyVersions(members)
|
||||
|
||||
rmSync(destination, { recursive: true, force: true })
|
||||
mkdirSync(destination, { recursive: true })
|
||||
|
||||
const order: string[] = []
|
||||
for (const member of members) order.push(packMember(family, member, destination))
|
||||
writeFileSync(join(destination, PUBLISH_ORDER_FILE), `${order.join('\n')}\n`)
|
||||
|
||||
console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Process helpers shared by the release scripts: the release steps drive `git`,
|
||||
* `pnpm`, `npm`, and `tar`, and each needs one of three failure behaviours.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** Where and with what environment a release step runs a command. */
|
||||
export interface RunOptions {
|
||||
/** Working directory; defaults to the current one. */
|
||||
readonly cwd?: string
|
||||
/** Child environment; defaults to this process's. */
|
||||
readonly env?: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
/** What a command produced, for a caller that decides what a failure means. */
|
||||
export interface CommandResult {
|
||||
/** Exit status, or null when a signal ended the process. */
|
||||
readonly status: number | null
|
||||
/** Captured standard output. */
|
||||
readonly stdout: string
|
||||
/** Captured standard error. */
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command and capture its output without judging the exit status.
|
||||
* @param command - executable name.
|
||||
* @param args - command arguments.
|
||||
* @param options - working directory and environment.
|
||||
* @returns The exit status and captured streams.
|
||||
*/
|
||||
export function attempt(command: string, args: readonly string[], options: RunOptions = {}): CommandResult {
|
||||
const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, encoding: 'utf8' })
|
||||
if (result.error !== undefined) throw result.error
|
||||
return { status: result.status, stdout: result.stdout, stderr: result.stderr }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command, capture its standard output, and fail on a non-zero exit.
|
||||
* @param command - executable name.
|
||||
* @param args - command arguments.
|
||||
* @param options - working directory and environment.
|
||||
* @returns The trimmed standard output.
|
||||
*/
|
||||
export function capture(command: string, args: readonly string[], options: RunOptions = {}): string {
|
||||
const result = attempt(command, args, options)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}:\n${result.stdout}\n${result.stderr}`)
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command with inherited streams, so its progress reaches the log, and
|
||||
* fail on a non-zero exit.
|
||||
* @param command - executable name.
|
||||
* @param args - command arguments.
|
||||
* @param options - working directory and environment.
|
||||
*/
|
||||
export function run(command: string, args: readonly string[], options: RunOptions = {}): void {
|
||||
const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, stdio: 'inherit' })
|
||||
if (result.error !== undefined) throw result.error
|
||||
if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this module is the process entry point.
|
||||
*
|
||||
* The release scripts are both commands and modules: a test imports their pure
|
||||
* logic, and importing a module runs its body, so an unguarded `main()` would
|
||||
* run the wrong command with the wrong arguments.
|
||||
* @param moduleUrl - the caller's `import.meta.url`.
|
||||
* @returns True when Node started this module.
|
||||
*/
|
||||
export function isEntry(moduleUrl: string): boolean {
|
||||
const invoked = process.argv[1]
|
||||
if (invoked === undefined) return false
|
||||
return realpathSync(invoked) === realpathSync(fileURLToPath(moduleUrl))
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Publish one packed release family from the tarballs the pack step produced.
|
||||
*
|
||||
* Publication is decided per package against the registry, never from a list of
|
||||
* "what this release includes": a version the registry lacks is published, a
|
||||
* version whose published tarball has the same integrity is skipped, and a
|
||||
* version whose published tarball differs fails the run — that last case means
|
||||
* the content changed without a version bump
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*
|
||||
* Skipping on identical integrity is what makes re-running the publish step over
|
||||
* the same artifact safe.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily } from './families.ts'
|
||||
import { attempt, isEntry, run } from './process.ts'
|
||||
import { packedIdentity, readPublishOrder } from './tarball.ts'
|
||||
|
||||
/** npm access level for every package this repository publishes. */
|
||||
const ACCESS = 'restricted'
|
||||
|
||||
/** What the registry knows about one version. */
|
||||
type RegistryState =
|
||||
| { readonly kind: 'absent' }
|
||||
| { readonly kind: 'present'; readonly integrity: string }
|
||||
|
||||
/**
|
||||
* The subresource integrity string npm records for a tarball.
|
||||
* @param tarball - absolute tarball path.
|
||||
* @returns A `sha512-<base64>` string.
|
||||
*/
|
||||
function integrityOf(tarball: string): string {
|
||||
return `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the registry whether a version exists, and with what integrity.
|
||||
* @param name - package name.
|
||||
* @param version - package version.
|
||||
* @returns The registry state for that version.
|
||||
*/
|
||||
function registryState(name: string, version: string): RegistryState {
|
||||
const result = attempt('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'])
|
||||
if (result.status !== 0) {
|
||||
const output = `${result.stdout}${result.stderr}`
|
||||
if (output.includes('E404') || output.includes('404 Not Found')) return { kind: 'absent' }
|
||||
throw new Error(`npm view ${name}@${version} failed:\n${output}`)
|
||||
}
|
||||
const parsed: unknown = JSON.parse(result.stdout)
|
||||
if (typeof parsed !== 'string' || parsed === '') {
|
||||
throw new Error(`registry reported no dist.integrity for ${name}@${version}`)
|
||||
}
|
||||
return { kind: 'present', integrity: parsed }
|
||||
}
|
||||
|
||||
/** Publish the family named by `--family` from the directory named by `--from`. */
|
||||
function main(): void {
|
||||
const { values } = parseArgs({
|
||||
options: { family: { type: 'string' }, from: { type: 'string' } },
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.family === undefined || values.from === undefined) {
|
||||
throw new Error('usage: publish.ts --family <dsh|vendor> --from <packed directory>')
|
||||
}
|
||||
|
||||
const family = releaseFamily(values.family)
|
||||
const directory = resolve(process.cwd(), values.from)
|
||||
|
||||
let published = 0
|
||||
let skipped = 0
|
||||
for (const filename of readPublishOrder(directory)) {
|
||||
const tarball = join(directory, filename)
|
||||
const { name, version } = packedIdentity(tarball)
|
||||
const state = registryState(name, version)
|
||||
if (state.kind === 'present') {
|
||||
const local = integrityOf(tarball)
|
||||
if (state.integrity !== local) {
|
||||
throw new Error(
|
||||
`${name}@${version} is already published with different content`
|
||||
+ `\n registry: ${state.integrity}\n packed: ${local}`
|
||||
+ '\nBump the version, or investigate why the build is not reproducible.',
|
||||
)
|
||||
}
|
||||
console.log(`release publish: ${name}@${version} already published, skipping`)
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
// A prerelease version never takes the latest dist-tag.
|
||||
const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
|
||||
run('npm', ['publish', tarball, '--access', ACCESS, ...tagArgs])
|
||||
published += 1
|
||||
}
|
||||
|
||||
console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Reading packed npm tarballs and the order file that accompanies them.
|
||||
*
|
||||
* The release steps after pack treat a directory of tarballs as the unit of
|
||||
* work, so they read what a tarball declares rather than what the checkout
|
||||
* currently says.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { capture } from './process.ts'
|
||||
|
||||
/** Name of the file recording the order in which a packed family uploads. */
|
||||
export const PUBLISH_ORDER_FILE = 'publish-order.txt'
|
||||
|
||||
/** What a packed tarball calls itself. */
|
||||
export interface PackedIdentity {
|
||||
/** Package name from the packed manifest. */
|
||||
readonly name: string
|
||||
/** Package version from the packed manifest. */
|
||||
readonly version: string
|
||||
}
|
||||
|
||||
/**
|
||||
* List a tarball's members.
|
||||
* @param tarball - absolute tarball path.
|
||||
* @returns Every path inside the archive.
|
||||
*/
|
||||
export function tarballFiles(tarball: string): string[] {
|
||||
return capture('tar', ['-tzf', tarball]).split('\n').filter(line => line !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a packed tarball's own manifest.
|
||||
* @param tarball - absolute tarball path.
|
||||
* @returns The name and version the tarball declares.
|
||||
*/
|
||||
export function packedIdentity(tarball: string): PackedIdentity {
|
||||
const manifest: unknown = JSON.parse(capture('tar', ['-xOzf', tarball, 'package/package.json']))
|
||||
if (manifest === null || typeof manifest !== 'object') throw new Error(`${tarball} has no manifest`)
|
||||
const { name, version } = manifest as Record<string, unknown>
|
||||
if (typeof name !== 'string' || typeof version !== 'string') throw new Error(`${tarball} manifest lacks name/version`)
|
||||
return { name, version }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a packed directory's upload order.
|
||||
* @param directory - absolute path of a pack output directory.
|
||||
* @returns Tarball filenames in upload order.
|
||||
*/
|
||||
export function readPublishOrder(directory: string): string[] {
|
||||
return readFileSync(join(directory, PUBLISH_ORDER_FILE), 'utf8').split('\n').filter(line => line !== '')
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Install packed tarballs into a throwaway consumer outside the repository and
|
||||
* drive the installed executable with plain Node.
|
||||
*
|
||||
* Every tarball the installed tree needs comes from `--from`, so the only
|
||||
* registry traffic is for external dependencies. That matters beyond hermetic
|
||||
* verification: the harness packages declare the vendored framework as a peer,
|
||||
* and those packages live in another release sequence that this credential-free
|
||||
* job cannot fetch from a private registry — so a dsh verification passes the
|
||||
* vendored family's pack output too, while publishing only its own
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*
|
||||
* What this proves is that `files` selected a complete payload and that the
|
||||
* published dependency ranges resolve. A workspace link or a stale `lib/` in the
|
||||
* checkout cannot stand in for a missing file here.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily } from './families.ts'
|
||||
import { capture, isEntry } from './process.ts'
|
||||
import { packedIdentity } from './tarball.ts'
|
||||
|
||||
/**
|
||||
* Environment for the installed artifact: no host Node hooks, no host DeepSeek
|
||||
* Harness home, and no ambient npm user agent that would confuse npm.
|
||||
* @param consumerRoot - the throwaway consumer directory.
|
||||
* @returns The child environment.
|
||||
*/
|
||||
function consumerEnvironment(consumerRoot: string): NodeJS.ProcessEnv {
|
||||
const environment = { ...process.env }
|
||||
delete environment.npm_config_user_agent
|
||||
delete environment.NPM_CONFIG_USER_AGENT
|
||||
delete environment.NODE_OPTIONS
|
||||
delete environment.NODE_PATH
|
||||
environment.DSH_HOME = resolve(consumerRoot, '.dsh')
|
||||
environment.DSH_AGENTS_HOME = resolve(consumerRoot, '.agents')
|
||||
environment.DSH_TELEMETRY_DISABLED = '1'
|
||||
return environment
|
||||
}
|
||||
|
||||
/**
|
||||
* Every packed tarball in the given directories, as `file:` dependency entries.
|
||||
*
|
||||
* The directories are read by their contents rather than a pack order file: a
|
||||
* directory here can hold tarballs packed only to satisfy a cross-sequence
|
||||
* dependency, which no release order describes.
|
||||
* @param directories - absolute directories holding packed tarballs.
|
||||
* @returns Package name to tarball file URL, and the version each carries.
|
||||
*/
|
||||
function packedDependencies(directories: readonly string[]): Map<string, { url: string; version: string }> {
|
||||
const dependencies = new Map<string, { url: string; version: string }>()
|
||||
for (const directory of directories) {
|
||||
const tarballs = readdirSync(directory).filter(name => name.endsWith('.tgz')).sort()
|
||||
if (tarballs.length === 0) throw new Error(`${directory} holds no packed tarball`)
|
||||
for (const filename of tarballs) {
|
||||
const tarball = join(directory, filename)
|
||||
const { name, version } = packedIdentity(tarball)
|
||||
dependencies.set(name, { url: pathToFileURL(tarball).href, version })
|
||||
}
|
||||
}
|
||||
return dependencies
|
||||
}
|
||||
|
||||
/** Install every tarball under `--from` and drive the `--family` entry. */
|
||||
function main(): void {
|
||||
const { values } = parseArgs({
|
||||
options: { family: { type: 'string' }, from: { type: 'string', multiple: true } },
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.family === undefined || values.from === undefined || values.from.length === 0) {
|
||||
throw new Error('usage: verify-packed-install.ts --family <dsh|vendor> --from <packed directory> [--from ...]')
|
||||
}
|
||||
|
||||
const family = releaseFamily(values.family)
|
||||
const entry = family.installedEntry
|
||||
if (entry === undefined) {
|
||||
console.log(`release verify-packed-install: family ${family.id} publishes no executable, nothing to drive`)
|
||||
return
|
||||
}
|
||||
|
||||
const root = process.cwd()
|
||||
const packed = packedDependencies(values.from.map(directory => resolve(root, directory)))
|
||||
const expected = packed.get(entry.packageName)
|
||||
if (expected === undefined) throw new Error(`${entry.packageName} is not among the packed tarballs`)
|
||||
|
||||
const consumerRoot = mkdtempSync(join(tmpdir(), `dsh-packed-${family.id}-`))
|
||||
try {
|
||||
writeFileSync(join(consumerRoot, 'package.json'), `${JSON.stringify({
|
||||
name: `dsh-packed-install-${family.id}`,
|
||||
version: '0.0.0',
|
||||
private: true,
|
||||
dependencies: Object.fromEntries([...packed].map(([name, entryPacked]) => [name, entryPacked.url])),
|
||||
}, null, 2)}\n`)
|
||||
|
||||
const environment = consumerEnvironment(consumerRoot)
|
||||
console.log(`release verify-packed-install: installing ${String(packed.size)} tarball(s) into ${consumerRoot}`)
|
||||
// Optional dependencies are omitted: the Landlock platform packages behind
|
||||
// them need a musl toolchain and one build per architecture, and a consumer
|
||||
// that cannot install them must still start — which is what optional means
|
||||
// here. Their entry package is a plain dependency of dsh-sandbox-local, so
|
||||
// its tarball is supplied through --from.
|
||||
capture('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional'],
|
||||
{ cwd: consumerRoot, env: environment })
|
||||
|
||||
const bin = join(consumerRoot, 'node_modules', ...entry.packageName.split('/'), entry.binPath)
|
||||
const version = capture(process.execPath, [bin, '--version'], { cwd: consumerRoot, env: environment })
|
||||
if (version !== expected.version) {
|
||||
throw new Error(`installed ${entry.packageName} --version reported ${JSON.stringify(version)}, expected ${expected.version}`)
|
||||
}
|
||||
console.log(`release verify-packed-install: installed ${entry.packageName} reports ${version}`)
|
||||
} finally {
|
||||
rmSync(consumerRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Verify a release family's version baseline, and — when publishing — that the
|
||||
* run comes from the family's tag and its members are publishable.
|
||||
*
|
||||
* Publication happens only from GitHub Actions, so the tag and publishability
|
||||
* checks are gates on the workflow, not advisory local warnings
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { isEntry } from './process.ts'
|
||||
import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts'
|
||||
|
||||
/**
|
||||
* Assert every member may be published: npm refuses a `private` package.
|
||||
* @param members - the family's members.
|
||||
*/
|
||||
function verifyPublishable(members: readonly ReleaseMember[]): void {
|
||||
const priv = members.filter(member => member.manifest.private === true)
|
||||
if (priv.length > 0) {
|
||||
throw new Error(`publishing requires removing "private": true from:\n${priv.map(member => member.directory).join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the workflow runs from a tag this family publishes from, and that the
|
||||
* tag names a version the family actually carries.
|
||||
* @param family - the release family.
|
||||
* @param members - the family's members.
|
||||
* @param ref - the `GITHUB_REF` value.
|
||||
*/
|
||||
function verifyTag(family: ReleaseFamily, members: readonly ReleaseMember[], ref: string): void {
|
||||
const prefix = 'refs/tags/'
|
||||
if (!ref.startsWith(prefix)) {
|
||||
throw new Error(`publishing release family ${family.id} requires running from a ${family.tagPrefix}* tag, got ${ref || '(no ref)'}`)
|
||||
}
|
||||
const tag = ref.slice(prefix.length)
|
||||
if (!tag.startsWith(family.tagPrefix)) {
|
||||
throw new Error(`tag ${tag} does not belong to release family ${family.id} (expected ${family.tagPrefix}*)`)
|
||||
}
|
||||
const expected = members.map(member => family.tagFor(member))
|
||||
if (!expected.includes(tag)) {
|
||||
throw new Error(`tag ${tag} names no version this family carries; its members would tag as:\n${[...new Set(expected)].join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the verification for the family named by `--family`. */
|
||||
function main(): void {
|
||||
const { values } = parseArgs({
|
||||
options: { family: { type: 'string' } },
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.family === undefined) throw new Error('usage: verify.ts --family <dsh|vendor>')
|
||||
|
||||
const family = releaseFamily(values.family)
|
||||
const members = family.members(process.cwd())
|
||||
family.verifyVersions(members)
|
||||
|
||||
const publishing = process.env.RELEASE_PUBLISH === 'true'
|
||||
if (publishing) {
|
||||
verifyPublishable(members)
|
||||
verifyTag(family, members, process.env.GITHUB_REF ?? '')
|
||||
}
|
||||
|
||||
const versions = [...new Set(members.map(member => member.version))]
|
||||
const summary = versions.length === 1 ? versions[0] : `${String(versions.length)} versions`
|
||||
console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}${publishing ? ', publish gates passed' : ''}`)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Acceptance-path coverage for the rescope codemod's exact-edit classifier: a
|
||||
* duplicated insertion — what a non-idempotent apply produces — must be
|
||||
* rejected rather than applied again.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { exactEditState } from './rescope-vendor.ts'
|
||||
|
||||
const ANCHOR = '\n## Sync procedure'
|
||||
const INSERTED = `\n15. **rescope**: one log entry.\n${ANCHOR}`
|
||||
|
||||
describe('exactEditState', () => {
|
||||
it('classifies an insertion by its target form, so a duplicate is invalid', () => {
|
||||
expect(exactEditState(`log\n${ANCHOR}\n`, ANCHOR, INSERTED, 1)).toBe('pending')
|
||||
expect(exactEditState(`log${INSERTED}\n`, ANCHOR, INSERTED, 1)).toBe('applied')
|
||||
// The anchor survives an insertion, so counting the source form would have
|
||||
// called this pending and inserted the entry a second time.
|
||||
expect(exactEditState(`log${INSERTED}${INSERTED}\n`, ANCHOR, INSERTED, 1)).toBe('invalid')
|
||||
expect(exactEditState('log\n', ANCHOR, INSERTED, 1)).toBe('invalid')
|
||||
})
|
||||
|
||||
it('classifies a deletion by its source form, and requires its remainder to survive', () => {
|
||||
const remainder = 'exclude:\n'
|
||||
const withEntries = 'exclude:\n - cordis@4\n'
|
||||
expect(exactEditState(withEntries, withEntries, remainder, 1)).toBe('pending')
|
||||
expect(exactEditState(remainder, withEntries, remainder, 1)).toBe('applied')
|
||||
// Upstream dropped the whole field: the source form is gone, but so is the
|
||||
// remainder, so this is a moved site rather than a completed deletion.
|
||||
expect(exactEditState('unrelated:\n', withEntries, remainder, 1)).toBe('invalid')
|
||||
})
|
||||
|
||||
it('requires a replacement to leave no source form and the exact target count', () => {
|
||||
expect(exactEditState('a = 1\n', 'a = 1', 'b = 2', 1)).toBe('pending')
|
||||
expect(exactEditState('b = 2\n', 'a = 1', 'b = 2', 1)).toBe('applied')
|
||||
expect(exactEditState('b = 2\nb = 2\n', 'a = 1', 'b = 2', 1)).toBe('invalid')
|
||||
// A moved or partially applied site: neither state is complete.
|
||||
expect(exactEditState('a = 1\nb = 2\n', 'a = 1', 'b = 2', 1)).toBe('invalid')
|
||||
expect(exactEditState('x\n', 'a = 1', 'b = 2', 1)).toBe('invalid')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* Rescope the vendored Cordis packages into the `@deepseek-ai` scope, and undo
|
||||
* that rescope with `--reverse`. Every harness package declares `cordis` as a
|
||||
* peer dependency, so publication carries this framework layer too; publishing
|
||||
* it under the upstream names would squat them on the registry
|
||||
* ([rationale](../.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md),
|
||||
* [name mapping](../docs/rescope.md)).
|
||||
*
|
||||
* The generic pass rewrites ONLY delimited, complete package-name tokens:
|
||||
* `'old'` / `"old"` / `` `old` `` / `'old/subpath'`, plus a YAML `name: old`
|
||||
* scalar. A match needs a quote (or `name: `) immediately left and the matching
|
||||
* quote — optionally after a `/subpath` — immediately right, which excludes
|
||||
* `cordis.yml`, the Loader's `cordis:` builtin prefix, `cordis-config-entry`,
|
||||
* `@deepseek-ai/dsh-tool-cordis`, and `cordiverse/cordis`, and makes the
|
||||
* rewrite idempotent because the scoped name's `cordis` is preceded by `/`.
|
||||
* Markdown follows the rename inside every fence, and in `docs/` prose too:
|
||||
* a tutorial that teaches an unresolvable name is wrong, while prose elsewhere
|
||||
* records what was true when it was written.
|
||||
*
|
||||
* Sites the token rule cannot express (dot-notation access, unquoted object
|
||||
* keys, regex literals, the vendored-manifest table) are listed in
|
||||
* {@link EXACT_EDITS} with an exact hit count, so an upstream change to one of
|
||||
* them fails loudly instead of being silently skipped.
|
||||
*
|
||||
* Usage: `pnpm run rescope-vendor [--apply|--check] [--reverse]`. Without a
|
||||
* mode it reports what would change. `--check` asserts the post-state: no
|
||||
* residue, every exact edit landed, every postcondition holds, and a second
|
||||
* `--apply` would be a no-op.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** One vendored package's directory, upstream npm name, and rescoped name. */
|
||||
interface Rename {
|
||||
readonly directory: string
|
||||
readonly upstream: string
|
||||
readonly scoped: string
|
||||
}
|
||||
|
||||
/** The mapping this codemod applies; `vendor/README.md` carries the same table. */
|
||||
const RENAMES: readonly Rename[] = [
|
||||
{ directory: 'cordis', upstream: 'cordis', scoped: '@deepseek-ai/cordis' },
|
||||
{ directory: 'cosmokit', upstream: 'cosmokit', scoped: '@deepseek-ai/cosmokit' },
|
||||
{ directory: 'schemastery', upstream: 'schemastery', scoped: '@deepseek-ai/schemastery' },
|
||||
{ directory: 'loader', upstream: '@cordisjs/plugin-loader', scoped: '@deepseek-ai/cordis-plugin-loader' },
|
||||
{ directory: 'include', upstream: '@cordisjs/plugin-include', scoped: '@deepseek-ai/cordis-plugin-include' },
|
||||
{ directory: 'group', upstream: '@cordisjs/plugin-group', scoped: '@deepseek-ai/cordis-plugin-group' },
|
||||
{ directory: 'timer', upstream: '@cordisjs/plugin-timer', scoped: '@deepseek-ai/cordis-plugin-timer' },
|
||||
{ directory: 'hmr', upstream: '@cordisjs/plugin-hmr', scoped: '@deepseek-ai/cordis-plugin-hmr' },
|
||||
{ directory: 'logger-console', upstream: '@cordisjs/plugin-logger-console', scoped: '@deepseek-ai/cordis-plugin-logger-console' },
|
||||
]
|
||||
|
||||
const EXTENSIONS = ['.ts', '.tsx', '.js', '.mjs', '.cjs', '.tpl', '.json', '.yml', '.yaml', '.md'] as const
|
||||
|
||||
/** An exact-string edit the token rule cannot express, with its required hit count. */
|
||||
interface ExactEdit {
|
||||
readonly id: string
|
||||
readonly file: string
|
||||
readonly find: string
|
||||
readonly replace: string
|
||||
readonly expect: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A file where an upstream name also appears as a vendor DIRECTORY name or an
|
||||
* upstream runtime identifier: the generic pass is disabled for the listed
|
||||
* names and {@link EXACT_EDITS} renames the real package-name occurrences.
|
||||
*/
|
||||
interface GenericSkip {
|
||||
readonly file: string
|
||||
readonly upstream: readonly string[]
|
||||
}
|
||||
|
||||
const GENERIC_SKIPS: readonly GenericSkip[] = [
|
||||
// `vendorPackages` lists vendor/ directory names, joined with 'vendor' below it.
|
||||
{ file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', upstream: ['cordis', 'cosmokit', 'schemastery'] },
|
||||
// `Symbol.for('schemastery')` and the `vendor:` metadata field are upstream identifiers.
|
||||
{ file: 'vendor/schemastery/src/index.ts', upstream: ['schemastery'] },
|
||||
// Asserts the vendored-manifest table, which gains an upstream-name column.
|
||||
{ file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) },
|
||||
// `cordis` is also an agent-preset id — the directory name under
|
||||
// apps/cli/config/agent-presets/ — so in these files the bare name is
|
||||
// product data, not a package reference. Renaming it changed which preset
|
||||
// the creator flow stages and which id the roster reports.
|
||||
{ file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/src/client/index.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/tests/section.spec.tsx', upstream: ['cordis'] },
|
||||
{ file: 'apps/cli/tests/web-agent-presets.e2e.ts', upstream: ['cordis'] },
|
||||
{ file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/preset/agent-presets/tests/session.spec.ts', upstream: ['cordis'] },
|
||||
// The preset's own composition: its header comment and its system prompt name
|
||||
// the preset a model mounts, so the scoped name would send the model after an
|
||||
// id no roster reports.
|
||||
{ file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', upstream: ['cordis'] },
|
||||
// GROUP_ORDER holds `packages/<group>/` directory names, not package names.
|
||||
{ file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] },
|
||||
{ file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] },
|
||||
]
|
||||
|
||||
/** A string that must appear exactly `count` times once the rescope has run. */
|
||||
interface PostCondition {
|
||||
readonly file: string
|
||||
readonly text: string
|
||||
readonly count: number
|
||||
}
|
||||
|
||||
const POSTCONDITIONS: readonly PostCondition[] = [
|
||||
{ file: 'vendor/cordis/package.json', text: '"name": "@deepseek-ai/cordis"', count: 1 },
|
||||
{ file: 'vendor/hmr/package.json', text: '"name": "@deepseek-ai/cordis-plugin-hmr"', count: 1 },
|
||||
{ file: 'scripts/cordis-walk.ts', text: '@deepseek-ai\\/cordis', count: 1 },
|
||||
{ file: 'scripts/cordis-walk.ts', text: '!== \'@deepseek-ai/cordis\'', count: 1 },
|
||||
{ file: 'scripts/gen-scoped-events.ts', text: '=== \'@deepseek-ai/cordis\'', count: 1 },
|
||||
{ file: 'packages/typert/generator/src/analyzer.ts', text: '!== \'@deepseek-ai/cordis\'', count: 2 },
|
||||
{ file: 'scripts/check-workspace-constraints.ts', text: '?.[\'@deepseek-ai/cordis\']', count: 2 },
|
||||
{ file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 },
|
||||
{ file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 },
|
||||
// One insertion, once: a duplicated log entry is what a non-idempotent apply produced.
|
||||
{ file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 },
|
||||
{ file: 'knip.json', text: '@cordisjs', count: 0 },
|
||||
{ file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 },
|
||||
// The preset ids in this table are product data, not package names.
|
||||
{ file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 },
|
||||
// The preset id the shipped composition documents to its own model.
|
||||
{ file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 },
|
||||
{ file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 },
|
||||
{ file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', text: '\'cordis\', \'loader\', \'include\', \'timer\', \'hmr\', \'logger-console\',', count: 1 },
|
||||
]
|
||||
|
||||
/**
|
||||
* Every exact edit, in application order. Each `find` is written against the
|
||||
* PRE-rename text because these run before the generic pass, so no `find` may
|
||||
* quote a neighbouring line the generic pass would rewrite.
|
||||
*/
|
||||
const EXACT_EDITS: readonly ExactEdit[] = [
|
||||
{
|
||||
id: 'cordis-walk-merge-head',
|
||||
file: 'scripts/cordis-walk.ts',
|
||||
find: 'const MERGE_HEAD = /declare module [\'"](?:cordis|\\.\\/context\\.ts)[\'"]/',
|
||||
replace: 'const MERGE_HEAD = /declare module [\'"](?:@deepseek-ai\\/cordis|\\.\\/context\\.ts)[\'"]/',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'constraints-manifest-lookup',
|
||||
file: 'scripts/check-workspace-constraints.ts',
|
||||
find: ` const peer = manifest.peerDependencies?.cordis
|
||||
const dev = manifest.devDependencies?.cordis
|
||||
|
||||
if (!peer) errors.push(\`\${label}: cordis must be a peerDependency\`)
|
||||
if (!dev) errors.push(\`\${label}: cordis must also be a devDependency\`)
|
||||
if (peer && dev && peer !== dev) {
|
||||
errors.push(\`\${label}: cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`,
|
||||
replace: ` const peer = manifest.peerDependencies?.['@deepseek-ai/cordis']
|
||||
const dev = manifest.devDependencies?.['@deepseek-ai/cordis']
|
||||
|
||||
if (!peer) errors.push(\`\${label}: @deepseek-ai/cordis must be a peerDependency\`)
|
||||
if (!dev) errors.push(\`\${label}: @deepseek-ai/cordis must also be a devDependency\`)
|
||||
if (peer && dev && peer !== dev) {
|
||||
errors.push(\`\${label}: @deepseek-ai/cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The rescoped name is already covered by the `@deepseek-ai/.+` pattern beside it.
|
||||
id: 'knip-logger-console',
|
||||
file: 'knip.json',
|
||||
find: ` "ignoreDependencies": [
|
||||
"@cordisjs/plugin-logger-console",
|
||||
"@deepseek-ai/.+"
|
||||
]
|
||||
},
|
||||
"packages/util/home": {`,
|
||||
replace: ` "ignoreDependencies": [
|
||||
"@deepseek-ai/.+"
|
||||
]
|
||||
},
|
||||
"packages/util/home": {`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'knip-bundle-base',
|
||||
file: 'knip.json',
|
||||
find: ` "packages/bundle/base": {
|
||||
"ignoreDependencies": [
|
||||
"@deepseek-ai/.+",
|
||||
"@cordisjs/.+"
|
||||
]`,
|
||||
replace: ` "packages/bundle/base": {
|
||||
"ignoreDependencies": [
|
||||
"@deepseek-ai/.+"
|
||||
]`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// Rescoped packages are never fetched from a registry, so the exclusion is dead config.
|
||||
id: 'pnpm-release-age',
|
||||
file: 'pnpm-workspace.yaml',
|
||||
find: `minimumReleaseAgeExclude:
|
||||
# Cordis release candidates are source-vendored and pinned in vendor/README.md
|
||||
# during the same-day sync that updates package manifests and the lockfile.
|
||||
- '@cordisjs/plugin-loader@1.0.0-rc.5'
|
||||
- cordis@4.0.0-rc.7
|
||||
`,
|
||||
replace: 'minimumReleaseAgeExclude:\n',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'publication-set-scope-assertion',
|
||||
file: 'scripts/publish-npm-baseline.ts',
|
||||
find: ' if (!isVendored && !name.startsWith(\'@deepseek-ai/\')) {',
|
||||
replace: ` // Vendored packages are rescoped too (vendor/README.md), so publication
|
||||
// never carries an upstream name that would squat it on the registry.
|
||||
if (!name.startsWith('@deepseek-ai/')) {`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendor-readme-preamble',
|
||||
file: 'vendor/README.md',
|
||||
find: 'All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names.',
|
||||
replace: 'All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`.',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendor-readme-schemastery-note',
|
||||
file: 'vendor/README.md',
|
||||
find: 'whose lazy `require(\'cosmokit\')` can race',
|
||||
replace: 'whose lazy `require(\'@deepseek-ai/cosmokit\')` can race',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendor-readme-table-head',
|
||||
file: 'vendor/README.md',
|
||||
find: '| Directory | npm name | Version | Upstream repo | Commit |\n|---|---|---|---|---|',
|
||||
replace: '| Directory | npm name | Upstream name | Version | Upstream repo | Commit |\n|---|---|---|---|---|---|',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendor-readme-local-modification-log',
|
||||
file: 'vendor/README.md',
|
||||
find: '\n16. **`cordis/package.json` publishes `src`**',
|
||||
replace: '\n16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.\n17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// A plain fence listing the bundle's mounted tree: a bare token, no quotes.
|
||||
id: 'agent-spine-demo-mounted-tree',
|
||||
file: 'packages/examples/agent-spine-demo/README.md',
|
||||
find: '@cordisjs/plugin-timer timer service',
|
||||
replace: '@deepseek-ai/cordis-plugin-timer timer service',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'agent-spine-demo-mounted-tree-zh',
|
||||
file: 'packages/examples/agent-spine-demo/README.zh.md',
|
||||
find: '@cordisjs/plugin-timer timer service',
|
||||
replace: '@deepseek-ai/cordis-plugin-timer timer service',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The root contract claimed vendored packages keep their upstream names.
|
||||
id: 'root-agents-vendored-name-contract',
|
||||
file: 'AGENTS.md',
|
||||
find: 'vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.',
|
||||
replace: 'vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package.',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The client purity gate reads `@deepseek-ai/` as "another plugin package".
|
||||
// The rescope moves the vendored framework and its libraries into that
|
||||
// namespace, where the gate would reject the library imports client
|
||||
// bundles have always inlined, so it needs their names.
|
||||
id: 'client-purity-vendored-libraries',
|
||||
file: 'packages/client/tsdown.client.ts',
|
||||
find: '/** Generated descriptor/codec contribution with no shared runtime identity. */',
|
||||
replace: `/**
|
||||
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
|
||||
* would read them as plugin packages. They carry no cross-plugin runtime
|
||||
* identity to share — the framework itself is a platform module (external),
|
||||
* while these are ordinary libraries a browser bundle inlines.
|
||||
*/
|
||||
const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
|
||||
|
||||
/** Generated descriptor/codec contribution with no shared runtime identity. */`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'client-purity-vendored-libraries-predicate',
|
||||
file: 'packages/client/tsdown.client.ts',
|
||||
find: ' if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point',
|
||||
replace: ` if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity
|
||||
if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The step-1 file tree told the reader to keep the upstream name, one
|
||||
// paragraph above the invariant that says to rescope it.
|
||||
id: 'vendoring-cookbook-tree-comment',
|
||||
file: 'docs/cookbook/adding-a-vendored-package.md',
|
||||
find: ' package.json # from upstream; set "private": true, keep name/exports/type',
|
||||
replace: ' package.json # from upstream; set "private": true, rescope the name, keep exports/type',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendoring-cookbook-tree-comment-zh',
|
||||
file: 'docs/cookbook/adding-a-vendored-package.zh.md',
|
||||
find: ' package.json # from upstream; set "private": true, keep name/exports/type',
|
||||
replace: ' package.json # from upstream; set "private": true, rescope the name, keep exports/type',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The checklist told the next vendoring to keep upstream's name.
|
||||
id: 'vendoring-cookbook-name-invariant',
|
||||
file: 'docs/cookbook/adding-a-vendored-package.md',
|
||||
find: "keep upstream's `name`/`version`/`exports`/`type`",
|
||||
replace: "rescope the `name` ([mapping](../rescope.md)) while keeping upstream's `version`/`exports`/`type`",
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendoring-cookbook-name-invariant-zh',
|
||||
file: 'docs/cookbook/adding-a-vendored-package.zh.md',
|
||||
find: '保留上游的 `name`/`version`/`exports`/`type`',
|
||||
replace: '改写 `name` 的 scope([映射](../rescope.md)),保留上游的 `version`/`exports`/`type`',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The real package references in files whose other `cordis` strings are preset ids.
|
||||
id: 'agent-preset-spec-framework-import',
|
||||
file: 'packages/client/ui-agent-preset/tests/apply.spec.ts',
|
||||
find: "import { Context } from 'cordis'",
|
||||
replace: "import { Context } from '@deepseek-ai/cordis'",
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'web-agent-presets-e2e-framework-import',
|
||||
file: 'apps/cli/tests/web-agent-presets.e2e.ts',
|
||||
find: "import { Context } from 'cordis'",
|
||||
replace: "import { Context } from '@deepseek-ai/cordis'",
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-vendored-row-type',
|
||||
file: 'scripts/gen-third-party-notices.ts',
|
||||
find: `export interface VendoredRow {
|
||||
npmName: string
|
||||
upstream: string
|
||||
}`,
|
||||
replace: `export interface VendoredRow {
|
||||
npmName: string
|
||||
/** The name this package carries upstream; MIT attribution names the fork's origin, not our scope. */
|
||||
upstreamName: string
|
||||
upstream: string
|
||||
}`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-vendored-row-parse',
|
||||
file: 'scripts/gen-third-party-notices.ts',
|
||||
find: ` const match = /^\\| \\x60\\S+\\/\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\S+ \\| (https:\\/\\/\\S+?)(?: \\([^)]*\\))? \\| \\x60[0-9a-f]+\\x60 \\|$/.exec(line)
|
||||
if (match === null) continue
|
||||
const [, npmName, upstream] = match
|
||||
if (npmName === undefined || upstream === undefined) continue
|
||||
rows.push({ npmName, upstream })`,
|
||||
replace: ` const match = new RegExp(String.raw\`^\\| \\x60\\S+\\/\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\S+ \\| \`
|
||||
+ String.raw\`(https:\\/\\/\\S+?)(?: \\([^)]*\\))? \\| \\x60[0-9a-f]+\\x60 \\|$\`).exec(line)
|
||||
if (match === null) continue
|
||||
const [, npmName, upstreamName, upstream] = match
|
||||
if (npmName === undefined || upstreamName === undefined || upstream === undefined) continue
|
||||
rows.push({ npmName, upstreamName, upstream })`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-vendored-section',
|
||||
file: 'scripts/gen-third-party-notices.ts',
|
||||
find: 'The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed',
|
||||
replace: 'The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the \\`@deepseek-ai\\` scope. All are MIT-licensed',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-vendored-table',
|
||||
file: 'scripts/gen-third-party-notices.ts',
|
||||
find: `| Package | Upstream | License |
|
||||
| --- | --- | --- |
|
||||
\${vendored.map(row => \`| \\\`\${row.npmName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`,
|
||||
replace: `| Package | Upstream name | Upstream | License |
|
||||
| --- | --- | --- | --- |
|
||||
\${vendored.map(row => \`| \\\`\${row.npmName}\\\` | \\\`\${row.upstreamName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-spec-row-fixture',
|
||||
file: 'scripts/gen-third-party-notices.spec.ts',
|
||||
find: ' expect(rows).toContainEqual({ npmName: \'cordis\', upstream: \'https://github.com/cordiverse/cordis\' })',
|
||||
replace: ` expect(rows).toContainEqual({
|
||||
npmName: '@deepseek-ai/cordis',
|
||||
upstreamName: 'cordis',
|
||||
upstream: 'https://github.com/cordiverse/cordis',
|
||||
})`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-spec-shape-fixture',
|
||||
file: 'scripts/gen-third-party-notices.spec.ts',
|
||||
find: 'parseVendoredRows(\'| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')',
|
||||
replace: 'parseVendoredRows(\'| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The framework peer is no longer a registry name, so the rehearsal must install this
|
||||
// repository's vendored copies; cosmokit comes along as cordis's own dependency.
|
||||
id: 'packed-install-vendored-peer',
|
||||
file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts',
|
||||
find: ` 'packages/support/invariants',
|
||||
]`,
|
||||
replace: ` 'packages/support/invariants',
|
||||
// The framework and the vendored packages the closure declares outright:
|
||||
// rescoped into @deepseek-ai, so the consumer installs this repository's
|
||||
// copies. Schemastery is a hard dependency of three members above, not a
|
||||
// peer, so npm resolves it while installing them.
|
||||
'vendor/cordis',
|
||||
'vendor/cosmokit',
|
||||
'vendor/schemastery',
|
||||
]`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'packed-install-registry-spec',
|
||||
file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts',
|
||||
find: ` // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional
|
||||
// dependencies because the launcher selects its OS/CPU package through one.
|
||||
writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' }))
|
||||
const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.7'], {`,
|
||||
replace: ` // Peer ranges resolve to the tarballs, the framework peer included. Do not omit optional
|
||||
// dependencies because the launcher selects its OS/CPU package through one.
|
||||
writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' }))
|
||||
const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], {`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'packed-install-module-doc',
|
||||
file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts',
|
||||
find: ` * Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current
|
||||
* repository's Landlock entry/platform packages, then installs those exact tarballs in an external
|
||||
* plain-Node consumer. The host launcher comes from the exact local tarballs, so no registry copy,
|
||||
* tsx, path mapping, or workspace resolution can hide missing files, dependency errors, or lost
|
||||
* executable modes.`,
|
||||
replace: ` * Keyless publish-path rehearsal. It packs the provider, its workspace peers, the vendored framework
|
||||
* peer, and the current repository's Landlock entry/platform packages, then installs those exact
|
||||
* tarballs in an external plain-Node consumer. The host launcher comes from the exact local tarballs,
|
||||
* so no registry copy, tsx, path mapping, or workspace resolution can hide missing files, dependency
|
||||
* errors, or lost executable modes.`,
|
||||
expect: 1,
|
||||
},
|
||||
// The manifest table's name column plus the new upstream-name column, one edit per row.
|
||||
...RENAMES.map(rename => ({
|
||||
id: `vendor-readme-row-${rename.directory}`,
|
||||
file: 'vendor/README.md',
|
||||
find: `| \`${rename.directory}/\` | \`${rename.upstream}\` | `,
|
||||
replace: `| \`${rename.directory}/\` | \`${rename.scoped}\` | \`${rename.upstream}\` | `,
|
||||
expect: 1,
|
||||
})),
|
||||
]
|
||||
|
||||
/** Files the rescope must never rewrite. */
|
||||
function excluded(file: string): boolean {
|
||||
if (file === 'scripts/rescope-vendor.ts') return true // the mapping itself
|
||||
if (file.startsWith('.agents/notes/')) return true // notes record what was true when written
|
||||
// Recorded model payloads quote documentation verbatim, so they must mirror the
|
||||
// sources on disk — including the notes this rescope leaves alone.
|
||||
if (file.startsWith('scripts/snapshots/')) return true
|
||||
// The mapping documents state both names on purpose.
|
||||
if (file === 'docs/rescope.md' || file === 'docs/rescope.zh.md') return true
|
||||
if (file.endsWith('.i18n.yaml')) return true // blob-hash records, re-recorded by the pairing gate
|
||||
if (file === 'pnpm-lock.yaml') return true // regenerated by pnpm install
|
||||
if (/^vendor\/[^/]+\/(README\.md|LICENSE)$/.test(file)) return true // upstream files kept verbatim
|
||||
return !EXTENSIONS.some(extension => file.endsWith(extension))
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/** One name's rewrite, precompiled for both delimited forms. */
|
||||
interface Pattern {
|
||||
readonly upstream: string
|
||||
readonly from: string
|
||||
readonly to: string
|
||||
readonly token: RegExp
|
||||
readonly yamlName: RegExp
|
||||
}
|
||||
|
||||
function patterns(reverse: boolean): Pattern[] {
|
||||
return RENAMES
|
||||
.map(rename => ({
|
||||
upstream: rename.upstream,
|
||||
from: reverse ? rename.scoped : rename.upstream,
|
||||
to: reverse ? rename.upstream : rename.scoped,
|
||||
}))
|
||||
.sort((left, right) => right.from.length - left.from.length)
|
||||
.map(rename => ({
|
||||
...rename,
|
||||
token: new RegExp(`(['"\`])${escapeRegExp(rename.from)}((?:/[^'"\`\\s]*)?)\\1`, 'g'),
|
||||
yamlName: new RegExp(`^(\\s*(?:-\\s*)?name:[ \\t]+)${escapeRegExp(rename.from)}([ \\t]*(?:#.*)?)$`, 'gm'),
|
||||
}))
|
||||
}
|
||||
|
||||
function skipped(file: string, pattern: Pattern): boolean {
|
||||
return GENERIC_SKIPS.some(skip => skip.file === file && skip.upstream.includes(pattern.upstream))
|
||||
}
|
||||
|
||||
function rewriteLine(line: string, file: string, all: readonly Pattern[]): string {
|
||||
let out = line
|
||||
for (const pattern of all) {
|
||||
if (skipped(file, pattern)) continue
|
||||
out = out.replace(pattern.token, (_match, quote: string, subpath: string) => `${quote}${pattern.to}${subpath}${quote}`)
|
||||
out = out.replace(pattern.yamlName, (_match, prefix: string, suffix: string) => `${prefix}${pattern.to}${suffix}`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a file's eligible lines.
|
||||
*
|
||||
* Markdown splits in two. Every fence is code a reader copies or a
|
||||
* configuration they mount, so every fence follows the rename regardless of its
|
||||
* info string. Prose follows it only under `docs/`, where a sentence quoting
|
||||
* `` `cordis` `` teaches a name this repository no longer resolves; elsewhere
|
||||
* prose is a record of what was true when it was written, and the same spelling
|
||||
* can mean something else entirely — the Python SDK's `cordis` option, or the
|
||||
* unvendored `@cordisjs/plugin-http`.
|
||||
*/
|
||||
function rewrite(text: string, file: string, all: readonly Pattern[]): { text: string; lines: number } {
|
||||
const markdown = file.endsWith('.md')
|
||||
const prose = markdown && file.startsWith('docs/')
|
||||
let insideFence = false
|
||||
let lines = 0
|
||||
const out = text.split('\n').map((line) => {
|
||||
if (markdown) {
|
||||
if (/^\s*```/.test(line)) {
|
||||
insideFence = !insideFence
|
||||
return line
|
||||
}
|
||||
if (!insideFence && !prose) return line
|
||||
}
|
||||
const next = rewriteLine(line, file, all)
|
||||
if (next !== line) lines += 1
|
||||
return next
|
||||
})
|
||||
return { text: out.join('\n'), lines }
|
||||
}
|
||||
|
||||
function classify(file: string): string {
|
||||
if (/^vendor\/[^/]+\/package\.json$/.test(file)) return 'vendor manifest name'
|
||||
if (file.endsWith('package.json')) return 'package.json dependencies'
|
||||
if (/\.(ts|tsx|js|mjs|cjs|tpl)$/.test(file)) return 'code specifiers'
|
||||
if (/\.(yml|yaml)$/.test(file)) return 'YAML plugin names'
|
||||
if (file.endsWith('.json')) return 'JSON configuration'
|
||||
return 'Markdown fences and docs prose'
|
||||
}
|
||||
|
||||
/**
|
||||
* One exact edit's state in the text it targets. `pending` means the source
|
||||
* form is present and the target form absent; `applied` means the reverse;
|
||||
* anything else — a partial application, a moved site, or a DUPLICATED
|
||||
* insertion — is `invalid`, so it fails the run instead of being applied again.
|
||||
*/
|
||||
export type ExactEditState = 'pending' | 'applied' | 'invalid'
|
||||
|
||||
/**
|
||||
* Classify one exact edit against its target text.
|
||||
*
|
||||
* An insertion keeps its anchor (`replace` contains `find`) and a deletion
|
||||
* keeps its remainder (`find` contains `replace`), so neither can be judged by
|
||||
* the source form alone: the surviving side counts the target form instead.
|
||||
* @param text - the complete current text of the edited file.
|
||||
* @param find - the source form, already oriented for the running direction.
|
||||
* @param replace - the target form, already oriented for the running direction.
|
||||
* @param expect - how many occurrences one complete application produces.
|
||||
* @returns Whether the edit is pending, already applied, or invalid.
|
||||
*/
|
||||
export function exactEditState(text: string, find: string, replace: string, expect: number): ExactEditState {
|
||||
const hits = text.split(find).length - 1
|
||||
const landed = text.split(replace).length - 1
|
||||
if (replace.includes(find)) {
|
||||
if (landed === expect) return 'applied'
|
||||
return landed === 0 && hits === expect ? 'pending' : 'invalid'
|
||||
}
|
||||
if (find.includes(replace)) {
|
||||
if (hits === 0) return landed === expect ? 'applied' : 'invalid'
|
||||
return hits === expect ? 'pending' : 'invalid'
|
||||
}
|
||||
if (hits === 0 && landed === expect) return 'applied'
|
||||
return hits === expect && landed === 0 ? 'pending' : 'invalid'
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const args = process.argv.slice(2)
|
||||
const mode = args.includes('--apply') ? 'apply' : args.includes('--check') ? 'check' : 'dry'
|
||||
const reverse = args.includes('--reverse')
|
||||
const all = patterns(reverse)
|
||||
const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' })
|
||||
.split('\0')
|
||||
.filter(file => file !== '' && !excluded(file))
|
||||
|
||||
const counts = new Map<string, { files: number; lines: number }>()
|
||||
const failures: string[] = []
|
||||
const outstanding: string[] = []
|
||||
|
||||
// Classify every exact edit before writing anything: a single invalid site
|
||||
// means the mapping and the tree disagree, and a half-applied tree is worse
|
||||
// than an untouched one.
|
||||
const planned: { edit: ExactEdit; path: string; find: string; replace: string }[] = []
|
||||
for (const edit of EXACT_EDITS) {
|
||||
const path = resolve(root, edit.file)
|
||||
const before = readFileSync(path, 'utf8')
|
||||
const find = reverse ? edit.replace : edit.find
|
||||
const replace = reverse ? edit.find : edit.replace
|
||||
const state = exactEditState(before, find, replace, edit.expect)
|
||||
if (state === 'invalid') {
|
||||
failures.push(`exact edit ${edit.id}: ${edit.file} is neither pending nor cleanly applied (duplicated, partial, or moved)`)
|
||||
continue
|
||||
}
|
||||
if (mode === 'check') {
|
||||
if (state !== 'applied') failures.push(`exact edit ${edit.id} did not land in ${edit.file}`)
|
||||
continue
|
||||
}
|
||||
if (state === 'pending') planned.push({ edit, path, find, replace })
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) console.error(`rescope-vendor: ${failure}`)
|
||||
console.error(`rescope-vendor: ${String(failures.length)} problem(s); nothing was written.`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (mode === 'apply') {
|
||||
// Re-read per edit: two edits can target one file, and a stale snapshot
|
||||
// would let the second write discard the first.
|
||||
for (const { path, find, replace } of planned) {
|
||||
writeFileSync(path, readFileSync(path, 'utf8').split(find).join(replace))
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const path = resolve(root, file)
|
||||
const before = readFileSync(path, 'utf8')
|
||||
const { text: after, lines } = rewrite(before, file, all)
|
||||
if (after === before) continue
|
||||
outstanding.push(file)
|
||||
const kind = classify(file)
|
||||
const current = counts.get(kind) ?? { files: 0, lines: 0 }
|
||||
counts.set(kind, { files: current.files + 1, lines: current.lines + lines })
|
||||
if (mode === 'apply') writeFileSync(path, after)
|
||||
}
|
||||
|
||||
console.log(`rescope-vendor: ${mode}${reverse ? ' --reverse' : ''} over ${String(files.length)} tracked files`)
|
||||
for (const kind of [...counts.keys()].sort()) {
|
||||
const { files: count, lines } = counts.get(kind) ?? { files: 0, lines: 0 }
|
||||
console.log(` ${kind.padEnd(24)} ${String(count).padStart(4)} file(s), ${String(lines)} line(s)`)
|
||||
}
|
||||
|
||||
if (mode !== 'dry') {
|
||||
for (const check of POSTCONDITIONS) {
|
||||
if (reverse) break
|
||||
const path = resolve(root, check.file)
|
||||
const hits = existsSync(path) ? readFileSync(path, 'utf8').split(check.text).length - 1 : -1
|
||||
if (hits !== check.count) {
|
||||
failures.push(`postcondition: ${check.file} has ${String(hits)} occurrence(s) of ${JSON.stringify(check.text)}, expected ${String(check.count)}`)
|
||||
}
|
||||
}
|
||||
// The generic pass above already told us which files would still change,
|
||||
// which in check mode is exactly the residue-and-idempotency signal.
|
||||
if (mode === 'check') {
|
||||
for (const file of outstanding) failures.push(`residue: ${file} still carries a pre-rescope name token`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) console.error(`rescope-vendor: ${failure}`)
|
||||
console.error(`rescope-vendor: ${String(failures.length)} problem(s); the mapping or an upstream site moved.`)
|
||||
process.exitCode = 1
|
||||
} else if (mode === 'check') {
|
||||
console.log('rescope-vendor: post-state verified — no residue, every exact edit landed, idempotent.')
|
||||
} else if (mode === 'apply') {
|
||||
console.log('rescope-vendor: applied. Run `pnpm install`, `pnpm run gen-third-party-notices`, and re-record the touched bilingual pairs.')
|
||||
}
|
||||
}
|
||||
|
||||
// Importing this module for its exported classifier must not run the codemod.
|
||||
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
||||
main()
|
||||
}
|
||||
+10
-13
@@ -83,6 +83,15 @@ describe('gate graph validation', () => {
|
||||
expect(ids).toContain('public-repository-links')
|
||||
})
|
||||
|
||||
it('keeps native Windows coverage blocking while portability inventory remains observational', () => {
|
||||
const gates = withPnpmEntrypoint(() => gatesForMode('ci-windows-complete'))
|
||||
const byId = new Map(gates.map(subject => [subject.id, subject]))
|
||||
|
||||
expect(byId.get('coverage')?.allowFailure).not.toBe(true)
|
||||
expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true)
|
||||
expect(byId.get('duplication')?.allowFailure).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['empty', [], /gate graph has no gates/],
|
||||
['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/],
|
||||
@@ -218,7 +227,7 @@ describe('Node 24 lane ownership', () => {
|
||||
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
|
||||
|
||||
expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
|
||||
workers: 11,
|
||||
workers: 10,
|
||||
source: 'ci-consumers gate count',
|
||||
})
|
||||
expect(subject.map(item => item.id)).toEqual([
|
||||
@@ -232,7 +241,6 @@ describe('Node 24 lane ownership', () => {
|
||||
'doc-typecheck',
|
||||
'node-next-types',
|
||||
'built-bin-smoke',
|
||||
'github-repository-plugin-e2e',
|
||||
])
|
||||
expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
|
||||
expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
|
||||
@@ -243,7 +251,6 @@ describe('Node 24 lane ownership', () => {
|
||||
'doc-typecheck',
|
||||
'node-next-types',
|
||||
'built-bin-smoke',
|
||||
'github-repository-plugin-e2e',
|
||||
]) {
|
||||
expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
|
||||
}
|
||||
@@ -257,16 +264,6 @@ describe('Node 24 lane ownership', () => {
|
||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||
]),
|
||||
)
|
||||
const githubRepositoryPlugin = subject.find(item => item.id === 'github-repository-plugin-e2e')
|
||||
expect(githubRepositoryPlugin).toMatchObject({
|
||||
label: 'GitHub repository Plugin dsh run',
|
||||
env: {
|
||||
DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1',
|
||||
},
|
||||
})
|
||||
expect(githubRepositoryPlugin?.args).toEqual(
|
||||
expect.arrayContaining(['apps/cli/tests/github-repository-plugin.built.e2e.ts']),
|
||||
)
|
||||
expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({
|
||||
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
|
||||
env: { DSH_SNAPSHOT: 'replay' },
|
||||
|
||||
+8
-20
@@ -341,7 +341,7 @@ function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
|
||||
return gates
|
||||
}
|
||||
|
||||
/** Active Node major used to scope version-specific compatibility contracts. */
|
||||
/** Active Node major used to select version-specific compatibility checks. */
|
||||
function runningNodeMajor(): number {
|
||||
const major = Number.parseInt(process.versions.node.split('.')[0] ?? '', 10)
|
||||
if (!Number.isSafeInteger(major)) {
|
||||
@@ -406,7 +406,6 @@ function ciConsumerGates(): Gate[] {
|
||||
needs: validatedBuild,
|
||||
}),
|
||||
builtBinSmokeGate(validatedBuild),
|
||||
githubRepositoryPluginE2eGate(validatedBuild),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -435,6 +434,7 @@ function ciWindowsCompleteGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
|
||||
...coverageGates(),
|
||||
...observational,
|
||||
]
|
||||
}
|
||||
@@ -442,7 +442,7 @@ function ciWindowsCompleteGates(): Gate[] {
|
||||
function ciWindowsObservationalGates(): Gate[] {
|
||||
return [
|
||||
...ciStaticGates({ ownsBuild: true }),
|
||||
// Linux owns required lint, coverage, and snapshots; Windows omits those duplicates.
|
||||
// Linux owns required lint and snapshots; Windows omits those duplicates.
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
@@ -472,7 +472,7 @@ function lintGate(options: { needs?: string[] } = {}): Gate {
|
||||
// The heavy suites run uninstrumented beside the thresholded gate: their
|
||||
// compiler- and subprocess-bound fixtures pay a multiple of their runtime
|
||||
// under v8 instrumentation while contributing nothing the thresholds need
|
||||
// (membership contract in scripts/coverage-exempt.ts).
|
||||
// (membership rules in scripts/coverage-exempt.ts).
|
||||
//
|
||||
// DSH_COVERAGE_MAX_WORKERS is the lane's worker budget, so the two parallel
|
||||
// gates split it instead of each claiming it whole (the failover pool's
|
||||
@@ -517,7 +517,7 @@ function coverageGates(): Gate[] {
|
||||
}
|
||||
|
||||
// Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node,
|
||||
// plugins via real exports); repository-script snapshots execute their real source entry path.
|
||||
// plugins via real exports); script snapshots execute their real source entry path.
|
||||
// Callers wait either on `build` or on a validation gate that transitively owns that build.
|
||||
function snapshotGate(needs: string[] = ['build']): Gate {
|
||||
return pnpmScript('snapshot', 'test:snapshot', {
|
||||
@@ -553,6 +553,7 @@ function flagEnabled(envName: string): boolean {
|
||||
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
|
||||
return [
|
||||
pnpmScript('rescope-vendor', 'rescope-vendor:check', { label: 'vendor rescope' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
@@ -598,6 +599,7 @@ function docSyncLeafGates(options: {
|
||||
pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format' }),
|
||||
pnpmScript('archived-agent-notes', 'verify-archived-agent-notes', { label: 'archived agent notes' }),
|
||||
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
|
||||
pnpmScript('skill-invocation-metadata', 'verify-skill-invocation-metadata', { label: 'skill invocation metadata' }),
|
||||
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
|
||||
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
|
||||
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
|
||||
@@ -620,7 +622,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
||||
'apps/cli/tests/built-bin.e2e.ts',
|
||||
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
||||
'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
|
||||
'packages/scaffold/server/tests/built-scope-carrier.e2e.ts',
|
||||
'packages/sdk/server/tests/built-scope-carrier.e2e.ts',
|
||||
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||
'packages/api/remotes/tests/built-lib.e2e.ts',
|
||||
@@ -637,20 +639,6 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
||||
})
|
||||
}
|
||||
|
||||
function githubRepositoryPluginE2eGate(needs: string[]): Gate {
|
||||
return pnpmExec('github-repository-plugin-e2e', [
|
||||
'vitest',
|
||||
'run',
|
||||
'--config',
|
||||
'vitest.e2e.config.ts',
|
||||
'apps/cli/tests/github-repository-plugin.built.e2e.ts',
|
||||
], {
|
||||
label: 'GitHub repository Plugin dsh run',
|
||||
needs,
|
||||
env: { DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a gate list whose graph cannot be executed unambiguously.
|
||||
* @param gates - complete aggregate to validate.
|
||||
|
||||
+79
-100
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deepseek_harness import TurnResult
|
||||
from deepseek_harness import RunResult
|
||||
|
||||
|
||||
EXPECTED_TEXT = "runtime smoke ok"
|
||||
@@ -25,10 +25,14 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value."
|
||||
CODE_WORKER_TEXT = "code worker smoke ok"
|
||||
WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents."
|
||||
WORKFLOW_WORKER_TEXT = "workflow worker smoke ok"
|
||||
PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor."
|
||||
PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok"
|
||||
PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: "
|
||||
PERSISTENT_BASH_COMMAND = (
|
||||
MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor."
|
||||
MINIMAL_TEXT = "minimal agent smoke ok"
|
||||
MINIMAL_EDITOR_PATH_PREFIX = "Editor path: "
|
||||
MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant."
|
||||
MINIMAL_CORDIS = (
|
||||
Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml"
|
||||
)
|
||||
MINIMAL_BASH_COMMAND = (
|
||||
"counter=$(( ${counter:-0} + 1 )); export counter; "
|
||||
"printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; "
|
||||
"if [ \"$counter\" -eq 1 ]; then cd /tmp; fi"
|
||||
@@ -103,51 +107,6 @@ CUSTOM_CORDIS = """\
|
||||
- id: cordis-tool
|
||||
name: '@deepseek-ai/dsh-tool-cordis'
|
||||
"""
|
||||
PERSISTENT_TOOLS_CORDIS = """\
|
||||
- id: jsonrpc
|
||||
name: '@deepseek-ai/dsh-jsonrpc'
|
||||
- id: llm
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: danger-full-access
|
||||
workspaceRoot: !!js process.env.DSH_CWD
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
- id: fs
|
||||
name: '@deepseek-ai/dsh-fs-local'
|
||||
config:
|
||||
cwd: !!js process.env.DSH_CWD
|
||||
- id: agent-core
|
||||
name: '@deepseek-ai/dsh-agent-spine-demo'
|
||||
config:
|
||||
includeHarnessIdentity: false
|
||||
persona: 'You are a helpful software engineer assistant.'
|
||||
workspaceContext: false
|
||||
skills:
|
||||
enabled: false
|
||||
toolBash: false
|
||||
toolTasks: false
|
||||
- id: sessions
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js process.env.DSH_SESSION_ROOT
|
||||
compression: 'none'
|
||||
- id: persistent-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
- id: str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
"""
|
||||
|
||||
|
||||
class MockModelHandler(BaseHTTPRequestHandler):
|
||||
"""Return deterministic text, worker, and orchestration completions."""
|
||||
|
||||
@@ -182,9 +141,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
if latest.get("role") == "tool":
|
||||
call_id, tool_name = latest_tool_call(messages)
|
||||
tool_text = message_text(latest.get("content"))
|
||||
persistent = persistent_tool_followup(body, call_id, tool_name, tool_text)
|
||||
if persistent is not None:
|
||||
return persistent
|
||||
minimal = minimal_tool_followup(body, call_id, tool_name, tool_text)
|
||||
if minimal is not None:
|
||||
return minimal
|
||||
advanced = advanced_tool_followup(body, call_id, tool_name, tool_text)
|
||||
if advanced is not None:
|
||||
return advanced
|
||||
@@ -196,16 +155,35 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
return text_chunks(WORKFLOW_WORKER_TEXT)
|
||||
raise AssertionError(f"unexpected tool follow-up: {tool_name}")
|
||||
|
||||
prompt = message_text(latest.get("content"))
|
||||
if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"):
|
||||
minimal_prompt = next(
|
||||
(
|
||||
message_text(message.get("content"))
|
||||
for message in reversed(messages)
|
||||
if isinstance(message, dict)
|
||||
and message.get("role") == "user"
|
||||
and message_text(message.get("content")).startswith(
|
||||
f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}"
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if minimal_prompt is not None:
|
||||
names = advertised_tool_names(body)
|
||||
if names != {"bash", "str_replace_editor"}:
|
||||
raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}")
|
||||
raise AssertionError(f"minimal agent smoke advertised unexpected tools: {names}")
|
||||
system_prompts = [
|
||||
message_text(message.get("content"))
|
||||
for message in messages
|
||||
if isinstance(message, dict) and message.get("role") == "system"
|
||||
]
|
||||
if system_prompts != [MINIMAL_SYSTEM_PROMPT]:
|
||||
raise AssertionError(f"minimal agent smoke assembled unexpected system prompts: {system_prompts}")
|
||||
return tool_call_chunks(
|
||||
"persistent-bash-1",
|
||||
"minimal-bash-1",
|
||||
"bash",
|
||||
{"command": PERSISTENT_BASH_COMMAND},
|
||||
{"command": MINIMAL_BASH_COMMAND},
|
||||
)
|
||||
prompt = message_text(latest.get("content"))
|
||||
if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT:
|
||||
return text_chunks("DIRECT_CHILD_OK")
|
||||
if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT:
|
||||
@@ -240,24 +218,24 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
return text_chunks(EXPECTED_TEXT)
|
||||
|
||||
|
||||
def persistent_tool_followup(
|
||||
def minimal_tool_followup(
|
||||
body: dict[str, object],
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
tool_text: str,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""Verify packaged PTY persistence, then invoke the packaged editor."""
|
||||
if not call_id.startswith("persistent-"):
|
||||
"""Verify the checked-in minimal composition's PTY and editor."""
|
||||
if not call_id.startswith("minimal-"):
|
||||
return None
|
||||
if call_id == "persistent-bash-1" and tool_name == "bash":
|
||||
if call_id == "minimal-bash-1" and tool_name == "bash":
|
||||
if "COUNT=1" not in tool_text:
|
||||
raise AssertionError(f"first persistent bash call lost its output: {tool_text}")
|
||||
return tool_call_chunks(
|
||||
"persistent-bash-2",
|
||||
"minimal-bash-2",
|
||||
"bash",
|
||||
{"command": PERSISTENT_BASH_COMMAND},
|
||||
{"command": MINIMAL_BASH_COMMAND},
|
||||
)
|
||||
if call_id == "persistent-bash-2" and tool_name == "bash":
|
||||
if call_id == "minimal-bash-2" and tool_name == "bash":
|
||||
if "COUNT=2 CWD=/tmp" not in tool_text:
|
||||
raise AssertionError(f"persistent bash did not retain state: {tool_text}")
|
||||
messages = body.get("messages")
|
||||
@@ -265,18 +243,18 @@ def persistent_tool_followup(
|
||||
raise AssertionError("persistent editor smoke request has no messages")
|
||||
editor_path = next(
|
||||
(
|
||||
text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip()
|
||||
text.split(MINIMAL_EDITOR_PATH_PREFIX, 1)[1].strip()
|
||||
for message in messages
|
||||
if isinstance(message, dict) and message.get("role") == "user"
|
||||
for text in [message_text(message.get("content"))]
|
||||
if PERSISTENT_EDITOR_PATH_PREFIX in text
|
||||
if MINIMAL_EDITOR_PATH_PREFIX in text
|
||||
),
|
||||
None,
|
||||
)
|
||||
if editor_path is None:
|
||||
raise AssertionError("persistent editor smoke prompt has no editor path")
|
||||
return tool_call_chunks(
|
||||
"persistent-editor",
|
||||
"minimal-editor",
|
||||
"str_replace_editor",
|
||||
{
|
||||
"command": "create",
|
||||
@@ -284,11 +262,11 @@ def persistent_tool_followup(
|
||||
"file_text": "created by packaged editor\n",
|
||||
},
|
||||
)
|
||||
if call_id == "persistent-editor" and tool_name == "str_replace_editor":
|
||||
if call_id == "minimal-editor" and tool_name == "str_replace_editor":
|
||||
if "New file created successfully" not in tool_text:
|
||||
raise AssertionError(f"packaged editor did not create its file: {tool_text}")
|
||||
return text_chunks(PERSISTENT_TOOLS_TEXT)
|
||||
raise AssertionError(f"unexpected persistent-tools follow-up: {call_id} {tool_name}: {tool_text}")
|
||||
return text_chunks(MINIMAL_TEXT)
|
||||
raise AssertionError(f"unexpected minimal-agent follow-up: {call_id} {tool_name}: {tool_text}")
|
||||
|
||||
|
||||
def advanced_tool_followup(
|
||||
@@ -470,14 +448,14 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"),
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"),
|
||||
default="all",
|
||||
)
|
||||
parser.add_argument("--exe", type=Path)
|
||||
parser.add_argument("--update-snapshots", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, persistent, snapshot, and direct scenarios")
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-snapshot or all")
|
||||
if args.exe is not None and not args.exe.is_file():
|
||||
@@ -489,9 +467,9 @@ def main() -> None:
|
||||
if args.scenario in {"all", "sdk-custom"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_custom(model.url, args.exe.resolve())
|
||||
if args.scenario in {"all", "sdk-persistent"}:
|
||||
if args.scenario in {"all", "sdk-minimal"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_persistent_tools(model.url, args.exe.resolve())
|
||||
smoke_sdk_minimal(model.url, args.exe.resolve())
|
||||
if args.scenario in {"all", "sdk-snapshot"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
@@ -519,7 +497,6 @@ def smoke_sdk_default(base_url: str) -> None:
|
||||
request_timeout_seconds=60,
|
||||
) as harness:
|
||||
result = harness.run("reply with the smoke text", session_id="default-smoke")
|
||||
assert result.status == "ok", result
|
||||
assert result.final_response == EXPECTED_TEXT, result.final_response
|
||||
assert_zstd_session_log(sessions)
|
||||
|
||||
@@ -546,46 +523,40 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
|
||||
text_result = harness.run("reply with the smoke text", session_id="custom-smoke")
|
||||
code_result = harness.run(CODE_PROMPT, session_id="custom-smoke")
|
||||
workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke")
|
||||
assert text_result.status == "ok", text_result
|
||||
assert text_result.final_response == EXPECTED_TEXT, text_result.final_response
|
||||
assert code_result.status == "ok", code_result
|
||||
assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response
|
||||
assert workflow_result.status == "ok", workflow_result
|
||||
assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response
|
||||
assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT)
|
||||
|
||||
|
||||
def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None:
|
||||
"""Exercise native PTY state and the editor through the packaged executable."""
|
||||
def smoke_sdk_minimal(base_url: str, executable: Path) -> None:
|
||||
"""Exercise the checked-in minimal composition through the packaged executable."""
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary:
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-minimal-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
editor_path = root / "created.txt"
|
||||
prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}"
|
||||
prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}"
|
||||
sessions = root / "sessions"
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(PERSISTENT_TOOLS_CORDIS)
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek",
|
||||
provider="deepseek-official",
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
cordis=str(cordis),
|
||||
cordis=str(MINIMAL_CORDIS),
|
||||
runtime_bin=str(executable),
|
||||
api_key="sk-keyless-smoke",
|
||||
base_url=base_url,
|
||||
request_timeout_seconds=60,
|
||||
) as harness:
|
||||
result = harness.run(prompt, session_id="persistent-tools-smoke")
|
||||
result = harness.run(prompt, session_id="minimal-agent-smoke")
|
||||
|
||||
assert result.status == "ok", result
|
||||
event_text = json.dumps(result.events)
|
||||
if PERSISTENT_TOOLS_TEXT not in event_text:
|
||||
raise AssertionError(f"packaged tools run emitted no final response: {result.events}")
|
||||
if MINIMAL_TEXT not in event_text:
|
||||
raise AssertionError(f"minimal agent run emitted no final response: {result.events}")
|
||||
if editor_path.read_text() != "created by packaged editor\n":
|
||||
raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}")
|
||||
assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp")
|
||||
assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp")
|
||||
|
||||
|
||||
def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
|
||||
@@ -610,7 +581,6 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
|
||||
) as harness:
|
||||
result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID)
|
||||
|
||||
assert result.status == "ok", result
|
||||
assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response
|
||||
methods = [notification.method for notification in result.notifications]
|
||||
if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2:
|
||||
@@ -657,8 +627,8 @@ def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
"params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]},
|
||||
})
|
||||
messages = peer.read_until(lambda message: message.get("id") == "prompt")
|
||||
if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages):
|
||||
messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished"))
|
||||
if not any(is_idle_notification(message) for message in messages):
|
||||
messages.extend(peer.read_until(is_idle_notification))
|
||||
event_text = json.dumps(messages)
|
||||
if EXPECTED_TEXT not in event_text:
|
||||
raise AssertionError(f"direct runtime emitted no final response: {messages}")
|
||||
@@ -669,6 +639,16 @@ def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
assert_session_log(sessions, root, EXPECTED_TEXT)
|
||||
|
||||
|
||||
def is_idle_notification(message: dict[str, object]) -> bool:
|
||||
"""Return whether a JSON-RPC notification marks a session idle."""
|
||||
params = message.get("params")
|
||||
return (
|
||||
message.get("method") == "session.status"
|
||||
and isinstance(params, dict)
|
||||
and params.get("status") == "idle"
|
||||
)
|
||||
|
||||
|
||||
class RuntimePeer:
|
||||
def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None:
|
||||
self.process = subprocess.Popen(
|
||||
@@ -776,7 +756,7 @@ def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]:
|
||||
return logs
|
||||
|
||||
|
||||
def snapshot_child_ids(result: "TurnResult") -> list[str]:
|
||||
def snapshot_child_ids(result: "RunResult") -> list[str]:
|
||||
"""Return the two child session ids in their SDK notification order."""
|
||||
child_ids: list[str] = []
|
||||
for notification in result.notifications:
|
||||
@@ -794,7 +774,7 @@ def snapshot_child_ids(result: "TurnResult") -> list[str]:
|
||||
|
||||
|
||||
def build_snapshot_files(
|
||||
result: "TurnResult",
|
||||
result: "RunResult",
|
||||
logs: dict[str, list[dict[str, object]]],
|
||||
child_ids: list[str],
|
||||
cwd: Path,
|
||||
@@ -809,7 +789,6 @@ def build_snapshot_files(
|
||||
|
||||
result_value = {
|
||||
"session_id": result.session_id,
|
||||
"status": result.status,
|
||||
"final_response": result.final_response,
|
||||
"events": result.events,
|
||||
"notifications": [
|
||||
@@ -834,7 +813,7 @@ def build_snapshot_files(
|
||||
return files
|
||||
|
||||
|
||||
def snapshot_agent_id(result: "TurnResult", child_id: str) -> str:
|
||||
def snapshot_agent_id(result: "RunResult", child_id: str) -> str:
|
||||
"""Find the successful subagent id paired with one child session."""
|
||||
for notification in result.notifications:
|
||||
if notification.method != "subagent.finished":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,18 @@
|
||||
{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}}
|
||||
{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -1,14 +1,18 @@
|
||||
{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn"}}
|
||||
{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -1,68 +1,71 @@
|
||||
{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}
|
||||
{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"<anonymous>\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}
|
||||
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}
|
||||
{"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}}
|
||||
{"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}}
|
||||
{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
|
||||
{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}
|
||||
{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}}
|
||||
{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}
|
||||
{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}}
|
||||
{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
|
||||
{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
|
||||
{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":5,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[4],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}
|
||||
{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"<anonymous>\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"request/header","seq":18,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}
|
||||
{"type":"tool/code-dispatch-start","seq":26,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}}
|
||||
{"type":"tool/code-dispatch","seq":27,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}}
|
||||
{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[25],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":30,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}
|
||||
{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":36,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[31,32,33,34,35],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":37,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
|
||||
{"type":"tool/result","seq":38,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[37],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":39,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":40,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}
|
||||
{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}}
|
||||
{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":46,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":47,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}
|
||||
{"type":"tool/result","seq":48,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[47],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":49,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"step/start","seq":50,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}}
|
||||
{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":56,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":57,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
|
||||
{"type":"tool/result","seq":58,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[57],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":59,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"step/start","seq":60,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"request/header","seq":61,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
|
||||
{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":67,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[62,63,64,65,66],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":68,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"turn/end","seq":69,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, FiberState, Service, ValidationError } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import z from 'schemastery'
|
||||
import { Context, FiberState, Service, ValidationError } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { packageInvariantOwners } from './package-invariants.ts'
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
usesManualInvariantTree,
|
||||
} from './test-invariants.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
testInvariantProbe: TestInvariantProbe
|
||||
}
|
||||
@@ -39,18 +39,6 @@ function requiredConfig() {
|
||||
})
|
||||
}
|
||||
|
||||
function queuedReadinessConfig(
|
||||
ctx: Context,
|
||||
onPublished: (dispose: () => void) => void,
|
||||
) {
|
||||
return z.transform(z.any(), () => {
|
||||
queueMicrotask(() => {
|
||||
onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true))
|
||||
})
|
||||
return {}
|
||||
}, true)
|
||||
}
|
||||
|
||||
function invalidConfigApply(): never {
|
||||
throw new Error('invalid plugin apply executed')
|
||||
}
|
||||
@@ -137,7 +125,7 @@ describe('global test invariant host', () => {
|
||||
.toEqual(Object.keys(testInvariantCompanions).sort())
|
||||
})
|
||||
|
||||
it('loads and executes every source companion through the real Loader shape', async () => {
|
||||
it('loads and executes every source companion through the real Loader setup', async () => {
|
||||
const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName]))
|
||||
const registrations = new Map<string, string>()
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
@@ -189,84 +177,55 @@ describe('global test invariant host', () => {
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disposes invalid config when readiness refresh wins the rejection-handler race', async () => {
|
||||
it('disposes invalid config after delayed invariant readiness', async () => {
|
||||
await withDelayedFirstCompanion(
|
||||
async ({ started, release }) => {
|
||||
const ctx = new Context()
|
||||
const apply = vi.fn(invalidConfigApply)
|
||||
let disposeQueuedReadiness: (() => void) | undefined
|
||||
const plugin = {
|
||||
apply,
|
||||
Config: z.intersect([
|
||||
queuedReadinessConfig(ctx, (dispose) => {
|
||||
disposeQueuedReadiness = dispose
|
||||
}),
|
||||
requiredConfig(),
|
||||
]),
|
||||
Config: requiredConfig(),
|
||||
}
|
||||
|
||||
const fiber = ctx.plugin(plugin, {})
|
||||
const firstError = await rejectionOf(fiber)
|
||||
expectRequiredConfigValidation(firstError)
|
||||
expect(fiber.state).toBe(FiberState.DISPOSED)
|
||||
const returnedError = rejectionOf(fiber)
|
||||
await started
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
|
||||
await started
|
||||
if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published')
|
||||
disposeQueuedReadiness()
|
||||
release()
|
||||
await ctx.plugin(TestInvariantProbe)
|
||||
|
||||
const secondError = await rejectionOf(fiber)
|
||||
expect(secondError).toBe(firstError)
|
||||
expectRequiredConfigValidation(await returnedError)
|
||||
expect(fiber.state).toBe(FiberState.DISPOSED)
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('retains a valid plugin failure when readiness wins the initial-probe race', async () => {
|
||||
it('retains a valid plugin failure after delayed invariant readiness', async () => {
|
||||
await withDelayedFirstCompanion(
|
||||
async ({ started, release }) => {
|
||||
const ctx = new Context()
|
||||
const failure = new Error('valid plugin apply failed')
|
||||
const applied = deferred()
|
||||
const apply = vi.fn(function validConfigApply() {
|
||||
applied.resolve()
|
||||
throw failure
|
||||
})
|
||||
let disposeQueuedReadiness: (() => void) | undefined
|
||||
const plugin = {
|
||||
apply,
|
||||
Config: queuedReadinessConfig(ctx, (dispose) => {
|
||||
disposeQueuedReadiness = dispose
|
||||
}),
|
||||
Config: z.object({}),
|
||||
}
|
||||
|
||||
const fiber = ctx.plugin(plugin, {})
|
||||
const returnedError = rejectionOf(fiber)
|
||||
try {
|
||||
await Promise.all([started, applied.promise])
|
||||
expect(fiber.state).toBe(FiberState.FAILED)
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(ctx.registry.has(plugin)).toBe(true)
|
||||
expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1)
|
||||
await started
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
|
||||
if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published')
|
||||
Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE)
|
||||
disposeQueuedReadiness()
|
||||
release()
|
||||
|
||||
expect(await returnedError).toBe(failure)
|
||||
expect(fiber.state).toBe(FiberState.FAILED)
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(ctx.registry.has(plugin)).toBe(true)
|
||||
expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1)
|
||||
} finally {
|
||||
Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE)
|
||||
disposeQueuedReadiness?.()
|
||||
release()
|
||||
}
|
||||
release()
|
||||
expect(await returnedError).toBe(failure)
|
||||
expect(fiber.state).toBe(FiberState.FAILED)
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(ctx.registry.has(plugin)).toBe(true)
|
||||
expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
+55
-14
@@ -6,8 +6,15 @@
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest'
|
||||
import { FiberState, Inject, RegistryService } from 'cordis'
|
||||
import type { Context, Plugin } from 'cordis'
|
||||
import { FiberState, Inject, RegistryService, ValidationError } from '@deepseek-ai/cordis'
|
||||
import type { Context, Plugin } from '@deepseek-ai/cordis'
|
||||
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
SaveImageAttachment,
|
||||
StoredImageAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
declare global {
|
||||
@@ -17,7 +24,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
/** Loader-safe shape shared by every package invariant companion. */
|
||||
/** Loader-safe exports shared by every package invariant companion. */
|
||||
export interface TestInvariantCompanion {
|
||||
readonly name: string
|
||||
readonly inject: readonly string[]
|
||||
@@ -102,6 +109,29 @@ export function usesManualInvariantTree(testPath: string): boolean {
|
||||
}
|
||||
|
||||
const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const
|
||||
const ATTACHMENT_COMPANION = '../packages/attachment/attachment-local/src/invariant.ts'
|
||||
|
||||
class TestAttachmentStore extends AttachmentStore {
|
||||
readonly imageLimits: ImageAttachmentLimits = {
|
||||
maxImageBytes: 1,
|
||||
maxImagesPerMessage: 1,
|
||||
maxMessageImageBytes: 1,
|
||||
maxImagePixels: 1,
|
||||
mediaTypes: ['image/png'],
|
||||
}
|
||||
|
||||
validateImage(_input: SaveImageAttachment): Promise<void> {
|
||||
return Promise.reject(new Error('test invariant attachment store does not validate images'))
|
||||
}
|
||||
|
||||
saveImage(_input: SaveImageAttachment): Promise<ImageAttachmentRef> {
|
||||
return Promise.reject(new Error('test invariant attachment store does not save images'))
|
||||
}
|
||||
|
||||
readImage(_ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
|
||||
return Promise.reject(new Error('test invariant attachment store does not read images'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the package companions that an ordinary test root must register.
|
||||
@@ -148,6 +178,9 @@ function startInvariantHost(root: Context): InvariantHost {
|
||||
const testPath = expect.getState().testPath ?? ''
|
||||
const companionPaths = testInvariantCompanionPaths(testPath)
|
||||
const ready = requireActive(serviceFiber, 'invariant service').then(async () => {
|
||||
const attachmentFiber = companionPaths.includes(ATTACHMENT_COMPANION)
|
||||
? mount(TestAttachmentStore)
|
||||
: undefined
|
||||
const companions = await Promise.all(companionPaths.map(async (path) => {
|
||||
const load = testInvariantCompanions[path]
|
||||
if (load === undefined) {
|
||||
@@ -163,7 +196,12 @@ function startInvariantHost(root: Context): InvariantHost {
|
||||
fiber: mount(companion),
|
||||
path,
|
||||
}))
|
||||
await Promise.all(companionFibers.map(({ fiber, path }) => requireActive(fiber, path)))
|
||||
await Promise.all([
|
||||
...(attachmentFiber === undefined
|
||||
? []
|
||||
: [requireActive(attachmentFiber, 'test attachment store')]),
|
||||
...companionFibers.map(({ fiber, path }) => requireActive(fiber, path)),
|
||||
])
|
||||
root.provide(TEST_INVARIANT_READY_SERVICE, true)
|
||||
})
|
||||
const host = { byCallback, barrierOwners, ready }
|
||||
@@ -210,22 +248,25 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi
|
||||
function joinInvariantStartup(
|
||||
fiber: PluginFiber,
|
||||
invariantReady: Promise<void>,
|
||||
disposeInitialFailure = false,
|
||||
disposePendingValidationFailure = false,
|
||||
): PluginFiber {
|
||||
// RegistryService returns a thenable wrapper whose context still points to
|
||||
// the raw Fiber. Calling inherited await() on the wrapper would return and
|
||||
// assimilate that thenable, accidentally following later plugin startup.
|
||||
const rawFiber = fiber.ctx.fiber
|
||||
const initialized = disposeInitialFailure
|
||||
? rawFiber.await().catch(async (error: unknown) => {
|
||||
// Config validation is the only failure recorded while a gated fiber
|
||||
// is initially PENDING. Dispose it even if queued readiness publication
|
||||
// changes its state before this rejection handler runs.
|
||||
await rawFiber.dispose()
|
||||
const readiness = invariantReady.then(async () => {
|
||||
try {
|
||||
return await rawFiber.await()
|
||||
} catch (error) {
|
||||
// Config resolves only after the readiness injection activates. Dispose
|
||||
// validation failures owned by an initially pending target; ordinary
|
||||
// callback failures remain inspectable.
|
||||
if (disposePendingValidationFailure && error instanceof ValidationError) {
|
||||
await rawFiber.dispose()
|
||||
}
|
||||
throw error
|
||||
})
|
||||
: Promise.resolve()
|
||||
const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await())
|
||||
}
|
||||
})
|
||||
const joined = Object.create(fiber) as PluginFiber
|
||||
joined.then = readiness.then.bind(readiness)
|
||||
return joined
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface GitIndexBlob {
|
||||
* @param root - Repository root.
|
||||
* @param path - Repository-relative path.
|
||||
* @returns The stage-zero blob, or `undefined` when the path is absent.
|
||||
* @throws Error when the path is unmerged or has an invalid index shape.
|
||||
* @throws Error when the path is unmerged or its index entries are not a valid merge state.
|
||||
*/
|
||||
export function readGitIndexBlob(root: string, path: string): GitIndexBlob | undefined {
|
||||
const output = runGit(
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url))
|
||||
const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url))
|
||||
const workspaceRoot = fileURLToPath(new URL('../', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx/esm'))
|
||||
const tsxLoader = import.meta.resolve('tsx/esm')
|
||||
const fixtures: string[] = []
|
||||
|
||||
interface Fixture {
|
||||
@@ -231,7 +231,7 @@ function expectMergedPair(fixture: Fixture): void {
|
||||
)
|
||||
}
|
||||
|
||||
describe('translation pairing merge composition', () => {
|
||||
describe('translation pairing merge composition', { timeout: 15_000 }, () => {
|
||||
it('rejects a pairing-record path outside the repository', () => {
|
||||
const fixture = createFixture(false)
|
||||
|
||||
|
||||
@@ -182,6 +182,9 @@ describe('translation pairing records', () => {
|
||||
describe('translation scope discovery', () => {
|
||||
it.each([
|
||||
'README.md',
|
||||
'CONTRIBUTING.md',
|
||||
'CONTRIBUTING.zh.md',
|
||||
'CONTRIBUTING.i18n.yaml',
|
||||
'apps/cli/README.md',
|
||||
'future/subtree/readme.md',
|
||||
'packages/example/README.zh.md',
|
||||
@@ -195,6 +198,7 @@ describe('translation scope discovery', () => {
|
||||
|
||||
it.each([
|
||||
'packages/example/guide.md',
|
||||
'packages/example/CONTRIBUTING.md',
|
||||
'examples/tutorial.md',
|
||||
'website/reference.md',
|
||||
'packages/example/README.txt',
|
||||
|
||||
@@ -80,7 +80,7 @@ const PAIR_META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
|
||||
/**
|
||||
* Parse a `foo.i18n.yaml` consistency record into basename → recorded blob
|
||||
* hash, or undefined when any non-comment line deviates from the exact
|
||||
* `<basename>.md: <40-hex>` shape or repeats a key. Consumers must
|
||||
* `<basename>.md: <40-hex>` format or repeats a key. Consumers must
|
||||
* additionally require exactly the two expected basenames — a renamed key is
|
||||
* a malformed record, never a silently-missing entry.
|
||||
* @param content - Sidecar file text.
|
||||
@@ -118,13 +118,14 @@ export function renderPairMeta(source: string, sourceHash: string, zh: string, z
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Validated shape of `scripts/translation-pairing.manifest.json`. */
|
||||
/** Validated fields of `scripts/translation-pairing.manifest.json`. */
|
||||
export interface TranslationPairingManifest {
|
||||
/** Source documents exempt from pairing because they are generated, instructional, or bilingual by construction. */
|
||||
excluded: string[]
|
||||
}
|
||||
|
||||
const README_ARTIFACT = /(?:^|\/)readme(?:\.md|\.zh\.md|\.i18n\.yaml)$/i
|
||||
const ROOT_CONTRIBUTING_ARTIFACT = /^contributing(?:\.md|\.zh\.md|\.i18n\.yaml)$/i
|
||||
const NON_SOURCE_DIRECTORIES = new Set([
|
||||
'node_modules',
|
||||
'lib',
|
||||
@@ -179,6 +180,7 @@ function isTranslationSourceExcluded(file: string): boolean {
|
||||
export function isTranslationScopeFile(file: string): boolean {
|
||||
return !file.startsWith('.agents/notes/archived/')
|
||||
&& !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file)
|
||||
|| ROOT_CONTRIBUTING_ARTIFACT.test(file)
|
||||
|| file.startsWith('.agents/notes/')
|
||||
|| file.startsWith('docs/')
|
||||
|| file.startsWith('python/'))
|
||||
@@ -281,7 +283,7 @@ export function parseTranslationPairingCliArgs(argv: string[]): TranslationPairi
|
||||
}
|
||||
}
|
||||
|
||||
/** The structural surface compared between the two sides of a pair. */
|
||||
/** The structural signature compared between the two sides of a pair. */
|
||||
export interface TranslationStructureSignature {
|
||||
/** Heading depths in document order (h2 -> 2). */
|
||||
headings: number[]
|
||||
|
||||
@@ -21,7 +21,7 @@ const retainedExamples = [
|
||||
['### Stiff passive voice → Active and natural', 'a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.', '门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。'],
|
||||
['### Invented word → Natural expression', 'A sidecar record of both blob hashes makes consistency checkable', '伴随记录保存两侧 blob hash,使一致性可检查'],
|
||||
['### Em-dash → Colon/period', 'FIXME — an issue that should block a new release.', 'FIXME:应当阻塞新版本发布的问题。'],
|
||||
['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to hear without the source anchoring you', '不对照原文时,更容易察觉别扭的表达'],
|
||||
['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to notice when you read the translation without comparing it with the source', '不对照原文阅读译文时,更容易察觉别扭的表达'],
|
||||
['### Terminology — do not translate what should be kept in English', 'typed service seams, and explicit extension points', '类型化的服务 seam 与显式扩展点'],
|
||||
['### Slang/jargon → Professional phrasing', 'The committed agent workflow lives in .agents/skills/dsh-translate-docs', '仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs'],
|
||||
['### "For humans" — translate the intent, not the word', 'For humans, start with the development guide', '面向开发者:请先阅读开发指南'],
|
||||
@@ -44,7 +44,7 @@ describe('translation prompt rendering', () => {
|
||||
expect(zh).toContain('from Chinese to English')
|
||||
})
|
||||
|
||||
it('retains every v4 embedded example', () => {
|
||||
it('contains every embedded example', () => {
|
||||
for (const example of retainedExamples) {
|
||||
for (const fragment of example) expect(document).toContain(fragment)
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ function unescapeResponseBody(value: string): string {
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
/** Serialize a response in the exact escaped three-section shape the prompt requests. */
|
||||
/** Serialize a response in the exact escaped three-section format the prompt requests. */
|
||||
export function renderTranslationResponse(response: TranslationResponse): string {
|
||||
return RESPONSE_SECTIONS.map(section => `<${section}>\n${escapeResponseBody(response[section])}\n</${section}>`).join('\n\n')
|
||||
}
|
||||
@@ -178,7 +178,7 @@ export function renderTranslationResponse(response: TranslationResponse): string
|
||||
* Parse the three-section response. Sections must each appear exactly once
|
||||
* and in order; escaped delimiter lines in Markdown bodies are restored.
|
||||
* A fenced ```xml wrapper around the whole response is tolerated, matching
|
||||
* the shape some models echo back from the prompt's own example.
|
||||
* the wrapper some models copy from the prompt's own example.
|
||||
*/
|
||||
export function parseTranslationResponse(text: string): TranslationResponse {
|
||||
let body = text.trim()
|
||||
|
||||
@@ -520,6 +520,11 @@
|
||||
"symbol": "SessionLocation",
|
||||
"source": "packages/session/session-persistence/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/persistence.md",
|
||||
"symbol": "SessionRawArtifact",
|
||||
"source": "packages/session/session-persistence/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/session-query.md",
|
||||
"symbol": "SessionEventSurface",
|
||||
@@ -860,6 +865,31 @@
|
||||
"symbol": "ApprovalRequest",
|
||||
"source": "packages/interaction/user-approval/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/attachment.md",
|
||||
"symbol": "ImageMediaType",
|
||||
"source": "packages/attachment/attachment/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/attachment.md",
|
||||
"symbol": "ImageAttachmentRef",
|
||||
"source": "packages/attachment/attachment/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/attachment.md",
|
||||
"symbol": "ImageAttachmentLimits",
|
||||
"source": "packages/attachment/attachment/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/attachment.md",
|
||||
"symbol": "SaveImageAttachment",
|
||||
"source": "packages/attachment/attachment/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/attachment.md",
|
||||
"symbol": "StoredImageAttachment",
|
||||
"source": "packages/attachment/attachment/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/bash.md",
|
||||
"symbol": "BashExecRequest",
|
||||
@@ -1140,6 +1170,11 @@
|
||||
"symbol": "SkillLookupOptions",
|
||||
"source": "packages/skill/skill/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/skills.md",
|
||||
"symbol": "SkillViewOptions",
|
||||
"source": "packages/skill/skill/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/skills.md",
|
||||
"symbol": "SkillProviderObservation",
|
||||
@@ -1215,6 +1250,11 @@
|
||||
"symbol": "SubagentReportDelivery",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/subagent.md",
|
||||
"symbol": "SubagentSettledMessageSource",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/subagent.md",
|
||||
"symbol": "SubagentReportOptions",
|
||||
@@ -1328,7 +1368,7 @@
|
||||
{
|
||||
"doc": "docs/subsystems/workflow.md",
|
||||
"symbol": "WorkflowStartRequest",
|
||||
"source": "packages/workflow/workflow/src/types.ts"
|
||||
"source": "packages/workflow/workflow/src/runtime-types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/workflow.md",
|
||||
@@ -1343,7 +1383,7 @@
|
||||
{
|
||||
"doc": "docs/subsystems/workflow.md",
|
||||
"symbol": "WorkflowRun",
|
||||
"source": "packages/workflow/workflow/src/types.ts"
|
||||
"source": "packages/workflow/workflow/src/runtime-types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/lsp.md",
|
||||
@@ -1670,6 +1710,11 @@
|
||||
"symbol": "WebBootGraph",
|
||||
"source": "packages/client/modules/src/client/manifest.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/telemetry.md",
|
||||
"symbol": "TelemetrySharingStatus",
|
||||
"source": "packages/session/session-telemetry/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/telemetry.md",
|
||||
"symbol": "TelemetrySeverity",
|
||||
@@ -1784,6 +1829,101 @@
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "AgentOptions",
|
||||
"source": "packages/core/agent/src/runtime-types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackVersion",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackRating",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackItem",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackListRequest",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackListValue",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackPutRequest",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackDeleteRequest",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackDeleteValue",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackSessionNotFound",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackTargetNotFound",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackVersionConflict",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackNoteBlank",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackNoteTooLarge",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackFailure",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackSuccess",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackRejected",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackListResult",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackPutResult",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/feedback.md",
|
||||
"symbol": "MessageFeedbackDeleteResult",
|
||||
"source": "packages/feedback/message-feedback/src/types.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Enforce Agent Note lifecycle/class paths and dated filenames. Structural rules
|
||||
* are shared with `agent-note-tree.ts`; the closed classification contract lives
|
||||
* are shared with `agent-note-tree.ts`; the closed classification rules live
|
||||
* in `.agents/notes/README.md`.
|
||||
*/
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { agentNoteRoot, walkAgentNoteTree } from './agent-note-tree.ts'
|
||||
|
||||
/** The date the format contract landed; the grandfather comment is valid only before it. */
|
||||
/** The date these format rules took effect; the grandfather comment is valid only before it. */
|
||||
const FORMAT_ADOPTED = '2026-07-05'
|
||||
|
||||
/** The exact comment a pre-format Agent Note carries in place of `## Alternatives considered`. */
|
||||
|
||||
@@ -99,7 +99,7 @@ if (!writeMode) {
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('verify-archived-agent-notes: archive contract violated:')
|
||||
console.error('verify-archived-agent-notes: archive rules violated:')
|
||||
for (const error of errors) console.error(` ${error}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* across domains.
|
||||
*
|
||||
* Layer model (lower may not import higher):
|
||||
* 0 contract/ shared contract surface (types + slot declarations)
|
||||
* 0 contract/ shared contract API (types + slot declarations)
|
||||
* 1 <domain>/ + service domain implementations (skeleton/, chat/, ...)
|
||||
* 2 apply.ts, index.ts assembly point and re-export shell
|
||||
*
|
||||
@@ -71,7 +71,7 @@ function checkPackage(pkgName: string, clientDir: string): Violation[] {
|
||||
imported: spec,
|
||||
reason: fromDomain === ''
|
||||
? `top-level non-assembly file imports domain "${toDomain}" (only apply/index may assemble)`
|
||||
: `domain "${fromDomain}" imports sibling domain "${toDomain}" (route shared surface through contract/)`,
|
||||
: `domain "${fromDomain}" imports sibling domain "${toDomain}" (route shared API through contract/)`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const SHIPPED_CONFIG_GLOBS = [
|
||||
'python/*/src/**/cordis.yml',
|
||||
]
|
||||
|
||||
/** Ordinary single-line forms this narrow source-shape check rejects; not full YAML analysis. */
|
||||
/** Ordinary single-line configuration forms this source check rejects; not full YAML analysis. */
|
||||
const INLINE_DENY = /^\s*(apiKey|baseURL|apiKeyEnv|authToken|headers)\s*:\s*!!js\b/
|
||||
|
||||
/** Return every forbidden inline environment form in shipped configuration. */
|
||||
|
||||
@@ -79,6 +79,7 @@ for (const file of files) {
|
||||
errors.push(...validateExampleResolution())
|
||||
errors.push(...validateAppResolution())
|
||||
errors.push(...validateSourcePlaneResolution())
|
||||
errors.push(...validatePresetPlaneSeparation())
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:')
|
||||
@@ -88,6 +89,76 @@ if (errors.length > 0) {
|
||||
console.log(`verify-cordis-config: ${files.length} config files passed.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* No shipped agent preset may repeat a row the host composition still runs.
|
||||
*
|
||||
* A preset contributes what ONE session adds to the host's registries. A row
|
||||
* active on both planes is therefore mounted twice — once per process and once
|
||||
* per session — and what that costs depends on what the row does: a provider
|
||||
* behind an `isolate` realm shadows the host's for its own consumers, so a host
|
||||
* contributor to that service reaches nobody; a row that registers into a host
|
||||
* singleton registers once per live session, so the second one collides.
|
||||
*
|
||||
* Both have happened. `bash-env` in a preset realm left `DSH_WEB_URL` reaching
|
||||
* no shell, and `tool-subagent-report` handed every child `report` once per live
|
||||
* session until the second registration threw. Neither changes a tool catalog,
|
||||
* so no catalog assertion can see them — and the shipped presets are near-copies
|
||||
* of each other, so a fix applied to three of four is the normal failure.
|
||||
* @returns one diagnostic per preset row that is also active on the host plane.
|
||||
*/
|
||||
function validatePresetPlaneSeparation(): string[] {
|
||||
const problems: string[] = []
|
||||
// The shipped Web surface is two bundle patch layers over an empty root.
|
||||
const hostFile = 'packages/bundle/base/cordis.patch.yml'
|
||||
const overlayFile = 'packages/bundle/web-app/cordis.patch.yml'
|
||||
const hostRows = rowIds(hostFile)
|
||||
const overlay = loadEntries(overlayFile)
|
||||
const disabled = new Set<string>()
|
||||
for (const entry of overlay) {
|
||||
if (!isRecord(entry)) continue
|
||||
if (entry.disabled === true && typeof entry.id === 'string') disabled.add(entry.id)
|
||||
}
|
||||
// The overlay's own inserts are host-plane too; its disables take them back out.
|
||||
const active = new Set([...hostRows, ...rowIds(overlayFile)].filter(id => !disabled.has(id)))
|
||||
for (const file of globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root })) {
|
||||
for (const id of rowIds(file)) {
|
||||
if (!active.has(id)) continue
|
||||
problems.push(
|
||||
`${file}: row "${id}" is also active in the host composition; `
|
||||
+ 'a row belongs to exactly one plane',
|
||||
)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
/** Every entry of one config file, or an empty list when it is not an entry array. */
|
||||
function loadEntries(file: string): unknown[] {
|
||||
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
|
||||
return isUnknownArray(document) ? document : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Row ids declared anywhere in one config file, including inside group `config`
|
||||
* lists — a preset nests most of its rows in `isolate` groups.
|
||||
* @param file - repository-relative config path.
|
||||
* @returns the declared ids.
|
||||
*/
|
||||
function rowIds(file: string): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
const walk = (value: unknown): void => {
|
||||
if (isUnknownArray(value)) {
|
||||
for (const item of value) walk(item)
|
||||
return
|
||||
}
|
||||
if (!isRecord(value)) return
|
||||
if (typeof value.id === 'string' && typeof value.name === 'string') ids.add(value.id)
|
||||
for (const child of Object.values(value)) walk(child)
|
||||
}
|
||||
walk(loadEntries(file))
|
||||
return ids
|
||||
}
|
||||
|
||||
function validateEntry(value: unknown, file: string, path: string): void {
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${file}${path}: entry must be an object`)
|
||||
@@ -95,7 +166,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
||||
}
|
||||
recordPlugin(value, file)
|
||||
validateMetadata(value, file, path)
|
||||
if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
|
||||
if ((value.group === true || value.name === '@deepseek-ai/cordis-plugin-group') && isUnknownArray(value.config)) {
|
||||
for (let index = 0; index < value.config.length; index++) {
|
||||
validateEntry(value.config[index], file, `${path}.config[${index}]`)
|
||||
}
|
||||
@@ -105,7 +176,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
||||
validateEntry(value.insert[index], file, `${path}.insert[${index}]`)
|
||||
}
|
||||
}
|
||||
if (value.name !== '@cordisjs/plugin-include') return
|
||||
if (value.name !== '@deepseek-ai/cordis-plugin-include') return
|
||||
const config = value.config
|
||||
if (!isRecord(config) || !isUnknownArray(config.patches)) return
|
||||
for (let index = 0; index < config.patches.length; index++) {
|
||||
|
||||
@@ -58,7 +58,7 @@ function thisReceiver(p: ts.ParameterDeclaration): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Peel wrapper expressions that carry no surface of their own — parentheses,
|
||||
* Peel wrapper expressions that define no API of their own — parentheses,
|
||||
* `as` / `satisfies` / angle-bracket casts, non-null assertions — so a
|
||||
* wrapped function expression is still classified as function-like.
|
||||
* @param e - the expression to unwrap.
|
||||
@@ -75,9 +75,9 @@ function unwrapExpression(e: ts.Expression): ts.Expression {
|
||||
|
||||
/**
|
||||
* Classify inline callable annotations. Mixed callable literals fail closed;
|
||||
* other annotations are ordinary value shapes.
|
||||
* other annotations are ordinary value types.
|
||||
* @param type - the declarator's type annotation.
|
||||
* @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape.
|
||||
* @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable type.
|
||||
*/
|
||||
function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'refuse' | null {
|
||||
if (ts.isFunctionTypeNode(type)) return type
|
||||
@@ -91,7 +91,7 @@ function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'r
|
||||
}
|
||||
|
||||
/**
|
||||
* Find inherited documentation for a class member without exempting newly public surface.
|
||||
* Find inherited documentation for a class member without exempting a newly public API.
|
||||
* @param cls - the class whose heritage to search.
|
||||
* @param name - the member name to look up.
|
||||
* @param staticSide - whether to search the constructor side instead of the instance side.
|
||||
@@ -112,7 +112,7 @@ function heritageExemption(
|
||||
const prop = type.getProperty(name)
|
||||
if (prop === undefined) continue
|
||||
const decls = prop.declarations ?? []
|
||||
if (decls.length > 0 && decls.every(isProtected)) continue // public override of a protected base: new surface
|
||||
if (decls.length > 0 && decls.every(isProtected)) continue // public override of a protected base: new API
|
||||
let baseParams: Set<string> | null = null
|
||||
let baseVoidReturn: boolean | null = null
|
||||
for (const d of decls) {
|
||||
@@ -192,7 +192,7 @@ function checkFunctionLike(
|
||||
if (!raw) { w.violations.push(`${where} has no JSDoc.`); return }
|
||||
if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`)
|
||||
const { params, returns } = parseTags(raw)
|
||||
checkParams(where, 'export', parameters, params, w.sf, thisReceiver, w.violations)
|
||||
checkParams(where, 'exported', parameters, params, w.sf, thisReceiver, w.violations)
|
||||
if (!returnsWaived) checkReturns(where, returnType, returns, w.sf, w.violations)
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ function checkFunctionLike(
|
||||
* statics are exempt; constructors are not checked (framework-constructed
|
||||
* plugins, and the class doc owns the story).
|
||||
* @param cls - the exported class declaration.
|
||||
* @param name - the class's surface name (namespace-qualified).
|
||||
* @param name - the class's exported name (namespace-qualified).
|
||||
* @param w - the walk state violations append to.
|
||||
*/
|
||||
function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
|
||||
@@ -230,12 +230,12 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
|
||||
const raw = rawJsDoc(w.text, m)
|
||||
// The heritage declaration owns the prose; parameters the base never
|
||||
// names — including binding patterns, which no base declaration can
|
||||
// name — are new surface and keep their @param duty.
|
||||
// name — are new API and keep their @param duty.
|
||||
const base = exemption.baseParams
|
||||
const inBase = (p: ts.ParameterDeclaration): boolean =>
|
||||
base !== null && ts.isIdentifier(p.name) && base.has(p.name.text.replace(/^_+/, ''))
|
||||
if (base !== null && m.parameters.some(p => !thisReceiver(p) && !inBase(p))) {
|
||||
checkParams(where, 'export', m.parameters, parseTags(raw).params, w.sf,
|
||||
checkParams(where, 'exported', m.parameters, parseTags(raw).params, w.sf,
|
||||
p => thisReceiver(p) || inBase(p), w.violations)
|
||||
}
|
||||
// A void base return carried no @returns duty, so an override growing a concrete result
|
||||
@@ -258,7 +258,7 @@ function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
|
||||
} else if (ts.isSetAccessorDeclaration(m) && !documentedGetters.has(mname)) {
|
||||
checkDescribed(`exported class accessor '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w)
|
||||
}
|
||||
// index signatures / static blocks: not named surface
|
||||
// index signatures / static blocks: no named API
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ function checkDecl(
|
||||
const raw = rawJsDoc(w.text, stmt) // JSDoc sits on the statement, not the declarator
|
||||
for (const d of stmt.declarationList.declarations) {
|
||||
const name = ts.isIdentifier(d.name) ? d.name.text : d.name.getText(w.sf)
|
||||
if (only !== null && !only.has(name)) continue // sibling declarator the export list never named: not surface
|
||||
if (only !== null && !only.has(name)) continue // sibling declarator the export list never named: not exported API
|
||||
if (prefix === '' && PROTOCOL_EXPORTS.has(name)) continue // cordis plugin-protocol slot
|
||||
const where = `exported const '${prefix}${name}'${at(d)}`
|
||||
const annotation = d.type !== undefined ? callableAnnotation(d.type) : null
|
||||
@@ -321,7 +321,7 @@ function checkDecl(
|
||||
// tags against — fail closed rather than silently narrow the check.
|
||||
w.violations.push(`${where}: its callable type literal is not gate-classifiable; extract a named type and document it there.`)
|
||||
} else if (annotation !== null) {
|
||||
// An INLINE callable annotation is the surface signature itself: its
|
||||
// An INLINE callable annotation is the exported signature itself: its
|
||||
// parameters and result need docs right here. (A NAMED reference
|
||||
// type carries its docs at the type's own declaration instead.)
|
||||
checkFunctionLike(where, raw, annotation.parameters, annotation.type, false, w)
|
||||
@@ -350,7 +350,7 @@ function checkDecl(
|
||||
}
|
||||
// In an ambient (`declare`) namespace body, members are implicitly
|
||||
// exported — no `export` modifier required — so the recursion must treat
|
||||
// every statement as surface.
|
||||
// every statement as exported API.
|
||||
const declared = ambient
|
||||
|| ((ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)?.some(m => m.kind === ts.SyntaxKind.DeclareKeyword) ?? false)
|
||||
if (body !== undefined && ts.isModuleBlock(body)) checkScope(body.statements, nsPrefix, w, declared)
|
||||
@@ -376,7 +376,7 @@ function checkDecl(
|
||||
}
|
||||
// Fail CLOSED: an exported statement kind this dispatch does not recognize
|
||||
// must never pass silently — the gate's whole promise is that unchecked
|
||||
// surface cannot exist. New TypeScript export forms extend the gate here.
|
||||
// unchecked API cannot exist. New TypeScript export forms extend the gate here.
|
||||
w.violations.push(`exported statement${at(stmt)} uses an export form verify-export-jsdoc does not handle; extend the gate.`)
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@ function checkDecl(
|
||||
* exported declaration, resolving `export { … }` lists (no module specifier)
|
||||
* to their local declarations.
|
||||
* @param statements - the scope's statements.
|
||||
* @param prefix - the namespace qualification for surface names ('' at top level).
|
||||
* @param prefix - the namespace qualification for exported names ('' at top level).
|
||||
* @param w - the walk state violations append to.
|
||||
* @param ambient - whether this scope is ambient (`declare` namespace or a declaration file), where members export implicitly.
|
||||
*/
|
||||
@@ -445,8 +445,8 @@ function checkScope(
|
||||
}
|
||||
if (ts.isExportAssignment(stmt)) {
|
||||
if (stmt.isExportEquals) {
|
||||
// `export =` has no ESM consumer surface in this repo and the walk
|
||||
// cannot classify its operand's shape; refuse rather than fail open.
|
||||
// `export =` has no ESM consumer API in this repo and the walk
|
||||
// cannot classify its operand's type; refuse rather than fail open.
|
||||
w.violations.push(`export-equals assignment (${pointer(w.rel, w.sf, stmt)}) is not a gate-supported export form; use ESM named exports.`)
|
||||
continue
|
||||
}
|
||||
@@ -599,11 +599,11 @@ export function collectExportJsdocViolations(scanRoot: string = root): string[]
|
||||
return violations
|
||||
}
|
||||
|
||||
/** CLI entry: list every violation and exit 1, or confirm a clean surface. */
|
||||
/** CLI entry: list every violation and exit 1, or confirm a documented API. */
|
||||
function main(): void {
|
||||
const violations = collectExportJsdocViolations()
|
||||
if (violations.length === 0) {
|
||||
console.log('verify-export-jsdoc: every exported name on the package surface is documented.')
|
||||
console.log('verify-export-jsdoc: every exported name in each package API is documented.')
|
||||
return
|
||||
}
|
||||
console.error(`verify-export-jsdoc: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):`)
|
||||
|
||||
@@ -27,7 +27,6 @@ const PATTERNS = [
|
||||
'AGENTS.md',
|
||||
'packages/AGENTS.md',
|
||||
'.agents/skills/**/*.md',
|
||||
'skills/**/*.md',
|
||||
]
|
||||
|
||||
/** A broken relative link: a missing target path or a missing anchor on it. */
|
||||
|
||||
@@ -26,7 +26,6 @@ const PATTERNS = [
|
||||
'AGENTS.md',
|
||||
'packages/AGENTS.md',
|
||||
'.agents/skills/**/*.md',
|
||||
'skills/**/*.md',
|
||||
]
|
||||
|
||||
interface Block {
|
||||
|
||||
@@ -151,7 +151,7 @@ try {
|
||||
cwd: root,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
console.log(`verify-node-next-types: ${packages.length} workspace package declaration surface(s) compile under NodeNext.`)
|
||||
console.log(`verify-node-next-types: ${packages.length} workspace package declaration API(s) compile under NodeNext.`)
|
||||
} catch (error: unknown) {
|
||||
failed = true
|
||||
const output = error as { stdout?: Buffer; stderr?: Buffer }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Verify package-owned invariant source and publication contracts. */
|
||||
/** Verify package-owned invariant source and publication rules. */
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
import {
|
||||
|
||||
@@ -68,7 +68,7 @@ function isDriftedPackageReference(ref: string): boolean {
|
||||
// A missing reference is drift only when a path segment names a live package.
|
||||
// A leading segment that is itself an existing group directory is explained by
|
||||
// the group, not by a relocated leaf sharing its name (`client` is both the
|
||||
// client-modules group and the scaffold leaf), so only later segments count.
|
||||
// client-modules group and the sdk leaf), so only later segments count.
|
||||
const segments = ref.split('/').slice(1)
|
||||
const [group] = segments
|
||||
const scanned = group !== undefined && segments.length > 1 && existsSync(resolve(root, 'packages', group))
|
||||
|
||||
@@ -38,63 +38,70 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
|
||||
|
||||
/**
|
||||
* Packages whose Model Experience is simple enough for one gated sentence plus
|
||||
* a KV-cache field. Every other package must carry canonical context-surface
|
||||
* a KV-cache field. Every other package must carry canonical model-context
|
||||
* blocks. A package moves on or off this list with its context behavior.
|
||||
*/
|
||||
const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' },
|
||||
'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' },
|
||||
'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
|
||||
'packages/bash/bash-env': { kind: 'indirect', reason: 'The env service surfaces managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' },
|
||||
'packages/bash/bash-env': { kind: 'indirect', reason: 'The env service exposes managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' },
|
||||
'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
|
||||
'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' },
|
||||
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/core/agent-tool-mode': { kind: 'indirect', reason: 'The row only selects between the two projections dsh-tools owns; it registers no prompt, schema, or result of its own.' },
|
||||
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
|
||||
'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' },
|
||||
'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
|
||||
'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' },
|
||||
'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
|
||||
'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' },
|
||||
'packages/e2b/e2b': { kind: 'none', reason: 'The shared remote-runtime owner registers no model context; provider adapters and consumers own rendered effects.' },
|
||||
'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers no model surface.' },
|
||||
'packages/client/test-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers no model surface.' },
|
||||
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers no model surface.' },
|
||||
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers nothing model-facing.' },
|
||||
'packages/client/test-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers nothing model-facing.' },
|
||||
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers nothing model-facing.' },
|
||||
'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and identity policy; selected services own any model-visible effect.' },
|
||||
'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/runtime': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' },
|
||||
'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-task': { kind: 'none', reason: 'Browser-side read-only projection of ctx.tasks records; dsh-tool-tasks owns the model-facing behavior.' },
|
||||
'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' },
|
||||
'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' },
|
||||
'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' },
|
||||
'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' },
|
||||
'packages/client/ui-permission': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' },
|
||||
'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' },
|
||||
'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' },
|
||||
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
|
||||
'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
|
||||
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
|
||||
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' },
|
||||
'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers no model surface.' },
|
||||
'packages/host/directory-picker-auto': { kind: 'none', reason: 'The GUI-host picking chooser only mounts a backend row; registers no model surface.' },
|
||||
'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' },
|
||||
'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' },
|
||||
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
|
||||
'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers no model surface.' },
|
||||
'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model surface.' },
|
||||
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register nothing model-facing.' },
|
||||
'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers nothing model-facing.' },
|
||||
'packages/host/directory-picker-auto': { kind: 'none', reason: 'The GUI-host picking chooser only mounts a backend row; it registers nothing model-facing.' },
|
||||
'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' },
|
||||
'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' },
|
||||
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers nothing model-facing.' },
|
||||
'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers nothing model-facing.' },
|
||||
'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' },
|
||||
'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' },
|
||||
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
|
||||
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
|
||||
@@ -104,29 +111,26 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/e2b/subprocess-e2b': { kind: 'indirect', reason: 'The remote spawn backend delegates model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
|
||||
'packages/scaffold/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
|
||||
'packages/scaffold/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
|
||||
'packages/scaffold/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
|
||||
'packages/scaffold/client': { kind: 'none', reason: 'Client-process library; the model surface lives in the spawned runtime\'s composed plugins.' },
|
||||
'packages/scaffold/protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' },
|
||||
'packages/scaffold/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' },
|
||||
'packages/session/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' },
|
||||
'packages/session/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' },
|
||||
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' },
|
||||
'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model surface a value feeds.' },
|
||||
'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model surface.' },
|
||||
'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model surface a value authorizes.' },
|
||||
'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model surface.' },
|
||||
'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers no model surface.' },
|
||||
'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
|
||||
'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
|
||||
'packages/sandbox/sandbox-windows-acl': { kind: 'indirect', reason: 'The provider backend delegates model rendering to the bash/pwsh sandbox executors and their tools.' },
|
||||
'packages/sdk/client': { kind: 'none', reason: 'Client-process library; model-facing behavior lives in the spawned runtime\'s composed plugins.' },
|
||||
'packages/sdk/protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own model-facing behavior.' },
|
||||
'packages/session/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers nothing model-facing.' },
|
||||
'packages/session/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers nothing model-facing.' },
|
||||
'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers nothing model-facing.' },
|
||||
'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers nothing model-facing.' },
|
||||
'packages/settings/settings': { kind: 'indirect', reason: 'The seam stores and resolves user settings; consumer plugins own any model-facing content fed by a value.' },
|
||||
'packages/settings/settings-local': { kind: 'indirect', reason: 'The file provider stores and publishes namespace sections; consumers of ctx.settings own any model-facing behavior.' },
|
||||
'packages/credentials/credentials': { kind: 'indirect', reason: 'The seam resolves credential references; the consuming adapter owns every model-facing use a value authorizes.' },
|
||||
'packages/credentials/credentials-local': { kind: 'indirect', reason: 'The file/environment provider stores credential values; consumers of ctx.credentials own any model-facing behavior.' },
|
||||
'packages/util/atomic-write': { kind: 'none', reason: 'Pure filesystem write primitive; registers nothing model-facing.' },
|
||||
'packages/session/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers nothing model-facing.' },
|
||||
'packages/session/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers nothing model-facing.' },
|
||||
'packages/session/user-id': { kind: 'none', reason: 'The shared identifier appears only in telemetry metadata and a direct human command response; it registers nothing model-facing.' },
|
||||
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/skill/skill-badge': { kind: 'indirect', reason: 'The bundled provider delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
|
||||
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
|
||||
'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' },
|
||||
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
|
||||
'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' },
|
||||
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
|
||||
@@ -134,18 +138,19 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' },
|
||||
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
|
||||
'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' },
|
||||
'packages/typert/type-meta': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers no model surface.' },
|
||||
'packages/typert/type-meta': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers nothing model-facing.' },
|
||||
'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' },
|
||||
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' },
|
||||
'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and controller plugins own all model rendering over the task registry.' },
|
||||
'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' },
|
||||
'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' },
|
||||
'packages/boot/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' },
|
||||
'packages/boot/cmdline': { kind: 'none', reason: 'Resolves the process command line before any session exists; configured rows own every model-visible consequence.' },
|
||||
'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' },
|
||||
'packages/interaction/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
|
||||
'packages/interaction/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
|
||||
'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
|
||||
'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' },
|
||||
'packages/util/native-command': { kind: 'none', reason: 'The host-side subprocess runner registers no model surface.' },
|
||||
'packages/util/native-command': { kind: 'none', reason: 'The host-side subprocess runner registers nothing model-facing.' },
|
||||
'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
|
||||
'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
|
||||
'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
|
||||
@@ -159,7 +164,7 @@ interface Failure {
|
||||
|
||||
type Line = MarkdownProseLine
|
||||
|
||||
interface ContextSurface {
|
||||
interface ModelExperienceEntry {
|
||||
heading: Line
|
||||
modelView: Line
|
||||
tokenEffect: Line
|
||||
@@ -191,7 +196,7 @@ function validateNestedVerbatim(raw: readonly string[], fragments: Set<string>):
|
||||
const fragment = headingFragment(title)
|
||||
if (fragment.length === 0) return { blocks, error: 'verbatim H5 title must be non-empty' }
|
||||
if (fragments.has(fragment)) {
|
||||
return { blocks, error: `verbatim H5 title ${JSON.stringify(title)} is duplicated within its context surface` }
|
||||
return { blocks, error: `verbatim H5 title ${JSON.stringify(title)} is duplicated within its model-context entry` }
|
||||
}
|
||||
fragments.add(fragment)
|
||||
cursor += 1
|
||||
@@ -210,13 +215,13 @@ function validateNestedVerbatim(raw: readonly string[], fragments: Set<string>):
|
||||
return { blocks }
|
||||
}
|
||||
|
||||
/** GitHub-style fragment for the simple ASCII nested titles allowed by this contract. */
|
||||
/** GitHub-style fragment for the simple ASCII nested titles allowed by these rules. */
|
||||
function headingFragment(title: string): string {
|
||||
return title.toLowerCase().replaceAll('`', '').replaceAll(/[^a-z0-9 _-]/g, '').trim().replaceAll(/\s+/g, '-')
|
||||
}
|
||||
|
||||
/** A direct stable system-prompt contribution, as named by the README contract. */
|
||||
function isDirectSystemPromptSurface(title: string): boolean {
|
||||
/** A direct stable system-prompt contribution, as named by the README rules. */
|
||||
function isDirectSystemPromptEntry(title: string): boolean {
|
||||
return /\bsystem prompt\b/i.test(title)
|
||||
}
|
||||
|
||||
@@ -236,13 +241,13 @@ const failures: Failure[] = []
|
||||
const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort()
|
||||
const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length)))
|
||||
let structuredCount = 0
|
||||
let contextSurfaceCount = 0
|
||||
let modelContextEntryCount = 0
|
||||
let omittedSectionCount = 0
|
||||
let explainedNoneCount = 0
|
||||
let indirectCount = 0
|
||||
let verbatimBlockCount = 0
|
||||
let systemPromptSurfaceCount = 0
|
||||
let toolSchemaSurfaceCount = 0
|
||||
let systemPromptEntryCount = 0
|
||||
let toolSchemaEntryCount = 0
|
||||
let kvCacheEffectCount = 0
|
||||
|
||||
for (const [pkg, reason] of Object.entries(NO_MODEL_EXPERIENCE_SECTION)) {
|
||||
@@ -262,7 +267,7 @@ for (const [pkg, contract] of Object.entries(SENTENCE_MODEL_EXPERIENCE)) {
|
||||
failures.push({ path: `${pkg}/README.md`, message: 'sentence allowlist entry does not name a scanned package' })
|
||||
}
|
||||
if (contract.reason.trim().length === 0) {
|
||||
failures.push({ path: `${pkg}/README.md`, message: 'sentence allowlist entry must justify why structured context surfaces are unnecessary' })
|
||||
failures.push({ path: `${pkg}/README.md`, message: 'sentence allowlist entry must justify why structured model-context entries are unnecessary' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,47 +376,47 @@ for (const packageJson of packageJsons) {
|
||||
continue
|
||||
}
|
||||
|
||||
const surfaceStarts = content
|
||||
const entryStarts = content
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter(entry => /^### \S/.test(entry.line.raw))
|
||||
if (surfaceStarts.length === 0 || surfaceStarts[0]?.index !== 0) {
|
||||
failures.push({ path: readme, message: 'must contain one or more complete context-surface blocks' })
|
||||
if (entryStarts.length === 0 || entryStarts[0]?.index !== 0) {
|
||||
failures.push({ path: readme, message: 'must contain one or more complete model-context entries' })
|
||||
continue
|
||||
}
|
||||
|
||||
const surfaces: ContextSurface[] = []
|
||||
const surfaceFragments = new Set<string>()
|
||||
let surfaceError = false
|
||||
for (let surfaceIndex = 0; surfaceIndex < surfaceStarts.length; surfaceIndex += 1) {
|
||||
const start = surfaceStarts[surfaceIndex] as { line: Line; index: number }
|
||||
const end = surfaceStarts[surfaceIndex + 1]?.index ?? content.length
|
||||
const modelContextEntries: ModelExperienceEntry[] = []
|
||||
const entryFragments = new Set<string>()
|
||||
let entryError = false
|
||||
for (let entryIndex = 0; entryIndex < entryStarts.length; entryIndex += 1) {
|
||||
const start = entryStarts[entryIndex] as { line: Line; index: number }
|
||||
const end = entryStarts[entryIndex + 1]?.index ?? content.length
|
||||
const entries = content.slice(start.index, end)
|
||||
const heading = entries[0] as Line
|
||||
const title = heading.raw.slice('### '.length)
|
||||
const fragment = headingFragment(title)
|
||||
if (fragment.length === 0) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: each context surface requires a non-empty H3 heading` })
|
||||
surfaceError = true
|
||||
failures.push({ path: readme, message: `line ${heading.index}: each model-context entry requires a non-empty H3 heading` })
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
if (surfaceFragments.has(fragment)) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: duplicate context-surface link fragment ${JSON.stringify(fragment)}` })
|
||||
surfaceError = true
|
||||
if (entryFragments.has(fragment)) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: duplicate model-context entry link fragment ${JSON.stringify(fragment)}` })
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
const fieldStarts = entries
|
||||
.map((line, index) => ({ line, index }))
|
||||
.filter(entry => /^#### \S/.test(entry.line.raw))
|
||||
if (fieldStarts.length !== FIELD_HEADINGS.length || fieldStarts[0]?.index !== 1) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: context surface requires exactly three ordered H4 fields: ${FIELD_HEADINGS.join(', ')}` })
|
||||
surfaceError = true
|
||||
failures.push({ path: readme, message: `line ${heading.index}: model-context entry requires exactly three ordered H4 fields: ${FIELD_HEADINGS.join(', ')}` })
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
if ((surfaceIndex === 0 && heading.index !== modelHeading.index + 2)
|
||||
if ((entryIndex === 0 && heading.index !== modelHeading.index + 2)
|
||||
|| rawLines[heading.index - 2]?.trim().length !== 0
|
||||
|| fieldStarts[0].line.index !== heading.index + 2) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: context-surface heading and first field require one blank line between them` })
|
||||
surfaceError = true
|
||||
failures.push({ path: readme, message: `line ${heading.index}: model-context entry heading and first field require one blank line between them` })
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
const parsedFields: ParsedField[] = []
|
||||
@@ -421,7 +426,7 @@ for (const packageJson of packageJsons) {
|
||||
const expectedHeading = FIELD_HEADINGS[fieldIndex] as string
|
||||
if (fieldStart.line.raw !== expectedHeading) {
|
||||
failures.push({ path: readme, message: `line ${fieldStart.line.index}: expected exact field heading ${JSON.stringify(expectedHeading)}, found ${JSON.stringify(fieldStart.line.raw)}` })
|
||||
surfaceError = true
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
const fieldEnd = fieldStarts[fieldIndex + 1]?.index ?? entries.length
|
||||
@@ -429,42 +434,42 @@ for (const packageJson of packageJsons) {
|
||||
const value = fieldEntries[1]
|
||||
if (value === undefined || /^#{1,6} /.test(value.raw) || value.raw.trim().length === 0) {
|
||||
failures.push({ path: readme, message: `line ${fieldStart.line.index}: ${expectedHeading} requires one non-empty paragraph` })
|
||||
surfaceError = true
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
if (value.index !== fieldStart.line.index + 2) {
|
||||
failures.push({ path: readme, message: `line ${fieldStart.line.index}: ${expectedHeading} and its paragraph require one blank line between them` })
|
||||
surfaceError = true
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
const unexpected = fieldEntries.slice(2).find(line => !/^##### \S/.test(line.raw))
|
||||
if (unexpected !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${unexpected.index}: content after ${expectedHeading} paragraph must be a titled H5 plus \`markdown\` fence owned by that field` })
|
||||
surfaceError = true
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
const nextHeadingLine = fieldStarts[fieldIndex + 1]?.line.index
|
||||
?? surfaceStarts[surfaceIndex + 1]?.line.index
|
||||
?? entryStarts[entryIndex + 1]?.line.index
|
||||
?? nextH2Line
|
||||
if (rawLines[nextHeadingLine - 2]?.trim().length !== 0) {
|
||||
failures.push({ path: readme, message: `line ${nextHeadingLine}: Model Experience headings require a preceding blank line` })
|
||||
surfaceError = true
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
const verbatim = validateNestedVerbatim(rawLines.slice(value.index, nextHeadingLine - 1), verbatimFragments)
|
||||
if (verbatim.error !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${value.index}: ${verbatim.error}` })
|
||||
surfaceError = true
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
if (fieldEntries.length - 2 !== verbatim.blocks) {
|
||||
failures.push({ path: readme, message: `line ${value.index}: every nested H5 must own exactly one \`markdown\` fence` })
|
||||
surfaceError = true
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
parsedFields.push({ value, verbatimBlocks: verbatim.blocks })
|
||||
}
|
||||
if (surfaceError) break
|
||||
if (entryError) break
|
||||
const modelViewField = parsedFields[0] as ParsedField
|
||||
const tokenEffectField = parsedFields[1] as ParsedField
|
||||
const kvCacheEffectField = parsedFields[2] as ParsedField
|
||||
@@ -473,11 +478,11 @@ for (const packageJson of packageJsons) {
|
||||
const kvCacheEffect = kvCacheEffectField.value
|
||||
if (/\]\(#[^)]+\)/.test(modelView.raw) || /\]\(#[^)]+\)/.test(tokenEffect.raw) || /\]\(#[^)]+\)/.test(kvCacheEffect.raw)) {
|
||||
failures.push({ path: readme, message: `line ${heading.index}: Model Experience fields must not link between local subsections; nest the H5 in its owning H4 field` })
|
||||
surfaceError = true
|
||||
entryError = true
|
||||
break
|
||||
}
|
||||
surfaceFragments.add(fragment)
|
||||
surfaces.push({
|
||||
entryFragments.add(fragment)
|
||||
modelContextEntries.push({
|
||||
heading,
|
||||
modelView,
|
||||
tokenEffect,
|
||||
@@ -487,49 +492,49 @@ for (const packageJson of packageJsons) {
|
||||
verbatimBlocks: parsedFields.reduce((total, field) => total + field.verbatimBlocks, 0),
|
||||
})
|
||||
}
|
||||
if (surfaceError) continue
|
||||
if (entryError) continue
|
||||
|
||||
const promptWithoutVerbatim = surfaces.find(surface => isDirectSystemPromptSurface(surface.title)
|
||||
&& surface.modelViewVerbatimBlocks === 0)
|
||||
const promptWithoutVerbatim = modelContextEntries.find(entry => isDirectSystemPromptEntry(entry.title)
|
||||
&& entry.modelViewVerbatimBlocks === 0)
|
||||
if (promptWithoutVerbatim !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${promptWithoutVerbatim.heading.index}: system-prompt surface must contain a titled H5 plus verbatim \`markdown\` block under ${MODEL_VIEW_HEADING}` })
|
||||
failures.push({ path: readme, message: `line ${promptWithoutVerbatim.heading.index}: system-prompt entry must contain a titled H5 plus verbatim \`markdown\` block under ${MODEL_VIEW_HEADING}` })
|
||||
continue
|
||||
}
|
||||
const hasConcreteLiteral = surfaces.some(surface => surface.verbatimBlocks > 0
|
||||
|| surface.modelView.raw.includes('`')
|
||||
|| surface.tokenEffect.raw.includes('`')
|
||||
|| toolCatalogLinkFragments(surface.modelView.raw).length > 0)
|
||||
const hasConcreteLiteral = modelContextEntries.some(entry => entry.verbatimBlocks > 0
|
||||
|| entry.modelView.raw.includes('`')
|
||||
|| entry.tokenEffect.raw.includes('`')
|
||||
|| toolCatalogLinkFragments(entry.modelView.raw).length > 0)
|
||||
if (!hasConcreteLiteral) {
|
||||
failures.push({ path: readme, message: 'structured Model Experience must ground at least one surface with inline code, a nested `markdown` block, or an anchored tool-catalog link' })
|
||||
failures.push({ path: readme, message: 'structured Model Experience must ground at least one entry with inline code, a nested `markdown` block, or an anchored tool-catalog link' })
|
||||
continue
|
||||
}
|
||||
let catalogError = false
|
||||
for (const surface of surfaces) {
|
||||
if (!/\bschemas?\b/i.test(surface.title)) continue
|
||||
const fragments = toolCatalogLinkFragments(surface.modelView.raw)
|
||||
for (const entry of modelContextEntries) {
|
||||
if (!/\bschemas?\b/i.test(entry.title)) continue
|
||||
const fragments = toolCatalogLinkFragments(entry.modelView.raw)
|
||||
if (fragments.length === 0) {
|
||||
failures.push({ path: readme, message: `line ${surface.heading.index}: tool-schema surface must link an anchored section of ../../../docs/tool-catalog.md` })
|
||||
failures.push({ path: readme, message: `line ${entry.heading.index}: tool-schema entry must link an anchored section of ../../../docs/tool-catalog.md` })
|
||||
catalogError = true
|
||||
break
|
||||
}
|
||||
const invalid = fragments.find(fragment => !toolCatalogFragments.has(fragment))
|
||||
if (invalid !== undefined) {
|
||||
failures.push({ path: readme, message: `line ${surface.modelView.index}: tool-catalog link fragment ${JSON.stringify(invalid)} does not name an H2 section` })
|
||||
failures.push({ path: readme, message: `line ${entry.modelView.index}: tool-catalog link fragment ${JSON.stringify(invalid)} does not name an H2 section` })
|
||||
catalogError = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (catalogError) continue
|
||||
verbatimBlockCount += surfaces.reduce((total, surface) => total + surface.verbatimBlocks, 0)
|
||||
contextSurfaceCount += surfaces.length
|
||||
systemPromptSurfaceCount += surfaces.filter(surface => isDirectSystemPromptSurface(surface.title)).length
|
||||
toolSchemaSurfaceCount += surfaces.filter(surface => /\bschemas?\b/i.test(surface.title)).length
|
||||
kvCacheEffectCount += surfaces.length
|
||||
verbatimBlockCount += modelContextEntries.reduce((total, entry) => total + entry.verbatimBlocks, 0)
|
||||
modelContextEntryCount += modelContextEntries.length
|
||||
systemPromptEntryCount += modelContextEntries.filter(entry => isDirectSystemPromptEntry(entry.title)).length
|
||||
toolSchemaEntryCount += modelContextEntries.filter(entry => /\bschemas?\b/i.test(entry.title)).length
|
||||
kvCacheEffectCount += modelContextEntries.length
|
||||
structuredCount += 1
|
||||
}
|
||||
|
||||
if (failures.length === 0) {
|
||||
console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${omittedSectionCount} audited omissions, ${structuredCount} structured, ${contextSurfaceCount} context surfaces, ${kvCacheEffectCount} KV-cache fields, ${systemPromptSurfaceCount} fenced system-prompt surfaces, ${toolSchemaSurfaceCount} catalog-linked tool-schema surfaces, ${explainedNoneCount} explained none, ${indirectCount} indirect, ${verbatimBlockCount} verbatim markdown blocks), all conform.`)
|
||||
console.log(`verify-package-readme-model-experience: ${packageJsons.length} README(s) checked (${omittedSectionCount} audited omissions, ${structuredCount} structured, ${modelContextEntryCount} model-context entries, ${kvCacheEffectCount} KV-cache fields, ${systemPromptEntryCount} fenced system-prompt entries, ${toolSchemaEntryCount} catalog-linked tool-schema entries, ${explainedNoneCount} explained none, ${indirectCount} indirect, ${verbatimBlockCount} verbatim markdown blocks), all conform.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { findInternalRepositoryReferences } from './verify-public-repository-links.ts'
|
||||
import { findUnavailableRepositoryReferences } from './verify-public-repository-links.ts'
|
||||
|
||||
describe('public repository link policy', () => {
|
||||
it('rejects encoded and case-varied internal identities without blocking public repositories', () => {
|
||||
const internalOwner = ['deepseek', 'harness'].join('-')
|
||||
const internalRepository = [internalOwner, internalOwner].join('/')
|
||||
const encodedRepository = internalRepository.replaceAll('-', '%2D').replace('/', '%2F')
|
||||
const htmlEncodedRepository = internalRepository.replace('/', '/')
|
||||
const jsonEscapedRepository = internalRepository.replace('/', '\\/')
|
||||
const unicodeEscapedRepository = internalRepository.replace('/', String.raw`\u002f`)
|
||||
describe('repository link policy', () => {
|
||||
it('rejects encoded and case-varied references to the unavailable repository', () => {
|
||||
const unavailableOwner = ['deepseek', 'ai'].join('-')
|
||||
const unavailableName = ['deepseek', 'harness', 'sdk'].join('-')
|
||||
const unavailableRepository = `${unavailableOwner}/${unavailableName}`
|
||||
const encodedRepository = unavailableRepository.replaceAll('-', '%2D').replace('/', '%2F')
|
||||
const htmlEncodedRepository = unavailableRepository.replace('/', '/')
|
||||
const jsonEscapedRepository = unavailableRepository.replace('/', '\\/')
|
||||
const unicodeEscapedRepository = unavailableRepository.replace('/', String.raw`\u002f`)
|
||||
const source = [
|
||||
'https://github.com/deepseek-ai/deepseek-harness-sdk',
|
||||
`https://github.com/${internalOwner}/cordis`,
|
||||
`https://github.com/${internalRepository.toUpperCase()}/issues/1`,
|
||||
'https://github.com/deepseek-ai/deepseek-harness',
|
||||
`https://github.com/${unavailableRepository.toUpperCase()}/issues/1`,
|
||||
`https://github.com/${encodedRepository}/issues/2`,
|
||||
`https://github.com/${htmlEncodedRepository}/issues/3`,
|
||||
`"https:\\/\\/github.com\\/${jsonEscapedRepository}\\/issues\\/4"`,
|
||||
`"https:\\/\\/github.com\\/${unicodeEscapedRepository}\\/issues\\/5"`,
|
||||
`${internalOwner.toUpperCase()}#6`,
|
||||
`https://github.com/${unavailableOwner}/cordis`,
|
||||
`https://github.com/example/${unavailableName}`,
|
||||
].join('\n')
|
||||
|
||||
expect(findInternalRepositoryReferences('subject.md', source)).toEqual([
|
||||
expect(findUnavailableRepositoryReferences('subject.md', source)).toEqual([
|
||||
{ file: 'subject.md', line: 2 },
|
||||
{ file: 'subject.md', line: 3 },
|
||||
{ file: 'subject.md', line: 4 },
|
||||
{ file: 'subject.md', line: 5 },
|
||||
{ file: 'subject.md', line: 6 },
|
||||
{ file: 'subject.md', line: 7 },
|
||||
{ file: 'subject.md', line: 8 },
|
||||
])
|
||||
})
|
||||
|
||||
it('allows only the exact audited trusted-publishing repository declarations', () => {
|
||||
const internalOwner = ['deepseek', 'harness'].join('-')
|
||||
const internalRepository = [internalOwner, internalOwner].join('/')
|
||||
const repositoryUrl = `git+https://github.com/${internalRepository}.git`
|
||||
const manifestLine = ` "url": "${repositoryUrl}",`
|
||||
const constraintLine = `const repositoryUrl = '${repositoryUrl}'`
|
||||
const allowedDeclarations = [
|
||||
['native/landlock-run/packages/entry/package.json', manifestLine],
|
||||
['native/landlock-run/packages/linux-arm64/package.json', manifestLine],
|
||||
['native/landlock-run/packages/linux-x64/package.json', manifestLine],
|
||||
['scripts/check-workspace-constraints.ts', constraintLine],
|
||||
] as const
|
||||
it('preserves frozen archived Agent Notes', () => {
|
||||
const unavailableRepository = ['deepseek-ai', 'deepseek-harness-sdk'].join('/')
|
||||
|
||||
for (const [file, source] of allowedDeclarations) {
|
||||
expect(findInternalRepositoryReferences(file, source)).toEqual([])
|
||||
}
|
||||
|
||||
const wrongFile = 'native/landlock-run/package.json'
|
||||
expect(findInternalRepositoryReferences(wrongFile, manifestLine)).toEqual([{ file: wrongFile, line: 1 }])
|
||||
|
||||
const manifestFile = 'native/landlock-run/packages/entry/package.json'
|
||||
const wrongField = ` "homepage": "${repositoryUrl}",`
|
||||
expect(findInternalRepositoryReferences(manifestFile, wrongField)).toEqual([{ file: manifestFile, line: 1 }])
|
||||
|
||||
const encodedLine = manifestLine.replace('github.com/', 'github.com\\/')
|
||||
expect(findInternalRepositoryReferences(manifestFile, encodedLine)).toEqual([{ file: manifestFile, line: 1 }])
|
||||
expect(findUnavailableRepositoryReferences(
|
||||
'.agents/notes/archived/process/historical-record.md',
|
||||
`https://github.com/${unavailableRepository}`,
|
||||
)).toEqual([])
|
||||
expect(findUnavailableRepositoryReferences(
|
||||
'.agents/notes/implemented/process/active-record.md',
|
||||
`https://github.com/${unavailableRepository}`,
|
||||
)).toEqual([{ file: '.agents/notes/implemented/process/active-record.md', line: 1 }])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Reject tracked files that expose the internal repository identity outside audited publishing declarations. */
|
||||
/** Reject tracked files that reference an unavailable legacy repository. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs'
|
||||
@@ -6,22 +6,13 @@ import { resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const internalOwner = ['deepseek', 'harness'].join('-')
|
||||
const internalRepository = [internalOwner, internalOwner].join('/')
|
||||
const internalIssueShorthand = `${internalOwner}#`
|
||||
const trustedPublishingRepositoryUrl = `git+https://github.com/${internalRepository}.git`
|
||||
|
||||
/** Exact declarations that intentionally expose the source repository for trusted publishing. */
|
||||
const allowedInternalRepositoryLineByFile: Readonly<Record<string, string>> = {
|
||||
'native/landlock-run/packages/entry/package.json': `"url": "${trustedPublishingRepositoryUrl}",`,
|
||||
'native/landlock-run/packages/linux-arm64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`,
|
||||
'native/landlock-run/packages/linux-x64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`,
|
||||
'scripts/check-workspace-constraints.ts': `const repositoryUrl = '${trustedPublishingRepositoryUrl}'`,
|
||||
}
|
||||
const unavailableOwner = ['deepseek', 'ai'].join('-')
|
||||
const unavailableRepositoryName = ['deepseek', 'harness', 'sdk'].join('-')
|
||||
const unavailableRepository = `${unavailableOwner}/${unavailableRepositoryName}`
|
||||
const archivedAgentNotePrefix = '.agents/notes/archived/'
|
||||
|
||||
const namedReferenceCharacters: Readonly<Record<string, string>> = {
|
||||
hyphen: '-',
|
||||
num: '#',
|
||||
sol: '/',
|
||||
}
|
||||
|
||||
@@ -40,8 +31,8 @@ function canonicalReferenceText(source: string): string {
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/** One tracked reference to the internal repository. */
|
||||
export interface InternalRepositoryReference {
|
||||
/** One tracked reference to the unavailable repository. */
|
||||
export interface UnavailableRepositoryReference {
|
||||
/** Repository-relative file path. */
|
||||
file: string
|
||||
/** One-based source line. */
|
||||
@@ -49,20 +40,18 @@ export interface InternalRepositoryReference {
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate unaudited internal-repository references in one text file.
|
||||
* Locate unavailable-repository references in one active text file.
|
||||
* @param file - Repository-relative path used in diagnostics.
|
||||
* @param source - Text to inspect.
|
||||
* @returns every matching source line.
|
||||
* @returns every matching source line, excluding frozen archived Agent Notes.
|
||||
*/
|
||||
export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] {
|
||||
const references: InternalRepositoryReference[] = []
|
||||
export function findUnavailableRepositoryReferences(file: string, source: string): UnavailableRepositoryReference[] {
|
||||
if (file.startsWith(archivedAgentNotePrefix)) return []
|
||||
|
||||
const references: UnavailableRepositoryReference[] = []
|
||||
for (const [index, line] of source.split('\n').entries()) {
|
||||
const canonicalLine = canonicalReferenceText(line)
|
||||
const isAllowedPublishingDeclaration = line.trim() === allowedInternalRepositoryLineByFile[file]
|
||||
if (!isAllowedPublishingDeclaration
|
||||
&& (canonicalLine.includes(internalRepository) || canonicalLine.includes(internalIssueShorthand))) {
|
||||
references.push({ file, line: index + 1 })
|
||||
}
|
||||
if (canonicalLine.includes(unavailableRepository)) references.push({ file, line: index + 1 })
|
||||
}
|
||||
return references
|
||||
}
|
||||
@@ -73,8 +62,8 @@ function trackedFiles(repoRoot: string): string[] {
|
||||
.filter(file => file !== '')
|
||||
}
|
||||
|
||||
function scanRepository(repoRoot: string): InternalRepositoryReference[] {
|
||||
const references: InternalRepositoryReference[] = []
|
||||
function scanRepository(repoRoot: string): UnavailableRepositoryReference[] {
|
||||
const references: UnavailableRepositoryReference[] = []
|
||||
for (const file of trackedFiles(repoRoot)) {
|
||||
const path = resolve(repoRoot, file)
|
||||
if (!existsSync(path)) continue
|
||||
@@ -82,7 +71,7 @@ function scanRepository(repoRoot: string): InternalRepositoryReference[] {
|
||||
if (!stat.isFile() && !stat.isSymbolicLink()) continue
|
||||
const source = stat.isSymbolicLink() ? readlinkSync(path) : readFileSync(path, 'utf8')
|
||||
if (source.includes('\0')) continue
|
||||
references.push(...findInternalRepositoryReferences(file, source))
|
||||
references.push(...findUnavailableRepositoryReferences(file, source))
|
||||
}
|
||||
return references
|
||||
}
|
||||
@@ -92,9 +81,9 @@ const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(re
|
||||
if (isMain) {
|
||||
const references = scanRepository(root)
|
||||
if (references.length === 0) {
|
||||
console.log('verify-public-repository-links: tracked files expose no unexpected internal repository identity.')
|
||||
console.log('verify-public-repository-links: tracked files reference no unavailable repository.')
|
||||
} else {
|
||||
console.error('verify-public-repository-links: unexpected internal repository references found:')
|
||||
console.error('verify-public-repository-links: unavailable repository references found:')
|
||||
for (const reference of references) console.error(` ${reference.file}:${String(reference.line)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectSkillInvocationMetadataViolations } from './verify-skill-invocation-metadata.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixtureRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-skill-invocation-metadata-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function writeSkill(root: string, name: string, frontmatter: string, policy = ''): void {
|
||||
const directory = join(root, '.agents/skills', name)
|
||||
mkdirSync(join(directory, 'agents'), { recursive: true })
|
||||
writeFileSync(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Test skill\n${frontmatter}---\n\nTest.\n`)
|
||||
writeFileSync(
|
||||
join(directory, 'agents/openai.yaml'),
|
||||
`interface:\n display_name: "Test"\n${policy}`,
|
||||
)
|
||||
}
|
||||
|
||||
describe('cross-product skill invocation metadata gate', () => {
|
||||
it('accepts aligned default and manual-only policies', () => {
|
||||
const root = fixtureRoot()
|
||||
writeSkill(root, 'default-skill', '')
|
||||
writeSkill(
|
||||
root,
|
||||
'manual-skill',
|
||||
'disable-model-invocation: true\nuser-invocable: true\n',
|
||||
'policy:\n allow_implicit_invocation: false\n',
|
||||
)
|
||||
|
||||
expect(collectSkillInvocationMetadataViolations(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects either direction of a manual-only policy mismatch', () => {
|
||||
const root = fixtureRoot()
|
||||
writeSkill(root, 'claude-only', 'disable-model-invocation: true\n')
|
||||
writeSkill(root, 'codex-only', '', 'policy:\n allow_implicit_invocation: false\n')
|
||||
|
||||
expect(collectSkillInvocationMetadataViolations(root)).toEqual([
|
||||
'.agents/skills/claude-only: Claude Code manual-only=true but Codex manual-only=false',
|
||||
'.agents/skills/codex-only: Claude Code manual-only=false but Codex manual-only=true',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Keep Claude Code and Codex invocation metadata aligned for repository skills.
|
||||
* @module scripts/verify-skill-invocation-metadata
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { load } from 'js-yaml'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Return an object-shaped YAML value, or undefined for every other shape. */
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Parse a skill's YAML frontmatter as an object. */
|
||||
function parseSkillFrontmatter(source: string): Record<string, unknown> {
|
||||
const lines = source.split('\n')
|
||||
if (lines[0] !== '---') throw new Error('SKILL.md must start with YAML frontmatter')
|
||||
const end = lines.indexOf('---', 1)
|
||||
if (end < 0) throw new Error('SKILL.md frontmatter is not closed')
|
||||
const metadata = asRecord(load(lines.slice(1, end).join('\n')))
|
||||
if (metadata === undefined) throw new Error('SKILL.md frontmatter must be a YAML object')
|
||||
return metadata
|
||||
}
|
||||
|
||||
/** Find repository skill directories that carry Codex product metadata. */
|
||||
function skillDirectories(root: string): string[] {
|
||||
const skillsRoot = resolve(root, '.agents/skills')
|
||||
if (!existsSync(skillsRoot)) return []
|
||||
return readdirSync(skillsRoot, { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory() && existsSync(resolve(skillsRoot, entry.name, 'agents/openai.yaml')))
|
||||
.map(entry => entry.name)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Report cross-product invocation-policy mismatches for repository skills.
|
||||
* @param root - Repository root containing `.agents/skills`.
|
||||
* @returns diagnostics for malformed metadata or policies that expose a skill differently.
|
||||
*/
|
||||
export function collectSkillInvocationMetadataViolations(root: string): string[] {
|
||||
const violations: string[] = []
|
||||
|
||||
for (const skill of skillDirectories(root)) {
|
||||
const relativeRoot = `.agents/skills/${skill}`
|
||||
const skillFile = resolve(root, relativeRoot, 'SKILL.md')
|
||||
const openaiFile = resolve(root, relativeRoot, 'agents/openai.yaml')
|
||||
if (!existsSync(skillFile)) {
|
||||
violations.push(`${relativeRoot}: agents/openai.yaml has no sibling SKILL.md`)
|
||||
continue
|
||||
}
|
||||
|
||||
let frontmatter: Record<string, unknown>
|
||||
let openai: Record<string, unknown>
|
||||
try {
|
||||
frontmatter = parseSkillFrontmatter(readFileSync(skillFile, 'utf8'))
|
||||
}
|
||||
catch (error) {
|
||||
violations.push(`${relativeRoot}/SKILL.md: ${error instanceof Error ? error.message : String(error)}`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const parsed = asRecord(load(readFileSync(openaiFile, 'utf8')))
|
||||
if (parsed === undefined) throw new Error('agents/openai.yaml must be a YAML object')
|
||||
openai = parsed
|
||||
}
|
||||
catch (error) {
|
||||
violations.push(`${relativeRoot}/agents/openai.yaml: ${error instanceof Error ? error.message : String(error)}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const disableModelInvocation = frontmatter['disable-model-invocation']
|
||||
if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
|
||||
violations.push(`${relativeRoot}/SKILL.md: disable-model-invocation must be a boolean`)
|
||||
continue
|
||||
}
|
||||
const userInvocable = frontmatter['user-invocable']
|
||||
if (userInvocable !== undefined && typeof userInvocable !== 'boolean') {
|
||||
violations.push(`${relativeRoot}/SKILL.md: user-invocable must be a boolean`)
|
||||
continue
|
||||
}
|
||||
|
||||
const policy = asRecord(openai.policy)
|
||||
const allowImplicitInvocation = policy?.allow_implicit_invocation
|
||||
if (allowImplicitInvocation !== undefined && typeof allowImplicitInvocation !== 'boolean') {
|
||||
violations.push(`${relativeRoot}/agents/openai.yaml: policy.allow_implicit_invocation must be a boolean`)
|
||||
continue
|
||||
}
|
||||
|
||||
const claudeManualOnly = disableModelInvocation === true
|
||||
const codexManualOnly = allowImplicitInvocation === false
|
||||
if (claudeManualOnly !== codexManualOnly) {
|
||||
violations.push(
|
||||
`${relativeRoot}: Claude Code manual-only=${String(claudeManualOnly)}`
|
||||
+ ` but Codex manual-only=${String(codexManualOnly)}`,
|
||||
)
|
||||
}
|
||||
if (claudeManualOnly && userInvocable === false) {
|
||||
violations.push(`${relativeRoot}/SKILL.md: a manual-only skill must remain user-invocable`)
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
const skills = skillDirectories(ROOT)
|
||||
const violations = collectSkillInvocationMetadataViolations(ROOT)
|
||||
if (violations.length > 0) {
|
||||
process.stderr.write('verify-skill-invocation-metadata: violations found:\n')
|
||||
for (const violation of violations) process.stderr.write(` ${violation}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`verify-skill-invocation-metadata: ${String(skills.length)} cross-product skill policy pair(s) aligned.\n`,
|
||||
)
|
||||
}
|
||||
@@ -291,6 +291,6 @@ if (errors.length === 0) {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):')
|
||||
console.error('verify-translation-pairing: bilingual pairing rules violated (see docs/i18n/README.md):')
|
||||
for (const message of errors) console.error(` ${message}`)
|
||||
process.exit(1)
|
||||
Reference in New Issue
Block a user