diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index 3bcb0c2bea..394c0de708 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -12,7 +12,7 @@ The lifecycle has two distinct classes of content. The initial applicable chain ## Decision -The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. `@deepseek-ai/dsh-agent-core` mounts it for both product front doors and forwards its config. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability. +The implementation lives in `packages/context/workspace-context` as `@deepseek-ai/dsh-workspace-context`. It is a request-context extension, not a core service or a filesystem backend. The shared demo spine and Host Runtime mount it from an explicit `{ maxBytes } | false` deployment choice; `dsh web` enables a 65,536-byte budget while the Host Runtime's headless consumer disables it. The plugin consumes `agent/session-prefix`, `tools/post-execute`, and the optional `ctx.fs` capability. The plugin does not statically inject `fs`. Providerless product trees therefore boot normally and the plugin no-ops until a filesystem provider exists. All production reads go through that provider. Candidate probes resolve each path and stat the result, so a final-component symlink is followed to its target: a link to a regular file loads, while a missing path or a non-file target is a confirmed absence. Following repository-owned links across the trust boundary is a deliberate reversal of the original no-follow probe; the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns that decision and its residual risk. The session-prefix signal and dynamic tool execution signal propagate through resolution, metadata probes, and streaming reads, so cancellation does not wait for an unrelated filesystem scan. A resolve or stat exception is classified as unavailable: it skips only that candidate and is never interpreted as the deletion of an already-loaded scope. @@ -76,7 +76,7 @@ There is intentionally no watcher. Detection occurs at the next successful struc ## Consequences -Workspace guidance is isolated per session and shared by both product front doors and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. +Workspace guidance is isolated per session and shared by the demo front doors, Web Host, and every tool presentation mode. Initial instructions benefit from stable prefix caching, while nested and changed content remains durable and replayable. The generic session/agent context contract carries JSON metadata propagated through prompt-submit and post-tool `additionalContexts` arrays without flattening entries. Repository text remains untrusted input. Lower-authority user-role framing, explicit precedence language, and delimiter escaping reduce risk but do not eliminate prompt injection. Following a candidate symlink to its target widens that surface to off-tree content, so the permission and sandbox layers that confine `ctx.fs` to trusted roots are the boundary that treats workspace files as data rather than authority (the [instruction-symlink follow note](2026-07-21-follow-instruction-symlinks.md) owns the residual risk). diff --git a/apps/cli/README.md b/apps/cli/README.md index b8ff616d59..87f5e670e3 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,6 +1,6 @@ # `@deepseek-ai/dsh` -The `dsh` command-line entry, following the `apps/` assembly tier proposed by the `dsh web` PR (#443): `apps/*` are product assemblies over `packages/*` libraries. This branch ships one surface — plain `dsh [config.yml]` boots the interactive TUI coding agent — and reserves the `web` and `-p`/`--prompt` subcommands for that PR so the dispatch merges as a union. +The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. The TUI surface: @@ -10,6 +10,8 @@ The TUI surface: - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. +The Web surface treats its invoking directory as the default project and loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget. + ## Install (developer machine) Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step: diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index dc1fa192a1..303bac61f8 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -78,7 +78,12 @@ export async function runHeadless(argv: string[]): Promise { } // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ boot: { persistenceRoot: './.sessions' } }) + const host = await startHost({ + boot: { + persistenceRoot: './.sessions', + workspaceContext: false, + }, + }) const api = new InProcessApiClient(host.handler) const created = await unwrap(await api.sessions.create({}), () => host.dispose()) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 02e98e78b5..66a99bb577 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -36,7 +36,12 @@ export async function runWeb(argv: string[]): Promise { } // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). - const host = await startHost({ boot: { persistenceRoot: './.sessions' } }) + const host = await startHost({ + boot: { + persistenceRoot: './.sessions', + workspaceContext: { maxBytes: 65_536 }, + }, + }) // Web UI plugin chain: in-memory Loader tree over the eight UI packages, // then the registry that feeds __DSH_BOOT__ and /plugins//client.js. diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a51e0c9e56..9ccdcad606 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -15,7 +15,8 @@ // and theme after, reload recovery last. Tests run sequentially in-file. import type { ChildProcess } from 'node:child_process' import { spawn } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -57,6 +58,25 @@ function waitForReadyLine(child: ChildProcess): Promise { }) } +async function rpc(baseUrl: string, method: string, payload: unknown): Promise { + const response = await fetch(`${baseUrl}/api/${method}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: `smoke-${method}`, + method, + payload, + }), + }) + if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`) + const body = await response.json() as { + result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } + } + if (!body.result.ok) throw new Error(`${method} failed: ${body.result.error.code}: ${body.result.error.message}`) + return body.result.value +} + /** W5 screenshot: evidence for the figma comparison, not a failure artifact. */ async function screen(page: Page, name: string): Promise { await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) }) @@ -116,6 +136,91 @@ describe('dsh web keyless CLI smoke', () => { rmSync(sessionsDir, { recursive: true, force: true }) } }) + + it('injects the invoking workspace AGENTS.md into the provider request', async () => { + requireDist() + const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-')) + mkdirSync(join(workspace, '.git')) + writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n') + + let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void + const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => { + resolveProviderRequest = resolve + }) + const provider = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] }) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end([ + 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', + 'data: {"choices":[{"delta":{"content":"done"}}]}', + 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + }) + }) + await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve)) + const address = provider.address() + if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port') + const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href + const child = spawn( + process.execPath, + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], + { + cwd: workspace, + env: { + ...process.env, + DEEPSEEK_API_KEY: 'keyless-web-workspace', + DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, + DSH_HOME: join(workspace, '.dsh'), + TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + try { + const baseUrl = await waitForReadyLine(child) + const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) + await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + sessionId: created.sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'go' }], + }) + const captured = await Promise.race([ + providerRequest, + new Promise((_resolve, reject) => { + setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref() + }), + ]) + const workspaceMessage = captured.messages?.find(message => + message.role === 'user' && message.content?.includes('web-workspace-context-probe')) + expect(workspaceMessage).toMatchInlineSnapshot(` + { + "content": " + The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions. + + Instructions from: AGENTS.md + + web-workspace-context-probe + + ", + "role": "user", + } + `) + } finally { + const closed = child.exitCode === null + ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) + : Promise.resolve() + if (child.exitCode === null) child.kill('SIGTERM') + await closed + await new Promise(resolveClose => provider.close(() => { resolveClose() })) + rmSync(workspace, { recursive: true, force: true }) + } + }) }) describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => { diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index 39f9888180..60fe4b22fa 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-host-runtime -Host runtime assembly for `dsc`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`. +Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, workspace instructions, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`. Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it. @@ -9,6 +9,7 @@ Which plugins mount and with what defaults is decided only here — shells must | Key | Default | Contract | |---|---:|---| | `persistenceRoot` | (required) | Root directory for JSONL session persistence. | +| `workspaceContext` | (required) | [`AGENTS.md`/`CLAUDE.md` loader](../../context/workspace-context/README.md) config with an explicit `maxBytes`, or `false` to disable it. | | `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. | | `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. | @@ -18,7 +19,7 @@ Unary methods take the narrow `RpcRequest

