@immediately-run/grove 0.1.5 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,20 +1,64 @@
1
1
  // The reach card (GROVE_AGENT_SPEC §6) — the agent's envelope, rendered as rows in
2
2
  // the two-word vocabulary with a cause for every ✗ (R-SP-3). R-GA-1: every row is
3
- // COMPUTED from the session's envelope (provider three-state, chat grant, mount
4
- // writability, source trust); no capability claim on any pixel of this surface is
5
- // hand-written copy. The four old banners collapse into these rows; chips render
6
- // only for rows that are ✓ — derived, not curated.
3
+ // COMPUTED from the session's envelope (provider four-state, chat grant, mount
4
+ // writability, packaging, source trust); no capability claim on any pixel of this
5
+ // surface is hand-written copy. The four old banners collapse into these rows; chips
6
+ // render only for rows that are ✓ — derived, not curated.
7
+ //
8
+ // R3-752: a row whose outcome is a destination is not a denial — the apply row
9
+ // renders `→ elsewhere` (R-GA-3 still holds: never from this panel), the packaging
10
+ // substrate is its own neutral row, the source-trust sentence is its own line keyed
11
+ // on WHY the source reads as shared (R-SP-3/R-SP-6), and the tool-less degrade is a
12
+ // visible qualifier on the Q&A row instead of a silent downgrade (SPEC_AUDIT §2.8u).
7
13
 
8
- import type { ChatProviderState } from '@immediately-run/sdk';
14
+ import type { AgentContextBlock, ChatProviderState } from '@immediately-run/sdk';
15
+
16
+ /** Why `sourceShared` reads as it does — the SDK's own union, indexed off the
17
+ * exported block so the two can never drift (R6: one home, the SDK's). */
18
+ export type SourceSharedBasis = AgentContextBlock['sourceSharedBasis'];
19
+
20
+ /** The visually-hidden word for a row's state — one home for the map the panel
21
+ * renders per row and announces through its live region (R6). */
22
+ export function stateWord(state: ReachRow['state']): string {
23
+ return state === 'ok' ? 'available' : state === 'blocked' ? 'unavailable' : state === 'elsewhere' ? 'opens elsewhere' : 'not applicable';
24
+ }
25
+
26
+ /** One reach-card row.
27
+ *
28
+ * `state: 'neutral'` renders neither ✓ nor ✗. Four producers, all of them "we are not
29
+ * claiming anything here":
30
+ * 1. the `unknown` provider state — rendering a cause there re-creates the false banner
31
+ * R3-300 fixed: `unknown` means unanswered, not ungranted;
32
+ * 2. since R3-752, the packaging row — substrate context, not a capability claim;
33
+ * 3. since R3-688, the Q&A row while the CATALOG is unanswered — the same "not told
34
+ * yet" as (1), one channel over;
35
+ * 4. since R3-688, the exhaustiveness fallback at the end of the Q&A chain. Unreachable
36
+ * while `ChatProviderState` has four members, and deliberately neutral rather than ✓
37
+ * so that a fifth member added without updating this file degrades to claiming
38
+ * nothing instead of claiming everything.
39
+ *
40
+ * `state: 'elsewhere'` is the apply row: the outcome happens at another surface, which is
41
+ * where to go — never a ✗. */
42
+ /** R3-790 — the one spelling of the ungranted-chat cause: the row renders it and
43
+ * the composer's refusal toast composes from it, so a reword cannot drift the
44
+ * two surfaces R-GA-1 holds to agreement. The tail is its own constant so the
45
+ * cancelled toast composes the SAME clause without splitting a literal at
46
+ * runtime (a reword of the cause must never render 'undefined' into copy). */
47
+ export const READING_WORKS_NORMAL = 'reading works as normal';
48
+ export const UNGRANTED_CHAT_CAUSE = `this Grove wasn't granted chat — ${READING_WORKS_NORMAL}`;
9
49
 
10
- /** One reach-card row. `state: 'neutral'` renders neither ✓ nor ✗ — used only for
11
- * the unknown provider state (rendering a cause there re-creates the false banner
12
- * R3-300 fixed: `unknown` means unanswered, not ungranted). */
13
50
  export interface ReachRow {
14
- key: 'answer' | 'read' | 'draft' | 'apply';
51
+ key: 'packaging' | 'answer' | 'read' | 'draft' | 'apply';
15
52
  label: string;
16
- state: 'ok' | 'blocked' | 'neutral';
53
+ state: 'ok' | 'blocked' | 'neutral' | 'elsewhere';
17
54
  cause?: string;
55
+ /** R3-790 — the row's earning affordance: on the ✗ chat row, an 'Enable chat'
56
+ * control whose CLICK invokes (the host's lazy consent gesture mints on that
57
+ * same activation, site-main#612). Declared here so the card stays the one
58
+ * computed surface; the panel renders it, never invents it. */
59
+ action?: 'enable-chat';
60
+ /** Where a `'elsewhere'` row's outcome happens — rendered after `→`, never a ✗. */
61
+ destination?: string;
18
62
  /** Chips this row contributes when ✓ — the panel renders exactly these. */
19
63
  chips?: string[];
20
64
  }
