Merge branch 'main' of github.com:amruthpillai/reactive-resume

This commit is contained in:
Amruth Pillai
2026-08-18 20:42:47 +02:00
19 changed files with 2627 additions and 675 deletions
+60
View File
@@ -0,0 +1,60 @@
# dsh-plugin-reactive-resume
Connect [Reactive Resume](https://rxresu.me) to [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). Read, create, and edit your resumes and job applications from a Harness session.
## Install
```bash
dsh plugin --profile <name> add dsh-plugin-reactive-resume
```
This package declares `dsh.bundle`, so the profile picks it up as a layer and mounts it automatically. Until you configure a key it mounts nothing and logs a warning, so installing it never leaves a profile unbootable.
## Configure
Mint an API key at `https://rxresu.me/dashboard/settings/api-keys` and export it as `RXRESUME_API_KEY` — the bundle patch reads that variable. To set it explicitly, or to change any other option, patch the row by id from your profile's `cordis.patch.yml`:
```yaml
- id: reactive-resume
config:
apiKey: !!js process.env.RXRESUME_API_KEY
```
### Options
| Key | Default | Description |
|---|---|---|
| `apiKey` | `''` | API key from `<url>/dashboard/settings/api-keys`. Empty mounts nothing. |
| `url` | `https://rxresu.me` | Origin of your instance. Set this if you self-host. |
| `serverName` | `resume` | Tool namespace. Tools reach the model as `mcp__<serverName>__<rawName>`. |
| `toolCallTimeoutMs` | `60000` | Per-tool-call timeout. |
Every tool Reactive Resume publishes is exposed. Narrowing that set is not currently possible from a plugin: Harness's `ctx.tools.restrict()` requires an agent-scoped context, which a plugin context is not.
### Self-hosted
```yaml
- id: reactive-resume
config:
apiKey: !!js process.env.RXRESUME_API_KEY
url: http://localhost:3000
```
## What it does
Bridges Reactive Resume's MCP server into `ctx.tools`, and contributes a system-prompt section covering the things models get wrong about resume editing: reading before patching, RFC 6902 path construction against the published schema, UUID-keyed section entries, and locked resumes.
You could wire the bridge yourself with a raw `@deepseek-ai/dsh-mcp-client` row. What you cannot do that way is contribute the prompt section — that is what this package adds.
## Development
This package lives in the [Reactive Resume monorepo](https://github.com/amruthpillai/reactive-resume) at `packages/dsh-plugin`, next to `packages/mcp` — the server it bridges. `src/tool-names.test.ts` checks every tool the prompt guide names against `@reactive-resume/mcp/tool-names`, so renaming a tool breaks this package in the same pull request.
```bash
pnpm --filter dsh-plugin-reactive-resume test
pnpm --filter dsh-plugin-reactive-resume build
```
## License
MIT
+17
View File
@@ -0,0 +1,17 @@
# dsh-plugin-reactive-resume bundle patch: one insert over the profile root.
# `dsh plugin add` joins this layer to the profile's bundle stack automatically.
# Override any field by patching the row by id from your profile's own
# cordis.patch.yml (the last write wins per row) — for a self-hosted instance:
#
# - id: reactive-resume
# config:
# apiKey: !!js process.env.RXRESUME_API_KEY
# url: http://localhost:3000
#
# With no apiKey the row loads and mounts nothing, so installing this plugin
# before configuring it leaves the profile bootable.
- insert:
- id: reactive-resume
name: dsh-plugin-reactive-resume
config:
apiKey: !!js process.env.RXRESUME_API_KEY ?? ''
@@ -0,0 +1,166 @@
# DeepSeek Harness Plugin for Reactive Resume — Design
**Date:** 2026-08-16
**Status:** Approved, ready for implementation planning
**Package:** `dsh-plugin-reactive-resume`
> **Post-implementation note:** the `tools` config key and `ctx.tools.restrict()` call described below (Config, `apply()` step 2, "Tool-name generation and drift detection") were cut from v0.1.0. `docs/spikes/2026-08-16-restrict-semantics.md` found that `restrict()` cannot reach tools a plugin's own `ctx.plugin(mcpClient, …)` registers — it throws when called from an unscoped plugin context, and even from a real agent scope it refuses to touch a scope's own (as opposed to inherited) registrations. There is no arrangement reachable from this plugin's `apply(ctx, config)` where curation works, so 0.1.0 ships all tools unfiltered, per that spike's documented fallback. The rest of this document is left as originally written for history; it does not describe what shipped.
## Goal
Distribute a DeepSeek Harness plugin that connects Harness to a Reactive Resume account, so a DSH user can read, create, and edit resumes and job applications from their agent session with one config row and an API key.
## Context
### What Reactive Resume already ships
Reactive Resume exposes a complete remote MCP server. Nothing on the server side needs to change for this plugin to work.
- `packages/mcp` registers 33 tools (`list_resumes`, `read_resume`, `apply_resume_patch`, `tailor_resume_for_application`, …), 3 prompts, and 2 resources.
- `apps/server/src/http/app.ts` mounts `/mcp` and `/mcp/*` (Streamable HTTP) plus `/.well-known/mcp/server-card.json` (SEP-1649).
- `apps/server/src/mcp/auth.ts` accepts either an OAuth `Authorization: Bearer` token or an `x-api-key` header. The API key path needs no interactive flow.
- The web app already ships API key management at `/dashboard/settings/api-keys`, so user provisioning is a solved problem.
### What DeepSeek Harness provides
Verified against the published packages, not only the docs.
- A plugin is a TypeScript module exporting `name`, optional `inject`, and `apply(ctx, config)`. Config schemas use `@deepseek-ai/schemastery`.
- `@deepseek-ai/dsh-mcp-client` (v0.0.1-rc.1) bridges one external MCP server per plugin instance. Its `StreamableHttpConfig` is exactly:
```ts
interface StreamableHttpConfig {
transport: 'streamable-http'
serverName: string // [A-Za-z0-9_-]{1,32}, unique across live instances
url: string
headers: Record<string, string>
toolCallTimeoutMs: number
failOnStartupError: boolean
}
```
Bridged tools become model-facing as `mcp__<serverName>__<rawName>`.
- `ctx.tools` is a `ToolRegistry` exposing `restrict(filter: ToolRestriction): () => void`, where `ToolRestriction` is `{ allow?: readonly string[]; deny?: readonly string[] }`.
- `ctx.systemPrompt` (from `@deepseek-ai/dsh-system-prompt`) exposes `section(section: PromptSection): () => void`, where `PromptSection` is `{ name, order, text, complete? }`. Convention: `-100` is harness identity, `0` the deployment persona, and `100199` is tool guidance.
- Plugins are distributed on npm and discovered through the `dsh-plugin` GitHub topic.
### The gap this plugin fills
`dsh-mcp-client` has no tool filtering and no way to contribute prompt text. A user pasting a raw MCP row into `cordis.yml` gets all 33 tool schemas in their context budget and no guidance on Reactive Resume's JSON Patch semantics. Those two things are the plugin's reason to exist.
## Decisions
| Decision | Choice | Why |
|---|---|---|
| Scope | Thin MCP bridge + prompt section | The tool layer already exists and is maintained in Reactive Resume. Reimplementing 33 tool contracts against oRPC would drift every release. |
| Repo | Standalone, outside the Reactive Resume monorepo | The plugin imports nothing from Reactive Resume — it speaks HTTP. The monorepo has no npm publish pipeline: root and every package are `private: true`, there is no build output, no changesets, no `NPM_TOKEN`, and no publish workflow. Adding one to ship a dependency-free package is cost without benefit. |
| Auth | `x-api-key` only | Two clicks in Reactive Resume settings. OAuth needs a token store and an interactive flow for no gain. |
| Drift protection | Server-card contract test in CI | Replaces the lockstep the monorepo would have given, without the pipeline. |
## Architecture
### Package shape
Namespace plugin, mirroring `dsh-mcp-client`'s own export form:
```ts
export const name = 'reactive-resume'
export const inject = ['tools', 'systemPrompt']
export const Config: z<Config>
export async function apply(ctx: Context, config: Config): Promise<void>
```
`@deepseek-ai/cordis`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and `@deepseek-ai/dsh-mcp-client` are `peerDependencies` so the plugin binds to the host's versions rather than installing a second copy of the runtime.
### Config
```ts
interface Config {
/** API key from <url>/dashboard/settings/api-keys. */
apiKey: string
/** Reactive Resume instance origin. Default 'https://rxresu.me'. */
url?: string
/** Tool namespace: tools appear as mcp__<serverName>__<rawName>. Default 'resume'. */
serverName?: string
/** Which tool group to expose. Default 'all'. */
tools?: 'resume' | 'applications' | 'all'
/** Per-tool-call timeout. Default inherited from dsh-mcp-client. */
toolCallTimeoutMs?: number
}
```
`url` accepts any origin so self-hosted instances work unchanged.
### apply()
Three steps, in order:
1. **Mount the bridge.** `ctx.plugin(mcpClient, { transport: 'streamable-http', serverName, url:`${url}/mcp`, headers: { 'x-api-key': apiKey }, toolCallTimeoutMs, failOnStartupError: true })`. `failOnStartupError: true` turns a bad API key into a loud activation failure instead of tools that silently fail at call time.
2. **Curate the tool surface.** When `tools !== 'all'`, call `ctx.tools.restrict({ deny: [...] })` with the namespaced names of the excluded group, computed from the generated tool-name list. See "Open risk" below.
3. **Contribute prompt guidance.** `ctx.systemPrompt.section({ name: 'reactive-resume', order: 150, text: PATCH_GUIDE })`.
All three return disposers; Cordis effect scoping unwinds them on unload, so no manual cleanup is needed beyond returning them where the API expects it.
### PATCH_GUIDE
The plugin's substance. Roughly 40 lines covering the failure modes Reactive Resume already encodes as error hints in `packages/mcp/src/tools.ts` (`errorHint`) — those hints exist precisely because models get these wrong:
- Call `read_resume` before `apply_resume_patch`; never patch blind.
- `apply_resume_patch` takes RFC-6902 operations against the resume data document.
- Fetch the `resume://_meta/schema` resource before constructing paths.
- Section entries are arrays of objects keyed by UUID; ids are not indices.
- A locked resume rejects writes — call `unlock_resume` first.
- `list_resumes` is the way to recover a valid id after a 404.
Written as static text, not a provider function — it does not vary per assembly.
### Tool-name generation and drift detection
> **Superseded by the move into the monorepo.** The plugin now sits beside the MCP server it bridges, so `src/tool-names.test.ts` reads `@reactive-resume/mcp/tool-names` directly. A tool rename fails that test on the same pull request. The generated snapshot, the fetch script, and the scheduled network job described below no longer exist.
- Build step fetches `<url>/.well-known/mcp/server-card.json` and emits `src/tool-names.generated.ts` containing the raw tool names split into the `resume` and `applications` groups.
- The generated file is committed, so `npm install` never needs network access.
- A scheduled CI job refetches from `https://rxresu.me` and fails when the committed list no longer matches the live card. That failure is the signal to cut a new plugin release.
### Install UX
README's copy-paste block:
```yaml
- insert:
- id: reactive-resume
name: dsh-plugin-reactive-resume
config:
apiKey: !!js process.env.RXRESUME_API_KEY
```
## Open risk
`ToolRegistry.restrict`'s contract reads: *"Per-scope filter over the tools a scope INHERITS — the global layer and every ancestor layer on its chain. Restrictions intersect, and do not affect the scope's own registrations."*
`ctx.plugin(mcpClient, …)` mounts the bridge in a **child** scope of the plugin's context. The bridged tools are therefore registered in a descendant, not an ancestor, of the scope calling `restrict`. Whether a parent-scope restriction reaches them is unverified.
This must be settled by a spike before the config surface is committed, because `tools` is a public config key and removing it later is a breaking change.
**Fallback if `restrict` does not reach the child scope:** ship 0.1.0 without the `tools` key (equivalent to `'all'`), and solve curation in 0.2.0 — possibly by mounting the bridge at the same scope level rather than as a child. The prompt section alone justifies the release.
## Testing
- **Spike (blocking):** run a local Reactive Resume (`dotenvx run -f .env.local -- pnpm dev`, port 3000), point a scratch Harness config at `http://localhost:3000/mcp` with an `x-api-key`, and confirm (a) the Streamable HTTP bridge connects and lists 33 tools, and (b) whether `ctx.tools.restrict` from the plugin's scope hides bridged tools.
- **Unit:** `Config` schema defaults and validation; the deny-list computation from the generated tool names; `PATCH_GUIDE` section registers at order 150 with the expected name.
- **Contract:** server-card fetch matches `src/tool-names.generated.ts`.
- **Manual smoke before publish:** in a real Harness session against rxresu.me — list resumes, read one, apply a patch, verify the change in the web UI.
## Out of scope
OAuth support, `ctx.commands` shortcuts, a bundled Harness skill, local resume caching, and PDF rendering inside Harness. Each is additive and none blocks a useful 0.1.0.
## Build order
0. Spike `restrict()` semantics and the Streamable HTTP bridge against localhost.
1. Repo scaffold, `Config` schema, bridge mount, README. End-to-end working install.
2. `PATCH_GUIDE` prompt section.
3. Tool profiles, generated name list, drift CI.
4. Publish 0.1.0, tag the repo with the `dsh-plugin` topic, submit to `awesome-deepseek-harness`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,382 @@
# Spike: does `ctx.tools.restrict()` reach a child scope's tools?
**Verdict up front:** `restrict reaches child-scope tools: NO`
The plugin's own `apply(ctx, config)` — a bare Cordis plugin context that
never mints a `dsh-scope` scope — cannot call `ctx.tools.restrict()` at all.
It throws. When the arrangement is fixed so `restrict()` is at least
callable (a real agent-style scope), it still refuses to touch a tool the
bridge registered inside that same scope's own layer. Neither path reaches
the topology this plugin needs. The `tools` config key must not ship in
0.1.0.
## Environment and bootstrapping
Scaffolded a throwaway workspace at `/tmp/dsh-spike` (deleted after this
spike; nothing there is part of the plugin repo).
```bash
mkdir -p /tmp/dsh-spike && cd /tmp/dsh-spike && pnpm init
```
**Version resolution problem.** `pnpm add @deepseek-ai/cordis @deepseek-ai/dsh-tools @deepseek-ai/schemastery`
with no version pins resolves `dsh-tools` to its `next` dist-tag
(`0.1.0-rc.6`), whose peer chain requires `@deepseek-ai/dsh-agent` and
`@deepseek-ai/dsh-session`, both of which peer-depend on
`@deepseek-ai/dsh-type-meta` — a package that returns a plain 404 from the
public npm registry (confirmed directly: `npm view @deepseek-ai/dsh-type-meta`
`404 Not Found`). Pinning `dsh-tools` to `0.0.1-rc.1` (the version this
plugin actually targets — confirmed via `npm view @deepseek-ai/dsh-tools
dist-tags`, where `latest` is `0.0.1-rc.1`) does not by itself fix this: pnpm
still auto-installs peer dependencies for the lockfile, so the same
`dsh-type-meta` 404 recurs indirectly through `dsh-agent`'s and
`dsh-session`'s peer graph.
**Fix that worked:** disable pnpm's peer auto-install and add only the
packages `dsh-tools`'s *compiled* `lib/index.js` actually imports at
runtime (checked directly — `import type` lines don't need the package on
disk, real `import` lines do):
```
import { Service } from "@deepseek-ai/cordis";
import z from "@deepseek-ai/schemastery";
import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from "@deepseek-ai/dsh-scope";
import { CallId, HarnessError, assertNever, deepFreeze } from "@deepseek-ai/dsh-llm";
import { isJsonValue, snapshotJsonValue } from "@deepseek-ai/dsh-session";
```
`.npmrc`:
```
auto-install-peers=false
strict-peer-dependencies=false
```
Install commands, in order (each added only after the previous run's
`ERR_MODULE_NOT_FOUND` named the next missing runtime import):
```bash
pnpm add @deepseek-ai/cordis@^4.0.1-rc.1 @deepseek-ai/dsh-tools@0.0.1-rc.1 @deepseek-ai/schemastery@^3.18.1-rc.1 \
--config.auto-install-peers=false --config.strict-peer-dependencies=false
pnpm add @deepseek-ai/dsh-scope@0.0.1-rc.1 @deepseek-ai/dsh-llm@0.0.1-rc.1 @deepseek-ai/dsh-session@0.0.1-rc.1 \
--config.auto-install-peers=false --config.strict-peer-dependencies=false
pnpm add @deepseek-ai/dsh-system-prompt@0.0.1-rc.1 @deepseek-ai/dsh-invariants@0.0.1-rc.1 \
--config.auto-install-peers=false --config.strict-peer-dependencies=false
pnpm add @deepseek-ai/dsh-timeout@0.0.1-rc.1 \
--config.auto-install-peers=false --config.strict-peer-dependencies=false
```
`dsh-system-prompt` and `dsh-invariants` were needed for a second reason,
not just a `dsh-tools` runtime import: `ToolRegistry.inject = ["systemPrompt"]`
and its constructor calls `ctx.systemPrompt.tools(...)` immediately, so a
`systemPrompt` service must be mounted on `ctx` *before* `ToolRegistry` is.
`dsh-timeout` surfaced one level further down, as a real (non-type) import
inside `dsh-llm`'s compiled output.
None of the packages actually needed at `0.0.1-rc.1` depend on
`dsh-type-meta` — only `dsh-agent` and `dsh-session`'s *peer* list does
(`dsh-session` doesn't import it at runtime, so it was never installed and
never missed). Final resolved set: `cordis@4.0.1`, `dsh-tools@0.0.1-rc.1`,
`schemastery@3.18.1`, `dsh-scope@0.0.1-rc.1`, `dsh-llm@0.0.1-rc.1`,
`dsh-session@0.0.1-rc.1`, `dsh-system-prompt@0.0.1-rc.1`,
`dsh-invariants@0.0.1-rc.1`, `dsh-timeout@0.0.1-rc.1`.
**Dead end worth recording:** before finding the npm-registry version-pin
fix, I found a fully-resolved install of these packages already on disk at
`~/.local/share/mise/installs/node/24.19.0/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/*`
(the globally-installed `dsh` CLI's own bundled `node_modules`). That
install is `dsh-tools@0.1.0-rc.6`, not `0.0.1-rc.1` — a different minor
line with a renamed class (`ToolRuntime`, not `ToolRegistry`) and a
different `register`/`restrict`/`schemas` signature (`scope?: ScopeKey`
parameter instead of implicit calling-context resolution). I did **not**
use this install for the verdict below — it's the wrong pinned version for
this plugin — but it's why the bootstrapping path above took several
iterations: I initially assumed the API surface from that install, then had
to re-derive it from the actually-pinned `0.0.1-rc.1` types.
## The actual `ToolDefinition` shape (0.0.1-rc.1)
The brief's guessed shape (`parameters`, bare `async execute() { return
{content:[...]} }`) doesn't compile against `node_modules/@deepseek-ai/dsh-tools/lib/types/index.d.ts`.
The real shape:
```ts
export interface ToolDefinition extends ToolSchema {
// ToolSchema = { name: string; description: string; parameters: Record<string, unknown> }
readonly output: ToolOutputDefinition; // MANDATORY, not optional
execute(args: unknown, exec: ToolRunContext): Promise<unknown>; // returns the canonical value, not ContentBlock[]
finalizeContent?(...): ContentBlock[] | undefined;
timeoutMs?: number;
isConcurrencySafe?(args: unknown): boolean;
presentCall?(args: unknown): ToolCallView | undefined;
presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;
}
interface ToolOutputDefinition {
readonly schema: JsonSchemaNode; // enforced JSON Schema subset
render(args: unknown, value: JsonValue): ContentBlock[]; // projects the canonical value to model-facing content
presentationMeta?(args: unknown, value: JsonValue): JsonValue;
}
```
`execute()` returns the tool's canonical JSON value (validated against
`output.schema`); `output.render()` is what turns that value into
`ContentBlock[]`. `register()` throws a `TypeError` if `output` is missing
or `output.render` isn't a function — confirmed by reading
`ToolRegistry.register` in the compiled `lib/index.js`.
`ToolRegistry` itself: `export { ToolRegistry, ToolRegistry as default }`
it's a Cordis `Service` subclass (`super(ctx, "tools")`), so it's mounted
with `ctx.plugin(ToolRegistry, config)`, not `ctx.plugin(tools)` where
`tools` is the whole module namespace (the brief's guess).
## The mechanism (read from the compiled source, then verified by running it)
`declare module '@deepseek-ai/cordis' { interface Context { tools: ToolRegistry } }`
`ctx.tools` is one Cordis **service singleton**, shared down the whole
context tree exactly like every other Cordis service. There is no
per-Cordis-child-context instance of the registry; nesting a plugin under
`ctx.plugin(...)` does not give it a private `tools`.
What actually gates `register()`/`restrict()`'s visibility isn't the Cordis
plugin-context tree at all — it's a **separate, opt-in scoping layer** from
`@deepseek-ai/dsh-scope`:
- `scopeOf(ctx)` reads "the nearest scope tag inherited by a context" — and
a context only carries a scope tag if something called
`createScope(ctx, key)` on it (or a Cordis ancestor of it). A plain
`ctx.plugin(child)` context is **not** scoped by that call alone.
- `ToolRegistry.register(definition)`: `this.layers.effect(this.ctx, (layer) => layer.tools.insert(name, definition), ...)`
lands in whatever layer `scopeOf(this.ctx)` resolves to (the global layer
if unscoped). No scope requirement to call it.
- `ToolRegistry.restrict(filter)`: the **first line** is
`const scope = scopeOf(this.ctx); if (scope === void 0) throw new Error("tools.restrict() requires a scoped context (agent.ctx): ...")`.
It is unconditionally unusable from an unscoped context — this is not a
silent no-op, it's a thrown error.
- Even when `scope !== undefined`, `restrict()` computes
`known = this.view(scope).restrictableNames` (the scope's *inherited*
surface — global + ancestor layers) and rejects any name not in that set:
`"a restriction filters what this scope inherits, never what it registers itself"`.
A tool registered as a **child** of the exact scope doing the restricting
is, by construction, in that scope's own layer, not its inherited surface
— so it is unconditionally unreachable by that scope's own `restrict()`
call, confirmed by the second probe below.
This is a stricter, more mechanical version of what the `ToolRestriction`
docstring already said in prose (`"do not affect the scope's own
registrations"`) — the two probes below hit it from two different angles
and got two different thrown errors, not one graceful no-op.
## Probe 1 — the literal topology from the task brief
`ctx.plugin(mcpClient)` mounts the bridge as a Cordis **child** of the
plugin's own `apply(ctx, config)`; the plugin then calls
`ctx.tools.restrict(...)` from its own (parent, unscoped) `ctx`. This
reproduces the plugin's real design as literally as a stub allows.
`/tmp/dsh-spike/probe.ts`:
```ts
import { Context } from "@deepseek-ai/cordis";
import SystemPrompt from "@deepseek-ai/dsh-system-prompt";
import ToolRegistry from "@deepseek-ai/dsh-tools";
const root = new Context();
// ToolRegistry.inject = ["systemPrompt"], and its constructor calls
// ctx.systemPrompt.tools(...) immediately, so systemPrompt must be mounted first.
await root.plugin(SystemPrompt);
await root.plugin(ToolRegistry);
/** Stands in for dsh-mcp-client: registers one tool in whatever scope loads it. */
const stubBridge = {
name: "stub-bridge",
inject: ["tools"],
apply(ctx: Context) {
ctx.tools.register({
name: "mcp__resume__list_applications",
description: "stub",
parameters: { type: "object", properties: {} },
output: {
schema: { type: "string" },
render: (_args: unknown, value: unknown) => [{ type: "text", text: String(value) }],
},
async execute() {
return "ok";
},
});
},
};
// The plugin under design mounts the bridge as a child, exactly like this.
await root.plugin(stubBridge);
const names = () => root.tools.schemas().map((s) => s.name);
console.log("BEFORE", names());
try {
const dispose = root.tools.restrict({ deny: ["mcp__resume__list_applications"] });
console.log("AFTER", names());
dispose();
console.log("DISPOSED", names());
} catch (err) {
console.log("RESTRICT_THREW", (err as Error).message);
}
```
Run with `node --experimental-strip-types probe.ts`. Actual output,
verbatim:
```
BEFORE [ 'mcp__resume__list_applications' ]
RESTRICT_THREW tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead
```
`restrict()` never gets a chance to filter anything — it throws before
touching the tool set, because `root` (and every context under it, absent
an explicit `createScope()`) is unscoped.
## Probe 2 (Step 4) — the best-case sibling arrangement
Per the brief's Step 4, tried fixing the exception by giving the plugin a
real `dsh-scope` scope (what `createScope()` provides) and calling
`restrict()` from *inside* that scope, matching "registering the
restriction inside the same scope the bridge loads into."
Where a real agent gets this: `@deepseek-ai/dsh-agent-loop@0.0.1-rc.1`
`lib/index.js:375`
```js
this.scope = createScope(loopCtx, this);
this.ctx = this.scope.ctx.extend({ agent: this });
```
**not** `dsh-agent`, which never calls `createScope` (its compiled
`lib/index.js` only calls `scopeTarget`, for event routing, not for minting
a scope). This is the exact line that builds `agent.ctx` — the thing
`ToolRegistry`'s thrown error message names as what `restrict()` requires.
Confirmed directly: installed both `dsh-agent@0.0.1-rc.1` and
`dsh-agent-loop@0.0.1-rc.1` with the same
`--config.auto-install-peers=false --config.strict-peer-dependencies=false`
workaround used for the rest of the dependency tree (`dsh-type-meta` is only
a *peer* dependency of `dsh-agent`, never a runtime import — same situation
as `dsh-session`/`dsh-scope` above — so it's never actually needed on disk),
then grepped the installed `lib/index.js` files for `createScope`.
`/tmp/dsh-spike/probe2.ts`:
```ts
import { Context } from "@deepseek-ai/cordis";
import { createScope } from "@deepseek-ai/dsh-scope";
import SystemPrompt from "@deepseek-ai/dsh-system-prompt";
import ToolRegistry from "@deepseek-ai/dsh-tools";
const root = new Context();
await root.plugin(SystemPrompt);
await root.plugin(ToolRegistry);
const stubBridge = {
name: "stub-bridge",
inject: ["tools"],
apply(ctx: Context) {
ctx.tools.register({
name: "mcp__resume__list_applications",
description: "stub",
parameters: { type: "object", properties: {} },
output: {
schema: { type: "string" },
render: (_args: unknown, value: unknown) => [{ type: "text", text: String(value) }],
},
async execute() {
return "ok";
},
});
},
};
// Mint a dsh-scope "Scope" (what dsh-agent-loop does to build agent.ctx --
// see lib/index.js:375) and mount the bridge as a CHILD of that scope's ctx
// -- i.e. an inherited/ancestor registration relative to the scope itself,
// not the scope's own layer.
const scopeKey = {};
const scope = createScope(root, scopeKey);
await scope.ctx.plugin(stubBridge);
const namesFor = (ctx: Context) => ctx.tools.schemas(scopeKey).map((s) => s.name);
console.log("BEFORE(scoped view)", namesFor(root));
// Cordis requires a plugin to declare inject: ['tools'] to bare-access
// ctx.tools; call restrict() from inside a plugin mounted on scope.ctx so
// the "calling scope" Cordis sees is the scope itself (agent.ctx-equivalent).
let disposeRestrict: (() => void) | undefined;
const restrictor = {
name: "stub-restrictor",
inject: ["tools"],
apply(ctx: Context) {
try {
disposeRestrict = ctx.tools.restrict({ deny: ["mcp__resume__list_applications"] });
console.log("AFTER(scoped view)", namesFor(root));
} catch (err) {
console.log("RESTRICT_THREW", (err as Error).message);
}
},
};
await scope.ctx.plugin(restrictor);
if (disposeRestrict) {
disposeRestrict();
console.log("DISPOSED(scoped view)", namesFor(root));
}
```
Run with `node --experimental-strip-types probe2.ts`. Actual output,
verbatim:
```
BEFORE(scoped view) [ 'mcp__resume__list_applications' ]
RESTRICT_THREW tools.restrict() names unknown inherited tool "mcp__resume__list_applications"; a restriction filters what this scope inherits, never what it registers itself. Restrictable tools: (none)
```
`restrict()` is now at least *callable*, but it explicitly refuses: the
bridge's tool lives in the same scope's own layer (it was mounted as a
Cordis child of `scope.ctx`, which is what makes it inherit that scope
tag), and `restrict()`'s error message says outright that it will never
touch a scope's own registrations, only what it inherits from an ancestor.
`Restrictable tools: (none)` — there was nothing in this arrangement for
the scope to restrict, because nothing was registered in any ancestor of
it.
I did not chase the remaining permutation (bridge registered as an
*ancestor* scope's own layer, restrict called from a *descendant* scope of
that ancestor) — the class-level doc comment in `dsh-tools` confirms that
shape is the one `restrict()` is actually built for (a parent scope curbing
what a child scope inherits from it), but it doesn't match this plugin's
topology: the plugin's `apply(ctx, config)` runs once at harness startup,
before any real agent scope exists, and isn't in a position to be an
ancestor of the eventual agent's scope. Chasing it further would still not
produce an arrangement reachable from this plugin's own `apply(ctx, config)`,
which is the brief's actual bar for flipping the verdict to YES. Time spent
on Step 4: about 15 minutes, well under the 30-minute cap.
I did not spin up a full agent loop from `dsh-agent`/`dsh-agent-loop` (the
stub plugins above stand in for one, per the brief's instruction not to need
a live agent) — but I did install both packages and grep their compiled
output to confirm the `createScope()` attribution above, as noted earlier in
this section. No residual gap remains on that point.
## Verdict
```
restrict reaches child-scope tools: NO
```
Both the literal plugin topology (unscoped `restrict()` call → throws
immediately) and the best-case fix for that (scoped `restrict()` call →
throws with "unknown inherited tool" because the bridge's registration is
the scope's own, not inherited) fail to hide the child-registered tool. The
`tools` config key is deferred out of 0.1.0, per Task 6's fallback note.
## Cleanup
```bash
rm -rf /tmp/dsh-spike
```
+75
View File
@@ -0,0 +1,75 @@
{
"name": "dsh-plugin-reactive-resume",
"version": "0.1.0",
"description": "DeepSeek Harness plugin for Reactive Resume: bridges your resumes and job applications into a Harness session over MCP.",
"keywords": [
"dsh-plugin",
"deepseek-harness",
"mcp",
"reactive-resume",
"resume"
],
"license": "MIT",
"author": "Amruth Pillai",
"repository": {
"type": "git",
"url": "git+https://github.com/amruthpillai/reactive-resume.git",
"directory": "packages/dsh-plugin"
},
"homepage": "https://github.com/amruthpillai/reactive-resume/tree/main/packages/dsh-plugin#readme",
"bugs": {
"url": "https://github.com/amruthpillai/reactive-resume/issues"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"cordis.patch.yml"
],
"publishConfig": {
"access": "public"
},
"engines": {
"node": "^22.19.0 || >=24.0.0"
},
"scripts": {
"build": "tsdown",
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests",
"prepublishOnly": "pnpm build"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-mcp-client": "^0.1.0-rc.6",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
"@deepseek-ai/schemastery": "^3.18.1"
},
"devDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-mcp-client": "^0.1.0-rc.6",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
"@deepseek-ai/schemastery": "^3.18.1",
"@reactive-resume/config": "workspace:*",
"@reactive-resume/mcp": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"tsdown": "^0.22.0",
"typescript": "^7.0.2",
"vitest": "^4.1.10"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import { expect, it } from "vitest";
import { Config } from "./config";
it("applies defaults for every optional field", () => {
const parsed = Config({ apiKey: "test-key" });
expect(parsed).toEqual({
apiKey: "test-key",
url: "https://rxresu.me",
serverName: "resume",
toolCallTimeoutMs: 60_000,
});
});
it("keeps explicit values", () => {
const parsed = Config({
apiKey: "test-key",
url: "http://localhost:3000",
serverName: "rr",
toolCallTimeoutMs: 5_000,
});
expect(parsed.url).toBe("http://localhost:3000");
expect(parsed.serverName).toBe("rr");
expect(parsed.toolCallTimeoutMs).toBe(5_000);
});
it("defaults apiKey to empty rather than throwing, so an unconfigured row still loads", () => {
expect(Config({}).apiKey).toBe("");
});
+34
View File
@@ -0,0 +1,34 @@
import z from "@deepseek-ai/schemastery";
/** `dsh-mcp-client` reserves this shape for a server namespace. */
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
/** Resolved plugin configuration. Every field is populated after parsing. */
export interface Config {
/** API key minted at `<url>/dashboard/settings/api-keys`. */
apiKey: string;
/** Reactive Resume origin, no trailing slash. */
url: string;
/** Tool namespace: tools reach the model as `mcp__<serverName>__<rawName>`. */
serverName: string;
/** Per-tool-call timeout in milliseconds. */
toolCallTimeoutMs: number;
}
export const Config = z.object({
// Not `.required()`: the bundle patch mounts this plugin as soon as the
// package is installed, so a missing key has to be an inert no-op rather
// than a validation error that takes the whole profile down at boot.
// `apply` warns and mounts nothing instead.
apiKey: z.string().default("").description("API key from <url>/dashboard/settings/api-keys."),
url: z
.string()
.default("https://rxresu.me")
.description("Reactive Resume origin. Set this for a self-hosted instance."),
serverName: z
.string()
.pattern(SERVER_NAME_PATTERN)
.default("resume")
.description("Tool namespace. Must match [A-Za-z0-9_-]{1,32} and be unique across live MCP instances."),
toolCallTimeoutMs: z.natural().default(60_000).description("Per-tool-call timeout in milliseconds."),
});
+58
View File
@@ -0,0 +1,58 @@
import { expect, it, vi } from "vitest";
import { Config } from "./config";
import { apply, inject, name } from "./index";
/** Minimal stand-in for the parts of the Cordis context `apply` touches. */
function fakeContext() {
return {
plugin: vi.fn(async (_plugin: unknown, _config: unknown) => undefined),
systemPrompt: { section: vi.fn(() => () => undefined) },
logger: { warn: vi.fn() },
};
}
it("exports the cordis plugin name", () => {
expect(name).toBe("reactive-resume");
});
it("declares the services it needs", () => {
expect(inject).toEqual(["systemPrompt"]);
});
it("mounts the MCP bridge with streamable-http and the api key header", async () => {
const ctx = fakeContext();
await apply(ctx as never, Config({ apiKey: "test-key" }));
expect(ctx.plugin).toHaveBeenCalledTimes(1);
expect(ctx.plugin.mock.calls[0]?.[1]).toEqual({
transport: "streamable-http",
serverName: "resume",
url: "https://rxresu.me/mcp",
headers: { "x-api-key": "test-key" },
toolCallTimeoutMs: 60_000,
failOnStartupError: true,
});
});
it("strips a trailing slash from the configured url", async () => {
const ctx = fakeContext();
await apply(ctx as never, Config({ apiKey: "test-key", url: "http://localhost:3000/" }));
expect(ctx.plugin.mock.calls[0]?.[1]).toMatchObject({ url: "http://localhost:3000/mcp" });
});
it("rejects a serverName the bridge would refuse at config-parse time, before apply runs", () => {
expect(() => Config({ apiKey: "test-key", serverName: "has spaces" })).toThrow(/serverName/);
});
it("mounts nothing when no apiKey is configured, so an unconfigured install still boots", async () => {
const ctx = fakeContext();
await apply(ctx as never, Config({}));
expect(ctx.plugin).not.toHaveBeenCalled();
expect(ctx.systemPrompt.section).not.toHaveBeenCalled();
expect(ctx.logger.warn).toHaveBeenCalledTimes(1);
});
+66
View File
@@ -0,0 +1,66 @@
/**
* DeepSeek Harness plugin for Reactive Resume.
* @module dsh-plugin-reactive-resume
*/
import type { Context } from "@deepseek-ai/cordis";
// Side-effect import: pulls in the `Context.systemPrompt` module augmentation
// this plugin relies on below. No runtime value is used from this module.
import type {} from "@deepseek-ai/dsh-system-prompt";
import type { Config } from "./config";
import * as mcpClient from "@deepseek-ai/dsh-mcp-client";
import { buildPatchGuide } from "./prompt";
// Re-exports the interface AND the schema — `config.ts` exports both under the
// name `Config`, and Cordis reads the schema export to validate config before
// this plugin starts.
export { Config } from "./config";
/** Cordis plugin name used by loader diagnostics. */
export const name = "reactive-resume";
/**
* Services required by this plugin.
*
* `tools` is deliberately not injected here: `ctx.tools.restrict()` requires
* an agent-scoped context, which a plugin's own `apply(ctx, config)` never
* is, and this plugin never calls it. `@deepseek-ai/dsh-mcp-client` declares
* its own dependency on the tools service, so the bridge still gets what it
* needs without this plugin waiting on it.
*/
export const inject = ["systemPrompt"];
/**
* Connect a Reactive Resume account to the session.
* @param ctx - plugin context carrying prompt assembly.
* @param config - resolved plugin configuration. `Config` already rejects an
* invalid `serverName` at parse time, before `apply` ever runs.
*/
export async function apply(ctx: Context, config: Config): Promise<void> {
// The bundle patch mounts this row on install, before anyone has minted a
// key. Mount nothing rather than failing the profile's boot: `dsh plugin
// add` should never leave the harness unbootable.
if (config.apiKey === "") {
ctx.logger.warn("no apiKey configured — set one at %s/dashboard/settings/api-keys to enable the tools", config.url);
return;
}
const origin = config.url.replace(/\/+$/, "");
await ctx.plugin(mcpClient, {
transport: "streamable-http",
serverName: config.serverName,
url: `${origin}/mcp`,
headers: { "x-api-key": config.apiKey },
toolCallTimeoutMs: config.toolCallTimeoutMs,
failOnStartupError: true,
});
ctx.systemPrompt.section({
// Namespaced by `serverName` so a second instance (e.g. a self-hosted
// account alongside the hosted one) doesn't collide on section name.
name: `reactive-resume:${config.serverName}`,
order: 150,
text: buildPatchGuide(config.serverName),
});
}
+61
View File
@@ -0,0 +1,61 @@
import type { PromptSection } from "@deepseek-ai/dsh-system-prompt";
import { expect, it, vi } from "vitest";
import { Config } from "./config";
import { apply } from "./index";
import { PATCH_GUIDE } from "./prompt";
function fakeContext() {
return {
plugin: vi.fn(async (_plugin: unknown, _config: unknown) => undefined),
systemPrompt: { section: vi.fn((_section: PromptSection) => () => undefined) },
};
}
it("registers one prompt section in the tool-guidance order band", async () => {
const ctx = fakeContext();
await apply(ctx as never, Config({ apiKey: "test-key" }));
expect(ctx.systemPrompt.section).toHaveBeenCalledTimes(1);
const section = ctx.systemPrompt.section.mock.calls[0]?.[0] as PromptSection;
expect(section.name).toBe("reactive-resume:resume");
expect(section.order).toBeGreaterThanOrEqual(100);
expect(section.order).toBeLessThanOrEqual(199);
expect(section.text).toBe(PATCH_GUIDE);
});
it("derives the section name from serverName so a second instance can coexist", async () => {
const first = fakeContext();
const second = fakeContext();
await apply(first as never, Config({ apiKey: "test-key" }));
await apply(second as never, Config({ apiKey: "test-key", serverName: "self-hosted" }));
const firstSection = first.systemPrompt.section.mock.calls[0]?.[0] as PromptSection;
const secondSection = second.systemPrompt.section.mock.calls[0]?.[0] as PromptSection;
expect(firstSection.name).not.toBe(secondSection.name);
});
it("names the tools it references with the configured namespace", async () => {
const ctx = fakeContext();
await apply(ctx as never, Config({ apiKey: "test-key", serverName: "rr" }));
const section = ctx.systemPrompt.section.mock.calls[0]?.[0] as PromptSection;
expect(section.text).toContain("mcp__rr__read_resume");
expect(section.text).not.toContain("mcp__resume__read_resume");
});
it("covers the documented failure modes", () => {
for (const phrase of ["RFC 6902", "unlock_resume", "lock_resume", "list_resumes"]) {
expect(PATCH_GUIDE).toContain(phrase);
}
});
it("does not point at the unreachable schema resource", () => {
expect(PATCH_GUIDE).not.toContain("resume://_meta/schema");
});
it("does not claim update_resume replaces resume content", () => {
expect(PATCH_GUIDE).toContain("only changes metadata");
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Build the Reactive Resume system-prompt section for one tool namespace.
* @param serverName - the MCP namespace bridged tools are published under.
* @returns prompt text naming tools exactly as the model will see them.
*/
export function buildPatchGuide(serverName: string): string {
const t = (raw: string) => `\`mcp__${serverName}__${raw}\``;
return [
"## Reactive Resume",
"",
"These tools operate on the user's real, live resumes and job applications. Changes are immediate and visible in their account.",
"",
"### Reading before writing",
"",
`- Call ${t("list_resumes")} to discover resume IDs. IDs are UUIDs, never titles or slugs.`,
`- Call ${t("read_resume")} before any edit. Never patch a resume you have not read this session.`,
`- If a call fails with "not found", re-run ${t("list_resumes")} rather than guessing an ID.`,
"",
"### Editing",
"",
`- ${t("apply_resume_patch")} takes RFC 6902 JSON Patch operations applied to the resume data document.`,
`- Construct paths from the resume you already read this session — do not guess. ${t("apply_resume_patch")}'s own tool description ships concrete path examples (for example \`/basics/name\`, \`/sections/experience/items/-\`, \`/sections/experience/items/0/company\`, \`/metadata/template\`); match those shapes.`,
"- Section entries are arrays of objects, each with its own UUID `id`. Address an existing entry by locating its index from the document you just read; never treat an `id` as an index.",
"- Prefer one patch with several operations over several single-operation patches. Operations apply in order and the whole patch fails atomically.",
`- ${t("update_resume")} only changes metadata — name, slug, tags, and public visibility. It cannot touch resume content; use ${t("apply_resume_patch")} for that. No tool replaces an existing resume's content wholesale: ${t("import_resume")} creates a brand-new resume from a full data document, it does not overwrite one you already have.`,
"",
"### Locking",
"",
`- A locked resume rejects every write. When a call fails because the resume is locked, call ${t("unlock_resume")}, make the change, then call ${t("lock_resume")} to leave the lock as you found it.`,
"",
"### Scope",
"",
"- Never delete a resume or an application unless the user asked for that specific deletion in this conversation.",
"- When the user describes a change in prose, restate the concrete edit you are about to make before making it.",
].join("\n");
}
/** The prompt section for the default `resume` namespace. */
export const PATCH_GUIDE: string = buildPatchGuide("resume");
@@ -0,0 +1,27 @@
import { expect, it } from "vitest";
import { MCP_TOOL_NAME } from "@reactive-resume/mcp/tool-names";
import { buildPatchGuide } from "./prompt";
/** Every raw tool name the prompt guide instructs the model to call. */
function toolsReferencedByGuide(): string[] {
const guide = buildPatchGuide("resume");
const matches = guide.matchAll(/mcp__resume__([a-z0-9_]+)/g);
return [...new Set([...matches].map((match) => match[1] as string))];
}
it("references at least one tool", () => {
// Guards the regex itself: a guide rewrite that drops the namespaced form
// would otherwise make the next test pass vacuously.
expect(toolsReferencedByGuide().length).toBeGreaterThan(0);
});
it("only references tools the MCP server actually publishes", () => {
// Reads the server's own tool-name table rather than a generated snapshot of
// a live server card, so renaming a tool in `packages/mcp` fails here on the
// same PR instead of drifting until a scheduled network check notices.
const published: readonly string[] = Object.values(MCP_TOOL_NAME);
for (const referenced of toolsReferencedByGuide()) {
expect(published).toContain(referenced);
}
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
// Scoped to `src` so tsdown's declaration emit does not follow
// `vitest.config.ts` into the root `vitest.shared.ts` and drop a stray
// `vitest.shared.d.ts` at the repository root.
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
outDir: "dist",
format: ["esm"],
dts: true,
clean: true,
outExtensions: () => ({ js: ".js" }),
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": ["//"],
"tags": ["runtime:server", "role:adapter"]
}
+8
View File
@@ -0,0 +1,8 @@
import { fileURLToPath } from "node:url";
// @boundaries-ignore root shared Vitest config
import { createVitestProjectConfig } from "../../vitest.shared";
export default createVitestProjectConfig({
name: "dsh-plugin-reactive-resume",
dirname: fileURLToPath(new URL(".", import.meta.url)),
});
+2 -1
View File
@@ -5,7 +5,8 @@
"private": true,
"exports": {
".": "./src/index.ts",
"./server-card": "./src/mcp-server-card.ts"
"./server-card": "./src/mcp-server-card.ts",
"./tool-names": "./src/mcp-tool-names.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit",
+470 -674
View File
File diff suppressed because it is too large Load Diff