Merge origin/master and refresh doc graphs

This commit is contained in:
Tianyi Cui
2026-07-04 12:50:06 +08:00
354 changed files with 27627 additions and 1433 deletions

View File

@@ -27,6 +27,8 @@ const vendoredPackages = new Set([
'@cordisjs/plugin-logger-console',
])
const localArtifactDirs = new Set(['node_modules'])
/** The subset of package.json fields this constraint check cares about. */
interface PackageManifest {
name?: string
@@ -64,10 +66,13 @@ function packageDirs(base: string, depth: number): string[] {
if (depth === 1) {
return readdirSync(join(root, base), { withFileTypes: true })
.filter(entry => entry.isDirectory())
.filter(entry => !localArtifactDirs.has(entry.name))
.filter(entry => existsSync(join(root, base, entry.name, 'package.json')))
.map(entry => join(base, entry.name))
}
return readdirSync(join(root, base), { withFileTypes: true })
.filter(entry => entry.isDirectory())
.filter(entry => !localArtifactDirs.has(entry.name))
.flatMap(group => packageDirs(join(base, group.name), depth - 1))
}
@@ -177,6 +182,7 @@ function checkHierarchyShape(): string[] {
}
for (const pkg of readdirSync(join(packagesRoot, group.name), { withFileTypes: true })) {
if (!pkg.isDirectory()) continue
if (localArtifactDirs.has(pkg.name)) continue
const pkgRel = join(groupRel, pkg.name)
if (!existsSync(join(packagesRoot, group.name, pkg.name, 'package.json'))) {
errors.push(`${pkgRel}: expected a package here (no package.json found) — the hierarchy is exactly packages/<group>/<pkg>, no deeper nesting`)

View File

@@ -75,6 +75,15 @@ const LINK_MAP: Record<string, string> = {
BashRunResult: 'bash.md',
BashTask: 'bash.md',
BashTaskRead: 'bash.md',
FsEditOutcome: 'filesystem.md',
FsEditRequest: 'filesystem.md',
FsInfo: 'filesystem.md',
FsTarget: 'filesystem.md',
FsVersion: 'filesystem.md',
FsWriteIntent: 'filesystem.md',
FsWriteOutcome: 'filesystem.md',
FsPolicyExec: 'filesystem.md',
FileReadOutcome: 'filesystem.md',
}
/** One harness event, extracted from an `interface Events` block. */

View File

@@ -53,6 +53,7 @@ interface ServiceRole {
mode: 'core' | 'seam' | 'bundle'
implementations?: string[]
consumers?: string[]
companions?: string[]
note: string
}
@@ -73,7 +74,21 @@ interface ToolPackageMeta {
note: string
}
const GROUP_ORDER = ['util', 'llm', 'core', 'bash', 'compact', 'subagent', 'session-persistence', 'todo', 'support', 'ui']
const GROUP_ORDER = [
'util',
'llm',
'core',
'bash',
'fs',
'compact',
'subagent',
'web',
'todo',
'hooks',
'session-persistence',
'support',
'ui',
]
const SERVICE_ROLES: ServiceRole[] = [
{
@@ -107,7 +122,7 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'system-prompt',
title: 'System prompt assembly registry',
mode: 'core',
consumers: ['agent-loop', 'tools'],
consumers: ['agent-loop', 'tools', 'tool-fs', 'tool-web'],
note: 'Collects prompt sections and model-facing tool schemas for each step.',
},
{
@@ -115,8 +130,8 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'tools',
title: 'Tool registry and execution waterfall',
mode: 'core',
consumers: ['agent-loop', 'tool-bash', 'tool-subagent', 'tool-todo', 'acp'],
note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/execute.',
consumers: ['agent-loop', 'tool-bash', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.',
},
{
key: 'agents',
@@ -140,8 +155,18 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Bash executor seam',
mode: 'seam',
implementations: ['bash-local'],
consumers: ['tool-bash'],
note: 'The model-facing bash tools consume this seam; sandboxed or remote executors can replace bash-local.',
consumers: ['tool-bash', 'hooks-claude', 'hooks-codex'],
note: 'The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local.',
},
{
key: 'fs',
pkg: 'fs',
title: 'Filesystem provider seam',
mode: 'seam',
implementations: ['fs-local'],
consumers: ['tool-fs'],
companions: ['fs-policy'],
note: 'tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate.',
},
{
key: 'compact',
@@ -161,6 +186,15 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['tool-subagent'],
note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.',
},
{
key: 'web',
pkg: 'web',
title: 'Web access provider registry',
mode: 'seam',
implementations: ['web-search-exa', 'web-search-perplexity', 'web-search-deepseek', 'web-fetch-local'],
consumers: ['tool-web'],
note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.',
},
]
const TOOL_PACKAGE_META: Record<string, ToolPackageMeta> = {
@@ -169,6 +203,11 @@ const TOOL_PACKAGE_META: Record<string, ToolPackageMeta> = {
writes: ['tool/call', 'tool/result', 'context/message via agent.inject() for background completion notices'],
note: 'The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam.',
},
'@deepseek-ai/dsh-tool-fs': {
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'],
note: 'read/write/edit are the model-facing filesystem tools; read windowing lives here, while read-before-edit policy is supplied by fs-policy through fs/* events.',
},
'@deepseek-ai/dsh-tool-subagent': {
requires: ['ctx.tools', 'ctx.subagents'],
writes: ['tool/call', 'tool/result', 'child session events through the chosen provider'],
@@ -180,6 +219,11 @@ const TOOL_PACKAGE_META: Record<string, ToolPackageMeta> = {
writes: ['tool/call', 'todo/write', 'tool/result'],
note: 'todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan.',
},
'@deepseek-ai/dsh-tool-web': {
requires: ['ctx.tools', 'ctx.web', 'ctx.systemPrompt'],
writes: ['tool/call', 'tool/result'],
note: 'web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps.',
},
}
const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [
@@ -331,6 +375,7 @@ function renderCapabilitySeams(pkgs: Pkg[]): string {
const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
const nodes = new Map<string, string>()
const edges = new Set<string>()
const companionEdges = new Set<string>()
const addNode = (id: string, label: string): void => {
if (!nodes.has(id)) nodes.set(id, ` ${id}["${escLabel(label)}"]`)
}
@@ -356,11 +401,15 @@ function renderCapabilitySeams(pkgs: Pkg[]): string {
addNode(nodeId('pkg', consumer), consumer)
addEdge(svc, nodeId('pkg', consumer))
}
for (const companion of role.companions ?? []) {
addNode(nodeId('pkg', companion), companion)
companionEdges.add(` ${svc} -. event gate .-> ${nodeId('pkg', companion)}`)
}
}
lines.push(...nodes.values(), ...[...edges].sort())
lines.push('```', '', '| ctx key | Role | Owner | Implementations | Direct consumers | Note |', '| --- | --- | --- | --- | --- | --- |')
lines.push(...nodes.values(), ...[...edges].sort(), ...[...companionEdges].sort())
lines.push('```', '', '| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |', '| --- | --- | --- | --- | --- | --- | --- |')
for (const role of SERVICE_ROLES) {
lines.push(`| \`ctx.${role.key}\` | \`${role.mode}\` | ${pkgLink(pkgsByShort.get(role.pkg), role.pkg)} | ${pkgList(role.implementations, pkgsByShort)} | ${pkgList(role.consumers, pkgsByShort)} | ${tableCell(role.note)} |`)
lines.push(`| \`ctx.${role.key}\` | \`${role.mode}\` | ${pkgLink(pkgsByShort.get(role.pkg), role.pkg)} | ${pkgList(role.implementations, pkgsByShort)} | ${pkgList(role.consumers, pkgsByShort)} | ${pkgList(role.companions, pkgsByShort)} | ${tableCell(role.note)} |`)
}
lines.push('')
return lines.join('\n')
@@ -592,40 +641,48 @@ async function renderToolAffordance(): Promise<string> {
function renderLifecycle(): string {
return [
...generatedHeader('Agent Turn And Step Lifecycle', 'curated Mermaid sequence; exact event signatures live in the generated Cordis catalog'),
'This sequence is the visual companion to [architecture.md](../architecture.md#loop-lifecycle-session--turn--step). It shows the durable session event path separately from live `agent/*` notifications.',
'This sequence is the visual companion to [architecture.md](../architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
'',
'```mermaid',
'sequenceDiagram',
' participant User',
' participant Agent',
' participant Driver',
' participant Hooks as hook listeners',
' participant Prompt as ctx.systemPrompt',
' participant LLM as ctx.llm',
' participant Tools as ctx.tools',
' participant Session',
' participant Persistence',
' participant SDK as UI or SDK listener',
' User->>Agent: send(content)',
' Agent-->>SDK: agent/queued',
' Agent->>Driver: queued work wakes driver',
' Driver->>Session: turn/start + user/message',
' Driver-->>User: agent/turn-start',
' Driver-->>SDK: agent/status running',
' Driver->>Session: turn/start',
' Driver->>Hooks: agent/prompt-submit waterfall',
' Hooks-->>Driver: allow, block, or add context',
' Driver->>Session: user/message or rejected turn/end',
' Driver->>Prompt: system-prompt/assemble waterfall',
' Driver-->>Driver: agent/pre-step serial checkpoint',
' Driver->>Session: step/start',
' Driver->>LLM: agent/request waterfall, then llm/stream waterfall',
' LLM-->>Driver: StreamChunk*',
' Driver->>Session: assistant/chunk*',
' Driver-->>User: agent/stream-chunk* (master live mirror)',
' Session-->>SDK: session/event assistant/chunk*',
' Driver->>Hooks: agent/step-result waterfall',
' Driver->>Session: assistant/message',
' Driver->>Tools: tools/execute waterfall for each tool-call',
' Driver->>Session: tool/call',
' Driver->>Tools: execute through pre and post waterfalls',
' Tools-->>Session: tool-owned events when applicable',
' Driver->>Session: tool/result',
' Driver-->>Driver: agent/turn-continuation waterfall',
' Driver->>Session: tool/result and step/end',
' Driver->>Hooks: agent/turn-continuation waterfall',
' Driver->>Session: turn/end',
' Driver->>Persistence: session/flush parallel checkpoint',
' Driver-->>User: agent/status idle',
' Driver-->>SDK: agent/status idle',
'```',
'',
'Future pressure from the hooks stack: PR #129 removes the live `agent/stream-chunk` mirror and leaves durable `assistant/chunk` on `session/event` as the authoritative token stream. Consumers that need replayable transcript data should already treat `session/event` as the load-bearing path.',
'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.',
'',
].join('\n')
}
@@ -633,29 +690,38 @@ function renderLifecycle(): string {
function renderToolPipeline(): string {
return [
...generatedHeader('Tool Execution Pipeline', 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'),
'This graph shows where policy, hooks, sandboxing, and future filesystem guards fit without changing the loop. The key extension point is the `tools/execute` waterfall.',
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute` and `tools/post-execute` waterfalls.',
'',
'```mermaid',
'flowchart TD',
' model["Assistant message contains tool-call block"]',
' toolCall["Session event: tool/call"]',
' waterfall["ctx.tools.execute()<br/>tools/execute waterfall"]',
' policy["Policy / permission / hooks listener"]',
' toolCall["Session event: tool/call<br/>logged before execution"]',
' presentCall["UI pending card<br/>presentCall(args)"]',
' pre["tools/pre-execute waterfall<br/>hooks, permission, sandbox"]',
' denied["deny or ask<br/>tool body skipped"]',
' toolBody["Registered tool execute() body"]',
' owned["Tool-owned session events<br/>todo/write, future fs policy facts"]',
' toolResult["Session event: tool/result"]',
' ui["UI presentation<br/>presentCall / presentResult"]',
' model --> toolCall --> waterfall',
' waterfall --> policy',
' policy -->|next| toolBody',
' policy -->|veto / throw| toolResult',
' fsGate["fs/write-intent or fs/edit-intent<br/>tool-fs mutations only"]',
' owned["Tool-owned session events<br/>todo/write, fs/observed, hook/invoked, hook/result"]',
' post["tools/post-execute waterfall<br/>accept, block, replace, add context"]',
' context["Buffered additionalContext<br/>context/message after all tool results"]',
' toolResult["Session event: tool/result<br/>single model-facing outcome"]',
' presentResult["UI completed card<br/>presentResult(args, result)"]',
' model --> toolCall',
' toolCall --> presentCall',
' toolCall --> pre',
' pre -->|allow| toolBody',
' pre -->|deny or ask| denied',
' denied --> post',
' toolBody --> fsGate',
' fsGate --> toolBody',
' toolBody --> owned',
' toolBody --> toolResult',
' toolCall --> ui',
' toolResult --> ui',
' toolBody --> post',
' post --> context',
' post --> toolResult',
' toolResult --> presentResult',
'```',
'',
'Future pressure from the fs stack: PR #128 snapshots a policy rejection card. The graph keeps the veto path explicit because filesystem read-before-edit checks, permission prompts, and hook bridges all belong on this path.',
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate, while hook bridges and future permission prompts live on the generic tool waterfalls. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.',
'',
].join('\n')
}
@@ -739,23 +805,26 @@ function renderHotReload(): string {
function renderSnapshotReplay(): string {
return [
...generatedHeader('ACP Snapshot Replay', 'curated Mermaid sequence based on the snapshot test harness'),
'This graph explains what a snapshot scenario proves: recorded real-model session logs are replayed keylessly, then ACP stdout is normalized and diffed.',
'This graph explains what a snapshot scenario proves: recorded real-model session logs are replayed keylessly, ACP stdout is normalized and diffed, and scenario workspaces preserve tool side effects that the UI stream alone cannot prove.',
'',
'```mermaid',
'sequenceDiagram',
' participant Recorder as Real API recording',
' participant Fixture as snapshot fixture',
' participant Workspace',
' participant Replay as llm-replay adapter',
' participant ACP as acp-agent subprocess',
' participant Golden as stdout golden',
' Recorder->>Fixture: session.jsonl + workspace inputs',
' Fixture->>Workspace: seed files and hook configs',
' Fixture->>Replay: recorded StreamChunk script',
' Replay->>ACP: deterministic llm/stream chunks',
' ACP->>Workspace: bash, fs, and hook side effects',
' ACP->>Golden: normalized sessionUpdate stream',
' Golden-->>ACP: diff must be empty',
'```',
'',
'Future pressure from the fs stack: policy rejection scenarios are valuable because they prove both world state and failed tool-card rendering, not just that replay returns text.',
'The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.',
'',
].join('\n')
}

View File

@@ -40,11 +40,17 @@ import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
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 * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/tool-catalog/tools.md'
@@ -97,6 +103,20 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolBash)
},
},
{
pkg: '@deepseek-ai/dsh-tool-fs',
dir: 'tool-fs',
source: 'packages/fs/tool-fs/src/index.ts',
async mount(ctx) {
// The tool injects `fs`; boot the local backend to satisfy it. The schemas
// do not depend on the policy plugin (an event gate that changes behavior,
// not tool shape), so the bare provider is enough to harvest them.
await ctx.plugin(LocalFileSystem)
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.',
},
{
pkg: '@deepseek-ai/dsh-tool-subagent',
dir: 'tool-subagent',
@@ -118,6 +138,21 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolTodo)
},
},
{
pkg: '@deepseek-ai/dsh-tool-web',
dir: 'tool-web',
source: 'packages/web/tool-web/src/index.ts',
async mount(ctx) {
// The tools inject `web`; boot the seam plus one search and one fetch
// provider so both `web_search` and `web_fetch` register. The schemas do
// not depend on which provider backs the seam (or on it being available),
// so any registered provider is enough to harvest them.
await ctx.plugin(WebService)
await ctx.plugin(WebSearchExa)
await ctx.plugin(WebFetchLocal)
await ctx.plugin(ToolWeb)
},
},
]
/** One package's contribution to the catalog: its schemas plus attribution. */

View File

@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process'
import { readdirSync } from 'node:fs'
import { existsSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
// publint every harness package. Packages live at packages/<group>/<pkg>
@@ -14,6 +14,7 @@ const packages = readdirSync(packagesRoot, { withFileTypes: true })
.flatMap(group =>
readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true })
.filter(pkg => pkg.isDirectory())
.filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json')))
.map(pkg => `packages/${group.name}/${pkg.name}`),
)

View File

@@ -0,0 +1,16 @@
{
"required": [
"README.md",
"docs/development.md",
"docs/i18n/README.md",
"docs/i18n/translation-rules.md",
"docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md"
],
"excluded": [
"docs/AGENTS.md",
"docs/module-graph.md",
"docs/cordis-catalog/",
"docs/tool-catalog/",
"docs/i18n/terminology.md"
]
}

View File

@@ -10,6 +10,10 @@
{ "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" },
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" },
@@ -34,6 +38,8 @@
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },
@@ -42,6 +48,19 @@
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTargetKey", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsVersion", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsInfo", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsDirEntry", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteIntent", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsWriteOutcome", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditRequest", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsEditOutcome", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsErrorCode", "source": "packages/fs/fs/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsPolicyExec", "source": "packages/fs/fs-policy/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FileReadOutcome", "source": "packages/fs/tool-fs/src/read-render.ts" },
{ "doc": "docs/core-data-structures/compaction.md", "symbol": "CompactionResult", "source": "packages/compact/compact/src/types.ts" },
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" },
@@ -49,6 +68,15 @@
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" },
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" },
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" },
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" }
{ "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchRequest", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchResult", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebSearchSource", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebCapabilityStatus", "source": "packages/web/web/src/types.ts" }
]
}

View File

@@ -48,6 +48,7 @@ const root = resolve(import.meta.dirname, '..')
*/
const PATTERNS = [
'README.md',
'README.zh.md',
'docs/**/*.md',
'packages/*/*.md',
'packages/*/*/*.md',

View File

@@ -36,7 +36,7 @@ import type { Nodes } from 'mdast'
const root = resolve(import.meta.dirname, '..')
/** Files to check: doc-typecheck's scope plus the AGENTS.md pair. */
const PATTERNS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md']
const PATTERNS = ['README.md', 'README.zh.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md']
/** A located hard-wrap: a prose paragraph spanning more than one source line. */
interface Violation {

View File

@@ -68,6 +68,9 @@ for (const lifecycle of LIFECYCLES) {
const segs = match.split('/')
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
// indexed via its English filename; the pairing gate owns its consistency.
if (match.endsWith('.zh.md')) continue
const cls = segs[1]
const base = segs[2]
if (segs.length !== 3 || cls === undefined || base === undefined) {

View File

@@ -0,0 +1,335 @@
/**
* Doc-sync gate: enforce the bilingual pairing contract (docs/i18n/README.md).
* English and Chinese carry EQUAL authority — either language may be authored
* first — so consistency is recorded per pair in a sidecar metadata file,
* `foo.i18n.yaml`, holding the full git blob hash of BOTH files as of the last
* time a human confirmed the two say the same thing:
*
* foo.md: <40-hex blob hash>
* foo.zh.md: <40-hex blob hash>
*
* The gate checks, mechanically, the checkable half of the contract:
*
* 1. Every file in the manifest's `required` list has a COMPLETE pair
* (the enforcement frontier — grows batch by batch).
* 2. Every pair that exists at all is complete and consistent: all three
* files present (a `.zh.md` or a `.i18n.yaml` without its counterparts
* is an error — pairs merge whole, never half), each side's current
* blob hash equals the recorded one (an edit to EITHER side without a
* re-confirmed counterpart goes red), both sides carry the language
* switcher, and the structural signatures match one to one — heading
* depths in order, fenced code blocks VERBATIM (info string + content),
* table column counts, list kinds, and every link target except the
* switcher itself.
* 3. `excluded` files (generated docs, agent instructions, the bilingual
* terminology table) have no `.zh.md` and no `.i18n.yaml` at all.
*
* What it deliberately does NOT check is translation quality or which side
* is "right": a green gate means the pair was confirmed consistent at these
* exact contents, not that the confirmation was sound — accuracy,
* terminology, and tone are the human reviewer's half of the contract
* (docs/i18n/translation-rules.md).
*
* Blob hashes, not commit hashes, so a pair edited in the same PR verifies
* without any history lookup: consistency is a pure content comparison,
* computed here directly (sha1 of `blob <size>\0<content>`) without spawning
* git. The recorded hash also recovers the last-confirmed text of either
* side (`git cat-file -p <hash>`) for diff-based minimal updates.
*
* Run: `tsx scripts/verify-translation-pairing.ts` — or with `--list` to
* print the pairing state of every in-scope document as a work list (always
* exits 0), or with `--write` to (re)record both hashes for every complete
* pair after you have brought the two sides back in line (the resulting
* yaml diff is the reviewable act of confirming consistency).
*/
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, join, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
import type { Nodes } from 'mdast'
const root = resolve(import.meta.dirname, '..')
const listMode = process.argv.includes('--list')
const writeMode = process.argv.includes('--write')
/** Scope of the bilingual contract: the root README and the docs tree. */
const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml']
/** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */
interface Manifest {
required: string[]
excluded: string[]
}
const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) as Manifest
/**
* An excluded entry ending in `/` excludes the whole directory. The trailing
* slash IS the path boundary — `docs/tool-catalog/` cannot prefix-match a
* sibling like `docs/tool-catalog-notes/x.md` — so directory entries in the
* manifest must keep their trailing slash.
*/
function isExcluded(file: string): boolean {
return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
}
/** Full git blob hash (what `git hash-object` prints). */
function blobHash(content: Buffer): string {
const hash = createHash('sha1')
hash.update(`blob ${content.byteLength}\0`)
hash.update(content)
return hash.digest('hex')
}
/** The three paths of a pair, derived from the English-file path. */
function pairPaths(source: string): { zh: string; meta: string } {
return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') }
}
const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
/** Parse a `foo.i18n.yaml` consistency record: basename → recorded blob hash. */
function parseMeta(content: string): Map<string, string> | undefined {
const out = new Map<string, string>()
for (const line of content.split('\n')) {
if (line === '' || line.startsWith('#')) continue
const match = META_LINE.exec(line)
if (!match?.[1] || !match[2]) return undefined
out.set(match[1], match[2])
}
return out
}
/** Render a `foo.i18n.yaml` consistency record. */
function renderMeta(source: string, sourceHash: string, zh: string, zhHash: string): string {
return [
'# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each',
'# side as of the last confirmed-consistent state. Both languages carry equal authority;',
'# after editing either side, bring the other along and re-record with:',
'# pnpm run verify-translation-pairing --write',
`${basename(source)}: ${sourceHash}`,
`${basename(zh)}: ${zhHash}`,
'',
].join('\n')
}
/**
* The structural signature the two sides must share, as ordered sequences so
* a swap or a level change is caught, not just a count change. Prose is
* deliberately absent: the gate checks shape, never wording.
*/
interface Signature {
/** Heading depths in document order (h2 → 2). */
headings: number[]
/** Fenced code blocks verbatim: info string + content, in order. */
code: string[]
/** Column count of each table, in order. */
tables: number[]
/** Each list's kind (ordered vs bullet), in order. */
lists: string[]
/** Every link target in order, the language switcher's excluded. */
links: string[]
}
/** Whether the tree contains a link to exactly `target` (the switcher check). */
function linksTo(tree: Nodes, target: string): boolean {
let found = false
const visit = (node: Nodes): void => {
if (node.type === 'link' && node.url === target) found = true
if ('children' in node) for (const child of node.children) visit(child)
}
visit(tree)
return found
}
/** Collect the structural signature, skipping links to `switcherTarget`. */
function signatureOf(tree: Nodes, switcherTarget: string): Signature {
const sig: Signature = { headings: [], code: [], tables: [], lists: [], links: [] }
const visit = (node: Nodes): void => {
switch (node.type) {
case 'heading':
sig.headings.push(node.depth)
break
case 'code':
sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
break
case 'table':
sig.tables.push(node.children[0]?.children.length ?? 0)
break
case 'list':
sig.lists.push(node.ordered ? 'ordered' : 'bullet')
break
case 'link':
if (node.url !== switcherTarget) sig.links.push(node.url)
break
default:
// Every other node kind is prose or container — not part of the signature.
break
}
if ('children' in node) for (const child of node.children) visit(child)
}
visit(tree)
return sig
}
/** Render a signature element for an error message, truncated for readability. */
function show(value: string | number | undefined): string {
if (value === undefined) return 'nothing'
const text = JSON.stringify(value)
return text.length > 72 ? `${text.slice(0, 72)}` : text
}
/** First divergence between two signatures, as messages; empty when identical. */
function signatureDiff(source: Signature, zh: Signature): string[] {
const out: string[] = []
const fields: [string, (string | number)[], (string | number)[]][] = [
['heading (depth)', source.headings, zh.headings],
['code block', source.code, zh.code],
['table (column count)', source.tables, zh.tables],
['list (kind)', source.lists, zh.lists],
['link target', source.links, zh.links],
]
for (const [field, s, z] of fields) {
const length = Math.max(s.length, z.length)
for (let i = 0; i < length; i++) {
if (s[i] !== z[i]) {
out.push(`${field} #${i + 1} diverges between the pair: ${show(s[i])} vs ${show(z[i])}`)
break
}
}
}
return out
}
function parse(content: string): Nodes {
return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
}
// Enumerate the scope once.
const files = new Set<string>()
for (const pattern of SCOPE_PATTERNS) {
for await (const match of glob(pattern, { cwd: root })) files.add(match)
}
const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort()
// --write: (re)record both hashes for every complete pair, creating missing records.
if (writeMode) {
let written = 0
for (const source of sources) {
if (isExcluded(source)) continue
const { zh, meta } = pairPaths(source)
if (!existsSync(join(root, zh))) continue
const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh))))
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
writeFileSync(join(root, meta), record)
console.log(`verify-translation-pairing: recorded ${meta}`)
written++
}
console.log(`verify-translation-pairing: ${written} record(s) written; run the check to validate the pairs.`)
process.exit(0)
}
const errors: string[] = []
const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
// 1. Required pairs exist.
for (const req of manifest.required) {
if (!existsSync(join(root, req))) {
errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`)
continue
}
const { zh } = pairPaths(req)
if (!existsSync(join(root, zh))) {
errors.push(`${req}: required to have a translation, but ${zh} does not exist`)
state.set(req, 'missing')
}
}
// 2. Every pair that exists at all is complete and consistent. Anchor on the
// union of .zh.md files and .i18n.yaml records so a half-deleted pair is
// caught from either remnant.
const pairAnchors = new Set<string>()
for (const zh of translations) pairAnchors.add(zh.replace(/\.zh\.md$/, '.md'))
for (const meta of metas) pairAnchors.add(meta.replace(/\.i18n\.yaml$/, '.md'))
for (const source of [...pairAnchors].sort()) {
const { zh, meta } = pairPaths(source)
const have = { source: existsSync(join(root, source)), zh: existsSync(join(root, zh)), meta: existsSync(join(root, meta)) }
if (isExcluded(source)) {
if (have.zh) errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`)
if (have.meta) errors.push(`${meta}: ${source} is excluded from pairing; this consistency record must not exist`)
continue
}
const missing = Object.entries(have).filter(([, ok]) => !ok).map(([k]) => (k === 'source' ? source : k === 'zh' ? zh : meta))
if (missing.length > 0) {
errors.push(`${source}: incomplete pair — missing ${missing.join(', ')} (pairs merge whole: both languages plus the .i18n.yaml record)`)
continue
}
const sourceContent = readFileSync(join(root, source))
const zhContent = readFileSync(join(root, zh))
const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
if (!record || record.size !== 2 || !record.has(basename(source)) || !record.has(basename(zh))) {
errors.push(`${meta}: malformed consistency record (expected exactly \`${basename(source)}: <40-hex>\` and \`${basename(zh)}: <40-hex>\`)`)
continue
}
let consistent = true
for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) {
const current = blobHash(content)
if (record.get(basename(file)) !== current) {
errors.push(`${file}: out of sync — content no longer matches the pair's last confirmed-consistent state in ${meta} (bring the other side along, then re-record with --write)`)
consistent = false
}
}
if (!consistent) {
state.set(source, 'out-of-sync')
continue
}
const sourceTree = parse(sourceContent.toString('utf8'))
const zhTree = parse(zhContent.toString('utf8'))
if (!linksTo(zhTree, basename(source))) {
errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
}
if (!linksTo(sourceTree, basename(zh))) {
errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
}
for (const divergence of signatureDiff(signatureOf(sourceTree, basename(zh)), signatureOf(zhTree, basename(source)))) {
errors.push(`${source}${zh}: ${divergence}`)
}
if (!state.has(source)) state.set(source, 'ok')
}
// Complete the state map for --list: any in-scope, non-excluded document with no pair yet is backlog.
for (const source of sources) {
if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing')
}
if (listMode) {
const order = { 'out-of-sync': 0, missing: 1, ok: 2 } as const
const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
for (const [file, status] of rows) {
const required = manifest.required.includes(file)
console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? (required ? ' (required)' : ' (backlog)') : ''}`)
}
const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }
for (const status of state.values()) counts[status]++
console.log(`verify-translation-pairing: ${counts.ok} ok, ${counts['out-of-sync']} out-of-sync, ${counts.missing} missing (of ${state.size} in scope)`)
process.exit(0)
}
if (errors.length === 0) {
console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked against ${manifest.required.length} required, all consistent.`)
process.exit(0)
}
console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):')
for (const message of errors) console.error(` ${message}`)
process.exit(1)