7 Commits
Author SHA1 Message Date
gitea-actions f36d46efa1 Release v0.1.4 [skip release] [skip actions] 2026-09-26 08:03:19 +00:00
NotExpectedYet a1c8ecb1e8 fix: say so when a session has no approval channel to ask through
Release / release (push) Successful in 6s
`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
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
12 changed files with 982 additions and 67 deletions

No files matched your search

+21
View File
@@ -2,6 +2,27 @@
Every release, newest first, generated from the commits in each release.
## v0.1.4 — 2026-09-26
### Fixed
- say so when a session has no approval channel to ask through
## 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
+40 -7
View File
@@ -115,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
```
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
@@ -171,12 +188,13 @@ Honest boundaries, because each one is a decision rather than an oversight:
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.
- 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`,
@@ -246,14 +264,29 @@ it.
```
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/ 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
- [`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
* swapped without touching any of this.
*
* There is deliberately no `settings` namespace. A facet grant is a statement
* about one conversation, so the only thing worth persisting is the deployment's
* standing grants, and those belong in the profile patch — the layer where this
* machine's facts already live.
* There is deliberately no `settings` *namespace* of our own: a facet grant is a
* statement about one conversation, so the only thing worth persisting is the
* deployment's standing grants, and those belong in the profile patch — the
* 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'
@@ -28,21 +30,42 @@ import { createFacetPolicy } from './lib/policy.js'
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({
/**
* How a call outside the facet is handled. `ask` questions the user, `deny`
* refuses outright, `off` disables enforcement while leaving the preset in
* 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
* types, a common test harness. Relative paths resolve against each session's
* 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. */
sectionOrder: Schema.number().default(100),
+60 -10
View File
@@ -64,10 +64,48 @@ export const Config = Schema.object({
*/
export function apply(ctx, config) {
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
/**
* Whether an approval request from this session can reach a person.
*
* `ask` mode is a promise 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`, under which
* the approval service's `decide()` returns "rejected" before consulting any
* answerer. An ask is then a denial, and `facet action=add` — itself an ask —
* is denied with it, so the scope can never be widened while the model is told
* only `the user rejected tool "…"`. Naming that is the binding's job; see
* `facetContextText`.
*
* A missing service is a definite no (the tool pipeline denies an ask it
* cannot route). An unreadable policy is treated as a yes, so a transient
* failure does not tell a user to go and edit their configuration.
*
* @param {{session?: object}|undefined} agent - the agent whose session supplies the policy.
* @returns {boolean} whether an ask can reach a person.
*/
const canAsk = (agent) => {
const approval = ctx.get('approval')
if (approval === undefined || agent?.session === undefined) return false
try {
return approval.effectivePolicy(agent.session) !== 'never'
} catch {
return true
}
}
// `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 contextOrder = policy.contextOrder ?? 105
@@ -129,6 +167,9 @@ export function apply(ctx, config) {
const classify = async (exec) => {
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:
// it is explicit, deliberate, and the only thing standing between a scoped
// agent and an unscoped one.
@@ -155,11 +196,13 @@ export function apply(ctx, config) {
}
// 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({
name: 'facet-scope',
order: Number(sectionOrder),
text: facetRuleText({ mode }),
text: facetRuleText(),
interpolate: false,
})
@@ -173,11 +216,17 @@ export function apply(ctx, config) {
text: (context) => {
const scope = policy.scopeFor(context?.agent)
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(),
canAsk: canAsk(context?.agent),
})
},
})
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
// only ever adds a question or a refusal to a call that was otherwise allowed.
@@ -206,9 +255,8 @@ function requestedPaths(args) {
*
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context.
* @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({
name: FACET_TOOL,
description:
@@ -276,7 +324,9 @@ function facetTool(ctx, policy, mode) {
ok: true,
root: 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,
granted,
scope: scope.extras,
+64 -6
View File
@@ -18,6 +18,38 @@
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
* @property {string} cwd - the session working directory, absolute.
@@ -28,6 +60,13 @@ import { isAbsolute, resolve } from 'node:path'
/**
* 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 {'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.
@@ -36,7 +75,20 @@ import { isAbsolute, resolve } from 'node:path'
export function createFacetPolicy(config) {
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
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.
@@ -66,7 +118,7 @@ export function createFacetPolicy(config) {
function extrasFor(agent, cwd) {
const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined
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)]
}
@@ -124,11 +176,17 @@ export function createFacetPolicy(config) {
}
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. */
sectionOrder: config.sectionOrder ?? 100,
contextOrder: config.contextOrder ?? 105,
standing: [...standing],
sectionOrder: configValue(config.sectionOrder) ?? 100,
contextOrder: configValue(config.contextOrder) ?? 105,
/** The standing grants. A getter for the same reason as {@link mode}. */
get standing() {
return standingRoots()
},
scopeFor,
grant,
revoke,
+43 -19
View File
@@ -118,25 +118,24 @@ export function isWideningCall(toolName, args) {
* treatment the sandbox policy and approval policy already get, for the same
* reason.
*
* @param {object} input
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled.
* @returns {string} prompt section text, or `''` to contribute nothing.
* It takes no configuration, and that is deliberate rather than an oversight.
* A system-prompt section is a plain string registered once; there is no
* 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) {
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.'
export function facetRuleText() {
return [
'# 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.',
'',
@@ -164,28 +163,53 @@ export function displayPath(cwd, path) {
/**
* 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
* whenever it changes, and a scope statement that costs more than the scope is
* worth is a scope that does not pay for itself.
* Kept short because it is re-sent as part of the conversation whenever it
* changes, and a scope statement that costs more than the scope is worth is a
* 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 {string} input.cwd - the session working directory.
* @param {string|undefined} input.root - the facet root, or undefined when unscoped.
* @param {readonly string[]} input.extras - absolute granted paths, outside the root.
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled.
* @param {boolean} [input.canAsk] - whether an approval request can reach the user. False means every ask is refused before anyone is consulted.
* @returns {string} context text, or `''` to contribute nothing.
*/
export function facetContextText(input) {
const { cwd, root, extras = [], mode } = input
const { cwd, root, extras = [], mode, canAsk = true } = input
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`.
//
// `canAsk` comes first because it collapses the other two. A session can be
// composed without an approval channel — the `danger-full-access` permission
// preset sets `approval: never`, and `decide()` then returns "rejected" before
// any answerer is consulted. There, an out-of-facet call is refused whatever
// `mode` says, and `facet action=add` is refused with it, because widening is
// itself a question. Telling the agent to ask would send it into a loop, and
// the reason this guard writes never reaches it: `serviceAsk` replaces a
// rejected ask with `the user rejected tool "…"`. So the binding is the one
// channel that can say what is true, and it names the mechanism that still
// works — `extraRoots` is configuration applied by the profile, not a grant
// asked for per conversation.
const reach = !canAsk
? 'refused — approval prompts are off in this session, so `facet action=add` cannot be approved either; only the user can widen this, by adding the path to `extraRoots`'
: 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
// 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
// prompt needs to see which folder they are being asked about.
const lines = [`facet: ${root}`]
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')
}
+12 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-facet",
"version": "0.1.1",
"version": "0.1.4",
"private": true,
"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.",
@@ -14,6 +14,9 @@
"types": "./lib/types/guard.d.ts",
"default": "./lib/guard.js"
},
"./client": {
"default": "./client/client.js"
},
"./cordis.patch.yml": "./cordis.patch.yml",
"./presets/facet.patch.yml": "./presets/facet.patch.yml",
"./package.json": "./package.json"
@@ -21,6 +24,7 @@
"files": [
"index.js",
"lib",
"client",
"presets",
"cordis.patch.yml",
"README.md"
@@ -40,6 +44,13 @@
"./cordis.patch.yml",
"./presets/facet.patch.yml"
]
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-ui-plugin-manager"
]
}
},
"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')
})
+74 -3
View File
@@ -18,6 +18,15 @@ import { after, before, test } from 'node:test'
import { apply as guard } from '../lib/guard.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. */
const fsLikeLocal = {
@@ -49,13 +58,22 @@ const fsLikeLocal = {
},
}
/** Capture what `apply` registered, so the test can drive it. */
function harness({ mode = 'ask', extraRoots = [], tool = true } = {}) {
const policy = createFacetPolicy({ mode, extraRoots })
/**
* Capture what `apply` registered, so the test can drive it.
*
* `approval` stands in for the approval service named by its policy: `'ask'`
* and `'never'` are the two the schema admits, and leaving it undefined models a
* deployment that composed no approval service at all — a case the tool pipeline
* denies outright, so the guard must treat it as no channel.
*/
function harness({ mode = 'ask', extraRoots = [], tool = true, config, approval } = {}) {
const policy = createFacetPolicy(config ?? { mode, extraRoots })
const captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined }
const ctx = {
facetPolicy: policy,
fs: fsLikeLocal,
get: (name) =>
name === 'approval' && approval !== undefined ? { effectivePolicy: () => approval } : undefined,
systemPrompt: {
section: (value) => {
captured.section = value
@@ -217,6 +235,25 @@ test('off mode registers nothing', () => {
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 () => {
const { captured } = harness()
const denied = await dispatch(captured, {
@@ -247,6 +284,40 @@ test('the binding follows the agent, and is empty without one', () => {
assert.equal(captured.context.text(undefined), '')
})
test('the binding refuses honestly when the session has no approval channel', () => {
// `danger-full-access` sets approval: never, so `decide()` returns "rejected"
// before any answerer is consulted — and `facet action=add`, itself an ask, is
// refused with it. The reason the guard writes never reaches the model on that
// path (`serviceAsk` replaces it with `the user rejected tool "…"`), so the
// binding is the only place the truth can be said.
const { captured } = harness({ mode: 'ask', approval: 'never' })
const text = captured.context.text({ agent: agent() })
assert.match(text, /approval prompts are off in this session/)
assert.match(text, /cannot be approved either/)
assert.match(text, /`extraRoots`/)
// The promise `ask` makes cannot be kept here, so it must not be made.
assert.doesNotMatch(text, /put to the user as a question/)
})
test('an approval channel keeps the promise, and a missing service counts as none', () => {
const withChannel = harness({ mode: 'ask', approval: 'ask' })
assert.match(withChannel.captured.context.text({ agent: agent() }), /put to the user as a question/)
// No approval service at all: the tool pipeline denies an ask it cannot route,
// so the binding must not offer one.
const without = harness({ mode: 'ask' })
assert.match(without.captured.context.text({ agent: agent() }), /approval prompts are off in this session/)
})
test('deny mode still names the way that works when there is no channel', () => {
const { captured } = harness({ mode: 'deny', approval: 'never' })
const text = captured.context.text({ agent: agent() })
assert.match(text, /refused/)
assert.match(text, /`extraRoots`/)
})
test('the facet tool reports the scope', async () => {
const { captured } = harness()
const value = await captured.tool.execute({ action: 'status' }, { agent: agent() })
+61 -2
View File
@@ -10,6 +10,18 @@ import assert from 'node:assert/strict'
import { test } from 'node:test'
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. */
function harness() {
@@ -28,12 +40,59 @@ function harness() {
test('the schema fills every default, so a bare row is a working row', () => {
const config = Config({})
assert.equal(config.mode, 'ask')
assert.deepEqual(config.extraRoots, [])
assert.equal(configValue(config.mode), 'ask')
assert.deepEqual(configValue(config.extraRoots), [])
assert.equal(config.sectionOrder, 100)
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', () => {
assert.throws(() => Config({ mode: 'maybe' }))
})
+48 -12
View File
@@ -51,33 +51,69 @@ test('isWideningCall is true only for the one act that needs the user', () => {
})
test('facetRuleText states the rule and never the binding', () => {
const ask = facetRuleText({ mode: 'ask' })
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 rule = facetRuleText()
const deny = facetRuleText({ mode: 'deny' })
assert.match(deny, /A call outside it is refused/)
// Mode-neutral by construction. A prompt section is registered once as a plain
// 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
// 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' }), '')
})
test('facetContextText carries the binding that does change', () => {
const ask = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' })
assert.equal(
facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }),
'facet: /w/src/room\noutside the facet: ask the user',
ask,
'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(
facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' }),
'facet: /w/src/room\ngranted outside it: src/shared\noutside the facet: refused',
deny,
'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.
assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '')
assert.equal(facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'off' }), '')
})
test('a session with no approval channel says so instead of promising a question', () => {
// `approval: never` refuses every ask before an answerer is consulted, so the
// mode stops mattering: `ask` and `deny` both end in a refusal, and
// `facet action=add` cannot be approved either. `canAsk: false` is the one
// input that collapses them, and it has to name the mechanism that still
// works — `extraRoots` is configuration, not a per-conversation grant.
for (const mode of ['ask', 'deny']) {
const text = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode, canAsk: false })
assert.match(text, /outside the facet: refused — approval prompts are off in this session/)
assert.match(text, /`facet action=add` cannot be approved either/)
assert.match(text, /`extraRoots`/)
assert.doesNotMatch(text, /put to the user as a question/)
}
// The default stays a promise: a caller that knows nothing about approval
// channels must not silently change what the binding says.
assert.match(
facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }),
/put to the user as a question/,
)
})
test('displayPath prefers the shortest unambiguous form', () => {