8 Commits
Author SHA1 Message Date
gitea-actions 6e696f10a2 Release v0.1.3 [skip release] [skip actions] 2026-09-26 06:50:42 +00:00
NotExpectedYet 1be3a67b97 fix: put the settings card on the Plugins page with a browser half
Release / release (push) Successful in 6s
The `volatile()` markers made the Host serve the namespace, and that was as
far as it went: the Plugins page does not build a card from a `Config` schema.
It draws only what browser halves register into the slots it declares, which is
why dsh-slopo, dsh-recall and dsh-context all ship a `client/` — and why this
plugin, with two served fields, still showed nothing.

The card registers into `plugins.bundle.config` while
`configForms.whileServed` reports the namespace served. The two keys are
deliberately different strings and that is the trap in the file: the settings
document is filed under the profile entry id (`facet-policy`) while the card is
keyed by the package name (`dsh-facet`). Every sibling plugin happens to name
them identically. Binding the form to the wrong one yields a card that renders,
saves, and writes nowhere, so `test/client.test.js` pins both, holds the card's
field list to the schema's volatile fields, and refuses to register at all when
the namespace is not served.
2026-09-26 07:39:57 +01:00
gitea-actions e860ac2989 Release v0.1.2 [skip release] [skip actions] 2026-09-26 06:14:27 +00:00
NotExpectedYet 0e9f75cfab Merge branch 'master' of https://git.notexpectedyet.com/Not-Expected-Yet/dsh-facet
Release / release (push) Successful in 6s
2026-09-26 07:14:12 +01:00
NotExpectedYet 28f20a8aac fix: show the settings card, and make an edit actually take effect
`mode` and `extraRoots` carried no `volatile()` marker, so the framework's
form projection dropped the whole entry from `settings.describe` and the plugin
had no card under Plugins at all. The marker is what creates the card.

It is also what turns each field into a reference rather than a value: the
resolved config hands `apply` a `{get(), [write]}` per volatile field, which is
how a settings write lands in a running plugin without re-applying it. So the
policy and the guard now read both fields at the point of use instead of
snapshotting them at mount, and `sectionOrder`/`contextOrder` stay unmarked
because a prompt section cannot be re-ordered without a remount.

A section is also registered once as a plain string, so the rule could not
follow a live `mode` without freezing at load. The mode-specific sentence moved
into the binding, which is a callback.
2026-09-26 07:13:33 +01:00
gitea-actions e7401445d7 Release v0.1.1 [skip release] [skip actions] 2026-09-26 05:54:48 +00:00
NotExpectedYet c41c555400 Merge branch 'master' of https://git.notexpectedyet.com/Not-Expected-Yet/dsh-facet
Release / release (push) Successful in 6s
2026-09-26 06:54:31 +01:00
NotExpectedYet 651093a00c docs: document installing the bundle from the repository
The Install section only showed a local path, which is the one form that
cannot be updated: pnpm records a commit for a bare git URL, so
`dsh plugin update` has no version to move between. Show the
`#semver:^0.1.0` form, and say why the fragment is the difference.
2026-09-26 06:53:13 +01:00
12 changed files with 889 additions and 66 deletions

No files matched your search

