Release / release (push) Successful in 6s
A plugin installed as a bare git+https URL is pinned to a commit, so with no tags there is nothing for `dsh plugin update` to move to. The workflow reads the commit subjects since the last tag, bumps package.json, prepends CHANGELOG.md, tags vX.Y.Z, and publishes the Gitea release with those subjects as the body. The decision logic lives in .gitea/scripts/release.sh so it runs and is tested without a runner (`npm run test:release`).
244 lines
11 KiB
Markdown
244 lines
11 KiB
Markdown
# dsh-facet
|
|
|
|
Scope an agent session to **one facet of a system** — the folder the
|
|
conversation was started in, plus whatever you explicitly grant it.
|
|
|
|
A DeepSeek Harness bundle: one host row (the policy) and one agent preset (the
|
|
enforcement).
|
|
|
|
---
|
|
|
|
## Why this exists
|
|
|
|
A multi-faceted system is built from modules that look alike. In the project this
|
|
was written against there are fourteen controllers — the same idioms, the same
|
|
naming, the same shape — and a conversation about one of them that can see all
|
|
fourteen spends its first several steps working out which one it is looking at.
|
|
That is a quality problem before it is a cost problem, and the cost is real too:
|
|
in a long session the dominant expense is re-reading accumulated history, and
|
|
most of that history is file content the agent did not need.
|
|
|
|
So this narrows the world to one module rather than trying to steer through all
|
|
of them. It is the same idea as the `focus` argument in
|
|
[`dsh-recall`](https://github.com/green-dalii/dsh-recall) — which confines a
|
|
*search* to one module — applied to the whole agent: what it may read, where it
|
|
may search, and what its prompt says it is.
|
|
|
|
## What it does
|
|
|
|
Three things, and they are deliberately in three different places:
|
|
|
|
| | where | why there |
|
|
|---|---|---|
|
|
| **the rule** ("work inside the facet; ask to go outside") | system prompt | invariant for the session, so it does not invalidate the prompt cache |
|
|
| **the binding** (which folder, what is granted) | runtime-context snapshot | it changes when you grant something, and a changed system prompt costs a full-context cache miss |
|
|
| **the guard** | `tools/pre-execute` | the part that does not depend on the model agreeing |
|
|
|
|
**The scope is not configured — it is where you already are.** A session's
|
|
working directory is recorded immutably when the session is created, so starting
|
|
a conversation in `src/controllers/room/occupancy` makes that controller the
|
|
facet, and starting one at the project root simply leaves the session unscoped
|
|
(which is the honest answer for a conversation that is genuinely about
|
|
everything).
|
|
|
|
**Going outside is a question, not a refusal.** In that same project 41.6% of the
|
|
code — shared types, services, infrastructure — sits outside every controller. A
|
|
wall that cannot be opened would not confine the agent, it would only make it
|
|
wrong, and an agent that cannot reach a shared type does not stop needing it.
|
|
So an out-of-scope file call is put to you, and saying yes records the grant for
|
|
the rest of the conversation.
|
|
|
|
### The `facet` tool
|
|
|
|
| action | effect |
|
|
|---|---|
|
|
| `status` | the root, the mode, the standing grants and this session's grants |
|
|
| `add` | asks you to approve an extra path, then records it for this session |
|
|
| `remove` | drops a session grant |
|
|
| `reset` | returns to the session's own folder |
|
|
|
|
`add` is the only one that needs approval. It is also asked in `deny` mode,
|
|
because otherwise there would be no way to grant anything at all.
|
|
|
|
## Install
|
|
|
|
```sh
|
|
dsh plugin --profile web add "/path/to/dsh-facet"
|
|
dsh --profile web --dump-config | grep preset-facet # expect the row
|
|
```
|
|
|
|
The bundle contributes two layers:
|
|
|
|
- **`cordis.patch.yml`** — the host row `facet-policy`, which owns the mode and
|
|
the standing grants and publishes them as `ctx.facetPolicy`.
|
|
- **`presets/facet.patch.yml`** — the agent preset `facet`, which is the shipped
|
|
`standard` composition plus the guard and a configured compaction row.
|
|
|
|
Then pick **Facet** as the preset for a session — in the Web UI that is the
|
|
preset picker in the new-session surface, or *Settings → General → Agent preset*
|
|
to make it the default for new sessions. The preset is fixed once a session's
|
|
first turn has run, so choose it when you start the conversation.
|
|
|
|
### Configure it in your profile, not in the package
|
|
|
|
The host row's config is where this deployment's facts belong — the profile's own
|
|
`cordis.patch.yml`:
|
|
|
|
```yaml
|
|
- id: facet-policy
|
|
name: dsh-facet
|
|
config:
|
|
mode: ask # ask | deny | off
|
|
extraRoots: # always in scope, in every session
|
|
- ../../shared # relative to each session's own directory
|
|
```
|
|
|
|
`extraRoots` is for code a module legitimately always depends on. Anything you
|
|
grant by hand is per-conversation and is never persisted — "this session may
|
|
also see `src/shared`" is a statement about one conversation, and writing it down
|
|
would quietly widen every future one, which is the failure this plugin exists to
|
|
prevent.
|
|
|
|
## The compaction row
|
|
|
|
`presets/facet.patch.yml` also configures `compaction-basic`, because a scoped
|
|
session and a budgeted context are two halves of one idea — scoping reduces
|
|
context at the source, compaction is the ceiling:
|
|
|
|
```yaml
|
|
thresholdRatio: 0.35 # a 350k ceiling, not the shipped 800k
|
|
retainTokens: 80000 # an absolute working set, not 16% of a 1M window
|
|
maxTokens: 16384 # the summarisation cap, stated rather than inherited
|
|
```
|
|
|
|
The shipped ratio is `0.80` of a 1,000,000-token window, i.e. 800k. Measured
|
|
across this deployment's 40 recorded sessions, the deepest ever reached 787,668
|
|
pressure tokens — **the trigger had never once fired**, so every token below it
|
|
was re-read on every subsequent step. `maxTokens` is worth stating explicitly:
|
|
its default is not 8192 but `headroomTokens`, 65536.
|
|
|
|
If you want that budget on *every* session rather than only scoped ones,
|
|
`presets/standard-compaction.patch.yml` is an opt-in overlay that applies the
|
|
same row to the shipped `standard` preset. It is not in the bundle's default
|
|
patch list, and the header of that file explains why: a preset declaration
|
|
replaces the whole plugin list rather than merging into it, so the file is a
|
|
frozen copy of `standard` that will not follow the harness when `standard`
|
|
changes.
|
|
|
|
## What it does not cover
|
|
|
|
Honest boundaries, because each one is a decision rather than an oversight:
|
|
|
|
- **`bash` is not guarded.** A shell command cannot be judged reliably from
|
|
argv, so the facet is enforced on the file tools and `bash` remains a
|
|
legitimate way out. The prompt tells the model this rather than pretending
|
|
otherwise.
|
|
- **Tools that name a path in a shape the table cannot read are not guessed at** —
|
|
including MCP tools. See `PATH_ARGUMENTS` in `lib/scope.js`; it is a list of
|
|
tools whose path argument is a plain top-level string.
|
|
- **This guards the tool pipeline, not the filesystem.** There is no read seam in
|
|
the harness — `dsh-fs-sandbox` states plainly that "reads pass through
|
|
untouched: every mode permits reading" — so anything that reads through
|
|
`ctx.fs` without going through tool dispatch (another plugin, the HTTP
|
|
workspace-files endpoint) is unaffected. Fencing *all* readers would mean
|
|
replacing `ctx.fs` with a stricter `FileSystem` subclass.
|
|
- **A grant is not persisted**, so it does not survive a process restart.
|
|
- **Sessions at the project root are not scoped at all**, which is deliberate.
|
|
|
|
## What has been verified, and what has not
|
|
|
|
Verified:
|
|
|
|
- 42 tests (`npm test`) over the scope algebra, the policy state, and the guard —
|
|
including that containment is decided on canonical targets (a symlink inside
|
|
the facet pointing outside it is refused), that a prefix is not a parent
|
|
(`/a/room` does not contain `/a/roommate`), that a malformed tool call never
|
|
makes the guard throw, and that every `facet` result satisfies the tool's own
|
|
declared output schema.
|
|
- The bundle's patch layers parse, merge and land correctly against the real
|
|
profile: `dsh --profile web --patch ./cordis.patch.yml --patch
|
|
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
|
|
the `facet-guard` row and the compaction config, with `preset-standard`
|
|
untouched. The optional `standard-compaction` overlay was checked the same way.
|
|
|
|
**Not yet verified: a live session.** The plugin has not been loaded by a running
|
|
harness, because that needs the bundle installed. The first thing to check after
|
|
installing is that `preset-facet` is not `broken` — a preset whose child row
|
|
fails to resolve still appears on the roster, with its diagnostic, and cannot
|
|
compose a session:
|
|
|
|
```sh
|
|
dsh --profile web --dump-config | grep -A 3 preset-facet
|
|
```
|
|
|
|
## Releases
|
|
|
|
`.gitea/workflows/release.yml` runs on every push to `master`: it works out the
|
|
range since the last tag, bumps `package.json`, prepends a `CHANGELOG.md`
|
|
section, commits and tags `vX.Y.Z`, then creates the Gitea release with the
|
|
commit subjects as the body.
|
|
|
|
The bug it fixes is not tidiness. Installed as a bare `git+https://…/repo.git`,
|
|
a plugin is pinned to a **commit** in the profile's lockfile, so with no tags
|
|
there is nothing for `dsh plugin update` to move to. Point the profile at
|
|
`#semver:^0.1.0` and the updates follow releases instead:
|
|
|
|
```jsonc
|
|
"dsh-facet": "git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-facet.git#semver:^0.1.0"
|
|
```
|
|
|
|
The bump is read from the commit subjects being released:
|
|
|
|
| in a commit subject | effect |
|
|
|---|---|
|
|
| `feat`, as its own word (`feat:`, `feat(x):`) | minor — `0.1.0` → `0.2.0` |
|
|
| `feat!:` / `fix(x)!:` / `BREAKING` | major — `0.1.0` → `1.0.0` |
|
|
| `fix:`, `docs:`, prose, anything else | patch — `0.1.0` → `0.1.1` |
|
|
| `[minor]` / `[major]` | force the bump when the subject cannot carry it |
|
|
| `[skip release]` | this push publishes nothing |
|
|
|
|
Only the **subject** line is read, and `feat` has to be a word — `Fix the feature
|
|
flag leak` is a patch, not a minor. With no tags yet, the version already in
|
|
`package.json` is what gets tagged, so the first release matches the code.
|
|
|
|
The logic lives in `.gitea/scripts/release.sh` so it runs without a runner:
|
|
`npm run test:release` exercises it — an empty range, a repository with no tags
|
|
yet, a `feat` that is only a word — `npm run release:plan` says whether the
|
|
current HEAD would release, and `npm run release:notes` prints the notes it
|
|
would publish.
|
|
|
|
## Development
|
|
|
|
```sh
|
|
node scripts/dev-links.mjs # link the harness's cordis/schemastery/dsh-tools
|
|
npm test
|
|
```
|
|
|
|
`dev-links.mjs` links rather than installs those three packages, and for
|
|
`@deepseek-ai/cordis` that is a correctness requirement rather than tidiness: a
|
|
second copy of Cordis means a second `Context` class, two service registries in
|
|
one process, and a plugin whose services are invisible to the harness that loaded
|
|
it.
|
|
|
|
## Layout
|
|
|
|
```
|
|
index.js host row: the policy, published as ctx.facetPolicy
|
|
lib/policy.js the stateful half — scope resolution, grants
|
|
lib/scope.js the pure half — argument extraction, text, reasons
|
|
lib/guard.js preset row: rule, binding, tool, pre-execute guard
|
|
presets/facet.patch.yml the `facet` agent preset
|
|
presets/standard-compaction.patch.yml optional: the same budget for `standard`
|
|
test/ 42 tests
|
|
```
|
|
|
|
## Also in this repository
|
|
|
|
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
|
|
including the harness mechanisms it rests on.
|
|
- [`compaction-tuning.md`](../compaction-tuning.md) — the investigation into why
|
|
compaction had never fired. Its measurements reproduce exactly; note that its
|
|
"The change" section prescribes `$DSH_HOME/.agent-presets/`, a directory
|
|
**nothing reads any more** on the installed harness. Presets are bundle patch
|
|
declarations now, which is what `presets/facet.patch.yml` is.
|