@vibe-commit/capture 0.6.1 → 0.8.0

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.
Files changed (172) hide show
  1. package/README.md +191 -9
  2. package/dist/account_not_here.d.ts +15 -0
  3. package/dist/account_not_here.js +25 -0
  4. package/dist/account_not_here.js.map +1 -0
  5. package/dist/agent_hooks.d.ts +8 -0
  6. package/dist/agent_hooks.js +31 -1
  7. package/dist/agent_hooks.js.map +1 -1
  8. package/dist/agents/claude_code.d.ts +34 -0
  9. package/dist/agents/claude_code.js +141 -8
  10. package/dist/agents/claude_code.js.map +1 -1
  11. package/dist/agents/codex_cli.d.ts +13 -0
  12. package/dist/agents/codex_cli.js +241 -2
  13. package/dist/agents/codex_cli.js.map +1 -1
  14. package/dist/agents/cursor.d.ts +34 -0
  15. package/dist/agents/cursor.js +115 -1
  16. package/dist/agents/cursor.js.map +1 -1
  17. package/dist/agents/session_listing.d.ts +29 -0
  18. package/dist/agents/session_listing.js +49 -0
  19. package/dist/agents/session_listing.js.map +1 -0
  20. package/dist/agents/types.d.ts +118 -8
  21. package/dist/agents/types.js +17 -3
  22. package/dist/agents/types.js.map +1 -1
  23. package/dist/commands/approval_pending.d.ts +9 -0
  24. package/dist/commands/approval_pending.js +23 -0
  25. package/dist/commands/approval_pending.js.map +1 -0
  26. package/dist/commands/auth.d.ts +7 -0
  27. package/dist/commands/auth.js +52 -11
  28. package/dist/commands/auth.js.map +1 -1
  29. package/dist/commands/backfill.d.ts +160 -0
  30. package/dist/commands/backfill.js +748 -0
  31. package/dist/commands/backfill.js.map +1 -0
  32. package/dist/commands/browser_credential.d.ts +6 -2
  33. package/dist/commands/browser_credential.js +34 -14
  34. package/dist/commands/browser_credential.js.map +1 -1
  35. package/dist/commands/connect.d.ts +146 -0
  36. package/dist/commands/connect.js +216 -117
  37. package/dist/commands/connect.js.map +1 -1
  38. package/dist/commands/connect_account.d.ts +135 -0
  39. package/dist/commands/connect_account.js +321 -0
  40. package/dist/commands/connect_account.js.map +1 -0
  41. package/dist/commands/connect_picker.d.ts +27 -0
  42. package/dist/commands/connect_picker.js +549 -0
  43. package/dist/commands/connect_picker.js.map +1 -0
  44. package/dist/commands/context.d.ts +16 -0
  45. package/dist/commands/context.js +27 -0
  46. package/dist/commands/context.js.map +1 -1
  47. package/dist/commands/credential_problem.d.ts +15 -4
  48. package/dist/commands/credential_problem.js +29 -22
  49. package/dist/commands/credential_problem.js.map +1 -1
  50. package/dist/commands/device_credential.d.ts +5 -1
  51. package/dist/commands/device_credential.js +17 -14
  52. package/dist/commands/device_credential.js.map +1 -1
  53. package/dist/commands/finalize.d.ts +95 -0
  54. package/dist/commands/finalize.js +249 -74
  55. package/dist/commands/finalize.js.map +1 -1
  56. package/dist/commands/finish_mint.d.ts +30 -6
  57. package/dist/commands/finish_mint.js +26 -6
  58. package/dist/commands/finish_mint.js.map +1 -1
  59. package/dist/commands/report.js +38 -30
  60. package/dist/commands/report.js.map +1 -1
  61. package/dist/commands/scan.d.ts +162 -0
  62. package/dist/commands/scan.js +507 -0
  63. package/dist/commands/scan.js.map +1 -0
  64. package/dist/commands/status.d.ts +19 -1
  65. package/dist/commands/status.js +232 -26
  66. package/dist/commands/status.js.map +1 -1
  67. package/dist/commands/status_hooks.d.ts +14 -0
  68. package/dist/commands/status_hooks.js +81 -0
  69. package/dist/commands/status_hooks.js.map +1 -0
  70. package/dist/commands/status_machine.d.ts +27 -0
  71. package/dist/commands/status_machine.js +210 -0
  72. package/dist/commands/status_machine.js.map +1 -0
  73. package/dist/commands/sync_reason.d.ts +17 -0
  74. package/dist/commands/sync_reason.js +25 -0
  75. package/dist/commands/sync_reason.js.map +1 -0
  76. package/dist/commands/why.js +44 -31
  77. package/dist/commands/why.js.map +1 -1
  78. package/dist/consent.d.ts +217 -0
  79. package/dist/consent.js +400 -1
  80. package/dist/consent.js.map +1 -1
  81. package/dist/copy/error_codes.d.ts +222 -0
  82. package/dist/copy/error_codes.js +105 -0
  83. package/dist/copy/error_codes.js.map +1 -0
  84. package/dist/copy/index.d.ts +2 -2
  85. package/dist/copy/index.js +2 -2
  86. package/dist/copy/index.js.map +1 -1
  87. package/dist/copy/strings.d.ts +617 -30
  88. package/dist/copy/strings.js +741 -36
  89. package/dist/copy/strings.js.map +1 -1
  90. package/dist/copy/time.d.ts +11 -0
  91. package/dist/copy/time.js +18 -0
  92. package/dist/copy/time.js.map +1 -1
  93. package/dist/credential.d.ts +5 -1
  94. package/dist/credential.js +2 -2
  95. package/dist/credential.js.map +1 -1
  96. package/dist/git.d.ts +43 -0
  97. package/dist/git.js +95 -0
  98. package/dist/git.js.map +1 -1
  99. package/dist/grants_sync.d.ts +165 -0
  100. package/dist/grants_sync.js +289 -0
  101. package/dist/grants_sync.js.map +1 -0
  102. package/dist/grants_verb.d.ts +70 -0
  103. package/dist/grants_verb.js +121 -0
  104. package/dist/grants_verb.js.map +1 -0
  105. package/dist/hooks/entry.d.ts +18 -1
  106. package/dist/hooks/entry.js +117 -14
  107. package/dist/hooks/entry.js.map +1 -1
  108. package/dist/hooks/post_commit.js +3 -3
  109. package/dist/hooks/post_commit.js.map +1 -1
  110. package/dist/hooks/post_rewrite.js +6 -4
  111. package/dist/hooks/post_rewrite.js.map +1 -1
  112. package/dist/hooks/session_grants.d.ts +59 -0
  113. package/dist/hooks/session_grants.js +91 -0
  114. package/dist/hooks/session_grants.js.map +1 -0
  115. package/dist/hooks/session_start.d.ts +114 -0
  116. package/dist/hooks/session_start.js +243 -0
  117. package/dist/hooks/session_start.js.map +1 -0
  118. package/dist/index.d.ts +1 -1
  119. package/dist/index.js +76 -11
  120. package/dist/index.js.map +1 -1
  121. package/dist/install.d.ts +57 -0
  122. package/dist/install.js +54 -20
  123. package/dist/install.js.map +1 -1
  124. package/dist/json.d.ts +32 -9
  125. package/dist/json.js +35 -8
  126. package/dist/json.js.map +1 -1
  127. package/dist/machine_state.d.ts +52 -0
  128. package/dist/machine_state.js +63 -0
  129. package/dist/machine_state.js.map +1 -0
  130. package/dist/oauth/retire.d.ts +10 -0
  131. package/dist/oauth/retire.js +13 -0
  132. package/dist/oauth/retire.js.map +1 -1
  133. package/dist/oauth/session.d.ts +1 -0
  134. package/dist/org_pending.d.ts +52 -0
  135. package/dist/org_pending.js +109 -0
  136. package/dist/org_pending.js.map +1 -0
  137. package/dist/paths.d.ts +70 -0
  138. package/dist/paths.js +90 -0
  139. package/dist/paths.js.map +1 -1
  140. package/dist/pending_revoke.d.ts +117 -0
  141. package/dist/pending_revoke.js +233 -0
  142. package/dist/pending_revoke.js.map +1 -0
  143. package/dist/pending_revoke_file.d.ts +55 -0
  144. package/dist/pending_revoke_file.js +74 -0
  145. package/dist/pending_revoke_file.js.map +1 -0
  146. package/dist/picker.d.ts +208 -0
  147. package/dist/picker.js +526 -0
  148. package/dist/picker.js.map +1 -0
  149. package/dist/picker_checklist.d.ts +141 -0
  150. package/dist/picker_checklist.js +411 -0
  151. package/dist/picker_checklist.js.map +1 -0
  152. package/dist/policy.d.ts +70 -1
  153. package/dist/policy.js +68 -2
  154. package/dist/policy.js.map +1 -1
  155. package/dist/post.d.ts +91 -18
  156. package/dist/post.js +259 -27
  157. package/dist/post.js.map +1 -1
  158. package/dist/state.d.ts +6 -0
  159. package/dist/state.js +38 -1
  160. package/dist/state.js.map +1 -1
  161. package/dist/system_message.d.ts +32 -2
  162. package/dist/system_message.js +47 -5
  163. package/dist/system_message.js.map +1 -1
  164. package/dist/term.d.ts +28 -2
  165. package/dist/term.js +23 -2
  166. package/dist/term.js.map +1 -1
  167. package/dist/verb_help.d.ts +13 -0
  168. package/dist/verb_help.js +40 -0
  169. package/dist/verb_help.js.map +1 -0
  170. package/dist/version.d.ts +1 -1
  171. package/dist/version.js +1 -1
  172. package/package.json +5 -9
