@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.
- package/llms.txt +1 -1
- package/package.json +3 -3
- package/src/GroveApp.css +37 -0
- package/src/GroveWiki.tsx +24 -5
- package/src/components/GroveAgent.test.tsx +282 -91
- package/src/components/GroveAgent.tsx +73 -13
- package/src/components/GroveAgent.unanswered.test.tsx +45 -0
- package/src/components/PageView.tsx +6 -0
- package/src/hooks/useCatalogAnswered.late.test.tsx +59 -0
- package/src/hooks/useCatalogAnswered.test.tsx +89 -0
- package/src/hooks/useCatalogAnswered.ts +73 -0
- package/src/hooks/useEditAffordance.ts +6 -9
- package/src/hooks/useHeadingFragmentUrl.test.tsx +146 -0
- package/src/hooks/useHeadingFragmentUrl.ts +98 -0
- package/src/lib/content.test.ts +47 -1
- package/src/lib/content.ts +21 -2
- package/src/lib/reachCard.test.ts +507 -61
- package/src/lib/reachCard.ts +193 -28
- package/src/lib/shell.ts +3 -2
package/src/lib/reachCard.ts
CHANGED
|
@@ -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
|
|
4
|
-
// writability, source trust); no capability claim on any pixel of this
|
|
5
|
-
// hand-written copy. The four old banners collapse into these rows; chips
|
|
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
|
-
|
|
34
|
-
|
|
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 === '
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
85
|
-
//
|
|
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: '
|
|
90
|
-
|
|
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
|
|
48
|
-
*
|
|
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
|