Merge remote-tracking branch 'origin/stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

# Conflicts:
#	docs/architecture.i18n.yaml
#	docs/architecture.zh.md
#	docs/module-graph.md
#	packages/host/apiproxy/src/api-proxy.ts
#	scripts/verify-package-readme-model-experience.ts
This commit is contained in:
Yichen Jiang
2026-08-09 20:38:21 +08:00
1623 changed files with 15464 additions and 5450 deletions

View File

@@ -44,6 +44,7 @@ export { REGION_BEGIN, REGION_END }
*/
export const SERVICE_PAGE: Record<string, string> = {
agentLoop: 'core.md',
agentDefaultModel: 'core.md',
agentPresets: 'core.md',
agents: 'core.md',
approval: 'approval.md',
@@ -197,6 +198,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
AgentCancelCause: 'core.md',
AgentFactory: 'core.md',
AgentHandle: 'core.md',
ModelSelection: 'core.md',
AgentOptions: 'core.md',
AgentStatus: 'core.md',
ContentBlock: 'llm-streaming.md',
@@ -261,6 +263,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
FsEditOutcome: 'filesystem.md',
FsEditRequest: 'filesystem.md',
FsInfo: 'filesystem.md',
FsObservation: 'filesystem.md',
FsPathInfo: 'filesystem.md',
FsPolicyExec: 'filesystem.md',
FsTarget: 'filesystem.md',
@@ -511,7 +514,7 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
],
inheritedServices: [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / short-circuit chain).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },

View File

