fix: tighten project instruction path handling
This commit is contained in:
@@ -6,7 +6,7 @@ Project instruction file loader for the harness. It discovers `AGENTS.md` with `
|
||||
|
||||
The plugin listens on the `agent/request` waterfall. For each request it derives the workspace from `agent.session.header.cwd`; if the session has no cwd, it falls back to `process.cwd()` for single-session local/stdio runs. It then finds the project root by walking upward until it sees `.git` as either a directory or a file, considers the ancestor chain from project root to cwd, and loads at most one instruction file per directory: `AGENTS.md` wins, `CLAUDE.md` is a compatibility fallback.
|
||||
|
||||
User-global instructions live at `$DSH_HOME/AGENTS.md`; `$DSH_HOME` defaults to `~/.dsh`. The user-global file renders before project files, so deeper project files appear later in the context and can override broader guidance.
|
||||
User-global instructions live at `$DSH_HOME/AGENTS.md`; `$DSH_HOME` defaults to `~/.dsh`. A configured `~`, `~/...`, or Windows-style `~\...` prefix is expanded against the operating-system home directory before resolution. The user-global file renders before project files, so deeper project files appear later in the context and can override broader guidance.
|
||||
|
||||
The loaded files are inserted as a synthetic user-role workspace-context message, not as provider system text and not as persisted session events. The rendered envelope states that these files are workspace-provided guidance, lower authority than system/developer/direct user instructions, and must not override safety, permission, or secret-handling rules.
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface Config {
|
||||
|
||||
The renderer keeps full text until the configured byte budget is exceeded. When it must trim, it preserves more-specific files first, drops whole less-specific files before truncating a more-specific file, and emits an HTML comment naming omitted and truncated files with byte counts.
|
||||
|
||||
Discovery re-walks the applicable ancestor chain on every request so newly created baseline files are noticed. File content is cached by normalized absolute path plus `mtimeMs` and `size`; a changed signature causes a re-read.
|
||||
Discovery re-walks the applicable ancestor chain on every request so newly created baseline files are noticed. File content is cached by normalized absolute path plus `mtimeMs` and `size`; a changed signature causes a re-read. The discovery pass carries the file signature forward to the read pass, so a cache hit does not stat the same instruction file twice in one request.
|
||||
|
||||
## Non-goals
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-paths": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -34,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
*/
|
||||
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join, relative, resolve } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome, expandHomePath } from '@deepseek-ai/dsh-paths'
|
||||
|
||||
export const name = 'project-instructions'
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface Config {
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
dshHome: z.string().default(join(homedir(), '.dsh')),
|
||||
dshHome: z.string().default(defaultDshHome()),
|
||||
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
|
||||
baselineMaxBytes: z.number().default(DEFAULT_BASELINE_MAX_BYTES),
|
||||
enableClaudeFallback: z.boolean().default(true),
|
||||
@@ -46,6 +46,10 @@ export interface InstructionFile {
|
||||
displayPath: string
|
||||
}
|
||||
|
||||
interface DiscoveredInstructionFile extends InstructionFile {
|
||||
signature: FileSignature
|
||||
}
|
||||
|
||||
export interface LoadedInstructionFile extends InstructionFile {
|
||||
content: string
|
||||
}
|
||||
@@ -94,7 +98,7 @@ interface LoadOptions extends DiscoverOptions {
|
||||
|
||||
function resolveConfig(config: Config): ResolvedConfig {
|
||||
return {
|
||||
dshHome: resolve(config.dshHome ?? join(homedir(), '.dsh')),
|
||||
dshHome: resolve(expandHomePath(config.dshHome ?? defaultDshHome())),
|
||||
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
|
||||
baselineMaxBytes: config.baselineMaxBytes ?? DEFAULT_BASELINE_MAX_BYTES,
|
||||
enableClaudeFallback: config.enableClaudeFallback ?? true,
|
||||
@@ -162,15 +166,17 @@ async function firstExistingInstructionFile(
|
||||
dir: string,
|
||||
root: string,
|
||||
enableClaudeFallback: boolean,
|
||||
): Promise<InstructionFile | undefined> {
|
||||
): Promise<DiscoveredInstructionFile | undefined> {
|
||||
const agentsPath = join(dir, 'AGENTS.md')
|
||||
if (await statFile(agentsPath)) {
|
||||
return { absolutePath: agentsPath, displayPath: relativeDisplay(root, agentsPath) }
|
||||
const agentsSignature = await statFile(agentsPath)
|
||||
if (agentsSignature !== undefined) {
|
||||
return { absolutePath: agentsPath, displayPath: relativeDisplay(root, agentsPath), signature: agentsSignature }
|
||||
}
|
||||
if (!enableClaudeFallback) return undefined
|
||||
const claudePath = join(dir, 'CLAUDE.md')
|
||||
if (await statFile(claudePath)) {
|
||||
return { absolutePath: claudePath, displayPath: relativeDisplay(root, claudePath) }
|
||||
const claudeSignature = await statFile(claudePath)
|
||||
if (claudeSignature !== undefined) {
|
||||
return { absolutePath: claudePath, displayPath: relativeDisplay(root, claudePath), signature: claudeSignature }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -179,29 +185,38 @@ function relativeDisplay(root: string, path: string): string {
|
||||
return relative(root, path)
|
||||
}
|
||||
|
||||
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
|
||||
async function discoverInstructionFiles(options: DiscoverOptions): Promise<DiscoveredInstructionFile[]> {
|
||||
const config = resolveConfig(options)
|
||||
const files: InstructionFile[] = []
|
||||
const files: DiscoveredInstructionFile[] = []
|
||||
const seen = new Set<string>()
|
||||
const addFile = (file: DiscoveredInstructionFile): void => {
|
||||
if (seen.has(file.absolutePath)) return
|
||||
seen.add(file.absolutePath)
|
||||
files.push(file)
|
||||
}
|
||||
|
||||
const userGlobal = join(config.dshHome, 'AGENTS.md')
|
||||
if (await statFile(userGlobal)) {
|
||||
const defaultDshHome = resolve(join(homedir(), '.dsh'))
|
||||
const displayPath = config.dshHome === defaultDshHome ? '~/.dsh/AGENTS.md' : '$DSH_HOME/AGENTS.md'
|
||||
files.push({ absolutePath: userGlobal, displayPath })
|
||||
const userGlobalSignature = await statFile(userGlobal)
|
||||
if (userGlobalSignature !== undefined) {
|
||||
const defaultHome = resolve(defaultDshHome())
|
||||
const displayPath = config.dshHome === defaultHome ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md'
|
||||
addFile({ absolutePath: userGlobal, displayPath, signature: userGlobalSignature })
|
||||
}
|
||||
|
||||
const cwd = resolve(options.cwd)
|
||||
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers)
|
||||
for (const dir of ancestorChain(projectRoot, cwd)) {
|
||||
const file = await firstExistingInstructionFile(dir, projectRoot, config.enableClaudeFallback)
|
||||
if (file !== undefined) files.push(file)
|
||||
if (file !== undefined) addFile(file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function readCached(path: string, cache: InstructionContentCache): Promise<string | undefined> {
|
||||
const signature = await statFile(path)
|
||||
/* v8 ignore next -- race-only path: file existed during discovery but vanished before the read-side stat. */
|
||||
if (signature === undefined) return undefined
|
||||
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
|
||||
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
|
||||
}
|
||||
|
||||
async function readCached(path: string, signature: FileSignature, cache: InstructionContentCache): Promise<string | undefined> {
|
||||
const cached = cache.get(path)
|
||||
if (cached !== undefined && cached.mtimeMs === signature.mtimeMs && cached.size === signature.size) {
|
||||
return cached.content
|
||||
@@ -221,11 +236,11 @@ export async function loadBaselineInstructions(options: LoadOptions): Promise<Re
|
||||
const config = resolveConfig(options)
|
||||
if (config.baselineMaxBytes === 0) return undefined
|
||||
const cache = options.cache ?? new Map<string, CachedContent>()
|
||||
const discovered = await discoverBaselineInstructionFiles(options)
|
||||
const discovered = await discoverInstructionFiles(options)
|
||||
const loaded: LoadedInstructionFile[] = []
|
||||
for (const file of discovered) {
|
||||
const content = await readCached(file.absolutePath, cache)
|
||||
if (content !== undefined) loaded.push({ ...file, content })
|
||||
const content = await readCached(file.absolutePath, file.signature, cache)
|
||||
if (content !== undefined) loaded.push({ absolutePath: file.absolutePath, displayPath: file.displayPath, content })
|
||||
}
|
||||
if (loaded.length === 0) return undefined
|
||||
return renderProjectInstructions(loaded, { maxBytes: config.baselineMaxBytes })
|
||||
|
||||
@@ -215,6 +215,40 @@ describe('project instruction discovery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('expands a configured ~/.dsh home to the operating-system home directory', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await write(join(home, '.dsh/AGENTS.md'), 'global tilde rule')
|
||||
|
||||
vi.resetModules()
|
||||
vi.doMock('node:os', () => ({ homedir: () => home }))
|
||||
const isolated = await import('@deepseek-ai/dsh-project-instructions')
|
||||
const files = await isolated.discoverBaselineInstructionFiles({ cwd: root, dshHome: '~/.dsh' })
|
||||
|
||||
expect(files).toEqual([{ absolutePath: join(home, '.dsh/AGENTS.md'), displayPath: '~/.dsh/AGENTS.md' }])
|
||||
} finally {
|
||||
vi.doUnmock('node:os')
|
||||
vi.resetModules()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates user-global instructions when dshHome points at the project root', async () => {
|
||||
const root = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'same file')
|
||||
|
||||
const files = await discoverBaselineInstructionFiles({ cwd: root, dshHome: root })
|
||||
|
||||
expect(files).toEqual([{ absolutePath: join(root, 'AGENTS.md'), displayPath: '$DSH_HOME/AGENTS.md' }])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores instruction candidates that are directories', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
@@ -492,4 +526,39 @@ describe('project instruction request injection', () => {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reuses the discovery stat signature when reading cached content', async () => {
|
||||
const root = await tempRepo()
|
||||
const home = await tempRepo()
|
||||
try {
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await write(join(root, 'AGENTS.md'), 'repo rule')
|
||||
|
||||
const observedStats = new Map<string, number>()
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
stat: async (path: string) => {
|
||||
observedStats.set(path, (observedStats.get(path) ?? 0) + 1)
|
||||
return actual.stat(path)
|
||||
},
|
||||
}
|
||||
})
|
||||
const isolated = await import('@deepseek-ai/dsh-project-instructions')
|
||||
const cache: InstructionContentCache = new Map()
|
||||
|
||||
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache })
|
||||
observedStats.clear()
|
||||
await isolated.loadBaselineInstructions({ cwd: root, dshHome: home, cache })
|
||||
|
||||
expect(observedStats.get(join(root, 'AGENTS.md'))).toBe(1)
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user