NotExpectedYet a1c8ecb1e8
Release / release (push) Successful in 6s
fix: say so when a session has no approval channel to ask through
`ask` mode promises that an out-of-facet call is put to the user, and that
promise needs a channel. A session can be composed without one: the
`danger-full-access` permission preset sets `approval: never`, and the
approval service's `decide()` then returns "rejected" before consulting any
answerer. The call is refused, `facet action=add` is refused with it — widening
is itself a question — and the scope can never be widened.

The model is told none of that. `serviceAsk` replaces a rejected ask with
`the user rejected tool \"…\"`, discarding the reason this guard wrote, so the
binding is the only channel left that can state what is true. It now reads the
session's effective approval policy and, when there is no channel, says the call
was refused rather than asked and names the mechanism that still works:
`extraRoots` is configuration applied by the profile, not a grant asked for per
conversation.

A missing approval service counts as no channel (the tool pipeline denies an ask
it cannot route); an unreadable policy counts as a channel, so a transient
failure never tells a user to go and edit their configuration.
2026-09-26 09:02:48 +01:00
2026-09-26 06:45:50 +01:00
2026-09-26 06:45:50 +01:00
2026-09-26 06:45:50 +01:00
2026-09-26 06:45:50 +01:00

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 — 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

From the repository, following releases:

dsh plugin --profile web add "git+https://git.notexpectedyet.com/Not-Expected-Yet/dsh-facet.git#semver:^0.1.0"
dsh --profile web --dump-config | grep preset-facet    # expect the row

Or from a checkout, while working on the plugin itself:

dsh plugin --profile web add "/path/to/dsh-facet"

The #semver:^0.1.0 fragment is the difference between an install that can be updated and one that cannot. Leave it off and pnpm resolves the default branch to a commit and records that commit in pnpm-lock.yaml — dsh plugin update then has no version to move to, because there is nothing to move between. With the fragment, pnpm resolves against the repository's tags, which the release workflow creates on every push to master. The fragment needs a tag to resolve against, so it only works once the first release has been published.

Either way the command is pnpm add run inside the profile, and it registers the bundle in dsh.profile.bundles for you. A git-hosted plugin that builds on install can be blocked by pnpm's build policy; add the key pnpm asks for under allowBuilds in the profile's pnpm-workspace.yaml. This bundle ships sources with no build step, so it does not trip that.

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:

- 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

Both fields are also on the plugin's card under Plugins in the Web UI, which is the same layer reached a different way: a settings write rewrites this entry in the profile patch rather than storing anything new. An edit there is live — the guard reads mode and extraRoots on every call, so a change decides the next file call without a restart.

The one exception is off. Choosing it while a session is running stops the guard immediately and empties the binding on the next request, because the binding is a callback. The rule is not: it was registered at load as a plain string, and nothing unregisters it, so the prompt would go on stating a rule that nothing enforces. An off deployment is therefore settled at load — set it in the patch. ask and deny swap freely in both directions.

sectionOrder and contextOrder are deliberately absent from the card. They place the rule and the binding among the other prompt sections, which is decided once at load, and no section is re-ordered without a remount.

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:

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:

  • 53 tests (npm test) over the scope algebra, the policy state, the guard, and the browser half — 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, that a live mode edit decides the next call without the guard being remounted, 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:

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:

"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

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
client/client.js             browser half: the settings card on the Plugins page
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/                        53 tests

client/client.js is a browser half rather than a module the host imports, and it is not optional decoration. The Plugins page does not build a card from a Config schema — it draws only what browser halves register into the slots it declares. volatile() on the host schema makes the namespace servable (settings.describe() drops an entry with none, and the card registers only while the namespace is served), but a schema alone puts nothing on the page.

The two keys in that file are deliberately different strings, and that is the easy mistake to make here: the settings document is filed under the profile entry id (facet-policy, the row's id in cordis.patch.yml) while the card is keyed by the package name (dsh-facet). Every sibling plugin happens to name them identically, so binding the form to the wrong one produces a card that renders, saves, and writes nowhere. test/client.test.js pins both.

Also in this repository

  • facet-preset.md — the design note this was built from, including the harness mechanisms it rests on.
  • 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.
S
Description
No description provided
Readme
423 KiB
0 Stars 2 Watchers 0 Forks
v0.4.0
Latest
2026-09-28 07:51:00 +01:00
Languages
JavaScript 92.3%
Shell 7.7%