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:
commit
004f598917
1499 files changed
+48621
-21956
No files matched your search
@@ -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', '[email protected]'])
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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) })
|
||||
+5
-16
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"AGENTS.md": 1680,
|
||||
"AGENTS.md": 1705,
|
||||
"docs/AGENTS.md": 1150,
|
||||
"docs/architecture.md": 1800,
|
||||
"docs/cordis-primer.md": 600,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
+72
-26
@@ -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.',
|
||||
'',
|
||||
|
||||
@@ -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.',
|
||||
'',
|
||||
|
||||
@@ -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',
|
||||
|
||||
+652
-16
@@ -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
|
||||
}
|
||||
@@ -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: '[email protected]',
|
||||
GIT_AUTHOR_NAME: 'Hooks Test',
|
||||
GIT_COMMITTER_EMAIL: '[email protected]',
|
||||
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)
|
||||
})
|
||||
})
|
||||
+99
-29
@@ -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 ;;
|
||||
|
||||
+47
-14
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+13
-18
@@ -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).`)
|
||||
|
||||
@@ -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
@@ -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"}}}
|
||||
@@ -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"}}}
|
||||
@@ -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"}}}
|
||||
@@ -8,19 +8,19 @@
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer clones DeepSeek Harness to `~/.dsh/source`, links `dsh` into `~/.local/bin`, and launches it. Re-running the command updates the checkout. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the frontend after installation and after each update, then start the Web UI:\n\n```sh\npnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web\ndsh web\n```\n\nThe Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently pre-release.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n"
|
||||
"content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-<timestamp>`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nThe Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently pre-release.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会将 DeepSeek Harness 克隆到 `~/.dsh/source`,把 `dsh` 链接到 `~/.local/bin`,然后启动它。再次运行该命令会更新源码目录。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI:\n\n```sh\npnpm --dir ~/.dsh/source run build && pnpm --dir ~/.dsh/source run build:web\ndsh web\n```\n\nWeb UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于预发布阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n"
|
||||
"content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析):\n\n```sh\ndsh_bin=$(cd \"$(dirname \"$(command -v dsh)\")\" && pwd -P)/$(basename \"$(command -v dsh)\")\nwhile [ -L \"$dsh_bin\" ]; do\n link=$(readlink \"$dsh_bin\")\n case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd \"$(dirname \"$dsh_bin\")\" && cd \"$(dirname \"$link\")\" && pwd -P)/$(basename \"$link\") ;; esac\ndone\ndsh_dir=$(cd \"$(dirname \"$dsh_bin\")/..\" && pwd -P)\npnpm --dir \"$dsh_dir\" run build && pnpm --dir \"$dsh_dir\" run build:web\ndsh web\n```\n\nWeb UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于预发布阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper script uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands.\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\npnpm exec lefthook install --force\n```\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defauLine truncated
|
||||
"content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly Line truncated
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\npnpm exec lefthook install --force\n```\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时Line truncated
|
||||
"content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为Line truncated
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -26,6 +26,7 @@ const PATTERNS = [
|
||||
'AGENTS.md',
|
||||
'packages/AGENTS.md',
|
||||
'.agents/skills/**/*.md',
|
||||
'skills/**/*.md',
|
||||
]
|
||||
|
||||
interface Block {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Executable
+223
@@ -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"
|
||||
Reference in new issue
Block a user