+21
View File
@@ -2,6 +2,27 @@
Every release, newest first, generated from the commits in each release. Every release, newest first, generated from the commits in each release.
## v0.1.3 — 2026-09-26
### Fixed
- put the settings card on the Plugins page with a browser half
## v0.1.2 — 2026-09-26
### Fixed
- show the settings card, and make an edit actually take effect
## v0.1.1 — 2026-09-26
### Documented
- document installing the bundle from the repository
## v0.1.0 — 2026-09-26 ## v0.1.0 — 2026-09-26
### Added ### Added
+63 -8
View File
@@ -62,11 +62,33 @@ because otherwise there would be no way to grant anything at all.
## Install ## Install
From the repository, following releases:
```sh ```sh
dsh plugin --profile web add "/path/to/dsh-facet" 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 dsh --profile web --dump-config | grep preset-facet # expect the row
``` ```
Or from a checkout, while working on the plugin itself:
```sh
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: The bundle contributes two layers:
- **`cordis.patch.yml`** — the host row `facet-policy`, which owns the mode and - **`cordis.patch.yml`** — the host row `facet-policy`, which owns the mode and
@@ -93,6 +115,23 @@ The host row's config is where this deployment's facts belong — the profile's
- ../../shared # relative to each session's own directory - ../../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 `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 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 also see `src/shared`" is a statement about one conversation, and writing it down
@@ -149,12 +188,13 @@ Honest boundaries, because each one is a decision rather than an oversight:
Verified: Verified:
- 42 tests (`npm test`) over the scope algebra, the policy state, and the guard — - 53 tests (`npm test`) over the scope algebra, the policy state, the guard, and
including that containment is decided on canonical targets (a symlink inside the browser half — including that containment is decided on canonical targets
the facet pointing outside it is refused), that a prefix is not a parent (a symlink inside the facet pointing outside it is refused), that a prefix is
(`/a/room` does not contain `/a/roommate`), that a malformed tool call never not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool
makes the guard throw, and that every `facet` result satisfies the tool's own call never makes the guard throw, that a live `mode` edit decides the next call
declared output schema. 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 - The bundle's patch layers parse, merge and land correctly against the real
profile: `dsh --profile web --patch ./cordis.patch.yml --patch profile: `dsh --profile web --patch ./cordis.patch.yml --patch
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`, ./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
@@ -224,14 +264,29 @@ it.
``` ```
index.js host row: the policy, published as ctx.facetPolicy 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/policy.js the stateful half — scope resolution, grants
lib/scope.js the pure half — argument extraction, text, reasons lib/scope.js the pure half — argument extraction, text, reasons
lib/guard.js preset row: rule, binding, tool, pre-execute guard lib/guard.js preset row: rule, binding, tool, pre-execute guard
presets/facet.patch.yml the `facet` agent preset presets/facet.patch.yml the `facet` agent preset
presets/standard-compaction.patch.yml optional: the same budget for `standard` presets/standard-compaction.patch.yml optional: the same budget for `standard`
test/ 42 tests 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 ## Also in this repository
- [`facet-preset.md`](../facet-preset.md) — the design note this was built from, - [`facet-preset.md`](../facet-preset.md) — the design note this was built from,
+291
View File
@@ -0,0 +1,291 @@
/**
* dsh-facet — the browser half.
*
* **Why this file exists when the host half already declares the schema.** The
* Plugins page does not generate a card from a `Config` schema. It draws whatever
* browser halves register into the slots it declares, and `plugins.bundle.config`
* is the seat for one package's own configuration. Marking a field `volatile()`
* makes the Host *serve* that namespace — `settings.describe()` drops an entry
* with no volatile field, and `configForms.whileServed()` below refuses to
* register a page the Host does not serve — but serving it is not drawing it. No
* browser half means no card, however the schema is marked.
*
* **The two keys below are not the same string, and that is the trap.** Every
* other plugin in this installation happens to name them identically, so the
* distinction never shows up until it does:
*
* * the settings DOCUMENT is filed under the plugin's profile entry id —
* `facet-policy`, the `id` of this bundle's row in `cordis.patch.yml`.
* Exporting `Config` from `index.js` is the registration; this is the key
* `configForms.get()` addresses.
* * the CARD is filed under the bundle's PACKAGE NAME — `dsh-facet`. That is
* what the Plugins page dispatches `plugins.bundle.config` by, and it goes on
* the bundle's own page between its description and its rows.
*
* Bind the form to the wrong one and the card renders, saves, and writes nowhere.
*
* **Loadable without a bundler.** `react` and the primitives package are both in
* the boot seed table; the settings document and the slot ledgers arrive as
* Cordis services or slot props. Nothing here reaches into another plugin's
* bundle.
*/
window.__ModuleLoader__.load({
id: 'dsh-facet',
factory: (require) => {
var module = { exports: {} }
var exports = module.exports
const react = require('react')
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
const { SegmentedControl, SettingsForm, SettingsFormModel, SettingsValueField } = primitives
const h = react.createElement
/** The settings document's key: this bundle's row id, not its package name. */
const SETTINGS_ENTRY = 'facet-policy'
/** The card's key: the bundle's package name, not its row id. */
const BUNDLE_CONFIG_KEY = 'dsh-facet'
/** The modes the host schema accepts, in the order the control offers them. */
const MODES = ['ask', 'deny', 'off']
const TITLE = 'Facet'
const DESCRIPTION = 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.'
const OVERRIDDEN_LABEL = 'Overridden'
const RESET_LABEL = 'Reset to default'
const MODE_LABELS = { ask: 'Ask', deny: 'Deny', off: 'Off' }
const MODE_OPTIONS = MODES.map((value) => ({ value, label: MODE_LABELS[value] }))
const labelStyle = {
display: 'block',
marginBottom: '6px',
fontSize: '13px',
fontWeight: 500,
color: 'var(--dsw-alias-label-primary)',
}
const hintStyle = {
margin: '6px 0 0',
fontSize: '12px',
lineHeight: '18px',
color: 'var(--dsw-alias-label-tertiary)',
}
const badgeStyle = {
marginLeft: '8px',
fontSize: '11px',
fontWeight: 400,
color: 'var(--dsw-alias-label-tertiary)',
}
/**
* A finite-choice field, staged as the chosen value.
*
* The primitives ship `settingsNumberField` and `settingsTextField` and
* deliberately stop there: only the owning plugin knows what its own
* vocabulary is. An unrecognised draft returns `undefined`, which marks the
* field invalid and blocks the save rather than quietly coercing a typo into
* a mode the schema would reject at the Host anyway.
*
* @param {string} field - field name inside the namespace section.
* @param {readonly string[]} choices - the values the host schema accepts.
*/
function settingsChoiceField(field, choices) {
return {
field,
format: (value) => (typeof value === 'string' && choices.includes(value) ? value : ''),
parse: (text) => {
const normalised = String(text).trim().toLowerCase()
if (normalised === '') return { kind: 'clear' }
if (choices.includes(normalised)) return { kind: 'set', value: normalised }
return undefined
},
}
}
/**
* A whole-list field: comma or newline separated in the control, an array of
* non-empty strings in the document.
*
* Empty stages a clear rather than an empty array. The schema's own default
* is `[]`, so writing one would shadow a deployment's composed default with
* nothing and leave the field marked overridden when the user meant to
* remove their override.
*
* @param {string} field - field name inside the namespace section.
*/
function settingsListField(field) {
return {
field,
format: (value) => (Array.isArray(value) ? value.join(', ') : ''),
parse: (text) => {
const parts = String(text)
.split(/[,\n]/)
.map((part) => part.trim())
.filter((part) => part !== '')
return parts.length === 0 ? { kind: 'clear' } : { kind: 'set', value: parts }
},
}
}
/** The facet page's staged form over the composed `facet-policy` entry. */
var FacetCardController = class {
/**
* @param {object} scope - the shared configuration form for `facet-policy`.
*/
constructor(scope) {
this.form = new SettingsFormModel(scope, [
settingsChoiceField('mode', MODES),
settingsListField('extraRoots'),
])
this.store = this.form.bind(() => this.projection())
}
/** @returns {object} the shell's shared state plus this card's two controls. */
projection() {
return {
...this.form.shell(),
mode: this.form.field('mode'),
extraRoots: this.form.field('extraRoots'),
}
}
/**
* Build the face the slot registration injects.
*
* The `hooks` key becomes the component's `useFacetCard` prop — the slot
* runtime turns each entry into a bound hook — and the actions supply
* `edit`, `resetField`, `save` and `discard` beside it.
*
* @returns {object} the snapshot hook and the form actions.
*/
inject() {
return { hooks: { facetCard: this.store }, ...this.form.actions() }
}
/** Release the form subscription. */
dispose() {
this.form.dispose()
}
}
/**
* Render the facet card's one-liner or its settings form, as the page asks.
*
* @param {object} props - the view asked for, the bound snapshot, and the form actions.
*/
function FacetCard(props) {
if (props.view === 'summary') return DESCRIPTION
const state = props.useFacetCard((snapshot) => snapshot)
const disabled = !state.writable || state.saving
// The mode is editable as a segmented control rather than through
// `SettingsValueField`, because a three-value vocabulary in a free-text box
// invites a typo the field can only answer with "invalid". It is still the
// same staged draft underneath — the control calls the form's own `edit`,
// so nothing is written until Save.
const mode = h(
'div',
{ key: 'mode' },
h(
'label',
{ style: labelStyle, htmlFor: 'facet-mode' },
'Enforcement mode',
state.mode.overridden ? h('span', { style: badgeStyle }, OVERRIDDEN_LABEL) : null,
),
h(SegmentedControl, {
id: 'facet-mode',
value: state.mode.text === '' ? 'ask' : state.mode.text,
options: MODE_OPTIONS,
onChange: (next) => props.edit('mode', next),
label: 'Enforcement mode',
disabled,
}),
h(
'p',
{ style: hintStyle },
'Ask puts each call outside the facet to you as a question. Deny refuses it. Off disables enforcement while leaving the preset in place — useful for telling a scoping problem from an unrelated one.',
),
)
const extraRoots = h(SettingsValueField, {
key: 'extraRoots',
id: 'facet-extra-roots',
label: 'Standing grants',
hint: 'Paths always in scope, in every session — shared types, a common test harness. Separate them with commas. Relative paths resolve against each session\u2019s own directory, so the same list is meaningful in every workspace.',
text: state.extraRoots.text,
overridden: state.extraRoots.overridden,
invalid: state.extraRoots.invalid,
overriddenLabel: OVERRIDDEN_LABEL,
resetLabel: RESET_LABEL,
invalidLabel: 'Enter one or more paths, separated by commas.',
disabled,
onEdit: (text) => props.edit('extraRoots', text),
onReset: () => props.resetField('extraRoots'),
})
return h(
SettingsForm,
{
labels: {
unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
readOnly: 'This deployment stores settings read-only.',
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
save: 'Save',
saving: 'Saving\u2026',
},
state,
onSave: props.save,
onDiscard: props.discard,
// Column rather than a bare array, so the two controls keep the form's
// own spacing instead of sitting flush against each other.
children: h('div', { style: { display: 'flex', flexDirection: 'column', gap: '18px' } }, mode, extraRoots),
},
)
}
/**
* Mount the facet settings card while the Host serves its namespace.
*
* `whileServed` is the gate that makes the `volatile()` markers load-bearing:
* the card is registered only while `facet-policy` appears in the Host's
* served namespaces. An unserved namespace would be a control that silently
* writes nowhere, so it registers nothing at all.
*
* @param {object} ctx - the browser plugin context.
*/
function apply(ctx) {
// `inject` rather than a required dependency, so a deployment that composed
// no settings provider loses the card and keeps everything else this bundle
// contributes.
ctx.inject(['configForms'], (scoped) => {
const card = new FacetCardController(scoped.configForms.get(SETTINGS_ENTRY))
scoped.effect(() => () => card.dispose(), 'dsh-facet: settings form')
scoped.effect(
() =>
scoped.configForms.whileServed([SETTINGS_ENTRY], () =>
scoped.slots.inject('plugins.bundle.config', () =>
scoped.slots.register(
{
name: 'plugins.bundle.config',
key: BUNDLE_CONFIG_KEY,
inject: () => card.inject(),
},
FacetCard,
),
),
),
'dsh-facet: settings page',
)
})
}
module.exports = { name: 'dsh-facet', inject: ['slots'], apply, SETTINGS_ENTRY, BUNDLE_CONFIG_KEY, FacetCard, FacetCardController }
return module.exports
},
})
+30 -7
View File
@@ -16,10 +16,12 @@
* published once here and consumed there, which is also why the preset can be * published once here and consumed there, which is also why the preset can be
* swapped without touching any of this. * swapped without touching any of this.
* *
* There is deliberately no `settings` namespace. A facet grant is a statement * There is deliberately no `settings` *namespace* of our own: a facet grant is a
* about one conversation, so the only thing worth persisting is the deployment's * statement about one conversation, so the only thing worth persisting is the
* standing grants, and those belong in the profile patch — the layer where this * deployment's standing grants, and those belong in the profile patch — the
* machine's facts already live. * layer where this machine's facts already live. What the framework projects
* onto this row is the {@link Config} schema below, which is the same patch
* layer reached through the Web UI rather than a second place to store things.
*/ */
import Schema from '@deepseek-ai/schemastery' import Schema from '@deepseek-ai/schemastery'
@@ -28,21 +30,42 @@ import { createFacetPolicy } from './lib/policy.js'
export const name = 'dsh-facet' export const name = 'dsh-facet'
/** Every configuration field this plugin exposes. */ /**
* Every configuration field this plugin exposes.
*
* `mode` and `extraRoots` are marked `volatile()`, and that marker is doing two
* jobs rather than one. It is what puts them on the settings page at all — the
* framework's form projection keeps only fields under a volatile ancestor, and
* drops an entry with no such field from `settings.describe` entirely, so
* without it this row has no card. It is also what makes an edit *live*: a
* volatile field is handed to {@link apply} as a reference rather than a value,
* and the framework commits an edit into that reference without re-applying the
* plugin.
*
* The second job is why every reader of these two fields goes through
* `configValue()` in `./lib/policy.js` and reads at the point of use. A plugin
* that takes the reference for a string gets a settings card that appears to
* work and changes nothing.
*
* `sectionOrder` and `contextOrder` are deliberately NOT volatile. They place
* the rule and the binding among the other prompt sections, which is settled
* once at load; making them volatile would put two knobs nobody should touch on
* the card, and a section cannot be re-ordered without a remount anyway.
*/
export const Config = Schema.object({ export const Config = Schema.object({
/** /**
* How a call outside the facet is handled. `ask` questions the user, `deny` * How a call outside the facet is handled. `ask` questions the user, `deny`
* refuses outright, `off` disables enforcement while leaving the preset in * refuses outright, `off` disables enforcement while leaving the preset in
* place — useful for telling a scoping problem from an unrelated one. * place — useful for telling a scoping problem from an unrelated one.
*/ */
mode: Schema.union(['ask', 'deny', 'off']).default('ask'), mode: Schema.union(['ask', 'deny', 'off']).default('ask').volatile(),
/** /**
* Paths always in scope, for code a module legitimately depends on — shared * Paths always in scope, for code a module legitimately depends on — shared
* types, a common test harness. Relative paths resolve against each session's * types, a common test harness. Relative paths resolve against each session's
* own directory, so the same list is meaningful in every workspace. * own directory, so the same list is meaningful in every workspace.
*/ */
extraRoots: Schema.array(String).default([]), extraRoots: Schema.array(String).default([]).volatile(),
/** Placement of the invariant rule in the system prompt. */ /** Placement of the invariant rule in the system prompt. */
sectionOrder: Schema.number().default(100), sectionOrder: Schema.number().default(100),
+25 -10
View File
@@ -64,10 +64,19 @@ export const Config = Schema.object({
*/ */
export function apply(ctx, config) { export function apply(ctx, config) {
const policy = ctx.facetPolicy const policy = ctx.facetPolicy
/** @type {'ask'|'deny'|'off'} */
const mode = policy.mode
if (mode === 'off') return // Read at the point of use, never captured. `mode` is a volatile field, so a
// settings edit updates the policy's reference in place and deliberately does
// not re-apply this plugin; a `const mode` here would answer with the value
// the session was loaded with, for the life of the session.
const modeNow = () => policy.mode
// `off` is the one part that stays a mount-time decision. Deciding it here
// means an `off` deployment contributes no rule, no binding and no tool —
// which is the point of the mode. The cost is that turning it *on* later needs
// a reload, because a volatile-only edit never re-runs this function. `ask`
// and `deny` swap freely in both directions.
if (modeNow() === 'off') return
const sectionOrder = policy.sectionOrder ?? 100 const sectionOrder = policy.sectionOrder ?? 100
const contextOrder = policy.contextOrder ?? 105 const contextOrder = policy.contextOrder ?? 105
@@ -129,6 +138,9 @@ export function apply(ctx, config) {
const classify = async (exec) => { const classify = async (exec) => {
if (exec.signal?.aborted) return undefined if (exec.signal?.aborted) return undefined
const mode = modeNow()
if (mode === 'off') return undefined
// Widening is the one act that always needs the user, whatever the mode: // Widening is the one act that always needs the user, whatever the mode:
// it is explicit, deliberate, and the only thing standing between a scoped // it is explicit, deliberate, and the only thing standing between a scoped
// agent and an unscoped one. // agent and an unscoped one.
@@ -155,11 +167,13 @@ export function apply(ctx, config) {
} }
// The rule. `interpolate: false` because every value in it is already // The rule. `interpolate: false` because every value in it is already
// literal; there is nothing here for the harness to substitute. // literal; there is nothing here for the harness to substitute. It carries no
// `mode`, because a section is registered once and cannot follow a live edit —
// see `facetRuleText`.
ctx.systemPrompt.section({ ctx.systemPrompt.section({
name: 'facet-scope', name: 'facet-scope',
order: Number(sectionOrder), order: Number(sectionOrder),
text: facetRuleText({ mode }), text: facetRuleText(),
interpolate: false, interpolate: false,
}) })
@@ -173,11 +187,11 @@ export function apply(ctx, config) {
text: (context) => { text: (context) => {
const scope = policy.scopeFor(context?.agent) const scope = policy.scopeFor(context?.agent)
if (scope === undefined) return '' if (scope === undefined) return ''
return facetContextText({ cwd: scope.cwd, root: scope.root, extras: scope.extras, mode }) return facetContextText({ cwd: scope.cwd, root: scope.root, extras: scope.extras, mode: modeNow() })
}, },
}) })
if (config.tool) ctx.tools.register(facetTool(ctx, policy, mode)) if (config.tool) ctx.tools.register(facetTool(ctx, policy))
// `next()` first, so an earlier policy plugin keeps its say: this listener // `next()` first, so an earlier policy plugin keeps its say: this listener
// only ever adds a question or a refusal to a call that was otherwise allowed. // only ever adds a question or a refusal to a call that was otherwise allowed.
@@ -206,9 +220,8 @@ function requestedPaths(args) {
* *
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context. * @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context.
* @param {object} policy - the `facetPolicy` service. * @param {object} policy - the `facetPolicy` service.
* @param {'ask'|'deny'|'off'} mode - the deployment's enforcement mode.
*/ */
function facetTool(ctx, policy, mode) { function facetTool(ctx, policy) {
return defineTool({ return defineTool({
name: FACET_TOOL, name: FACET_TOOL,
description: description:
@@ -276,7 +289,9 @@ function facetTool(ctx, policy, mode) {
ok: true, ok: true,
root: scope.root, root: scope.root,
facet: scope.root, facet: scope.root,
mode, // Read here rather than captured at mount, so `status` reports the mode
// in force now instead of the one this session started under.
mode: String(policy.mode),
standing: policy.standing, standing: policy.standing,
granted, granted,
scope: scope.extras, scope: scope.extras,
+64 -6
View File
@@ -18,6 +18,38 @@
import { isAbsolute, resolve } from 'node:path' import { isAbsolute, resolve } from 'node:path'
/**
* The symbol cosmokit uses to mark a volatile config reference.
*
* Looked up by symbol rather than by importing cosmokit, because the ESM and CJS
* copies of that library are distinct module instances and `Symbol.for` is the
* only identity they share.
*/
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
/**
* Read one configuration field.
*
* A `volatile()` field is a *reference* in the running config, not a plain
* value: the framework resolves the schema through `Schema.resolve`, which wraps
* each volatile field in `createVolatile(...)` — `{get(), [write]}`. A
* non-volatile field, or a config object a test handed straight to
* {@link createFacetPolicy}, is the value itself. Both are accepted, so one code
* path serves a real profile and a direct call.
*
* Every read has to happen at the point of use rather than once at construction.
* A settings write commits into these references in place and deliberately does
* *not* re-apply the plugin, so anything captured at construction would never
* see the change — which is how a plugin ends up with a settings card that does
* nothing.
*
* @param {unknown} value - a field of the resolved config.
* @returns {unknown} the current value.
*/
export function configValue(value) {
return typeof value === 'object' && value !== null && VOLATILE_WRITE in value ? value.get() : value
}
/** /**
* @typedef {object} FacetScope * @typedef {object} FacetScope
* @property {string} cwd - the session working directory, absolute. * @property {string} cwd - the session working directory, absolute.
@@ -28,6 +60,13 @@ import { isAbsolute, resolve } from 'node:path'
/** /**
* Build the policy for one deployment. * Build the policy for one deployment.
* *
* `config` is the *resolved* composition config, so its volatile fields are
* references rather than values; every read below goes through
* {@link configValue} and happens per use. Holding the config and reading it lazily
* is what makes the settings card honest: `mode` and `extraRoots` are edited live
* in the Web UI, and a policy that snapshotted them here would keep answering
* with the values it was loaded with.
*
* @param {object} config * @param {object} config
* @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled. * @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled.
* @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd. * @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd.
@@ -36,7 +75,20 @@ import { isAbsolute, resolve } from 'node:path'
export function createFacetPolicy(config) { export function createFacetPolicy(config) {
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */ /** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
const grants = new Map() const grants = new Map()
const standing = Array.isArray(config.extraRoots) ? config.extraRoots.filter(isPath) : []
/**
* The deployment's standing grants, read fresh on every call.
*
* Not filtered once at construction: `extraRoots` is volatile, so an edit in
* the settings UI replaces the reference's contents without remounting this
* plugin, and the next scope resolution is expected to see it.
*
* @returns {string[]} configured paths, blanks dropped.
*/
function standingRoots() {
const configured = configValue(config.extraRoots)
return Array.isArray(configured) ? configured.filter(isPath) : []
}
/** /**
* The scope of one agent, or `undefined` when it has none. * The scope of one agent, or `undefined` when it has none.
@@ -66,7 +118,7 @@ export function createFacetPolicy(config) {
function extrasFor(agent, cwd) { function extrasFor(agent, cwd) {
const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined
const own = sessionId === undefined ? [] : grants.get(sessionId) ?? [] const own = sessionId === undefined ? [] : grants.get(sessionId) ?? []
const all = [...standing, ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path))) const all = [...standingRoots(), ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
return [...new Set(all)] return [...new Set(all)]
} }
@@ -124,11 +176,17 @@ export function createFacetPolicy(config) {
} }
return { return {
mode: config.mode, /** The enforcement mode. A getter, so a settings edit is answered on the next call. */
get mode() {
return configValue(config.mode)
},
/** Prompt placements, carried here so the preset row needs no configuration of its own. */ /** Prompt placements, carried here so the preset row needs no configuration of its own. */
sectionOrder: config.sectionOrder ?? 100, sectionOrder: configValue(config.sectionOrder) ?? 100,
contextOrder: config.contextOrder ?? 105, contextOrder: configValue(config.contextOrder) ?? 105,
standing: [...standing], /** The standing grants. A getter for the same reason as {@link mode}. */
get standing() {
return standingRoots()
},
scopeFor, scopeFor,
grant, grant,
revoke, revoke,
+28 -18
View File
@@ -118,25 +118,24 @@ export function isWideningCall(toolName, args) {
* treatment the sandbox policy and approval policy already get, for the same * treatment the sandbox policy and approval policy already get, for the same
* reason. * reason.
* *
* @param {object} input * It takes no configuration, and that is deliberate rather than an oversight.
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled. * A system-prompt section is a plain string registered once; there is no
* @returns {string} prompt section text, or `''` to contribute nothing. * callback to re-render it, and a settings write to a `volatile()` field
* updates the config reference *without* re-applying the plugin. So anything
* this function interpolated would freeze at whatever the value was at load —
* the prompt would go on describing the old `mode` while the guard enforced the
* new one. Everything that depends on `mode` therefore lives in the binding,
* which is a callback and therefore live.
*
* @returns {string} prompt section text.
*/ */
export function facetRuleText(input) { export function facetRuleText() {
const { mode } = input
if (mode === 'off') return ''
const reach =
mode === 'deny'
? 'A call outside it is refused. `facet action=add` asks the user to grant a path, and is the only way to widen the scope.'
: 'A call outside it is not refused: it is put to the user as a question, so say in one line what you need and why. If they agree, record it with `facet action=add` so the rest of the session does not have to ask again.'
return [ return [
'# Facet scope', '# Facet scope',
'', '',
'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which.', 'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which, and says how a call outside it is treated.',
'', '',
`Work inside the facet. ${reach}`, 'Work inside the facet.',
'', '',
'Never widen the scope on your own initiative: a grant is the user\u2019s decision, and an unrequested one is a bug, not initiative. Do not add paths you merely expect to need — ask when you actually need them.', 'Never widen the scope on your own initiative: a grant is the user\u2019s decision, and an unrequested one is a bug, not initiative. Do not add paths you merely expect to need — ask when you actually need them.',
'', '',
@@ -164,9 +163,12 @@ export function displayPath(cwd, path) {
/** /**
* The mutable half of the facet, for the runtime-context snapshot. * The mutable half of the facet, for the runtime-context snapshot.
* *
* Kept to three short lines because it is re-sent as part of the conversation * Kept short because it is re-sent as part of the conversation whenever it
* whenever it changes, and a scope statement that costs more than the scope is * changes, and a scope statement that costs more than the scope is worth is a
* worth is a scope that does not pay for itself. * scope that does not pay for itself. It carries one sentence more than the bare
* binding used to, because `mode` now belongs here: this is the only half that
* is re-rendered per request, so it is the only half that can follow a settings
* change. See {@link facetRuleText}.
* *
* @param {object} input * @param {object} input
* @param {string} input.cwd - the session working directory. * @param {string} input.cwd - the session working directory.
@@ -179,13 +181,21 @@ export function facetContextText(input) {
const { cwd, root, extras = [], mode } = input const { cwd, root, extras = [], mode } = input
if (mode === 'off' || typeof root !== 'string' || root === '') return '' if (mode === 'off' || typeof root !== 'string' || root === '') return ''
// Stated in terms of what the model should *do*, because the same sentence is
// what the user reads in an approval prompt. A denial that does not say how to
// proceed is a denial the agent works around with `bash`.
const reach =
mode === 'deny'
? 'refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope'
: 'put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again'
// The root is stated absolutely even though it is the session's own directory // The root is stated absolutely even though it is the session's own directory
// and a relative form would be shorter. `facet: .` is true and useless: the // and a relative form would be shorter. `facet: .` is true and useless: the
// model needs a path it can reason about, and the user reading an approval // model needs a path it can reason about, and the user reading an approval
// prompt needs to see which folder they are being asked about. // prompt needs to see which folder they are being asked about.
const lines = [`facet: ${root}`] const lines = [`facet: ${root}`]
if (extras.length > 0) lines.push(`granted outside it: ${extras.map((path) => displayPath(cwd, path)).join(', ')}`) if (extras.length > 0) lines.push(`granted outside it: ${extras.map((path) => displayPath(cwd, path)).join(', ')}`)
lines.push(`outside the facet: ${mode === 'deny' ? 'refused' : 'ask the user'}`) lines.push(`outside the facet: ${reach}`)
return lines.join('\n') return lines.join('\n')
} }
+12 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-facet", "name": "dsh-facet",
"version": "0.1.0", "version": "0.1.3",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.", "description": "Scope an agent session to one facet of a multi-faceted system: the session's own folder, plus whatever files the user grants it.",
@@ -14,6 +14,9 @@
"types": "./lib/types/guard.d.ts", "types": "./lib/types/guard.d.ts",
"default": "./lib/guard.js" "default": "./lib/guard.js"
}, },
"./client": {
"default": "./client/client.js"
},
"./cordis.patch.yml": "./cordis.patch.yml", "./cordis.patch.yml": "./cordis.patch.yml",
"./presets/facet.patch.yml": "./presets/facet.patch.yml", "./presets/facet.patch.yml": "./presets/facet.patch.yml",
"./package.json": "./package.json" "./package.json": "./package.json"
@@ -21,6 +24,7 @@
"files": [ "files": [
"index.js", "index.js",
"lib", "lib",
"client",
"presets", "presets",
"cordis.patch.yml", "cordis.patch.yml",
"README.md" "README.md"
@@ -40,6 +44,13 @@
"./cordis.patch.yml", "./cordis.patch.yml",
"./presets/facet.patch.yml" "./presets/facet.patch.yml"
] ]
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-plugin-manager"
]
} }
}, },
"keywords": [ "keywords": [
+238
View File
@@ -0,0 +1,238 @@
/**
* The browser half: the two keys it binds, and the fields it stages.
*
* This file exists because of a bug that no host-side test could catch. Marking
* the schema's fields `volatile()` makes the Host *serve* the namespace, but the
* Plugins page draws only what a browser half registers — so the plugin showed no
* settings card at all, and every host-side test passed.
*
* The wiring is therefore the thing worth testing, and the two keys are the trap:
* the settings DOCUMENT is keyed by the profile entry id (`facet-policy`) while
* the CARD is keyed by the package name (`dsh-facet`). Every sibling plugin
* happens to name those identically, which is exactly why this drifted. A card
* bound to the wrong id renders, saves, and writes nowhere.
*
* `client/client.js` is a `window.__ModuleLoader__` module rather than an ES
* module — that is how the harness loads a browser half without a bundler — so
* the test stands up the two globals it reads and drives it from there.
*/
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'node:test'
import { Config } from '../index.js'
/** Load the browser half with `window` and `require` faked, as the seed would. */
async function loadClient() {
const source = await readFile(new URL('../client/client.js', import.meta.url), 'utf8')
/** Every spec a card staged, in construction order. */
const staged = []
const primitives = {
SettingsForm: 'SettingsForm',
SettingsValueField: 'SettingsValueField',
SegmentedControl: 'SegmentedControl',
/** Stands in for the real model: records the specs and answers with the value. */
SettingsFormModel: class {
constructor(scope, specs) {
this.scope = scope
this.specs = specs
staged.push(...specs)
}
bind(project) {
this.project = project
return { project }
}
shell() {
return { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false }
}
field(name) {
const spec = this.specs.find((candidate) => candidate.field === name)
return { text: spec?.format(this.scope.value?.[name]) ?? '', overridden: false, invalid: false }
}
actions() {
return { edit: () => {}, resetField: () => {}, save: () => {}, discard: () => {} }
}
dispose() {}
},
}
const require = (id) => {
if (id === 'react') return { createElement: (type, props, ...children) => ({ type, props, children }) }
if (id === '@deepseek-ai/dsh-client-ui-primitives') return primitives
throw new Error(`client.js required an unexpected module: ${id}`)
}
let registered
const loader = { load: (value) => (registered = value) }
// eslint-disable-next-line no-new-func -- the file is a ModuleLoader script, not an ES module.
new Function('window', 'require', source)({ __ModuleLoader__: loader }, require)
assert.notEqual(registered, undefined, 'client.js never called window.__ModuleLoader__.load')
return { module: registered.factory(require), staged, primitives }
}
/** Drive `apply` against a fake slot/configForms surface and report what it did. */
function drive(mod, { served = true, value = {} } = {}) {
const seen = { formNs: undefined, slot: undefined, entry: undefined, card: undefined, watched: undefined }
const disposers = []
const ctx = {
inject: (names, scope) => {
const scoped = {
configForms: {
get: (ns) => {
seen.formNs = ns
return { value }
},
whileServed: (namespaces, register) => {
seen.watched = namespaces
// The real `whileServed` registers only while the Host serves the
// namespace; `served` is what the Host's `settings.describe` answers.
if (!served) return () => {}
return register(new Set(namespaces))
},
},
slots: {
inject: (name, register) => {
seen.slot = name
register()
},
register: (options, component) => {
seen.entry = options
seen.card = component
return () => {}
},
},
effect: (fn) => {
const dispose = fn()
if (typeof dispose === 'function') disposers.push(dispose)
return dispose
},
}
scope(scoped)
},
}
mod.apply(ctx)
return { seen, disposers }
}
test('the module is the one the Plugins page loads, and asks for the slots it registers into', async () => {
const { module: mod } = await loadClient()
assert.equal(mod.name, 'dsh-facet')
assert.deepEqual(mod.inject, ['slots'])
assert.equal(typeof mod.apply, 'function')
})
test('the form binds to the profile entry id while the card registers under the package name', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
// These two are deliberately different strings. `facet-policy` is the `id` of
// this bundle's row in cordis.patch.yml — what `Config` is filed under. The
// card is dispatched by the BUNDLE's package name.
assert.equal(seen.formNs, 'facet-policy')
assert.equal(seen.entry.key, 'dsh-facet')
assert.notEqual(seen.formNs, seen.entry.key, 'if these ever coincide, the trap this test guards has moved')
assert.equal(seen.slot, 'plugins.bundle.config')
assert.deepEqual(seen.watched, ['facet-policy'])
})
test('the host row the form binds to is the one cordis.patch.yml actually declares', async () => {
const patch = await readFile(new URL('../cordis.patch.yml', import.meta.url), 'utf8')
const { module: mod } = await loadClient()
const { seen } = drive(mod)
// A rename in the patch without a rename here is a card that writes nowhere,
// so the id is read out of the patch rather than trusted to the test.
assert.match(patch, new RegExp(`id:\\s*${seen.formNs}\\b`))
assert.equal((await import('../package.json', { with: { type: 'json' } })).default.name, seen.entry.key)
})
test('nothing is registered while the Host does not serve the namespace', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod, { served: false })
assert.equal(seen.formNs, 'facet-policy', 'the form is still bound')
assert.equal(seen.entry, undefined, 'but no card is registered for a namespace nobody serves')
})
test('the card stages exactly the fields the host marks volatile', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const declared = Object.entries(Config.dict)
.filter(([, schema]) => schema.meta.volatile === true)
.map(([key]) => key)
// A field served but never staged is invisible in the UI; a field staged but
// never served makes `whileServed` withhold the whole card.
assert.deepEqual(
staged.map((spec) => spec.field).sort(),
declared.sort(),
'the card and the host schema have drifted apart',
)
})
test('the choice field refuses a value the schema would reject, and clears on empty', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const mode = staged.find((spec) => spec.field === 'mode')
assert.equal(mode.parse('deny').value, 'deny')
assert.equal(mode.parse(' DENY ').value, 'deny')
assert.deepEqual(mode.parse(''), { kind: 'clear' })
// Undefined marks the draft invalid, which blocks the save rather than
// coercing a typo into a mode the Host would reject anyway.
assert.equal(mode.parse('maybe'), undefined)
})
test('the list field clears rather than storing an empty array that shadows the default', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const roots = staged.find((spec) => spec.field === 'extraRoots')
assert.deepEqual(roots.parse('a, b\nc').value, ['a', 'b', 'c'])
assert.deepEqual(roots.parse(' '), { kind: 'clear' })
assert.equal(roots.format(['a', 'b']), 'a, b')
assert.equal(roots.format(undefined), '')
})
test('the card renders both views without reaching for anything it was not given', async () => {
const { module: mod } = await loadClient()
const { seen } = drive(mod)
assert.equal(typeof seen.card, 'function')
assert.equal(seen.card({ view: 'summary' }), 'How a call outside the session\u2019s own folder is handled, and which paths are always in scope.')
const tree = seen.card({
view: 'page',
useFacetCard: (select) =>
select({
available: true,
writable: true,
dirty: false,
invalid: false,
saving: false,
failed: false,
mode: { text: 'deny', overridden: false, invalid: false },
extraRoots: { text: 'src/shared', overridden: true, invalid: false },
}),
edit: () => {},
resetField: () => {},
save: () => {},
discard: () => {},
})
// The form frame, with the segmented control reading the staged mode. Children
// ride the `children` prop, which is how the platform's own cards pass them.
assert.equal(tree.type, 'SettingsForm')
const controls = tree.props.children.children
assert.equal(controls[0].type, 'div')
assert.equal(controls[1].type, 'SettingsValueField')
assert.equal(controls[0].children[1].props.value, 'deny')
assert.equal(controls[1].props.text, 'src/shared')
})
+30 -2
View File
@@ -18,6 +18,15 @@ import { after, before, test } from 'node:test'
import { apply as guard } from '../lib/guard.js' import { apply as guard } from '../lib/guard.js'
import { createFacetPolicy } from '../lib/policy.js' import { createFacetPolicy } from '../lib/policy.js'
import { Config } from '../index.js'
/**
* The symbol cosmokit marks a volatile config reference with. A settings write
* reaches a running plugin as `target[write](value)` — that is literally what
* `updateVolatile` does — so a test can commit an edit the way the framework
* does, without a settings service.
*/
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
/** A filesystem provider with `dsh-fs-local`'s two relevant behaviours. */ /** A filesystem provider with `dsh-fs-local`'s two relevant behaviours. */
const fsLikeLocal = { const fsLikeLocal = {
@@ -50,8 +59,8 @@ const fsLikeLocal = {
} }
/** Capture what `apply` registered, so the test can drive it. */ /** Capture what `apply` registered, so the test can drive it. */
function harness({ mode = 'ask', extraRoots = [], tool = true } = {}) { function harness({ mode = 'ask', extraRoots = [], tool = true, config } = {}) {
const policy = createFacetPolicy({ mode, extraRoots }) const policy = createFacetPolicy(config ?? { mode, extraRoots })
const captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined } const captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined }
const ctx = { const ctx = {
facetPolicy: policy, facetPolicy: policy,
@@ -217,6 +226,25 @@ test('off mode registers nothing', () => {
assert.equal(captured.tool, undefined) assert.equal(captured.tool, undefined)
}) })
test('a live mode edit decides the next call, without the guard being remounted', async () => {
// The loader hands the guard the schema-resolved config, so `mode` arrives as
// a volatile reference rather than a string. This drives that exact shape.
const config = Config({ mode: 'ask' })
const { captured } = harness({ config })
const outside = { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent() }
assert.equal((await dispatch(captured, outside)).kind, 'ask')
// What a settings write does: commit into the reference in place, and
// deliberately do not re-apply the plugin. One mount, three answers.
config.mode[VOLATILE_WRITE]('deny')
assert.equal((await dispatch(captured, outside)).kind, 'deny')
// `off` silences the guard entirely, which is the whole meaning of the mode.
config.mode[VOLATILE_WRITE]('off')
assert.equal((await dispatch(captured, outside)).kind, 'allow')
})
test('the guard adds to a decision, never overrides one', async () => { test('the guard adds to a decision, never overrides one', async () => {
const { captured } = harness() const { captured } = harness()
const denied = await dispatch(captured, { const denied = await dispatch(captured, {
+61 -2
View File
@@ -10,6 +10,18 @@ import assert from 'node:assert/strict'
import { test } from 'node:test' import { test } from 'node:test'
import { apply, Config } from '../index.js' import { apply, Config } from '../index.js'
import { configValue } from '../lib/policy.js'
/**
* The symbol cosmokit marks a volatile config reference with, and the writer it
* carries. A settings write reaches a running plugin through exactly this
* function — `updateVolatile(target, source)` is `target[write](source.get())` —
* so a test can commit an edit the same way the framework does.
*/
const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
/** Whether a field arrived as a live reference rather than a plain value. */
const isReference = (value) => typeof value === 'object' && value !== null && VOLATILE_WRITE in value
/** The smallest context this row touches. */ /** The smallest context this row touches. */
function harness() { function harness() {
@@ -28,12 +40,59 @@ function harness() {
test('the schema fills every default, so a bare row is a working row', () => { test('the schema fills every default, so a bare row is a working row', () => {
const config = Config({}) const config = Config({})
assert.equal(config.mode, 'ask') assert.equal(configValue(config.mode), 'ask')
assert.deepEqual(config.extraRoots, []) assert.deepEqual(configValue(config.extraRoots), [])
assert.equal(config.sectionOrder, 100) assert.equal(config.sectionOrder, 100)
assert.equal(config.contextOrder, 105) assert.equal(config.contextOrder, 105)
}) })
test('the editable fields are volatile, which is the only reason they have a settings card', () => {
const config = Config({})
// The framework's form projection keeps only fields under a volatile ancestor
// and drops an entry with no such field from `settings.describe` entirely, so
// dropping either of these markers silently removes the card from the UI.
assert.equal(isReference(config.mode), true)
assert.equal(isReference(config.extraRoots), true)
// Placement is settled once at load: not settings anyone edits live, and not
// on the card.
assert.equal(isReference(config.sectionOrder), false)
assert.equal(isReference(config.contextOrder), false)
})
test('a settings edit is answered on the next call, without the plugin being re-applied', () => {
const provided = []
let applies = 0
const config = Config({ mode: 'ask', extraRoots: ['/w/src/shared'] })
apply(
{
provide: (name, value) => {
applies += 1
provided.push({ name, value })
},
on: () => {},
},
config,
)
const policy = provided[0].value
assert.equal(policy.mode, 'ask')
assert.deepEqual(policy.standing, ['/w/src/shared'])
// What the framework does on a settings write: it commits into the running
// references and deliberately does NOT re-apply the plugin. Everything the
// policy reports has to follow from the reference alone.
config.mode[VOLATILE_WRITE]('deny')
config.extraRoots[VOLATILE_WRITE](['/w/src/shared', '/w/src/types'])
assert.equal(applies, 1)
assert.equal(policy.mode, 'deny')
assert.deepEqual(policy.standing, ['/w/src/shared', '/w/src/types'])
assert.deepEqual(
policy.scopeFor({ session: { id: 's1', header: { cwd: '/w/src/room' } } }).extras,
['/w/src/shared', '/w/src/types'],
)
})
test('a bad mode fails loudly at load rather than at the first file call', () => { test('a bad mode fails loudly at load rather than at the first file call', () => {
assert.throws(() => Config({ mode: 'maybe' })) assert.throws(() => Config({ mode: 'maybe' }))
}) })
+26 -12
View File
@@ -51,33 +51,47 @@ test('isWideningCall is true only for the one act that needs the user', () => {
}) })
test('facetRuleText states the rule and never the binding', () => { test('facetRuleText states the rule and never the binding', () => {
const ask = facetRuleText({ mode: 'ask' }) const rule = facetRuleText()
assert.match(ask, /put to the user as a question/)
// The two modes must not read alike: ask must not claim the call is refused.
assert.doesNotMatch(ask, /A call outside it is refused/)
const deny = facetRuleText({ mode: 'deny' }) // Mode-neutral by construction. A prompt section is registered once as a plain
assert.match(deny, /A call outside it is refused/) // string, and a `volatile()` edit updates the config reference without
// re-applying the plugin — so a `mode` interpolated here would freeze at load
// while the guard went on enforcing the new one. The mode-specific sentence
// lives in the binding, which is a callback.
assert.match(rule, /Work inside the facet\./)
assert.doesNotMatch(rule, /refused|put to the user as a question/)
assert.match(rule, /Never widen the scope on your own initiative/)
// The rule must not name a path: a changing system prompt invalidates the // The rule must not name a path: a changing system prompt invalidates the
// prompt cache for the whole conversation. // prompt cache for the whole conversation.
assert.doesNotMatch(ask, /\/|src\//) assert.doesNotMatch(rule, /\/|src\//)
assert.equal(facetRuleText({ mode: 'off' }), '')
assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '') assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '')
}) })
test('facetContextText carries the binding that does change', () => { test('facetContextText carries the binding that does change', () => {
const ask = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' })
assert.equal( assert.equal(
facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }), ask,
'facet: /w/src/room\noutside the facet: ask the user', 'facet: /w/src/room\n' +
'outside the facet: put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again',
) )
const deny = facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' })
assert.equal( assert.equal(
facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' }), deny,
'facet: /w/src/room\ngranted outside it: src/shared\noutside the facet: refused', 'facet: /w/src/room\n' +
'granted outside it: src/shared\n' +
'outside the facet: refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope',
) )
// The binding is the only half that can follow a live edit, so the two modes
// have to read differently here — they no longer differ in the rule.
assert.notEqual(ask, deny)
// Unscoped sessions contribute nothing rather than an empty heading. // Unscoped sessions contribute nothing rather than an empty heading.
assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '') assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '')
assert.equal(facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'off' }), '')
}) })
test('displayPath prefers the shortest unambiguous form', () => { test('displayPath prefers the shortest unambiguous form', () => {