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