Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/src/index.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/fetch-carrier.spec.ts
#	packages/llm/llm-deepseek/src/adapter.ts
#	packages/llm/llm-deepseek/tests/adapter.spec.ts
#	packages/llm/llm-deepseek/tests/serialize.spec.ts
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm-pi-ai/src/index.ts
#	packages/llm/llm-pi-ai/tests/adapter.spec.ts
#	packages/llm/llm/src/types.ts
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
Yichen Jiang
2026-07-28 11:41:40 +08:00
1499 changed files with 48621 additions and 21956 deletions

View File

@@ -0,0 +1,241 @@
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { renderChangeScope } from './change-scope.ts'
interface Report {
formatVersion: number
repositoryRoot: string
input: { base: string; head: string }
resolved: { baseSha: string; headSha: string; mergeBaseSha: string }
paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] }
}
interface Fixture {
container: string
root: string
}
const fixtureRoots: string[] = []
afterEach(() => {
for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function git(cwd: string, args: string[], input?: string | Buffer): string {
return execFileSync('git', ['-C', cwd, ...args], {
encoding: 'utf8',
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
input,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
}
function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer {
return execFileSync('git', ['-C', cwd, ...args], {
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
input,
stdio: ['pipe', 'pipe', 'pipe'],
})
}
function write(path: string, content: string, mode?: number): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content, mode === undefined ? undefined : { mode })
}
function fixture(worktreeName = 'worktree'): Fixture {
const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-'))
fixtureRoots.push(container)
const origin = join(container, 'origin.git')
const root = join(container, worktreeName)
const hooks = join(container, 'hooks')
mkdirSync(hooks)
git(container, ['init', '--bare', '--initial-branch=master', origin])
git(container, ['init', '--initial-branch=master', root])
git(root, ['config', 'user.email', 'change-scope@example.com'])
git(root, ['config', 'user.name', 'Change Scope Tests'])
git(root, ['config', 'commit.gpgsign', 'false'])
git(root, ['config', 'core.hooksPath', hooks])
write(join(root, 'README.md'), '# Fixture\n')
git(root, ['add', 'README.md'])
git(root, ['commit', '-m', 'initial'])
git(root, ['remote', 'add', 'origin', origin])
git(root, ['push', '--set-upstream', 'origin', 'master'])
return { container, root }
}
function commit(root: string, path: string, content: string): string {
write(join(root, path), content)
git(root, ['add', '--', path])
git(root, ['commit', '-m', `add ${path}`])
return git(root, ['rev-parse', 'HEAD'])
}
function invoke(root: string, args: string[]): string {
return renderChangeScope(args, root)
}
function jsonReport(root: string, base: string, head?: string): Report {
const args = ['--base', base]
if (head !== undefined) args.push('--head', head)
return JSON.parse(invoke(root, args)) as Report
}
function repositoryState(root: string): Record<string, string> {
const status = git(root, ['status', '--porcelain=v2', '--branch', '-z'])
return {
status,
head: git(root, ['rev-parse', 'HEAD']),
refs: git(root, ['for-each-ref', '--format=%(refname) %(objectname)']),
index: readFileSync(join(root, '.git/index')).toString('base64'),
config: readFileSync(join(root, '.git/config')).toString('base64'),
}
}
describe('change-scope', () => {
it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => {
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(fresh.resolved).toEqual({
baseSha: git(root, ['rev-parse', 'origin/master']),
headSha,
mergeBaseSha: git(root, ['rev-parse', 'origin/master']),
})
expect(fresh.paths).toEqual({ committed: ['feature.txt'], staged: [], unstaged: [], untracked: [] })
expect(git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/feature'])).toBe('')
git(root, ['push', '--set-upstream', 'origin', 'feature'])
const pushed = jsonReport(root, 'origin/master')
expect(pushed.paths.committed).toEqual(['feature.txt'])
})
it.skipIf(process.platform === 'win32')('preserves trailing spaces in the worktree path', () => {
const { root } = fixture('worktree ')
const report = jsonReport(root, 'HEAD')
expect(report.repositoryRoot).toBe(realpathSync(root))
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
})
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'])
const baseSha = commit(root, 'foundation.txt', 'foundation\n')
git(root, ['switch', '-c', 'topic'])
const headSha = commit(root, 'topic.txt', 'topic\n')
commit(root, 'later.txt', 'later\n')
write(join(root, 'current-worktree.txt'), 'current worktree\n')
const report = jsonReport(root, 'foundation', headSha)
expect(report.input).toEqual({ base: 'foundation', head: headSha })
expect(report.resolved).toEqual({ baseSha, headSha, mergeBaseSha: baseSha })
expect(report.paths.committed).toEqual(['topic.txt'])
expect(report.paths.untracked).toEqual(['current-worktree.txt'])
})
it('keeps committed, staged, unstaged, and untracked paths independent and does not mutate state', () => {
const { root } = fixture()
commit(root, 'unstaged.txt', 'before\n')
const baseSha = git(root, ['rev-parse', 'HEAD'])
commit(root, 'committed.txt', 'committed\n')
write(join(root, 'staged.txt'), 'staged\n')
write(join(root, 'mixed.txt'), 'staged part\n')
git(root, ['add', 'staged.txt', 'mixed.txt'])
write(join(root, 'mixed.txt'), 'staged part\nunstaged part\n')
write(join(root, 'unstaged.txt'), 'unstaged\n')
write(join(root, 'untracked.txt'), 'untracked\n')
const before = repositoryState(root)
const report = jsonReport(root, baseSha)
expect(report.paths).toEqual({
committed: ['committed.txt'],
staged: ['mixed.txt', 'staged.txt'],
unstaged: ['mixed.txt', 'unstaged.txt'],
untracked: ['untracked.txt'],
})
expect(repositoryState(root)).toEqual(before)
})
it.skipIf(process.platform === 'win32')('does not execute a configured filesystem monitor', () => {
const { container, root } = fixture()
const monitor = join(container, 'fsmonitor.sh')
const sideEffect = `${monitor}.ran`
write(monitor, '#!/bin/sh\ntouch "$0.ran"\n', 0o755)
git(root, ['config', 'core.fsmonitor', monitor])
const report = jsonReport(root, 'HEAD')
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
expect(existsSync(sideEffect)).toBe(false)
})
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')
const firstPath = Buffer.from([0x80])
const secondPath = Buffer.from([0x81])
gitBytes(root, ['update-index', '-z', '--index-info'], Buffer.concat([
entry,
firstPath,
Buffer.from([0]),
entry,
secondPath,
Buffer.from([0]),
]))
expect(gitBytes(root, ['diff', '--cached', '--name-only', '-z', '--'])).toEqual(Buffer.concat([
firstPath,
Buffer.from([0]),
secondPath,
Buffer.from([0]),
]))
expect(() => {
renderChangeScope(['--base', 'HEAD'], root)
}).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8')
})
it('rejects missing, ambiguous, and non-commit refs', () => {
const { root } = fixture()
git(root, ['branch', 'collision'])
git(root, ['tag', 'collision'])
write(join(root, 'blob.txt'), 'blob\n')
const blobSha = git(root, ['hash-object', '-w', 'blob.txt'])
git(root, ['tag', 'blob-ref', blobSha])
for (const { args, message } of [
{ args: ['--base', 'missing'], message: /base ref .* does not resolve to a commit/u },
{ args: ['--base', 'collision'], message: /base ref .* is ambiguous/u },
{ 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 },
]) {
expect(() => {
renderChangeScope(args, root)
}).toThrow(message)
}
})
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'])
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'])
})
})

248
scripts/change-scope.ts Normal file
View File