@@ -22,42 +66,149 @@ export interface ReachRow {
22
66
  export interface ReachInputs {
23
67
  providerState: ChatProviderState;
24
68
  /** Whether the grant-filtered catalog advertises `llm:chat` (the consented
25
- * capability — absent on an ungranted fork, a distinct cause from "no key"). */
69
+ * capability — absent on an ungranted fork, a distinct cause from "no key").
70
+ *
71
+ * Since R3-688 this is the BELT, not the primary discriminator: a 0.72.0 host marks
72
+ * the grantless answer on the provider channel itself, and that mark is read first.
73
+ * But it is still checked BEFORE `not-configured`, not after, because a host
74
+ * predating the mark answers an ungranted fork with `{ provider: null }` — the same
75
+ * payload as keyless — so not-configured would otherwise win and send a user who HAS
76
+ * a key to Settings. See the ordering note in `computeReachRows` for the one
77
+ * imprecision that buys, and how `catalogAnswered` bounds it. */
26
78
  chatGranted: boolean;
79
+ /** Whether the host has actually ANSWERED the catalog (`useCatalogAnswered()`).
80
+ *
81
+ * `!chatGranted` is true both for "not granted" and for "has not replied yet", and
82
+ * the value cannot tell them apart because an empty catalog is a legitimate answer.
83
+ * This does, because the push channel has no value-equality check: a second
84
+ * notification means the host spoke. While it is `false`, the card declines to name
85
+ * a cause rather than guessing one. */
86
+ catalogAnswered: boolean;
27
87
  writable: boolean;
28
- /** Fail-closed source trust (git ⇒ indeterminate ⇒ treated as shared). */
88
+ /** Fail-closed source trust (git ⇒ indeterminate ⇒ treated as shared). Not
89
+ * rendered on any row since R3-752 — it is the source-trust LINE's input (see
90
+ * `sourceTrustLine`) — but part of the envelope, so the cross-product over the
91
+ * card's inputs can assert no input re-blocks the apply row. */
29
92
  sourceShared: boolean;
93
+ /** The corpus mount id (`getCorpusMountId()`): `null` is a fork reading its own
94
+ * bundled corpus, an id is a wiki mounted into it. One fact, decided once at
95
+ * boot by the same delegation as the root — never a second source of truth. */
96
+ mountId: string | null;
97
+ /** Whether the configured provider advertises `features.tools`. `false` with a
98
+ * configured provider degrades the agent to context-stuffing (G-GA-8) — a real
99
+ * capability change the card must show, not hide (SPEC_AUDIT §2.8u). */
100
+ toolsSupported: boolean;
30
101
  }
31
102
 
