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:
@@ -0,0 +1,241 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { renderChangeScope } from './change-scope.ts'
|
||||
|
||||
interface Report {
|
||||
formatVersion: number
|
||||
repositoryRoot: string
|
||||
input: { base: string; head: string }
|
||||
resolved: { baseSha: string; headSha: string; mergeBaseSha: string }
|
||||
paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] }
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
container: string
|
||||
root: string
|
||||
}
|
||||
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function git(cwd: string, args: string[], input?: string | Buffer): string {
|
||||
return execFileSync('git', ['-C', cwd, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
|
||||
input,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer {
|
||||
return execFileSync('git', ['-C', cwd, ...args], {
|
||||
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
|
||||
input,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
}
|
||||
|
||||
function write(path: string, content: string, mode?: number): void {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, content, mode === undefined ? undefined : { mode })
|
||||
}
|
||||
|
||||
function fixture(worktreeName = 'worktree'): Fixture {
|
||||
const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-'))
|
||||
fixtureRoots.push(container)
|
||||
const origin = join(container, 'origin.git')
|
||||
const root = join(container, worktreeName)
|
||||
const hooks = join(container, 'hooks')
|
||||
mkdirSync(hooks)
|
||||
git(container, ['init', '--bare', '--initial-branch=master', origin])
|
||||
git(container, ['init', '--initial-branch=master', root])
|
||||
git(root, ['config', 'user.email', 'change-scope@example.com'])
|
||||
git(root, ['config', 'user.name', 'Change Scope Tests'])
|
||||
git(root, ['config', 'commit.gpgsign', 'false'])
|
||||
git(root, ['config', 'core.hooksPath', hooks])
|
||||
write(join(root, 'README.md'), '# Fixture\n')
|
||||
git(root, ['add', 'README.md'])
|
||||
git(root, ['commit', '-m', 'initial'])
|
||||
git(root, ['remote', 'add', 'origin', origin])
|
||||
git(root, ['push', '--set-upstream', 'origin', 'master'])
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
function commit(root: string, path: string, content: string): string {
|
||||
write(join(root, path), content)
|
||||
git(root, ['add', '--', path])
|
||||
git(root, ['commit', '-m', `add ${path}`])
|
||||
return git(root, ['rev-parse', 'HEAD'])
|
||||
}
|
||||
|
||||
function invoke(root: string, args: string[]): string {
|
||||
return renderChangeScope(args, root)
|
||||
}
|
||||
|
||||
function jsonReport(root: string, base: string, head?: string): Report {
|
||||
const args = ['--base', base]
|
||||
if (head !== undefined) args.push('--head', head)
|
||||
return JSON.parse(invoke(root, args)) as Report
|
||||
}
|
||||
|
||||
function repositoryState(root: string): Record<string, string> {
|
||||
const status = git(root, ['status', '--porcelain=v2', '--branch', '-z'])
|
||||
return {
|
||||
status,
|
||||
head: git(root, ['rev-parse', 'HEAD']),
|
||||
refs: git(root, ['for-each-ref', '--format=%(refname) %(objectname)']),
|
||||
index: readFileSync(join(root, '.git/index')).toString('base64'),
|
||||
config: readFileSync(join(root, '.git/config')).toString('base64'),
|
||||
}
|
||||
}
|
||||
|
||||
describe('change-scope', () => {
|
||||
it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => {
|
||||
const { root } = fixture()
|
||||
git(root, ['switch', '-c', 'feature'])
|
||||
git(root, ['branch', '--set-upstream-to=origin/master'])
|
||||
const headSha = commit(root, 'feature.txt', 'feature\n')
|
||||
|
||||
const fresh = jsonReport(root, 'origin/master')
|
||||
expect(fresh.repositoryRoot).toBe(realpathSync(root))
|
||||
expect(fresh.resolved).toEqual({
|
||||
baseSha: git(root, ['rev-parse', 'origin/master']),
|
||||
headSha,
|
||||
mergeBaseSha: git(root, ['rev-parse', 'origin/master']),
|
||||
})
|
||||
expect(fresh.paths).toEqual({ committed: ['feature.txt'], staged: [], unstaged: [], untracked: [] })
|
||||
expect(git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/feature'])).toBe('')
|
||||
|
||||
git(root, ['push', '--set-upstream', 'origin', 'feature'])
|
||||
const pushed = jsonReport(root, 'origin/master')
|
||||
expect(pushed.paths.committed).toEqual(['feature.txt'])
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('preserves trailing spaces in the worktree path', () => {
|
||||
const { root } = fixture('worktree ')
|
||||
const report = jsonReport(root, 'HEAD')
|
||||
|
||||
expect(report.repositoryRoot).toBe(realpathSync(root))
|
||||
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
|
||||
})
|
||||
|
||||
it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => {
|
||||
const { root } = fixture()
|
||||
git(root, ['switch', '-c', 'foundation'])
|
||||
const baseSha = commit(root, 'foundation.txt', 'foundation\n')
|
||||
git(root, ['switch', '-c', 'topic'])
|
||||
const headSha = commit(root, 'topic.txt', 'topic\n')
|
||||
commit(root, 'later.txt', 'later\n')
|
||||
write(join(root, 'current-worktree.txt'), 'current worktree\n')
|
||||
|
||||
const report = jsonReport(root, 'foundation', headSha)
|
||||
expect(report.input).toEqual({ base: 'foundation', head: headSha })
|
||||
expect(report.resolved).toEqual({ baseSha, headSha, mergeBaseSha: baseSha })
|
||||
expect(report.paths.committed).toEqual(['topic.txt'])
|
||||
expect(report.paths.untracked).toEqual(['current-worktree.txt'])
|
||||
})
|
||||
|
||||
it('keeps committed, staged, unstaged, and untracked paths independent and does not mutate state', () => {
|
||||
const { root } = fixture()
|
||||
commit(root, 'unstaged.txt', 'before\n')
|
||||
const baseSha = git(root, ['rev-parse', 'HEAD'])
|
||||
commit(root, 'committed.txt', 'committed\n')
|
||||
write(join(root, 'staged.txt'), 'staged\n')
|
||||
write(join(root, 'mixed.txt'), 'staged part\n')
|
||||
git(root, ['add', 'staged.txt', 'mixed.txt'])
|
||||
write(join(root, 'mixed.txt'), 'staged part\nunstaged part\n')
|
||||
write(join(root, 'unstaged.txt'), 'unstaged\n')
|
||||
write(join(root, 'untracked.txt'), 'untracked\n')
|
||||
const before = repositoryState(root)
|
||||
|
||||
const report = jsonReport(root, baseSha)
|
||||
|
||||
expect(report.paths).toEqual({
|
||||
committed: ['committed.txt'],
|
||||
staged: ['mixed.txt', 'staged.txt'],
|
||||
unstaged: ['mixed.txt', 'unstaged.txt'],
|
||||
untracked: ['untracked.txt'],
|
||||
})
|
||||
expect(repositoryState(root)).toEqual(before)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('does not execute a configured filesystem monitor', () => {
|
||||
const { container, root } = fixture()
|
||||
const monitor = join(container, 'fsmonitor.sh')
|
||||
const sideEffect = `${monitor}.ran`
|
||||
write(monitor, '#!/bin/sh\ntouch "$0.ran"\n', 0o755)
|
||||
git(root, ['config', 'core.fsmonitor', monitor])
|
||||
|
||||
const report = jsonReport(root, 'HEAD')
|
||||
|
||||
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
|
||||
expect(existsSync(sideEffect)).toBe(false)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths', () => {
|
||||
const { root } = fixture()
|
||||
const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content')
|
||||
const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii')
|
||||
const firstPath = Buffer.from([0x80])
|
||||
const secondPath = Buffer.from([0x81])
|
||||
gitBytes(root, ['update-index', '-z', '--index-info'], Buffer.concat([
|
||||
entry,
|
||||
firstPath,
|
||||
Buffer.from([0]),
|
||||
entry,
|
||||
secondPath,
|
||||
Buffer.from([0]),
|
||||
]))
|
||||
expect(gitBytes(root, ['diff', '--cached', '--name-only', '-z', '--'])).toEqual(Buffer.concat([
|
||||
firstPath,
|
||||
Buffer.from([0]),
|
||||
secondPath,
|
||||
Buffer.from([0]),
|
||||
]))
|
||||
expect(() => {
|
||||
renderChangeScope(['--base', 'HEAD'], root)
|
||||
}).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8')
|
||||
})
|
||||
|
||||
it('rejects missing, ambiguous, and non-commit refs', () => {
|
||||
const { root } = fixture()
|
||||
git(root, ['branch', 'collision'])
|
||||
git(root, ['tag', 'collision'])
|
||||
write(join(root, 'blob.txt'), 'blob\n')
|
||||
const blobSha = git(root, ['hash-object', '-w', 'blob.txt'])
|
||||
git(root, ['tag', 'blob-ref', blobSha])
|
||||
|
||||
for (const { args, message } of [
|
||||
{ args: ['--base', 'missing'], message: /base ref .* does not resolve to a commit/u },
|
||||
{ args: ['--base', 'collision'], message: /base ref .* is ambiguous/u },
|
||||
{ args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u },
|
||||
{ args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u },
|
||||
]) {
|
||||
expect(() => {
|
||||
renderChangeScope(args, root)
|
||||
}).toThrow(message)
|
||||
}
|
||||
})
|
||||
|
||||
it('renders deterministic versioned JSON', () => {
|
||||
const { root } = fixture()
|
||||
git(root, ['switch', '-c', 'format'])
|
||||
commit(root, 'zeta.txt', 'zeta\n')
|
||||
commit(root, 'alpha.txt', 'alpha\n')
|
||||
|
||||
const json = invoke(root, ['--base', 'origin/master'])
|
||||
const repeatedJson = invoke(root, ['--base', 'origin/master'])
|
||||
const report = JSON.parse(json) as Report
|
||||
|
||||
expect(json).toBe(repeatedJson)
|
||||
expect(report.formatVersion).toBe(1)
|
||||
expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt'])
|
||||
})
|
||||
})
|
||||
@@ -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: 'hooks@example.test',
|
||||
GIT_AUTHOR_NAME: 'Hooks Test',
|
||||
GIT_COMMITTER_EMAIL: 'hooks@example.test',
|
||||
GIT_COMMITTER_NAME: 'Hooks Test',
|
||||
GIT_CONFIG_GLOBAL: join(container, 'global.gitconfig'),
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
HOME: container,
|
||||
XDG_CONFIG_HOME: join(container, '.config'),
|
||||
}
|
||||
const fixture = { container, env, linked, main }
|
||||
mkdirSync(main)
|
||||
git(fixture, container, ['init', main])
|
||||
write(join(main, 'README.md'), '# fixture\n')
|
||||
git(fixture, main, ['add', 'README.md'])
|
||||
git(fixture, main, ['commit', '-m', 'fixture'])
|
||||
git(fixture, main, ['worktree', 'add', '-b', 'linked', linked])
|
||||
write(join(main, 'lefthook.yml'), 'main-worktree-config\n')
|
||||
write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
|
||||
installFakeLefthook(main)
|
||||
installFakeLefthook(linked)
|
||||
return fixture
|
||||
}
|
||||
|
||||
function gitDirectory(fixture: Fixture, root: string): string {
|
||||
return git(fixture, root, ['rev-parse', '--absolute-git-dir'])
|
||||
}
|
||||
|
||||
function commonDirectory(fixture: Fixture): string {
|
||||
const output = git(fixture, fixture.main, ['rev-parse', '--git-common-dir'])
|
||||
return isAbsolute(output) ? output : resolve(fixture.main, output)
|
||||
}
|
||||
|
||||
function hooksPath(fixture: Fixture, root: string): string {
|
||||
return join(gitDirectory(fixture, root), 'dsh-hooks')
|
||||
}
|
||||
|
||||
function installLockPath(fixture: Fixture): string {
|
||||
return join(commonDirectory(fixture), 'dsh-lefthook-install.lock')
|
||||
}
|
||||
|
||||
async function waitForPath(path: string): Promise<void> {
|
||||
const deadline = Date.now() + 5_000
|
||||
while (!existsSync(path)) {
|
||||
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`)
|
||||
await new Promise(resolveWait => setTimeout(resolveWait, 10))
|
||||
}
|
||||
}
|
||||
|
||||
function runInstaller(
|
||||
fixture: Fixture,
|
||||
root: string,
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
): Promise<CommandResult> {
|
||||
return new Promise((resolveResult, reject) => {
|
||||
const child = spawn(process.execPath, [installer], {
|
||||
cwd: root,
|
||||
env: { ...fixture.env, ...extraEnv },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
|
||||
child.on('error', reject)
|
||||
child.on('close', (status) => { resolveResult({ status, stderr, stdout }) })
|
||||
})
|
||||
}
|
||||
|
||||
describe('worktree-local Lefthook installer', () => {
|
||||
for (const [label, extraEnv] of [
|
||||
['CI', { CI: 'true' }],
|
||||
['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
|
||||
] satisfies [string, NodeJS.ProcessEnv][]) {
|
||||
it(`skips hook installation when ${label} marks an automated job`, async () => {
|
||||
const fixture = createFixture()
|
||||
const common = commonDirectory(fixture)
|
||||
const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig')
|
||||
git(fixture, fixture.main, [
|
||||
'config',
|
||||
'--local',
|
||||
'includeIf.gitdir:/github/workspace/.git.path',
|
||||
missingInclude,
|
||||
])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main, extraEnv)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0')
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
expect(existsSync(join(common, 'config.worktree'))).toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
it('isolates main and linked worktrees without changing legacy common hooks', async () => {
|
||||
const fixture = createFixture()
|
||||
const common = commonDirectory(fixture)
|
||||
const legacyHook = join(common, 'hooks/pre-commit')
|
||||
write(legacyHook, '#!/bin/sh\n# legacy hook\n', 0o755)
|
||||
|
||||
const mainInstall = await runInstaller(fixture, fixture.main)
|
||||
const linkedInstall = await runInstaller(fixture, fixture.linked)
|
||||
expect(mainInstall.status, mainInstall.stderr).toBe(0)
|
||||
expect(linkedInstall.status, linkedInstall.stderr).toBe(0)
|
||||
|
||||
const mainHooks = hooksPath(fixture, fixture.main)
|
||||
const linkedHooks = hooksPath(fixture, fixture.linked)
|
||||
expect(mainHooks).not.toBe(linkedHooks)
|
||||
expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
|
||||
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
|
||||
|
||||
const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
|
||||
const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')
|
||||
const canonicalMain = git(fixture, fixture.main, ['rev-parse', '--show-toplevel'])
|
||||
const canonicalLinked = git(fixture, fixture.linked, ['rev-parse', '--show-toplevel'])
|
||||
expect(mainHook).toContain(`# root=${canonicalMain}`)
|
||||
expect(mainHook).toContain('# config=main-worktree-config')
|
||||
expect(mainHook).not.toContain(canonicalLinked)
|
||||
expect(linkedHook).toContain(`# root=${canonicalLinked}`)
|
||||
expect(linkedHook).toContain('# config=linked-worktree-config')
|
||||
expect(linkedHook).not.toContain(canonicalMain)
|
||||
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
|
||||
|
||||
const commonConfig = join(common, 'config')
|
||||
expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion'])).toBe('1')
|
||||
expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'extensions.worktreeConfig'])).toBe('true')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1)
|
||||
|
||||
const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
|
||||
git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked])
|
||||
expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval)
|
||||
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
|
||||
})
|
||||
|
||||
it('serializes concurrent installs and keeps repeated output stable', async () => {
|
||||
const fixture = createFixture()
|
||||
const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' }
|
||||
const first = await Promise.all([
|
||||
runInstaller(fixture, fixture.main, delayed),
|
||||
runInstaller(fixture, fixture.linked, delayed),
|
||||
])
|
||||
for (const result of first) expect(result.status, result.stderr).toBe(0)
|
||||
|
||||
const mainHookPath = join(hooksPath(fixture, fixture.main), 'pre-push')
|
||||
const initialHook = readFileSync(mainHookPath, 'utf8')
|
||||
const repeated = await Promise.all([
|
||||
runInstaller(fixture, fixture.main, delayed),
|
||||
runInstaller(fixture, fixture.main, delayed),
|
||||
])
|
||||
for (const result of repeated) expect(result.status, result.stderr).toBe(0)
|
||||
expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook)
|
||||
expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false)
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
|
||||
})
|
||||
|
||||
it('repairs its owned absolute hook path after the checkout moves', async () => {
|
||||
const fixture = createFixture()
|
||||
const oldRoot = fixture.main
|
||||
const first = await runInstaller(fixture, oldRoot)
|
||||
expect(first.status, first.stderr).toBe(0)
|
||||
const oldHooks = hooksPath(fixture, oldRoot)
|
||||
const movedRoot = join(fixture.container, 'moved-main')
|
||||
renameSync(oldRoot, movedRoot)
|
||||
|
||||
const moved = await runInstaller(fixture, movedRoot)
|
||||
|
||||
expect(moved.status, moved.stderr).toBe(0)
|
||||
const movedHooks = hooksPath(fixture, movedRoot)
|
||||
expect(movedHooks).not.toBe(oldHooks)
|
||||
expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(movedHooks)
|
||||
const canonicalMoved = git(fixture, movedRoot, ['rev-parse', '--show-toplevel'])
|
||||
expect(readFileSync(join(movedHooks, 'pre-commit'), 'utf8')).toContain(`# root=${canonicalMoved}`)
|
||||
expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain(
|
||||
JSON.stringify(movedHooks),
|
||||
)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => {
|
||||
const fixture = createFixture()
|
||||
const oldRoot = fixture.main
|
||||
const first = await runInstaller(fixture, oldRoot)
|
||||
expect(first.status, first.stderr).toBe(0)
|
||||
const oldHooks = hooksPath(fixture, oldRoot)
|
||||
const markerName = '.dsh-lefthook-owned'
|
||||
const externalMarker = join(fixture.container, 'external-marker')
|
||||
linkSync(join(oldHooks, markerName), externalMarker)
|
||||
const externalContent = readFileSync(externalMarker, 'utf8')
|
||||
const movedRoot = join(fixture.container, 'moved-main')
|
||||
renameSync(oldRoot, movedRoot)
|
||||
|
||||
const result = await runInstaller(fixture, movedRoot)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('invalid ownership marker')
|
||||
expect(readFileSync(externalMarker, 'utf8')).toBe(externalContent)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('refuses aliased generated hooks before Lefthook can overwrite their targets', async () => {
|
||||
for (const kind of ['symlink', 'hardlink'] as const) {
|
||||
const fixture = createFixture()
|
||||
const first = await runInstaller(fixture, fixture.main)
|
||||
expect(first.status, first.stderr).toBe(0)
|
||||
const hook = join(hooksPath(fixture, fixture.main), 'pre-commit')
|
||||
const externalHook = join(fixture.container, `${kind}-external-hook`)
|
||||
rmSync(hook)
|
||||
write(externalHook, `external ${kind} target\n`)
|
||||
if (kind === 'symlink') symlinkSync(externalHook, hook)
|
||||
else linkSync(externalHook, hook)
|
||||
const externalContent = readFileSync(externalHook, 'utf8')
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('non-regular or multiply linked hook entry')
|
||||
expect(readFileSync(externalHook, 'utf8')).toBe(externalContent)
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the marker-backed stale hook path when relocation reinstall fails', async () => {
|
||||
const fixture = createFixture()
|
||||
const oldRoot = fixture.main
|
||||
const first = await runInstaller(fixture, oldRoot)
|
||||
expect(first.status, first.stderr).toBe(0)
|
||||
const oldHooks = hooksPath(fixture, oldRoot)
|
||||
const markerName = '.dsh-lefthook-owned'
|
||||
const previousMarker = readFileSync(join(oldHooks, markerName), 'utf8')
|
||||
const movedRoot = join(fixture.container, 'moved-main')
|
||||
renameSync(oldRoot, movedRoot)
|
||||
|
||||
const failed = await runInstaller(fixture, movedRoot, { DSH_TEST_LEFTHOOK_FAIL: '1' })
|
||||
|
||||
expect(failed.status).toBe(1)
|
||||
expect(failed.stderr).toContain('exit status 77')
|
||||
const movedHooks = hooksPath(fixture, movedRoot)
|
||||
expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(oldHooks)
|
||||
expect(readFileSync(join(movedHooks, markerName), 'utf8')).toBe(previousMarker)
|
||||
})
|
||||
|
||||
it('refuses dormant repository extensions before upgrading the repository format', async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
git(fixture, fixture.main, ['config', 'extensions.dshUnknown', 'true'])
|
||||
expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0)
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('dormant repository extension extensions.dshunknown')
|
||||
expect(git(fixture, fixture.main, [
|
||||
'config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion',
|
||||
])).toBe('0')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses direct core.worktree before enabling worktree config', async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.worktree', fixture.main])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.linked)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('core.worktree is in the common config')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('refuses a symlinked common repository config before writing through it', async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
const externalConfig = join(fixture.container, 'external-common.gitconfig')
|
||||
renameSync(commonConfig, externalConfig)
|
||||
symlinkSync(externalConfig, commonConfig)
|
||||
const externalContent = readFileSync(externalConfig, 'utf8')
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('common repository config')
|
||||
expect(result.stderr).toContain('not a regular file')
|
||||
expect(lstatSync(commonConfig).isSymbolicLink()).toBe(true)
|
||||
expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves stale installer locks for explicit recovery', async () => {
|
||||
const fixture = createFixture()
|
||||
const lockPath = installLockPath(fixture)
|
||||
const completed = spawnSync(process.execPath, ['-e', ''])
|
||||
expect(completed.status).toBe(0)
|
||||
const staleRecord = `${String(completed.pid)} 00000000-0000-4000-8000-000000000000\n`
|
||||
writeFileSync(lockPath, staleRecord)
|
||||
|
||||
const results = await Promise.all(Array.from(
|
||||
{ length: 4 },
|
||||
() => runInstaller(fixture, fixture.main),
|
||||
))
|
||||
|
||||
for (const result of results) {
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('stale Lefthook installer lock')
|
||||
expect(result.stderr).toContain('remove it manually')
|
||||
}
|
||||
expect(readFileSync(lockPath, 'utf8')).toBe(staleRecord)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
})
|
||||
|
||||
it('leaves invalid installer locks for explicit recovery', async () => {
|
||||
const fixture = createFixture()
|
||||
const lockPath = installLockPath(fixture)
|
||||
const invalidRecord = 'not an installer lock\n'
|
||||
writeFileSync(lockPath, invalidRecord)
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('invalid Lefthook installer lock')
|
||||
expect(result.stderr).toContain('remove it manually')
|
||||
expect(readFileSync(lockPath, 'utf8')).toBe(invalidRecord)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not release an installer lock whose ownership changed', async () => {
|
||||
const fixture = createFixture()
|
||||
const lockPath = installLockPath(fixture)
|
||||
const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running')
|
||||
const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' })
|
||||
await waitForPath(runningPath)
|
||||
const replacementRecord = 'replacement owner\n'
|
||||
writeFileSync(lockPath, replacementRecord)
|
||||
|
||||
const result = await install
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('installer lock ownership changed')
|
||||
expect(readFileSync(lockPath, 'utf8')).toBe(replacementRecord)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('preserves trailing spaces in worktree paths', async () => {
|
||||
const fixture = createFixture({ main: 'main ', linked: 'linked ' })
|
||||
|
||||
for (const root of [fixture.main, fixture.linked]) {
|
||||
const result = await runInstaller(fixture, root)
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(git(fixture, root, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, root))
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves user-owned hook paths unless an inherited value is explicitly overridden', async () => {
|
||||
const fixture = createFixture()
|
||||
const customHook = join(fixture.main, 'custom-hooks/pre-commit')
|
||||
write(customHook, '#!/bin/sh\n# custom hook\n', 0o755)
|
||||
git(fixture, fixture.main, ['config', 'core.hooksPath', 'custom-hooks'])
|
||||
|
||||
const refused = await runInstaller(fixture, fixture.main)
|
||||
expect(refused.status).toBe(1)
|
||||
expect(refused.stderr).toContain('refusing to replace user-owned core.hooksPath')
|
||||
expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1')
|
||||
expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
|
||||
expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
|
||||
const optedIn = await runInstaller(fixture, fixture.main, {
|
||||
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
|
||||
})
|
||||
expect(optedIn.status, optedIn.stderr).toBe(0)
|
||||
expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.main))
|
||||
expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
|
||||
expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
|
||||
|
||||
git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', 'linked-custom-hooks'])
|
||||
const explicitWorktreePath = await runInstaller(fixture, fixture.linked, {
|
||||
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
|
||||
})
|
||||
expect(explicitWorktreePath.status).toBe(1)
|
||||
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks')
|
||||
})
|
||||
|
||||
it('refuses to activate a sibling worktree dormant hook path', async () => {
|
||||
const fixture = createFixture()
|
||||
const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree')
|
||||
const linkedHooks = join(fixture.linked, 'custom-hooks')
|
||||
git(fixture, fixture.main, ['config', '--file', linkedConfig, 'core.hooksPath', linkedHooks])
|
||||
expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('sibling dormant worktree config')
|
||||
expect(result.stderr).toContain(linkedConfig)
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('refuses an active symlinked worktree config before writing through it', async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree')
|
||||
const externalConfig = join(fixture.container, 'external.gitconfig')
|
||||
const externalContent = '[user]\n\tname = External owner\n'
|
||||
write(externalConfig, externalContent)
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
|
||||
symlinkSync(externalConfig, worktreeConfig)
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('active worktree config')
|
||||
expect(result.stderr).toContain('not a regular file')
|
||||
expect(lstatSync(worktreeConfig).isSymbolicLink()).toBe(true)
|
||||
expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent)
|
||||
expect(gitResult(fixture, fixture.main, [
|
||||
'config', '--file', externalConfig, '--get', 'core.hooksPath',
|
||||
]).status).toBe(1)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) {
|
||||
for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) {
|
||||
it(`ignores ${key} loaded through ${includeKey}`, async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`)
|
||||
const value = key === 'core.worktree' ? fixture.main : 'true'
|
||||
git(fixture, fixture.main, ['config', '--file', includedConfig, key, value])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, includeKey, includedConfig])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.linked)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(
|
||||
hooksPath(fixture, fixture.linked),
|
||||
)
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('ignores an inactive global includeIf that provides a hook path for another repository', async () => {
|
||||
const fixture = createFixture()
|
||||
const globalConfig = fixture.env.GIT_CONFIG_GLOBAL
|
||||
if (globalConfig === undefined) throw new Error('fixture global config path is missing')
|
||||
const includedConfig = join(fixture.container, 'other-repository.gitconfig')
|
||||
const includedHooks = join(fixture.container, 'other-repository-hooks')
|
||||
git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
|
||||
git(fixture, fixture.main, [
|
||||
'config',
|
||||
'--file',
|
||||
globalConfig,
|
||||
`includeIf.gitdir:${join(fixture.container, 'other')}/.path`,
|
||||
includedConfig,
|
||||
])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.linked)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked))
|
||||
})
|
||||
|
||||
it('never overrides a command-scoped hook path', async () => {
|
||||
const fixture = createFixture()
|
||||
const commandHooks = join(fixture.container, 'command-hooks')
|
||||
const sentinel = join(commandHooks, 'pre-commit')
|
||||
write(sentinel, '#!/bin/sh\n# command-scope sentinel\n', 0o755)
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main, {
|
||||
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'core.hooksPath',
|
||||
GIT_CONFIG_VALUE_0: commandHooks,
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('command-scoped core.hooksPath')
|
||||
expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# command-scope sentinel\n')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not pass unrelated command-scoped Git config to Lefthook', async () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main, {
|
||||
DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY: 'dsh.testSentinel',
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'dsh.testSentinel',
|
||||
GIT_CONFIG_VALUE_0: 'must-not-reach-lefthook',
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.main), 'pre-commit'))).toBe(true)
|
||||
})
|
||||
|
||||
it('never overrides a hook path included by worktree config', async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree')
|
||||
const includedConfig = join(fixture.container, 'included-worktree.gitconfig')
|
||||
const includedHooks = join(fixture.container, 'included-hooks')
|
||||
const sentinel = join(includedHooks, 'pre-commit')
|
||||
write(sentinel, '#!/bin/sh\n# included-worktree sentinel\n', 0o755)
|
||||
git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
|
||||
git(fixture, fixture.main, ['config', '--file', worktreeConfig, 'include.path', includedConfig])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main, {
|
||||
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('worktree-scoped core.hooksPath')
|
||||
expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks)
|
||||
expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# included-worktree sentinel\n')
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it('restores the previous hook lookup when Lefthook installation fails', async () => {
|
||||
const fixture = createFixture()
|
||||
const common = commonDirectory(fixture)
|
||||
const legacyHook = join(common, 'hooks/pre-push')
|
||||
write(legacyHook, '#!/bin/sh\n# legacy pre-push\n', 0o755)
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_FAIL: '1' })
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('exit status 77')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n')
|
||||
})
|
||||
|
||||
it('reports installation and hook-path rollback failures together', async () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main, {
|
||||
DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG: '1',
|
||||
DSH_TEST_LEFTHOOK_FAIL: '1',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('Lefthook installation failed')
|
||||
expect(result.stderr).toContain('exit status 77')
|
||||
expect(result.stderr).toContain('worktree hook rollback also failed')
|
||||
expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed')
|
||||
})
|
||||
|
||||
it('refuses an unowned directory at the reserved worktree hook path', async () => {
|
||||
const fixture = createFixture()
|
||||
const reservedHook = join(hooksPath(fixture, fixture.main), 'pre-commit')
|
||||
write(reservedHook, '#!/bin/sh\n# user content\n', 0o755)
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('refusing to overwrite unowned hooks directory')
|
||||
expect(readFileSync(reservedHook, 'utf8')).toBe('#!/bin/sh\n# user content\n')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects Git without config-scope support before mutation', async () => {
|
||||
const fixture = createFixture()
|
||||
const realGit = commandResult('which', ['git'], fixture.main, fixture.env).stdout.trim()
|
||||
const fakeBin = join(fixture.container, 'fake-bin')
|
||||
const fakeGit = join(fakeBin, 'git')
|
||||
write(
|
||||
fakeGit,
|
||||
`#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.25.0"; exit 0; fi\nexec "${realGit}" "$@"\n`,
|
||||
0o755,
|
||||
)
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main, {
|
||||
PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`,
|
||||
})
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('Git 2.26 or newer is required')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
})
|
||||
+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"}}}
|
||||
File diff suppressed because one or more lines are too long
@@ -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