@immediately-run/grove 0.1.5 → 0.1.7

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,51 @@
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';
9
15
 
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). */
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 ✗. */
13
42
  export interface ReachRow {
14
- key: 'answer' | 'read' | 'draft' | 'apply';
43
+ key: 'packaging' | 'answer' | 'read' | 'draft' | 'apply';
15
44
  label: string;
16
- state: 'ok' | 'blocked' | 'neutral';
45
+ state: 'ok' | 'blocked' | 'neutral' | 'elsewhere';
17
46
  cause?: string;
47
+ /** Where a `'elsewhere'` row's outcome happens — rendered after `→`, never a ✗. */
48
+ destination?: string;
18
49
  /** Chips this row contributes when ✓ — the panel renders exactly these. */
19
50
  chips?: string[];
20
51
  }
@@ -22,42 +53,139 @@ export interface ReachRow {
22
53
  export interface ReachInputs {
23
54
  providerState: ChatProviderState;
24
55
  /** Whether the grant-filtered catalog advertises `llm:chat` (the consented
25
- * capability — absent on an ungranted fork, a distinct cause from "no key"). */
56
+ * capability — absent on an ungranted fork, a distinct cause from "no key").
57
+ *
58
+ * Since R3-688 this is the BELT, not the primary discriminator: a 0.72.0 host marks
59
+ * the grantless answer on the provider channel itself, and that mark is read first.
60
+ * But it is still checked BEFORE `not-configured`, not after, because a host
61
+ * predating the mark answers an ungranted fork with `{ provider: null }` — the same
62
+ * payload as keyless — so not-configured would otherwise win and send a user who HAS
63
+ * a key to Settings. See the ordering note in `computeReachRows` for the one
64
+ * imprecision that buys, and how `catalogAnswered` bounds it. */
26
65
  chatGranted: boolean;
66
+ /** Whether the host has actually ANSWERED the catalog (`useCatalogAnswered()`).
67
+ *
68
+ * `!chatGranted` is true both for "not granted" and for "has not replied yet", and
69
+ * the value cannot tell them apart because an empty catalog is a legitimate answer.
70
+ * This does, because the push channel has no value-equality check: a second
71
+ * notification means the host spoke. While it is `false`, the card declines to name
72
+ * a cause rather than guessing one. */
73
+ catalogAnswered: boolean;
27
74
  writable: boolean;
28
- /** Fail-closed source trust (git ⇒ indeterminate ⇒ treated as shared). */
75
+ /** Fail-closed source trust (git ⇒ indeterminate ⇒ treated as shared). Not
76
+ * rendered on any row since R3-752 — it is the source-trust LINE's input (see
77
+ * `sourceTrustLine`) — but part of the envelope, so the cross-product over the
78
+ * card's inputs can assert no input re-blocks the apply row. */
29
79
  sourceShared: boolean;
80
+ /** The corpus mount id (`getCorpusMountId()`): `null` is a fork reading its own
81
+ * bundled corpus, an id is a wiki mounted into it. One fact, decided once at
82
+ * boot by the same delegation as the root — never a second source of truth. */
83
+ mountId: string | null;
84
+ /** Whether the configured provider advertises `features.tools`. `false` with a
85
+ * configured provider degrades the agent to context-stuffing (G-GA-8) — a real
86
+ * capability change the card must show, not hide (SPEC_AUDIT §2.8u). */
87
+ toolsSupported: boolean;
30
88
  }
