fix: tighten project instruction path handling
This commit is contained in:
@@ -25,6 +25,7 @@ The split is the point: a package's group says whether it is part of the product
|
||||
|
||||
```
|
||||
dsh-brand (no harness deps — type-only Branded<B> primitive)
|
||||
dsh-paths (no harness deps — shared filesystem path helpers)
|
||||
dsh-llm ← dsh-brand (vocabulary; brands CallId)
|
||||
dsh-bash ← dsh-brand (abstract executor seam; brands BashTaskId/OwnerToken)
|
||||
dsh-session ← dsh-llm, dsh-brand
|
||||
@@ -32,7 +33,7 @@ dsh-system-prompt ← dsh-llm
|
||||
dsh-agent ← dsh-llm, dsh-session, dsh-brand
|
||||
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; backend + tool deferred)
|
||||
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
|
||||
dsh-project-instructions ← dsh-agent, dsh-llm (AGENTS.md/CLAUDE.md workspace context loader)
|
||||
dsh-project-instructions ← dsh-agent, dsh-llm, dsh-paths (AGENTS.md/CLAUDE.md workspace context loader)
|
||||
dsh-bash-local ← dsh-bash (BashExecutor impl)
|
||||
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
|
||||
dsh-llm-deepseek ← dsh-llm (DeepSeek adapter)
|
||||
@@ -89,6 +90,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
|
||||
| `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) |
|
||||
| `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
| `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) |
|
||||
| `paths/` | `util` | Shared filesystem path constants and helpers for harness user data | (none) |
|
||||
|
||||
Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ Zero-dependency primitives shared across the other groups. A package lands here
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `brand/` | The type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) |
|
||||
| `paths/` | Shared filesystem path constants and helpers for harness user data |
|
||||
|
||||
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-bash`'s `BashTaskId`/`OwnerToken`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
|
||||
|
||||
13
packages/util/paths/README.md
Normal file
13
packages/util/paths/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# dsh-paths
|
||||
|
||||
Shared filesystem path helpers for DeepSeek Harness user data.
|
||||
|
||||
## DSH home
|
||||
|
||||
`DSH_HOME_DIR_NAME` owns the default user-data directory name: `.dsh`.
|
||||
|
||||
`defaultDshHome()` returns the default DeepSeek Harness home by joining the operating-system home directory with `.dsh`, using Node's platform path rules.
|
||||
|
||||
`expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched.
|
||||
|
||||
This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another.
|
||||
30
packages/util/paths/package.json
Normal file
30
packages/util/paths/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-paths",
|
||||
"description": "Shared filesystem path helpers for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
26
packages/util/paths/src/index.ts
Normal file
26
packages/util/paths/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Shared filesystem path helpers for DeepSeek Harness user data.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-paths
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/** Directory name for the default DeepSeek Harness home under the OS home. */
|
||||
export const DSH_HOME_DIR_NAME = '.dsh'
|
||||
|
||||
/** Stable user-facing display form for the default DeepSeek Harness home. */
|
||||
export const DEFAULT_DSH_HOME_DISPLAY = `~/${DSH_HOME_DIR_NAME}`
|
||||
|
||||
/** Resolve the default DeepSeek Harness home using Node's platform path rules. */
|
||||
export function defaultDshHome(): string {
|
||||
return join(homedir(), DSH_HOME_DIR_NAME)
|
||||
}
|
||||
|
||||
/** Expand `~`, `~/...`, and Windows-style `~\...` prefixes against the OS home. */
|
||||
export function expandHomePath(path: string): string {
|
||||
if (path === '~') return homedir()
|
||||
if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
|
||||
return path
|
||||
}
|
||||
25
packages/util/paths/tests/paths.spec.ts
Normal file
25
packages/util/paths/tests/paths.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_DSH_HOME_DISPLAY,
|
||||
DSH_HOME_DIR_NAME,
|
||||
defaultDshHome,
|
||||
expandHomePath,
|
||||
} from '@deepseek-ai/dsh-paths'
|
||||
|
||||
describe('dsh path helpers', () => {
|
||||
it('owns the shared default DSH home directory name', () => {
|
||||
expect(DSH_HOME_DIR_NAME).toBe('.dsh')
|
||||
expect(DEFAULT_DSH_HOME_DISPLAY).toBe('~/.dsh')
|
||||
expect(defaultDshHome()).toBe(join(homedir(), '.dsh'))
|
||||
})
|
||||
|
||||
it('expands tilde paths without changing non-tilde paths', () => {
|
||||
expect(expandHomePath('~')).toBe(homedir())
|
||||
expect(expandHomePath('~/.dsh')).toBe(join(homedir(), '.dsh'))
|
||||
expect(expandHomePath('~\\.dsh')).toBe(join(homedir(), '.dsh'))
|
||||
expect(expandHomePath('/tmp/.dsh')).toBe('/tmp/.dsh')
|
||||
expect(expandHomePath('~other/.dsh')).toBe('~other/.dsh')
|
||||
})
|
||||
})
|
||||
11
packages/util/paths/tsconfig.json
Normal file
11
packages/util/paths/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
Reference in New Issue
Block a user