feat: scope a session from a declaration the agent cannot widen

The facet root was the session's working directory and nothing else, so the
only way to scope a conversation to a module was to make the module a
workspace — and the only way to reach anything outside it was `extraRoots`,
which is a fact about the deployment rather than about one conversation.

Scope now has two layers, and the split is the point. The **user layer** is
`config.scopes[sessionId]` — a `focus` and a list of `extras`, written only
from the browser context panel through the settings service, which is what
`settings.mutate` requiring a volatile path buys us. The **agent layer** is in
memory: a narrowing `focus` and its grants. Both can only ever reduce what the
user layer allows, so an agent that goes wrong cannot widen its own scope.

`facet action=focus` is therefore the one scope change the agent may make
unasked, bounded by the scope already in force and decided on canonical targets.
`reset` clears the agent's side only, landing on the user's declaration rather
than on the session directory — landing on the directory would have made
`reset` a widening. `extraRoots` demotes to an optional deployment-wide seed.

The binding now names the context panel as the way to widen, because it is: in
a deployment with no approval channel, `facet action=add` cannot be granted at
all.
This commit is contained in:
NotExpectedYet committed 2026-09-27 06:10:53 +01:00
1 parent f36d46efa1
commit d9caf4eeec
8 files changed
+431 -52

No files matched your search

+6 -6
View File
@@ -82,7 +82,7 @@ test('facetContextText carries the binding that does change', () => {
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',
'outside the facet: refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted',
)
// The binding is the only half that can follow a live edit, so the two modes
@@ -97,14 +97,14 @@ test('facetContextText carries the binding that does change', () => {
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
// `facet action=add` cannot be granted 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.
// works — the context panel, which the user owns.
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.match(text, /outside the facet: refused — this session has no approval channel/)
assert.match(text, /`facet action=add` cannot be granted/)
assert.match(text, /context panel/)
assert.doesNotMatch(text, /put to the user as a question/)
}