` and echo `request.rpcId`; a prompt ## Model Experience -Indirectly, through the model-facing plugins bootHost mounts and the provider/model defaults injected into created and resumed agents. +Indirectly, through the model-facing plugins bootHost mounts and the provider/model defaults injected into created and resumed agents. When `workspaceContext` is enabled, each agent-loop instance freezes the applicable workspace instructions into its logged request prefix; the owning package documents the exact [model-visible framing](../../context/workspace-context/README.md#prompt-shape). #### KV Cache effect @@ -27,5 +28,5 @@ No direct invalidation; the mounted model-facing plugins own their request-prefi ## Known Limitations and Deferred Work - **`respond` is a stub** — it always returns `not-pending`; the approval/question pending registry (stable-rpcId mint on accept, baseline replay on stream reopen, wire answerer) is the next host-side step. -- **`session.list` covers live sessions only** — cold sessions in the persistence directory are not yet merged into the listing; `host.describe.version` is a placeholder rather than the `apps/cli` package version. +- **`host.describe.version` is a placeholder** — it does not yet report the `apps/cli` package version. - **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet. diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index ea32040012..be3e9e1050 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -69,6 +69,7 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^" }, "peerDependencies": { diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index ca40b9f8b3..40b4837c91 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -23,6 +23,7 @@ import FsLocal from '@deepseek-ai/dsh-fs-local' import * as fsPolicy from '@deepseek-ai/dsh-fs-policy' import * as toolFs from '@deepseek-ai/dsh-tool-fs' import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search' +import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import * as toolSkill from '@deepseek-ai/dsh-tool-skill' @@ -42,6 +43,8 @@ import * as spillPolicy from '@deepseek-ai/dsh-spill-policy' export interface BootHostOptions { /** Root directory for JSONL session persistence. */ persistenceRoot: string + /** Workspace-instruction byte budget/config, or false to disable AGENTS.md/CLAUDE.md loading. */ + workspaceContext: workspaceContext.Config | false /** Default provider route for created/resumed agents (defaults to 'deepseek', the only adapter bootHost registers). */ provider?: string /** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */ @@ -76,7 +79,7 @@ export interface HostHandle { /** * Compose the harness host plugin assembly (the one place deciding which plugins mount and * with what defaults — shells must not alter the assembly). - * @param options - persistence root and optional default provider/model. + * @param options - persistence, workspace instructions, and optional default routing. * @returns the booted handle (ctx + defaults + dispose). */ export async function bootHost(options: BootHostOptions): Promise { @@ -109,6 +112,9 @@ export async function bootHost(options: BootHostOptions): Promise { await ctx.plugin(fsPolicy) await ctx.plugin(toolFs, {}) await ctx.plugin(toolFsSearch, {}) + if (options.workspaceContext !== false) { + await ctx.plugin(workspaceContext, options.workspaceContext) + } // Skill stack with the demo default dshHome (~/.dsh via resolveDshHome). await ctx.plugin(SkillService, {}) await ctx.plugin(SkillLocal, {}) diff --git a/packages/host/runtime/src/start.ts b/packages/host/runtime/src/start.ts index 44412cf184..94e5f22da1 100644 --- a/packages/host/runtime/src/start.ts +++ b/packages/host/runtime/src/start.ts @@ -16,10 +16,9 @@ import { createApiProxy } from './api-proxy.ts' /** Options for startHost. */ export interface StartHostOptions { /** - * Passed through to bootHost verbatim (persistenceRoot required + - * provider?/model?). Future host-level knobs (profile, log sink — any - * output added to the assembly MUST be switchable off here) land as - * additive fields. + * Passed through to bootHost verbatim. Future host-level knobs (profile, + * log sink — any output added to the assembly MUST be switchable off here) + * land as additive fields. */ boot: BootHostOptions } diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index f3a2dc9a82..8f800dd08a 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from 'node:fs' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -15,11 +15,14 @@ import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/i /** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */ class ScriptedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + constructor(private script: (StreamChunk[] | 'hang')[]) { super() } async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) const entry = this.script.shift() if (!entry) throw new Error('ScriptedAdapter: script exhausted') if (entry === 'hang') { @@ -79,7 +82,12 @@ afterEach(async () => { async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise { host = await startHost({ - boot: { persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')), provider: 'scripted', model: 'test-model' }, + boot: { + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')), + workspaceContext: false, + provider: 'scripted', + model: 'test-model', + }, }) host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script)) return host @@ -87,7 +95,10 @@ async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise { it('falls back to the deepseek defaults and disposes idempotently', async () => { - const handle: HostHandle = await bootHost({ persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')) }) + const handle: HostHandle = await bootHost({ + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')), + workspaceContext: false, + }) expect(handle.defaults).toMatchObject({ provider: 'deepseek', model: 'deepseek-v4-flash' }) expect(typeof handle.defaults.cwd).toBe('string') await handle.dispose() @@ -105,6 +116,41 @@ describe('bootHost / startHost', () => { await first host = undefined }) + + it('routes workspace instructions through the assembled agent request prefix', async () => { + const workspace = mkdtempSync(join(tmpdir(), 'dsh-host-workspace-')) + mkdirSync(join(workspace, '.git')) + writeFileSync(join(workspace, 'AGENTS.md'), 'host-workspace-context-probe\n') + const adapter = new ScriptedAdapter([textResponse('done')]) + host = await startHost({ + boot: { + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-workspace-sessions-')), + workspaceContext: { dshHome: join(workspace, '.dsh'), maxBytes: 65_536 }, + provider: 'scripted', + model: 'test-model', + cwd: workspace, + }, + }) + host.ctx.llm.registerAdapter(['scripted'], adapter) + const { sessionId } = expectOk(await host.api.sessions.create(request({}))) + const agent = host.ctx.agents.get(sessionId) as Agent + const idle = waitForIdle(host.ctx, agent) + + expectOk(await host.api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'go' }], + }))) + await idle + + const requestText = adapter.requests[0]?.messages + .flatMap(message => message.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') ?? '' + expect(requestText).toContain('Instructions from: AGENTS.md') + expect(requestText).toContain('host-workspace-context-probe') + }) }) describe('host.describe', () => { @@ -196,7 +242,9 @@ describe('sessions.prompt / cancel', () => { describe('sessions.history', () => { it('implicitly resumes a cold session, deduplicating concurrent calls to one attach', async () => { const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-host-resume-')) - const first = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } }) + const first = await startHost({ + boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' }, + }) first.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('persisted')])) const { sessionId } = expectOk(await first.api.sessions.create(request({}))) const agent = first.ctx.agents.get(sessionId) as Agent @@ -205,7 +253,9 @@ describe('sessions.history', () => { await idle await first.dispose() - host = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } }) + host = await startHost({ + boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' }, + }) host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([])) expect(host.ctx.agents.get(sessionId)).toBeUndefined() const [a, b] = await Promise.all([ diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index 9836e0b5fd..08efb88294 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -62,6 +62,9 @@ { "path": "../../fs/tool-fs-search" }, + { + "path": "../../context/workspace-context" + }, { "path": "../../llm/token-meter" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9b18ff2e5..3e28836146 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2077,6 +2077,9 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:^ version: link:../../workflow/workflow-workerthread + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:^ + version: link:../../context/workspace-context devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^