@vibe-commit/capture 0.6.0 → 0.7.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 (143) hide show
  1. package/README.md +84 -5
  2. package/dist/agent_hooks.d.ts +8 -0
  3. package/dist/agent_hooks.js +31 -1
  4. package/dist/agent_hooks.js.map +1 -1
  5. package/dist/agents/claude_code.d.ts +34 -0
  6. package/dist/agents/claude_code.js +141 -8
  7. package/dist/agents/claude_code.js.map +1 -1
  8. package/dist/agents/codex_cli.d.ts +13 -0
  9. package/dist/agents/codex_cli.js +241 -2
  10. package/dist/agents/codex_cli.js.map +1 -1
  11. package/dist/agents/cursor.d.ts +34 -0
  12. package/dist/agents/cursor.js +115 -1
  13. package/dist/agents/cursor.js.map +1 -1
  14. package/dist/agents/session_listing.d.ts +29 -0
  15. package/dist/agents/session_listing.js +49 -0
  16. package/dist/agents/session_listing.js.map +1 -0
  17. package/dist/agents/types.d.ts +118 -8
  18. package/dist/agents/types.js +17 -3
  19. package/dist/agents/types.js.map +1 -1
  20. package/dist/commands/approval_pending.d.ts +9 -0
  21. package/dist/commands/approval_pending.js +23 -0
  22. package/dist/commands/approval_pending.js.map +1 -0
  23. package/dist/commands/auth.js +31 -11
  24. package/dist/commands/auth.js.map +1 -1
  25. package/dist/commands/backfill.d.ts +160 -0
  26. package/dist/commands/backfill.js +748 -0
  27. package/dist/commands/backfill.js.map +1 -0
  28. package/dist/commands/browser_credential.d.ts +2 -0
  29. package/dist/commands/browser_credential.js +31 -14
  30. package/dist/commands/browser_credential.js.map +1 -1
  31. package/dist/commands/connect.d.ts +137 -0
  32. package/dist/commands/connect.js +203 -109
  33. package/dist/commands/connect.js.map +1 -1
  34. package/dist/commands/connect_picker.d.ts +27 -0
  35. package/dist/commands/connect_picker.js +415 -0
  36. package/dist/commands/connect_picker.js.map +1 -0
  37. package/dist/commands/context.d.ts +16 -0
  38. package/dist/commands/context.js +27 -0
  39. package/dist/commands/context.js.map +1 -1
  40. package/dist/commands/credential_problem.d.ts +15 -4
  41. package/dist/commands/credential_problem.js +29 -22
  42. package/dist/commands/credential_problem.js.map +1 -1
  43. package/dist/commands/device_credential.d.ts +2 -0
  44. package/dist/commands/device_credential.js +16 -14
  45. package/dist/commands/device_credential.js.map +1 -1
  46. package/dist/commands/finalize.d.ts +95 -0
  47. package/dist/commands/finalize.js +249 -74
  48. package/dist/commands/finalize.js.map +1 -1
  49. package/dist/commands/finish_mint.d.ts +2 -6
  50. package/dist/commands/finish_mint.js +10 -2
  51. package/dist/commands/finish_mint.js.map +1 -1
  52. package/dist/commands/report.js +38 -30
  53. package/dist/commands/report.js.map +1 -1
  54. package/dist/commands/scan.d.ts +162 -0
  55. package/dist/commands/scan.js +507 -0
  56. package/dist/commands/scan.js.map +1 -0
  57. package/dist/commands/status.js +119 -22
  58. package/dist/commands/status.js.map +1 -1
  59. package/dist/commands/status_hooks.d.ts +14 -0
  60. package/dist/commands/status_hooks.js +81 -0
  61. package/dist/commands/status_hooks.js.map +1 -0
  62. package/dist/commands/status_machine.d.ts +18 -0
  63. package/dist/commands/status_machine.js +149 -0
  64. package/dist/commands/status_machine.js.map +1 -0
  65. package/dist/commands/why.js +44 -31
  66. package/dist/commands/why.js.map +1 -1
  67. package/dist/consent.d.ts +111 -0
  68. package/dist/consent.js +203 -1
  69. package/dist/consent.js.map +1 -1
  70. package/dist/copy/error_codes.d.ts +222 -0
  71. package/dist/copy/error_codes.js +105 -0
  72. package/dist/copy/error_codes.js.map +1 -0
  73. package/dist/copy/index.d.ts +2 -2
  74. package/dist/copy/index.js +2 -2
  75. package/dist/copy/index.js.map +1 -1
  76. package/dist/copy/strings.d.ts +497 -29
  77. package/dist/copy/strings.js +601 -35
  78. package/dist/copy/strings.js.map +1 -1
  79. package/dist/copy/time.d.ts +11 -0
  80. package/dist/copy/time.js +18 -0
  81. package/dist/copy/time.js.map +1 -1
  82. package/dist/credential.d.ts +5 -1
  83. package/dist/credential.js +2 -2
  84. package/dist/credential.js.map +1 -1
  85. package/dist/git.d.ts +43 -0
  86. package/dist/git.js +95 -0
  87. package/dist/git.js.map +1 -1
  88. package/dist/hooks/entry.d.ts +18 -1
  89. package/dist/hooks/entry.js +110 -13
  90. package/dist/hooks/entry.js.map +1 -1
  91. package/dist/hooks/post_commit.js +3 -3
  92. package/dist/hooks/post_commit.js.map +1 -1
  93. package/dist/hooks/post_rewrite.js +6 -4
  94. package/dist/hooks/post_rewrite.js.map +1 -1
  95. package/dist/hooks/session_start.d.ts +93 -0
  96. package/dist/hooks/session_start.js +199 -0
  97. package/dist/hooks/session_start.js.map +1 -0
  98. package/dist/index.d.ts +1 -1
  99. package/dist/index.js +56 -8
  100. package/dist/index.js.map +1 -1
  101. package/dist/install.d.ts +57 -0
  102. package/dist/install.js +54 -20
  103. package/dist/install.js.map +1 -1
  104. package/dist/json.d.ts +32 -9
  105. package/dist/json.js +35 -8
  106. package/dist/json.js.map +1 -1
  107. package/dist/machine_state.d.ts +52 -0
  108. package/dist/machine_state.js +63 -0
  109. package/dist/machine_state.js.map +1 -0
  110. package/dist/oauth/session.d.ts +1 -0
  111. package/dist/org_pending.d.ts +52 -0
  112. package/dist/org_pending.js +109 -0
  113. package/dist/org_pending.js.map +1 -0
  114. package/dist/paths.d.ts +50 -0
  115. package/dist/paths.js +64 -0
  116. package/dist/paths.js.map +1 -1
  117. package/dist/picker.d.ts +180 -0
  118. package/dist/picker.js +477 -0
  119. package/dist/picker.js.map +1 -0
  120. package/dist/picker_checklist.d.ts +125 -0
  121. package/dist/picker_checklist.js +375 -0
  122. package/dist/picker_checklist.js.map +1 -0
  123. package/dist/policy.d.ts +54 -0
  124. package/dist/policy.js +47 -0
  125. package/dist/policy.js.map +1 -1
  126. package/dist/post.d.ts +50 -16
  127. package/dist/post.js +265 -58
  128. package/dist/post.js.map +1 -1
  129. package/dist/state.d.ts +6 -0
  130. package/dist/state.js +38 -1
  131. package/dist/state.js.map +1 -1
  132. package/dist/system_message.d.ts +8 -1
  133. package/dist/system_message.js +19 -1
  134. package/dist/system_message.js.map +1 -1
  135. package/dist/term.d.ts +28 -2
  136. package/dist/term.js +23 -2
  137. package/dist/term.js.map +1 -1
  138. package/dist/verb_help.d.ts +13 -0
  139. package/dist/verb_help.js +40 -0
  140. package/dist/verb_help.js.map +1 -0
  141. package/dist/version.d.ts +1 -1
  142. package/dist/version.js +1 -1
  143. package/package.json +5 -9
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The `connect` picker — style guide §10.12, design review D5-D35; capture 0.7.0, PR 3.
3
+ *
4
+ * Which repositories does the person want captured? This module is the PURE half: it turns a `scan` result and the
5
+ * grants already on disk into rows, parses what the person types, renders the typed screen and the `You chose:` block,
6
+ * and says what a choice changes. It reads no file, opens no transcript and writes nothing — `connect` supplies the
7
+ * facts and applies the change, and `picker_checklist.ts` is the arrow-key driver over the same model.
8
+ *
9
+ * ## What the rows are
10
+ *
11
+ * on now grants and per-clone consents that are already admitting capture. REMOVABLE (D31): unticking one, or
12
+ * typing `-N`, deletes the grant. It is not an exclusion (`off` is), so the repo can be chosen again.
13
+ * owners one group per owner NOT yet granted, its repos behind an `aN` "all, including repos you clone later"
14
+ * row (D9: at most 8 repos before `+ N more`).
15
+ * turned off slugs in `excluded`. Choosing one is a re-include; the grant block names it on a `turn on` row, inside
16
+ * the same y/N (D7 · 3A), and only a yes clears the exclusion.
17
+ *
18
+ * ⛔ **A pre-tick is not a consent** (D34): the working folder's repo starts ticked, and Enter on it leads to the naming
19
+ * y/N, which defaults to N. It is an ADDITION to the diff, never part of the baseline.
20
+ */
21
+ import { type Grants } from "./consent.js";
22
+ import type { ScanClone, ScanRepo } from "./commands/scan.js";
23
+ import type { AgentId } from "./agents/types.js";
24
+ /** D9: each owner group shows at most this many repos before the `+ N more` row. */
25
+ export declare const GROUP_CAP = 8;
26
+ /** An agent session summary shared by the repo rows. */
27
+ interface Counts {
28
+ readonly sessions: number;
29
+ readonly sinceMs: number;
30
+ readonly agents: Readonly<Record<AgentId, number>>;
31
+ }
32
+ /** A repo the person can add. `synthetic` rows come from the working folder, not from the scan. */
33
+ export interface PickRepo extends Counts {
34
+ readonly slug: string;
35
+ readonly owner: string;
36
+ readonly name: string;
37
+ readonly clones: readonly ScanClone[];
38
+ readonly latestMs: number;
39
+ /** The working folder's repo (D34). */
40
+ readonly here: "repo" | "folder" | null;
41
+ /** Starts ticked in the checklist. Only ever the working folder's repo, and only when it is not `$HOME`. */
42
+ readonly pre: boolean;
43
+ }
44
+ /** One `on now` row. `kind` says what removing it deletes. */
45
+ export interface OnNowRow {
46
+ readonly kind: "owner" | "repo" | "clone";
47
+ /** `alexvibecommit` (owner) or a slug. */
48
+ readonly key: string;
49
+ readonly reason: string;
50
+ readonly counts: Counts | null;
51
+ /** Repos found here under an owner row. */
52
+ readonly reposHere: number;
53
+ /** The common dirs a per-clone row stands for (removed together). */
54
+ readonly commonDirs: readonly string[];
55
+ }
56
+ export interface OwnerGroup {
57
+ readonly owner: string;
58
+ /** `a1`, `a2`, … */
59
+ readonly token: string;
60
+ /** Every repo under the owner not already on and not turned off, newest session first. */
61
+ readonly repos: readonly PickRepo[];
62
+ }
63
+ export interface TurnedOffRow {
64
+ readonly slug: string;
65
+ readonly counts: Counts | null;
66
+ /** The working folder's repo, turned off: `connect` inside it pre-selects the `turn on` (D34 + the re-include rule). */
67
+ readonly pre: boolean;
68
+ }
69
+ export interface PickerModel {
70
+ readonly onNow: readonly OnNowRow[];
71
+ readonly owners: readonly OwnerGroup[];
72
+ readonly turnedOff: readonly TurnedOffRow[];
73
+ /** The picker's `Not listed here:` lines (no remote, can't be matched, unreadable agents). */
74
+ readonly notListed: readonly string[];
75
+ /** Repos the scan listed, for the `Found N repositories` line. */
76
+ readonly found: number;
77
+ /** The scan saw no session at all (the not-listed block is empty): the new-laptop screen. */
78
+ readonly sawNothing: boolean;
79
+ }
80
+ /** What a choice changes. Everything is normalized; the lists are disjoint. */
81
+ export interface PickerChange {
82
+ readonly addOwners: readonly string[];
83
+ readonly addRepos: readonly string[];
84
+ readonly turnOn: readonly string[];
85
+ readonly stopOwners: readonly string[];
86
+ readonly stopRepos: readonly string[];
87
+ /** The slugs of per-clone consents to withdraw. */
88
+ readonly stopClones: readonly string[];
89
+ }
90
+ export declare const NO_CHANGE: PickerChange;
91
+ export declare function isNoChange(change: PickerChange): boolean;
92
+ /**
93
+ * What Enter means when the working folder's repo is pre-selected (D34): choosing it, which leads to the y/N and never to
94
+ * a grant. `null` when nothing is pre-selected, where Enter changes nothing (D5).
95
+ */
96
+ export declare function preChange(model: PickerModel): PickerChange | null;
97
+ /** Anything the person is ADDING needs the D65 disclosure; removals alone do not. */
98
+ export declare function hasAdditions(change: PickerChange): boolean;
99
+ /** The working folder, as the model needs it (D34). */
100
+ export interface HereRepo {
101
+ readonly slug: string;
102
+ readonly toplevel: string;
103
+ /** `repo`: inside a work tree, pre-ticked. `folder`: `$HOME` is itself a repo — flagged, never pre-ticked. */
104
+ readonly kind: "repo" | "folder";
105
+ }
106
+ export interface ModelInput {
107
+ readonly repos: readonly ScanRepo[];
108
+ readonly grants: Grants;
109
+ /** Per-clone consents (`projects.json`), resolved to the slug of each clone's remote. `null`: no remote. */
110
+ readonly perClone: readonly {
111
+ readonly commonDir: string;
112
+ readonly slug: string | null;
113
+ }[];
114
+ readonly notListed: readonly string[];
115
+ readonly sawNothing: boolean;
116
+ readonly here: HereRepo | null;
117
+ }
118
+ export declare function buildModel(input: ModelInput): PickerModel;
119
+ /** What a number on screen stands for. On-now rows come first, then each owner's repos, then turned-off. */
120
+ export type RowRef = {
121
+ readonly kind: "on";
122
+ readonly row: OnNowRow;
123
+ } | {
124
+ readonly kind: "repo";
125
+ readonly repo: PickRepo;
126
+ } | {
127
+ readonly kind: "off";
128
+ readonly row: TurnedOffRow;
129
+ };
130
+ /**
131
+ * The numbered rows in screen order. `showAll` is the `more` token: every repo is shown and numbered afresh. Without it
132
+ * each owner shows `GROUP_CAP` repos and the rest are reachable only by typing `owner/repo` or `more`.
133
+ */
134
+ export declare function numberedRows(model: PickerModel, showAll: boolean): RowRef[];
135
+ export type Parsed = {
136
+ readonly kind: "empty";
137
+ } | {
138
+ readonly kind: "more";
139
+ } | {
140
+ readonly kind: "change";
141
+ readonly change: PickerChange;
142
+ readonly noClone: readonly string[];
143
+ } | {
144
+ readonly kind: "error";
145
+ readonly tokens: readonly string[];
146
+ };
147
+ /**
148
+ * Parse one typed answer (D8 · 4A). ⛔ **Never applies part of an answer:** any token that is not understood returns
149
+ * `error` naming each such token, and nothing is chosen. `owner/` and `owner/repo` are always accepted, a clone here or
150
+ * not, because a new laptop needs them; the grant block flags `(no clone here yet)`.
151
+ */
152
+ export declare function parseTyped(input: string, model: PickerModel, showAll: boolean): Parsed;
153
+ export interface RenderOptions {
154
+ readonly colour: boolean;
155
+ readonly nowMs: number;
156
+ /** Show every repo, numbered afresh (the `more` token). */
157
+ readonly showAll: boolean;
158
+ }
159
+ /** The `Not listed here:` block, muted, or nothing. */
160
+ export declare function notListedBlock(model: PickerModel, colour: boolean): string[];
161
+ /** The screen above the prompt, as lines — the typed picker (§10.12 "The picker, typed"). */
162
+ export declare function renderTyped(model: PickerModel, options: RenderOptions): string[];
163
+ /** The `Not understood:` line the prompt prints before it asks again. */
164
+ export declare function notUnderstoodLines(tokens: readonly string[]): string[];
165
+ /** What the grant block needs to know about the machine, so each row says what it covers. */
166
+ export interface BlockFacts {
167
+ /** owner -> repos found on this machine under it. */
168
+ readonly reposHere: ReadonlyMap<string, number>;
169
+ /** Typed `owner/` and `owner/repo` with no clone here. */
170
+ readonly noClone: ReadonlySet<string>;
171
+ }
172
+ /**
173
+ * 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
+ * change; an owner row says how many repos are here and that later clones are covered, and a typed name with no clone
175
+ * on this machine says so, so a typo shows BEFORE the question.
176
+ */
177
+ export declare function renderGrantBlock(change: PickerChange, facts: BlockFacts, colour: boolean): string[];
178
+ /** The grant y/N: additions (with or without removals) get the upload question, removals alone the stop question. */
179
+ export declare function grantQuestion(change: PickerChange, colour: boolean, answers: string): string;
180
+ export {};
package/dist/picker.js ADDED
@@ -0,0 +1,477 @@
1
+ /**
2
+ * The `connect` picker — style guide §10.12, design review D5-D35; capture 0.7.0, PR 3.
3
+ *
4
+ * Which repositories does the person want captured? This module is the PURE half: it turns a `scan` result and the
5
+ * grants already on disk into rows, parses what the person types, renders the typed screen and the `You chose:` block,
6
+ * and says what a choice changes. It reads no file, opens no transcript and writes nothing — `connect` supplies the
7
+ * facts and applies the change, and `picker_checklist.ts` is the arrow-key driver over the same model.
8
+ *
9
+ * ## What the rows are
10
+ *
11
+ * on now grants and per-clone consents that are already admitting capture. REMOVABLE (D31): unticking one, or
12
+ * typing `-N`, deletes the grant. It is not an exclusion (`off` is), so the repo can be chosen again.
13
+ * owners one group per owner NOT yet granted, its repos behind an `aN` "all, including repos you clone later"
14
+ * row (D9: at most 8 repos before `+ N more`).
15
+ * turned off slugs in `excluded`. Choosing one is a re-include; the grant block names it on a `turn on` row, inside
16
+ * the same y/N (D7 · 3A), and only a yes clears the exclusion.
17
+ *
18
+ * ⛔ **A pre-tick is not a consent** (D34): the working folder's repo starts ticked, and Enter on it leads to the naming
19
+ * y/N, which defaults to N. It is an ADDITION to the diff, never part of the baseline.
20
+ */
21
+ import { ownerOfSlug, normalizeSlug, normalizeOwner, isLocalSlug } from "./consent.js";
22
+ import { AGENT_HOOKS, SCAN, PICKER, shortDate } from "./copy/index.js";
23
+ import { LABEL_GUTTER, paint, truncateMiddle, wrap } from "./term.js";
24
+ /** D9: each owner group shows at most this many repos before the `+ N more` row. */
25
+ export const GROUP_CAP = 8;
26
+ /** The gap between the name column and the counts, as `scan`'s screen has it. */
27
+ const GAP = 3;
28
+ /** The width of the lead before a name: a right-aligned number and two spaces, or a cursor and a tick box. */
29
+ const LEAD = 8;
30
+ const AGENT_ORDER = ["claude-code", "codex-cli", "cursor"];
31
+ export const NO_CHANGE = {
32
+ addOwners: [],
33
+ addRepos: [],
34
+ turnOn: [],
35
+ stopOwners: [],
36
+ stopRepos: [],
37
+ stopClones: [],
38
+ };
39
+ export function isNoChange(change) {
40
+ return (change.addOwners.length === 0 &&
41
+ change.addRepos.length === 0 &&
42
+ change.turnOn.length === 0 &&
43
+ change.stopOwners.length === 0 &&
44
+ change.stopRepos.length === 0 &&
45
+ change.stopClones.length === 0);
46
+ }
47
+ /**
48
+ * What Enter means when the working folder's repo is pre-selected (D34): choosing it, which leads to the y/N and never to
49
+ * a grant. `null` when nothing is pre-selected, where Enter changes nothing (D5).
50
+ */
51
+ export function preChange(model) {
52
+ const repo = model.owners.flatMap((g) => g.repos).find((r) => r.pre);
53
+ if (repo !== undefined)
54
+ return { ...NO_CHANGE, addRepos: [repo.slug] };
55
+ const off = model.turnedOff.find((r) => r.pre);
56
+ return off === undefined ? null : { ...NO_CHANGE, turnOn: [off.slug] };
57
+ }
58
+ /** Anything the person is ADDING needs the D65 disclosure; removals alone do not. */
59
+ export function hasAdditions(change) {
60
+ return change.addOwners.length + change.addRepos.length + change.turnOn.length > 0;
61
+ }
62
+ function splitSlug(slug) {
63
+ const owner = ownerOfSlug(slug);
64
+ return { owner, name: owner === "" ? slug : slug.slice(owner.length + 1) };
65
+ }
66
+ function emptyAgents() {
67
+ return { "claude-code": 0, "codex-cli": 0, cursor: 0 };
68
+ }
69
+ export function buildModel(input) {
70
+ const { grants } = input;
71
+ const excluded = new Set(grants.excluded);
72
+ const coveredByGrant = (slug) => grants.repos.includes(slug) || grants.owners.includes(ownerOfSlug(slug));
73
+ const repos = new Map();
74
+ for (const repo of input.repos) {
75
+ const slug = normalizeSlug(repo.slug);
76
+ const { owner, name } = splitSlug(slug);
77
+ repos.set(slug, {
78
+ slug,
79
+ owner,
80
+ name,
81
+ sessions: repo.sessions,
82
+ sinceMs: repo.sinceMs,
83
+ latestMs: repo.latestMs,
84
+ agents: repo.agents,
85
+ clones: repo.clones,
86
+ here: null,
87
+ pre: false,
88
+ });
89
+ }
90
+ // This repo's row comes from the working folder, not the scan: a repo no agent has run in yet still appears (D34).
91
+ if (input.here !== null) {
92
+ const slug = normalizeSlug(input.here.slug);
93
+ const found = repos.get(slug);
94
+ const { owner, name } = splitSlug(slug);
95
+ const base = found ?? {
96
+ slug,
97
+ owner,
98
+ name,
99
+ sessions: 0,
100
+ sinceMs: 0,
101
+ latestMs: 0,
102
+ agents: emptyAgents(),
103
+ clones: [{ path: input.here.toplevel, hooksPath: null }],
104
+ here: null,
105
+ pre: false,
106
+ };
107
+ repos.set(slug, { ...base, here: input.here.kind, pre: input.here.kind === "repo" });
108
+ }
109
+ const perCloneSlugs = new Map();
110
+ for (const entry of input.perClone) {
111
+ if (entry.slug === null)
112
+ continue;
113
+ const slug = normalizeSlug(entry.slug);
114
+ if (isLocalSlug(slug))
115
+ continue;
116
+ perCloneSlugs.set(slug, [...(perCloneSlugs.get(slug) ?? []), entry.commonDir]);
117
+ }
118
+ const countsOf = (slug) => {
119
+ const r = repos.get(slug);
120
+ return r === undefined || r.sessions === 0 ? null : r;
121
+ };
122
+ const onNow = [];
123
+ for (const owner of grants.owners) {
124
+ const reposHere = [...repos.values()].filter((r) => r.owner === owner).length;
125
+ onNow.push({ kind: "owner", key: owner, reason: PICKER.reasonOwner, counts: null, reposHere, commonDirs: [] });
126
+ }
127
+ for (const slug of grants.repos) {
128
+ if (excluded.has(slug) || grants.owners.includes(ownerOfSlug(slug)))
129
+ continue;
130
+ onNow.push({ kind: "repo", key: slug, reason: PICKER.reasonRepo, counts: countsOf(slug), reposHere: 0, commonDirs: [] });
131
+ }
132
+ for (const [slug, commonDirs] of perCloneSlugs) {
133
+ if (excluded.has(slug) || coveredByGrant(slug))
134
+ continue;
135
+ onNow.push({ kind: "clone", key: slug, reason: PICKER.reasonPerClone, counts: countsOf(slug), reposHere: 0, commonDirs });
136
+ }
137
+ const isOn = (slug) => coveredByGrant(slug) || perCloneSlugs.has(slug);
138
+ const byOwner = new Map();
139
+ for (const repo of repos.values()) {
140
+ if (excluded.has(repo.slug) || isOn(repo.slug))
141
+ continue;
142
+ byOwner.set(repo.owner, [...(byOwner.get(repo.owner) ?? []), repo]);
143
+ }
144
+ const groups = [...byOwner.entries()]
145
+ .map(([owner, list]) => ({
146
+ owner,
147
+ list: [...list].sort((a, b) => b.latestMs - a.latestMs || (a.slug < b.slug ? -1 : 1)),
148
+ }))
149
+ .sort((a, b) => b.list[0].latestMs - a.list[0].latestMs || (a.owner < b.owner ? -1 : 1));
150
+ const owners = groups.map((g, i) => ({ owner: g.owner, token: `a${i + 1}`, repos: g.list }));
151
+ const hereSlug = input.here?.kind === "repo" ? normalizeSlug(input.here.slug) : null;
152
+ const turnedOff = [...excluded]
153
+ .sort()
154
+ .map((slug) => ({ slug, counts: countsOf(slug), pre: slug === hereSlug }));
155
+ return {
156
+ onNow,
157
+ owners,
158
+ turnedOff,
159
+ notListed: input.notListed,
160
+ found: input.repos.length,
161
+ sawNothing: input.sawNothing,
162
+ };
163
+ }
164
+ /**
165
+ * The numbered rows in screen order. `showAll` is the `more` token: every repo is shown and numbered afresh. Without it
166
+ * each owner shows `GROUP_CAP` repos and the rest are reachable only by typing `owner/repo` or `more`.
167
+ */
168
+ export function numberedRows(model, showAll) {
169
+ const rows = model.onNow.map((row) => ({ kind: "on", row }));
170
+ for (const group of model.owners) {
171
+ const shown = showAll ? group.repos : group.repos.slice(0, GROUP_CAP);
172
+ for (const repo of shown)
173
+ rows.push({ kind: "repo", repo });
174
+ }
175
+ for (const row of model.turnedOff)
176
+ rows.push({ kind: "off", row });
177
+ return rows;
178
+ }
179
+ const OWNER_TOKEN = /^[^\s/]+(?:\/[^\s/]+)*\/$/u;
180
+ const REPO_TOKEN = /^[^\s/]+(?:\/[^\s/]+)*\/[^\s/]+$/u;
181
+ /**
182
+ * Parse one typed answer (D8 · 4A). ⛔ **Never applies part of an answer:** any token that is not understood returns
183
+ * `error` naming each such token, and nothing is chosen. `owner/` and `owner/repo` are always accepted, a clone here or
184
+ * not, because a new laptop needs them; the grant block flags `(no clone here yet)`.
185
+ */
186
+ export function parseTyped(input, model, showAll) {
187
+ const tokens = input.split(/[\s,]+/u).filter((t) => t !== "");
188
+ if (tokens.length === 0)
189
+ return { kind: "empty" };
190
+ if (tokens.length === 1 && tokens[0].toLowerCase() === "more")
191
+ return { kind: "more" };
192
+ const rows = numberedRows(model, showAll);
193
+ const bad = [];
194
+ const addOwners = new Set();
195
+ const addRepos = new Set();
196
+ const turnOn = new Set();
197
+ const stopOwners = new Set();
198
+ const stopRepos = new Set();
199
+ const stopClones = new Set();
200
+ const grantedOwners = new Set(model.onNow.filter((r) => r.kind === "owner").map((r) => r.key));
201
+ const grantedRepos = new Set(model.onNow.filter((r) => r.kind === "repo").map((r) => r.key));
202
+ const cloneSlugs = new Set(model.onNow.filter((r) => r.kind === "clone").map((r) => r.key));
203
+ const offSlugs = new Set(model.turnedOff.map((r) => r.slug));
204
+ const stop = (row) => {
205
+ if (row.kind === "owner")
206
+ stopOwners.add(row.key);
207
+ else if (row.kind === "repo")
208
+ stopRepos.add(row.key);
209
+ else
210
+ stopClones.add(row.key);
211
+ };
212
+ const addSlug = (slug, token) => {
213
+ if (offSlugs.has(slug))
214
+ turnOn.add(slug);
215
+ else if (grantedRepos.has(slug) || cloneSlugs.has(slug) || grantedOwners.has(ownerOfSlug(slug))) {
216
+ bad.push(PICKER.alreadyOn(token, `-${token}`));
217
+ }
218
+ else
219
+ addRepos.add(slug);
220
+ };
221
+ for (const token of tokens) {
222
+ const owner = /^a(\d+)$/iu.exec(token);
223
+ const remove = token.startsWith("-") && token.length > 1 ? token.slice(1) : null;
224
+ if (owner !== null) {
225
+ const group = model.owners.find((g) => g.token === `a${Number(owner[1])}`);
226
+ if (group === undefined)
227
+ bad.push(PICKER.noRow(token));
228
+ else
229
+ addOwners.add(normalizeOwner(group.owner));
230
+ }
231
+ else if (/^\d+$/u.test(token)) {
232
+ const row = rows[Number(token) - 1];
233
+ if (row === undefined)
234
+ bad.push(PICKER.noRow(token));
235
+ else if (row.kind === "on")
236
+ bad.push(PICKER.alreadyOn(token, `-${token}`));
237
+ else if (row.kind === "off")
238
+ turnOn.add(row.row.slug);
239
+ else
240
+ addSlug(row.repo.slug, token);
241
+ }
242
+ else if (remove !== null) {
243
+ const n = /^\d+$/u.test(remove) ? rows[Number(remove) - 1] : undefined;
244
+ if (/^\d+$/u.test(remove)) {
245
+ if (n === undefined)
246
+ bad.push(PICKER.noRow(remove));
247
+ else if (n.kind === "on")
248
+ stop(n.row);
249
+ else
250
+ bad.push(PICKER.notOn(token));
251
+ }
252
+ else if (OWNER_TOKEN.test(remove)) {
253
+ const name = normalizeOwner(remove);
254
+ if (grantedOwners.has(name))
255
+ stopOwners.add(name);
256
+ else
257
+ bad.push(PICKER.notOn(token));
258
+ }
259
+ else if (REPO_TOKEN.test(remove)) {
260
+ const name = normalizeSlug(remove);
261
+ if (grantedRepos.has(name))
262
+ stopRepos.add(name);
263
+ else if (cloneSlugs.has(name))
264
+ stopClones.add(name);
265
+ else
266
+ bad.push(PICKER.notOn(token));
267
+ }
268
+ else
269
+ bad.push(token);
270
+ }
271
+ else if (OWNER_TOKEN.test(token)) {
272
+ const name = normalizeOwner(token);
273
+ if (grantedOwners.has(name))
274
+ bad.push(PICKER.alreadyOn(token, `-${token}`));
275
+ else
276
+ addOwners.add(name);
277
+ }
278
+ else if (REPO_TOKEN.test(token)) {
279
+ addSlug(normalizeSlug(token), token);
280
+ }
281
+ else
282
+ bad.push(token);
283
+ }
284
+ if (bad.length > 0)
285
+ return { kind: "error", tokens: bad };
286
+ // Typed owners/repos with no clone on this machine are accepted and flagged in the grant block.
287
+ const known = new Set(model.owners.map((g) => normalizeOwner(g.owner)));
288
+ const knownRepos = new Set(model.owners.flatMap((g) => g.repos.map((r) => r.slug)));
289
+ const noClone = [
290
+ ...[...addOwners].filter((o) => !known.has(o) && !model.onNow.some((r) => r.kind === "owner" && r.key === o)).map((o) => `${o}/`),
291
+ ...[...addRepos].filter((r) => !knownRepos.has(r)),
292
+ ];
293
+ return {
294
+ kind: "change",
295
+ noClone,
296
+ change: {
297
+ addOwners: [...addOwners],
298
+ addRepos: [...addRepos].filter((slug) => ![...addOwners].includes(ownerOfSlug(slug))),
299
+ turnOn: [...turnOn],
300
+ stopOwners: [...stopOwners],
301
+ stopRepos: [...stopRepos],
302
+ stopClones: [...stopClones],
303
+ },
304
+ };
305
+ }
306
+ function countsText(counts, nowMs) {
307
+ if (counts === null)
308
+ return "";
309
+ return `${SCAN.sessions(counts.sessions)} · ${SCAN.since(shortDate(counts.sinceMs, nowMs))}`;
310
+ }
311
+ function agentsText(counts) {
312
+ if (counts === null)
313
+ return "";
314
+ const used = AGENT_ORDER.filter((agent) => counts.agents[agent] > 0);
315
+ return used.length > 1 ? used.map((agent) => SCAN.agent[agent]).join(", ") : "";
316
+ }
317
+ function joinMeta(...parts) {
318
+ return parts.filter((p) => p !== "").join(" · ");
319
+ }
320
+ function onNowDisplay(row, nowMs) {
321
+ if (row.kind === "owner") {
322
+ return { name: `${row.key}/`, meta: joinMeta(row.reason, PICKER.ownerOnReposHere(row.reposHere)), agents: "" };
323
+ }
324
+ return {
325
+ name: row.key,
326
+ meta: joinMeta(row.reason, countsText(row.counts, nowMs)),
327
+ agents: agentsText(row.counts),
328
+ };
329
+ }
330
+ function repoDisplay(repo, nowMs) {
331
+ const flag = repo.here === "repo" ? PICKER.thisRepo : repo.here === "folder" ? PICKER.thisFolder : "";
332
+ return {
333
+ name: repo.name,
334
+ meta: joinMeta(countsText(repo.sessions === 0 ? null : repo, nowMs), flag),
335
+ agents: agentsText(repo.sessions === 0 ? null : repo),
336
+ };
337
+ }
338
+ /**
339
+ * The name column and the cut names (D27 · 21A). A name is cut in the MIDDLE to the widest column whose counts still fit
340
+ * in 80 columns; the agent-names suffix is dropped before any name is cut; an owner is never cut (it is in the group
341
+ * heading, and an on-now/turned-off row keeps its owner and cuts only the repo part).
342
+ */
343
+ function layout(rows) {
344
+ const longestMeta = rows.reduce((n, r) => Math.max(n, r.meta.length), 0);
345
+ const longestName = rows.reduce((n, r) => Math.max(n, r.name.length), 0);
346
+ const room = 80 - LEAD - GAP - longestMeta;
347
+ const column = Math.max(12, Math.min(longestName, room));
348
+ const cut = (name) => {
349
+ if (name.length <= column)
350
+ return name;
351
+ const slash = name.lastIndexOf("/");
352
+ if (slash >= 0 && slash + 1 < column) {
353
+ return `${name.slice(0, slash + 1)}${truncateMiddle(name.slice(slash + 1), column - slash - 1)}`;
354
+ }
355
+ return truncateMiddle(name, column);
356
+ };
357
+ const withAgents = (row) => row.agents !== "" && LEAD + column + GAP + row.meta.length + 3 + row.agents.length <= 80
358
+ ? `${row.meta} · ${row.agents}`
359
+ : row.meta;
360
+ return { column, cut, withAgents };
361
+ }
362
+ /** ` alexvibecommit/ a1 all 3 repos here, including repos you clone later` */
363
+ function ownerLine(group, colour) {
364
+ return ` ${paint(colour, "text", `${group.owner}/`)} ${paint(colour, "text", group.token)} ${paint(colour, "text", PICKER.ownerAll(group.repos.length))}`;
365
+ }
366
+ /** The `Not listed here:` block, muted, or nothing. */
367
+ export function notListedBlock(model, colour) {
368
+ if (model.notListed.length === 0)
369
+ return [];
370
+ return [
371
+ "",
372
+ paint(colour, "muted", ` ${SCAN.notListed}`),
373
+ ...model.notListed.map((line) => paint(colour, "muted", ` ${line}`)),
374
+ ];
375
+ }
376
+ /** The screen above the prompt, as lines — the typed picker (§10.12 "The picker, typed"). */
377
+ export function renderTyped(model, options) {
378
+ const { colour, nowMs, showAll } = options;
379
+ const lines = [""];
380
+ if (model.found === 0 && model.onNow.length === 0 && model.turnedOff.length === 0 && model.owners.length === 0) {
381
+ return [
382
+ ...lines,
383
+ ...wrap(model.sawNothing ? PICKER.emptyNone : SCAN.noneListable, 2).map((l) => paint(colour, "strong", l)),
384
+ ...(model.sawNothing ? [...wrap(PICKER.emptyExpected, 2), ...wrap(AGENT_HOOKS.codexTrust, 2)] : []),
385
+ ...notListedBlock(model, colour),
386
+ "",
387
+ ...wrap(model.sawNothing ? PICKER.emptyType : PICKER.emptyListable, 2),
388
+ ` ${PICKER.enterNothing}`,
389
+ ];
390
+ }
391
+ lines.push(...wrap(SCAN.found(model.found), 2).map((l) => paint(colour, "strong", l)), "");
392
+ const onNow = model.onNow.map((row) => onNowDisplay(row, nowMs));
393
+ const shown = (g) => (showAll ? g.repos : g.repos.slice(0, GROUP_CAP));
394
+ const repoRows = model.owners.flatMap((g) => shown(g).map((r) => repoDisplay(r, nowMs)));
395
+ const off = model.turnedOff.map((row) => ({
396
+ name: row.slug,
397
+ meta: PICKER.turnOnHint,
398
+ agents: "",
399
+ }));
400
+ const { cut, column, withAgents } = layout([...onNow, ...repoRows, ...off]);
401
+ const row = (n, display) => `${String(n).padStart(6)} ${paint(colour, "text", cut(display.name).padEnd(column))}${" ".repeat(GAP)}${paint(colour, "muted", withAgents(display))}`;
402
+ let n = 0;
403
+ if (model.onNow.length > 0) {
404
+ lines.push(paint(colour, "muted", ` ${PICKER.onNow}`));
405
+ for (const d of onNow)
406
+ lines.push(row(++n, d));
407
+ }
408
+ for (const group of model.owners) {
409
+ lines.push(ownerLine(group, colour));
410
+ for (const repo of shown(group))
411
+ lines.push(row(++n, repoDisplay(repo, nowMs)));
412
+ const hidden = group.repos.length - shown(group).length;
413
+ if (hidden > 0) {
414
+ lines.push(paint(colour, "muted", ` + ${PICKER.moreTyped(hidden, group.owner)}`));
415
+ }
416
+ }
417
+ if (model.turnedOff.length > 0) {
418
+ lines.push(paint(colour, "muted", ` ${PICKER.turnedOff}`));
419
+ for (const d of off)
420
+ lines.push(row(++n, d));
421
+ }
422
+ lines.push(...notListedBlock(model, colour), "");
423
+ lines.push(` ${paint(colour, "text", PICKER.choose(model.owners.length > 0))}`);
424
+ if (model.onNow.length > 0)
425
+ lines.push(` ${paint(colour, "text", PICKER.removeHint)}`);
426
+ const pre = preChange(model);
427
+ const preSlug = pre === null ? null : (pre.addRepos[0] ?? pre.turnOn[0] ?? null);
428
+ lines.push(` ${paint(colour, "text", preSlug === null ? PICKER.enterNothing : PICKER.enterThisRepo(preSlug))}`);
429
+ return lines;
430
+ }
431
+ /** The `Not understood:` line the prompt prints before it asks again. */
432
+ export function notUnderstoodLines(tokens) {
433
+ return wrap(PICKER.notUnderstood(tokens.join(", ")), 2);
434
+ }
435
+ /**
436
+ * 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
437
+ * change; an owner row says how many repos are here and that later clones are covered, and a typed name with no clone
438
+ * on this machine says so, so a typo shows BEFORE the question.
439
+ */
440
+ export function renderGrantBlock(change, facts, colour) {
441
+ const lines = [` ${paint(colour, "strong", PICKER.chose)}`];
442
+ const rows = [
443
+ [
444
+ PICKER.labelOwners,
445
+ change.addOwners.map((o) => {
446
+ const here = facts.reposHere.get(o) ?? 0;
447
+ return `${o}/ ${here > 0 ? PICKER.ownerHere(here) : PICKER.noCloneHere}`;
448
+ }),
449
+ ],
450
+ [
451
+ PICKER.labelRepos,
452
+ change.addRepos.map((r) => (facts.noClone.has(r) ? `${r} ${PICKER.noCloneHere}` : r)),
453
+ ],
454
+ [PICKER.labelTurnOn, [...change.turnOn]],
455
+ [
456
+ PICKER.labelStop,
457
+ [
458
+ ...change.stopOwners.map((o) => `${o}/`),
459
+ ...change.stopRepos,
460
+ ...change.stopClones,
461
+ ],
462
+ ],
463
+ ];
464
+ for (const [label, values] of rows) {
465
+ values.forEach((value, i) => {
466
+ const key = i === 0 ? label.padEnd(LABEL_GUTTER) : " ".repeat(LABEL_GUTTER);
467
+ lines.push(` ${paint(colour, "muted", key)}${paint(colour, "text", value)}`);
468
+ });
469
+ }
470
+ return lines;
471
+ }
472
+ /** The grant y/N: additions (with or without removals) get the upload question, removals alone the stop question. */
473
+ export function grantQuestion(change, colour, answers) {
474
+ const q = hasAdditions(change) ? PICKER.questionGrant : PICKER.questionStop;
475
+ return ` ${paint(colour, "text", q)} ${paint(colour, "muted", answers)} `;
476
+ }
477
+ //# sourceMappingURL=picker.js.map