@@ -314,6 +314,14 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-loop', 'acp', 'subagent-inprocess'],
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
},
{
key: 'agentDefaultModel',
pkg: 'agent-default-model',
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.',
},
{
key: 'agentLoop',
pkg: 'agent-loop',
@@ -1229,7 +1237,7 @@ function renderLifecycle(): string {
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
'```',
'',
'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.',
'The `assistant/message` event records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history, while the durable event keeps usage and `sourceEventSeqs` listing the exact `assistant/chunk` events, including an explicit empty list.',
'',
'`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.',
'',

View File

@@ -253,7 +253,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
dir: 'tool-str-replace-editor',
source: 'packages/fs/tool-str-replace-editor/src/index.ts',
requires: ['ctx.tools', 'ctx.fs'],
writes: ['tool/call', 'fs/observed after successful file operations', 'tool/result'],
writes: ['tool/call', 'fs/observed after view presence/absence, edit absence, or successful mutation', 'tool/result'],
async mount(ctx) {
await ctx.plugin(LocalFileSystem)
await ctx.plugin(ToolStrReplaceEditor)
@@ -266,7 +266,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
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 successful file operations', 'tool/result'],
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful mutation', 'tool/result'],
async mount(ctx) {
// The tool needs `fs`; the bare provider is sufficient because policy
// changes behavior, not schema shape.

View File

@@ -85,7 +85,7 @@ describe('Oxlint repository rule fingerprint', () => {
const overrides: readonly unknown[] = parsed.overrides
it('pins the complete override shape', () => {
expect(overrides).toHaveLength(6)
expect(overrides).toHaveLength(8)
})
it.each(Object.entries(profiles))('pins the %s rule profile', (_name, profile) => {

View File

@@ -1,14 +1,15 @@
import { spawnSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { fileURLToPath } from 'node:url'
import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript'
import { describe, expect, it } from 'vitest'
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
const eslintCli = fileURLToPath(new URL('../node_modules/eslint/bin/eslint.js', import.meta.url))
const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url))
const tsxCli = fileURLToPath(new URL('../node_modules/tsx/dist/cli.mjs', import.meta.url))
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -18,11 +19,11 @@ function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value)
}
function runStagedFormatter(paths: readonly string[]) {
return spawnSync(process.execPath, [eslintCli, '--config', 'eslint.format.config.mjs', '--fix', '--no-warn-ignored', ...paths], {
function runRepositoryOxlint(args: readonly string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync(process.execPath, [tsxCli, 'scripts/run-oxlint.ts', ...args], {
cwd: repositoryRoot,
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1' },
env: { ...process.env, NO_COLOR: '1', ...env },
})
}
@@ -150,7 +151,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
}
}, 20_000)
it('keeps formatter rules aligned with Oxlint validation', async () => {
it('keeps the complete stylistic contract in Oxlint', async () => {
const oxlintPath = join(repositoryRoot, '.oxlintrc.json')
const result = parseConfigFileTextToJson(oxlintPath, await readFile(oxlintPath, 'utf8'))
if (result.error !== undefined) {
@@ -160,27 +161,67 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
if (!isRecord(parsed) || !isUnknownArray(parsed.overrides)) {
throw new Error('.oxlintrc.json must contain an overrides array')
}
expect(parsed.ignorePatterns).toEqual(expect.arrayContaining([
'packages/typert/generator/tests/fixtures/type-model/**',
]))
const stylisticOverride = parsed.overrides.find((value: unknown) =>
isRecord(value) && isRecord(value.rules) && '@stylistic/max-len' in value.rules)
if (!isRecord(stylisticOverride) || !isRecord(stylisticOverride.rules)) {
throw new Error('.oxlintrc.json must contain the @stylistic validator override')
}
const validatorRules = { ...stylisticOverride.rules }
const maxLen = validatorRules['@stylistic/max-len']
delete validatorRules['@stylistic/max-len']
expect(stylisticOverride.rules).toMatchObject({
'@stylistic/indent': ['error', 2],
'@stylistic/semi': ['error', 'never'],
'@stylistic/quotes': ['error', 'single', { avoidEscape: true }],
'@stylistic/comma-dangle': ['error', 'always-multiline'],
'@stylistic/eol-last': ['error', 'always'],
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }],
'@stylistic/member-delimiter-style': ['error', {
multiline: { delimiter: 'none' },
singleline: { delimiter: 'semi', requireLast: false },
}],
'@stylistic/max-len': ['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }],
})
const typeGraphOverride = parsed.overrides.find((value: unknown) =>
isRecord(value)
&& isUnknownArray(value.files)
&& value.files.includes('packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts'))
expect(typeGraphOverride).toMatchObject({
rules: { '@stylistic/quotes': 'off' },
})
})
const formatterUrl = pathToFileURL(join(repositoryRoot, 'eslint.format.config.mjs')).href
const formatterModule = await import(formatterUrl) as unknown
if (!isRecord(formatterModule) || !isUnknownArray(formatterModule.default)) {
throw new Error('eslint.format.config.mjs must default-export a config array')
}
const formatterOverride = formatterModule.default.find((value: unknown) => isRecord(value) && isRecord(value.rules))
if (!isRecord(formatterOverride) || !isRecord(formatterOverride.rules)) {
throw new Error('eslint.format.config.mjs must contain a rules object')
it('checks preserved TypeGraph syntax without type-aware analysis', () => {
const result = runOxlint([
'--config',
'.oxlintrc.staged.json',
'packages/typert/generator/tests/fixtures/type-model',
])
expect(result.error).toBeUndefined()
expect(result.status, normalizedOutput(result)).toBe(0)
})
it('keeps repository lint workflows Oxlint-only', async () => {
const packageJson = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')) as unknown
if (!isRecord(packageJson) || !isRecord(packageJson.scripts) || !isRecord(packageJson.devDependencies)) {
throw new Error('package.json must contain scripts and devDependencies objects')
}
expect(validatorRules).toStrictEqual(formatterOverride.rules)
expect(maxLen).toStrictEqual(['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }])
expect(packageJson.scripts['lint:contracts-ready']).toBe('tsx scripts/run-oxlint.ts .')
expect(packageJson.scripts['lint:fix:contracts-ready']).toBe(
'tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json packages/typert/generator/tests/fixtures/type-model --fix && tsx scripts/run-oxlint.ts . --fix',
)
expect(packageJson.devDependencies).not.toHaveProperty('eslint')
expect(packageJson.devDependencies).not.toHaveProperty('@typescript-eslint/parser')
expect(existsSync(join(repositoryRoot, 'eslint.format.config.mjs'))).toBe(false)
const lefthook = await readFile(join(repositoryRoot, 'lefthook.yml'), 'utf8')
expect(lefthook).toContain('scripts/run-oxlint.ts --config .oxlintrc.staged.json --fix')
expect(lefthook).not.toContain('node_modules/.bin/eslint')
expect(lefthook).not.toContain('eslint.format.config.mjs')
})
it('reports an unused suppression', async () => {
@@ -227,10 +268,13 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
if (result.error !== undefined) {
throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n'))
}
expect(result.config).toMatchObject({
const stagedConfig = result.config as unknown
if (!isRecord(stagedConfig)) throw new Error('.oxlintrc.staged.json must contain a config object')
expect(stagedConfig).toMatchObject({
extends: ['./.oxlintrc.json'],
options: { typeAware: false },
})
expect(stagedConfig.ignorePatterns).not.toContain('packages/typert/generator/tests/fixtures/type-model/**')
const suffix = randomUUID()
const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`)
@@ -254,30 +298,76 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
}
})
it('applies staged stylistic fixes before Oxlint validation', async () => {
it('preserves successful fix output channels', async () => {
const suffix = randomUUID()
const configPath = await writeContractConfig(suffix)
const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`)
const path = join(directory, 'fix.ts')
const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`)
try {
await mkdir(directory, { recursive: true })
await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n')
const relativePath = relative(repositoryRoot, path)
const formatResult = runStagedFormatter([relativePath])
const lintResult = runOxlint(['--config', relative(repositoryRoot, configPath), '--fix', relativePath])
expect(formatResult.error).toBeUndefined()
expect(formatResult.status, normalizedOutput(formatResult)).toBe(0)
expect(lintResult.error).toBeUndefined()
expect(lintResult.status, normalizedOutput(lintResult)).toBe(0)
await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n')
} finally {
await Promise.all([
rm(directory, { recursive: true, force: true }),
rm(configPath, { force: true }),
await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n')
const result = runRepositoryOxlint([
'--config',
'.oxlintrc.staged.json',
'--format',
'unix',
'--fix',
relative(repositoryRoot, path),
])
expect(result.error).toBeUndefined()
expect(result.status, normalizedOutput(result)).toBe(0)
expect(result.stdout).toContain('Unused oxlint-disable directive')
expect(result.stderr).toBe('')
} finally {
await rm(path, { force: true })
}
}, 20_000)
})
it('prints only the final diagnostics when a fix retry still fails', async () => {
const suffix = randomUUID()
const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`)
try {
await writeFile(path, `export const longProbe = ${'1 + '.repeat(80)}1\n`)
const result = runRepositoryOxlint([
'--config',
'.oxlintrc.staged.json',
'--format',
'unix',
'--fix',
relative(repositoryRoot, path),
])
const output = normalizedOutput(result)
expect(result.error).toBeUndefined()
expect(result.status, output).toBe(1)
expect(output.match(/@stylistic\(max-len\)/g)).toHaveLength(1)
} finally {
await rm(path, { force: true })
}
})
it.each(['--fix', '--fix-suggestions', '--fix-dangerously'])(
'converges overlapping staged stylistic fixes through Oxlint under %s',
async (fixFlag) => {
const suffix = randomUUID()
const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`)
const path = join(directory, 'fix.ts')
try {
await mkdir(directory, { recursive: true })
await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n')
const relativePath = relative(repositoryRoot, path)
const lintResult = runRepositoryOxlint(['--config', '.oxlintrc.staged.json', fixFlag, relativePath])
expect(lintResult.error).toBeUndefined()
expect(lintResult.status, normalizedOutput(lintResult)).toBe(0)
expect(normalizedOutput(lintResult)).not.toContain('@stylistic')
await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n')
} finally {
await rm(directory, { recursive: true, force: true })
}
},
20_000,
)
})

View File

@@ -299,22 +299,55 @@ describe('docsPages locale routes', () => {
})
it('publishes the Cordis core API under matching locale structures', () => {
const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md', 'inherited.md']
const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md']
for (const file of files) {
const root = docsPages.find(page => page.route === `reference/cordis-api/${file}`)
const english = docsPages.find(page => page.route === `en/reference/cordis-api/${file}`)
expect(root?.source).toBe(`docs/cordis-api/${file}`)
expect(root?.source).toBe(`docs/cordis-api/${file.replace(/\.md$/, '.zh.md')}`)
expect(root?.contentLocale).toBe('zh-CN')
expect(root?.section).toBe('Cordis API')
expect(english?.source).toBe(root?.source)
expect(english?.source).toBe(`docs/cordis-api/${file}`)
expect(english?.contentLocale).toBe('en-US')
expect(english?.section).toBe('Cordis Core API')
}
})
it('includes persistence event headings in both locale outlines', () => {
const pages = docsPages.filter(page => page.source === 'docs/persistence-catalog.md')
it('keeps Cordis inherited on the English fallback in both locales', () => {
const pages = docsPages.filter(page => page.route.endsWith('reference/cordis-api/inherited.md'))
expect(pages).toHaveLength(2)
expect(pages.every(page => page.source === 'docs/cordis-api/inherited.md')).toBe(true)
expect(pages.every(page => page.contentLocale === 'en-US')).toBe(true)
})
it('includes persistence event headings in both locale outlines', () => {
const pages = docsPages.filter(page => page.route.endsWith('reference/persistence-catalog.md'))
expect(pages).toHaveLength(2)
expect(pages.map(page => page.source).sort()).toEqual([
'docs/persistence-catalog.md',
'docs/persistence-catalog.zh.md',
])
expect(pages.map(page => page.outline)).toEqual(['deep', 'deep'])
})
it('projects reviewed generated counterparts into root locale routes', () => {
// module-graph, event-producer-consumer, and graph-atlas are paired but intentionally unpublished.
const routes = [
'reference/capability-seams.md',
'reference/agent-lifecycle.md',
'reference/tool-execution-pipeline.md',
'reference/config-catalog.md',
'reference/tool-catalog.md',
'reference/persistence-catalog.md',
'reference/cordis-api/context.md',
'reference/cordis-api/events.md',
'reference/cordis-api/fiber.md',
'reference/cordis-api/registry.md',
'reference/cordis-api/service.md',
]
const pages = routes.map(route => docsPages.find(page => page.route === route))
expect(pages.every(page => page?.contentLocale === 'zh-CN')).toBe(true)
expect(pages.every(page => page?.source.endsWith('.zh.md'))).toBe(true)
})
})
describe('addProjectionFrontmatter', () => {

View File

@@ -3,6 +3,12 @@ import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url))
const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024 * 1024
const FIX_FLAGS = new Set(['--fix', '--fix-dangerously', '--fix-suggestions'])
function isFixInvocation(args: readonly string[]): boolean {
return args.some(arg => FIX_FLAGS.has(arg))
}
/** Complete Oxlint child-process arguments and environment. */
export interface OxlintInvocation {
@@ -32,14 +38,50 @@ export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.Pro
}
}
function completeFrom(result: { readonly signal: NodeJS.Signals | null; readonly status: number | null }): void {
if (result.signal !== null) {
process.kill(process.pid, result.signal)
return
}
process.exitCode = result.status ?? 1
}
function main(): void {
const invocation = resolveOxlintInvocation(process.argv.slice(2), process.env)
const result = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
if (!isFixInvocation(invocation.args)) {
const result = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
env: invocation.env,
stdio: 'inherit',
})
if (result.error !== undefined) throw result.error
completeFrom(result)
return
}
const first = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
encoding: 'utf8',
env: invocation.env,
maxBuffer: MAX_CAPTURED_OUTPUT_BYTES,
})
if (first.error !== undefined) throw first.error
if (first.signal !== null) {
completeFrom(first)
return
}
if (first.status === 0) {
process.stdout.write(first.stdout)
process.stderr.write(first.stderr)
process.exitCode = 0
return
}
// Overlapping JS-plugin fixes can expose one more fixable diagnostic after the first pass.
const second = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
env: invocation.env,
stdio: 'inherit',
})
if (result.error !== undefined) throw result.error
process.exitCode = result.status ?? 1
if (second.error !== undefined) throw second.error
completeFrom(second)
}
const entrypoint = process.argv[1]

File diff suppressed because one or more lines are too long

View File

@@ -119,6 +119,12 @@ const otherSource = baseSource.replace('Beta base.', 'Beta other.')
const otherZh = baseZh.replace('乙基础。', '乙对侧。')
const mergedSource = currentSource.replace('Beta base.', 'Beta other.')
const mergedZh = currentZh.replace('乙基础。', '乙对侧。')
const generatedBaseSource = '# Module graph\n\nAlpha base.\n\nBeta base.\n'
const generatedBaseZh = '# 模块图\n\n[English](module-graph.md) | 中文\n\n甲基础。\n\n乙基础。\n'
const generatedCurrentSource = generatedBaseSource.replace('Alpha base.', 'Alpha current.')
const generatedCurrentZh = generatedBaseZh.replace('甲基础。', '甲当前。')
const generatedOtherSource = generatedBaseSource.replace('Beta base.', 'Beta other.')
const generatedOtherZh = generatedBaseZh.replace('乙基础。', '乙对侧。')
const manualBaseSource = baseSource.replace('guide.zh.md', 'manual.zh.md')
const manualBaseZh = baseZh.replace('guide.md', 'manual.md')
const manualCurrentSource = manualBaseSource.replace('Alpha base.', 'Alpha current.')
@@ -257,6 +263,60 @@ describe('translation pairing merge composition', () => {
expect(result.zhHash).toBe(gitBlobHash(Buffer.from(mergedZh)))
})
it('merges a generated source without an English language switcher', () => {
const fixture = createFixture(false)
const ancestor = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, generatedBaseZh)
const current = record(fixture.root, 'docs/module-graph.md', generatedCurrentSource, generatedCurrentZh)
const other = record(fixture.root, 'docs/module-graph.md', generatedOtherSource, generatedOtherZh)
const result = mergeTranslationPairingRecords(
fixture.root,
'docs/module-graph.i18n.yaml',
ancestor,
current,
other,
)
expect(result.sourceContent.toString('utf8')).toBe(
generatedCurrentSource.replace('Beta base.', 'Beta other.'),
)
expect(result.zhContent.toString('utf8')).toBe(generatedCurrentZh.replace('乙基础。', '乙对侧。'))
})
it('rejects an authored source without an English language switcher', () => {
const fixture = createFixture(false)
const source = baseSource.replace('English | [中文](guide.zh.md)\n\n', '')
const ancestor = record(fixture.root, 'docs/guide.md', source, baseZh)
const current = record(fixture.root, 'docs/guide.md', source, baseZh)
const other = record(fixture.root, 'docs/guide.md', source, baseZh)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
ancestor,
current,
other,
)).toThrow('docs/guide.md clean merge lost its language-switcher link to guide.zh.md')
})
it('rejects generated Chinese content without its English backlink', () => {
const fixture = createFixture(false)
const zh = generatedBaseZh.replace('[English](module-graph.md) | 中文\n\n', '')
const ancestor = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
const current = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
const other = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/module-graph.i18n.yaml',
ancestor,
current,
other,
)).toThrow(
'docs/module-graph.zh.md clean merge lost its language-switcher link to module-graph.md',
)
})
it('leaves owner-content conflicts for a human', () => {
const fixture = createFixture(false)
const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)

View File

@@ -15,6 +15,7 @@ import {
linksTo,
isTranslationScopeFile,
parseTranslationMarkdown,
requiresSourceLanguageSwitcher,
translationStructureDiff,
translationStructureSignature,
} from './translation-pairing.ts'
@@ -163,7 +164,7 @@ function loadRecordOwners(
function assertMergedPairStructure(paths: TranslationPairPaths, source: Buffer, zh: Buffer): void {
const sourceTree = parseTranslationMarkdown(source.toString('utf8'))
const zhTree = parseTranslationMarkdown(zh.toString('utf8'))
if (!linksTo(sourceTree, basename(paths.zh))) {
if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, basename(paths.zh))) {
throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`)
}
if (!linksTo(zhTree, basename(paths.source))) {

View File

@@ -4,18 +4,9 @@
".agents/notes/implemented/AGENTS.md",
".agents/notes/implemented/CLAUDE.md",
"docs/AGENTS.md",
"docs/agent-lifecycle.md",
"docs/capability-seams.md",
"docs/config-catalog.md",
"docs/cordis-api/",
"docs/event-producer-consumer.md",
"docs/graph-atlas.md",
"docs/cordis-api/inherited.md",
"docs/i18n/style-samples.md",
"docs/i18n/terminology.md",
"docs/i18n/translation-prompt.md",
"docs/module-graph.md",
"docs/persistence-catalog.md",
"docs/tool-catalog.md",
"docs/tool-execution-pipeline.md"
"docs/i18n/translation-prompt.md"
]
}

View File

@@ -19,6 +19,7 @@ import {
parseTranslationPairingCliArgs,
parseTranslationPairingManifest,
partitionGeneratedRegions,
requiresSourceLanguageSwitcher,
translationStructureDiff,
translationStructureSignature,
} from './translation-pairing.ts'
@@ -142,6 +143,16 @@ describe('translation pairing manifest', () => {
})
})
describe('translation pairing switchers', () => {
it('exempts only paired generated English sources from reciprocal switchers', () => {
expect(requiresSourceLanguageSwitcher('docs/config-catalog.md')).toBe(false)
expect(requiresSourceLanguageSwitcher('docs/cordis-api/context.md')).toBe(false)
expect(requiresSourceLanguageSwitcher('docs/cordis-api/inherited.md')).toBe(false)
expect(requiresSourceLanguageSwitcher('docs/architecture.md')).toBe(true)
expect(requiresSourceLanguageSwitcher('packages/core/session/README.md')).toBe(true)
})
})
describe('translation pairing records', () => {
const paths = translationPairPaths('docs/foo.md')
const record = {

View File

@@ -311,6 +311,28 @@ export function linksTo(tree: Nodes, target: string): boolean {
return found
}
/** Generated English sources cannot carry a switcher without making their generator stale. */
export function requiresSourceLanguageSwitcher(source: string): boolean {
return ![
'docs/agent-lifecycle.md',
'docs/capability-seams.md',
'docs/config-catalog.md',
'docs/cordis-api/context.md',
'docs/cordis-api/events.md',
'docs/cordis-api/fiber.md',
// Excluded from pairing, but kept here for generated-category completeness and direct spec coverage.
'docs/cordis-api/inherited.md',
'docs/cordis-api/registry.md',
'docs/cordis-api/service.md',
'docs/event-producer-consumer.md',
'docs/graph-atlas.md',
'docs/module-graph.md',
'docs/persistence-catalog.md',
'docs/tool-catalog.md',
'docs/tool-execution-pipeline.md',
].includes(source)
}
/** Collect the ordered structural signature, skipping one switcher target. */
export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature {
const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }

View File

@@ -960,6 +960,11 @@
"symbol": "FsVersion",
"source": "packages/fs/fs/src/types.ts"
},
{
"doc": "docs/subsystems/filesystem.md",
"symbol": "FsObservation",
"source": "packages/fs/fs/src/types.ts"
},
{
"doc": "docs/subsystems/filesystem.md",
"symbol": "FsInfo",

View File

@@ -49,6 +49,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
'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.' },
@@ -70,7 +71,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'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-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 target at the next prompt-assembly boundary and owns the 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.' },
@@ -96,7 +97,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'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/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/web bundles.' },
'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.' },
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },

View File

@@ -24,6 +24,7 @@ import {
parseTranslationPairingCliArgs,
parseTranslationPairingManifest,
partitionGeneratedRegions,
requiresSourceLanguageSwitcher,
isTranslationScopeFile,
TRANSLATION_SCOPE_GLOB_EXCLUDES,
translationStructureDiff,
@@ -254,7 +255,7 @@ for (const source of [...pairAnchors].sort()) {
if (!linksTo(zhTree, basename(source))) {
errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
}
if (!linksTo(sourceTree, basename(zh))) {
if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, basename(zh))) {
errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
}
for (const divergence of translationStructureDiff(