@vibe-commit/capture 0.7.0 → 0.9.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 (152) hide show
  1. package/README.md +238 -7
  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/agents/claude_code.js +13 -0
  6. package/dist/agents/claude_code.js.map +1 -1
  7. package/dist/agents/codex_cli.js +11 -0
  8. package/dist/agents/codex_cli.js.map +1 -1
  9. package/dist/agents/cursor.js +7 -1
  10. package/dist/agents/cursor.js.map +1 -1
  11. package/dist/agents/registry.d.ts +7 -0
  12. package/dist/agents/registry.js +9 -0
  13. package/dist/agents/registry.js.map +1 -1
  14. package/dist/agents/types.d.ts +32 -0
  15. package/dist/agents/types.js.map +1 -1
  16. package/dist/bounded_id.d.ts +29 -0
  17. package/dist/bounded_id.js +42 -0
  18. package/dist/bounded_id.js.map +1 -0
  19. package/dist/codex_folders.d.ts +340 -0
  20. package/dist/codex_folders.js +1448 -0
  21. package/dist/codex_folders.js.map +1 -0
  22. package/dist/commands/auth.d.ts +7 -0
  23. package/dist/commands/auth.js +21 -0
  24. package/dist/commands/auth.js.map +1 -1
  25. package/dist/commands/backfill.d.ts +29 -7
  26. package/dist/commands/backfill.js +387 -109
  27. package/dist/commands/backfill.js.map +1 -1
  28. package/dist/commands/browser_credential.d.ts +4 -2
  29. package/dist/commands/browser_credential.js +5 -2
  30. package/dist/commands/browser_credential.js.map +1 -1
  31. package/dist/commands/connect.d.ts +10 -1
  32. package/dist/commands/connect.js +14 -9
  33. package/dist/commands/connect.js.map +1 -1
  34. package/dist/commands/connect_account.d.ts +135 -0
  35. package/dist/commands/connect_account.js +321 -0
  36. package/dist/commands/connect_account.js.map +1 -0
  37. package/dist/commands/connect_picker.js +204 -29
  38. package/dist/commands/connect_picker.js.map +1 -1
  39. package/dist/commands/device_credential.d.ts +3 -1
  40. package/dist/commands/device_credential.js +1 -0
  41. package/dist/commands/device_credential.js.map +1 -1
  42. package/dist/commands/finalize.d.ts +67 -0
  43. package/dist/commands/finalize.js +450 -114
  44. package/dist/commands/finalize.js.map +1 -1
  45. package/dist/commands/finish_mint.d.ts +28 -0
  46. package/dist/commands/finish_mint.js +16 -4
  47. package/dist/commands/finish_mint.js.map +1 -1
  48. package/dist/commands/scan.d.ts +28 -0
  49. package/dist/commands/scan.js +11 -0
  50. package/dist/commands/scan.js.map +1 -1
  51. package/dist/commands/status.d.ts +19 -1
  52. package/dist/commands/status.js +123 -14
  53. package/dist/commands/status.js.map +1 -1
  54. package/dist/commands/status_machine.d.ts +12 -3
  55. package/dist/commands/status_machine.js +111 -13
  56. package/dist/commands/status_machine.js.map +1 -1
  57. package/dist/commands/sync_reason.d.ts +17 -0
  58. package/dist/commands/sync_reason.js +25 -0
  59. package/dist/commands/sync_reason.js.map +1 -0
  60. package/dist/consent.d.ts +131 -3
  61. package/dist/consent.js +248 -14
  62. package/dist/consent.js.map +1 -1
  63. package/dist/copy/strings.d.ts +165 -4
  64. package/dist/copy/strings.js +190 -7
  65. package/dist/copy/strings.js.map +1 -1
  66. package/dist/folder_grants.d.ts +44 -0
  67. package/dist/folder_grants.js +101 -0
  68. package/dist/folder_grants.js.map +1 -0
  69. package/dist/git.d.ts +26 -4
  70. package/dist/git.js +82 -11
  71. package/dist/git.js.map +1 -1
  72. package/dist/grants_sync.d.ts +165 -0
  73. package/dist/grants_sync.js +289 -0
  74. package/dist/grants_sync.js.map +1 -0
  75. package/dist/grants_verb.d.ts +70 -0
  76. package/dist/grants_verb.js +121 -0
  77. package/dist/grants_verb.js.map +1 -0
  78. package/dist/hooks/entry.d.ts +30 -50
  79. package/dist/hooks/entry.js +601 -561
  80. package/dist/hooks/entry.js.map +1 -1
  81. package/dist/hooks/fire_lock.d.ts +31 -0
  82. package/dist/hooks/fire_lock.js +120 -12
  83. package/dist/hooks/fire_lock.js.map +1 -1
  84. package/dist/hooks/session_grants.d.ts +59 -0
  85. package/dist/hooks/session_grants.js +91 -0
  86. package/dist/hooks/session_grants.js.map +1 -0
  87. package/dist/hooks/session_start.d.ts +28 -7
  88. package/dist/hooks/session_start.js +55 -11
  89. package/dist/hooks/session_start.js.map +1 -1
  90. package/dist/index.js +22 -5
  91. package/dist/index.js.map +1 -1
  92. package/dist/machine_state.d.ts +44 -1
  93. package/dist/machine_state.js +75 -12
  94. package/dist/machine_state.js.map +1 -1
  95. package/dist/oauth/discovery.js +2 -1
  96. package/dist/oauth/discovery.js.map +1 -1
  97. package/dist/oauth/pkce.js +2 -1
  98. package/dist/oauth/pkce.js.map +1 -1
  99. package/dist/oauth/retire.d.ts +10 -0
  100. package/dist/oauth/retire.js +13 -0
  101. package/dist/oauth/retire.js.map +1 -1
  102. package/dist/paths.d.ts +54 -1
  103. package/dist/paths.js +88 -12
  104. package/dist/paths.js.map +1 -1
  105. package/dist/pending_revoke.d.ts +117 -0
  106. package/dist/pending_revoke.js +233 -0
  107. package/dist/pending_revoke.js.map +1 -0
  108. package/dist/pending_revoke_file.d.ts +55 -0
  109. package/dist/pending_revoke_file.js +74 -0
  110. package/dist/pending_revoke_file.js.map +1 -0
  111. package/dist/physical_path.d.ts +46 -0
  112. package/dist/physical_path.js +151 -0
  113. package/dist/physical_path.js.map +1 -0
  114. package/dist/picker.d.ts +82 -5
  115. package/dist/picker.js +183 -10
  116. package/dist/picker.js.map +1 -1
  117. package/dist/picker_checklist.d.ts +27 -3
  118. package/dist/picker_checklist.js +81 -6
  119. package/dist/picker_checklist.js.map +1 -1
  120. package/dist/policy.d.ts +24 -1
  121. package/dist/policy.js +30 -2
  122. package/dist/policy.js.map +1 -1
  123. package/dist/post.d.ts +55 -2
  124. package/dist/post.js +72 -22
  125. package/dist/post.js.map +1 -1
  126. package/dist/project.d.ts +89 -0
  127. package/dist/project.js +123 -0
  128. package/dist/project.js.map +1 -1
  129. package/dist/redact.d.ts +77 -2
  130. package/dist/redact.js +713 -47
  131. package/dist/redact.js.map +1 -1
  132. package/dist/session_targets.d.ts +713 -0
  133. package/dist/session_targets.js +2602 -0
  134. package/dist/session_targets.js.map +1 -0
  135. package/dist/spool.d.ts +12 -0
  136. package/dist/spool.js +29 -1
  137. package/dist/spool.js.map +1 -1
  138. package/dist/spool_union.d.ts +51 -0
  139. package/dist/spool_union.js +57 -0
  140. package/dist/spool_union.js.map +1 -0
  141. package/dist/state.d.ts +5 -0
  142. package/dist/state.js +19 -1
  143. package/dist/state.js.map +1 -1
  144. package/dist/system_message.d.ts +25 -2
  145. package/dist/system_message.js +29 -5
  146. package/dist/system_message.js.map +1 -1
  147. package/dist/text_tail.d.ts +8 -0
  148. package/dist/text_tail.js +19 -0
  149. package/dist/text_tail.js.map +1 -0
  150. package/dist/version.d.ts +1 -1
  151. package/dist/version.js +1 -1
  152. package/package.json +3 -3
