Merge remote-tracking branch 'origin/master' into worktree/replayable-gate-plans-20260727

This commit is contained in:
Tianyi Cui
2026-07-28 15:34:30 +08:00
470 changed files with 11071 additions and 1636 deletions

View File

@@ -4,11 +4,11 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { writeChangeScope } from './change-scope.ts'
import { renderChangeScope } from './change-scope.ts'
interface Report {
formatVersion: number
repository: { root: string; branch: string | null; upstream: string | null }
repositoryRoot: string
input: { base: string; head: string }
resolved: { baseSha: string; headSha: string; mergeBaseSha: string }
paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] }
@@ -17,7 +17,6 @@ interface Report {
interface Fixture {
container: string
root: string
origin: string
}
const fixtureRoots: string[] = []
@@ -66,7 +65,7 @@ function fixture(worktreeName = 'worktree'): Fixture {
git(root, ['commit', '-m', 'initial'])
git(root, ['remote', 'add', 'origin', origin])
git(root, ['push', '--set-upstream', 'origin', 'master'])
return { container, root, origin }
return { container, root }
}
function commit(root: string, path: string, content: string): string {
@@ -77,42 +76,15 @@ function commit(root: string, path: string, content: string): string {
}
function invoke(root: string, args: string[]): string {
const output: string[] = []
writeChangeScope(args, root, chunk => output.push(chunk))
expect(output).toHaveLength(1)
return output[0] as string
return renderChangeScope(args, root)
}
function jsonReport(root: string, base: string, head?: string): Report {
const args = ['--base', base, '--json']
const args = ['--base', base]
if (head !== undefined) args.push('--head', head)
return JSON.parse(invoke(root, args)) as Report
}
function formatHumanFromJson(report: Report): string {
const value = (input: string | null): string => JSON.stringify(input) ?? 'null'
const paths = (label: string, entries: string[]): string[] => [
`${label} (${entries.length}):`,
...(entries.length === 0 ? [' (none)'] : entries.map(entry => ` - ${value(entry)}`)),
]
return [
`Format version: ${report.formatVersion}`,
`Repository root: ${value(report.repository.root)}`,
`Branch: ${value(report.repository.branch)}`,
`Upstream: ${value(report.repository.upstream)}`,
`Base ref: ${value(report.input.base)}`,
`Head ref: ${value(report.input.head)}`,
`Base commit: ${report.resolved.baseSha}`,
`Head commit: ${report.resolved.headSha}`,
`Merge base: ${report.resolved.mergeBaseSha}`,
...paths('Committed paths', report.paths.committed),
...paths('Staged paths', report.paths.staged),
...paths('Unstaged paths', report.paths.unstaged),
...paths('Untracked paths', report.paths.untracked),
'',
].join('\n')
}
function repositoryState(root: string): Record<string, string> {
const status = git(root, ['status', '--porcelain=v2', '--branch', '-z'])
return {
@@ -132,7 +104,7 @@ describe('change-scope', () => {
const headSha = commit(root, 'feature.txt', 'feature\n')
const fresh = jsonReport(root, 'origin/master')
expect(fresh.repository).toEqual({ root: realpathSync(root), branch: 'feature', upstream: 'origin/master' })
expect(fresh.repositoryRoot).toBe(realpathSync(root))
expect(fresh.resolved).toEqual({
baseSha: git(root, ['rev-parse', 'origin/master']),
headSha,
@@ -143,7 +115,6 @@ describe('change-scope', () => {
git(root, ['push', '--set-upstream', 'origin', 'feature'])
const pushed = jsonReport(root, 'origin/master')
expect(pushed.repository.upstream).toBe('origin/feature')
expect(pushed.paths.committed).toEqual(['feature.txt'])
})
@@ -151,24 +122,10 @@ describe('change-scope', () => {
const { root } = fixture('worktree ')
const report = jsonReport(root, 'HEAD')
expect(report.repository.root).toBe(realpathSync(root))
expect(report.repositoryRoot).toBe(realpathSync(root))
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
})
it('preserves legal Unicode edge whitespace in branch and upstream names', () => {
const { root } = fixture()
const branch = '\u00a0topic\u3000'
const upstreamBranch = '\u3000upstream\u00a0'
git(root, ['switch', '-c', branch])
git(root, ['push', 'origin', `HEAD:refs/heads/${upstreamBranch}`])
git(root, ['branch', '--set-upstream-to', `origin/${upstreamBranch}`])
const report = jsonReport(root, 'origin/master')
expect(report.repository.branch).toBe(branch)
expect(report.repository.upstream).toBe(`origin/${upstreamBranch}`)
})
it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => {
const { root } = fixture()
git(root, ['switch', '-c', 'foundation'])
@@ -222,55 +179,7 @@ describe('change-scope', () => {
expect(existsSync(sideEffect)).toBe(false)
})
it.skipIf(process.platform === 'win32')('rejects non-UTF-8 branch and upstream names without partial output', () => {
const invalidBranch = fixture()
const branchHead = git(invalidBranch.root, ['rev-parse', 'HEAD'])
const invalidBranchName = Buffer.from([0x80])
writeFileSync(join(invalidBranch.root, '.git/packed-refs'), Buffer.concat([
Buffer.from(`${branchHead} refs/heads/`),
invalidBranchName,
Buffer.from('\n'),
]))
writeFileSync(
join(invalidBranch.root, '.git/HEAD'),
Buffer.concat([Buffer.from('ref: refs/heads/'), invalidBranchName, Buffer.from('\n')]),
)
const branchOutput: string[] = []
expect(() => {
writeChangeScope(['--base', branchHead, '--json'], invalidBranch.root, chunk => branchOutput.push(chunk))
}).toThrow('cannot inspect the current branch: Git stdout is not valid UTF-8')
expect(branchOutput).toEqual([])
const invalidUpstream = fixture()
const upstreamHead = git(invalidUpstream.root, ['rev-parse', 'HEAD'])
const invalidUpstreamName = Buffer.from([0x81])
writeFileSync(join(invalidUpstream.root, '.git/packed-refs'), Buffer.concat([
Buffer.from(`${upstreamHead} refs/remotes/origin/`),
invalidUpstreamName,
Buffer.from('\n'),
]))
const configPath = join(invalidUpstream.root, '.git/config')
const config = readFileSync(configPath)
const merge = Buffer.from('\tmerge = refs/heads/master\n')
const mergeIndex = config.indexOf(merge)
expect(mergeIndex).toBeGreaterThanOrEqual(0)
writeFileSync(configPath, Buffer.concat([
config.subarray(0, mergeIndex),
Buffer.from('\tmerge = refs/heads/'),
invalidUpstreamName,
Buffer.from('\n'),
config.subarray(mergeIndex + merge.length),
]))
const upstreamOutput: string[] = []
expect(() => {
writeChangeScope(['--base', upstreamHead, '--json'], invalidUpstream.root, chunk => upstreamOutput.push(chunk))
}).toThrow('cannot inspect the configured upstream: Git stdout is not valid UTF-8')
expect(upstreamOutput).toEqual([])
})
it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths without partial output', () => {
it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths', () => {
const { root } = fixture()
const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content')
const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii')
@@ -290,15 +199,12 @@ describe('change-scope', () => {
secondPath,
Buffer.from([0]),
]))
const output: string[] = []
expect(() => {
writeChangeScope(['--base', 'HEAD', '--json'], root, chunk => output.push(chunk))
renderChangeScope(['--base', 'HEAD'], root)
}).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8')
expect(output).toEqual([])
})
it('rejects missing, ambiguous, and non-commit refs before writing output', () => {
it('rejects missing, ambiguous, and non-commit refs', () => {
const { root } = fixture()
git(root, ['branch', 'collision'])
git(root, ['tag', 'collision'])
@@ -312,32 +218,24 @@ describe('change-scope', () => {
{ args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u },
{ args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u },
]) {
const output: string[] = []
expect(() => {
writeChangeScope(args, root, (chunk) => {
output.push(chunk)
})
renderChangeScope(args, root)
}).toThrow(message)
expect(output).toEqual([])
}
})
it('renders deterministic human and JSON forms with the same facts', () => {
it('renders deterministic versioned JSON', () => {
const { root } = fixture()
git(root, ['switch', '-c', 'format'])
commit(root, 'zeta.txt', 'zeta\n')
commit(root, 'alpha.txt', 'alpha\n')
const json = invoke(root, ['--base', 'origin/master', '--json'])
const repeatedJson = invoke(root, ['--base', 'origin/master', '--json'])
const human = invoke(root, ['--base', 'origin/master'])
const repeatedHuman = invoke(root, ['--base', 'origin/master'])
const json = invoke(root, ['--base', 'origin/master'])
const repeatedJson = invoke(root, ['--base', 'origin/master'])
const report = JSON.parse(json) as Report
expect(json).toBe(repeatedJson)
expect(report.formatVersion).toBe(1)
expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt'])
expect(human).toBe(repeatedHuman)
expect(human).toBe(formatHumanFromJson(report))
})
})

View File

@@ -11,11 +11,7 @@ const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
interface ChangeScopeReport {
formatVersion: typeof FORMAT_VERSION
repository: {
root: string
branch: string | null
upstream: string | null
}
repositoryRoot: string
input: {
base: string
head: string
@@ -50,7 +46,6 @@ interface GitBytesCommandResult {
interface ChangeScopeOptions {
base: string
head: string
json: boolean
}
function executeGit(cwd: string, args: string[], context: string): GitCommandResult {
@@ -111,12 +106,11 @@ function parseOptions(args: string[]): ChangeScopeOptions {
options: {
base: { type: 'string' },
head: { type: 'string', default: 'HEAD' },
json: { type: 'boolean', default: false },
},
strict: true,
})
if (values.base === undefined) throw new Error('missing required --base <ref>')
return { base: values.base, head: values.head, json: values.json }
return { base: values.base, head: values.head }
}
function resolveCommit(root: string, label: 'base' | 'head', ref: string): string {
@@ -158,33 +152,6 @@ function resolveMergeBase(root: string, baseSha: string, headSha: string): strin
return mergeBases[0] as string
}
function currentBranch(root: string): string | null {
const result = executeGit(
root,
['symbolic-ref', '--quiet', '--short', 'HEAD'],
'cannot inspect the current branch',
)
if (result.status === 1) return null
if (result.status !== 0) throw new Error(`cannot inspect the current branch: ${failureDetail(result)}`)
return stripGitLineTerminator(result.stdout)
}
function configuredUpstream(root: string, branch: string | null): string | null {
if (branch === null) return null
const output = stripGitLineTerminator(requireGit(
root,
['for-each-ref', '--count=1', '--format=%(upstream:short)', `refs/heads/${branch}`],
'cannot inspect the configured upstream',
))
return output === '' ? null : output
}
function comparePaths(left: string, right: string): number {
if (left < right) return -1
if (left > right) return 1
return 0
}
function parsePathSet(output: Buffer, context: string): string[] {
const paths: string[] = []
let start = 0
@@ -201,7 +168,7 @@ function parsePathSet(output: Buffer, context: string): string[] {
}
start = end + 1
}
return [...new Set(paths)].sort(comparePaths)
return [...new Set(paths)].sort()
}
function diffPaths(root: string, args: string[], context: string): string[] {
@@ -232,14 +199,9 @@ function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeRep
const baseSha = resolveCommit(root, 'base', options.base)
const headSha = resolveCommit(root, 'head', options.head)
const mergeBaseSha = resolveMergeBase(root, baseSha, headSha)
const branch = currentBranch(root)
return {
formatVersion: FORMAT_VERSION,
repository: {
root,
branch,
upstream: configuredUpstream(root, branch),
},
repositoryRoot: root,
input: {
base: options.base,
head: options.head,
@@ -262,56 +224,22 @@ function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeRep
}
}
function formatValue(value: string | null): string {
return JSON.stringify(value)
}
function formatPaths(label: string, paths: string[]): string[] {
return [
`${label} (${paths.length}):`,
...(paths.length === 0 ? [' (none)'] : paths.map(path => ` - ${formatValue(path)}`)),
]
}
function formatHuman(report: ChangeScopeReport): string {
return [
`Format version: ${report.formatVersion}`,
`Repository root: ${formatValue(report.repository.root)}`,
`Branch: ${formatValue(report.repository.branch)}`,
`Upstream: ${formatValue(report.repository.upstream)}`,
`Base ref: ${formatValue(report.input.base)}`,
`Head ref: ${formatValue(report.input.head)}`,
`Base commit: ${report.resolved.baseSha}`,
`Head commit: ${report.resolved.headSha}`,
`Merge base: ${report.resolved.mergeBaseSha}`,
...formatPaths('Committed paths', report.paths.committed),
...formatPaths('Staged paths', report.paths.staged),
...formatPaths('Unstaged paths', report.paths.unstaged),
...formatPaths('Untracked paths', report.paths.untracked),
].join('\n')
}
/**
* Validate arguments, collect one complete report, then invoke the writer once.
* Validate arguments and render one complete versioned report.
* @param args - Command-line arguments after the script path.
* @param cwd - Directory whose containing Git worktree is inspected.
* @param write - Destination called once only after every Git query succeeds.
* @returns Nothing.
* @returns JSON report with a trailing newline.
*/
export function writeChangeScope(
args: string[],
cwd: string,
write: (output: string) => void,
): void {
export function renderChangeScope(args: string[], cwd: string): string {
const options = parseOptions(args)
const report = collectReport(options, cwd)
write(`${options.json ? JSON.stringify(report, null, 2) : formatHuman(report)}\n`)
return `${JSON.stringify(report, null, 2)}\n`
}
const entryPath = process.argv[1]
if (entryPath !== undefined && resolve(entryPath) === fileURLToPath(import.meta.url)) {
try {
writeChangeScope(process.argv.slice(2), process.cwd(), output => process.stdout.write(output))
process.stdout.write(renderChangeScope(process.argv.slice(2), process.cwd()))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`change-scope: ${message}\n`)

View File

@@ -354,7 +354,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
'',
'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](core-data-structures/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](core-data-structures/persistence.md) (how the log is made durable), and the [cordis events catalog](cordis-catalog/events.md) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
'',
'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/implemented/process/2026-07-04-persistence-log-catalog.md).',
'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](core-data-structures/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.',
'',

View File

@@ -57,6 +57,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-conversation': { 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-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.' },
@@ -84,6 +85,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
'packages/sdk/sdk-client': { kind: 'none', reason: 'Client-process library; the model surface lives in the spawned runtime\'s composed plugins.' },
'packages/sdk/sdk-protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' },
'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or 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.' },