@@ -0,0 +1,248 @@
/** Report the explicit committed and worktree scope of a repository change. */
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { resolve } from 'node:path'
import { parseArgs, TextDecoder } from 'node:util'
const FORMAT_VERSION = 1
const MAX_GIT_OUTPUT = 64 * 1024 * 1024
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
interface ChangeScopeReport {
formatVersion: typeof FORMAT_VERSION
repositoryRoot: string
input: {
base: string
head: string
}
resolved: {
baseSha: string
headSha: string
mergeBaseSha: string
}
paths: {
committed: string[]
staged: string[]
unstaged: string[]
untracked: string[]
}
}
interface GitCommandResult {
status: number | null
stdout: string
stderr: string
error: Error | undefined
}
interface GitBytesCommandResult {
status: number | null
stdout: Buffer
stderr: Buffer
error: Error | undefined
}
interface ChangeScopeOptions {
base: string
head: string
}
function executeGit(cwd: string, args: string[], context: string): GitCommandResult {
const result = executeGitBytes(cwd, args)
return {
status: result.status,
stdout: decodeGitText(result.stdout, context, 'stdout'),
stderr: decodeGitText(result.stderr, context, 'stderr'),
error: result.error,
}
}
function executeGitBytes(cwd: string, args: string[]): GitBytesCommandResult {
const result = spawnSync('git', ['-C', cwd, '-c', 'core.fsmonitor=false', ...args], {
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LANG: 'C', LC_ALL: 'C' },
maxBuffer: MAX_GIT_OUTPUT,
})
return {
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
error: result.error,
}
}
function decodeGitText(output: Buffer, context: string, stream: 'stdout' | 'stderr'): string {
try {
return UTF8_DECODER.decode(output)
} catch {
throw new Error(`${context}: Git ${stream} is not valid UTF-8`)
}
}
function failureDetail(result: GitCommandResult): string {
return result.error?.message ?? (result.stderr.trim() || `Git exited with status ${String(result.status)}`)
}
function requireGit(cwd: string, args: string[], context: string): string {
const result = executeGit(cwd, args, context)
if (result.status !== 0) throw new Error(`${context}: ${failureDetail(result)}`)
return result.stdout
}
function requireGitBytes(cwd: string, args: string[], context: string): Buffer {
const result = executeGitBytes(cwd, args)
if (result.status !== 0) {
const detail = result.error?.message
?? (result.stderr.toString('utf8').trim() || `Git exited with status ${String(result.status)}`)
throw new Error(`${context}: ${detail}`)
}
return result.stdout
}
function parseOptions(args: string[]): ChangeScopeOptions {
const { values } = parseArgs({
args,
allowPositionals: false,
options: {
base: { type: 'string' },
head: { type: 'string', default: 'HEAD' },
},
strict: true,
})
if (values.base === undefined) throw new Error('missing required --base <ref>')
return { base: values.base, head: values.head }
}
function resolveCommit(root: string, label: 'base' | 'head', ref: string): string {
const context = `cannot resolve ${label} ref ${JSON.stringify(ref)}`
const result = executeGit(root, [
'-c',
'core.warnAmbiguousRefs=true',
'rev-parse',
'--verify',
'--end-of-options',
`${ref}^{commit}`,
], context)
if (/\bambiguous\b/iu.test(result.stderr)) {
throw new Error(`${label} ref ${JSON.stringify(ref)} is ambiguous; use a fully qualified ref or commit ID`)
}
if (result.status !== 0) {
throw new Error(`${label} ref ${JSON.stringify(ref)} does not resolve to a commit: ${failureDetail(result)}`)
}
const commits = result.stdout.trim().split(/\r?\n/u).filter(Boolean)
if (commits.length !== 1) {
throw new Error(`${label} ref ${JSON.stringify(ref)} did not resolve to exactly one commit`)
}
return commits[0] as string
}
function resolveMergeBase(root: string, baseSha: string, headSha: string): string {
const result = executeGit(
root,
['merge-base', '--all', baseSha, headSha],
'cannot resolve the merge base',
)
if (result.status !== 0) {
throw new Error(`base and head do not have a merge base: ${failureDetail(result)}`)
}
const mergeBases = result.stdout.trim().split(/\r?\n/u).filter(Boolean)
if (mergeBases.length !== 1) {
throw new Error(`base and head do not have a unique merge base; found ${mergeBases.length}`)
}
return mergeBases[0] as string
}
function parsePathSet(output: Buffer, context: string): string[] {
const paths: string[] = []
let start = 0
let record = 0
for (let end = 0; end < output.length; end += 1) {
if (output[end] !== 0) continue
if (end > start) {
record += 1
try {
paths.push(UTF8_DECODER.decode(output.subarray(start, end)))
} catch {
throw new Error(`${context}: Git path ${record} is not valid UTF-8`)
}
}
start = end + 1
}
return [...new Set(paths)].sort()
}
function diffPaths(root: string, args: string[], context: string): string[] {
return parsePathSet(requireGitBytes(root, [
'diff',
'--no-ext-diff',
'--no-textconv',
'--no-renames',
'--ignore-submodules=none',
'--name-only',
'-z',
...args,
'--',
], context), context)
}
function stripGitLineTerminator(output: string): string {
const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output
return process.platform === 'win32' && withoutLineFeed.endsWith('\r')
? withoutLineFeed.slice(0, -1)
: withoutLineFeed
}
function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeReport {
const root = stripGitLineTerminator(
requireGit(cwd, ['rev-parse', '--show-toplevel'], 'cannot locate a Git worktree'),
)
const baseSha = resolveCommit(root, 'base', options.base)
const headSha = resolveCommit(root, 'head', options.head)
const mergeBaseSha = resolveMergeBase(root, baseSha, headSha)
return {
formatVersion: FORMAT_VERSION,
repositoryRoot: root,
input: {
base: options.base,
head: options.head,
},
resolved: {
baseSha,
headSha,
mergeBaseSha,
},
paths: {
committed: diffPaths(root, [mergeBaseSha, headSha], 'cannot inspect committed paths'),
staged: diffPaths(root, ['--cached'], 'cannot inspect staged paths'),
unstaged: diffPaths(root, [], 'cannot inspect unstaged paths'),
untracked: parsePathSet(requireGitBytes(
root,
['ls-files', '--others', '--exclude-standard', '-z', '--'],
'cannot inspect untracked paths',
), 'cannot inspect untracked paths'),
},
}
}
/**
* 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.
* @returns JSON report with a trailing newline.
*/
export function renderChangeScope(args: string[], cwd: string): string {
const options = parseOptions(args)
const report = collectReport(options, cwd)
return `${JSON.stringify(report, null, 2)}\n`
}
const entryPath = process.argv[1]
if (entryPath !== undefined && resolve(entryPath) === fileURLToPath(import.meta.url)) {
try {
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`)
process.exitCode = 1
}
}

View File

@@ -97,6 +97,7 @@ function workspaceManifests(): WorkspaceManifest[] {
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
'@deepseek-ai/dsh-helper': ['lib/assets'],
'@deepseek-ai/dsh-tui': ['lib/prompt.js'],
'@deepseek-ai/dsh-scripts': [
'lib/dev/tsdown-config.js',
'lib/local-plugin-loader-hooks.js',

View File

@@ -0,0 +1,33 @@
/** Regression coverage for source declarations owned by the client test aggregate. */
import { existsSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
const root = fileURLToPath(new URL('..', import.meta.url))
function clientCssDeclarations(): string[] {
const clientRoot = resolve(root, 'packages/client')
return readdirSync(clientRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => resolve(clientRoot, entry.name, 'src/css-modules.d.ts'))
.filter(existsSync)
.sort()
}
describe('client TypeScript aggregate', () => {
it('loads package CSS declarations without relying on workspace-link realpaths', () => {
const configPath = resolve(root, 'tsconfig.client.json')
const read = ts.readConfigFile(configPath, file => ts.sys.readFile(file))
if (read.error !== undefined) {
throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, '\n'))
}
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, root)
const loaded = parsed.fileNames
.filter(file => file.endsWith('/src/css-modules.d.ts'))
.sort()
expect(loaded).toEqual(clientCssDeclarations())
})
})

24
scripts/demo-cordis.mjs Normal file
View File

@@ -0,0 +1,24 @@
/**
* Boot the self-referential Cordis tools under TUI, Web, or ACP, defaulting
* to TUI. This is a repository demo wrapper, not a product CLI feature.
*/
import { spawn } from 'node:child_process'
const SURFACES = new Map([
['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/cordis-agent/cordis.yml']],
// `dsh web` does not accept alternate configs yet. The TUI config escape
// hatch still boots this browser-only tree; the config owns port 3081.
['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/web-cordis/cordis.yml']],
['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/cordis-tools.cordis.yml']],
])
const surface = process.argv[2] ?? 'tui'
const args = SURFACES.get(surface)
if (args === undefined || process.argv.length > 3) {
console.error('usage: pnpm run demo:cordis [tui|web|acp]')
process.exit(2)
}
if (surface === 'web') console.log('Cordis Web: http://127.0.0.1:3081')
const child = spawn(process.execPath, args, { stdio: 'inherit' })
child.on('exit', (code, signal) => { process.exit(signal === null ? code ?? 1 : 1) })

View File

@@ -17,8 +17,8 @@
* `watch` through API-level inline config (tsdown workspace mode fills inline
* keys under each package's file config, and no package config defines it).
*/
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { globSync, readFileSync } from 'node:fs'
import { dirname, join, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { build } from 'tsdown'
@@ -33,20 +33,9 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url))
*/
function discoverPluginDirs(): string[] {
const dirs: string[] = []
for (const group of readdirSync(join(repoRoot, 'packages'), { withFileTypes: true })) {
if (!group.isDirectory()) continue
for (const pkg of readdirSync(join(repoRoot, 'packages', group.name), { withFileTypes: true })) {
if (!pkg.isDirectory()) continue
let manifest: { dshClient?: { platform?: unknown } }
try {
manifest = JSON.parse(
readFileSync(join(repoRoot, 'packages', group.name, pkg.name, 'package.json'), 'utf8'),
) as { dshClient?: { platform?: unknown } }
} catch {
continue // no package.json (support dirs, scratch): not a workspace package
}
if (manifest.dshClient?.platform === 'web') dirs.push(`packages/${group.name}/${pkg.name}`)
}
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: repoRoot }).sort()) {
const manifest = JSON.parse(readFileSync(join(repoRoot, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
}
return dirs
}

View File

@@ -1,5 +1,5 @@
{
"AGENTS.md": 1680,
"AGENTS.md": 1705,
"docs/AGENTS.md": 1150,
"docs/architecture.md": 1800,
"docs/cordis-primer.md": 600,

View File

@@ -10,7 +10,7 @@ import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node
import { join, relative, resolve } from 'node:path'
import ts from 'typescript'
import { builtDeclarationPath } from './doc-typecheck-paths.ts'
import { extractFences } from './md-fences.ts'
import { markdownFences } from './markdown.ts'
import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
import { isArchivedAgentNotePath } from './repo-files.ts'
@@ -46,8 +46,10 @@ const KIND_BY_INFO: Record<string, BlockKind> = {
/** Extract every recognized TypeScript fence from one Markdown file. */
function extractBlocks(absPath: string): Block[] {
const file = relative(root, absPath)
return extractFences(absPath, info => KIND_BY_INFO[info] ?? null)
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
return markdownFences(readFileSync(absPath, 'utf8')).flatMap((fence) => {
const kind = KIND_BY_INFO[fence.info]
return kind === undefined ? [] : [{ file, line: fence.line, kind, code: fence.code }]
})
}
const configHost: ts.ParseConfigFileHost = {

View File

@@ -32,13 +32,16 @@ function quote(value: string): string {
/**
* Reduce an exported class to its type shape: drop method/constructor bodies
* and property initializers so the catalog serves member signatures, not
* implementation.
* implementation. An abstract class (e.g. `Agent`) is a public type consumers
* program against, so it belongs in the type closure alongside interfaces.
*/
function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration {
const isNonPublic = (member: ts.ClassElement): boolean =>
(ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined)?.some(m =>
m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false
const members = node.members.flatMap((member): ts.ClassElement[] => {
// A model-facing type shape carries only the public surface — drop private,
// protected, and #private members, and strip every kept member's body.
if (isNonPublic(member) || (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name))) return []
if (ts.isMethodDeclaration(member)) {
return [ts.factory.updateMethodDeclaration(
@@ -67,8 +70,9 @@ function classShape(node: ts.ClassDeclaration): ts.ClassDeclaration {
}
/**
* Collect exported interface, type-alias, and body-stripped class shapes; omit
* names declared in multiple packages rather than serve the wrong shape.
* Collect exported interface, type-alias, and (body-stripped) class shapes;
* omit names declared in multiple packages rather than risk serving the wrong
* package's shape.
*/
function collectTypeDecls(scanRoot: string = root): Map<string, string> {
const printer = ts.createPrinter({ removeComments: true })

View File

@@ -35,19 +35,24 @@ export const LINK_MAP: Record<string, string> = {
ContinuationDecision: 'core.md',
ContinuationStop: 'core.md',
GenerateOptions: 'core.md',
InboxPlacement: 'core.md',
AgentMessage: 'core.md',
AgentMessageId: 'core.md',
HookContext: 'core.md',
SettleReason: 'core.md',
LlmCallConfig: 'core.md',
LlmModelContext: 'core.md',
LlmModelReasoningInfo: 'core.md',
LlmResolvedModelInfo: 'core.md',
LlmFailure: 'llm-streaming.md',
LlmModelInfo: 'core.md',
LlmProviderInfo: 'core.md',
ResolvedRetryPolicy: 'llm-streaming.md',
Message: 'core.md',
MessageSource: 'core.md',
PromptDecision: 'core.md',
RequestErrorAction: 'core.md',
RequestError: 'core.md',
RequestErrorDecision: 'core.md',
PreparedReferencedMessage: 'session-reference.md',
SessionReferenceCandidate: 'session-reference.md',
SessionReferenceInput: 'session-reference.md',
@@ -67,7 +72,12 @@ export const LINK_MAP: Record<string, string> = {
BashExecSpec: 'bash.md',
BashProcess: 'bash.md',
BashRunResult: 'bash.md',
DshEnvironment: 'bash.md',
DshEnvironment: 'subprocess.md',
SubprocessHandle: 'subprocess.md',
SubprocessOutcome: 'subprocess.md',
SubprocessOutputRead: 'subprocess.md',
SubprocessOutputReader: 'subprocess.md',
SubprocessSpawnSpec: 'subprocess.md',
CodeRunRequest: 'code-runtime.md',
CodeRunResult: 'code-runtime.md',
CompactionResult: 'compaction.md',
@@ -95,6 +105,7 @@ export const LINK_MAP: Record<string, string> = {
CommandResult: 'commands.md',
CommandSurface: 'commands.md',
LlmAdapter: 'llm-streaming.md',
PreparedLlmCall: 'llm-streaming.md',
LlmService: 'llm-streaming.md',
StreamChunk: 'llm-streaming.md',
CreateSessionOptions: 'persistence.md',
@@ -242,6 +253,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts',
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',

View File

@@ -60,6 +60,7 @@ const GROUP_ORDER = [
'llm',
'core',
'goal',
'process',
'bash',
'pty',
'sandbox',
@@ -78,6 +79,7 @@ const GROUP_ORDER = [
'session-persistence',
'session-query',
'session-title',
'telemetry',
'storage',
'workspace',
'support',
@@ -145,6 +147,15 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'],
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
},
{
key: 'telemetry',
pkg: 'session-telemetry',
title: 'Session telemetry seam',
mode: 'seam',
implementations: ['session-telemetry-otel'],
consumers: [],
note: 'The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process.',
},
{
key: 'storage',
pkg: 'storage',
@@ -274,6 +285,15 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'core',
note: 'Folds revisioned objective state from the session log and keeps live continuation activation process-local.',
},
{
key: 'subprocess',
pkg: 'subprocess',
title: 'Subprocess seam',
mode: 'seam',
implementations: ['subprocess-local'],
consumers: ['bash-local', 'bash-sandbox', 'lsp-local', 'subagent-acp'],
note: 'The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation.',
},
{
key: 'bash',
pkg: 'bash',
@@ -579,7 +599,7 @@ const APP_EXAMPLES = [
title: 'Cordis Agent App Composition',
label: 'examples/cordis-agent',
config: 'examples/cordis-agent/cordis.yml',
summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its own runtime and mount/unmount plugins into it.',
summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins.',
},
{
id: 'acp',
@@ -699,22 +719,31 @@ class EventRelationCollector {
/** Walk one package source file and classify event API calls by receiver type. */
private visitSource(source: PackageSource): void {
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const receiverKind = this.receiverKind(node.expression.expression)
const method = node.expression.name.text
if (receiverKind === 'events-service' && method === 'dispatch') {
const argumentList = node.arguments[1]
if (argumentList) {
for (const event of this.eventNamesFromArgumentList(argumentList, new Set())) {
this.addDispatcher(event, source.pkg, 'events.dispatch')
if (ts.isCallExpression(node)) {
if (this.isAgentEventEmitter(node.expression)) {
const event = node.arguments[2]
if (event) {
for (const name of this.finiteStringValues(event) ?? []) {
this.addDispatcher(name, source.pkg, 'emitAgentEvent')
}
}
} else if (receiverKind === 'context' || receiverKind === 'agent-dispatch') {
const eventNames = this.eventNamesFromCall(node, receiverKind)
if (method === 'on' || method === 'once') {
for (const event of eventNames) this.ensure(event).listeners.add(source.pkg)
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
for (const event of eventNames) this.addDispatcher(event, source.pkg, method)
} else if (ts.isPropertyAccessExpression(node.expression)) {
const receiverKind = this.receiverKind(node.expression.expression)
const method = node.expression.name.text
if (receiverKind === 'events-service' && method === 'dispatch') {
const argumentList = node.arguments[1]
if (argumentList) {
for (const event of this.eventNamesFromArgumentList(argumentList, new Set())) {
this.addDispatcher(event, source.pkg, 'events.dispatch')
}
}
} else if (receiverKind === 'context' || receiverKind === 'agent-dispatch') {
const eventNames = this.eventNamesFromCall(node, receiverKind)
if (method === 'on' || method === 'once') {
for (const event of eventNames) this.ensure(event).listeners.add(source.pkg)
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
for (const event of eventNames) this.addDispatcher(event, source.pkg, method)
}
}
}
}
@@ -723,6 +752,22 @@ class EventRelationCollector {
visit(source.sourceFile)
}
/** Match the exported contained-notification helper by declaration identity. */
private isAgentEventEmitter(expression: ts.Expression): boolean {
if (!ts.isIdentifier(expression)) return false
const local = this.project.checker.getSymbolAtLocation(expression)
if (!local) return false
const symbol = local.flags & ts.SymbolFlags.Alias
? this.project.checker.getAliasedSymbol(local)
: local
const declarations = symbol.declarations ?? []
return declarations.some((declaration) => {
return ts.isFunctionDeclaration(declaration)
&& declaration.name?.text === 'emitAgentEvent'
&& this.project.relativePath(declaration.getSourceFile()) === 'packages/core/agent/src/dispatch.ts'
})
}
/** Classify a receiver using assignability to the repository's actual event API types. */
private receiverKind(receiver: ts.Expression): EventReceiverKind | undefined {
const type = this.project.checker.getTypeAtLocation(receiver)
@@ -974,18 +1019,21 @@ function renderLifecycle(): string {
' participant LLM as ctx.llm',
' participant Tools as ctx.tools',
' participant Session',
' participant Persistence',
' participant SDK as UI or SDK listener',
' User->>Agent: followup(content)',
` Agent-->>SDK: ${mermaidCode('agent/inbox/enqueue')}`,
' Agent->>Driver: queued work wakes driver',
` Driver-->>SDK: ${mermaidCode('agent/status')} running`,
` Driver->>Session: ${mermaidCode('turn/start')}`,
' Note over Agent,Driver: next-step acceptance window opens',
` Driver->>Hooks: ${mermaidCode('agent/prompt-submit')} waterfall`,
' Hooks-->>Driver: authoritative allow, block, or add context',
` Driver->>Session: ${mermaidCode('user/message')} or rejected ${mermaidCode('turn/end')}`,
' alt prompt blocked or admission failed',
' Driver-->>Driver: append context-only batch or keep steering boundary pending',
' else prompt allowed',
` Driver->>Session: ${mermaidCode('turn/start')}`,
` Driver->>Session: ${mermaidCode('user/message')}`,
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`,
` Driver-->>Driver: ${mermaidCode('agent/pre-step')} serial checkpoint`,
` Driver-->>Driver: ${mermaidCode('agent/step')} serial checkpoint`,
` Driver->>Session: ${mermaidCode('step/start')}`,
` Driver->>LLM: ${mermaidCode('agent/request')} waterfall, then ${mermaidCode('llm/stream')} waterfall`,
' LLM-->>Driver: StreamChunk*',
@@ -994,9 +1042,8 @@ function renderLifecycle(): string {
' alt final adapter or terminal in-band request failure',
` Driver->>Session: ${mermaidCode('step/end')}`,
` Driver->>Hooks: ${mermaidCode('agent/request-error')} waterfall`,
' Hooks-->>Driver: retry in a new step or preserve the original error',
' Hooks-->>Driver: return retry action or preserve the original error',
' else model request succeeded',
` Driver->>Hooks: ${mermaidCode('agent/step-result')} waterfall`,
` Driver->>Session: ${mermaidCode('assistant/message')}`,
' Driver->>Tools: classify pending call by executionMode',
' loop barriers and bounded rolling pool, reclassify before start',
@@ -1011,19 +1058,18 @@ function renderLifecycle(): string {
' end',
' end',
' Driver->>Session: post-tool context and steering (no prompt-submit)',
` Driver->>Hooks: ${mermaidCode('agent/post-step')} serial checkpoint`,
` Driver->>Session: ${mermaidCode('step/end')}`,
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`,
` Driver->>Hooks: ${mermaidCode('agent/turn-stopping')} serial terminal checkpoint`,
' end',
' Note over Agent,Driver: next-step acceptance window closes',
` Driver->>Session: ${mermaidCode('turn/end')}`,
` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`,
' end',
` 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.',
'',
'`dsh-compact-basic` uses `agent/post-step` for pressure after those durable facts 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 a fresh retry step, and returns retry only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
'`dsh-compact-basic` uses `agent/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/prompt-submit` allow is authoritative; listeners wrapping `next()` preserve downstream content and additional contexts unless replacement is intentional. Steering bypasses that waterfall and joins at its durable checkpoint.',
'',

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

@@ -19,6 +19,7 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
@@ -197,6 +198,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
requires: ['ctx.tools', 'ctx.bash', 'ctx.tasks at call time for run_in_background'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor)
await ctx.plugin(ToolBash)
},
@@ -208,12 +210,12 @@ const TOOL_PACKAGES: ToolPackage[] = [
dir: 'tool-cordis',
source: 'packages/cordis/tool-cordis/src/index.ts',
requires: ['ctx.tools'],
writes: ['tool/call', 'tool/result', 'live plugin-tree mutations (mount/unmount)'],
writes: ['tool/call', 'tool/result', 'process-local temporary Plugin lifecycle'],
async mount(ctx) {
await ctx.plugin(ToolCordis)
},
note:
'Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; a full changed request header logs those tool-set changes.',
'Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.',
},
{
pkg: '@deepseek-ai/dsh-tool-fs',

View File

@@ -1,21 +1,657 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import { isAbsolute, join, resolve } from 'node:path'
const git = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' })
if (git.status !== 0) process.exit(0)
const MINIMUM_GIT = [2, 26, 0]
const HOOKS_DIRECTORY = 'dsh-hooks'
const OWNERSHIP_MARKER = '.dsh-lefthook-owned'
const OWNERSHIP_MARKER_VERSION = 1
const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks'
const INSTALL_LOCK = 'dsh-lefthook-install.lock'
const INSTALL_LOCK_TIMEOUT_MS = 30_000
const INSTALL_LOCK_POLL_MS = 50
const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
const isWindows = process.platform === 'win32'
const lefthook = join(process.cwd(), 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook')
if (!existsSync(lefthook)) process.exit(0)
function errorCode(error) {
return typeof error === 'object' && error !== null && 'code' in error
? error.code
: undefined
}
// On Windows the bin shim is a `.cmd` file, and recent Node (CVE-2024-27980)
// refuses to launch `.cmd`/`.bat` via spawn without `shell: true` — it returns
// `EINVAL` with a null status, which would otherwise fail postinstall. Quote
// the path because a shell re-parses the command line and the path may contain
// spaces. POSIX needs no shell: the extensionless shim is directly executable.
const result = isWindows
? spawnSync(`"${lefthook}"`, ['install', '--force'], { stdio: 'inherit', shell: true })
: spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' })
process.exit(result.status ?? 1)
function commandFailure(command, args, result) {
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : ''
const detail = result.error?.message ?? (stderr || `exit status ${String(result.status)}`)
return new Error(`${command} ${args.join(' ')} failed: ${detail}`)
}
function capture(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
encoding: 'utf8',
env: process.env,
})
if (result.status !== 0 && !options.allowStatuses?.includes(result.status)) {
throw commandFailure(command, args, result)
}
return result
}
function git(args, root, options = {}) {
return capture('git', args, { ...options, cwd: root })
}
function nulValues(result) {
if (result.status !== 0) return []
if (result.stdout === '') return ['']
const output = result.stdout.endsWith('\0') ? result.stdout.slice(0, -1) : result.stdout
return output.split('\0')
}
function stripGitLineTerminator(output) {
const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output
return process.platform === 'win32' && withoutLineFeed.endsWith('\r')
? withoutLineFeed.slice(0, -1)
: withoutLineFeed
}
function directFileConfigValues(root, configPath, key) {
return nulValues(git(
['config', '--file', configPath, '--no-includes', '--null', '--get-all', key],
root,
{ allowStatuses: [1] },
))
}
function parseFileConfigEntries(fields, key) {
if (fields.length % 2 !== 0) {
throw new Error(`git config returned invalid file entries for ${key}`)
}
const entries = []
for (let index = 0; index < fields.length; index += 2) {
entries.push({ origin: fields[index], value: fields[index + 1] })
}
return entries
}
function includedFileConfigEntries(root, configPath, key) {
const fields = nulValues(git(
['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key],
root,
{ allowStatuses: [1] },
))
return parseFileConfigEntries(fields, key)
}
function splitConfigNameValue(field, pattern) {
const separator = field.indexOf('\n')
if (separator < 0) throw new Error(`git config returned an invalid name and value for ${pattern}`)
return { name: field.slice(0, separator), value: field.slice(separator + 1) }
}
function directFileConfigMatchingEntries(root, configPath, pattern) {
const fields = nulValues(git(
['config', '--file', configPath, '--no-includes', '--null', '--show-origin', '--get-regexp', pattern],
root,
{ allowStatuses: [1] },
))
if (fields.length % 2 !== 0) {
throw new Error(`git config returned invalid matching file entries for ${pattern}`)
}
const entries = []
for (let index = 0; index < fields.length; index += 2) {
entries.push({ origin: fields[index], ...splitConfigNameValue(fields[index + 1], pattern) })
}
return entries
}
function effectiveConfigEntry(root, key) {
const fields = nulValues(git(
['config', '--null', '--show-scope', '--show-origin', '--get', key],
root,
{ allowStatuses: [1] },
))
if (fields.length === 0) return undefined
if (fields.length !== 3) {
throw new Error(`git config returned an invalid scoped value for ${key}`)
}
const [scope, origin, value] = fields
return { origin, scope, value }
}
function parseGitBoolean(value, key) {
const normalized = value.toLowerCase()
if (normalized === '' || normalized === 'true' || normalized === 'yes' || normalized === 'on' || normalized === '1') return true
if (normalized === 'false' || normalized === 'no' || normalized === 'off' || normalized === '0') return false
throw new Error(`invalid Boolean value for ${key}: ${JSON.stringify(value)}`)
}
function assertSingle(values, key) {
if (values.length > 1) throw new Error(`multiple ${key} values are not supported`)
return values[0]
}
function worktreeConfigExtensionEnabled(root, commonConfigPath) {
const extensionText = assertSingle(
directFileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'),
'extensions.worktreeConfig',
)
return extensionText === undefined
? false
: parseGitBoolean(extensionText, 'extensions.worktreeConfig')
}
function hasDirectConfigEntries(root, configPath) {
return git(['config', '--file', configPath, '--no-includes', '--null', '--list'], root).stdout !== ''
}
function registeredWorktreeConfigPaths(commonDirectory) {
const paths = [join(commonDirectory, 'config.worktree')]
const linkedDirectory = join(commonDirectory, 'worktrees')
try {
const entries = readdirSync(linkedDirectory, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
for (const entry of entries) {
paths.push(join(linkedDirectory, entry.name, 'config.worktree'))
}
} catch (error) {
if (errorCode(error) !== 'ENOENT') throw error
}
return paths
}
function lstatIfPresent(path) {
try {
return lstatSync(path)
} catch (error) {
if (errorCode(error) === 'ENOENT') return undefined
throw error
}
}
function assertCommonConfigFile(commonConfigPath) {
const configStat = lstatIfPresent(commonConfigPath)
if (configStat === undefined || !configStat.isFile() || configStat.isSymbolicLink()) {
throw new Error(
`refusing common repository config ${JSON.stringify(commonConfigPath)} because it is not a regular file`,
)
}
}
function assertWorktreeConfigFiles(root, commonDirectory, commonConfigPath, currentConfigPath) {
const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath)
for (const configPath of registeredWorktreeConfigPaths(commonDirectory)) {
const configStat = lstatIfPresent(configPath)
if (configStat === undefined) continue
if (!configStat.isFile() || configStat.isSymbolicLink()) {
const state = extensionEnabled ? 'active' : 'dormant'
throw new Error(
`refusing ${state} worktree config ${JSON.stringify(configPath)} because it is not a regular file; `
+ 'replace it with a regular worktree config or remove it before retrying',
)
}
if (extensionEnabled) continue
if (!hasDirectConfigEntries(root, configPath)) continue
const isCurrent = normalizedPath(configPath) === normalizedPath(currentConfigPath)
const owner = isCurrent ? 'current' : 'sibling'
throw new Error(
`cannot enable extensions.worktreeConfig while ${owner} dormant worktree config `
+ `${JSON.stringify(configPath)} contains user-owned settings that enabling the extension would activate; `
+ 'inspect and migrate those settings, then enable the extension explicitly or remove them before retrying',
)
}
}
function assertSupportedGit(root) {
const version = git(['--version'], root).stdout.trim()
const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(version)
if (match === null) throw new Error(`cannot determine Git version from ${JSON.stringify(version)}`)
const actual = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]
for (let index = 0; index < MINIMUM_GIT.length; index += 1) {
if (actual[index] > MINIMUM_GIT[index]) return
if (actual[index] < MINIMUM_GIT[index]) {
throw new Error(`Git 2.26 or newer is required for worktree-local hooks; found ${version}`)
}
}
}
function planWorktreeConfigMigration(root, commonConfigPath) {
const versions = directFileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion')
const versionText = assertSingle(versions, 'core.repositoryFormatVersion')
const version = Number(versionText)
if (!Number.isInteger(version) || version < 0) {
throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`)
}
if (version === 0) {
const extensionEntry = directFileConfigMatchingEntries(
root,
commonConfigPath,
REPOSITORY_EXTENSION_PATTERN,
)[0]
if (extensionEntry !== undefined) {
throw new Error(
`cannot upgrade core.repositoryFormatVersion from 0 while dormant repository extension `
+ `${extensionEntry.name} is configured (${configSource(extensionEntry)}); `
+ 'audit and migrate it, then set repository format 1 explicitly before retrying',
)
}
}
const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath)
const worktreeText = assertSingle(
directFileConfigValues(root, commonConfigPath, 'core.worktree'),
'core.worktree',
)
if (worktreeText !== undefined) {
throw new Error(
`cannot enable extensions.worktreeConfig while core.worktree is in the common config `
+ `(file:${commonConfigPath}: ${JSON.stringify(worktreeText)}); `
+ 'move it to the main worktree config first',
)
}
const directBareText = assertSingle(directFileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare')
const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare')
if (directBare === true) {
throw new Error(
`cannot enable extensions.worktreeConfig for a common config with core.bare=true `
+ `(file:${commonConfigPath}: ${JSON.stringify(directBareText)})`,
)
}
return { directBare, extensionEnabled, version }
}
function applyWorktreeConfigMigration(root, commonConfigPath, migration) {
const { directBare, extensionEnabled, version } = migration
if (version === 0) {
git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root)
}
if (!extensionEnabled) {
git(['config', '--file', commonConfigPath, 'extensions.worktreeConfig', 'true'], root)
}
if (directBare === false) {
git(['config', '--file', commonConfigPath, '--unset-all', 'core.bare'], root)
}
}
function readInstallLock(lockPath) {
try {
return readFileSync(lockPath, 'utf8')
} catch (error) {
if (errorCode(error) === 'ENOENT') return undefined
throw error
}
}
function installLockStat(lockPath) {
try {
return lstatSync(lockPath)
} catch (error) {
if (errorCode(error) === 'ENOENT') return undefined
throw error
}
}
function parseInstallLock(record) {
const match = /^([1-9]\d*) ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\n$/i.exec(record)
if (match === null) return undefined
const owner = Number(match[1])
return Number.isSafeInteger(owner) ? owner : undefined
}
function lockOwnerIsAlive(owner) {
try {
process.kill(owner, 0)
return true
} catch (error) {
if (errorCode(error) === 'ESRCH') return false
if (errorCode(error) === 'EPERM') return true
throw error
}
}
function manualLockRecoveryError(lockPath, condition) {
return new Error(
`${condition} Lefthook installer lock ${JSON.stringify(lockPath)}. `
+ 'Confirm no Lefthook installer is running, remove it manually, and retry.',
)
}
function lockOwnershipChangedError(lockPath) {
return new Error(`Lefthook installer lock ownership changed for ${lockPath}; refusing to remove it`)
}
function releaseInstallLock(lockPath, ownedRecord, ownedStat) {
const currentStat = installLockStat(lockPath)
if (
currentStat === undefined
|| !currentStat.isFile()
|| currentStat.isSymbolicLink()
|| currentStat.dev !== ownedStat.dev
|| currentStat.ino !== ownedStat.ino
|| readInstallLock(lockPath) !== ownedRecord
) {
throw lockOwnershipChangedError(lockPath)
}
try {
unlinkSync(lockPath)
} catch (error) {
if (errorCode(error) === 'ENOENT') {
throw lockOwnershipChangedError(lockPath)
}
throw error
}
}
async function acquireInstallLock(commonDirectory) {
const lockPath = join(commonDirectory, INSTALL_LOCK)
const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
const ownedRecord = `${String(process.pid)} ${randomUUID()}\n`
while (true) {
try {
writeFileSync(lockPath, ownedRecord, { flag: 'wx', mode: 0o600 })
const ownedStat = installLockStat(lockPath)
if (ownedStat === undefined || !ownedStat.isFile() || ownedStat.isSymbolicLink()) {
throw lockOwnershipChangedError(lockPath)
}
return () => releaseInstallLock(lockPath, ownedRecord, ownedStat)
} catch (error) {
if (errorCode(error) !== 'EEXIST') throw error
const existingStat = installLockStat(lockPath)
if (existingStat === undefined) continue
if (!existingStat.isFile() || existingStat.isSymbolicLink()) {
throw manualLockRecoveryError(lockPath, 'invalid')
}
const existingRecord = readInstallLock(lockPath)
if (existingRecord === undefined) continue
const owner = parseInstallLock(existingRecord)
if (owner === undefined) throw manualLockRecoveryError(lockPath, 'invalid')
if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale')
if (Date.now() >= deadline) {
throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`)
}
await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
}
}
}
function ownershipMarkerContent(hooksPath) {
return `${JSON.stringify({
version: OWNERSHIP_MARKER_VERSION,
owner: OWNERSHIP_MARKER_OWNER,
hooksPath,
})}\n`
}
function parseOwnershipMarker(content) {
let parsed
try {
parsed = JSON.parse(content)
} catch {
return undefined
}
if (
typeof parsed !== 'object'
|| parsed === null
|| parsed.version !== OWNERSHIP_MARKER_VERSION
|| parsed.owner !== OWNERSHIP_MARKER_OWNER
|| typeof parsed.hooksPath !== 'string'
|| !isAbsolute(parsed.hooksPath)
) {
return undefined
}
return { hooksPath: parsed.hooksPath }
}
function inspectOwnedHooksDirectory(hooksPath) {
const markerPath = join(hooksPath, OWNERSHIP_MARKER)
if (!existsSync(hooksPath)) return undefined
const hooksStat = lstatSync(hooksPath)
if (!hooksStat.isDirectory() || hooksStat.isSymbolicLink()) {
throw new Error(`refusing to use non-directory or symlinked hooks path ${hooksPath}`)
}
if (!existsSync(markerPath)) {
throw new Error(`refusing to overwrite unowned hooks directory ${hooksPath}`)
}
const markerStat = lstatSync(markerPath)
const marker = markerStat.isFile() && !markerStat.isSymbolicLink() && markerStat.nlink === 1
? parseOwnershipMarker(readFileSync(markerPath, 'utf8'))
: undefined
if (marker === undefined) {
throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`)
}
for (const name of readdirSync(hooksPath)) {
if (name === OWNERSHIP_MARKER) continue
const entryPath = join(hooksPath, name)
const entryStat = lstatSync(entryPath)
if (!entryStat.isFile() || entryStat.isSymbolicLink() || entryStat.nlink !== 1) {
throw new Error(
`refusing to overwrite non-regular or multiply linked hook entry ${JSON.stringify(entryPath)}`,
)
}
}
return { markerPath, ...marker }
}
function ensureOwnedHooksDirectory(hooksPath) {
const inspected = inspectOwnedHooksDirectory(hooksPath)
if (inspected !== undefined) return inspected
mkdirSync(hooksPath, { mode: 0o700 })
const markerPath = join(hooksPath, OWNERSHIP_MARKER)
writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { flag: 'wx', mode: 0o600 })
return { markerPath, hooksPath }
}
function updateOwnershipMarker(markerPath, hooksPath) {
writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { mode: 0o600 })
}
function environmentWithoutCommandGitConfig() {
const env = { ...process.env }
for (const key of Object.keys(env)) {
const normalized = key.toUpperCase()
if (
normalized === 'GIT_CONFIG_PARAMETERS'
|| normalized === 'GIT_CONFIG_COUNT'
|| /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(normalized)
) {
delete env[key]
}
}
return env
}
function runLefthook(root, lefthook) {
const args = ['install', '--force']
const env = environmentWithoutCommandGitConfig()
// Node refuses to spawn Windows `.cmd` shims directly; the quoted path is
// re-parsed by cmd.exe, while POSIX can execute its extensionless shim.
const result = process.platform === 'win32'
? spawnSync(`"${lefthook}"`, args, { cwd: root, env, stdio: 'inherit', shell: true })
: spawnSync(lefthook, args, { cwd: root, env, stdio: 'inherit' })
if (result.status !== 0) throw commandFailure(lefthook, args, result)
}
function configSource(entry) {
return `${entry.origin}: ${JSON.stringify(entry.value)}`
}
function normalizedPath(path) {
const normalized = resolve(path)
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
function configOriginPath(origin, root) {
if (!origin.startsWith('file:')) return undefined
const originPath = origin.slice('file:'.length)
return isAbsolute(originPath) ? originPath : resolve(root, originPath)
}
function originIsFile(origin, root, configPath) {
const originPath = configOriginPath(origin, root)
return originPath !== undefined && normalizedPath(originPath) === normalizedPath(configPath)
}
function refuseInheritedHooksPath(entry) {
throw new Error(
`refusing to replace user-owned core.hooksPath (${configSource(entry)}). `
+ `Chain those hooks through lefthook.yml, or, if this inherited path may remain active only in other worktrees, `
+ `rerun with ${ALLOW_HOOKS_PATH_OVERRIDE}=1`,
)
}
function refuseScopedHooksPath(entry) {
if (entry.scope === 'command') {
throw new Error(
`refusing to replace command-scoped core.hooksPath (${configSource(entry)}); `
+ `${ALLOW_HOOKS_PATH_OVERRIDE} cannot override transient command configuration`,
)
}
if (entry.scope === 'worktree') {
throw new Error(
`refusing to replace worktree-scoped core.hooksPath (${configSource(entry)}); `
+ 'a worktree-specific custom path must be integrated or removed explicitly',
)
}
throw new Error(
`refusing to replace core.hooksPath from unsupported ${entry.scope} scope (${configSource(entry)})`,
)
}
async function main() {
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return
const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
if (probe.status !== 0) return
const root = stripGitLineTerminator(probe.stdout)
const isWindows = process.platform === 'win32'
const lefthook = join(root, 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook')
if (!existsSync(lefthook)) return
assertSupportedGit(root)
const gitDirectory = stripGitLineTerminator(git(['rev-parse', '--absolute-git-dir'], root).stdout)
const commonOutput = stripGitLineTerminator(git(['rev-parse', '--git-common-dir'], root).stdout)
const commonDirectory = isAbsolute(commonOutput) ? commonOutput : resolve(root, commonOutput)
const commonConfigPath = join(commonDirectory, 'config')
const worktreeConfigPath = join(gitDirectory, 'config.worktree')
const hooksPath = join(gitDirectory, HOOKS_DIRECTORY)
const releaseLock = await acquireInstallLock(commonDirectory)
let installationError
try {
assertCommonConfigFile(commonConfigPath)
assertWorktreeConfigFiles(
root,
commonDirectory,
commonConfigPath,
worktreeConfigPath,
)
const worktreeEntries = includedFileConfigEntries(root, worktreeConfigPath, 'core.hooksPath')
const includedWorktreeEntry = worktreeEntries.find(
entry => !originIsFile(entry.origin, root, worktreeConfigPath),
)
if (includedWorktreeEntry !== undefined) {
refuseScopedHooksPath({ ...includedWorktreeEntry, scope: 'worktree' })
}
const worktreePath = assertSingle(
worktreeEntries.map(entry => entry.value),
'worktree core.hooksPath',
)
let ownedHooksDirectory
if (worktreePath !== undefined && worktreePath !== hooksPath) {
ownedHooksDirectory = inspectOwnedHooksDirectory(hooksPath)
if (ownedHooksDirectory === undefined || ownedHooksDirectory.hooksPath !== worktreePath) {
refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath })
}
}
const directWorktreePathIsOwned = worktreePath !== undefined
&& (worktreePath === hooksPath || ownedHooksDirectory?.hooksPath === worktreePath)
const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath')
if (effectiveEntry !== undefined) {
const effectivePathIsOwned = effectiveEntry.scope === 'worktree'
&& effectiveEntry.value === worktreePath
&& directWorktreePathIsOwned
&& originIsFile(effectiveEntry.origin, root, worktreeConfigPath)
if (!effectivePathIsOwned) {
if (effectiveEntry.scope === 'command' || effectiveEntry.scope === 'worktree') {
refuseScopedHooksPath(effectiveEntry)
}
if (!['system', 'global', 'local'].includes(effectiveEntry.scope)) {
refuseScopedHooksPath(effectiveEntry)
}
if (process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1') {
refuseInheritedHooksPath(effectiveEntry)
}
}
}
const migration = planWorktreeConfigMigration(root, commonConfigPath)
ownedHooksDirectory = ensureOwnedHooksDirectory(hooksPath)
if (
worktreePath !== undefined
&& worktreePath !== hooksPath
&& ownedHooksDirectory.hooksPath !== worktreePath
) {
throw new Error(`hooks directory ownership changed while relocating ${JSON.stringify(worktreePath)}`)
}
applyWorktreeConfigMigration(root, commonConfigPath, migration)
let pathChanged = false
try {
git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
pathChanged = worktreePath !== hooksPath
const installedEntry = effectiveConfigEntry(root, 'core.hooksPath')
if (
installedEntry === undefined
|| installedEntry.scope !== 'worktree'
|| installedEntry.value !== hooksPath
|| !originIsFile(installedEntry.origin, root, worktreeConfigPath)
) {
throw new Error('new worktree-local core.hooksPath did not become the effective direct worktree value')
}
runLefthook(root, lefthook)
updateOwnershipMarker(ownedHooksDirectory.markerPath, hooksPath)
} catch (error) {
if (pathChanged) {
try {
if (worktreePath === undefined) {
git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root)
} else {
git(['config', '--worktree', 'core.hooksPath', worktreePath], root)
}
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
`Lefthook installation failed: ${String(error)}; `
+ `worktree hook rollback also failed: ${String(rollbackError)}`,
)
}
}
throw error
}
} catch (error) {
installationError = error
throw error
} finally {
try {
releaseLock()
} catch (releaseError) {
if (installationError !== undefined) {
throw new AggregateError(
[installationError, releaseError],
`Lefthook installation failed: ${String(installationError)}; installer lock release also failed: ${String(releaseError)}`,
)
}
throw releaseError
}
}
}
try {
await main()
} catch (error) {
console.error(`[install-lefthook] ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}

View File

@@ -0,0 +1,714 @@
import { spawn, spawnSync } from 'node:child_process'
import {
chmodSync,
existsSync,
linkSync,
mkdirSync,
mkdtempSync,
lstatSync,
readFileSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
const fixtures: string[] = []
interface Fixture {
container: string
env: NodeJS.ProcessEnv
linked: string
main: string
}
interface CommandResult {
status: number | null
stderr: string
stdout: string
}
afterEach(() => {
for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
})
function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult {
const result = spawnSync(command, args, { cwd, encoding: 'utf8', env })
return { status: result.status, stderr: result.stderr, stdout: result.stdout }
}
function gitResult(fixture: Fixture, cwd: string, args: string[]): CommandResult {
return commandResult('git', args, cwd, fixture.env)
}
function git(fixture: Fixture, cwd: string, args: string[]): string {
const result = gitResult(fixture, cwd, args)
if (result.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
}
return result.stdout.trim()
}
function write(path: string, content: string, mode?: number): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content, mode === undefined ? undefined : { mode })
}
function fakeLefthookSource(): string {
return `#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { join } from 'node:path'
if (process.argv.slice(2).join(' ') !== 'install --force') process.exit(64)
const rootOutput = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
const root = rootOutput.endsWith('\\n') ? rootOutput.slice(0, -1) : rootOutput
const forbiddenConfigKey = process.env.DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY
if (forbiddenConfigKey !== undefined) {
try {
execFileSync('git', ['config', '--get', forbiddenConfigKey], { encoding: 'utf8' })
process.exit(92)
} catch (error) {
if (error === null || typeof error !== 'object' || !('status' in error) || error.status !== 1) throw error
}
}
const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim()
mkdirSync(hooksPath, { recursive: true })
const running = join(hooksPath, '.fake-lefthook-running')
try {
writeFileSync(running, String(process.pid), { flag: 'wx' })
} catch {
process.exit(91)
}
const delay = Number(process.env.DSH_TEST_LEFTHOOK_DELAY_MS ?? 0)
if (delay > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
const shouldFail = process.env.DSH_TEST_LEFTHOOK_FAIL === '1'
if (!shouldFail) {
const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
const config = readFileSync(join(root, 'lefthook.yml'), 'utf8').trim()
const hook = \`#!/bin/sh\\n# root=\${root}\\n# binary=\${binary}\\n# config=\${config}\\nexit 0\\n\`
for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 })
}
if (existsSync(running)) unlinkSync(running)
if (process.env.DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG === '1') {
const configPath = execFileSync('git', ['rev-parse', '--git-path', 'config.worktree'], { encoding: 'utf8' }).trim()
writeFileSync(configPath, '[invalid\\n')
}
if (shouldFail) process.exit(77)
`
}
function installFakeLefthook(root: string): void {
const binDirectory = join(root, 'node_modules/.bin')
mkdirSync(binDirectory, { recursive: true })
writeFileSync(join(binDirectory, 'fake-lefthook.mjs'), fakeLefthookSource())
if (process.platform === 'win32') {
writeFileSync(
join(binDirectory, 'lefthook.cmd'),
`@echo off\r\n"${process.execPath}" "%~dp0\\fake-lefthook.mjs" %*\r\n`,
)
return
}
const shim = join(binDirectory, 'lefthook')
writeFileSync(shim, `#!/bin/sh\nexec "${process.execPath}" "$(dirname "$0")/fake-lefthook.mjs" "$@"\n`)
chmodSync(shim, 0o755)
}
function createFixture(names: { main?: string; linked?: string } = {}): Fixture {
const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
fixtures.push(container)
const main = join(container, names.main ?? 'main')
const linked = join(container, names.linked ?? 'linked')
const env: NodeJS.ProcessEnv = {
...process.env,
CI: 'false',
GITHUB_ACTIONS: 'false',
GIT_AUTHOR_EMAIL: 'hooks@example.test',
GIT_AUTHOR_NAME: 'Hooks Test',
GIT_COMMITTER_EMAIL: 'hooks@example.test',
GIT_COMMITTER_NAME: 'Hooks Test',
GIT_CONFIG_GLOBAL: join(container, 'global.gitconfig'),
GIT_CONFIG_NOSYSTEM: '1',
HOME: container,
XDG_CONFIG_HOME: join(container, '.config'),
}
const fixture = { container, env, linked, main }
mkdirSync(main)
git(fixture, container, ['init', main])
write(join(main, 'README.md'), '# fixture\n')
git(fixture, main, ['add', 'README.md'])
git(fixture, main, ['commit', '-m', 'fixture'])
git(fixture, main, ['worktree', 'add', '-b', 'linked', linked])
write(join(main, 'lefthook.yml'), 'main-worktree-config\n')
write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
installFakeLefthook(main)
installFakeLefthook(linked)
return fixture
}
function gitDirectory(fixture: Fixture, root: string): string {
return git(fixture, root, ['rev-parse', '--absolute-git-dir'])
}
function commonDirectory(fixture: Fixture): string {
const output = git(fixture, fixture.main, ['rev-parse', '--git-common-dir'])
return isAbsolute(output) ? output : resolve(fixture.main, output)
}
function hooksPath(fixture: Fixture, root: string): string {
return join(gitDirectory(fixture, root), 'dsh-hooks')
}
function installLockPath(fixture: Fixture): string {
return join(commonDirectory(fixture), 'dsh-lefthook-install.lock')
}
async function waitForPath(path: string): Promise<void> {
const deadline = Date.now() + 5_000
while (!existsSync(path)) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`)
await new Promise(resolveWait => setTimeout(resolveWait, 10))
}
}
function runInstaller(
fixture: Fixture,
root: string,
extraEnv: NodeJS.ProcessEnv = {},
): Promise<CommandResult> {
return new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, [installer], {
cwd: root,
env: { ...fixture.env, ...extraEnv },
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
child.on('error', reject)
child.on('close', (status) => { resolveResult({ status, stderr, stdout }) })
})
}
describe('worktree-local Lefthook installer', () => {
for (const [label, extraEnv] of [
['CI', { CI: 'true' }],
['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
] satisfies [string, NodeJS.ProcessEnv][]) {
it(`skips hook installation when ${label} marks an automated job`, async () => {
const fixture = createFixture()
const common = commonDirectory(fixture)
const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig')
git(fixture, fixture.main, [
'config',
'--local',
'includeIf.gitdir:/github/workspace/.git.path',
missingInclude,
])
const result = await runInstaller(fixture, fixture.main, extraEnv)
expect(result.status, result.stderr).toBe(0)
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0')
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
expect(existsSync(join(common, 'config.worktree'))).toBe(false)
})
}
it('isolates main and linked worktrees without changing legacy common hooks', async () => {
const fixture = createFixture()
const common = commonDirectory(fixture)
const legacyHook = join(common, 'hooks/pre-commit')
write(legacyHook, '#!/bin/sh\n# legacy hook\n', 0o755)
const mainInstall = await runInstaller(fixture, fixture.main)
const linkedInstall = await runInstaller(fixture, fixture.linked)
expect(mainInstall.status, mainInstall.stderr).toBe(0)
expect(linkedInstall.status, linkedInstall.stderr).toBe(0)
const mainHooks = hooksPath(fixture, fixture.main)
const linkedHooks = hooksPath(fixture, fixture.linked)
expect(mainHooks).not.toBe(linkedHooks)
expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')
const canonicalMain = git(fixture, fixture.main, ['rev-parse', '--show-toplevel'])
const canonicalLinked = git(fixture, fixture.linked, ['rev-parse', '--show-toplevel'])
expect(mainHook).toContain(`# root=${canonicalMain}`)
expect(mainHook).toContain('# config=main-worktree-config')
expect(mainHook).not.toContain(canonicalLinked)
expect(linkedHook).toContain(`# root=${canonicalLinked}`)
expect(linkedHook).toContain('# config=linked-worktree-config')
expect(linkedHook).not.toContain(canonicalMain)
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
const commonConfig = join(common, 'config')
expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion'])).toBe('1')
expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'extensions.worktreeConfig'])).toBe('true')
expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1)
const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked])
expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval)
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
})
it('serializes concurrent installs and keeps repeated output stable', async () => {
const fixture = createFixture()
const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' }
const first = await Promise.all([
runInstaller(fixture, fixture.main, delayed),
runInstaller(fixture, fixture.linked, delayed),
])
for (const result of first) expect(result.status, result.stderr).toBe(0)
const mainHookPath = join(hooksPath(fixture, fixture.main), 'pre-push')
const initialHook = readFileSync(mainHookPath, 'utf8')
const repeated = await Promise.all([
runInstaller(fixture, fixture.main, delayed),
runInstaller(fixture, fixture.main, delayed),
])
for (const result of repeated) expect(result.status, result.stderr).toBe(0)
expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook)
expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false)
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
})
it('repairs its owned absolute hook path after the checkout moves', async () => {
const fixture = createFixture()
const oldRoot = fixture.main
const first = await runInstaller(fixture, oldRoot)
expect(first.status, first.stderr).toBe(0)
const oldHooks = hooksPath(fixture, oldRoot)
const movedRoot = join(fixture.container, 'moved-main')
renameSync(oldRoot, movedRoot)
const moved = await runInstaller(fixture, movedRoot)
expect(moved.status, moved.stderr).toBe(0)
const movedHooks = hooksPath(fixture, movedRoot)
expect(movedHooks).not.toBe(oldHooks)
expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(movedHooks)
const canonicalMoved = git(fixture, movedRoot, ['rev-parse', '--show-toplevel'])
expect(readFileSync(join(movedHooks, 'pre-commit'), 'utf8')).toContain(`# root=${canonicalMoved}`)
expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain(
JSON.stringify(movedHooks),
)
})
it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => {
const fixture = createFixture()
const oldRoot = fixture.main
const first = await runInstaller(fixture, oldRoot)
expect(first.status, first.stderr).toBe(0)
const oldHooks = hooksPath(fixture, oldRoot)
const markerName = '.dsh-lefthook-owned'
const externalMarker = join(fixture.container, 'external-marker')
linkSync(join(oldHooks, markerName), externalMarker)
const externalContent = readFileSync(externalMarker, 'utf8')
const movedRoot = join(fixture.container, 'moved-main')
renameSync(oldRoot, movedRoot)
const result = await runInstaller(fixture, movedRoot)
expect(result.status).toBe(1)
expect(result.stderr).toContain('invalid ownership marker')
expect(readFileSync(externalMarker, 'utf8')).toBe(externalContent)
})
it.skipIf(process.platform === 'win32')('refuses aliased generated hooks before Lefthook can overwrite their targets', async () => {
for (const kind of ['symlink', 'hardlink'] as const) {
const fixture = createFixture()
const first = await runInstaller(fixture, fixture.main)
expect(first.status, first.stderr).toBe(0)
const hook = join(hooksPath(fixture, fixture.main), 'pre-commit')
const externalHook = join(fixture.container, `${kind}-external-hook`)
rmSync(hook)
write(externalHook, `external ${kind} target\n`)
if (kind === 'symlink') symlinkSync(externalHook, hook)
else linkSync(externalHook, hook)
const externalContent = readFileSync(externalHook, 'utf8')
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('non-regular or multiply linked hook entry')
expect(readFileSync(externalHook, 'utf8')).toBe(externalContent)
}
})
it('restores the marker-backed stale hook path when relocation reinstall fails', async () => {
const fixture = createFixture()
const oldRoot = fixture.main
const first = await runInstaller(fixture, oldRoot)
expect(first.status, first.stderr).toBe(0)
const oldHooks = hooksPath(fixture, oldRoot)
const markerName = '.dsh-lefthook-owned'
const previousMarker = readFileSync(join(oldHooks, markerName), 'utf8')
const movedRoot = join(fixture.container, 'moved-main')
renameSync(oldRoot, movedRoot)
const failed = await runInstaller(fixture, movedRoot, { DSH_TEST_LEFTHOOK_FAIL: '1' })
expect(failed.status).toBe(1)
expect(failed.stderr).toContain('exit status 77')
const movedHooks = hooksPath(fixture, movedRoot)
expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(oldHooks)
expect(readFileSync(join(movedHooks, markerName), 'utf8')).toBe(previousMarker)
})
it('refuses dormant repository extensions before upgrading the repository format', async () => {
const fixture = createFixture()
const commonConfig = join(commonDirectory(fixture), 'config')
git(fixture, fixture.main, ['config', 'extensions.dshUnknown', 'true'])
expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0)
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('dormant repository extension extensions.dshunknown')
expect(git(fixture, fixture.main, [
'config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion',
])).toBe('0')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
it('refuses direct core.worktree before enabling worktree config', async () => {
const fixture = createFixture()
const commonConfig = join(commonDirectory(fixture), 'config')
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.worktree', fixture.main])
const result = await runInstaller(fixture, fixture.linked)
expect(result.status).toBe(1)
expect(result.stderr).toContain('core.worktree is in the common config')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
it.skipIf(process.platform === 'win32')('refuses a symlinked common repository config before writing through it', async () => {
const fixture = createFixture()
const commonConfig = join(commonDirectory(fixture), 'config')
const externalConfig = join(fixture.container, 'external-common.gitconfig')
renameSync(commonConfig, externalConfig)
symlinkSync(externalConfig, commonConfig)
const externalContent = readFileSync(externalConfig, 'utf8')
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('common repository config')
expect(result.stderr).toContain('not a regular file')
expect(lstatSync(commonConfig).isSymbolicLink()).toBe(true)
expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
it('leaves stale installer locks for explicit recovery', async () => {
const fixture = createFixture()
const lockPath = installLockPath(fixture)
const completed = spawnSync(process.execPath, ['-e', ''])
expect(completed.status).toBe(0)
const staleRecord = `${String(completed.pid)} 00000000-0000-4000-8000-000000000000\n`
writeFileSync(lockPath, staleRecord)
const results = await Promise.all(Array.from(
{ length: 4 },
() => runInstaller(fixture, fixture.main),
))
for (const result of results) {
expect(result.status).toBe(1)
expect(result.stderr).toContain('stale Lefthook installer lock')
expect(result.stderr).toContain('remove it manually')
}
expect(readFileSync(lockPath, 'utf8')).toBe(staleRecord)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
})
it('leaves invalid installer locks for explicit recovery', async () => {
const fixture = createFixture()
const lockPath = installLockPath(fixture)
const invalidRecord = 'not an installer lock\n'
writeFileSync(lockPath, invalidRecord)
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('invalid Lefthook installer lock')
expect(result.stderr).toContain('remove it manually')
expect(readFileSync(lockPath, 'utf8')).toBe(invalidRecord)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
it('does not release an installer lock whose ownership changed', async () => {
const fixture = createFixture()
const lockPath = installLockPath(fixture)
const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running')
const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' })
await waitForPath(runningPath)
const replacementRecord = 'replacement owner\n'
writeFileSync(lockPath, replacementRecord)
const result = await install
expect(result.status).toBe(1)
expect(result.stderr).toContain('installer lock ownership changed')
expect(readFileSync(lockPath, 'utf8')).toBe(replacementRecord)
})
it.skipIf(process.platform === 'win32')('preserves trailing spaces in worktree paths', async () => {
const fixture = createFixture({ main: 'main ', linked: 'linked ' })
for (const root of [fixture.main, fixture.linked]) {
const result = await runInstaller(fixture, root)
expect(result.status, result.stderr).toBe(0)
expect(git(fixture, root, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, root))
}
})
it('preserves user-owned hook paths unless an inherited value is explicitly overridden', async () => {
const fixture = createFixture()
const customHook = join(fixture.main, 'custom-hooks/pre-commit')
write(customHook, '#!/bin/sh\n# custom hook\n', 0o755)
git(fixture, fixture.main, ['config', 'core.hooksPath', 'custom-hooks'])
const refused = await runInstaller(fixture, fixture.main)
expect(refused.status).toBe(1)
expect(refused.stderr).toContain('refusing to replace user-owned core.hooksPath')
expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1')
expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
const optedIn = await runInstaller(fixture, fixture.main, {
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
})
expect(optedIn.status, optedIn.stderr).toBe(0)
expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.main))
expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', 'linked-custom-hooks'])
const explicitWorktreePath = await runInstaller(fixture, fixture.linked, {
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
})
expect(explicitWorktreePath.status).toBe(1)
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks')
})
it('refuses to activate a sibling worktree dormant hook path', async () => {
const fixture = createFixture()
const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree')
const linkedHooks = join(fixture.linked, 'custom-hooks')
git(fixture, fixture.main, ['config', '--file', linkedConfig, 'core.hooksPath', linkedHooks])
expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('sibling dormant worktree config')
expect(result.stderr).toContain(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)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
it.skipIf(process.platform === 'win32')('refuses an active symlinked worktree config before writing through it', async () => {
const fixture = createFixture()
const commonConfig = join(commonDirectory(fixture), 'config')
const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree')
const externalConfig = join(fixture.container, 'external.gitconfig')
const externalContent = '[user]\n\tname = External owner\n'
write(externalConfig, externalContent)
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
symlinkSync(externalConfig, worktreeConfig)
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('active worktree config')
expect(result.stderr).toContain('not a regular file')
expect(lstatSync(worktreeConfig).isSymbolicLink()).toBe(true)
expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent)
expect(gitResult(fixture, fixture.main, [
'config', '--file', externalConfig, '--get', 'core.hooksPath',
]).status).toBe(1)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) {
for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) {
it(`ignores ${key} loaded through ${includeKey}`, async () => {
const fixture = createFixture()
const commonConfig = join(commonDirectory(fixture), 'config')
const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`)
const value = key === 'core.worktree' ? fixture.main : 'true'
git(fixture, fixture.main, ['config', '--file', includedConfig, key, value])
git(fixture, fixture.main, ['config', '--file', commonConfig, includeKey, includedConfig])
const result = await runInstaller(fixture, fixture.linked)
expect(result.status, result.stderr).toBe(0)
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(
hooksPath(fixture, fixture.linked),
)
expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(true)
})
}
}
it('ignores an inactive global includeIf that provides a hook path for another repository', async () => {
const fixture = createFixture()
const globalConfig = fixture.env.GIT_CONFIG_GLOBAL
if (globalConfig === undefined) throw new Error('fixture global config path is missing')
const includedConfig = join(fixture.container, 'other-repository.gitconfig')
const includedHooks = join(fixture.container, 'other-repository-hooks')
git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
git(fixture, fixture.main, [
'config',
'--file',
globalConfig,
`includeIf.gitdir:${join(fixture.container, 'other')}/.path`,
includedConfig,
])
const result = await runInstaller(fixture, fixture.linked)
expect(result.status, result.stderr).toBe(0)
expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked))
})
it('never overrides a command-scoped hook path', async () => {
const fixture = createFixture()
const commandHooks = join(fixture.container, 'command-hooks')
const sentinel = join(commandHooks, 'pre-commit')
write(sentinel, '#!/bin/sh\n# command-scope sentinel\n', 0o755)
const result = await runInstaller(fixture, fixture.main, {
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.hooksPath',
GIT_CONFIG_VALUE_0: commandHooks,
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('command-scoped core.hooksPath')
expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# command-scope sentinel\n')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
it('does not pass unrelated command-scoped Git config to Lefthook', async () => {
const fixture = createFixture()
const result = await runInstaller(fixture, fixture.main, {
DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY: 'dsh.testSentinel',
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'dsh.testSentinel',
GIT_CONFIG_VALUE_0: 'must-not-reach-lefthook',
})
expect(result.status, result.stderr).toBe(0)
expect(existsSync(join(hooksPath(fixture, fixture.main), 'pre-commit'))).toBe(true)
})
it('never overrides a hook path included by worktree config', async () => {
const fixture = createFixture()
const commonConfig = join(commonDirectory(fixture), 'config')
const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree')
const includedConfig = join(fixture.container, 'included-worktree.gitconfig')
const includedHooks = join(fixture.container, 'included-hooks')
const sentinel = join(includedHooks, 'pre-commit')
write(sentinel, '#!/bin/sh\n# included-worktree sentinel\n', 0o755)
git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
git(fixture, fixture.main, ['config', '--file', worktreeConfig, 'include.path', includedConfig])
const result = await runInstaller(fixture, fixture.main, {
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('worktree-scoped core.hooksPath')
expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks)
expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# included-worktree sentinel\n')
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
it('restores the previous hook lookup when Lefthook installation fails', async () => {
const fixture = createFixture()
const common = commonDirectory(fixture)
const legacyHook = join(common, 'hooks/pre-push')
write(legacyHook, '#!/bin/sh\n# legacy pre-push\n', 0o755)
const result = await runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_FAIL: '1' })
expect(result.status).toBe(1)
expect(result.stderr).toContain('exit status 77')
expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n')
})
it('reports installation and hook-path rollback failures together', async () => {
const fixture = createFixture()
const result = await runInstaller(fixture, fixture.main, {
DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG: '1',
DSH_TEST_LEFTHOOK_FAIL: '1',
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('Lefthook installation failed')
expect(result.stderr).toContain('exit status 77')
expect(result.stderr).toContain('worktree hook rollback also failed')
expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed')
})
it('refuses an unowned directory at the reserved worktree hook path', async () => {
const fixture = createFixture()
const reservedHook = join(hooksPath(fixture, fixture.main), 'pre-commit')
write(reservedHook, '#!/bin/sh\n# user content\n', 0o755)
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('refusing to overwrite unowned hooks directory')
expect(readFileSync(reservedHook, 'utf8')).toBe('#!/bin/sh\n# user content\n')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
})
it.skipIf(process.platform === 'win32')('rejects Git without config-scope support before mutation', async () => {
const fixture = createFixture()
const realGit = commandResult('which', ['git'], fixture.main, fixture.env).stdout.trim()
const fakeBin = join(fixture.container, 'fake-bin')
const fakeGit = join(fakeBin, 'git')
write(
fakeGit,
`#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.25.0"; exit 0; fi\nexec "${realGit}" "$@"\n`,
0o755,
)
const result = await runInstaller(fixture, fixture.main, {
PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`,
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('Git 2.26 or newer is required')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
})

View File

@@ -3,16 +3,27 @@
#
# curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh
#
# It clones the harness to ~/.dsh/source, checks host dependencies (git, Node,
# pnpm) and offers to install a missing pnpm, runs `pnpm install` (no build —
# the `bin/dsh` launcher runs the TypeScript source through the repo's own tsx),
# symlinks `dsh` onto PATH, records your API credentials in the Harness home
# (`~/.dsh`) dsh reads at boot, and drops you into `dsh`.
# 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` (no build — the `bin/dsh` launcher runs the TypeScript source
# through the repo's own tsx), 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,
# and drops you into `dsh`. 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 reuses that checkout and skips the clone/update, leaving
# the working tree untouched; DSH_REF is ignored in that mode. Setting DSH_SOURCE
# to a different directory opts back into the normal clone/update path.
# than `curl ... | sh`) it reuses that checkout in place and skips the
# clone/worktree setup, leaving the working tree untouched and linking `dsh`
# straight at that checkout's `bin/dsh` (no `current` indirection — the checkout
# is not a managed staging worktree under the source container); DSH_REF is
# ignored in that mode. Setting DSH_SOURCE to a different directory opts back
# into the normal clone/worktree path.
#
# 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;
@@ -21,7 +32,9 @@
# Overridable via environment:
# DSH_REF branch or tag to clone/checkout (default: master)
# DSH_REPO clone URL (default: the GitHub repo)
# DSH_SOURCE checkout location (default: ~/.dsh/source)
# 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 the personal config (default: ~/.dsh)
# FIXME(install-ts): Move the post-checkout workflow into a tested TypeScript
@@ -30,19 +43,31 @@ set -eu
DSH_REF=${DSH_REF:-master}
DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git}
# Remember whether the caller pinned a source location before defaulting it, so
# in-repo detection only repoints an unset DSH_SOURCE.
# DSH_SOURCE is the container directory that holds the master clone and every
# staging worktree; DSH_MASTER is the one real clone inside it. Remember whether
# the caller pinned the source container before defaulting it, so in-repo
# detection only repoints an unset DSH_SOURCE.
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 -> current/bin/dsh
# -> <staging>/bin/dsh. Fresh installs and upgrades repoint this one symlink; the
# PATH launcher itself is written once and never moves. In-repo reuse ignores it.
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
# --- 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),
# reuse that checkout and skip the clone. An explicit DSH_SOURCE pointing
# elsewhere opts back into the clone/update path.
# reuse that checkout in place — link `dsh` straight at it and skip the
# clone/worktree setup. An explicit DSH_SOURCE pointing elsewhere opts back into
# the clone/worktree path.
IN_REPO=0
if [ -f "$0" ]; then
_self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir=''
@@ -52,7 +77,9 @@ if [ -f "$0" ]; then
&& [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then
if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then
IN_REPO=1
DSH_SOURCE=$_repo_root
# In-repo reuse links `dsh` at this checkout as-is; the master/staging
# split applies only to fresh clone installs.
DSH_STAGING=$_repo_root
fi
fi
fi
@@ -120,7 +147,13 @@ confirm() {
}
printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}"
printf '%ssource %s @ %s%s\n' "$DIM" "$DSH_SOURCE" "$DSH_REF" "$RST"
if [ "$IN_REPO" = 1 ]; then
printf '%ssource %s (in-repo reuse) @ %s%s\n' "$DIM" "$DSH_STAGING" "$DSH_REF" "$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"
@@ -170,36 +203,73 @@ else
fi
fi
# --- 2. clone (or update) the source ------------------------------------------
# --- 2. clone the master and lay out the staging worktree ---------------------
# Fresh installs keep one real clone at $DSH_MASTER and check the running code
# out as a git worktree at $DSH_STAGING, so every checkout lives under
# $DSH_SOURCE and shares one object store. In-repo reuse links `dsh` at the
# existing checkout untouched.
if [ "$IN_REPO" = 1 ]; then
step "Using existing checkout at $DSH_SOURCE"
step "Using existing checkout at $DSH_STAGING"
info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)"
else
step "Fetching source into $DSH_SOURCE"
if [ -d "$DSH_SOURCE/.git" ]; then
info "existing checkout found — updating"
git -C "$DSH_SOURCE" fetch --depth 1 origin "$DSH_REF"
# Reset the checkout to the freshly fetched tip. FETCH_HEAD (not
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_SOURCE" checkout -q -B "$DSH_REF" FETCH_HEAD
git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD
else
mkdir -p "$(dirname "$DSH_SOURCE")"
git clone --depth 1 --branch "$DSH_REF" "$DSH_REPO" "$DSH_SOURCE"
mkdir -p "$DSH_SOURCE"
git clone --branch "$DSH_REF" "$DSH_REPO" "$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."
# The staging worktree owns the branch dsh runs from; the master clone stays on
# $DSH_REF as the fetch/upgrade base. Exclude the per-worktree merge lock in the
# master clone's info/exclude, which every linked worktree inherits.
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
_exclude="$DSH_MASTER/.git/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"
fi
# --- 3. install dependencies (no build; the launcher runs from source) --------
step "Installing dependencies with pnpm (this can take a while)"
( cd "$DSH_SOURCE" && pnpm install )
( cd "$DSH_STAGING" && pnpm install )
[ -x "$DSH_SOURCE/bin/dsh" ] || die "launcher $DSH_SOURCE/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?"
[ -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 ------------------------------------------------------
# Clone installs go 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. In-repo reuse links PATH
# straight at the checkout, since that checkout is not a managed worktree.
step "Linking dsh into $DSH_BIN_DIR"
mkdir -p "$DSH_BIN_DIR"
ln -sf "$DSH_SOURCE/bin/dsh" "$DSH_BIN_DIR/dsh"
info "linked $DSH_BIN_DIR/dsh -> $DSH_SOURCE/bin/dsh"
if [ "$IN_REPO" = 1 ]; then
DSH_LAUNCH_TARGET=$DSH_STAGING/bin/dsh
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
else
# 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"
fi
case ":$PATH:" in
*":$DSH_BIN_DIR:"*) ON_PATH=1 ;;

View File

@@ -21,6 +21,24 @@ export interface MarkdownHeadingLine extends MarkdownProseLine {
text: string
}
/** One code block from a parsed Markdown source. */
export interface MarkdownFence {
/** 1-based source line of the opening fence. */
line: number
/** Info-string language (its first word), null on a bare or indented block. */
lang: string | null
/** Full info string (e.g. `ts ignore-check`), '' on a bare or indented block. */
info: string
/** Block body without the fence delimiters. */
code: string
/**
* Whether a closing fence delimiter terminates the block — mdast silently
* closes an unterminated fence at end of file. False on indented
* (non-fenced) blocks, whose end line is code.
*/
closed: boolean
}
/** Parse GitHub-flavored Markdown with the repository's standard extensions. */
export function parseMarkdown(source: string): Nodes {
return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
@@ -38,6 +56,26 @@ export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | v
}
}
/**
* Extract every parsed code block with its info string, in document order.
* @param source - Markdown source to scan.
* @returns each block's opening line, language, info string, and body.
*/
export function markdownFences(source: string): MarkdownFence[] {
const lines = source.split('\n')
const fences: MarkdownFence[] = []
visitMarkdown(parseMarkdown(source), (node) => {
if (node.type !== 'code' || node.position === undefined) return
const lang = node.lang ?? null
const meta = node.meta ?? ''
const info = lang === null ? '' : meta === '' ? lang : `${lang} ${meta}`
const endLine = lines[node.position.end.line - 1] ?? ''
const closed = /^ {0,3}(`{3,}|~{3,})\s*$/.test(endLine)
fences.push({ line: node.position.start.line, lang, info, code: node.value, closed })
})
return fences
}
/** Text a reader sees from one Markdown node; raw HTML itself contributes none. */
function renderedText(node: Nodes): string {
if (node.type === 'text' || node.type === 'inlineCode') return node.value
@@ -115,27 +153,22 @@ function hasRenderedTextOutsideComments(raw: string, ranges: readonly ColumnRang
}
/**
* Return source lines outside backtick or tilde fences and HTML comments.
* Return source lines outside code blocks and HTML comments.
* @param source - Markdown source whose prose should be retained verbatim.
* @returns unfenced lines with their original 1-based locations.
*/
export function markdownProseLines(source: string): MarkdownProseLine[] {
let fence: { marker: '`' | '~'; length: number } | undefined
const kept: MarkdownProseLine[] = []
const rawLines = source.split('\n')
const comments = htmlCommentRanges(source, rawLines)
const fenced = new Set<number>()
visitMarkdown(parseMarkdown(source), (node) => {
if (node.type !== 'code' || node.position === undefined) return
for (let line = node.position.start.line; line <= node.position.end.line; line += 1) fenced.add(line)
})
const kept: MarkdownProseLine[] = []
rawLines.forEach((raw, i) => {
const token = /^ {0,3}(`{3,}|~{3,})/.exec(raw)?.[1]
if (token !== undefined) {
const marker = token[0] as '`' | '~'
if (fence === undefined) {
fence = { marker, length: token.length }
} else if (marker === fence.marker && token.length >= fence.length) {
fence = undefined
}
return
}
if (fence === undefined && hasRenderedTextOutsideComments(raw, comments.get(i + 1))) {
if (fenced.has(i + 1)) return
if (hasRenderedTextOutsideComments(raw, comments.get(i + 1))) {
kept.push({ index: i + 1, raw })
}
})

View File

@@ -1,55 +0,0 @@
/**
* Shared fenced-code-block extractor for the Markdown doc gates
* (currently `doc-typecheck.ts`; future Markdown gates can share it). One scanner, per-gate
* classification: each gate maps a fence info string (` ```ts `,
* ` ```yaml ignore-check `, …) to its own kind tag and receives every
* classified block with its 1-based opening-fence line.
*/
import { readFileSync } from 'node:fs'
/** One extracted fenced block, classified by the caller's `classify`. */
export interface Fence<K> {
/** 1-based line of the opening fence. */
line: number
kind: K
code: string
}
/**
* Extract every fenced block of `absPath` whose info string `classify` maps
* to a kind. Blocks classified `null` are skipped (their bodies are still
* consumed, so an unrelated fence can never leak into a tracked one).
*
* @param absPath — absolute path of the Markdown file.
* @param classify — info string (trimmed, e.g. `ts ignore-check`) → kind, or
* null for fences this gate does not track.
* @returns the classified blocks in document order.
*/
export function extractFences<K>(absPath: string, classify: (info: string) => K | null): Fence<K>[] {
const lines = readFileSync(absPath, 'utf8').split('\n')
const blocks: Fence<K>[] = []
let open: { line: number; kind: K; body: string[] } | null = null
let skipping = false
lines.forEach((raw, i) => {
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
if (!fence) {
if (open) open.body.push(raw)
return
}
if (open) {
blocks.push({ line: open.line, kind: open.kind, code: open.body.join('\n') })
open = null
return
}
if (skipping) {
skipping = false
return
}
const kind = classify((fence[2] ?? '').trim())
if (kind !== null) open = { line: i + 1, kind, body: [] }
else skipping = true
})
return blocks
}

View File

@@ -2,19 +2,23 @@
import {
globSync,
readFileSync,
readdirSync,
readFileSync,
statSync,
} from 'node:fs'
import { availableParallelism } from 'node:os'
import { dirname, relative, resolve, sep } from 'node:path'
import { parseArgs } from 'node:util'
import { publint, type Message, type PackFile } from 'publint'
import { formatMessage } from 'publint/utils'
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
const repositoryRoot = resolve(import.meta.dirname, '..')
const options = parseOptions(process.argv.slice(2))
const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
const { values: options } = parseArgs({
args: process.argv.slice(2),
options: { 'packages-root': { type: 'string' } },
})
const packagesRoot = resolve(options['packages-root'] ?? repositoryRoot)
interface PackageTarget {
path: string
@@ -88,7 +92,12 @@ function publicationFiles(target: PackageTarget): PackFile[] {
function addPath(path: string, paths: Set<string>): void {
const stat = statSync(path)
if (stat.isDirectory()) {
for (const entry of readdirSync(path)) addPath(resolve(path, entry), paths)
// readdirSync, not globSync: `**/*` skips dot-prefixed segments, but npm
// pack publishes dotfiles inside included directories, and this view must
// match what npm publishes.
for (const entry of readdirSync(path, { recursive: true, withFileTypes: true })) {
if (entry.isFile()) paths.add(resolve(entry.parentPath, entry.name))
}
} else if (stat.isFile()) {
paths.add(path)
}
@@ -144,20 +153,6 @@ function printResult(result: PublintResult): void {
if (result.status === 'passed' && result.messages.length === 0) console.log('All good!')
}
function parseOptions(args: string[]): Map<string, string> {
const parsed = new Map<string, string>()
for (let index = 0; index < args.length; index += 2) {
const name = args[index]
const value = args[index + 1]
if (name !== '--packages-root' || value === undefined || value.startsWith('--')) {
throw new Error(`publint-all: expected [--packages-root PATH], got ${JSON.stringify(args)}.`)
}
if (parsed.has(name)) throw new Error(`publint-all: duplicate option ${name}.`)
parsed.set(name, value)
}
return parsed
}
const packages = workspacePackages()
const concurrency = publintConcurrency(packages.length)
console.log(`publint-all: linting ${packages.length} package(s) with ${concurrency} worker(s).`)

View File

@@ -36,8 +36,14 @@ return (ctx) => {
name: 'snapshot_double',
description: 'Double a number for executable snapshot verification.',
parameters: { value: { type: 'number', required: true } },
output: {
schema: { type: 'number' },
render(_args, value) {
return [{ type: 'text', text: String(value) }]
}
},
async execute(args) {
return [{ type: 'text', text: String(args.value * 2) }]
return args.value * 2
}
}))
}
@@ -182,14 +188,17 @@ def advanced_tool_followup(
if not call_id.startswith("advanced-"):
return None
if call_id == "advanced-mount" and tool_name == "cordis_mount":
if "mounted dyn-1" not in tool_text:
raise AssertionError(f"cordis_mount returned no mount id: {tool_text}")
if "Temporary Plugin dyn-1 is running" not in tool_text:
raise AssertionError(f"cordis_mount returned no temporary Plugin id: {tool_text}")
assert_advertised_tool(body, "run_code")
assert_advertised_tool(body, "snapshot_double")
return tool_call_chunks(
"advanced-code",
"run_code",
{"code": "return await tools.snapshot_double({ value: 21 })"},
{
"code": "return await tools.snapshot_double({ value: 21 })",
"description": "Run the temporary Plugin tool",
},
)
if call_id == "advanced-code" and tool_name == "run_code":
if "42" not in tool_text:
@@ -228,8 +237,8 @@ def advanced_tool_followup(
{"id": "dyn-1"},
)
if call_id == "advanced-unmount" and tool_name == "cordis_unmount":
if "unmounted dyn-1" not in tool_text:
raise AssertionError(f"cordis_unmount returned no disposal result: {tool_text}")
if "Temporary Plugin dyn-1 was unmounted and removed." not in tool_text:
raise AssertionError(f"cordis_unmount returned no unmount result: {tool_text}")
if "snapshot_double" in advertised_tool_names(body):
raise AssertionError("snapshot_double remained advertised after cordis_unmount")
return text_chunks(SNAPSHOT_FINAL_TEXT)
@@ -736,8 +745,6 @@ def scrub_snapshot_header(value: dict[object, object]) -> None:
tool.get("name") if isinstance(tool, dict) else "{{tools}}"
for tool in tools
]
if isinstance(header.get("messagePrefix"), list):
header["messagePrefix"] = ["{{messagePrefix}}" for _ in header["messagePrefix"]]
def render_jsonl(records: list[object]) -> str:

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,14 @@
{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"}
{"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"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"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":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"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,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"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"}}}

View File

@@ -1,13 +1,14 @@
{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"}
{"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"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"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":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"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,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"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"}}}

View File

@@ -1,66 +1,68 @@
{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}"}
{"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"}},"surfaceOp":"append"}
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"initial"}}
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":5,"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 async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}}
{"type":"assistant/chunk","seq":6,"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 async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"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 async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
{"type":"tool/call","seq":10,"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 async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"<anonymous>\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"change"}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":16,"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 })\"}"}}}
{"type":"assistant/chunk","seq":17,"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 })\"}"}}}}
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}
{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}
{"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}}
{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[21],"surfaceOp":"append"}
{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":27,"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":28,"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":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"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.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}
{"type":"tool/call","seq":32,"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":33,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"}
{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}}
{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}}
{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":37,"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":38,"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":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"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\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}
{"type":"tool/call","seq":42,"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":43,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[42],"surfaceOp":"append"}
{"type":"step/end","seq":44,"time":0,"data":{"turn":1,"step":4}}
{"type":"step/start","seq":45,"time":0,"data":{"turn":1,"step":5}}
{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":47,"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":48,"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":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":51,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[46,47,48,49,50],"surfaceOp":"append"}
{"type":"tool/call","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
{"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"<anonymous>\")"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"}
{"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":5}}
{"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":6}}
{"type":"request/header","seq":56,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"change"}}
{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}
{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":62,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[57,58,59,60,61],"surfaceOp":"append"}
{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}}
{"type":"turn/end","seq":64,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","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":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"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,"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\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"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,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"<anonymous>\"; available until unmounted or DSH restarts)."}],"isError":false},"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":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"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,"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\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"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,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false},"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,"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.\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"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,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"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,"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\"}}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"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,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"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,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"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,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"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":["bash","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","task_kill","task_list","task_output","workflow"],"messagePrefix":["{{messagePrefix}}"]},"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,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"provenance":{"provider":"deepseek","model":"smoke-model"},"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"}}}