package/dist/picker.d.ts CHANGED
@@ -12,14 +12,19 @@
12
12
  * typing `-N`, deletes the grant. It is not an exclusion (`off` is), so the repo can be chosen again.
13
13
  * owners one group per owner NOT yet granted, its repos behind an `aN` "all, including repos you clone later"
14
14
  * row (D9: at most 8 repos before `+ N more`).
15
+ * folders (any-folder capture, R3) folders outside every work tree that hold sessions, one `Folders` group. A folder grant
16
+ * captures the sessions there that touch no repository, under a `local:folder:` identity that only the uploader
17
+ * can read; its grant line SAYS so ("private to you", R7). Granted folders are `on now` rows, removable like any.
15
18
  * turned off slugs in `excluded`. Choosing one is a re-include; the grant block names it on a `turn on` row, inside
16
19
  * the same y/N (D7 · 3A), and only a yes clears the exclusion.
20
+ * on your account (slice 2) what the account grants that this machine has not named. Unticked until chosen, like any
21
+ * row: choosing one is an ordinary addition, so it goes through the same disclosure and y/N (R3-8, R3-9).
17
22
  *
18
23
  * ⛔ **A pre-tick is not a consent** (D34): the working folder's repo starts ticked, and Enter on it leads to the naming
19
24
  * y/N, which defaults to N. It is an ADDITION to the diff, never part of the baseline.
20
25
  */
21
26
  import { type Grants } from "./consent.js";
22
- import type { ScanClone, ScanRepo } from "./commands/scan.js";
27
+ import type { ScanClone, ScanFolder, ScanRepo } from "./commands/scan.js";
23
28
  import type { AgentId } from "./agents/types.js";
24
29
  /** D9: each owner group shows at most this many repos before the `+ N more` row. */
25
30
  export declare const GROUP_CAP = 8;
@@ -41,11 +46,22 @@ export interface PickRepo extends Counts {
41
46
  /** Starts ticked in the checklist. Only ever the working folder's repo, and only when it is not `$HOME`. */
42
47
  readonly pre: boolean;
43
48
  }