package/dist/consent.d.ts CHANGED
@@ -30,11 +30,21 @@
30
30
  * `.consent`, and the parameter is named for it so a toplevel passed by habit
31
31
  * reads wrong at the call site.
32
32
  *
33
+ * ## ⛔ A SECOND KEY, AND ONE DOOR: `isCaptureAllowed` (0.7.0, D1 · B)
34
+ *
35
+ * A repo can now also be admitted by its SLUG (`owner/repo`) through
36
+ * `grants.json`, so any clone of it matches, not only the one that ran
37
+ * `connect`. Every gate asks `isCaptureAllowed`; `isProjectAllowed` is the
38
+ * common-dir half of it and nothing outside this file calls it
39
+ * (`test/consent-gates.test.ts` scans for exactly that). Two keys behind two
40
+ * doors is how `post_rewrite.ts` was missed once.
41
+ *
33
42
  * ⚠ **No migration for allow lists written under the old key**, and that is a
34
43
  * decision rather than an omission: pre-launch there are zero users and zero
35
44
  * rows (`project_pre_launch_no_data`), so there is nothing to migrate. See
36
45
  * `resolveProjectKeys`.
37
46
  */
47
+ import type { ProjectKeys } from "./project.js";
38
48
  /** One consented project. `at` is informational; presence is the grant. */