File diff suppressed because one or more lines are too long

View File

@@ -46,6 +46,26 @@
"symbol": "LlmModelContext",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "ReasoningEffortId",
"source": "packages/llm/llm/src/brand.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "LlmReasoningEffortInfo",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "LlmModelReasoningInfo",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "LlmResolvedModelInfo",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "GenerateOptions",
@@ -66,21 +86,21 @@
"symbol": "SessionEvent",
"source": "packages/core/session/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "SendTarget",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "InboxPlacement",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "SendOptions",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "InjectOptions",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "ResolvedAgentInput",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "AgentMessageId",
@@ -106,11 +126,6 @@
"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",
@@ -118,7 +133,7 @@
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "ContinuationDecision",
"symbol": "RequestErrorAction",
"source": "packages/core/agent/src/types.ts"
},
{
@@ -126,16 +141,6 @@
"symbol": "RequestError",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "RequestErrorDecision",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "ContinuationStop",
"source": "packages/core/agent/src/types.ts"
},
{
"doc": "docs/core-data-structures/core.md",
"symbol": "SessionStartSource",
@@ -292,6 +297,11 @@
"source": "packages/llm/llm/src/assembler.ts",
"projection": "public-api"
},
{
"doc": "docs/core-data-structures/llm-streaming.md",
"symbol": "PreparedLlmCall",
"source": "packages/llm/llm/src/index.ts"
},
{
"doc": "docs/core-data-structures/llm-streaming.md",
"symbol": "LlmAdapter",
@@ -310,7 +320,7 @@
},
{
"doc": "docs/core-data-structures/session.md",
"symbol": "PromptMessageData",
"symbol": "UserMessageData",
"source": "packages/core/session/src/types.ts"
},
{
@@ -759,16 +769,6 @@
"symbol": "StoredImageAttachment",
"source": "packages/attachment/attachment/src/types.ts"
},
{
"doc": "docs/core-data-structures/bash.md",
"symbol": "DshEnvironmentKey",
"source": "packages/bash/bash/src/types.ts"
},
{
"doc": "docs/core-data-structures/bash.md",
"symbol": "DshEnvironment",
"source": "packages/bash/bash/src/types.ts"
},
{
"doc": "docs/core-data-structures/bash.md",
"symbol": "BashExecRequest",
@@ -789,11 +789,6 @@
"symbol": "BashSandboxInfo",
"source": "packages/bash/bash/src/types.ts"
},
{
"doc": "docs/core-data-structures/bash.md",
"symbol": "CollectedOutput",
"source": "packages/bash/bash/src/types.ts"
},
{
"doc": "docs/core-data-structures/bash.md",
"symbol": "BashProcess",
@@ -1278,6 +1273,71 @@
"doc": "docs/core-data-structures/session-query.md",
"symbol": "SessionSearchHit",
"source": "packages/session-query/session-query/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessSpawnSpec",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessHandle",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessOutputReader",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessOutputRead",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessOutcome",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "DshEnvironmentKey",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "DshEnvironment",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "CollectedOutput",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessStdinMode",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessCollect",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessOutputMode",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessStdio",
"source": "packages/subprocess/subprocess/src/types.ts"
},
{
"doc": "docs/core-data-structures/subprocess.md",
"symbol": "SubprocessCollectedOutputs",
"source": "packages/subprocess/subprocess/src/types.ts"
}
]
}