49
+ /** A folder the person can grant: outside every work tree, with sessions. `label` is how the row names it (`~/Documents/work`). */
50
+ export interface PickFolder extends Counts {
51
+ /** The REAL path: the key of the grant. */
52
+ readonly path: string;
53
+ readonly label: string;
54
+ readonly latestMs: number;
55
+ /** The person turned this folder off (or declined it). It is offered again, and choosing it turns it back on. */
56
+ readonly excluded?: boolean;
57
+ }
44
58
  /** One `on now` row. `kind` says what removing it deletes. */
45
59
  export interface OnNowRow {
46
- readonly kind: "owner" | "repo" | "clone";
47
- /** `alexvibecommit` (owner) or a slug. */
60
+ readonly kind: "owner" | "repo" | "clone" | "folder";
61
+ /** `alexvibecommit` (owner), a slug, or a folder's REAL path. */
48
62
  readonly key: string;
63
+ /** How a `folder` row is named on screen (its `key` is the path). */
64
+ readonly label?: string;
49
65
  readonly reason: string;
50
66
  readonly counts: Counts | null;
51
67
  /** Repos found here under an owner row. */
@@ -66,10 +82,19 @@ export interface TurnedOffRow {
66
82
  /** The working folder's repo, turned off: `connect` inside it pre-selects the `turn on` (D34 + the re-include rule). */
67
83
  readonly pre: boolean;
68
84
  }
85
+ /** One account grant this machine has not named (slice 2): an owner (`key` without its slash) or a repo (`key` is the slug). */
86
+ export interface AccountRow {
87
+ readonly kind: "owner" | "repo";
88
+ readonly key: string;
89
+ }
69
90
  export interface PickerModel {
70
91
  readonly onNow: readonly OnNowRow[];
71
92
  readonly owners: readonly OwnerGroup[];
93
+ /** What the account grants that this machine has not named. Empty before a sync, or on a machine with no credential. */
94
+ readonly account: readonly AccountRow[];
72
95
  readonly turnedOff: readonly TurnedOffRow[];
96
+ /** Folders outside every work tree that hold sessions and are not yet granted, newest first (R3). */
97
+ readonly folders: readonly PickFolder[];
73
98
  /** The picker's `Not listed here:` lines (no remote, can't be matched, unreadable agents). */
74
99
  readonly notListed: readonly string[];
75
100
  /** Repos the scan listed, for the `Found N repositories` line. */
@@ -86,6 +111,9 @@ export interface PickerChange {
86
111
  readonly stopRepos: readonly string[];
87
112
  /** The slugs of per-clone consents to withdraw. */
88
113
  readonly stopClones: readonly string[];
114
+ /** Folder grants to add / withdraw, by REAL path. Optional so a change built before folders existed still types. */
115
+ readonly addFolders?: readonly string[];
116
+ readonly stopFolders?: readonly string[];
89
117
  }
90
118
  export declare const NO_CHANGE: PickerChange;
91
119
  export declare function isNoChange(change: PickerChange): boolean;
@@ -114,7 +142,34 @@ export interface ModelInput {
114
142
  readonly notListed: readonly string[];
115
143
  readonly sawNothing: boolean;
116
144
  readonly here: HereRepo | null;
145
+ /** The account's grants this machine has not named, as `accountView` lists them: `owner/` for an owner, the slug for a repo. */
146
+ readonly accountNotHere?: readonly string[];
147
+ /** Folders outside every work tree that hold sessions (`ScanResult.folders`). */
148
+ readonly folders?: readonly ScanFolder[];
149
+ /** The REAL paths of the folders already granted (`folders.json`). */
150
+ readonly grantedFolders?: readonly string[];
151
+ /** The REAL paths of the folders turned off or declined: offered again, marked, so they can be granted later. */
152
+ readonly excludedFolders?: readonly string[];
153
+ /** How a folder path is shown (`~`-relative). Default: the path as is. */
154
+ readonly folderLabel?: (path: string) => string;
155
+ /**
156
+ * C34: may this agent's sessions be captured under a folder? Default `agentIsolatesRecords`, the registry value the resolver gates on.
157
+ * A seam so a cell can flip one agent and prove the picker reads the predicate rather than a list.
158
+ */
159
+ readonly isolates?: (agent: AgentId) => boolean;
117
160
  }
161
+ /**
162
+ * The folder as a FOLDER GRANT would capture it (C34): only the sessions of agents whose records can be isolated count, because a
163
+ * Cursor session (or a Codex one, until its isolation lands) under a granted folder is never captured, and a count or an offer that
164
+ * included it would promise what does not happen. `null` when no session passes. Times and counts are recomputed from the members
165
+ * when the scan kept them; a hand-built folder without members keeps its own times and zeroes the agents that fail.
166
+ */
167
+ export declare function capturableFolder(folder: ScanFolder, isolates?: (agent: AgentId) => boolean): ScanFolder | null;
168
+ /**
169
+ * The agents a folder grant captures, as the copy names them: `Claude Code`, `Claude Code or Codex`, `Claude Code, Codex or Cursor`.
170
+ * Built from the same predicate as the offers (C34), so the sentence and the behaviour cannot disagree.
171
+ */
172
+ export declare function folderCaptureAgents(isolates?: (agent: AgentId) => boolean): string;
118
173
  export declare function buildModel(input: ModelInput): PickerModel;
119
174
  /** What a number on screen stands for. On-now rows come first, then each owner's repos, then turned-off. */
120
175
  export type RowRef = {
@@ -123,6 +178,12 @@ export type RowRef = {
123
178
  } | {
124
179
  readonly kind: "repo";
125
180
  readonly repo: PickRepo;
181
+ } | {
182
+ readonly kind: "folder";
183
+ readonly folder: PickFolder;
184
+ } | {
185
+ readonly kind: "account";
186
+ readonly row: AccountRow;
126
187
  } | {
127
188
  readonly kind: "off";
128
189
  readonly row: TurnedOffRow;
@@ -136,6 +197,10 @@ export type Parsed = {
136
197
  readonly kind: "empty";
137
198
  } | {
138
199
  readonly kind: "more";
200
+ }
201
+ /** `g`, alone, where it was offered (slice 2, exception 3): use the repositories the account already grants. */
202
+ | {
203
+ readonly kind: "account";
139
204
  } | {
140
205
  readonly kind: "change";
141
206
  readonly change: PickerChange;
@@ -148,13 +213,19 @@ export type Parsed = {
148
213
  * Parse one typed answer (D8 · 4A). ⛔ **Never applies part of an answer:** any token that is not understood returns
149
214
  * `error` naming each such token, and nothing is chosen. `owner/` and `owner/repo` are always accepted, a clone here or
150
215
  * not, because a new laptop needs them; the grant block flags `(no clone here yet)`.
216
+ *
217
+ * ⛔ **`g` STANDS ALONE** (exception 3, R3-12): where `offerAccount` is true, `g` alone is the account's grants, and `g` mixed with
218
+ * any other token is refused with nothing applied, so the y/N after it always names a single list. Where it was not offered `g` is
219
+ * an ordinary token nobody understands.
151
220
  */
152
- export declare function parseTyped(input: string, model: PickerModel, showAll: boolean): Parsed;
221
+ export declare function parseTyped(input: string, model: PickerModel, showAll: boolean, offerAccount?: boolean): Parsed;
153
222
  export interface RenderOptions {
154
223
  readonly colour: boolean;
155
224
  readonly nowMs: number;
156
225
  /** Show every repo, numbered afresh (the `more` token). */
157
226
  readonly showAll: boolean;
227
+ /** Slice 2: the prompt block's `g` line (screen 1a). Only on a machine with no credential, a terminal and a browser to sign in with. */
228
+ readonly offerAccount?: boolean;
158
229
  }
159
230
  /** The `Not listed here:` block, muted, or nothing. */
160
231
  export declare function notListedBlock(model: PickerModel, colour: boolean): string[];
@@ -168,13 +239,19 @@ export interface BlockFacts {
168
239
  readonly reposHere: ReadonlyMap<string, number>;
169
240
  /** Typed `owner/` and `owner/repo` with no clone here. */
170
241
  readonly noClone: ReadonlySet<string>;
242
+ /** `turn on` slugs the ACCOUNT has turned off: the row says so, because the yes re-includes them on every machine. */
243
+ readonly accountOff?: ReadonlySet<string>;
244
+ /** How each folder in the change is named (`~`-relative); a folder with no entry shows its path. */
245
+ readonly folderLabels?: ReadonlyMap<string, string>;
171
246
  }
172
247
  /**
173
248
  * The `You chose:` block — the thing the y/N is asking about (D7 · 3A), on the §13.5 label gutter. One label per kind of
174
249
  * change; an owner row says how many repos are here and that later clones are covered, and a typed name with no clone
175
250
  * on this machine says so, so a typo shows BEFORE the question.
176
251
  */
177
- export declare function renderGrantBlock(change: PickerChange, facts: BlockFacts, colour: boolean): string[];
252
+ export declare function renderGrantBlock(change: PickerChange, facts: BlockFacts, colour: boolean,
253
+ /** `You chose:` for the person's picks; the account's own list (`g`, screen 1b) is headed `Your account grants:`. */
254
+ heading?: string): string[];
178
255
  /** The grant y/N: additions (with or without removals) get the upload question, removals alone the stop question. */
179
256
  export declare function grantQuestion(change: PickerChange, colour: boolean, answers: string): string;
180
257
  export {};
package/dist/picker.js CHANGED
@@ -12,14 +12,20 @@
12
12
  * typing `-N`, deletes the grant. It is not an exclusion (`off` is), so the repo can be chosen again.
13
13
  * owners one group per owner NOT yet granted, its repos behind an `aN` "all, including repos you clone later"
14
14
  * row (D9: at most 8 repos before `+ N more`).
15
+ * folders (any-folder capture, R3) folders outside every work tree that hold sessions, one `Folders` group. A folder grant
16
+ * captures the sessions there that touch no repository, under a `local:folder:` identity that only the uploader
17
+ * can read; its grant line SAYS so ("private to you", R7). Granted folders are `on now` rows, removable like any.
15
18
  * turned off slugs in `excluded`. Choosing one is a re-include; the grant block names it on a `turn on` row, inside
16
19
  * the same y/N (D7 · 3A), and only a yes clears the exclusion.
20
+ * on your account (slice 2) what the account grants that this machine has not named. Unticked until chosen, like any
21
+ * row: choosing one is an ordinary addition, so it goes through the same disclosure and y/N (R3-8, R3-9).
17
22
  *
18
23
  * ⛔ **A pre-tick is not a consent** (D34): the working folder's repo starts ticked, and Enter on it leads to the naming
19
24
  * y/N, which defaults to N. It is an ADDITION to the diff, never part of the baseline.
20
25
  */
21
26
  import { ownerOfSlug, normalizeSlug, normalizeOwner, isLocalSlug } from "./consent.js";
22
27
  import { AGENT_HOOKS, SCAN, PICKER, shortDate } from "./copy/index.js";
28
+ import { DIALECTS, agentIsolatesRecords } from "./agents/registry.js";
23
29
  import { LABEL_GUTTER, paint, truncateMiddle, wrap } from "./term.js";
24
30
  /** D9: each owner group shows at most this many repos before the `+ N more` row. */
25
31
  export const GROUP_CAP = 8;
@@ -42,7 +48,9 @@ export function isNoChange(change) {
42
48
  change.turnOn.length === 0 &&
43
49
  change.stopOwners.length === 0 &&
44
50
  change.stopRepos.length === 0 &&
45
- change.stopClones.length === 0);
51
+ change.stopClones.length === 0 &&
52
+ (change.addFolders ?? []).length === 0 &&
53
+ (change.stopFolders ?? []).length === 0);
46
54
  }
47
55
  /**
48
56
  * What Enter means when the working folder's repo is pre-selected (D34): choosing it, which leads to the y/N and never to
@@ -57,7 +65,48 @@ export function preChange(model) {
57
65
  }
58
66
  /** Anything the person is ADDING needs the D65 disclosure; removals alone do not. */
59
67
  export function hasAdditions(change) {
60
- return change.addOwners.length + change.addRepos.length + change.turnOn.length > 0;
68
+ return change.addOwners.length + change.addRepos.length + change.turnOn.length + (change.addFolders ?? []).length > 0;
69
+ }
70
+ /**
71
+ * The folder as a FOLDER GRANT would capture it (C34): only the sessions of agents whose records can be isolated count, because a
72
+ * Cursor session (or a Codex one, until its isolation lands) under a granted folder is never captured, and a count or an offer that
73
+ * included it would promise what does not happen. `null` when no session passes. Times and counts are recomputed from the members
74
+ * when the scan kept them; a hand-built folder without members keeps its own times and zeroes the agents that fail.
75
+ */
76
+ export function capturableFolder(folder, isolates = agentIsolatesRecords) {
77
+ const agents = emptyAgents();
78
+ if (folder.members !== undefined) {
79
+ const kept = folder.members.filter((member) => isolates(member.agent));
80
+ if (kept.length === 0)
81
+ return null;
82
+ let sinceMs = Infinity;
83
+ let latestMs = 0;
84
+ for (const member of kept) {
85
+ const at = member.session.startedMs ?? member.session.mtimeMs;
86
+ sinceMs = Math.min(sinceMs, at);
87
+ latestMs = Math.max(latestMs, at);
88
+ agents[member.agent] += 1;
89
+ }
90
+ return { path: folder.path, sessions: kept.length, sinceMs, latestMs, agents, members: kept };
91
+ }
92
+ let sessions = 0;
93
+ for (const agent of Object.keys(agents)) {
94
+ if (isolates(agent)) {
95
+ agents[agent] = folder.agents[agent] ?? 0;
96
+ sessions += agents[agent];
97
+ }
98
+ }
99
+ return sessions === 0 ? null : { path: folder.path, sessions, sinceMs: folder.sinceMs, latestMs: folder.latestMs, agents };
100
+ }
101
+ /**
102
+ * The agents a folder grant captures, as the copy names them: `Claude Code`, `Claude Code or Codex`, `Claude Code, Codex or Cursor`.
103
+ * Built from the same predicate as the offers (C34), so the sentence and the behaviour cannot disagree.
104
+ */
105
+ export function folderCaptureAgents(isolates = agentIsolatesRecords) {
106
+ const names = DIALECTS.filter((dialect) => isolates(dialect.id)).map((dialect) => SCAN.agent[dialect.id]);
107
+ if (names.length <= 1)
108
+ return names.join("");
109
+ return `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`;
61
110
  }
62
111
  function splitSlug(slug) {
63
112
  const owner = ownerOfSlug(slug);
@@ -134,6 +183,37 @@ export function buildModel(input) {
134
183
  continue;
135
184
  onNow.push({ kind: "clone", key: slug, reason: PICKER.reasonPerClone, counts: countsOf(slug), reposHere: 0, commonDirs });
136
185
  }
186
+ // Folder grants (R3): a granted folder is an `on now` row (removable); every other folder with sessions is offered.
187
+ const folderLabel = input.folderLabel ?? ((path) => path);
188
+ const grantedFolders = new Set(input.grantedFolders ?? []);
189
+ // ⛔ C34: counts and offers cover only what a folder grant would capture. A folder with no passing session is not offered; a granted
190
+ // one stays a removable `on now` row, with no counts.
191
+ const isolates = input.isolates ?? agentIsolatesRecords;
192
+ const capturable = (input.folders ?? []).flatMap((folder) => {
193
+ const kept = capturableFolder(folder, isolates);
194
+ return kept === null ? [] : [kept];
195
+ });
196
+ const folderCounts = new Map(capturable.map((folder) => [folder.path, folder]));
197
+ for (const path of input.grantedFolders ?? []) {
198
+ onNow.push({ kind: "folder", key: path, label: folderLabel(path), reason: PICKER.reasonFolder, counts: folderCounts.get(path) ?? null, reposHere: 0, commonDirs: [] });
199
+ }
200
+ const excludedSet = new Set(input.excludedFolders ?? []);
201
+ const offered = capturable.filter((folder) => !grantedFolders.has(folder.path));
202
+ const folders = [
203
+ ...offered.map((folder) => ({
204
+ path: folder.path,
205
+ label: folderLabel(folder.path),
206
+ sessions: folder.sessions,
207
+ sinceMs: folder.sinceMs,
208
+ latestMs: folder.latestMs,
209
+ agents: folder.agents,
210
+ ...(excludedSet.has(folder.path) ? { excluded: true } : {}),
211
+ })),
212
+ // A folder turned off that no scan found any more is still a row, so it can be turned back on.
213
+ ...[...excludedSet]
214
+ .filter((path) => !offered.some((folder) => folder.path === path))
215
+ .map((path) => ({ path, label: folderLabel(path), sessions: 0, sinceMs: 0, latestMs: 0, agents: emptyAgents(), excluded: true })),
216
+ ];
137
217
  const isOn = (slug) => coveredByGrant(slug) || perCloneSlugs.has(slug);
138
218
  const byOwner = new Map();
139
219
  for (const repo of repos.values()) {
@@ -152,10 +232,21 @@ export function buildModel(input) {
152
232
  const turnedOff = [...excluded]
153
233
  .sort()
154
234
  .map((slug) => ({ slug, counts: countsOf(slug), pre: slug === hereSlug }));
235
+ // ⛔ THE SCAN ROW WINS: an owner that already has a group above, and a repo already listed under one, are not repeated under "on
236
+ // your account" — choosing the scan row is the same change, and one row per thing is what a person can read.
237
+ const shownOwners = new Set(owners.map((group) => group.owner));
238
+ const shownRepos = new Set(owners.flatMap((group) => group.repos.map((repo) => repo.slug)));
239
+ const account = (input.accountNotHere ?? [])
240
+ .map((name) => name.endsWith("/")
241
+ ? { kind: "owner", key: normalizeOwner(name.slice(0, -1)) }
242
+ : { kind: "repo", key: normalizeSlug(name) })
243
+ .filter((row) => (row.kind === "owner" ? !shownOwners.has(row.key) : !shownRepos.has(row.key)));
155
244
  return {
156
245
  onNow,
157
246
  owners,
247
+ account,
158
248
  turnedOff,
249
+ folders,
159
250
  notListed: input.notListed,
160
251
  found: input.repos.length,
161
252
  sawNothing: input.sawNothing,
@@ -172,6 +263,10 @@ export function numberedRows(model, showAll) {
172
263
  for (const repo of shown)
173
264
  rows.push({ kind: "repo", repo });
174
265
  }
266
+ for (const folder of showAll ? model.folders : model.folders.slice(0, GROUP_CAP))
267
+ rows.push({ kind: "folder", folder });
268
+ for (const row of model.account)
269
+ rows.push({ kind: "account", row });
175
270
  for (const row of model.turnedOff)
176
271
  rows.push({ kind: "off", row });
177
272
  return rows;
@@ -182,13 +277,20 @@ const REPO_TOKEN = /^[^\s/]+(?:\/[^\s/]+)*\/[^\s/]+$/u;
182
277
  * Parse one typed answer (D8 · 4A). ⛔ **Never applies part of an answer:** any token that is not understood returns
183
278
  * `error` naming each such token, and nothing is chosen. `owner/` and `owner/repo` are always accepted, a clone here or
184
279
  * not, because a new laptop needs them; the grant block flags `(no clone here yet)`.
280
+ *
281
+ * ⛔ **`g` STANDS ALONE** (exception 3, R3-12): where `offerAccount` is true, `g` alone is the account's grants, and `g` mixed with
282
+ * any other token is refused with nothing applied, so the y/N after it always names a single list. Where it was not offered `g` is
283
+ * an ordinary token nobody understands.
185
284
  */
186
- export function parseTyped(input, model, showAll) {
285
+ export function parseTyped(input, model, showAll, offerAccount = false) {
187
286
  const tokens = input.split(/[\s,]+/u).filter((t) => t !== "");
188
287
  if (tokens.length === 0)
189
288
  return { kind: "empty" };
190
289
  if (tokens.length === 1 && tokens[0].toLowerCase() === "more")
191
290
  return { kind: "more" };
291
+ if (offerAccount && tokens.some((t) => t.toLowerCase() === "g")) {
292
+ return tokens.length === 1 ? { kind: "account" } : { kind: "error", tokens: [PICKER.gMixedToken] };
293
+ }
192
294
  const rows = numberedRows(model, showAll);
193
295
  const bad = [];
194
296
  const addOwners = new Set();
@@ -197,6 +299,8 @@ export function parseTyped(input, model, showAll) {
197
299
  const stopOwners = new Set();
198
300
  const stopRepos = new Set();
199
301
  const stopClones = new Set();
302
+ const addFolders = new Set();
303
+ const stopFolders = new Set();
200
304
  const grantedOwners = new Set(model.onNow.filter((r) => r.kind === "owner").map((r) => r.key));
201
305
  const grantedRepos = new Set(model.onNow.filter((r) => r.kind === "repo").map((r) => r.key));
202
306
  const cloneSlugs = new Set(model.onNow.filter((r) => r.kind === "clone").map((r) => r.key));
@@ -206,6 +310,8 @@ export function parseTyped(input, model, showAll) {
206
310
  stopOwners.add(row.key);
207
311
  else if (row.kind === "repo")
208
312
  stopRepos.add(row.key);
313
+ else if (row.kind === "folder")
314
+ stopFolders.add(row.key);
209
315
  else
210
316
  stopClones.add(row.key);
211
317
  };
@@ -236,6 +342,14 @@ export function parseTyped(input, model, showAll) {
236
342
  bad.push(PICKER.alreadyOn(token, `-${token}`));
237
343
  else if (row.kind === "off")
238
344
  turnOn.add(row.row.slug);
345
+ else if (row.kind === "folder")
346
+ addFolders.add(row.folder.path);
347
+ else if (row.kind === "account") {
348
+ if (row.row.kind === "owner")
349
+ addOwners.add(row.row.key);
350
+ else
351
+ addSlug(row.row.key, token);
352
+ }
239
353
  else
240
354
  addSlug(row.repo.slug, token);
241
355
  }
@@ -300,6 +414,9 @@ export function parseTyped(input, model, showAll) {
300
414
  stopOwners: [...stopOwners],
301
415
  stopRepos: [...stopRepos],
302
416
  stopClones: [...stopClones],
417
+ // Present only when something was chosen, so a repo-only answer is the object it always was.
418
+ ...(addFolders.size > 0 ? { addFolders: [...addFolders] } : {}),
419
+ ...(stopFolders.size > 0 ? { stopFolders: [...stopFolders] } : {}),
303
420
  },
304
421
  };
305
422
  }
@@ -322,11 +439,19 @@ function onNowDisplay(row, nowMs) {
322
439
  return { name: `${row.key}/`, meta: joinMeta(row.reason, PICKER.ownerOnReposHere(row.reposHere)), agents: "" };
323
440
  }
324
441
  return {
325
- name: row.key,
442
+ name: row.kind === "folder" ? (row.label ?? row.key) : row.key,
326
443
  meta: joinMeta(row.reason, countsText(row.counts, nowMs)),
327
444
  agents: agentsText(row.counts),
328
445
  };
329
446
  }
447
+ function folderDisplay(folder, nowMs) {
448
+ const counts = folder.sessions === 0 ? null : folder;
449
+ return {
450
+ name: folder.label,
451
+ meta: joinMeta(folder.excluded === true ? PICKER.turnOnHint : "", countsText(counts, nowMs)),
452
+ agents: agentsText(counts),
453
+ };
454
+ }
330
455
  function repoDisplay(repo, nowMs) {
331
456
  const flag = repo.here === "repo" ? PICKER.thisRepo : repo.here === "folder" ? PICKER.thisFolder : "";
332
457
  return {
@@ -377,7 +502,12 @@ export function notListedBlock(model, colour) {
377
502
  export function renderTyped(model, options) {
378
503
  const { colour, nowMs, showAll } = options;
379
504
  const lines = [""];
380
- if (model.found === 0 && model.onNow.length === 0 && model.turnedOff.length === 0 && model.owners.length === 0) {
505
+ if (model.found === 0 &&
506
+ model.onNow.length === 0 &&
507
+ model.turnedOff.length === 0 &&
508
+ model.owners.length === 0 &&
509
+ model.folders.length === 0 &&
510
+ model.account.length === 0) {
381
511
  return [
382
512
  ...lines,
383
513
  ...wrap(model.sawNothing ? PICKER.emptyNone : SCAN.noneListable, 2).map((l) => paint(colour, "strong", l)),
@@ -385,6 +515,7 @@ export function renderTyped(model, options) {
385
515
  ...notListedBlock(model, colour),
386
516
  "",
387
517
  ...wrap(model.sawNothing ? PICKER.emptyType : PICKER.emptyListable, 2),
518
+ ...(options.offerAccount === true ? wrap(PICKER.gLine, 2) : []),
388
519
  ` ${PICKER.enterNothing}`,
389
520
  ];
390
521
  }
@@ -392,12 +523,19 @@ export function renderTyped(model, options) {
392
523
  const onNow = model.onNow.map((row) => onNowDisplay(row, nowMs));
393
524
  const shown = (g) => (showAll ? g.repos : g.repos.slice(0, GROUP_CAP));
394
525
  const repoRows = model.owners.flatMap((g) => shown(g).map((r) => repoDisplay(r, nowMs)));
526
+ const shownFolders = showAll ? model.folders : model.folders.slice(0, GROUP_CAP);
527
+ const folderRows = shownFolders.map((f) => folderDisplay(f, nowMs));
528
+ const account = model.account.map((row) => ({
529
+ name: row.kind === "owner" ? `${row.key}/` : row.key,
530
+ meta: PICKER.onAccountHint,
531
+ agents: "",
532
+ }));
395
533
  const off = model.turnedOff.map((row) => ({
396
534
  name: row.slug,
397
535
  meta: PICKER.turnOnHint,
398
536
  agents: "",
399
537
  }));
400
- const { cut, column, withAgents } = layout([...onNow, ...repoRows, ...off]);
538
+ const { cut, column, withAgents } = layout([...onNow, ...repoRows, ...folderRows, ...account, ...off]);
401
539
  const row = (n, display) => `${String(n).padStart(6)} ${paint(colour, "text", cut(display.name).padEnd(column))}${" ".repeat(GAP)}${paint(colour, "muted", withAgents(display))}`;
402
540
  let n = 0;
403
541
  if (model.onNow.length > 0) {
@@ -414,6 +552,19 @@ export function renderTyped(model, options) {
414
552
  lines.push(paint(colour, "muted", ` + ${PICKER.moreTyped(hidden, group.owner)}`));
415
553
  }
416
554
  }
555
+ if (folderRows.length > 0) {
556
+ lines.push(paint(colour, "muted", ` ${PICKER.folders}`));
557
+ for (const d of folderRows)
558
+ lines.push(row(++n, d));
559
+ const hiddenFolders = model.folders.length - shownFolders.length;
560
+ if (hiddenFolders > 0)
561
+ lines.push(paint(colour, "muted", ` + ${PICKER.moreFoldersTyped(hiddenFolders)}`));
562
+ }
563
+ if (account.length > 0) {
564
+ lines.push(paint(colour, "muted", ` ${PICKER.onAccount}`));
565
+ for (const d of account)
566
+ lines.push(row(++n, d));
567
+ }
417
568
  if (model.turnedOff.length > 0) {
418
569
  lines.push(paint(colour, "muted", ` ${PICKER.turnedOff}`));
419
570
  for (const d of off)
@@ -421,6 +572,8 @@ export function renderTyped(model, options) {
421
572
  }
422
573
  lines.push(...notListedBlock(model, colour), "");
423
574
  lines.push(` ${paint(colour, "text", PICKER.choose(model.owners.length > 0))}`);
575
+ if (options.offerAccount === true)
576
+ lines.push(...wrap(PICKER.gLine, 2).map((l) => paint(colour, "text", l)));
424
577
  if (model.onNow.length > 0)
425
578
  lines.push(` ${paint(colour, "text", PICKER.removeHint)}`);
426
579
  const pre = preChange(model);
@@ -437,8 +590,10 @@ export function notUnderstoodLines(tokens) {
437
590
  * change; an owner row says how many repos are here and that later clones are covered, and a typed name with no clone
438
591
  * on this machine says so, so a typo shows BEFORE the question.
439
592
  */
440
- export function renderGrantBlock(change, facts, colour) {
441
- const lines = [` ${paint(colour, "strong", PICKER.chose)}`];
593
+ export function renderGrantBlock(change, facts, colour,
594
+ /** `You chose:` for the person's picks; the account's own list (`g`, screen 1b) is headed `Your account grants:`. */
595
+ heading = PICKER.chose) {
596
+ const lines = [` ${paint(colour, "strong", heading)}`];
442
597
  const rows = [
443
598
  [
444
599
  PICKER.labelOwners,
@@ -451,13 +606,23 @@ export function renderGrantBlock(change, facts, colour) {
451
606
  PICKER.labelRepos,
452
607
  change.addRepos.map((r) => (facts.noClone.has(r) ? `${r} ${PICKER.noCloneHere}` : r)),
453
608
  ],
454
- [PICKER.labelTurnOn, [...change.turnOn]],
609
+ [
610
+ PICKER.labelTurnOn,
611
+ change.turnOn.map((slug) => (facts.accountOff?.has(slug) === true ? `${slug} ${PICKER.turnedOffOnAccount}` : slug)),
612
+ ],
613
+ // ⛔ THE FOLDER GRANT LINE SAYS WHO CAN READ IT (R7 · D9): the server makes a folder repository private to its uploader, and the
614
+ // person is told so at the one moment they are asked. Nothing here claims more than that.
615
+ [
616
+ PICKER.labelFolders,
617
+ (change.addFolders ?? []).map((path) => `${facts.folderLabels?.get(path) ?? path} ${PICKER.folderPrivate}`),
618
+ ],
455
619
  [
456
620
  PICKER.labelStop,
457
621
  [
458
622
  ...change.stopOwners.map((o) => `${o}/`),
459
623
  ...change.stopRepos,
460
624
  ...change.stopClones,
625
+ ...(change.stopFolders ?? []).map((path) => facts.folderLabels?.get(path) ?? path),
461
626
  ],
462
627
  ],
463
628
  ];
@@ -471,7 +636,15 @@ export function renderGrantBlock(change, facts, colour) {
471
636
  }
472
637
  /** The grant y/N: additions (with or without removals) get the upload question, removals alone the stop question. */
473
638
  export function grantQuestion(change, colour, answers) {
474
- const q = hasAdditions(change) ? PICKER.questionGrant : PICKER.questionStop;
639
+ const folders = (change.addFolders ?? []).length > 0 || (change.stopFolders ?? []).length > 0;
640
+ const repos = change.addOwners.length + change.addRepos.length + change.turnOn.length + change.stopOwners.length + change.stopRepos.length + change.stopClones.length > 0;
641
+ const q = hasAdditions(change)
642
+ ? folders
643
+ ? repos ? PICKER.questionGrantMixed : PICKER.questionGrantFolders
644
+ : PICKER.questionGrant
645
+ : folders
646
+ ? repos ? PICKER.questionStopMixed : PICKER.questionStopFolders
647
+ : PICKER.questionStop;
475
648
  return ` ${paint(colour, "text", q)} ${paint(colour, "muted", answers)} `;
476
649
  }
477
650
  //# sourceMappingURL=picker.js.map