39
49
  export interface ProjectGrant {
40
50
  readonly at: string;
@@ -52,6 +62,213 @@ export declare function isProjectAllowed(home: string, consentKey: string | null
52
62
  export declare function grantProject(home: string, consentKey: string, now: Date): void;
53
63
  /** Revoke consent for one project (`vibecommit off`). */
54
64
  export declare function revokeProject(home: string, consentKey: string): boolean;
65
+ /**
66
+ * `grants.json`. ⛔ **Slugs, not paths**: `owner/repo` for GitHub, `host/owner/repo`
67
+ * for any other host (`git.ts`'s `parseRemoteSlug`), so every clone matches.
68
+ *
69
+ * - `owners` — "all repos under this owner, including ones cloned later". An owner is the
70
+ * slug minus its last segment: `alexvibecommit`, or `gitlab.example.com/team`.
71
+ * - `repos` — single repos granted one by one.
72
+ * - `excluded` — `vibecommit off`. **Wins over every other basis**, a surviving common-dir entry included.
73
+ * - `refused` — 0.7.0's field for server refusals, kept so a 0.7.0 client can still read a 0.8.0 file. ⛔ Nothing writes it:
74
+ * slice 2's refusals are written by hooks, and hooks never write this file (see `grants-account.json` below).
75
+ * It is still honoured as a denial if something is in it.
76
+ * - `pending` — slice 2: grant writes this machine has not yet delivered to the account `{ op, kind, value, at }`. The next
77
+ * CLI verb that syncs sends and clears them (`grants_sync.ts`). Absent in a 0.7.0 file, which reads as none.
78
+ *
79
+ * ⛔ **THIS FILE IS CLI-ONLY.** `updateGrants` is an unlocked read-modify-write, so a hook that wrote here could race
80
+ * `vibecommit off` and restore the repo `off` had just turned off. What a hook has to remember lives in `grants-account.json`.
81
+ *
82
+ * `disclosed_at` is when the D65 disclosure was last answered yes for a grant; `null` until then.
83
+ */
84
+ export interface Grants {
85
+ readonly disclosed_at: string | null;
86
+ readonly owners: readonly string[];
87
+ readonly repos: readonly string[];
88
+ readonly excluded: readonly string[];
89
+ readonly refused: readonly string[];
90
+ readonly pending: readonly PendingOp[];
91
+ /**
92
+ * ISO 8601, set ONCE, when this machine's first sync got a 200; `null` before. ⛔ **This is what makes a sync a "first sync"** (it
93
+ * uploads everything this machine already named), and it lives here and not in `grants-account.json` on purpose: that file is
94
+ * ignored when unreadable, and a deleted one must not make the next sync re-grant what was revoked elsewhere. CLI-only.
95
+ * Absent in a 0.7.0 file, which reads as `null`.
96
+ */
97
+ readonly synced_at: string | null;
98
+ }
99
+ /** One queued write to the account's grants — the shape the server's sync endpoint takes, plus when it was queued. */
100
+ export interface PendingOp {
101
+ readonly op: "grant" | "revoke" | "exclude" | "include";
102
+ /** `exclude` and `include` are repo-only. The kind cannot be read off the value (`acme/api` and `gitlab.example.com/team` are both one slash). */
103
+ readonly kind: "owner" | "repo";
104
+ readonly value: string;
105
+ /** ISO 8601, when it was queued. */
106
+ readonly at: string;
107
+ }
108
+ export declare const EMPTY_GRANTS: Grants;
109
+ /**
110
+ * ⛔ **THE ONE NORMALIZATION.** The whole host-qualified slug, lowercased, for every host — the identity the
111
+ * server already stores (`resolve_repository` sets `v_slug := lower(trim(...))`). Owners, repos, exclusions,
112
+ * every `grants.json` write and slice 2's server comparison all go through this, so two clones whose remotes
113
+ * differ only in case are one repo on both sides.
114
+ */
115
+ export declare function normalizeSlug(slug: string): string;
116
+ /** An owner as stored: normalized, and without the trailing `/` the picker displays (`alexvibecommit/`). */
117
+ export declare function normalizeOwner(owner: string): string;
118
+ /** `local:` slugs name a path, not a repo: no grant can match one, and their consent stays per clone. */
119
+ export declare function isLocalSlug(slug: string): boolean;
120
+ /** The owner part of a slug: everything before the last `/`. `""` when there is none. */
121
+ export declare function ownerOfSlug(slug: string): string;
122
+ /**
123
+ * What reading `grants.json` found. ⛔ **Three states, because two of them mean opposite things.**
124
+ *
125
+ * - `missing` — no file. Every machine upgrading from 0.6.x is here, and their common-dir consents
126
+ * must keep capturing.
127
+ * - `unreadable` — a file is there and cannot be read or does not parse. It may hold exclusions we cannot
128
+ * see, so it DENIES every clone that has a slug (`isCaptureAllowed`). `status` says so
129
+ * loudly, since a hook cannot.
130
+ * - `ok` — parsed and normalized.
131
+ */
132
+ export type GrantsRead = {
133
+ readonly kind: "missing";
134
+ } | {
135
+ readonly kind: "unreadable";
136
+ } | {
137
+ readonly kind: "ok";
138
+ readonly grants: Grants;
139
+ };
140
+ export declare function readGrants(home: string): GrantsRead;
141
+ /** True when a `grants.json` is present and unusable — the state `status` reports and a SessionStart notice names. */
142
+ export declare function grantsUnreadable(home: string): boolean;
143
+ /**
144
+ * The account's grants as the last sync or GET returned them. ⛔ **It never admits anything** (R3-22): a repo the account
145
+ * grants is captured on this machine only once THIS machine names it, in `grants.json`. `excluded` here does DENY — a repo
146
+ * turned off from another machine or the dashboard stops here too.
147
+ */
148
+ export interface AccountGrants {
149
+ readonly owners: readonly string[];
150
+ readonly repos: readonly string[];
151
+ readonly excluded: readonly string[];
152
+ /** The server's `epoch` for the user's grants when this was read. */
153
+ readonly epoch: number;
154
+ /** ISO 8601, when this machine got it. */
155
+ readonly synced_at: string;
156
+ }
157
+ /**
158
+ * `grants-account.json`.
159
+ *
160
+ * - `account` — the last synced account state, or `null` before the first sync.
161
+ * - `refused` — slugs the server refused with `repository_excluded` since the last sync. Denied here; a sync clears it.
162
+ * - `stale` — an `X-Grant-Epoch` differed from `account.epoch` (`noteGrantEpoch`); SessionStart's one GET reads it and clears it.
163
+ * - `last_get_at` — when SessionStart last did that GET, for its hourly limit.
164
+ *
165
+ * ⛔ **HOOKS WRITE THIS FILE AND NEVER `grants.json`.** It is written WHOLE and atomically (temp file + rename, 0600) and never
166
+ * through `updateGrants`. Two writers can still lose each other's update (the same unlocked read-modify-write), and that is
167
+ * safe by design: a lost `refused` slug means the server refuses it once more, and a lost `account` is read back by the next
168
+ * sync. Nothing in here can admit a repo, so nothing a race loses can capture one.
169
+ *
170
+ * ⛔ **An unreadable or corrupt file is IGNORED — it reads as empty, never as deny-all.** The opposite of `grants.json`, for the
171
+ * opposite reason: `grants.json` holds the user's own exclusions, which a deny-all protects; this file holds a cache and a
172
+ * server's refusals, and a machine that can't read it just asks the server again.
173
+ */
174
+ export interface AccountState {
175
+ readonly account: AccountGrants | null;
176
+ readonly refused: readonly string[];
177
+ readonly stale: boolean;
178
+ readonly last_get_at: string | null;
179
+ }
180
+ export declare const EMPTY_ACCOUNT_STATE: AccountState;
181
+ /** Read it. **NEVER THROWS, and any failure — missing, unreadable, not JSON, any field off-shape — is the empty state.** */
182
+ export declare function readAccountState(home: string): AccountState;
183
+ /** Write it whole: normalized, atomic, 0600. Throws on an I/O failure (the destination is then untouched). */
184
+ export declare function writeAccountState(home: string, state: AccountState): void;
185
+ /**
186
+ * A hook saw `repository_excluded` for this slug: remember it, so this machine stops reading that repo's transcripts until
187
+ * the next sync says otherwise. **NEVER THROWS** — it runs on the hook's path, where a failed write only means the server
188
+ * refuses the next upload too. `local:` slugs are never recorded (there is no repo identity to refuse).
189
+ */
190
+ export declare function recordRefused(home: string, slug: string): void;
191
+ /**
192
+ * A hook saw `X-Grant-Epoch` on a server answer (an ingest 2xx, or a grants refusal): if it is not the epoch this machine last
193
+ * recorded, mark the account state `stale`, which is what SessionStart's one bounded `GET` looks for (`session_grants.ts`).
194
+ * A machine with no recorded account state counts as epoch 0, as the server does when a user has no grants row.
195
+ *
196
+ * Writes `grants-account.json` and never `grants.json` (see `AccountState`), only when something changed — so a Stop hook that sees
197
+ * the same epoch every turn does no file write at all — and only ever sets `stale`; the GET (or a sync) clears it. **NEVER THROWS**:
198
+ * it runs on the hook's path, and a lost mark only means the next answer marks it again.
199
+ */
200
+ export declare function noteGrantEpoch(home: string, epoch: number | null | undefined): void;
201
+ /** A slug now, or a way to get it. The thunk is for the hooks: see `isCaptureAllowed`. */
202
+ export type SlugSource = string | (() => string);
203
+ /**
204
+ * Does the hold of `isCaptureAllowed` (step 3) apply to ANYTHING on this machine right now? True when the machine has synced
205
+ * (`synced_at` is set) and a queued `grant` op names an owner or a repo the last synced account state does not already grant
206
+ * (the same two conditions the per-slug hold uses: a synced machine, and the account not already admitting it). "0.8.0 truth
207
+ * fixes" R2-2: `connect`'s untold line says sessions wait only when something actually waits. Reads `grants.json` and
208
+ * `grants-account.json`; an unreadable `grants.json` holds nothing here (the gate denies for its own reason).
209
+ */
210
+ export declare function holdsAny(home: string): boolean;
211
+ /**
212
+ * ⛔ **THE ONE ADMISSION DECISION.** Every gate calls this; `entry.ts`, `post_commit.ts`, `post_rewrite.ts` (twice),
213
+ * `finalize.ts`, `status.ts` and `connect.ts` are the seven call sites. Absence is a NO and every error path denies.
214
+ *
215
+ * Precedence:
216
+ * 1. A MISSING `grants.json` is empty (0.6.x machines keep capturing on their common-dir consent). A PRESENT file
217
+ * that cannot be read denies every clone that has a slug, whatever `projects.json` says.
218
+ * 2. A denied slug is denied, whatever the basis: `grants.json`'s `excluded` (and its legacy `refused`), the account file's
219
+ * `refused`, or the account's own `excluded`.
220
+ * 3. On a machine that HAS SYNCED (`grants.synced_at` is set), a slug with a `grant` still PENDING (queued, not yet delivered to
221
+ * the account) is held back when that grant is the slug's ONLY local basis, i.e. the clone has no common-dir consent: its
222
+ * spans stay unsent with their offset unchanged, and go once a sync delivers the grant. It is not held either when the last
223
+ * synced account state already grants the repo or its owner (the server admits it, so a queued grant that was refused must
224
+ * not hold it for ever).
225
+ * ⭐ **A MACHINE THAT HAS NEVER SYNCED HOLDS NOTHING** (VQ, "0.8.0 truth fixes" F1): the server refuses only a credential that
226
+ * has synced (`grants_synced_at`), so before the first sync the hold protects nothing and would only defer uploads, for ever
227
+ * against a server without the routes. Such a machine decides capture by its own choices, as under 0.7.0.
228
+ * ⭐ **WHY A COMMON-DIR CONSENT LIFTS THE HOLD** (VQ's ruling): a remote repo gets a common-dir consent only from pre-0.8.0
229
+ * flows (0.8.0's in-repo `connect` pre-ticks the picker, which writes GRANTS). The first sync uploads those consented slugs, so
230
+ * either this credential has not synced, and the server never refuses anything for it, or the first sync already granted the
231
+ * slug, and holding it would only delay bytes the server accepts.
232
+ * 4. Otherwise: admitted on the common-dir consent, or on a slug matched by `owners` or `repos`. ⛔ The account's own
233
+ * `owners`/`repos` are NOT consulted — a repo only the account grants is not captured on this machine (per-machine naming).
234
+ *
235
+ * `local:` slugs never match a grant and keep common-dir consent alone. They are also not subject to
236
+ * exclusion or to an unreadable file: there is no repo identity to exclude.
237
+ *
238
+ * `grants-account.json` is read on every call and an unreadable one is the empty state, never a denial (`AccountState`).
239
+ *
240
+ * ⚠ **`slug` MAY BE A THUNK, and the hooks pass one.** Computing a slug can spawn `git remote get-url` and `ssh -G`,
241
+ * which is real time on a hook's budget; a machine with no `grants.json` (every 0.6.x machine) needs none of it, and
242
+ * neither does a clone with no consent and no grants to match. The slug is asked for only when the answer depends on it.
243
+ * Pass a memoized thunk (`git.ts`'s `onceRepoSlug`) so the caller's own later use shares the one spawn.
244
+ *
245
+ * `keys` is the `resolveProjectKeys` result the caller already holds, so the confinement roots it derives afterwards
246
+ * come from the very resolution this gate used (`D184 §3`).
247
+ */
248
+ export declare function isCaptureAllowed(home: string, keys: ProjectKeys | null, slug: SlugSource): boolean;
249
+ /** `updateGrants` refuses to rewrite a file it cannot read: overwriting would silently drop exclusions it never saw. */
250
+ export declare class GrantsUnreadableError extends Error {
251
+ constructor();
252
+ }
253
+ /**
254
+ * Read-modify-write `grants.json`. 0700 on the directory, 0600 on the file, atomic (a hook reading while a verb saves
255
+ * sees the old file or the new, never half). Entries are normalized and de-duplicated on the way out.
256
+ *
257
+ * ⛔ **Throws `GrantsUnreadableError` rather than start from empty** when the file is present and unusable: a rewrite
258
+ * would turn "denies everything" into "admits on common-dir consent" without the user having decided anything.
259
+ *
260
+ * ⛔ **Keeps the top-level keys it does not know**, verbatim and uninterpreted, after the five it rebuilds (which always
261
+ * win). A newer version's file read by this one is written back whole: dropping a key it added would silently undo its
262
+ * state. No lock: same read-modify-write as `projects.json`, so two simultaneous verbs can lose one write.
263
+ */
264
+ export declare function updateGrants(home: string, change: (current: Grants) => Grants): Grants;
265
+ /** `vibecommit off`: the slug joins `excluded`. Returns true when that changed the file. */
266
+ export declare function excludeSlug(home: string, slug: string): boolean;
267
+ /**
268
+ * A yes at the disclosure re-includes a turned-off repo; nothing else does. `"none"` when the slug was not excluded
269
+ * (no file is written then), `"unreadable"` when `grants.json` cannot be read.
270
+ */
271
+ export declare function clearExclusion(home: string, slug: string): "cleared" | "none" | "unreadable";
55
272
  /**
56
273
  * Interpret an answer to the consent prompt. **Only an explicit yes is a yes.**
57
274
  * Empty (the user pressed return) is NO, which is what makes `[y/N]` honest.
package/dist/consent.js CHANGED
@@ -30,6 +30,15 @@
30
30
  * `.consent`, and the parameter is named for it so a toplevel passed by habit
31
31
  * reads wrong at the call site.
32
32
  *
33
+ * ## ⛔ A SECOND KEY, AND ONE DOOR: `isCaptureAllowed` (0.7.0, D1 · B)
34
+ *
35
+ * A repo can now also be admitted by its SLUG (`owner/repo`) through
36
+ * `grants.json`, so any clone of it matches, not only the one that ran
37
+ * `connect`. Every gate asks `isCaptureAllowed`; `isProjectAllowed` is the
38
+ * common-dir half of it and nothing outside this file calls it
39
+ * (`test/consent-gates.test.ts` scans for exactly that). Two keys behind two
40
+ * doors is how `post_rewrite.ts` was missed once.
41
+ *
33
42
  * ⚠ **No migration for allow lists written under the old key**, and that is a
34
43
  * decision rather than an omission: pre-launch there are zero users and zero
35
44
  * rows (`project_pre_launch_no_data`), so there is nothing to migrate. See
@@ -37,7 +46,8 @@
37
46
  */
38
47
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
39
48
  import { dirname } from "node:path";
40
- import { projectsPath, rootDir } from "./paths.js";
49
+ import { writeFileAtomic } from "./atomic_write.js";
50
+ import { grantsAccountPath, grantsPath, projectsPath, rootDir } from "./paths.js";
41
51
  /**
42
52
  * Read the allow list. **Every failure returns an empty list**, which means "no
43
53
  * project is consented" — a corrupt or unreadable file must not be able to grant
@@ -100,6 +110,395 @@ export function revokeProject(home, consentKey) {
100
110
  chmodSync(path, 0o600);
101
111
  return true;
102
112
  }
113
+ export const EMPTY_GRANTS = {
114
+ disclosed_at: null,
115
+ owners: [],
116
+ repos: [],
117
+ excluded: [],
118
+ refused: [],
119
+ pending: [],
120
+ synced_at: null,
121
+ };
122
+ /**
123
+ * ⛔ **THE ONE NORMALIZATION.** The whole host-qualified slug, lowercased, for every host — the identity the
124
+ * server already stores (`resolve_repository` sets `v_slug := lower(trim(...))`). Owners, repos, exclusions,
125
+ * every `grants.json` write and slice 2's server comparison all go through this, so two clones whose remotes
126
+ * differ only in case are one repo on both sides.
127
+ */
128
+ export function normalizeSlug(slug) {
129
+ return slug.trim().toLowerCase();
130
+ }
131
+ /** An owner as stored: normalized, and without the trailing `/` the picker displays (`alexvibecommit/`). */
132
+ export function normalizeOwner(owner) {
133
+ return normalizeSlug(owner).replace(/\/+$/, "");
134
+ }
135
+ /** `local:` slugs name a path, not a repo: no grant can match one, and their consent stays per clone. */
136
+ export function isLocalSlug(slug) {
137
+ return slug.startsWith("local:");
138
+ }
139
+ /** The owner part of a slug: everything before the last `/`. `""` when there is none. */
140
+ export function ownerOfSlug(slug) {
141
+ const cut = slug.lastIndexOf("/");
142
+ return cut <= 0 ? "" : slug.slice(0, cut);
143
+ }
144
+ function stringList(value, normalize) {
145
+ if (!Array.isArray(value))
146
+ return null;
147
+ const out = [];
148
+ for (const item of value) {
149
+ if (typeof item !== "string")
150
+ return null;
151
+ const n = normalize(item);
152
+ if (n !== "" && !out.includes(n))
153
+ out.push(n);
154
+ }
155
+ return out;
156
+ }
157
+ const PENDING_OPS = new Set(["grant", "revoke", "exclude", "include"]);
158
+ /**
159
+ * `pending`: ABSENT is none (every 0.7.0 file), but a field that is there and malformed is `null` — the caller reads the
160
+ * whole file as unreadable, the same rule as the other lists, since a queue we cannot read may hold a grant we are holding.
161
+ */
162
+ function parsePending(value) {
163
+ if (value === undefined)
164
+ return [];
165
+ if (!Array.isArray(value))
166
+ return null;
167
+ const out = [];
168
+ for (const item of value) {
169
+ if (item === null || typeof item !== "object" || Array.isArray(item))
170
+ return null;
171
+ const { op, kind, value: v, at } = item;
172
+ if (typeof op !== "string" || !PENDING_OPS.has(op))
173
+ return null;
174
+ if (kind !== "owner" && kind !== "repo")
175
+ return null;
176
+ if (typeof v !== "string" || typeof at !== "string")
177
+ return null;
178
+ if ((op === "exclude" || op === "include") && kind !== "repo")
179
+ return null;
180
+ const clean = kind === "owner" ? normalizeOwner(v) : normalizeSlug(v);
181
+ if (clean === "")
182
+ return null;
183
+ out.push({ op: op, kind, value: clean, at });
184
+ }
185
+ return out;
186
+ }
187
+ /**
188
+ * The seven fields this version knows and REBUILDS on every write (`pending` and `synced_at` are slice 2's); every other top-level key
189
+ * of `grants.json` is carried through untouched.
190
+ */
191
+ const KNOWN_GRANTS_KEYS = new Set(["disclosed_at", "owners", "repos", "excluded", "refused", "pending", "synced_at"]);
192
+ /**
193
+ * `readGrants` plus the top-level keys it does not know, in file order. One read and one parse, so `updateGrants`
194
+ * rewrites exactly the file it read. `extras` is empty unless `read` is `ok`.
195
+ */
196
+ function readGrantsWithExtras(home) {
197
+ const none = [];
198
+ let raw;
199
+ try {
200
+ raw = readFileSync(grantsPath(home), "utf8");
201
+ }
202
+ catch (error) {
203
+ return { read: error.code === "ENOENT" ? { kind: "missing" } : { kind: "unreadable" }, extras: none };
204
+ }
205
+ try {
206
+ const parsed = JSON.parse(raw);
207
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
208
+ return { read: { kind: "unreadable" }, extras: none };
209
+ const o = parsed;
210
+ // A missing list is NOT read as empty: a typo'd `exclude` key would otherwise silently drop every
211
+ // exclusion. All four are required, extra keys are ignored.
212
+ const owners = stringList(o.owners, normalizeOwner);
213
+ const repos = stringList(o.repos, normalizeSlug);
214
+ const excluded = stringList(o.excluded, normalizeSlug);
215
+ const refused = stringList(o.refused, normalizeSlug);
216
+ const pending = parsePending(o.pending);
217
+ const syncedAt = o.synced_at === undefined ? null : o.synced_at;
218
+ const at = o.disclosed_at;
219
+ if (owners === null || repos === null || excluded === null || refused === null || pending === null) {
220
+ return { read: { kind: "unreadable" }, extras: none };
221
+ }
222
+ if (at !== null && typeof at !== "string")
223
+ return { read: { kind: "unreadable" }, extras: none };
224
+ if (syncedAt !== null && typeof syncedAt !== "string")
225
+ return { read: { kind: "unreadable" }, extras: none };
226
+ return {
227
+ read: { kind: "ok", grants: { disclosed_at: at, owners, repos, excluded, refused, pending, synced_at: syncedAt } },
228
+ extras: Object.entries(o).filter(([key]) => !KNOWN_GRANTS_KEYS.has(key)),
229
+ };
230
+ }
231
+ catch {
232
+ return { read: { kind: "unreadable" }, extras: none };
233
+ }
234
+ }
235
+ export function readGrants(home) {
236
+ return readGrantsWithExtras(home).read;
237
+ }
238
+ /** True when a `grants.json` is present and unusable — the state `status` reports and a SessionStart notice names. */
239
+ export function grantsUnreadable(home) {
240
+ return readGrants(home).kind === "unreadable";
241
+ }
242
+ export const EMPTY_ACCOUNT_STATE = { account: null, refused: [], stale: false, last_get_at: null };
243
+ function parseAccountGrants(value) {
244
+ if (value === null)
245
+ return null;
246
+ if (typeof value !== "object" || Array.isArray(value))
247
+ return undefined;
248
+ const o = value;
249
+ const owners = stringList(o.owners, normalizeOwner);
250
+ const repos = stringList(o.repos, normalizeSlug);
251
+ const excluded = stringList(o.excluded, normalizeSlug);
252
+ if (owners === null || repos === null || excluded === null)
253
+ return undefined;
254
+ if (typeof o.epoch !== "number" || !Number.isSafeInteger(o.epoch) || o.epoch < 0)
255
+ return undefined;
256
+ if (typeof o.synced_at !== "string")
257
+ return undefined;
258
+ return { owners, repos, excluded, epoch: o.epoch, synced_at: o.synced_at };
259
+ }
260
+ /** Read it. **NEVER THROWS, and any failure — missing, unreadable, not JSON, any field off-shape — is the empty state.** */
261
+ export function readAccountState(home) {
262
+ try {
263
+ const parsed = JSON.parse(readFileSync(grantsAccountPath(home), "utf8"));
264
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
265
+ return EMPTY_ACCOUNT_STATE;
266
+ const o = parsed;
267
+ const account = o.account === undefined ? null : parseAccountGrants(o.account);
268
+ const refused = o.refused === undefined ? [] : stringList(o.refused, normalizeSlug);
269
+ const last = o.last_get_at === undefined ? null : o.last_get_at;
270
+ if (account === undefined || refused === null)
271
+ return EMPTY_ACCOUNT_STATE;
272
+ if (o.stale !== undefined && typeof o.stale !== "boolean")
273
+ return EMPTY_ACCOUNT_STATE;
274
+ if (last !== null && typeof last !== "string")
275
+ return EMPTY_ACCOUNT_STATE;
276
+ return { account, refused, stale: o.stale === true, last_get_at: last };
277
+ }
278
+ catch {
279
+ return EMPTY_ACCOUNT_STATE;
280
+ }
281
+ }
282
+ /** Write it whole: normalized, atomic, 0600. Throws on an I/O failure (the destination is then untouched). */
283
+ export function writeAccountState(home, state) {
284
+ const clean = {
285
+ account: state.account === null
286
+ ? null
287
+ : {
288
+ owners: [...new Set(state.account.owners.map(normalizeOwner).filter((o) => o !== ""))],
289
+ repos: [...new Set(state.account.repos.map(normalizeSlug).filter((r) => r !== ""))],
290
+ excluded: [...new Set(state.account.excluded.map(normalizeSlug).filter((x) => x !== ""))],
291
+ epoch: state.account.epoch,
292
+ synced_at: state.account.synced_at,
293
+ },
294
+ refused: [...new Set(state.refused.map(normalizeSlug).filter((x) => x !== "" && !isLocalSlug(x)))],
295
+ stale: state.stale,
296
+ last_get_at: state.last_get_at,
297
+ };
298
+ const path = grantsAccountPath(home);
299
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
300
+ writeFileAtomic(path, `${JSON.stringify(clean, null, 2)}\n`, 0o600);
301
+ }
302
+ /**
303
+ * A hook saw `repository_excluded` for this slug: remember it, so this machine stops reading that repo's transcripts until
304
+ * the next sync says otherwise. **NEVER THROWS** — it runs on the hook's path, where a failed write only means the server
305
+ * refuses the next upload too. `local:` slugs are never recorded (there is no repo identity to refuse).
306
+ */
307
+ export function recordRefused(home, slug) {
308
+ const name = normalizeSlug(slug);
309
+ if (name === "" || isLocalSlug(name))
310
+ return;
311
+ try {
312
+ const current = readAccountState(home);
313
+ if (current.refused.includes(name))
314
+ return;
315
+ writeAccountState(home, { ...current, refused: [...current.refused, name] });
316
+ }
317
+ catch {
318
+ // The hook goes on: nothing was written, and the next refusal tries again.
319
+ }
320
+ }
321
+ /**
322
+ * A hook saw `X-Grant-Epoch` on a server answer (an ingest 2xx, or a grants refusal): if it is not the epoch this machine last
323
+ * recorded, mark the account state `stale`, which is what SessionStart's one bounded `GET` looks for (`session_grants.ts`).
324
+ * A machine with no recorded account state counts as epoch 0, as the server does when a user has no grants row.
325
+ *
326
+ * Writes `grants-account.json` and never `grants.json` (see `AccountState`), only when something changed — so a Stop hook that sees
327
+ * the same epoch every turn does no file write at all — and only ever sets `stale`; the GET (or a sync) clears it. **NEVER THROWS**:
328
+ * it runs on the hook's path, and a lost mark only means the next answer marks it again.
329
+ */
330
+ export function noteGrantEpoch(home, epoch) {
331
+ if (epoch === null || epoch === undefined || !Number.isSafeInteger(epoch) || epoch < 0)
332
+ return;
333
+ try {
334
+ const current = readAccountState(home);
335
+ if (current.stale)
336
+ return;
337
+ if (epoch === (current.account?.epoch ?? 0))
338
+ return;
339
+ writeAccountState(home, { ...current, stale: true });
340
+ }
341
+ catch {
342
+ // The hook goes on: nothing was written, and the next answer marks it again.
343
+ }
344
+ }
345
+ /**
346
+ * True when the last synced account state (the last 200) already grants this repo, or its owner. ⚠ Read only to avoid a NEEDLESS
347
+ * hold — say an owner grant was queued and refused with a 400 while the server already admits this repo — and never to admit: the
348
+ * account's grants do not admit anything on this machine.
349
+ */
350
+ function accountGrants(state, name) {
351
+ const a = state.account;
352
+ return a !== null && (a.repos.includes(name) || a.owners.includes(ownerOfSlug(name)));
353
+ }
354
+ /** True when a queued `grant` op names this slug, or its owner. */
355
+ function grantPendingFor(pending, name) {
356
+ const owner = ownerOfSlug(name);
357
+ return pending.some((p) => p.op === "grant" && (p.kind === "repo" ? p.value === name : p.value === owner));
358
+ }
359
+ /**
360
+ * Does the hold of `isCaptureAllowed` (step 3) apply to ANYTHING on this machine right now? True when the machine has synced
361
+ * (`synced_at` is set) and a queued `grant` op names an owner or a repo the last synced account state does not already grant
362
+ * (the same two conditions the per-slug hold uses: a synced machine, and the account not already admitting it). "0.8.0 truth
363
+ * fixes" R2-2: `connect`'s untold line says sessions wait only when something actually waits. Reads `grants.json` and
364
+ * `grants-account.json`; an unreadable `grants.json` holds nothing here (the gate denies for its own reason).
365
+ */
366
+ export function holdsAny(home) {
367
+ const read = readGrants(home);
368
+ if (read.kind !== "ok" || read.grants.synced_at === null)
369
+ return false;
370
+ const state = readAccountState(home);
371
+ // `accountGrants` is the gate's own predicate: an owner op is asked as `owner/` (its owner is `owner`, and no account repo is spelled that way).
372
+ return read.grants.pending.some((p) => p.op === "grant" && !accountGrants(state, p.kind === "repo" ? p.value : `${p.value}/`));
373
+ }
374
+ /**
375
+ * ⛔ **THE ONE ADMISSION DECISION.** Every gate calls this; `entry.ts`, `post_commit.ts`, `post_rewrite.ts` (twice),
376
+ * `finalize.ts`, `status.ts` and `connect.ts` are the seven call sites. Absence is a NO and every error path denies.
377
+ *
378
+ * Precedence:
379
+ * 1. A MISSING `grants.json` is empty (0.6.x machines keep capturing on their common-dir consent). A PRESENT file
380
+ * that cannot be read denies every clone that has a slug, whatever `projects.json` says.
381
+ * 2. A denied slug is denied, whatever the basis: `grants.json`'s `excluded` (and its legacy `refused`), the account file's
382
+ * `refused`, or the account's own `excluded`.
383
+ * 3. On a machine that HAS SYNCED (`grants.synced_at` is set), a slug with a `grant` still PENDING (queued, not yet delivered to
384
+ * the account) is held back when that grant is the slug's ONLY local basis, i.e. the clone has no common-dir consent: its
385
+ * spans stay unsent with their offset unchanged, and go once a sync delivers the grant. It is not held either when the last
386
+ * synced account state already grants the repo or its owner (the server admits it, so a queued grant that was refused must
387
+ * not hold it for ever).
388
+ * ⭐ **A MACHINE THAT HAS NEVER SYNCED HOLDS NOTHING** (VQ, "0.8.0 truth fixes" F1): the server refuses only a credential that
389
+ * has synced (`grants_synced_at`), so before the first sync the hold protects nothing and would only defer uploads, for ever
390
+ * against a server without the routes. Such a machine decides capture by its own choices, as under 0.7.0.
391
+ * ⭐ **WHY A COMMON-DIR CONSENT LIFTS THE HOLD** (VQ's ruling): a remote repo gets a common-dir consent only from pre-0.8.0
392
+ * flows (0.8.0's in-repo `connect` pre-ticks the picker, which writes GRANTS). The first sync uploads those consented slugs, so
393
+ * either this credential has not synced, and the server never refuses anything for it, or the first sync already granted the
394
+ * slug, and holding it would only delay bytes the server accepts.
395
+ * 4. Otherwise: admitted on the common-dir consent, or on a slug matched by `owners` or `repos`. ⛔ The account's own
396
+ * `owners`/`repos` are NOT consulted — a repo only the account grants is not captured on this machine (per-machine naming).
397
+ *
398
+ * `local:` slugs never match a grant and keep common-dir consent alone. They are also not subject to
399
+ * exclusion or to an unreadable file: there is no repo identity to exclude.
400
+ *
401
+ * `grants-account.json` is read on every call and an unreadable one is the empty state, never a denial (`AccountState`).
402
+ *
403
+ * ⚠ **`slug` MAY BE A THUNK, and the hooks pass one.** Computing a slug can spawn `git remote get-url` and `ssh -G`,
404
+ * which is real time on a hook's budget; a machine with no `grants.json` (every 0.6.x machine) needs none of it, and
405
+ * neither does a clone with no consent and no grants to match. The slug is asked for only when the answer depends on it.
406
+ * Pass a memoized thunk (`git.ts`'s `onceRepoSlug`) so the caller's own later use shares the one spawn.
407
+ *
408
+ * `keys` is the `resolveProjectKeys` result the caller already holds, so the confinement roots it derives afterwards
409
+ * come from the very resolution this gate used (`D184 §3`).
410
+ */
411
+ export function isCaptureAllowed(home, keys, slug) {
412
+ if (keys === null)
413
+ return false;
414
+ const byClone = isProjectAllowed(home, keys.consent);
415
+ const read = readGrants(home);
416
+ const resolve = () => normalizeSlug(typeof slug === "function" ? slug() : slug);
417
+ if (read.kind === "unreadable")
418
+ return isLocalSlug(resolve()) ? byClone : false;
419
+ const grants = read.kind === "ok" ? read.grants : EMPTY_GRANTS;
420
+ const account = readAccountState(home);
421
+ const denyLists = [grants.excluded, grants.refused, account.refused, account.account?.excluded ?? []];
422
+ const mayDeny = denyLists.some((list) => list.length > 0);
423
+ const mayHold = !byClone && grants.synced_at !== null && grants.pending.some((p) => p.op === "grant");
424
+ if (!mayDeny && !mayHold && (byClone || (grants.owners.length === 0 && grants.repos.length === 0)))
425
+ return byClone;
426
+ const name = resolve();
427
+ if (isLocalSlug(name))
428
+ return byClone;
429
+ if (denyLists.some((list) => list.includes(name)))
430
+ return false;
431
+ if (!byClone && grants.synced_at !== null && grantPendingFor(grants.pending, name) && !accountGrants(account, name))
432
+ return false;
433
+ return byClone || grants.repos.includes(name) || grants.owners.includes(ownerOfSlug(name));
434
+ }
435
+ /** `updateGrants` refuses to rewrite a file it cannot read: overwriting would silently drop exclusions it never saw. */
436
+ export class GrantsUnreadableError extends Error {
437
+ constructor() {
438
+ super("grants.json is present and cannot be read");
439
+ this.name = "GrantsUnreadableError";
440
+ }
441
+ }
442
+ /**
443
+ * Read-modify-write `grants.json`. 0700 on the directory, 0600 on the file, atomic (a hook reading while a verb saves
444
+ * sees the old file or the new, never half). Entries are normalized and de-duplicated on the way out.
445
+ *
446
+ * ⛔ **Throws `GrantsUnreadableError` rather than start from empty** when the file is present and unusable: a rewrite
447
+ * would turn "denies everything" into "admits on common-dir consent" without the user having decided anything.
448
+ *
449
+ * ⛔ **Keeps the top-level keys it does not know**, verbatim and uninterpreted, after the five it rebuilds (which always
450
+ * win). A newer version's file read by this one is written back whole: dropping a key it added would silently undo its
451
+ * state. No lock: same read-modify-write as `projects.json`, so two simultaneous verbs can lose one write.
452
+ */
453
+ export function updateGrants(home, change) {
454
+ const { read, extras } = readGrantsWithExtras(home);
455
+ if (read.kind === "unreadable")
456
+ throw new GrantsUnreadableError();
457
+ const next = change(read.kind === "ok" ? read.grants : EMPTY_GRANTS);
458
+ const clean = {
459
+ disclosed_at: next.disclosed_at,
460
+ owners: [...new Set(next.owners.map(normalizeOwner).filter((o) => o !== ""))],
461
+ repos: [...new Set(next.repos.map(normalizeSlug).filter((r) => r !== ""))],
462
+ excluded: [...new Set(next.excluded.map(normalizeSlug).filter((x) => x !== ""))],
463
+ refused: [...new Set(next.refused.map(normalizeSlug).filter((x) => x !== ""))],
464
+ pending: next.pending.flatMap((p) => {
465
+ const value = p.kind === "owner" ? normalizeOwner(p.value) : normalizeSlug(p.value);
466
+ return value === "" ? [] : [{ ...p, value }];
467
+ }),
468
+ synced_at: next.synced_at,
469
+ };
470
+ const path = grantsPath(home);
471
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
472
+ chmodSync(rootDir(home), 0o700);
473
+ // `Object.fromEntries` defines own properties, so even a key named `__proto__` is written back as data.
474
+ const out = Object.fromEntries([...Object.entries(clean), ...extras]);
475
+ writeFileAtomic(path, `${JSON.stringify(out, null, 2)}\n`, 0o600);
476
+ return clean;
477
+ }
478
+ /** `vibecommit off`: the slug joins `excluded`. Returns true when that changed the file. */
479
+ export function excludeSlug(home, slug) {
480
+ const name = normalizeSlug(slug);
481
+ let changed = false;
482
+ updateGrants(home, (g) => {
483
+ changed = !g.excluded.includes(name);
484
+ return changed ? { ...g, excluded: [...g.excluded, name] } : g;
485
+ });
486
+ return changed;
487
+ }
488
+ /**
489
+ * A yes at the disclosure re-includes a turned-off repo; nothing else does. `"none"` when the slug was not excluded
490
+ * (no file is written then), `"unreadable"` when `grants.json` cannot be read.
491
+ */
492
+ export function clearExclusion(home, slug) {
493
+ const name = normalizeSlug(slug);
494
+ const read = readGrants(home);
495
+ if (read.kind === "unreadable")
496
+ return "unreadable";
497
+ if (read.kind === "missing" || !read.grants.excluded.includes(name))
498
+ return "none";
499
+ updateGrants(home, (g) => ({ ...g, excluded: g.excluded.filter((x) => x !== name) }));
500
+ return "cleared";
501
+ }
103
502
  /**
104
503
  * Interpret an answer to the consent prompt. **Only an explicit yes is a yes.**
105
504
  * Empty (the user pressed return) is NO, which is what makes `[y/N]` honest.