32
- /** The rows, computed. Order is the card's display order. */
33
- export function computeReachRows({ providerState, chatGranted, writable, sourceShared }: ReachInputs): ReachRow[] {
34
- // Row 1 — Q&A. Three provider states × the grant, with the two NOT-causes never
103
+ /** The rows, computed. Order is the card's display order: the substrate first
104
+ * (context for every row under it), then the capability rows. */
105
+ export function computeReachRows({
106
+ providerState,
107
+ chatGranted,
108
+ catalogAnswered,
109
+ writable,
110
+ mountId,
111
+ toolsSupported,
112
+ }: ReachInputs): ReachRow[] {
113
+ // Row 0 — packaging (R3-752). Context, not a claim: `neutral`, no chips, no
114
+ // capability words. A pinned-library consumer is indistinguishable from a fork at
115
+ // runtime and the distinction does not change reach, so the row states the
116
+ // substrate — never a guess at how the app was assembled.
117
+ const packaging: ReachRow = {
118
+ key: 'packaging',
119
+ label: mountId === null ? 'Reads its own entries' : 'Reads a wiki mounted into it',
120
+ state: 'neutral',
121
+ };
122
+
123
+ // Row 1 — Q&A. The provider states × the grant, with the two NOT-causes never
35
124
  // conflated (G-GA-10): "no key" is the user's to fix in Settings; "not granted"
36
- // is this copy's consent state, and reading works either way.
125
+ // is this copy's consent state, and reading works either way. A configured
126
+ // provider without `features.tools` keeps the ✓ — asking still works — and
127
+ // carries the degrade as a qualifier (G-GA-8, SPEC_AUDIT §2.8u).
128
+ //
129
+ // R3-688: the host now marks the grantless answer on the provider channel itself
130
+ // (`ungranted`), so the consent cause is computable even though an ungranted frame is
131
+ // never told the provider — the host's grant decision IS the fact. The catalog check is
132
+ // the BELT for a host predating the mark, and it is checked BEFORE not-configured
133
+ // because a pre-mark host answers an ungranted fork with `{provider: null}` — the same
134
+ // payload as keyless — so not-configured would win and send a user who has a key to
135
+ // Settings.
136
+ //
137
+ // THE UNANSWERED CATALOG, and the two wrong answers before this one (grove#75 r1, r2).
138
+ //
139
+ // `!chatGranted` is true both for "not granted" and for "the host has not replied yet",
140
+ // and the catalog's VALUE cannot separate them because an empty catalog is a legitimate
141
+ // answer. Round 1 caught the belt firing on the unanswered case and claiming a consent
142
+ // state about a frame that may hold the grant.
143
+ //
144
+ // Wrong answer 1: gate on `catalog.length > 0`. That reads an empty ANSWER as silence,
145
+ // which turns the G-GA-10 case below (a configured provider beside an empty catalog)
146
+ // from a permanent correct ✗ into a permanent unbacked ✓ — the R-GA-1 violation this
147
+ // card exists to prevent. Measured; two tests catch it.
148
+ //
149
+ // Wrong answer 2: declare it unfixable without an SDK change. Round 2 found it is
150
+ // derivable here, because the push channel has no value-equality check — a second
151
+ // notification means the host spoke, even when the value equals the initial `[]`. That
152
+ // is `useCatalogAnswered()`.
153
+ //
154
+ // So: while the catalog is unanswered the row is NEUTRAL — no cause named. `catalogAnswered`
155
+ // alone is not enough, because falling through to the ✓ arm would trade an under-claim
156
+ // for an over-claim, and over-claiming is the one R-GA-1 forbids outright.
37
157
  let answer: ReachRow;
158
+ const degrade = toolsSupported ? '' : ' (reads a summary of this wiki, not entries on demand)';
38
159
  if (providerState.status === 'unknown') {
39
160
  answer = { key: 'answer', label: 'Answer questions about this wiki', state: 'neutral' };
40
- } else if (providerState.status === 'not-configured') {
161
+ } else if (providerState.status === 'ungranted') {
41
162
  answer = {
42
163
  key: 'answer',
43
164
  label: 'Answer questions about this wiki',
44
165
  state: 'blocked',
45
- cause: 'no model key connected — add one in Settings',
166
+ cause: UNGRANTED_CHAT_CAUSE,
167
+ // R3-790: the host's MARK says the grant is missing (provider and key state
168
+ // unknown — the mark does not tell); the invoke this row's own click makes is
169
+ // what the host's lazy gesture earns on that same activation.
170
+ action: 'enable-chat',
46
171
  };
47
172
  } else if (!chatGranted) {
173
+ answer = catalogAnswered
174
+ ? {
175
+ key: 'answer',
176
+ label: 'Answer questions about this wiki',
177
+ state: 'blocked',
178
+ cause: UNGRANTED_CHAT_CAUSE,
179
+ // R3-790: the affordance only when a provider RESOLVES (status
180
+ // 'configured' with the grant missing) — on a keyless host the invoke's
181
+ // earning path is the SP-7 connect flow, which the not-configured copy
182
+ // already names (Settings); offering both rows' affordances here would
183
+ // blur which half is missing.
184
+ ...(providerState.status === 'configured' ? { action: 'enable-chat' as const } : {}),
185
+ }
186
+ : // The host has not answered the catalog. We know nothing about the grant, so name
187
+ // nothing — the same shape `unknown` uses for an unanswered provider.
188
+ { key: 'answer', label: 'Answer questions about this wiki', state: 'neutral' };
189
+ } else if (providerState.status === 'not-configured') {
48
190
  answer = {
49
191
  key: 'answer',
50
192
  label: 'Answer questions about this wiki',
51
193
  state: 'blocked',
52
- cause: "this Grove wasn't granted chat — reading works as normal",
194
+ cause: 'no model key connected — add one in Settings',
53
195
  };
54
- } else {
196
+ } else if (providerState.status === 'configured') {
55
197
  answer = {
56
198
  key: 'answer',
57
- label: 'Answer questions about this wiki',
199
+ label: `Answer questions about this wiki${degrade}`,
58
200
  state: 'ok',
59
201
  chips: ['Summarize this entry', 'What entries are tagged security?'],
60
202
  };
203
+ } else {
204
+ // The ✓ arm is NARROWED to `configured` and this is the exhaustiveness check. The open
205
+ // `else` it replaces dates from R3-489, the file's first commit, and survived R3-752
206
+ // untouched; R3-688 is what closes it. Left open, a FIFTH provider state would land on
207
+ // ✓ with chips — an unbacked capability claim (R-GA-1) that `tsc` would not mention.
208
+ // A fourth was just added; the fifth must be a compile error, not a silent grant.
209
+ const unreachable: never = providerState;
210
+ void unreachable;
211
+ answer = { key: 'answer', label: 'Answer questions about this wiki', state: 'neutral' };
61
212
  }
62
213
 
63
214
  // Row 2 — the body source. Both packagings define one post-S2 (the fork's own
@@ -81,18 +232,18 @@ export function computeReachRows({ providerState, chatGranted, writable, sourceS
81
232
  : { key: 'draft', label: 'Draft changes', state: 'blocked', cause: 'you’re a reader here' };
82
233
 
83
234
  // Row 4 — applying. NEVER from this panel (R-GA-3): the widget renders content and
84
- // is exactly the broker core_concepts §8a Axis D forbids. The cause is where
85
- // changes go, plus the shared-source sentence when trust says others can write.
235
+ // is exactly the broker core_concepts §8a Axis D forbids. That is a DESTINATION,
236
+ // not a denial — where to go, stated as one — so it renders `→`, never ✗. The
237
+ // source-trust sentence does not ride here: it is a property of the source, and it
238
+ // has its own line under the card (`sourceTrustLine`).
86
239
  const apply: ReachRow = {
87
240
  key: 'apply',
88
241
  label: 'Apply changes',
89
- state: 'blocked',
90
- cause:
91
- 'changes open in the editor / workbench, where you confirm them' +
92
- (sourceShared ? ' — this repo is treated as if others can write (sole authorship can’t be verified yet), so agent actions go past you first' : ''),
242
+ state: 'elsewhere',
243
+ destination: 'in the editor or workbench, where you confirm them',
93
244
  };
94
245
 
95
- return [answer, read, draft, apply];
246
+ return [packaging, answer, read, draft, apply];
96
247
  }
97
248
 
98
249
  /** The chips the panel shows: exactly the ✓ rows' chips, in card order (R-GA-1 —
@@ -101,6 +252,20 @@ export function reachChips(rows: ReachRow[]): string[] {
101
252
  return rows.flatMap((r) => (r.state === 'ok' ? r.chips ?? [] : []));
102
253
  }
103
254
 
255
+ /**
256
+ * The source-trust line under the card (R3-752). It is a property of the SOURCE,
257
+ * not of the apply row it used to be glued to, and its copy is chosen by WHY the
258
+ * source reads as shared — a cause the reader can act on (R-SP-3), never a
259
+ * classification (R-SP-6). `null` when the source is not shared: no line, no
260
+ * reassurance about a regime that is not running.
261
+ */
262
+ export function sourceTrustLine(shared: boolean, basis: SourceSharedBasis): string | null {
263
+ if (!shared) return null;
264
+ return basis === 'git-indeterminate'
265
+ ? 'anyone who can push to this repo can change what the agent reads'
266
+ : 'others can change what the agent reads here';
267
+ }
268
+
104
269
  /** R-GA-6's unconditional egress line — shown whenever a provider is bound,
105
270
  * whatever the wiki's trust mode: Q&A composes read + provider egress, and the
106
271
  * confidentiality axis is grant-based, not sharedness-based. */
package/src/lib/shell.ts CHANGED
@@ -44,8 +44,9 @@ export interface GroveShell {
44
44
  /** True when the host REFUSED the last edit request — render it where the
45
45
  * affordance was offered (3.3.1, R3-608); a cancelled request never sets it. */
46
46
  editRefused: boolean;
47
- /** What a save actually does, for the affordance's title — under dispatch it says that
48
- * proposing a change back to the content repo is not wired yet (R3-266's residual). */
47
+ /** What a save actually does, for the affordance's title — under dispatch it says
48
+ * the write lands in the mounted content and can be proposed back as a PR
49
+ * (R3-643 / site-main #576 wired the contribute flow to the content repo). */
49
50
  editHint: string;
50
51
  siteTitle: string;
51
52
  /** Interpreter mode (TRUST_MODES §5): render this entry's body through the