@mnemom/mnemom 0.17.34 → 0.17.36

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 (177) hide show
  1. package/dist/commands/agent-config.js +3 -0
  2. package/dist/commands/agent-goal.d.ts +8 -3
  3. package/dist/commands/agent-goal.js +22 -8
  4. package/dist/commands/agent-run.d.ts +14 -2
  5. package/dist/commands/agent-run.js +28 -7
  6. package/dist/commands/agent-share-render.js +1 -1
  7. package/dist/commands/agent-slack.js +32 -0
  8. package/dist/commands/durable-inbox.d.ts +51 -0
  9. package/dist/commands/durable-inbox.js +134 -0
  10. package/dist/commands/durable-questions.d.ts +64 -0
  11. package/dist/commands/durable-questions.js +161 -0
  12. package/dist/commands/scan.d.ts +13 -3
  13. package/dist/commands/scan.js +48 -10
  14. package/dist/durable/seat-proxy.d.ts +12 -1
  15. package/dist/durable/seat-proxy.js +55 -9
  16. package/dist/durable/seat-socket.d.ts +20 -2
  17. package/dist/durable/seat-socket.js +58 -0
  18. package/dist/durable/turn-chain.d.ts +8 -0
  19. package/dist/durable/turn-chain.js +25 -4
  20. package/dist/host/client.d.ts +41 -0
  21. package/dist/host/client.js +7 -0
  22. package/dist/host/control.d.ts +7 -0
  23. package/dist/host/control.js +15 -0
  24. package/dist/host/daemon.d.ts +20 -0
  25. package/dist/host/daemon.js +74 -1
  26. package/dist/host/inbox-delivery.d.ts +174 -0
  27. package/dist/host/inbox-delivery.js +453 -0
  28. package/dist/host/run.d.ts +18 -0
  29. package/dist/host/run.js +61 -0
  30. package/dist/host/s1-names.d.ts +2 -0
  31. package/dist/host/s1-names.js +2 -0
  32. package/dist/host/tmux.d.ts +7 -0
  33. package/dist/host/tmux.js +19 -0
  34. package/dist/index.js +62 -3
  35. package/dist/lib/api.d.ts +5 -0
  36. package/dist/lib/api.js +16 -0
  37. package/dist/lib/config.d.ts +7 -0
  38. package/dist/lib/config.js +21 -0
  39. package/dist/lib/durable/api.d.ts +11 -0
  40. package/dist/lib/durable/api.js +28 -3
  41. package/dist/lib/durable/gate.d.ts +1 -1
  42. package/dist/lib/durable/gate.js +8 -1
  43. package/dist/lib/durable/live.d.ts +5 -0
  44. package/dist/lib/durable/live.js +19 -0
  45. package/dist/lib/experimental.js +3 -0
  46. package/dist/lib/goal-api.d.ts +11 -4
  47. package/dist/lib/goal-api.js +4 -3
  48. package/dist/lib/launcher-config.d.ts +5 -0
  49. package/dist/lib/launcher-config.js +17 -2
  50. package/dist/lib/prompt.d.ts +4 -2
  51. package/dist/lib/prompt.js +15 -5
  52. package/dist/lib/runner/job.d.ts +2 -2
  53. package/dist/lib/runner/job.js +16 -3
  54. package/dist/lib/runner/pr-body.d.ts +17 -0
  55. package/dist/lib/runner/pr-body.js +42 -0
  56. package/dist/lib/runner/proxy.d.ts +18 -3
  57. package/dist/lib/runner/proxy.js +24 -5
  58. package/dist/lib/scan/pr-source.js +2 -0
  59. package/dist/lib/scan/record.d.ts +60 -9
  60. package/dist/lib/scan/record.js +157 -39
  61. package/dist/lib/scan/repo-picker.d.ts +32 -0
  62. package/dist/lib/scan/repo-picker.js +80 -0
  63. package/dist/lib/scan/report.d.ts +6 -8
  64. package/dist/lib/scan/report.js +21 -7
  65. package/dist/lib/scan/share.d.ts +5 -1
  66. package/dist/lib/scan/share.js +15 -5
  67. package/dist/lib/slack-durable.d.ts +123 -0
  68. package/dist/lib/slack-durable.js +237 -0
  69. package/dist/lib/slack-router.d.ts +7 -0
  70. package/dist/lib/slack-router.js +50 -4
  71. package/dist/vendor/durable-contract/index.js +992 -34
  72. package/dist/vendor/shared/durable-contract/admission.d.ts +81 -2
  73. package/dist/vendor/shared/durable-contract/admission.js +319 -2
  74. package/dist/vendor/shared/durable-contract/cli-surface.js +21 -0
  75. package/dist/vendor/shared/durable-contract/codes.d.ts +60 -0
  76. package/dist/vendor/shared/durable-contract/codes.js +22 -0
  77. package/dist/vendor/shared/durable-contract/events.d.ts +4 -2
  78. package/dist/vendor/shared/durable-contract/events.js +55 -1
  79. package/dist/vendor/shared/durable-contract/freeze-record.d.ts +14 -0
  80. package/dist/vendor/shared/durable-contract/inbox.d.ts +299 -0
  81. package/dist/vendor/shared/durable-contract/inbox.js +462 -0
  82. package/dist/vendor/shared/durable-contract/index.d.ts +2 -0
  83. package/dist/vendor/shared/durable-contract/index.js +2 -0
  84. package/dist/vendor/shared/durable-contract/question.d.ts +218 -0
  85. package/dist/vendor/shared/durable-contract/question.js +373 -0
  86. package/dist/vendor/shared/durable-contract/routes.d.ts +44 -2
  87. package/dist/vendor/shared/durable-contract/routes.js +43 -1
  88. package/dist/vendor/shared/durable-contract/turn.d.ts +15 -1
  89. package/dist/vendor/shared/durable-contract/turn.js +33 -1
  90. package/dist/vendor/shared/durable-contract/vectors.d.ts +45 -0
  91. package/dist/vendor/shared/durable-contract/vectors.js +49 -0
  92. package/node_modules/@mnemom/aip-verifier/LICENSE +190 -0
  93. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.cts +478 -0
  94. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.ts +478 -0
  95. package/node_modules/@mnemom/aip-verifier/dist/index.cjs +971 -0
  96. package/node_modules/@mnemom/aip-verifier/dist/index.cjs.map +1 -0
  97. package/node_modules/@mnemom/aip-verifier/dist/index.d.cts +330 -0
  98. package/node_modules/@mnemom/aip-verifier/dist/index.d.ts +330 -0
  99. package/node_modules/@mnemom/aip-verifier/dist/index.js +933 -0
  100. package/node_modules/@mnemom/aip-verifier/dist/index.js.map +1 -0
  101. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs +879 -0
  102. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs.map +1 -0
  103. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.cts +96 -0
  104. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.ts +96 -0
  105. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js +875 -0
  106. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js.map +1 -0
  107. package/node_modules/@mnemom/aip-verifier/package.json +86 -0
  108. package/node_modules/@noble/ed25519/LICENSE +21 -0
  109. package/node_modules/@noble/ed25519/README.md +371 -0
  110. package/node_modules/@noble/ed25519/index.d.ts +391 -0
  111. package/node_modules/@noble/ed25519/index.js +933 -0
  112. package/node_modules/@noble/ed25519/index.ts +1134 -0
  113. package/node_modules/@noble/ed25519/package.json +54 -0
  114. package/node_modules/@noble/hashes/LICENSE +21 -0
  115. package/node_modules/@noble/hashes/README.md +478 -0
  116. package/node_modules/@noble/hashes/_blake.d.ts +20 -0
  117. package/node_modules/@noble/hashes/_blake.js +59 -0
  118. package/node_modules/@noble/hashes/_md.d.ts +95 -0
  119. package/node_modules/@noble/hashes/_md.js +209 -0
  120. package/node_modules/@noble/hashes/_u64.d.ts +36 -0
  121. package/node_modules/@noble/hashes/_u64.js +77 -0
  122. package/node_modules/@noble/hashes/argon2.d.ts +148 -0
  123. package/node_modules/@noble/hashes/argon2.js +577 -0
  124. package/node_modules/@noble/hashes/blake1.d.ts +162 -0
  125. package/node_modules/@noble/hashes/blake1.js +630 -0
  126. package/node_modules/@noble/hashes/blake2.d.ts +203 -0
  127. package/node_modules/@noble/hashes/blake2.js +573 -0
  128. package/node_modules/@noble/hashes/blake3.d.ts +68 -0
  129. package/node_modules/@noble/hashes/blake3.js +310 -0
  130. package/node_modules/@noble/hashes/eskdf.d.ts +91 -0
  131. package/node_modules/@noble/hashes/eskdf.js +225 -0
  132. package/node_modules/@noble/hashes/hkdf.d.ts +70 -0
  133. package/node_modules/@noble/hashes/hkdf.js +152 -0
  134. package/node_modules/@noble/hashes/hmac.d.ts +50 -0
  135. package/node_modules/@noble/hashes/hmac.js +93 -0
  136. package/node_modules/@noble/hashes/index.d.ts +1 -0
  137. package/node_modules/@noble/hashes/index.js +34 -0
  138. package/node_modules/@noble/hashes/legacy.d.ts +107 -0
  139. package/node_modules/@noble/hashes/legacy.js +348 -0
  140. package/node_modules/@noble/hashes/package.json +91 -0
  141. package/node_modules/@noble/hashes/pbkdf2.d.ts +66 -0
  142. package/node_modules/@noble/hashes/pbkdf2.js +176 -0
  143. package/node_modules/@noble/hashes/scrypt.d.ts +98 -0
  144. package/node_modules/@noble/hashes/scrypt.js +304 -0
  145. package/node_modules/@noble/hashes/sha2.d.ts +176 -0
  146. package/node_modules/@noble/hashes/sha2.js +446 -0
  147. package/node_modules/@noble/hashes/sha3-addons.d.ts +533 -0
  148. package/node_modules/@noble/hashes/sha3-addons.js +973 -0
  149. package/node_modules/@noble/hashes/sha3.d.ts +208 -0
  150. package/node_modules/@noble/hashes/sha3.js +475 -0
  151. package/node_modules/@noble/hashes/src/_blake.ts +67 -0
  152. package/node_modules/@noble/hashes/src/_md.ts +227 -0
  153. package/node_modules/@noble/hashes/src/_u64.ts +111 -0
  154. package/node_modules/@noble/hashes/src/argon2.ts +693 -0
  155. package/node_modules/@noble/hashes/src/blake1.ts +677 -0
  156. package/node_modules/@noble/hashes/src/blake2.ts +648 -0
  157. package/node_modules/@noble/hashes/src/blake3.ts +344 -0
  158. package/node_modules/@noble/hashes/src/eskdf.ts +265 -0
  159. package/node_modules/@noble/hashes/src/hkdf.ts +161 -0
  160. package/node_modules/@noble/hashes/src/hmac.ts +130 -0
  161. package/node_modules/@noble/hashes/src/index.ts +33 -0
  162. package/node_modules/@noble/hashes/src/legacy.ts +361 -0
  163. package/node_modules/@noble/hashes/src/pbkdf2.ts +219 -0
  164. package/node_modules/@noble/hashes/src/scrypt.ts +375 -0
  165. package/node_modules/@noble/hashes/src/sha2.ts +486 -0
  166. package/node_modules/@noble/hashes/src/sha3-addons.ts +1157 -0
  167. package/node_modules/@noble/hashes/src/sha3.ts +525 -0
  168. package/node_modules/@noble/hashes/src/utils.ts +978 -0
  169. package/node_modules/@noble/hashes/src/webcrypto.ts +283 -0
  170. package/node_modules/@noble/hashes/utils.d.ts +560 -0
  171. package/node_modules/@noble/hashes/utils.js +687 -0
  172. package/node_modules/@noble/hashes/webcrypto.d.ts +121 -0
  173. package/node_modules/@noble/hashes/webcrypto.js +228 -0
  174. package/package.json +5 -2
  175. package/plugins/integrity-manifest.json +6 -2
  176. package/plugins/mnemom/hooks/hooks.json +12 -0
  177. package/plugins/mnemom/hooks/seat-ask.mjs +221 -0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The body of the PR `mnemom agent run` opens: the job's goal line from the sealed contract and the