View File

@@ -13,11 +13,15 @@ import {
} from 'node:fs'
import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { parseArgs } from 'node:util'
const repositoryRoot = resolve(import.meta.dirname, '..')
const options = parseOptions(process.argv.slice(2))
const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
const loaderUrl = options.get('--loader-url')
const { values: options } = parseArgs({
args: process.argv.slice(2),
options: { 'packages-root': { type: 'string' }, 'loader-url': { type: 'string' } },
})
const packagesRoot = resolve(options['packages-root'] ?? repositoryRoot)
const loaderUrl = options['loader-url']
?? pathToFileURL(resolve(repositoryRoot, 'vendor/loader/lib/index.js')).href
const failures = []
const manifests = globSync('packages/*/*/package.json', { cwd: packagesRoot }).sort()
@@ -77,21 +81,6 @@ if (failures.length > 0) {
console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`)
function parseOptions(args) {
const allowed = new Set(['--packages-root', '--loader-url'])
const parsed = new Map()
for (let index = 0; index < args.length; index += 2) {
const name = args[index]
const value = args[index + 1]
if (!allowed.has(name) || value === undefined || value.startsWith('--')) {
throw new Error(`verify-built-package-invariants: expected [--packages-root PATH] [--loader-url URL], got ${JSON.stringify(args)}.`)
}
if (parsed.has(name)) throw new Error(`verify-built-package-invariants: duplicate option ${name}.`)
parsed.set(name, value)
}
return parsed
}
function copyDeclaredLibFiles(packageDir, stagedPackageDir, files) {
for (const pattern of files) {
if (!pattern.startsWith('lib/')) continue

View File

@@ -14,8 +14,8 @@
* pnpm exec tsx scripts/verify-client-domain-graph.ts
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { globSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve, sep } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const CLIENT_DIR = join(root, 'packages/client')
@@ -28,15 +28,11 @@ const ASSEMBLY_FILES = new Set(['apply.ts', 'index.ts', 'index.tsx'])
interface Violation { file: string; imported: string; reason: string }
/** Recursively list .ts/.tsx files under dir (relative paths). */
function listSources(dir: string, prefix = ''): string[] {
const out: string[] = []
for (const name of readdirSync(dir)) {
const full = join(dir, name)
const rel = prefix ? `${prefix}/${name}` : name
if (statSync(full).isDirectory()) out.push(...listSources(full, rel))
else if (/\.tsx?$/.test(name) && !/\.legacy\./.test(name)) out.push(rel)
}
return out
function listSources(dir: string): string[] {
return globSync('**/*.{ts,tsx}', { cwd: dir })
.map(rel => rel.split(sep).join('/'))
.filter(rel => !/\.legacy\./.test(rel.slice(rel.lastIndexOf('/') + 1)))
.sort()
}
/** First path segment of a client-relative file, or '' for top-level files. */

View File

@@ -25,6 +25,7 @@ const PATTERNS = [
'AGENTS.md',
'packages/AGENTS.md',
'.agents/skills/**/*.md',
'skills/**/*.md',
]
/** A broken relative link: a target path that does not resolve to a file. */

View File

@@ -26,6 +26,7 @@ const PATTERNS = [
'AGENTS.md',
'packages/AGENTS.md',
'.agents/skills/**/*.md',
'skills/**/*.md',
]
interface Block {

View File

@@ -5,7 +5,7 @@
* outside the check.
*/
import { existsSync, readdirSync } from 'node:fs'
import { existsSync, globSync } from 'node:fs'
import { resolve } from 'node:path'
import {
findReferenceViolations,
@@ -41,12 +41,8 @@ const isExcluded = (p: string): boolean =>
*/
function realPackageNames(): Set<string> {
const names = new Set<string>()
const pkgRoot = resolve(root, 'packages')
for (const group of readdirSync(pkgRoot, { withFileTypes: true })) {
if (!group.isDirectory()) continue
for (const pkg of readdirSync(resolve(pkgRoot, group.name), { withFileTypes: true })) {
if (pkg.isDirectory()) names.add(pkg.name)
}
for (const pkg of globSync('packages/*/*', { cwd: root, withFileTypes: true })) {
if (pkg.isDirectory()) names.add(pkg.name)
}
return names
}

View File

@@ -59,6 +59,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.' },
@@ -79,20 +80,25 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'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.' },
'packages/lsp/lsp-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-lsp.' },
'packages/subprocess/subprocess': { kind: 'indirect', reason: 'The seam delegates all 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/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' },
'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.' },
'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' },
'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' },
'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry 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/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' },
'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.' },

View File

@@ -3,8 +3,9 @@
* peer in its dependency graph. With auto peer installation disabled, a missing
* root peer can otherwise fail only when Cordis loads the packaged plugin.
*/
import { readFile, readdir } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { globSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
interface PackageManifest {
@@ -72,15 +73,9 @@ if (failures.length > 0) {
console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`)
async function loadWorkspacePackages(): Promise<Map<string, WorkspacePackage>> {
const paths: string[] = []
for (const group of await childDirectories(join(root, 'packages'))) {
for (const packageDir of await childDirectories(join(root, 'packages', group))) {
paths.push(join(root, 'packages', group, packageDir, 'package.json'))
}
}
for (const packageDir of await childDirectories(join(root, 'vendor'))) {
paths.push(join(root, 'vendor', packageDir, 'package.json'))
}
const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
.sort()
.map(relative => resolve(root, relative))
const result = new Map<string, WorkspacePackage>()
for (const path of paths) {
const manifest = await loadManifest(path)
@@ -89,11 +84,6 @@ async function loadWorkspacePackages(): Promise<Map<string, WorkspacePackage>> {
return result
}
async function childDirectories(path: string): Promise<string[]> {
const entries = await readdir(path, { withFileTypes: true })
return entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort()
}
async function loadManifest(path: string): Promise<PackageManifest> {
return JSON.parse(await readFile(path, 'utf8')) as PackageManifest
}

View File

@@ -11,6 +11,7 @@
import { globSync, readFileSync, existsSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
import { markdownFences } from './markdown.ts'
import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
import { isArchivedAgentNotePath } from './repo-files.ts'
@@ -81,42 +82,27 @@ function blockSymbol(code: string): string | null {
/** Extract every source-equivalence block from one Markdown file. */
function extractEquivBlocks(docRel: string): EquivBlock[] {
const text = readFileSync(resolve(root, docRel), 'utf8')
const lines = text.split('\n')
const blocks: EquivBlock[] = []
let open: { line: number; body: string[]; projection?: 'public-api' } | null = null
for (let i = 0; i < lines.length; i++) {
const raw = lines[i] ?? ''
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
if (!fence) {
if (open) open.body.push(raw)
continue
for (const fence of markdownFences(readFileSync(resolve(root, docRel), 'utf8'))) {
if (fence.info === 'ts type-equiv public-api') {
throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — use the concise \`ts public-api\` fence`)
}
if (open) {
const code = open.body.join('\n')
const symbol = blockSymbol(code)
if (!symbol) {
throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`)
}
blocks.push({
doc: docRel,
line: open.line,
symbol,
code,
...(open.projection === undefined ? {} : { projection: open.projection }),
})
open = null
continue
if (fence.info !== 'ts type-equiv' && fence.info !== 'ts public-api') continue
if (!fence.closed) {
throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — unterminated type-equivalence fence (missing closing \`\`\`)`)
}
const info = (fence[2] ?? '').trim()
if (info === 'ts type-equiv public-api') {
throw new Error(`verify-type-equiv: ${docRel}:${i + 1} — use the concise \`ts public-api\` fence`)
const symbol = blockSymbol(fence.code)
if (symbol === null) {
throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — type-equiv block has no parseable interface/type/class declaration`)
}
if (info === 'ts type-equiv') open = { line: i + 1, body: [] }
if (info === 'ts public-api') open = { line: i + 1, body: [], projection: 'public-api' }
blocks.push({
doc: docRel,
line: fence.line,
symbol,
code: fence.code,
...(fence.info === 'ts public-api' ? { projection: 'public-api' as const } : {}),
})
}
if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`)
return blocks
}

223
scripts/wine-windows-gates.sh Executable file
View File

@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# Run the blocking Windows gates (workspace build, production site) with real
# win-x64 Node.js under Wine — the same script the pull-request `windows` job
# in ci.yml executes and the optional local gate `pnpm run check:windows-wine`
# wraps. Owning rationale, fidelity limits, and measured timings:
# .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
#
# The working tree is never mutated: tracked plus untracked-unignored files
# are snapshotted into a scratch directory, the Wine-specific pnpm overrides
# (hoisted layout, win32-x64 platform packages) are appended to the SNAPSHOT's
# pnpm-workspace.yaml, and the install and gates run there against the shared
# pnpm store. The Wine prefix and the checksum-verified Windows Node zip
# persist in .cache/wine-windows/ so reruns skip provisioning.
#
# Environment: DSH_WINE_NODE_MAJOR (default $PRIMARY_NODE_VERSION, then 24)
# picks the Windows Node line; DSH_WINE_GATE_CACHE_DIR relocates the cache;
# DSH_WINE_GATE_KEEP=1 preserves the scratch tree for inspection.
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
node_major="${DSH_WINE_NODE_MAJOR:-${PRIMARY_NODE_VERSION:-24}}"
cache_dir="${DSH_WINE_GATE_CACHE_DIR:-$repo_root/.cache/wine-windows}"
export WINEDEBUG='-all'
export WINEARCH=win64
# Skip Wine Mono / Gecko installers: Node needs neither.
export WINEDLLOVERRIDES='mscoree,mshtml='
export WINEPREFIX="$cache_dir/prefix"
# ---- preflight: fail loud before any expensive work --------------------
wine_bin=''
for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do
if [ -n "$candidate" ] && [ -x "$candidate" ]; then wine_bin="$candidate"; break; fi
done
# GNU coreutils sha256sum on Linux; perl shasum ships with macOS. Both
# accept the same "<hash> <file>" --check input.
checksum_tool=''
if command -v sha256sum > /dev/null; then
checksum_tool='sha256sum'
elif command -v shasum > /dev/null; then
checksum_tool='shasum'
fi
missing=()
[ -n "$wine_bin" ] || missing+=('wine (apt: wine | brew: wine-stable)')
command -v curl > /dev/null || missing+=('curl')
command -v unzip > /dev/null || missing+=('unzip')
[ -n "$checksum_tool" ] || missing+=('sha256sum or shasum (apt: coreutils | macOS ships shasum)')
if ! command -v pnpm > /dev/null; then corepack enable > /dev/null 2>&1 || true; fi
command -v pnpm > /dev/null || missing+=('pnpm (corepack enable)')
if (( ${#missing[@]} > 0 )); then
printf 'wine-windows-gates: missing required tool: %s\n' "${missing[@]}" >&2
exit 1
fi
# Verify file $2 against SHA-256 hex $1 with whichever tool preflight found.
verify_sha256() {
case "$checksum_tool" in
sha256sum) printf '%s %s\n' "$1" "$2" | sha256sum --check - > /dev/null ;;
shasum) printf '%s %s\n' "$1" "$2" | shasum -a 256 --check - > /dev/null ;;
esac
}
scratch="$(mktemp -d "${TMPDIR:-/tmp}/dsh-wine-gates.XXXXXX")"
cleanup() {
wineserver -k > /dev/null 2>&1 || true
if [ "${DSH_WINE_GATE_KEEP:-0}" = '1' ]; then
echo "wine-windows-gates: scratch tree kept at $scratch"
else
rm -rf "$scratch"
fi
}
trap cleanup EXIT
mkdir -p "$cache_dir" "$scratch/logs"
# ---- provision Windows Node, boot Wine, snapshot + install concurrently ----
provision_node() {
# Latest release of the primary line, checksum-verified against the same
# dist directory. Offline runs fall back to the newest cached zip, loudly.
local version zip
version="$(curl -fsSL --max-time 30 https://nodejs.org/dist/index.json 2> /dev/null \
| node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{const v=JSON.parse(d).find(r=>r.version.startsWith('v$node_major.'));if(v)console.log(v.version)})" \
|| true)"
if [ -n "$version" ]; then
zip="$cache_dir/node-$version-win-x64.zip"
if [ ! -f "$zip" ]; then
curl -fsSL -o "$zip.tmp" "https://nodejs.org/dist/$version/node-$version-win-x64.zip"
local expected
expected="$(curl -fsSL "https://nodejs.org/dist/$version/SHASUMS256.txt" \
| awk -v a="node-$version-win-x64.zip" '$2 == a { print $1; exit }')"
[ -n "$expected" ] || { echo "wine-windows-gates: no SHASUMS256 entry for node-$version-win-x64.zip" >&2; exit 1; }
verify_sha256 "$expected" "$zip.tmp"
mv "$zip.tmp" "$zip"
fi
else
zip="$(ls -t "$cache_dir"/node-v"$node_major".*-win-x64.zip 2> /dev/null | head -1 || true)"
[ -n "$zip" ] || { echo "wine-windows-gates: nodejs.org unreachable and no cached Windows Node v$node_major zip in $cache_dir" >&2; exit 1; }
echo "wine-windows-gates: nodejs.org unreachable; using cached $(basename "$zip")" >&2
fi
unzip -q -o "$zip" -d "$scratch/node-win"
echo "$scratch/node-win/$(basename "$zip" .zip)/node.exe" > "$scratch/node-win-path"
}
boot_wine() {
"$wine_bin" wineboot --init > /dev/null 2>&1 || true
wineserver -w || true
}
snapshot_and_install() {
# Tracked + untracked-unignored files, minus agent-session litter; the
# existence filter drops paths staged as deleted. Then the Wine-specific
# install-time overrides go on the SNAPSHOT only: hoisted because Windows
# Node under Wine does not realpath pnpm's isolated-layout symlinks, and
# win32-x64 so the Windows esbuild/rolldown/rollup binaries materialize.
# Neither is recorded in the lockfile, so --frozen-lockfile stays valid;
# --ignore-scripts skips host lifecycle scripts no gate loads.
git -C "$repo_root" ls-files -z --cached --others --exclude-standard -- . ':!:.claude' ':!:.codex' \
| while IFS= read -r -d '' file; do [ -e "$repo_root/$file" ] && printf '%s\0' "$file"; done \
| tar -C "$repo_root" --null --files-from=- -cf - \
| tar -C "$scratch/tree" -xf -
cat >> "$scratch/tree/pnpm-workspace.yaml" << 'EOF'
nodeLinker: hoisted
supportedArchitectures:
os: [current, win32]
cpu: [current, x64]
EOF
(cd "$scratch/tree" && pnpm install --frozen-lockfile --ignore-scripts > "$scratch/logs/install.log" 2>&1) \
|| { tail -40 "$scratch/logs/install.log" >&2; return 1; }
}
mkdir "$scratch/tree"
start=$SECONDS
provision_node & node_pid=$!
boot_wine & wine_pid=$!
snapshot_and_install & install_pid=$!
# Wait for EVERY child before judging any: a bare `wait` under set -e would
# exit on the first failure and let the EXIT trap delete $scratch while the
# other children still run inside it. Named statuses also make the report
# point at the root cause instead of a downstream symptom.
node_status=0; wait "$node_pid" || node_status=$?
wine_status=0; wait "$wine_pid" || wine_status=$?
install_status=0; wait "$install_pid" || install_status=$?
provision_failed=0
report_provision() {
if (( $2 != 0 )); then
echo "wine-windows-gates: FAILED $1 (exit $2)" >&2
provision_failed=$2
fi
}
report_provision 'Windows Node provisioning' "$node_status"
report_provision 'wineboot' "$wine_status"
report_provision 'workspace snapshot + pnpm install' "$install_status"
if (( provision_failed != 0 )); then exit "$provision_failed"; fi
node_win="$(cat "$scratch/node-win-path")"
echo "wine-windows-gates: provisioned in $((SECONDS - start))s (wine $("$wine_bin" --version 2> /dev/null), node $(basename "$(dirname "$node_win")"))"
# ---- resolve entrypoints, lay the vue link, smoke ------------------------
# Node under Wine cannot attach stdio to pipes the caller owns (Socket open
# EBADF at bootstrap), so every invocation routes stdio through a file.
wine_node() {
local log="$1"
shift
local status=0
"$wine_bin" "$node_win" "$@" < /dev/null > "$log" 2>&1 || status=$?
return "$status"
}
cd "$scratch/tree"
tsc_js='node_modules/typescript/bin/tsc'
tsdown_js='node_modules/tsdown/dist/run.mjs'
vitepress_js='node_modules/vitepress/bin/vitepress.js'
[ -f "$vitepress_js" ] || vitepress_js='website/node_modules/vitepress/bin/vitepress.js'
for entry in "$tsc_js" "$tsdown_js" "$vitepress_js"; do
[ -f "$entry" ] || { echo "wine-windows-gates: expected entrypoint missing after hoisted install: $entry" >&2; exit 1; }
done
# VitePress links vue into the site's node_modules at build time; Wine cannot
# CREATE Windows symlinks (ENOTSUP) but follows pre-existing Unix ones.
if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then
mkdir -p website/node_modules
ln -s ../../node_modules/vue website/node_modules/vue
fi
wine_node "$scratch/logs/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
cat "$scratch/logs/smoke.log"
grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-gates: Windows Node smoke did not report win32 x64' >&2; exit 1; }
# ---- the two blocking surfaces, concurrently ------------------------------
# The same shape run-gates gives ci-windows-blocking on native Windows:
# `build` = tsc -b then tsdown, `production site` = the VitePress build. Both
# statuses are captured so one failure cannot hide the other's result.
build_gate() {
wine_node "$scratch/logs/tsc.log" "$tsc_js" -b --pretty false || return $?
wine_node "$scratch/logs/tsdown.log" "$tsdown_js"
}
site_gate() {
cd website
wine_node "$scratch/logs/site.log" "../$vitepress_js" build .
}
start=$SECONDS
build_gate & build_pid=$!
site_gate & site_pid=$!
build_status=0
wait "$build_pid" || build_status=$?
site_status=0
wait "$site_pid" || site_status=$?
elapsed=$((SECONDS - start))
report() {
local label="$1" status="$2"
shift 2
if (( status == 0 )); then
echo "wine-windows-gates: PASS $label (${elapsed}s window)"
else
echo "== FAILED $label (exit $status) ==" >&2
for log in "$@"; do tail -n 200 "$log" >&2 || true; done
fi
}
report 'build (tsc -b, tsdown)' "$build_status" "$scratch/logs/tsc.log" "$scratch/logs/tsdown.log"
report 'production site (vitepress build)' "$site_status" "$scratch/logs/site.log"
if (( build_status != 0 )); then exit "$build_status"; fi
exit "$site_status"