31
89
 
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
90
+ /** The rows, computed. Order is the card's display order: the substrate first
91
+ * (context for every row under it), then the capability rows. */
92
+ export function computeReachRows({
93
+ providerState,
94
+ chatGranted,
95
+ catalogAnswered,
96
+ writable,
97
+ mountId,
98
+ toolsSupported,
99
+ }: ReachInputs): ReachRow[] {
100
+ // Row 0 — packaging (R3-752). Context, not a claim: `neutral`, no chips, no
101
+ // capability words. A pinned-library consumer is indistinguishable from a fork at
102
+ // runtime and the distinction does not change reach, so the row states the
103
+ // substrate — never a guess at how the app was assembled.
104
+ const packaging: ReachRow = {
105
+ key: 'packaging',
106
+ label: mountId === null ? 'Reads its own entries' : 'Reads a wiki mounted into it',
107
+ state: 'neutral',
108
+ };
109
+
110
+ // Row 1 — Q&A. The provider states × the grant, with the two NOT-causes never
35
111
  // 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.
112
+ // is this copy's consent state, and reading works either way. A configured
113
+ // provider without `features.tools` keeps the ✓ — asking still works — and
114
+ // carries the degrade as a qualifier (G-GA-8, SPEC_AUDIT §2.8u).
115
+ //
116
+ // R3-688: the host now marks the grantless answer on the provider channel itself
117
+ // (`ungranted`), so the consent cause is computable even though an ungranted frame is
118
+ // never told the provider — the host's grant decision IS the fact. The catalog check is
119
+ // the BELT for a host predating the mark, and it is checked BEFORE not-configured
120
+ // because a pre-mark host answers an ungranted fork with `{provider: null}` — the same
121
+ // payload as keyless — so not-configured would win and send a user who has a key to
122
+ // Settings.
123
+ //
124
+ // THE UNANSWERED CATALOG, and the two wrong answers before this one (grove#75 r1, r2).
125
+ //
126
+ // `!chatGranted` is true both for "not granted" and for "the host has not replied yet",
127
+ // and the catalog's VALUE cannot separate them because an empty catalog is a legitimate
128
+ // answer. Round 1 caught the belt firing on the unanswered case and claiming a consent
129
+ // state about a frame that may hold the grant.
130
+ //
131
+ // Wrong answer 1: gate on `catalog.length > 0`. That reads an empty ANSWER as silence,
132
+ // which turns the G-GA-10 case below (a configured provider beside an empty catalog)
133
+ // from a permanent correct ✗ into a permanent unbacked ✓ — the R-GA-1 violation this
134
+ // card exists to prevent. Measured; two tests catch it.
135
+ //
136
+ // Wrong answer 2: declare it unfixable without an SDK change. Round 2 found it is
137
+ // derivable here, because the push channel has no value-equality check — a second
138
+ // notification means the host spoke, even when the value equals the initial `[]`. That
139
+ // is `useCatalogAnswered()`.
140
+ //
141
+ // So: while the catalog is unanswered the row is NEUTRAL — no cause named. `catalogAnswered`
142
+ // alone is not enough, because falling through to the ✓ arm would trade an under-claim
143
+ // for an over-claim, and over-claiming is the one R-GA-1 forbids outright.
37
144
  let answer: ReachRow;
145
+ const degrade = toolsSupported ? '' : ' (reads a summary of this wiki, not entries on demand)';
38
146
  if (providerState.status === 'unknown') {
39
147
  answer = { key: 'answer', label: 'Answer questions about this wiki', state: 'neutral' };
40
- } else if (providerState.status === 'not-configured') {
148
+ } else if (providerState.status === 'ungranted') {
41
149
  answer = {
42
150
  key: 'answer',
43
151
  label: 'Answer questions about this wiki',
44
152
  state: 'blocked',
45
- cause: 'no model key connected — add one in Settings',
153
+ cause: "this Grove wasn't granted chat — reading works as normal",
46
154
  };
47
155
  } else if (!chatGranted) {
156
+ answer = catalogAnswered
157
+ ? {
158
+ key: 'answer',
159
+ label: 'Answer questions about this wiki',
160
+ state: 'blocked',
161
+ cause: "this Grove wasn't granted chat — reading works as normal",
162
+ }
163
+ : // The host has not answered the catalog. We know nothing about the grant, so name
164
+ // nothing — the same shape `unknown` uses for an unanswered provider.
165
+ { key: 'answer', label: 'Answer questions about this wiki', state: 'neutral' };
166
+ } else if (providerState.status === 'not-configured') {
48
167
  answer = {
49
168
  key: 'answer',
50
169
  label: 'Answer questions about this wiki',
51
170
  state: 'blocked',
52
- cause: "this Grove wasn't granted chat — reading works as normal",
171
+ cause: 'no model key connected — add one in Settings',
53
172
  };
54
- } else {
173
+ } else if (providerState.status === 'configured') {
55
174
  answer = {
56
175
  key: 'answer',
57
- label: 'Answer questions about this wiki',
176
+ label: `Answer questions about this wiki${degrade}`,
58
177
  state: 'ok',
59
178
  chips: ['Summarize this entry', 'What entries are tagged security?'],
60
179
  };
180
+ } else {
181
+ // The ✓ arm is NARROWED to `configured` and this is the exhaustiveness check. The open
182
+ // `else` it replaces dates from R3-489, the file's first commit, and survived R3-752
183
+ // untouched; R3-688 is what closes it. Left open, a FIFTH provider state would land on
184
+ // ✓ with chips — an unbacked capability claim (R-GA-1) that `tsc` would not mention.
185
+ // A fourth was just added; the fifth must be a compile error, not a silent grant.
186
+ const unreachable: never = providerState;
187
+ void unreachable;
188
+ answer = { key: 'answer', label: 'Answer questions about this wiki', state: 'neutral' };
61
189
  }
62
190
 
63
191
  // Row 2 — the body source. Both packagings define one post-S2 (the fork's own
@@ -81,18 +209,18 @@ export function computeReachRows({ providerState, chatGranted, writable, sourceS
81
209
  : { key: 'draft', label: 'Draft changes', state: 'blocked', cause: 'you’re a reader here' };
82
210
 
83
211
  // 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.
212
+ // is exactly the broker core_concepts §8a Axis D forbids. That is a DESTINATION,
213
+ // not a denial — where to go, stated as one — so it renders `→`, never ✗. The
214
+ // source-trust sentence does not ride here: it is a property of the source, and it
215
+ // has its own line under the card (`sourceTrustLine`).
86
216
  const apply: ReachRow = {
87
217
  key: 'apply',
88
218
  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' : ''),
219
+ state: 'elsewhere',
220
+ destination: 'in the editor or workbench, where you confirm them',
93
221
  };
94
222
 
95
- return [answer, read, draft, apply];
223
+ return [packaging, answer, read, draft, apply];
96
224
  }
97
225
 
98
226
  /** The chips the panel shows: exactly the ✓ rows' chips, in card order (R-GA-1 —
@@ -101,6 +229,20 @@ export function reachChips(rows: ReachRow[]): string[] {
101
229
  return rows.flatMap((r) => (r.state === 'ok' ? r.chips ?? [] : []));
102
230
  }
103
231
 
232
+ /**
233
+ * The source-trust line under the card (R3-752). It is a property of the SOURCE,
234
+ * not of the apply row it used to be glued to, and its copy is chosen by WHY the
235
+ * source reads as shared — a cause the reader can act on (R-SP-3), never a
236
+ * classification (R-SP-6). `null` when the source is not shared: no line, no
237
+ * reassurance about a regime that is not running.
238
+ */
239
+ export function sourceTrustLine(shared: boolean, basis: SourceSharedBasis): string | null {
240
+ if (!shared) return null;
241
+ return basis === 'git-indeterminate'
242
+ ? 'anyone who can push to this repo can change what the agent reads'
243
+ : 'others can change what the agent reads here';
244
+ }
245
+
104
246
  /** R-GA-6's unconditional egress line — shown whenever a provider is bound,
105
247
  * whatever the wiki's trust mode: Q&A composes read + provider egress, and the
106
248
  * confidentiality axis is grant-based, not sharedness-based. */