3
+ * Canon node its seal points to, both from the job read the runner already holds (no extra call).
4
+ * Never the contract body or any key: the goal is the contract's `statement`, on one line and
5
+ * capped; the node is cited only when the job read names a well-formed one, and a job without one
6
+ * still opens its PR, with no citation.
7
+ */
8
+ /** The goal line's cap, in characters: a PR body line, not the contract. */
9
+ export const PR_GOAL_MAX = 280;
10
+ /** A Canon node key as the seal stores it: no spaces, markup or line breaks. */
11
+ const NODE_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/;
12
+ /** The node key when well formed, else null. */
13
+ export function canonNodeKey(v) {
14
+ return typeof v === "string" && NODE_KEY_RE.test(v) ? v : null;
15
+ }
16
+ /** The sealed contract's statement on one line, capped; null when the bytes carry none. */
17
+ export function goalLine(contract) {
18
+ let obj;
19
+ try {
20
+ obj = JSON.parse(contract);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ if (!obj || typeof obj !== "object")
26
+ return null;
27
+ const s = obj.statement;
28
+ if (typeof s !== "string")
29
+ return null;
30
+ const line = s.replace(/\s+/g, " ").trim();
31
+ if (!line)
32
+ return null;
33
+ return line.length > PR_GOAL_MAX ? `${line.slice(0, PR_GOAL_MAX - 1)}…` : line;
34
+ }
35
+ export function prBody(job) {
36
+ const goal = goalLine(job.contract);
37
+ const lines = [`Goal: ${goal ?? "(the sealed contract states none)"}`];
38
+ if (job.canon_node_key !== null)
39
+ lines.push("", `Canon node: \`${job.canon_node_key}\``);
40
+ lines.push("", "Opened by `mnemom agent run` for a sealed contract.");
41
+ return `${lines.join("\n")}\n`;
42
+ }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * A served phase's Claude Code (and a code phase's child) points ANTHROPIC_BASE_URL here, on
5
5
  * loopback. The proxy forwards each request to the gateway and decides every Mnemom header itself:
6
- * any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the job read's agent id in
6
+ * any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the runner's own
7
+ * Anthropic key in x-api-key (ruling 34.30.1; the phase only ever holds a placeholder), the job read's agent id in
7
8
  * x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
8
9
  * sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
9
10
  * sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
@@ -36,7 +37,20 @@ export interface ModelProxy {
36
37
  close(): Promise<void>;
37
38
  }
38
39
  /** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
39
- export declare function outboundHeaders(incoming: IncomingMessage["headers"], state: ProxyState): Record<string, string | string[] | undefined>;
40
+ export declare function outboundHeaders(incoming: IncomingMessage["headers"], state: ProxyState, modelKey: string): Record<string, string | string[] | undefined>;
41
+ /** What the job's model requests carried: each contract by its sha256, in first-seen order. */
42
+ export interface RequestSummary {
43
+ count: number;
44
+ contracts: {
45
+ contract_sha256: string;
46
+ requests: number;
47
+ }[];
48
+ }
49
+ /**
50
+ * One job's forwarded requests, summarised for the operator's log and for the door2-path header
51
+ * check (W3.11): the sha256 of each header value's decoded bytes is the contract it sealed to.
52
+ */
53
+ export declare function requestSummary(forwarded: readonly ForwardedRequest[]): RequestSummary;
40
54
  /** The largest 409 body the proxy reads for a contract mismatch. */
41
55
  export declare const MISMATCH_BODY_MAX: number;
42
56
  /** The client for the gateway's scheme: TLS for https, plain only for a local http gateway. */
@@ -46,4 +60,5 @@ export declare function requestFor(protocol: string): typeof httpRequest;
46
60
  * cut, so a truncated body is never mistaken for a whole one.
47
61
  */
48
62
  export declare function upstreamFailed(res: Pick<ServerResponse, "headersSent" | "writeHead" | "end" | "destroy">, e: Error): void;
49
- export declare function startModelProxy(gatewayUrl: string, state: ProxyState): Promise<ModelProxy>;
63
+ /** `modelKey` is the runner process's own ANTHROPIC_API_KEY; it never enters a phase's env. */
64
+ export declare function startModelProxy(gatewayUrl: string, state: ProxyState, modelKey: string): Promise<ModelProxy>;
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * A served phase's Claude Code (and a code phase's child) points ANTHROPIC_BASE_URL here, on
5
5
  * loopback. The proxy forwards each request to the gateway and decides every Mnemom header itself:
6
- * any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the job read's agent id in
6
+ * any `x-mnemom-*`, `x-api-key` or `authorization` the phase sent is dropped, the lease key goes in X-Mnemom-Api-Key, the runner's own
7
+ * Anthropic key in x-api-key (ruling 34.30.1; the phase only ever holds a placeholder), the job read's agent id in
7
8
  * x-mnemom-agent-id (the gateway checks it against the lease's session), and the job's
8
9
  * sealed contract goes in `x-mnemom-contract` (exactly one, the sealed version). The phase never
9
10
  * sees the lease key. A 409 `contract_mismatch` from the gateway (34.11) is reported to the job,
@@ -11,6 +12,7 @@
11
12
  */
12
13
  import { createServer, request as httpRequest, } from "node:http";
13
14
  import { request as httpsRequest } from "node:https";
15
+ import { createHash } from "node:crypto";
14
16
  import { applySealedContract, CONTRACT_HEADER } from "./contract-header.js";
15
17
  export const LEASE_KEY_HEADER = "x-mnemom-api-key";
16
18
  export const AGENT_ID_HEADER = "x-mnemom-agent-id";
@@ -25,12 +27,12 @@ const DROP = new Set([
25
27
  "trailer",
26
28
  "host",
27
29
  "content-length",
28
- // The phase's model key is a placeholder; the gateway authenticates on the lease key alone.
30
+ // The phase's model key is a placeholder; the runner's own key replaces it (34.30.1).
29
31
  "x-api-key",
30
32
  "authorization",
31
33
  ]);
32
34
  /** Outbound headers for one request: the phase's own, minus anything the proxy decides. */
33
- export function outboundHeaders(incoming, state) {
35
+ export function outboundHeaders(incoming, state, modelKey) {
34
36
  const kept = {};
35
37
  for (const [name, value] of Object.entries(incoming)) {
36
38
  const lower = name.toLowerCase();
@@ -39,9 +41,25 @@ export function outboundHeaders(incoming, state) {
39
41
  kept[lower] = value;
40
42
  }
41
43
  kept[LEASE_KEY_HEADER] = state.leaseKey;
44
+ kept["x-api-key"] = modelKey;
42
45
  kept[AGENT_ID_HEADER] = state.agentId;
43
46
  return applySealedContract(kept, state.contractValue);
44
47
  }
48
+ /**
49
+ * One job's forwarded requests, summarised for the operator's log and for the door2-path header
50
+ * check (W3.11): the sha256 of each header value's decoded bytes is the contract it sealed to.
51
+ */
52
+ export function requestSummary(forwarded) {
53
+ const by = new Map();
54
+ for (const f of forwarded) {
55
+ const sha = createHash("sha256").update(Buffer.from(f.contract, "base64url")).digest("hex");
56
+ by.set(sha, (by.get(sha) ?? 0) + 1);
57
+ }
58
+ return {
59
+ count: forwarded.length,
60
+ contracts: [...by].map(([contract_sha256, requests]) => ({ contract_sha256, requests })),
61
+ };
62
+ }
45
63
  /** The largest 409 body the proxy reads for a contract mismatch. */
46
64
  export const MISMATCH_BODY_MAX = 64 * 1024;
47
65
  function mismatchVersion(body) {
@@ -74,7 +92,8 @@ export function upstreamFailed(res, e) {
74
92
  res.writeHead(502, { "content-type": "application/json" });
75
93
  res.end(JSON.stringify({ error: { code: "runner_proxy_upstream", message: e.message } }));
76
94
  }
77
- export async function startModelProxy(gatewayUrl, state) {
95
+ /** `modelKey` is the runner process's own ANTHROPIC_API_KEY; it never enters a phase's env. */
96
+ export async function startModelProxy(gatewayUrl, state, modelKey) {
78
97
  const upstream = new URL(gatewayUrl);
79
98
  const send = requestFor(upstream.protocol);
80
99
  const basePath = upstream.pathname.replace(/\/+$/, "");
@@ -86,7 +105,7 @@ export async function startModelProxy(gatewayUrl, state) {
86
105
  close: async () => { },
87
106
  };
88
107
  const server = createServer((req, res) => {
89
- const headers = outboundHeaders(req.headers, state);
108
+ const headers = outboundHeaders(req.headers, state, modelKey);
90
109
  const sent = headers[CONTRACT_HEADER];
91
110
  const out = send({
92
111
  protocol: upstream.protocol,
@@ -31,6 +31,8 @@ const ALLOWED_PATHS = [
31
31
  new RegExp(`^/repos/${SLUG}/pulls\\?state=closed&sort=updated&direction=desc&per_page=\\d{1,3}&page=\\d{1,4}$`),
32
32
  new RegExp(`^/repos/${SLUG}/pulls/\\d{1,9}$`),
33
33
  new RegExp(`^/repos/${SLUG}/commits/[0-9a-f]{40}/check-runs\\?per_page=100$`),
34
+ // The repo picker's one list read (MNE-9643): the repos this login can read, newest push first.
35
+ /^\/user\/repos\?sort=pushed&direction=desc&per_page=\d{1,3}$/,
34
36
  ];
35
37
  export function isAllowedScanPath(path) {
36
38
  return ALLOWED_PATHS.some((re) => re.test(path));
@@ -13,15 +13,28 @@
13
13
  * "window holds more than N requests: narrow it") is read in pieces: the failing window is halved
14
14
  * at its midpoint, recursively, and the pieces are summed. The API's window is half-open
15
15
  * [from, to), so pieces [from, mid) and [mid, to) neither overlap nor leave a gap. Fan-out is
16
- * bounded (RECORD_MAX_REQUESTS reads, no piece split below RECORD_MIN_SPLIT_MS per half), and a
17
- * total is all or nothing: any piece that fails for any other reason makes the whole read
18
- * "unavailable", never a partial sum.
16
+ * bounded (the read budget scales with the depth the halving reaches, up to a hard ceiling; no
17
+ * piece split below RECORD_MIN_SPLIT_MS per half).
18
+ *
19
+ * A slow or failing piece must not blank all 30 days. A piece that times out, errors at the
20
+ * network, or gets a 429 or 5xx is retried (at most RECORD_MAX_TRIES tries, jittered exponential
21
+ * backoff, honoring Retry-After). A piece that still fails on a timeout, network error or 5xx is
22
+ * too dense to total in one read, so it is halved exactly as a 400 "narrow it" is (a 429 is rate
23
+ * limiting and is not halved). The same halving applies when the next wait (backoff or Retry-After)
24
+ * would pass RECORD_MAX_RETRY_WAIT_MS or the time left. Outage guard: while nothing at all has been
25
+ * read, only RECORD_MAX_FRUITLESS_SPLITS such split is allowed, so a total outage costs a bounded
26
+ * number of reads instead of halving down to RECORD_MIN_SPLIT_MS; once any piece has been read,
27
+ * halving recurses. A piece that cannot be split further (RECORD_MIN_SPLIT_MS) or that
28
+ * the budget never reached is left out; the readable pieces are summed and the result carries `coverage`, which
29
+ * says how many of the 30 days the numbers cover. Partial totals are never presented as complete.
30
+ * A sum that cannot be formed (an out-of-range total, an unparsable as_of) is not a slow piece but
31
+ * bad data, so it fails the whole read as "unavailable".
19
32
  *
20
33
  * Outcomes are a closed set, and every one has fixed text. The thrown value, the response body
21
34
  * and the credential never reach the terminal:
22
- * ok totals read and shape-checked
23
- * not_in_cohort 404: the org is not in the Record cohort (or the org is unknown). Not an error.
24
- * unavailable anything else (429 cap, 5xx, timeout, network, off-shape body). Scan continues.
35
+ * ok totals read and shape-checked; `coverage.complete` false when pieces are missing
36
+ * not_in_cohort 404 on the whole window: the org is not in the Record cohort (or unknown).
37
+ * unavailable no piece could be read at all, or the pieces could not be summed. Scan continues.
25
38
  * auth 401/403 or a missing login: throws RecordAuthError (fail closed).
26
39
  */
27
40
  import { type RecordWindowTotals } from "../api.js";
@@ -37,12 +50,35 @@ export declare const RECORD_TIMEOUT_MS = 10000;
37
50
  * within 60s of the API's in either direction; ending earlier would trade away the floor side.
38
51
  */
39
52
  export declare const RECORD_FLOOR_MARGIN_MS = 60000;
40
- /** Most reads one scan may make (the first full-window read included). */
53
+ /**
54
+ * The read budget is sized to the work, not fixed. The halving tree is discovered as it is read, so
55
+ * the budget grows with the deepest level reached: a window that has to be halved to depth
56
+ * RECORD_BASE_DEPTH (63 reads) fits the base budget; each level past that doubles the reads
57
+ * allowed and adds RECORD_TIME_PER_LEVEL_MS of wall clock. Both are capped by a HARD CEILING
58
+ * (RECORD_MAX_REQUESTS_CEILING reads, RECORD_TOTAL_TIMEOUT_CEILING_MS) that no depth can exceed, so
59
+ * a very dense org stays bounded. What the budget does not reach is reported as uncovered days.
60
+ */
61
+ export declare const RECORD_BASE_DEPTH = 5;
62
+ /** Base read budget (the first full-window read and every retry included). */
41
63
  export declare const RECORD_MAX_REQUESTS = 64;
42
- /** Wall-clock budget for all the reads of one scan; past it the result is "unavailable". */
64
+ /** Hard ceiling on reads for one scan, however deep the halving goes. */
65
+ export declare const RECORD_MAX_REQUESTS_CEILING = 256;
66
+ /** Base wall-clock budget for all the reads of one scan. */
43
67
  export declare const RECORD_TOTAL_TIMEOUT_MS = 60000;
68
+ /** Wall clock added per halving level past RECORD_BASE_DEPTH. */
69
+ export declare const RECORD_TIME_PER_LEVEL_MS = 30000;
70
+ /** Hard ceiling on wall clock for one scan, however deep the halving goes. */
71
+ export declare const RECORD_TOTAL_TIMEOUT_CEILING_MS = 180000;
72
+ /** At most this many tries per piece (first try included). */
73
+ export declare const RECORD_MAX_TRIES = 3;
74
+ /** First backoff before jitter; doubles per retry. */
75
+ export declare const RECORD_BACKOFF_BASE_MS = 500;
76
+ /** A Retry-After longer than this is not waited for: the piece is left unread. */
77
+ export declare const RECORD_MAX_RETRY_WAIT_MS = 30000;
44
78
  /** A window is split only if both halves stay at least this long. */
45
79
  export declare const RECORD_MIN_SPLIT_MS: number;
80
+ /** Timeout/5xx splits allowed before any piece has been read (past it the API is down, not dense). */
81
+ export declare const RECORD_MAX_FRUITLESS_SPLITS = 1;
46
82
  export declare const isOrgId: (v: unknown) => v is string;
47
83
  export declare const RECORD_AUTH_MSG = "Record totals were refused for this org. Run `mnemom login` (or set MNEMOM_API_KEY) with an owner, admin or auditor of the org passed to --org, then retry.";
48
84
  export declare class RecordAuthError extends Error {
@@ -52,10 +88,21 @@ export interface RecordWindow {
52
88
  from: string;
53
89
  to: string;
54
90
  }
91
+ /**
92
+ * How much of the window the totals cover. `complete` is false whenever any piece was left out;
93
+ * every consumer must check it before treating the totals as the whole 30 days.
94
+ */
95
+ export interface RecordCoverage {
96
+ complete: boolean;
97
+ /** Whole days read (rounded down, so never overstated); equals days_total when complete. */
98
+ days_covered: number;
99
+ days_total: number;
100
+ }
55
101
  export type RecordOutcome = {
56
102
  status: "ok";
57
103
  totals: RecordWindowTotals;
58
104
  window: RecordWindow;
105
+ coverage: RecordCoverage;
59
106
  } | {
60
107
  status: "not_in_cohort";
61
108
  } | {
@@ -65,8 +112,12 @@ export interface RecordDeps {
65
112
  fetchTotals?: (orgId: string, from: string, to: string, signal: AbortSignal) => Promise<unknown>;
66
113
  now?: () => number;
67
114
  timeoutMs?: number;
68
- /** Budget for every read of one total together (default RECORD_TOTAL_TIMEOUT_MS). */
115
+ /** Base budget for every read of one total together (default RECORD_TOTAL_TIMEOUT_MS); scales up with depth. */
69
116
  totalTimeoutMs?: number;
117
+ /** Backoff wait; default setTimeout. Injected so tests do not sleep. */
118
+ sleep?: (ms: number) => Promise<void>;
119
+ /** Jitter source in [0, 1); default Math.random. */
120
+ random?: () => number;
70
121
  }
71
122
  export declare function recordWindow(nowMs: number): RecordWindow;
72
123
  /** Shape-check the body: a malformed answer is "unavailable", never rendered. */
@@ -13,15 +13,28 @@
13
13
  * "window holds more than N requests: narrow it") is read in pieces: the failing window is halved
14
14
  * at its midpoint, recursively, and the pieces are summed. The API's window is half-open
15
15
  * [from, to), so pieces [from, mid) and [mid, to) neither overlap nor leave a gap. Fan-out is
16
- * bounded (RECORD_MAX_REQUESTS reads, no piece split below RECORD_MIN_SPLIT_MS per half), and a
17
- * total is all or nothing: any piece that fails for any other reason makes the whole read
18
- * "unavailable", never a partial sum.
16
+ * bounded (the read budget scales with the depth the halving reaches, up to a hard ceiling; no
17
+ * piece split below RECORD_MIN_SPLIT_MS per half).
18
+ *
19
+ * A slow or failing piece must not blank all 30 days. A piece that times out, errors at the
20
+ * network, or gets a 429 or 5xx is retried (at most RECORD_MAX_TRIES tries, jittered exponential
21
+ * backoff, honoring Retry-After). A piece that still fails on a timeout, network error or 5xx is
22
+ * too dense to total in one read, so it is halved exactly as a 400 "narrow it" is (a 429 is rate
23
+ * limiting and is not halved). The same halving applies when the next wait (backoff or Retry-After)
24
+ * would pass RECORD_MAX_RETRY_WAIT_MS or the time left. Outage guard: while nothing at all has been
25
+ * read, only RECORD_MAX_FRUITLESS_SPLITS such split is allowed, so a total outage costs a bounded
26
+ * number of reads instead of halving down to RECORD_MIN_SPLIT_MS; once any piece has been read,
27
+ * halving recurses. A piece that cannot be split further (RECORD_MIN_SPLIT_MS) or that
28
+ * the budget never reached is left out; the readable pieces are summed and the result carries `coverage`, which
29
+ * says how many of the 30 days the numbers cover. Partial totals are never presented as complete.
30
+ * A sum that cannot be formed (an out-of-range total, an unparsable as_of) is not a slow piece but
31
+ * bad data, so it fails the whole read as "unavailable".
19
32
  *
20
33
  * Outcomes are a closed set, and every one has fixed text. The thrown value, the response body
21
34
  * and the credential never reach the terminal:
22
- * ok totals read and shape-checked
23
- * not_in_cohort 404: the org is not in the Record cohort (or the org is unknown). Not an error.
24
- * unavailable anything else (429 cap, 5xx, timeout, network, off-shape body). Scan continues.
35
+ * ok totals read and shape-checked; `coverage.complete` false when pieces are missing
36
+ * not_in_cohort 404 on the whole window: the org is not in the Record cohort (or unknown).
37
+ * unavailable no piece could be read at all, or the pieces could not be summed. Scan continues.
25
38
  * auth 401/403 or a missing login: throws RecordAuthError (fail closed).
26
39
  */
27
40
  import { getRecordWindowTotals, MnemomApiError } from "../api.js";
@@ -37,12 +50,35 @@ export const RECORD_TIMEOUT_MS = 10_000;
37
50
  * within 60s of the API's in either direction; ending earlier would trade away the floor side.
38
51
  */
39
52
  export const RECORD_FLOOR_MARGIN_MS = 60_000;
40
- /** Most reads one scan may make (the first full-window read included). */
53
+ /**
54
+ * The read budget is sized to the work, not fixed. The halving tree is discovered as it is read, so
55
+ * the budget grows with the deepest level reached: a window that has to be halved to depth
56
+ * RECORD_BASE_DEPTH (63 reads) fits the base budget; each level past that doubles the reads
57
+ * allowed and adds RECORD_TIME_PER_LEVEL_MS of wall clock. Both are capped by a HARD CEILING
58
+ * (RECORD_MAX_REQUESTS_CEILING reads, RECORD_TOTAL_TIMEOUT_CEILING_MS) that no depth can exceed, so
59
+ * a very dense org stays bounded. What the budget does not reach is reported as uncovered days.
60
+ */
61
+ export const RECORD_BASE_DEPTH = 5;
62
+ /** Base read budget (the first full-window read and every retry included). */
41
63
  export const RECORD_MAX_REQUESTS = 64;
42
- /** Wall-clock budget for all the reads of one scan; past it the result is "unavailable". */
64
+ /** Hard ceiling on reads for one scan, however deep the halving goes. */
65
+ export const RECORD_MAX_REQUESTS_CEILING = 256;
66
+ /** Base wall-clock budget for all the reads of one scan. */
43
67
  export const RECORD_TOTAL_TIMEOUT_MS = 60_000;
68
+ /** Wall clock added per halving level past RECORD_BASE_DEPTH. */
69
+ export const RECORD_TIME_PER_LEVEL_MS = 30_000;
70
+ /** Hard ceiling on wall clock for one scan, however deep the halving goes. */
71
+ export const RECORD_TOTAL_TIMEOUT_CEILING_MS = 180_000;
72
+ /** At most this many tries per piece (first try included). */
73
+ export const RECORD_MAX_TRIES = 3;
74
+ /** First backoff before jitter; doubles per retry. */
75
+ export const RECORD_BACKOFF_BASE_MS = 500;
76
+ /** A Retry-After longer than this is not waited for: the piece is left unread. */
77
+ export const RECORD_MAX_RETRY_WAIT_MS = 30_000;
44
78
  /** A window is split only if both halves stay at least this long. */
45
79
  export const RECORD_MIN_SPLIT_MS = 60 * 60 * 1000;
80
+ /** Timeout/5xx splits allowed before any piece has been read (past it the API is down, not dense). */
81
+ export const RECORD_MAX_FRUITLESS_SPLITS = 1;
46
82
  /** The API's bigint ceiling: a larger sum cannot be a real total. */
47
83
  const MAX_MMU = 9223372036854775807n;
48
84
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -122,11 +158,20 @@ function addTotals(a, b) {
122
158
  as_of: laterAsOf(a.as_of, b.as_of),
123
159
  };
124
160
  }
125
- /** Any failure other than auth / not-in-cohort makes the whole total unavailable. */
126
- class SplitFailed extends Error {
161
+ /** A sum that cannot be formed is bad data, not a slow piece: the whole read is unavailable. */
162
+ class MergeFailed extends Error {
127
163
  }
128
164
  class NotInCohort extends Error {
129
165
  }
166
+ /** Worth another try: a timeout or network failure, a 429, or a 5xx. Everything else is final. */
167
+ function isTransient(e) {
168
+ if (e instanceof MnemomApiError) {
169
+ const s = e.effectiveStatus;
170
+ return s === 429 || s >= 500;
171
+ }
172
+ return true;
173
+ }
174
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
130
175
  /** Never throws except RecordAuthError (401/403, fail closed). */
131
176
  export async function fetchRecordTotals(orgId, deps = {}) {
132
177
  if (!isOrgId(orgId))
@@ -134,44 +179,103 @@ export async function fetchRecordTotals(orgId, deps = {}) {
134
179
  const window = recordWindow((deps.now ?? Date.now)());
135
180
  const fetchTotals = deps.fetchTotals ?? getRecordWindowTotals;
136
181
  const timeoutMs = deps.timeoutMs ?? RECORD_TIMEOUT_MS;
137
- const deadline = Date.now() + (deps.totalTimeoutMs ?? RECORD_TOTAL_TIMEOUT_MS);
182
+ const baseBudgetMs = deps.totalTimeoutMs ?? RECORD_TOTAL_TIMEOUT_MS;
183
+ const sleep = deps.sleep ?? defaultSleep;
184
+ const random = deps.random ?? Math.random;
185
+ const startedAt = Date.now();
138
186
  let requests = 0;
139
- /** Total of [fromMs, toMs). Throws RecordAuthError, NotInCohort (root 404 only) or SplitFailed. */
140
- const read = async (fromMs, toMs, root) => {
141
- const left = deadline - Date.now();
142
- if (requests >= RECORD_MAX_REQUESTS || left <= 0)
143
- throw new SplitFailed();
144
- requests += 1;
145
- try {
146
- const body = await fetchTotals(orgId, new Date(fromMs).toISOString(), new Date(toMs).toISOString(), AbortSignal.timeout(Math.min(timeoutMs, left)));
147
- const totals = parseWindowTotals(body);
148
- if (!totals)
149
- throw new SplitFailed();
150
- return totals;
151
- }
152
- catch (e) {
153
- if (e instanceof MnemomApiError) {
154
- const s = e.effectiveStatus;
187
+ let deepest = 0;
188
+ let sum = null;
189
+ let coveredMs = 0;
190
+ let fruitlessSplits = 0;
191
+ // Sized to the work (see RECORD_BASE_DEPTH), under a hard ceiling.
192
+ const extraLevels = () => Math.max(0, deepest - RECORD_BASE_DEPTH);
193
+ const maxReads = () => Math.min(RECORD_MAX_REQUESTS_CEILING, RECORD_MAX_REQUESTS * 2 ** extraLevels());
194
+ const budgetMs = () => Math.min(Math.max(baseBudgetMs, RECORD_TOTAL_TIMEOUT_CEILING_MS), baseBudgetMs + RECORD_TIME_PER_LEVEL_MS * extraLevels());
195
+ const timeLeft = () => startedAt + budgetMs() - Date.now();
196
+ /**
197
+ * Read [fromMs, toMs) and fold what is readable into `sum`. A piece that cannot be read is simply
198
+ * not added (and not counted as covered). Throws RecordAuthError, NotInCohort (root 404 only) or
199
+ * MergeFailed.
200
+ */
201
+ const read = async (fromMs, toMs, depth) => {
202
+ deepest = Math.max(deepest, depth);
203
+ // Halve at the midpoint and read both halves; a piece whose half would be under
204
+ // RECORD_MIN_SPLIT_MS is left unread. Reads stay under the budget and ceiling (checked per try).
205
+ const split = async () => {
206
+ const mid = fromMs + Math.floor((toMs - fromMs) / 2);
207
+ if (mid - fromMs < RECORD_MIN_SPLIT_MS || toMs - mid < RECORD_MIN_SPLIT_MS)
208
+ return;
209
+ await read(fromMs, mid, depth + 1);
210
+ await read(mid, toMs, depth + 1);
211
+ };
212
+ for (let attempt = 1; attempt <= RECORD_MAX_TRIES; attempt += 1) {
213
+ const left = timeLeft();
214
+ if (requests >= maxReads() || left <= 0)
215
+ return;
216
+ requests += 1;
217
+ let failure;
218
+ try {
219
+ const body = await fetchTotals(orgId, new Date(fromMs).toISOString(), new Date(toMs).toISOString(), AbortSignal.timeout(Math.min(timeoutMs, left)));
220
+ const totals = parseWindowTotals(body);
221
+ if (!totals)
222
+ return; // off-shape: not retried, piece left out
223
+ try {
224
+ sum = sum === null ? totals : addTotals(sum, totals);
225
+ }
226
+ catch {
227
+ throw new MergeFailed();
228
+ }
229
+ coveredMs += toMs - fromMs;
230
+ return;
231
+ }
232
+ catch (e) {
233
+ if (e instanceof MergeFailed)
234
+ throw e;
235
+ failure = e;
236
+ }
237
+ if (failure instanceof MnemomApiError) {
238
+ const s = failure.effectiveStatus;
155
239
  if (s === 401 || s === 403)
156
240
  throw new RecordAuthError();
157
- if (s === 404 && root)
241
+ if (s === 404 && depth === 0)
158
242
  throw new NotInCohort();
159
- if (isWindowTooBig(e)) {
160
- const mid = fromMs + Math.floor((toMs - fromMs) / 2);
161
- if (mid - fromMs < RECORD_MIN_SPLIT_MS || toMs - mid < RECORD_MIN_SPLIT_MS) {
162
- throw new SplitFailed();
163
- }
164
- const first = await read(fromMs, mid, false);
165
- return addTotals(first, await read(mid, toMs, false));
243
+ if (isWindowTooBig(failure)) {
244
+ await split();
245
+ return;
166
246
  }
167
247
  }
168
- // RecordAuthError / NotInCohort / nested failures above are thrown from this handler and never land here.
169
- throw new SplitFailed();
248
+ if (!isTransient(failure))
249
+ return;
250
+ // A piece too dense to total in one read times out or 5xxs: once its tries (or its allowed
251
+ // wait) are used up, halve it like a 400 "narrow it". A 429 is rate limiting, not density:
252
+ // halving would only add reads, so it is left unread.
253
+ const splittable = !(failure instanceof MnemomApiError && failure.effectiveStatus === 429);
254
+ // Outage guard: while nothing at all has been read, one such split is allowed; a second means
255
+ // the API is down rather than the piece dense, and halving further would only burn the budget.
256
+ const giveUp = async () => {
257
+ if (!splittable)
258
+ return;
259
+ if (coveredMs === 0) {
260
+ if (fruitlessSplits >= RECORD_MAX_FRUITLESS_SPLITS)
261
+ return;
262
+ fruitlessSplits += 1;
263
+ }
264
+ await split();
265
+ };
266
+ if (attempt === RECORD_MAX_TRIES)
267
+ return giveUp();
268
+ // Jittered exponential backoff (500ms, 1s before jitter), never shorter than Retry-After.
269
+ const retryAfter = failure instanceof MnemomApiError ? failure.retryAfterMs : undefined;
270
+ const backoff = RECORD_BACKOFF_BASE_MS * 2 ** (attempt - 1) * (0.5 + 0.5 * random());
271
+ const wait = Math.max(backoff, retryAfter ?? 0);
272
+ if (wait > RECORD_MAX_RETRY_WAIT_MS || wait >= timeLeft())
273
+ return giveUp();
274
+ await sleep(wait);
170
275
  }
171
276
  };
172
277
  try {
173
- const totals = await read(Date.parse(window.from), Date.parse(window.to), true);
174
- return { status: "ok", totals, window };
278
+ await read(Date.parse(window.from), Date.parse(window.to), 0);
175
279
  }
176
280
  catch (e) {
177
281
  if (e instanceof RecordAuthError)
@@ -180,4 +284,18 @@ export async function fetchRecordTotals(orgId, deps = {}) {
180
284
  return { status: "not_in_cohort" };
181
285
  return { status: "unavailable" };
182
286
  }
287
+ if (sum === null)
288
+ return { status: "unavailable" };
289
+ const windowMs = Date.parse(window.to) - Date.parse(window.from);
290
+ const complete = coveredMs >= windowMs;
291
+ return {
292
+ status: "ok",
293
+ totals: sum,
294
+ window,
295
+ coverage: {
296
+ complete,
297
+ days_covered: complete ? RECORD_WINDOW_DAYS : Math.floor(coveredMs / DAY_MS),
298
+ days_total: RECORD_WINDOW_DAYS,
299
+ },
300
+ };
183
301
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Repo picker for `mnemom scan` when --repo is omitted on a terminal (MNE-9643). Read-only: one GET of
3
+ * the repos the signed-in GitHub account can read, through the same credential seam the scan uses.
4
+ * Only `full_name` is kept; descriptions, owners' logins beyond the slug, URLs and the token never leave
5
+ * this module. Failures throw fixed text (never a GitHub body, header or URL). Prompt style and fail-closed
6
+ * handling follow promptShareLevel in ./share.ts: no terminal, no answer, or a bad answer selects nothing.
7
+ */
8
+ import { askMultiSelect } from "../prompt.js";
9
+ import { type GitHubCredential } from "./credential.js";
10
+ /** Most repos listed: the most recently pushed, so the list stays pickable. `--repo` names any other. */
11
+ export declare const REPO_PICKER_LIMIT = 30;
12
+ /** The only path the picker may read. */
13
+ export declare const REPO_LIST_PATH = "/user/repos?sort=pushed&direction=desc&per_page=30";
14
+ export declare const REPO_PICKER_NO_TERMINAL_HINT = "Pass --repo owner/name (repeatable), or run in a terminal to pick from your repos.";
15
+ export declare const REPO_LIST_FAILED_MSG = "Could not list your GitHub repos. Pass --repo owner/name, or check `gh auth status`.";
16
+ export declare const NO_REPOS_MSG = "No GitHub repos are readable by this account. Pass --repo owner/name.";
17
+ export declare const NO_REPO_SELECTED_MSG = "No repo selected. Pass --repo owner/name, or choose at least one number from the list.";
18
+ export interface RepoListDeps {
19
+ fetchImpl?: typeof fetch;
20
+ timeoutMs?: number;
21
+ }
22
+ /** GET-only read of the repos the credential can see. Returns valid slugs only, newest push first. */
23
+ export declare function listReadableRepos(credential: GitHubCredential, deps?: RepoListDeps): Promise<string[]>;
24
+ /**
25
+ * Lists the readable repos and asks which to scan (comma-separated numbers, as the other multi-select
26
+ * prompts do). Throws fixed text when there is no terminal, nothing is listed, or nothing valid is chosen.
27
+ */
28
+ export declare function pickRepos(credential: GitHubCredential, o: {
29
+ isTty: boolean;
30
+ deps?: RepoListDeps;
31
+ ask?: typeof askMultiSelect;
32
+ }): Promise<string[]>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Repo picker for `mnemom scan` when --repo is omitted on a terminal (MNE-9643). Read-only: one GET of
3
+ * the repos the signed-in GitHub account can read, through the same credential seam the scan uses.
4
+ * Only `full_name` is kept; descriptions, owners' logins beyond the slug, URLs and the token never leave
5
+ * this module. Failures throw fixed text (never a GitHub body, header or URL). Prompt style and fail-closed
6
+ * handling follow promptShareLevel in ./share.ts: no terminal, no answer, or a bad answer selects nothing.
7
+ */
8
+ import { askMultiSelect } from "../prompt.js";
9
+ import { GITHUB_API, isAllowedScanPath } from "./pr-source.js";
10
+ import { assertReadOnlyCredential, isValidRepoSlug } from "./credential.js";
11
+ /** Most repos listed: the most recently pushed, so the list stays pickable. `--repo` names any other. */
12
+ export const REPO_PICKER_LIMIT = 30;
13
+ /** The only path the picker may read. */
14
+ export const REPO_LIST_PATH = `/user/repos?sort=pushed&direction=desc&per_page=${REPO_PICKER_LIMIT}`;
15
+ export const REPO_PICKER_NO_TERMINAL_HINT = "Pass --repo owner/name (repeatable), or run in a terminal to pick from your repos.";
16
+ export const REPO_LIST_FAILED_MSG = "Could not list your GitHub repos. Pass --repo owner/name, or check `gh auth status`.";
17
+ export const NO_REPOS_MSG = "No GitHub repos are readable by this account. Pass --repo owner/name.";
18
+ export const NO_REPO_SELECTED_MSG = "No repo selected. Pass --repo owner/name, or choose at least one number from the list.";
19
+ /** GET-only read of the repos the credential can see. Returns valid slugs only, newest push first. */
20
+ export async function listReadableRepos(credential, deps = {}) {
21
+ if (!isAllowedScanPath(REPO_LIST_PATH))
22
+ throw new Error(REPO_LIST_FAILED_MSG);
23
+ // Same order as runLocalScan: a credential GitHub granted more than read is refused before any read.
24
+ await assertReadOnlyCredential(credential).catch(() => {
25
+ throw new Error(REPO_LIST_FAILED_MSG);
26
+ });
27
+ const doFetch = deps.fetchImpl ?? fetch;
28
+ let res;
29
+ try {
30
+ // User-level read: there is no single repo to scope to (the gh-login credential ignores the argument).
31
+ const bearer = await credential.tokenFor("");
32
+ res = await doFetch(`${GITHUB_API}${REPO_LIST_PATH}`, {
33
+ method: "GET",
34
+ headers: {
35
+ accept: "application/vnd.github+json",
36
+ "x-github-api-version": "2022-11-28",
37
+ "user-agent": "mnemom-cli-scan/1",
38
+ authorization: `Bearer ${bearer}`,
39
+ },
40
+ redirect: "error",
41
+ signal: AbortSignal.timeout(deps.timeoutMs ?? 10_000),
42
+ });
43
+ }
44
+ catch {
45
+ throw new Error(REPO_LIST_FAILED_MSG);
46
+ }
47
+ if (!res.ok) {
48
+ await res.body?.cancel().catch(() => { });
49
+ throw new Error(REPO_LIST_FAILED_MSG);
50
+ }
51
+ let json;
52
+ try {
53
+ json = await res.json();
54
+ }
55
+ catch {
56
+ throw new Error(REPO_LIST_FAILED_MSG);
57
+ }
58
+ if (!Array.isArray(json))
59
+ throw new Error(REPO_LIST_FAILED_MSG);
60
+ const slugs = json
61
+ .map((r) => (r && typeof r === "object" ? r.full_name : undefined))
62
+ .filter(isValidRepoSlug);
63
+ return [...new Set(slugs)].slice(0, REPO_PICKER_LIMIT);
64
+ }
65
+ /**
66
+ * Lists the readable repos and asks which to scan (comma-separated numbers, as the other multi-select
67
+ * prompts do). Throws fixed text when there is no terminal, nothing is listed, or nothing valid is chosen.
68
+ */
69
+ export async function pickRepos(credential, o) {
70
+ if (!o.isTty)
71
+ throw new Error(`--repo owner/name is required (repeatable). ${REPO_PICKER_NO_TERMINAL_HINT}`);
72
+ const repos = await listReadableRepos(credential, o.deps);
73
+ if (repos.length === 0)
74
+ throw new Error(NO_REPOS_MSG);
75
+ const chosen = await (o.ask ?? askMultiSelect)("Which repos should be scanned (read-only)?", repos);
76
+ const picked = chosen.filter((c) => repos.includes(c));
77
+ if (picked.length === 0)
78
+ throw new Error(NO_REPO_SELECTED_MSG);
79
+ return picked;
80
+ }