@manyoars/oars 0.2.0 → 0.4.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 (320) hide show
  1. package/README.md +147 -28
  2. package/dist/api.js +1 -1
  3. package/dist/bin.js +6 -29
  4. package/dist/cli.d.ts +10 -4
  5. package/dist/cli.js +440 -91
  6. package/dist/commands/keys.d.ts +31 -0
  7. package/dist/commands/keys.js +65 -0
  8. package/dist/commands/login.d.ts +1 -1
  9. package/dist/commands/login.js +3 -3
  10. package/dist/errors.d.ts +13 -13
  11. package/dist/errors.js +28 -11
  12. package/dist/gate/checks.d.ts +68 -0
  13. package/dist/gate/checks.js +584 -0
  14. package/dist/gate/command.d.ts +14 -0
  15. package/dist/gate/command.js +61 -0
  16. package/dist/gate/hook-fill-main.d.ts +1 -0
  17. package/dist/gate/hook-fill-main.js +16 -0
  18. package/dist/gate/hook-fill.d.ts +38 -0
  19. package/dist/gate/hook-fill.js +94 -0
  20. package/dist/gate/hook-main.d.ts +1 -0
  21. package/dist/gate/hook-main.js +47 -0
  22. package/dist/gate/hook-process.d.ts +24 -0
  23. package/dist/gate/hook-process.js +112 -0
  24. package/dist/gate/hook-record.d.ts +3 -0
  25. package/dist/gate/hook-record.js +8 -0
  26. package/dist/gate/hook.d.ts +48 -0
  27. package/dist/gate/hook.js +259 -0
  28. package/dist/gate/install.d.ts +25 -0
  29. package/dist/gate/install.js +126 -0
  30. package/dist/gate/local-bin.d.ts +8 -0
  31. package/dist/gate/local-bin.js +36 -0
  32. package/dist/gate/notes.d.ts +19 -0
  33. package/dist/gate/notes.js +27 -0
  34. package/dist/gate/novelty.d.ts +35 -0
  35. package/dist/gate/novelty.js +99 -0
  36. package/dist/gate/policy.d.ts +25 -0
  37. package/dist/gate/policy.js +73 -0
  38. package/dist/gate/record.d.ts +120 -0
  39. package/dist/gate/record.js +125 -0
  40. package/dist/gate/registry-state.d.ts +10 -0
  41. package/dist/gate/registry-state.js +30 -0
  42. package/dist/gate/registry.d.ts +56 -0
  43. package/dist/gate/registry.js +94 -0
  44. package/dist/gate/replay.d.ts +20 -0
  45. package/dist/gate/replay.js +254 -0
  46. package/dist/gate/report.d.ts +50 -0
  47. package/dist/gate/report.js +89 -0
  48. package/dist/gate/scope.d.ts +7 -0
  49. package/dist/gate/scope.js +27 -0
  50. package/dist/gate/transcripts.d.ts +68 -0
  51. package/dist/gate/transcripts.js +232 -0
  52. package/dist/hook-bin.d.ts +1 -0
  53. package/dist/hook-bin.js +50 -0
  54. package/dist/issuer.d.ts +10 -6
  55. package/dist/issuer.js +19 -24
  56. package/dist/main.d.ts +1 -0
  57. package/dist/main.js +67 -0
  58. package/dist/oidc.js +1 -1
  59. package/dist/sessions/actions.d.ts +35 -0
  60. package/dist/sessions/actions.js +429 -0
  61. package/dist/sessions/allow.d.ts +23 -0
  62. package/dist/sessions/allow.js +71 -0
  63. package/dist/sessions/archive.d.ts +11 -0
  64. package/dist/sessions/archive.js +38 -0
  65. package/dist/sessions/audit.d.ts +42 -0
  66. package/dist/sessions/audit.js +104 -0
  67. package/dist/sessions/baseline.d.ts +62 -0
  68. package/dist/sessions/baseline.js +190 -0
  69. package/dist/sessions/call-shape.d.ts +19 -0
  70. package/dist/sessions/call-shape.js +85 -0
  71. package/dist/sessions/capture-runtime.d.ts +10 -0
  72. package/dist/sessions/capture-runtime.js +12 -0
  73. package/dist/sessions/cell-increments.d.ts +47 -0
  74. package/dist/sessions/cell-increments.js +243 -0
  75. package/dist/sessions/cells.d.ts +109 -0
  76. package/dist/sessions/cells.js +261 -0
  77. package/dist/sessions/codex.d.ts +53 -0
  78. package/dist/sessions/codex.js +745 -0
  79. package/dist/sessions/command.d.ts +127 -0
  80. package/dist/sessions/command.js +419 -0
  81. package/dist/sessions/copilot.d.ts +40 -0
  82. package/dist/sessions/copilot.js +432 -0
  83. package/dist/sessions/coverage.d.ts +19 -0
  84. package/dist/sessions/coverage.js +56 -0
  85. package/dist/sessions/cursor.d.ts +24 -0
  86. package/dist/sessions/cursor.js +324 -0
  87. package/dist/sessions/derive.d.ts +57 -0
  88. package/dist/sessions/derive.js +386 -0
  89. package/dist/sessions/exclusion.d.ts +87 -0
  90. package/dist/sessions/exclusion.js +361 -0
  91. package/dist/sessions/extract.d.ts +181 -0
  92. package/dist/sessions/extract.js +1806 -0
  93. package/dist/sessions/fold.d.ts +17 -0
  94. package/dist/sessions/fold.js +71 -0
  95. package/dist/sessions/frame.d.ts +24 -0
  96. package/dist/sessions/frame.js +95 -0
  97. package/dist/sessions/gemini.d.ts +37 -0
  98. package/dist/sessions/gemini.js +613 -0
  99. package/dist/sessions/gold-arms.d.ts +67 -0
  100. package/dist/sessions/gold-arms.js +172 -0
  101. package/dist/sessions/gold.d.ts +197 -0
  102. package/dist/sessions/gold.js +463 -0
  103. package/dist/sessions/graph.d.ts +12 -0
  104. package/dist/sessions/graph.js +34 -0
  105. package/dist/sessions/grok.d.ts +36 -0
  106. package/dist/sessions/grok.js +421 -0
  107. package/dist/sessions/hook-calls.d.ts +3 -0
  108. package/dist/sessions/hook-calls.js +47 -0
  109. package/dist/sessions/hook-index.d.ts +66 -0
  110. package/dist/sessions/hook-index.js +164 -0
  111. package/dist/sessions/hooks.d.ts +17 -0
  112. package/dist/sessions/hooks.js +281 -0
  113. package/dist/sessions/identity.d.ts +27 -0
  114. package/dist/sessions/identity.js +131 -0
  115. package/dist/sessions/install.d.ts +70 -0
  116. package/dist/sessions/install.js +246 -0
  117. package/dist/sessions/lineage.d.ts +19 -0
  118. package/dist/sessions/lineage.js +118 -0
  119. package/dist/sessions/mcp.d.ts +7 -0
  120. package/dist/sessions/mcp.js +157 -0
  121. package/dist/sessions/merkle.d.ts +7 -0
  122. package/dist/sessions/merkle.js +28 -0
  123. package/dist/sessions/motif.d.ts +44 -0
  124. package/dist/sessions/motif.js +59 -0
  125. package/dist/sessions/opencode.d.ts +33 -0
  126. package/dist/sessions/opencode.js +319 -0
  127. package/dist/sessions/otel.d.ts +60 -0
  128. package/dist/sessions/otel.js +494 -0
  129. package/dist/sessions/package-books.d.ts +48 -0
  130. package/dist/sessions/package-books.js +120 -0
  131. package/dist/sessions/package-resolve.d.ts +32 -0
  132. package/dist/sessions/package-resolve.js +185 -0
  133. package/dist/sessions/pending.d.ts +34 -0
  134. package/dist/sessions/pending.js +142 -0
  135. package/dist/sessions/pi.d.ts +36 -0
  136. package/dist/sessions/pi.js +353 -0
  137. package/dist/sessions/pool-books.d.ts +21 -0
  138. package/dist/sessions/pool-books.js +75 -0
  139. package/dist/sessions/pool-choices.d.ts +5 -0
  140. package/dist/sessions/pool-choices.js +11 -0
  141. package/dist/sessions/pool-command.d.ts +115 -0
  142. package/dist/sessions/pool-command.js +669 -0
  143. package/dist/sessions/pool-delta.d.ts +54 -0
  144. package/dist/sessions/pool-delta.js +190 -0
  145. package/dist/sessions/pool-nightly-deps.d.ts +3 -0
  146. package/dist/sessions/pool-nightly-deps.js +11 -0
  147. package/dist/sessions/pool-nightly.d.ts +10 -0
  148. package/dist/sessions/pool-nightly.js +85 -0
  149. package/dist/sessions/pool-outbox.d.ts +76 -0
  150. package/dist/sessions/pool-outbox.js +313 -0
  151. package/dist/sessions/pool-print.d.ts +26 -0
  152. package/dist/sessions/pool-print.js +68 -0
  153. package/dist/sessions/pool-raw.d.ts +27 -0
  154. package/dist/sessions/pool-raw.js +68 -0
  155. package/dist/sessions/pool-replica.d.ts +35 -0
  156. package/dist/sessions/pool-replica.js +91 -0
  157. package/dist/sessions/pool-send-lock.d.ts +20 -0
  158. package/dist/sessions/pool-send-lock.js +87 -0
  159. package/dist/sessions/pool-sent.d.ts +4 -0
  160. package/dist/sessions/pool-sent.js +26 -0
  161. package/dist/sessions/pool-totals.d.ts +10 -0
  162. package/dist/sessions/pool-totals.js +21 -0
  163. package/dist/sessions/pool-wire.d.ts +435 -0
  164. package/dist/sessions/pool-wire.js +1100 -0
  165. package/dist/sessions/pool.d.ts +32 -0
  166. package/dist/sessions/pool.js +164 -0
  167. package/dist/sessions/projection.d.ts +75 -0
  168. package/dist/sessions/projection.js +165 -0
  169. package/dist/sessions/public-vocabulary.d.ts +72 -0
  170. package/dist/sessions/public-vocabulary.js +295 -0
  171. package/dist/sessions/query.d.ts +123 -0
  172. package/dist/sessions/query.js +268 -0
  173. package/dist/sessions/read-adoption.d.ts +2 -0
  174. package/dist/sessions/read-adoption.js +27 -0
  175. package/dist/sessions/read-call-shape.d.ts +2 -0
  176. package/dist/sessions/read-call-shape.js +25 -0
  177. package/dist/sessions/read-change.d.ts +2 -0
  178. package/dist/sessions/read-change.js +30 -0
  179. package/dist/sessions/read-cost.d.ts +4 -0
  180. package/dist/sessions/read-cost.js +48 -0
  181. package/dist/sessions/read-day-move.d.ts +3 -0
  182. package/dist/sessions/read-day-move.js +65 -0
  183. package/dist/sessions/read-fit.d.ts +7 -0
  184. package/dist/sessions/read-fit.js +41 -0
  185. package/dist/sessions/read-interaction.d.ts +5 -0
  186. package/dist/sessions/read-interaction.js +62 -0
  187. package/dist/sessions/read-lead-lag.d.ts +15 -0
  188. package/dist/sessions/read-lead-lag.js +81 -0
  189. package/dist/sessions/read-live.d.ts +6 -0
  190. package/dist/sessions/read-live.js +38 -0
  191. package/dist/sessions/read-method.d.ts +2 -0
  192. package/dist/sessions/read-method.js +29 -0
  193. package/dist/sessions/read-next-step.d.ts +2 -0
  194. package/dist/sessions/read-next-step.js +35 -0
  195. package/dist/sessions/read-order.d.ts +54 -0
  196. package/dist/sessions/read-order.js +62 -0
  197. package/dist/sessions/read-readable.d.ts +8 -0
  198. package/dist/sessions/read-readable.js +24 -0
  199. package/dist/sessions/read-recovery-edge.d.ts +2 -0
  200. package/dist/sessions/read-recovery-edge.js +26 -0
  201. package/dist/sessions/read-recovery.d.ts +20 -0
  202. package/dist/sessions/read-recovery.js +97 -0
  203. package/dist/sessions/read-rows.d.ts +13 -0
  204. package/dist/sessions/read-rows.js +47 -0
  205. package/dist/sessions/read-substitution.d.ts +5 -0
  206. package/dist/sessions/read-substitution.js +51 -0
  207. package/dist/sessions/read-support.d.ts +48 -0
  208. package/dist/sessions/read-support.js +73 -0
  209. package/dist/sessions/read-tool-day.d.ts +2 -0
  210. package/dist/sessions/read-tool-day.js +26 -0
  211. package/dist/sessions/read-version-boundary.d.ts +31 -0
  212. package/dist/sessions/read-version-boundary.js +95 -0
  213. package/dist/sessions/read.d.ts +86 -0
  214. package/dist/sessions/read.js +87 -0
  215. package/dist/sessions/recall-bundle.d.ts +12 -0
  216. package/dist/sessions/recall-bundle.js +33 -0
  217. package/dist/sessions/recall-consequence.d.ts +45 -0
  218. package/dist/sessions/recall-consequence.js +179 -0
  219. package/dist/sessions/recall-frames.d.ts +18 -0
  220. package/dist/sessions/recall-frames.js +44 -0
  221. package/dist/sessions/recall-next.d.ts +145 -0
  222. package/dist/sessions/recall-next.js +241 -0
  223. package/dist/sessions/recall-object.d.ts +139 -0
  224. package/dist/sessions/recall-object.js +78 -0
  225. package/dist/sessions/recall-pool.d.ts +19 -0
  226. package/dist/sessions/recall-pool.js +96 -0
  227. package/dist/sessions/recall-push.d.ts +26 -0
  228. package/dist/sessions/recall-push.js +47 -0
  229. package/dist/sessions/recall-reads.d.ts +124 -0
  230. package/dist/sessions/recall-reads.js +417 -0
  231. package/dist/sessions/recall-render.d.ts +19 -0
  232. package/dist/sessions/recall-render.js +168 -0
  233. package/dist/sessions/recovery-calls.d.ts +21 -0
  234. package/dist/sessions/recovery-calls.js +25 -0
  235. package/dist/sessions/redact.d.ts +17 -0
  236. package/dist/sessions/redact.js +113 -0
  237. package/dist/sessions/repo-root.d.ts +10 -0
  238. package/dist/sessions/repo-root.js +101 -0
  239. package/dist/sessions/schema.d.ts +694 -0
  240. package/dist/sessions/schema.js +167 -0
  241. package/dist/sessions/session-shape.d.ts +76 -0
  242. package/dist/sessions/session-shape.js +355 -0
  243. package/dist/sessions/sources.d.ts +50 -0
  244. package/dist/sessions/sources.js +233 -0
  245. package/dist/sessions/steps.d.ts +105 -0
  246. package/dist/sessions/steps.js +250 -0
  247. package/dist/sessions/store.d.ts +309 -0
  248. package/dist/sessions/store.js +1498 -0
  249. package/dist/sessions/strata.d.ts +26 -0
  250. package/dist/sessions/strata.js +77 -0
  251. package/dist/sessions/survival.d.ts +27 -0
  252. package/dist/sessions/survival.js +230 -0
  253. package/dist/sessions/transcript-tail.d.ts +14 -0
  254. package/dist/sessions/transcript-tail.js +64 -0
  255. package/dist/sessions/verify.d.ts +53 -0
  256. package/dist/sessions/verify.js +286 -0
  257. package/dist/sessions/vocab-load.d.ts +26 -0
  258. package/dist/sessions/vocab-load.js +61 -0
  259. package/dist/sessions/vscode-copilot.d.ts +38 -0
  260. package/dist/sessions/vscode-copilot.js +494 -0
  261. package/dist/telemetry/command.d.ts +8 -0
  262. package/dist/telemetry/command.js +85 -0
  263. package/dist/telemetry/consent.d.ts +17 -0
  264. package/dist/telemetry/consent.js +63 -0
  265. package/dist/telemetry/crash.d.ts +8 -0
  266. package/dist/telemetry/crash.js +52 -0
  267. package/dist/telemetry/emit.d.ts +31 -0
  268. package/dist/telemetry/emit.js +42 -0
  269. package/dist/telemetry/fields.d.ts +16 -0
  270. package/dist/telemetry/fields.js +26 -0
  271. package/dist/telemetry/health.d.ts +21 -0
  272. package/dist/telemetry/health.js +108 -0
  273. package/dist/telemetry/hook-emit.d.ts +4 -0
  274. package/dist/telemetry/hook-emit.js +18 -0
  275. package/dist/telemetry/measures.d.ts +32 -0
  276. package/dist/telemetry/measures.js +128 -0
  277. package/dist/telemetry/observe.d.ts +13 -0
  278. package/dist/telemetry/observe.js +51 -0
  279. package/dist/telemetry/read-events.d.ts +9 -0
  280. package/dist/telemetry/read-events.js +17 -0
  281. package/dist/telemetry/record.d.ts +17 -0
  282. package/dist/telemetry/record.js +26 -0
  283. package/dist/telemetry/report.d.ts +77 -0
  284. package/dist/telemetry/report.js +188 -0
  285. package/dist/telemetry/schema.d.ts +40 -0
  286. package/dist/telemetry/schema.js +187 -0
  287. package/dist/telemetry/send-main.d.ts +1 -0
  288. package/dist/telemetry/send-main.js +11 -0
  289. package/dist/telemetry/send.d.ts +36 -0
  290. package/dist/telemetry/send.js +172 -0
  291. package/dist/telemetry/spool.d.ts +17 -0
  292. package/dist/telemetry/spool.js +70 -0
  293. package/dist/telemetry/wire.d.ts +25 -0
  294. package/dist/telemetry/wire.js +76 -0
  295. package/package.json +4 -5
  296. package/vocab/crates.json +1 -0
  297. package/vocab/go.json +1 -0
  298. package/vocab/harness.json +1 -0
  299. package/vocab/homebrew.json +1 -0
  300. package/vocab/manifest.json +1 -0
  301. package/vocab/maven.json +1 -0
  302. package/vocab/models.json +1 -0
  303. package/vocab/npm.json +1 -0
  304. package/vocab/programs.json +1 -0
  305. package/vocab/pypi.json +1 -0
  306. package/vocab/rubygems.json +1 -0
  307. package/dist/commands/deploy-input.d.ts +0 -38
  308. package/dist/commands/deploy-input.js +0 -150
  309. package/dist/commands/deploy.d.ts +0 -22
  310. package/dist/commands/deploy.js +0 -124
  311. package/dist/commands/status.d.ts +0 -7
  312. package/dist/commands/status.js +0 -18
  313. package/dist/commands/templates.d.ts +0 -11
  314. package/dist/commands/templates.js +0 -32
  315. package/dist/commands/test-charge.d.ts +0 -11
  316. package/dist/commands/test-charge.js +0 -34
  317. package/dist/journal.d.ts +0 -26
  318. package/dist/journal.js +0 -58
  319. package/parity/deploy-plans/paid-api.json +0 -34
  320. package/parity/deploy-plans/per-task-agent.json +0 -34
@@ -0,0 +1,669 @@
1
+ // `oars pool`: the only part of the sessions code that talks to a remote. The server is the one named by flag or environment, else the Many Oars pool; nothing leaves until a command that sends is run or, after covenant acceptance, the nightly task sends. Sign-in runs the provider's device flow here and reads no harness token; a session leaves only as a skeleton of a closed list of fields, each value redacted first, and only after the exclusion check found it clean. Local capture, recall,
2
+ // and reads never come through here.
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
+ import { emitOf } from '../telemetry/emit.js';
6
+ import { CliError, exitCodeForProblem, ExitCode, unreachableError } from '../errors.js';
7
+ import { compileAllow } from './allow.js';
8
+ import { makeCoverage, readCoverageSetting, writeCoverageSetting } from './coverage.js';
9
+ import { checkSession, exclusionPaths, lastWritten, loadTerms, transcriptFilesOf } from './exclusion.js';
10
+ import { localNames, makeRedactor, REDACTION_VERSION, redactTranscript } from './redact.js';
11
+ import { recordSend, recordWire, newTally, tallyBatch } from '../telemetry/wire.js';
12
+ import { discardStale, FIELD_ROWS, fieldsOf, heldBatches, lockedBatch, markShown, outboxFolder, partsOf, removeBatchesExcept, removeBatchOf, staleBatches, unchangedBytes, writeBatch } from './pool-outbox.js';
13
+ import { HARNESSES, hasEnded, ORG_SIZES, REPO_VISIBILITIES, SESSION_IDLE_HOURS, isBundleVersion, isHarness, parseSkeleton, sessionsOf, skeletonOf, VOCAB_VERSION, verifyBundle } from './pool-wire.js';
14
+ import { carriedDeltas, markDeltasSent, placedOf, sentDeltas } from './pool-delta.js';
15
+ import { Masker, printed, TRANSPORT_HEADERS, waitWords } from './pool-print.js';
16
+ import { rawAllowed, sendsOf } from './pool-choices.js';
17
+ import { heldRaw, rawParts, removeRawExcept, writeRaw } from './pool-raw.js';
18
+ import { SendLockBusy, withSendLock } from './pool-send-lock.js';
19
+ import { markSent, sentLog } from './pool-sent.js';
20
+ import { shellReader } from './projection.js';
21
+ import { isVocabularyExpired, REGISTRY_MAX_AGE_DAYS, VOCAB_SNAPSHOT_DATE } from './public-vocabulary.js';
22
+ import { contentHash, FIRST_ORDER_FAMILIES } from './read.js';
23
+ import { isObject, keptReplica, loadSignIns, numberIn, replicaFile, signInOf, signInsFile, textIn } from './pool-replica.js';
24
+ import { CAPTURE_VERSION, harnessOf as sessionHarness } from './schema.js';
25
+ import { loadStore, readRedactDomains, readSkeletons, readState, updatePoolChoices } from './store.js';
26
+ import { runGit } from './survival.js';
27
+ import { vocabulary } from './vocab-load.js';
28
+ const LOGIN = 'pool-login';
29
+ export const DEPOSIT = 'pool-deposit';
30
+ const OUTBOX = 'pool-outbox';
31
+ const POOL_DEPOSITS = '/v1/deposits';
32
+ const FETCH = 'pool-fetch';
33
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
34
+ export const POOL_PROVIDERS = ['github', 'google'];
35
+ const DEFAULT_INTERVAL_SEC = 5;
36
+ const DEFAULT_EXPIRY_SEC = 900;
37
+ /** RFC 8628: a `slow_down` adds five seconds to every poll after it. */
38
+ const SLOW_DOWN_MS = 5000;
39
+ export { isObject, loadSignIns, textIn };
40
+ const usage = (message, step) => new CliError(message, ExitCode.USAGE, step);
41
+ /** The Many Oars pool, used when neither `--server` nor `OARS_POOL_URL` names another. */
42
+ export const DEFAULT_POOL_URL = 'https://pool.manyoars.com';
43
+ /** `--server`, else `OARS_POOL_URL`, else the Many Oars pool. A token and a session's shape cross this connection, so plain http goes only to this machine. */
44
+ export const poolServer = (env, step, named) => {
45
+ const given = named ?? (env.OARS_POOL_URL === undefined || env.OARS_POOL_URL === '' ? DEFAULT_POOL_URL : env.OARS_POOL_URL);
46
+ let url;
47
+ try {
48
+ url = new URL(given);
49
+ }
50
+ catch {
51
+ throw usage(`The pool server ${given} is not a URL.`, step);
52
+ }
53
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname)))
54
+ throw usage(`Refusing the pool server ${given}: it takes https, or plain http only to localhost.`, step);
55
+ if (url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '')
56
+ throw usage(`The pool server ${given} is an origin only: no credentials, path, or query.`, step);
57
+ return url.origin;
58
+ };
59
+ export const runtimeOf = (deps) => {
60
+ if (deps.sessions === undefined)
61
+ throw new CliError('The session store is not configured in this run.', ExitCode.UNEXPECTED, 'unexpected');
62
+ return deps.sessions;
63
+ };
64
+ export const signedIn = async (deps, storeDir, server, step) => {
65
+ const found = (await loadSignIns(storeDir, step)).find((entry) => entry.server === server);
66
+ if (found === undefined || found.expiresAt <= deps.now())
67
+ throw new CliError(`Not signed in to ${server}. Run oars pool login first.`, ExitCode.AUTH, step);
68
+ return found;
69
+ };
70
+ /** The request exactly as it will be sent: `outbox` prints it, and `ask` hands the same value to `fetch`. */
71
+ const outgoing = (deps, server, path, init) => {
72
+ const headers = [['user-agent', `oars/${deps.version}`], ['accept', 'application/json']];
73
+ if (init.token !== undefined)
74
+ headers.push(['authorization', `Bearer ${init.token}`]);
75
+ // Node's `fetch` writes `cache-control` and `pragma` itself whenever `if-none-match` is set, so they are named here to be printed.
76
+ if (init.part !== undefined)
77
+ headers.push(['oars-part', `${String(init.part.index)}/${String(init.part.count)}`], ['oars-batch', init.part.batch]);
78
+ if (init.ifNoneMatch !== undefined)
79
+ headers.push(['if-none-match', init.ifNoneMatch], ['cache-control', 'no-cache'], ['pragma', 'no-cache']);
80
+ headers.push(...(init.extra ?? []));
81
+ const body = init.bytes ?? (init.body === undefined ? undefined : JSON.stringify(init.body));
82
+ if (body !== undefined)
83
+ headers.push(['content-type', init.type ?? 'application/json']);
84
+ headers.push(...TRANSPORT_HEADERS);
85
+ return { method: init.method ?? (body === undefined ? 'GET' : 'POST'), url: new URL(path, server), headers, ...(body === undefined ? {} : { body }) };
86
+ };
87
+ /** One call to the pool. A redirect is an error: it could carry a batch or a token somewhere the checks on the named server never saw. */
88
+ export const ask = (deps, step, server, path, init, via) => {
89
+ const request = outgoing(deps, server, path, init);
90
+ return via === undefined ? send(deps, step, request, init.show) : via(request);
91
+ };
92
+ /** Prints the request when asked to, then sends exactly those headers and that body. */
93
+ const send = async (deps, step, request, show) => {
94
+ if (show !== undefined)
95
+ deps.io.stderr(`${printed(request, show)}
96
+ `);
97
+ let response;
98
+ try {
99
+ response = await fetch(request.url, { method: request.method, headers: request.headers, redirect: 'error', ...(request.body === undefined ? {} : { body: request.body }) });
100
+ }
101
+ catch (error) {
102
+ throw unreachableError(step, error, request.url.origin);
103
+ }
104
+ let body;
105
+ try {
106
+ body = await response.json();
107
+ }
108
+ catch {
109
+ body = undefined;
110
+ }
111
+ return { status: response.status, body, headers: response.headers };
112
+ };
113
+ /** The sender `deposit --send` uses when none is given: the request goes out over the network as built. The loopback test passes it to read what reaches the socket. */
114
+ export const networkSender = (deps, step) => (request) => send(deps, step, request, undefined);
115
+ /** The server's named refusal, as a failure: its `error` is the problem's code in `--json`. */
116
+ const refusal = (step, { status, body }) => {
117
+ const code = isObject(body) ? textIn(body, 'error') : undefined;
118
+ const message = (isObject(body) ? textIn(body, 'message') : undefined) ?? `The pool answered HTTP ${String(status)}.`;
119
+ return new CliError(message, exitCodeForProblem(status, code === undefined ? undefined : { code }), step, { title: message, status, ...(code === undefined ? {} : { code }) });
120
+ };
121
+ const PROVIDER_FLOWS = new Map([
122
+ ['github', { deviceUrl: 'https://github.com/login/device/code', tokenUrl: 'https://github.com/login/oauth/access_token', scope: '', clientIdVar: 'OARS_GITHUB_CLIENT_ID' }],
123
+ ['google', { deviceUrl: 'https://oauth2.googleapis.com/device/code', tokenUrl: 'https://oauth2.googleapis.com/token', scope: 'openid', clientIdVar: 'OARS_GOOGLE_CLIENT_ID', secretVar: 'OARS_GOOGLE_CLIENT_SECRET' }],
124
+ ]);
125
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
126
+ /** The provider's side of the flow, read from the environment. An unset value stops the command before anything is sent, and names what is missing. */
127
+ const configOf = (env, provider) => {
128
+ const set = (name) => (env[name] === undefined || env[name] === '' ? undefined : env[name]);
129
+ const clientId = set(provider.clientIdVar);
130
+ if (clientId === undefined)
131
+ throw usage(`Set ${provider.clientIdVar} to the client ID of the Many Oars OAuth app; it is not set.`, LOGIN);
132
+ const clientSecret = provider.secretVar === undefined ? undefined : set(provider.secretVar);
133
+ if (provider.secretVar !== undefined && clientSecret === undefined)
134
+ throw usage(`Set ${provider.secretVar} to the client secret of the Many Oars OAuth app; it is not set.`, LOGIN);
135
+ return { clientId, clientSecret };
136
+ };
137
+ /** One form POST to a provider, printed before it is sent. The provider answers JSON on whatever status it chooses (Google's pending poll is a 428), so the caller reads the body and not the status. */
138
+ const askProvider = (deps, url, form, masker) => {
139
+ const headers = [['user-agent', `oars/${deps.version}`], ['accept', 'application/json'], ['content-type', 'application/x-www-form-urlencoded'], ...TRANSPORT_HEADERS];
140
+ return send(deps, LOGIN, { method: 'POST', url: new URL(url), headers, body: new URLSearchParams(form).toString() }, masker);
141
+ };
142
+ const deviceOf = ({ body }) => {
143
+ // Google names the link `verification_url`, against the RFC's `verification_uri`.
144
+ const [deviceCode, userCode, verificationUri] = isObject(body) ? [textIn(body, 'device_code'), textIn(body, 'user_code'), textIn(body, 'verification_uri') ?? textIn(body, 'verification_url')] : [];
145
+ // A provider that refuses here (GitHub's `device_flow_disabled`, Google's `invalid_client`) is how an unfinished OAuth app shows, so its word goes to the operator.
146
+ const refusal = isObject(body) ? textIn(body, 'error') : undefined;
147
+ if (refusal !== undefined) {
148
+ const title = `The provider refused the sign-in: ${refusal}.`;
149
+ throw new CliError(title, ExitCode.AUTH, LOGIN, { code: 'login_refused', title });
150
+ }
151
+ // `openBrowser` parses the link before it returns its promise, so one that cannot parse has to stop here or it throws past the login's catch.
152
+ if (!isObject(body) || deviceCode === undefined || userCode === undefined || verificationUri === undefined || !URL.canParse(verificationUri))
153
+ throw new CliError('The provider did not start a sign-in.', ExitCode.UNEXPECTED, LOGIN);
154
+ // An interval of 0 or below would poll with no wait and never run out the clock, so it takes the default like a missing one.
155
+ const interval = numberIn(body, 'interval');
156
+ return { deviceCode, userCode, verificationUri, intervalMs: (interval === undefined || interval <= 0 ? DEFAULT_INTERVAL_SEC : interval) * 1000, expiresMs: (numberIn(body, 'expires_in') ?? DEFAULT_EXPIRY_SEC) * 1000 };
157
+ };
158
+ /** Polls the provider's token route until the operator approves, and returns the provider's token. Time is counted in the waits asked for, so a machine that slept through the code still gets one poll that says it expired. */
159
+ const awaitToken = async (deps, provider, config, device, masker) => {
160
+ let { intervalMs } = device;
161
+ const form = [['client_id', config.clientId], ['device_code', device.deviceCode], ['grant_type', DEVICE_GRANT]];
162
+ if (config.clientSecret !== undefined)
163
+ form.push(['client_secret', config.clientSecret]);
164
+ for (let waited = 0; waited < device.expiresMs; waited += intervalMs) {
165
+ await deps.sleep(intervalMs);
166
+ const { body } = await askProvider(deps, provider.tokenUrl, form, masker);
167
+ if (!isObject(body))
168
+ throw new CliError('The provider answered a sign-in poll with no body.', ExitCode.UNEXPECTED, LOGIN);
169
+ const accessToken = textIn(body, 'access_token');
170
+ if (accessToken !== undefined)
171
+ return accessToken;
172
+ const error = textIn(body, 'error') ?? 'no_token';
173
+ if (error === 'slow_down')
174
+ intervalMs += SLOW_DOWN_MS;
175
+ else if (error === 'expired_token')
176
+ break;
177
+ else if (error !== 'authorization_pending') {
178
+ const title = `The provider refused the sign-in: ${error}.`;
179
+ throw new CliError(title, ExitCode.AUTH, LOGIN, { code: 'login_refused', title });
180
+ }
181
+ }
182
+ const title = 'The sign-in code expired. Run oars pool login again.';
183
+ throw new CliError(title, ExitCode.AUTH, LOGIN, { code: 'EXPIRED_TOKEN', title });
184
+ };
185
+ /**
186
+ * `oars pool login` (spec Section 1.4): the device flow runs here, against the provider, and the pool sees only the provider's token, once, and answers its own credential. Every request prints before it is sent, with the device code, the provider's token,
187
+ * and the credential masked as `<credential: n bytes>`. A client ID, scope, grant type, and Google's `client_secret` print as they are: none is secret.
188
+ */
189
+ export const poolLogin = async (deps, options) => {
190
+ const { storeDir } = runtimeOf(deps);
191
+ const server = poolServer(deps.env, LOGIN, options.server);
192
+ const { provider } = options;
193
+ const flow = PROVIDER_FLOWS.get(provider ?? '');
194
+ if (provider === undefined || flow === undefined)
195
+ throw usage(`The provider is one of ${POOL_PROVIDERS.join(', ')}.`, LOGIN);
196
+ const config = configOf(deps.env, flow);
197
+ const saved = await loadSignIns(storeDir, LOGIN);
198
+ const masker = new Masker();
199
+ const device = deviceOf(await askProvider(deps, flow.deviceUrl, [['client_id', config.clientId], ['scope', flow.scope]], masker));
200
+ masker.add(device.deviceCode);
201
+ deps.io.stderr(`To sign in, open ${device.verificationUri}\nand confirm the code ${device.userCode}\n`);
202
+ await deps.openBrowser(device.verificationUri).catch(() => {
203
+ deps.io.stderr('Could not open a browser; open the link above yourself.\n');
204
+ });
205
+ const accessToken = await awaitToken(deps, flow, config, device, masker);
206
+ masker.add(accessToken);
207
+ const reply = await ask(deps, LOGIN, server, '/v1/login', { body: { provider, token: accessToken }, show: masker });
208
+ if (reply.status !== 200)
209
+ throw refusal(LOGIN, reply);
210
+ const signIn = signInOf(isObject(reply.body) ? { ...reply.body, server } : undefined);
211
+ if (signIn === undefined)
212
+ throw new CliError('The pool answered a sign-in with no account, token, or signing key.', ExitCode.UNEXPECTED, LOGIN);
213
+ const known = saved.find((entry) => entry.server === server);
214
+ // The key is trusted once, here, and never replaced by a later login: a server that swaps it could otherwise sign anything it likes.
215
+ if (known !== undefined && known.signingKey.x !== signIn.signingKey.x)
216
+ throw new CliError(`${server} now signs with a different key than the one pinned at your first login. Nothing was saved. If the pool really did change its key, remove ${server} from ${signInsFile(storeDir)} and sign in again.`, ExitCode.AUTH, LOGIN, { code: 'signing_key_changed', title: 'The pool signing key changed.' });
217
+ await mkdir(storeDir, { recursive: true });
218
+ await writeFile(signInsFile(storeDir), `${JSON.stringify([...saved.filter((entry) => entry !== known), signIn])}\n`, { mode: 0o600 });
219
+ return { document: { ok: true, server, account: signIn.account, expiresAt: signIn.expiresAt }, text: `Signed in to ${server} as ${signIn.account}.` };
220
+ };
221
+ /** A send stops here when the server is closed or wants a covenant accepted, before the batch is read for the last time. */
222
+ const openCovenant = async (deps, server, token, accept, via) => {
223
+ const reply = await ask(deps, DEPOSIT, server, '/v1/covenant', { token }, via);
224
+ if (reply.status !== 200 || !isObject(reply.body))
225
+ throw refusal(DEPOSIT, reply);
226
+ const version = textIn(reply.body, 'version');
227
+ if (version === undefined) {
228
+ const title = 'This pool is not taking deposits: its covenant is not published.';
229
+ throw new CliError(title, ExitCode.AUTH, DEPOSIT, { code: 'deposits_closed', title, status: 403 });
230
+ }
231
+ if (accept !== undefined) {
232
+ const accepted = await ask(deps, DEPOSIT, server, '/v1/covenant/accept', { token, body: { version: accept } }, via);
233
+ if (accepted.status !== 200)
234
+ throw refusal(DEPOSIT, accepted);
235
+ return;
236
+ }
237
+ if (Reflect.get(reply.body, 'accepted') !== true) {
238
+ const title = `Read covenant ${version} of this pool, then rerun with --accept-covenant ${version}.`;
239
+ throw new CliError(title, ExitCode.AUTH, DEPOSIT, { code: 'covenant_not_accepted', title, status: 403 });
240
+ }
241
+ };
242
+ const excludedKey = (reason) => (reason === 'no-raw' ? 'noRaw' : reason === 'no-terms' ? 'noTerms' : reason);
243
+ /** A deposit answered with this code is sent again: another deposit of the same session landed between the pool's read and its write, and the next one nets against the winner. */
244
+ const DEPOSIT_CONFLICT = 'deposit_conflict';
245
+ const SEND_ATTEMPTS = 3;
246
+ export const vocabularyExpired = (deps) => {
247
+ if (!isVocabularyExpired(new Date(deps.now())))
248
+ return undefined;
249
+ const title = `This oars is out of date: its vocabulary ${VOCAB_VERSION} was taken on ${VOCAB_SNAPSHOT_DATE}, over ${String(REGISTRY_MAX_AGE_DAYS)} days ago. Upgrade with npm install -g @manyoars/oars, then run oars pool deposit again. Your sessions stay on this machine.`;
250
+ return new CliError(title, ExitCode.CONFLICT, DEPOSIT, { code: 'vocabulary_expired', title, status: 409 });
251
+ };
252
+ const lockMessages = {
253
+ batch_unknown: 'No batch with that hash is in the outbox. Run oars pool outbox to list them.',
254
+ batch_not_shown: 'That batch was never printed. Run oars pool outbox, read it, then send it.',
255
+ batch_changed: 'That batch changed on disk after it was printed, or since it was written, so it was not sent. Run oars pool deposit to build it again, then oars pool outbox to print it.',
256
+ };
257
+ /** The calls one batch takes, in order: one, or one per part when it is past a request's bytes. `outbox` prints these and `deposit --send` sends these, so what is printed is what is sent. */
258
+ const callsOf = (token, bytes, hash) => {
259
+ const parts = partsOf(bytes);
260
+ return parts.map((piece, at) => ({ token, bytes: piece, ...(parts.length === 1 ? {} : { part: { index: at + 1, count: parts.length, batch: hash } }) }));
261
+ };
262
+ /** One batch to the pool: a batch past one request's bytes goes in parts, in order, each answered 202 until the last completes the deposit with 201. A part sent again is harmless, so the retry covers every part alike. */
263
+ export const postBatch = async (deps, options) => {
264
+ const { server, token, bytes, hash, via } = options;
265
+ const calls = callsOf(token, bytes, hash);
266
+ for (const [at, call] of calls.entries()) {
267
+ let reply = await ask(deps, DEPOSIT, server, POOL_DEPOSITS, call, via);
268
+ for (let attempt = 1; attempt < SEND_ATTEMPTS && reply.status === 409 && isObject(reply.body) && textIn(reply.body, 'error') === DEPOSIT_CONFLICT; attempt += 1) {
269
+ reply = await ask(deps, DEPOSIT, server, POOL_DEPOSITS, call, via);
270
+ }
271
+ // 201 on any part means the deposit already finished (this part is a retry), so the rest need not be sent.
272
+ const ended = reply.status !== 202 || at === calls.length - 1;
273
+ // One send event per batch, on the reply that ends it, whatever number of parts it took.
274
+ if (ended)
275
+ recordSend(deps, { status: reply.status, code: isObject(reply.body) ? textIn(reply.body, 'error') : undefined, bytes: Buffer.byteLength(bytes) });
276
+ if (reply.status === 201)
277
+ break;
278
+ if (ended)
279
+ throw refusal(DEPOSIT, reply);
280
+ }
281
+ emitOf(deps)('deposit', { step: 'send', sent: 1, bytes: Buffer.byteLength(bytes) });
282
+ };
283
+ /**
284
+ * One redacted raw copy to the pool: its parts by PUT, then the completion, which the pool checks against the SHA-256 in the path. Every request states the redaction and capture versions and that the person said yes to the raw prompt;
285
+ * the caller sends only for a person who did. A part sent again replaces itself, and one for an object that stands writes nothing, so a copy that failed halfway is sent again whole.
286
+ */
287
+ export const postRaw = async (deps, options) => {
288
+ const { server, token, hash, body, bytes, via } = options;
289
+ const extra = [['oars-redaction-version', REDACTION_VERSION], ['oars-capture-version', CAPTURE_VERSION], ['oars-raw-consent', 'granted']];
290
+ const parts = rawParts(body);
291
+ for (const [at, piece] of parts.entries()) {
292
+ const reply = await ask(deps, DEPOSIT, server, `/v1/raw/${hash}/parts/${String(at + 1)}`, { token, bytes: piece, method: 'PUT', type: 'application/x-ndjson', extra }, via);
293
+ if (reply.status !== 200)
294
+ throw refusal(DEPOSIT, reply);
295
+ }
296
+ const done = await ask(deps, DEPOSIT, server, `/v1/raw/${hash}/complete`, { token, body: { parts: parts.length, bytes }, extra }, via);
297
+ if (done.status !== 200 && done.status !== 201)
298
+ throw refusal(DEPOSIT, done);
299
+ };
300
+ const rawNote = (consent) => consent === 'granted' ? '' : consent === 'declined' ? 'You answered no to the raw prompt; oars pool raw --consent yes changes it.' : 'None is queued until you answer the raw prompt with oars pool raw --consent yes.';
301
+ /** `deposit --send <hash>`: the send lock the nightly task takes too, then a skip for a hash already in `sent.log`, then the batch lock, then the covenant (accepted here or before, or nothing is sent), then the request. The body is read and hashed again just before the request. */
302
+ const sendBatch = async (deps, options) => {
303
+ const { outbox, server, token, hash } = options;
304
+ const via = options.via ?? networkSender(deps, DEPOSIT);
305
+ if (!/^[0-9a-f]{64}$/.test(hash))
306
+ throw usage('--send takes the SHA-256 that oars pool outbox printed: 64 hexadecimal characters.', DEPOSIT);
307
+ const refuse = (code, title) => new CliError(title, ExitCode.CONFLICT, DEPOSIT, { code, title, status: 409 });
308
+ try {
309
+ return await withSendLock(outbox, async () => {
310
+ // The rule the nightly follows: a hash `sent.log` holds is not posted again. Read under the lock, so two runs cannot both see it unsent.
311
+ const sentAt = (await sentLog(outbox)).get(hash);
312
+ if (sentAt !== undefined)
313
+ return { document: { ok: true, server, hash, sent: false, sentAt }, text: `already sent ${hash} at ${sentAt}` };
314
+ const locked = await lockedBatch(outbox, hash);
315
+ if (!locked.ok)
316
+ throw refuse(locked.code, lockMessages[locked.code]);
317
+ await openCovenant(deps, server, token, options.acceptCovenant, via);
318
+ const bytes = await unchangedBytes(locked.file, hash);
319
+ if (bytes === undefined)
320
+ throw refuse('batch_changed', lockMessages.batch_changed);
321
+ await postBatch(deps, { server, token, bytes, hash, via });
322
+ await markSent(outbox, hash, deps.now());
323
+ return { document: { ok: true, server, hash, sent: true }, text: `Sent batch ${hash} to ${server}.` };
324
+ });
325
+ }
326
+ catch (error) {
327
+ if (error instanceof SendLockBusy)
328
+ throw refuse('send_locked', 'Another oars run is sending from this outbox. Try again in a minute.');
329
+ throw error;
330
+ }
331
+ };
332
+ /**
333
+ * `oars pool deposit`: every session in the store that passes both session gates is built into a canonical batch in the outbox, and the command stops: nothing is sent. The gates run before any projection does: the folder
334
+ * allowlist, then the exclusion check. A session that fails either, or has no raw to scan, is counted and never built. A session is built only once it has ended: no transcript of it, main or subagent, has gained a line (the later of its capture
335
+ * and its raw file's last write) for `session_idle_hours` (6 hours). One that has not is counted under `notEnded`, and any batch of it from an earlier pass is withdrawn. `--send <hash>` is the lock in `sendBatch`.
336
+ */
337
+ export const poolDeposit = async (deps, options) => {
338
+ const runtime = runtimeOf(deps);
339
+ const server = poolServer(deps.env, DEPOSIT, options.server);
340
+ const { account, token } = await signedIn(deps, runtime.storeDir, server, DEPOSIT);
341
+ // A client past `registry_max_age` builds no batch (spec Section 1.3): its sessions wait in `A` and nothing is lost. This comes before anything is written or sent.
342
+ const expired = vocabularyExpired(deps);
343
+ if (expired !== undefined)
344
+ throw expired;
345
+ // Accepting the covenant is a step of sending a batch: alone it would exit 0 as though something had been sent under it.
346
+ if (options.send === undefined && options.acceptCovenant !== undefined)
347
+ throw usage('--accept-covenant belongs to --send: it is accepted as a step of sending a batch. Nothing was accepted.', DEPOSIT);
348
+ const outbox = outboxFolder(runtime.storeDir, server);
349
+ if (options.send !== undefined)
350
+ return sendBatch(deps, { outbox, server, token, hash: options.send, acceptCovenant: options.acceptCovenant, via: options.via });
351
+ const { terms } = exclusionPaths(runtime.projectsRoot, deps.env);
352
+ const saved = await readState(runtime.storeDir);
353
+ const names = localNames();
354
+ const allow = compileAllow(saved.allow);
355
+ const discarded = await discardStale(outbox);
356
+ const counts = { sessions: 0, batched: 0, excluded: { folder: 0, term: 0, noRaw: 0, noTerms: 0, unreadable: 0 }, off: 0, raw: { copies: 0, bytes: 0, masked: 0 }, uncaptured: 0, notEnded: 0, refused: 0, refusedFields: [], discarded, withdrawn: 0 };
357
+ // The one gate on a name: the operator's redactor and exclusion terms (t881). Everything else the pool classes itself.
358
+ const gate = { redact: makeRedactor(await readRedactDomains(runtime.storeDir)), terms: await loadTerms(terms) };
359
+ const built = new Set();
360
+ const rawKept = new Set();
361
+ // The bytes of the batches written in this pass: telemetry counts them, and the command's own document does not carry them.
362
+ let builtBytes = 0;
363
+ const wireTally = newTally();
364
+ const refusedFields = new Set();
365
+ const roots = new Map((await readSkeletons(runtime.storeDir, undefined, true)).skeletons.map(({ session, merkleRoot, leaves }) => [session, { merkleRoot, leaves }]));
366
+ // The sessions that pass both gates and have ended, in the order the store holds them: the windows of a delta are counted over these alone, and the batch of each is built after, once its deltas are known.
367
+ const ready = [];
368
+ // A session that fails a gate loses its earlier batch at once, so a later throw in this pass cannot leave one in the outbox.
369
+ const withdraw = async (key) => {
370
+ counts.withdrawn += await removeBatchOf(outbox, key);
371
+ await removeRawExcept(outbox, new Set(), key);
372
+ };
373
+ for (const records of sessionsOf(await loadStore(runtime.storeDir), roots)) {
374
+ counts.sessions += 1;
375
+ // Hashed with the account, so a redeposit replaces its own batch and two accounts' copies of one session cannot be joined.
376
+ const key = contentHash([account, records.main.session]);
377
+ if (!allow.allows(records.main.project)) {
378
+ counts.excluded.folder += 1;
379
+ await withdraw(key);
380
+ continue;
381
+ }
382
+ // A folder switched off sends nothing from now on; what it sent before stays where it went.
383
+ if (sendsOf(saved.pool, records.main.project) === 'off') {
384
+ counts.off += 1;
385
+ await withdraw(key);
386
+ continue;
387
+ }
388
+ const verdict = await checkSession(runtime, terms, records.main.project, records.main.session);
389
+ if (verdict.reason !== 'clean') {
390
+ counts.excluded[excludedKey(verdict.reason)] += 1;
391
+ await withdraw(key);
392
+ continue;
393
+ }
394
+ // The batch of a session is built once the session and its subagents have ended; a session still in progress waits, and an earlier batch of it is withdrawn.
395
+ if (!hasEnded(records, deps.now(), await lastWritten(runtime, records.main.project, records.main.session))) {
396
+ counts.notEnded += 1;
397
+ await withdraw(key);
398
+ continue;
399
+ }
400
+ ready.push({ records, key });
401
+ }
402
+ // A delta is built from the sessions that passed, carried by the batch of the session that filled its after window, and marked sent only once that batch is written: a deposit that stops first builds it again.
403
+ const placed = ready.flatMap(({ records }) => placedOf(records) ?? []);
404
+ const carried = carriedDeltas(account, placed, await sentDeltas(outbox));
405
+ // The coverage bands (t871): the operator's two settings, read here, and the team band counted from the local git log. Nothing is looked up, and anything unread goes as unknown.
406
+ const coverageOf = makeCoverage(runtime, await readCoverageSetting(runtime.storeDir), options.git ?? runGit);
407
+ for (const { records, key } of ready) {
408
+ const delta = carried.get(records.main.session);
409
+ const skeleton = skeletonOf(records, key, shellReader(vocabulary()), contentHash, delta?.entries, gate, await coverageOf(records.main));
410
+ if (skeleton === null) {
411
+ counts.uncaptured += 1;
412
+ await withdraw(key);
413
+ continue;
414
+ }
415
+ const parsed = parseSkeleton(skeleton);
416
+ // The server reads a value outside its lists as `other` and keeps a field it does not know, so the parser no longer refuses either: the emitter is the privacy control, and a batch this client built holds neither.
417
+ const refusedAt = parsed.ok ? parsed.extensions[0]?.at : parsed.field;
418
+ if (!parsed.ok || refusedAt !== undefined) {
419
+ counts.refused += 1;
420
+ refusedFields.add(refusedAt ?? '');
421
+ await withdraw(key);
422
+ continue;
423
+ }
424
+ builtBytes += (await writeBatch(outbox, key, parsed.skeleton)).bytes;
425
+ // The raw copy comes only after both gates and only with a yes to the raw prompt and a folder not at skeleton only: each transcript of the session, redacted here, before it is queued.
426
+ if (rawAllowed(saved.pool, records.main.project)) {
427
+ let place = 0;
428
+ for (const file of await transcriptFilesOf(runtime, records.main.project, records.main.session)) {
429
+ const redacted = redactTranscript(await readFile(file.path, 'utf8'), saved.redactDomains, names);
430
+ counts.raw.bytes += (await writeRaw(outbox, key, place, redacted.text)).bytes;
431
+ counts.raw.copies += 1;
432
+ counts.raw.masked += redacted.masked;
433
+ place += 1;
434
+ }
435
+ rawKept.add(key);
436
+ }
437
+ tallyBatch(wireTally, parsed.skeleton);
438
+ await markDeltasSent(outbox, delta?.marks ?? []);
439
+ built.add(key);
440
+ counts.batched += 1;
441
+ }
442
+ // The batch of a session or account this store no longer holds goes too.
443
+ counts.withdrawn += await removeBatchesExcept(outbox, built);
444
+ await removeRawExcept(outbox, rawKept);
445
+ counts.refusedFields = [...refusedFields].sort();
446
+ recordWire(deps, wireTally);
447
+ const { folder, term, noRaw, noTerms, unreadable } = counts.excluded;
448
+ // Counts only: how many sessions each gate kept out, never a folder, a term, or a name.
449
+ emitOf(deps)('deposit', { step: 'build', sessions: counts.sessions, batched: counts.batched, bytes: builtBytes, excludedFolder: folder, excludedTerm: term, excludedNoRaw: noRaw, excludedNoTerms: noTerms, excludedUnreadable: unreadable, notEnded: counts.notEnded, uncaptured: counts.uncaptured, refused: counts.refused, withdrawn: counts.withdrawn, discarded });
450
+ const lines = [
451
+ `Wrote ${String(counts.batched)} of ${String(counts.sessions)} sessions to the outbox for ${server}. Nothing was sent.`,
452
+ `Kept out by the session gates, never built: ${String(folder)} in a folder not on the allowlist, ${String(term)} for a term, ${String(noRaw)} with no raw to scan, ${String(noTerms)} with no term file, ${String(unreadable)} unreadable.`,
453
+ `Not built: ${String(counts.uncaptured)} captured before steps and frames or with no root, ${String(counts.notEnded)} not ended (a transcript gained a line within ${String(SESSION_IDLE_HOURS)} hours), ${String(counts.refused)} refused by the vocabulary${counts.refusedFields.length === 0 ? '' : ` (${counts.refusedFields.join(', ')})`}.`,
454
+ ...(counts.withdrawn === 0 ? [] : [`Removed ${String(counts.withdrawn)} ${counts.withdrawn === 1 ? 'batch' : 'batches'} of sessions that no longer pass the gates, have not ended, or are no longer in the store.`]),
455
+ ...(discarded === 0 ? [] : [`Discarded ${String(discarded)} ${discarded === 1 ? 'batch' : 'batches'} built under an older vocabulary, field list, or skeleton format.`]),
456
+ `Raw copies queued: ${String(counts.raw.copies)} (${String(counts.raw.bytes)} bytes, ${String(counts.raw.masked)} values masked). ${rawNote(saved.pool.rawConsent)}`.trim(),
457
+ ...(counts.off === 0 ? [] : [`${String(counts.off)} ${counts.off === 1 ? 'session' : 'sessions'} in a folder switched off: nothing of them is queued.`]),
458
+ 'Run oars pool outbox to print exactly what would be sent.',
459
+ ];
460
+ return { document: { ok: true, server, ...counts }, text: lines.join('\n') };
461
+ };
462
+ /** Each field of a batch, at any depth, with its row. A field with no row fails the command rather than printing blank. */
463
+ const explained = (bytes) => {
464
+ const rows = {};
465
+ for (const field of fieldsOf(bytes)) {
466
+ const row = Object.hasOwn(FIELD_ROWS, field) ? FIELD_ROWS[field] : undefined;
467
+ if (row === undefined) {
468
+ const title = `The batch holds a field, ${field}, with no row to explain it. Nothing was printed for it.`;
469
+ throw new CliError(title, ExitCode.INVALID, OUTBOX, { code: 'field_without_row', title });
470
+ }
471
+ rows[field] = row;
472
+ }
473
+ return rows;
474
+ };
475
+ /** `oars pool outbox`: prints each batch as it would be sent (a batch past one request prints each part's request, in order), the request line and every header included, with the bearer credential as its size and nothing else masked, then its SHA-256, and records that it was shown. `--explain` adds each field's rule. */
476
+ export const poolOutbox = async (deps, options) => {
477
+ const runtime = runtimeOf(deps);
478
+ const server = poolServer(deps.env, OUTBOX, options.server);
479
+ const { token } = await signedIn(deps, runtime.storeDir, server, OUTBOX);
480
+ const outbox = outboxFolder(runtime.storeDir, server);
481
+ const { batches, edited } = await heldBatches(outbox);
482
+ const masker = new Masker();
483
+ masker.add(token);
484
+ const sent = await sentLog(outbox);
485
+ const shown = [];
486
+ for (const batch of batches) {
487
+ const fields = options.explain === true ? explained(batch.bytes) : undefined;
488
+ // A batch past one request prints every part's request, in the order they are sent.
489
+ const request = callsOf(token, batch.bytes, batch.hash).map((call) => printed(outgoing(deps, server, POOL_DEPOSITS, call), masker)).join('\n');
490
+ shown.push({ hash: batch.hash, request, ...(sent.has(batch.hash) ? { sent: sent.get(batch.hash) ?? '' } : {}), ...(fields === undefined ? {} : { fields }) });
491
+ }
492
+ const { copies } = await heldRaw(outbox);
493
+ const rawBlocks = copies.flatMap((copy) => rawParts(copy.body).map((part, at, all) => [`raw part ${String(at + 1)}/${String(all.length)} sha256: ${copy.hash} bytes: ${String(Buffer.byteLength(part))}${sent.has(copy.hash) ? ` sent: ${sent.get(copy.hash) ?? ''}` : ''}`, ...(options.explain === true ? [part] : [])].join('\n')));
494
+ for (const { hash } of shown)
495
+ await markShown(outbox, hash, deps.now());
496
+ const stale = await staleBatches(outbox);
497
+ const blocks = shown.map(({ hash, request, sent: at, fields }) => [request, `sha256: ${hash}`, ...(at === undefined ? [] : [`sent: ${at}`]), ...Object.entries(fields ?? {}).map(([field, row]) => ` ${field}: ${row}`)].join('\n'));
498
+ const notes = [
499
+ ...(edited === 0 ? [] : [`${String(edited)} ${edited === 1 ? 'batch was' : 'batches were'} edited on disk since ${edited === 1 ? 'it was' : 'they were'} written and ${edited === 1 ? 'is' : 'are'} not printed or sent. Run oars pool deposit to build ${edited === 1 ? 'it' : 'them'} again.`]),
500
+ ...(stale === 0 ? [] : [`${String(stale)} ${stale === 1 ? 'batch was' : 'batches were'} built under an older vocabulary, field list, or skeleton format and will never be sent.`]),
501
+ ];
502
+ const text = shown.length === 0 && rawBlocks.length === 0 ? ['The outbox is empty. Run oars pool deposit first.', ...notes].join('\n') : [...blocks, ...rawBlocks, ...notes].join('\n\n');
503
+ return { document: { ok: true, server, batches: shown, raw: copies.map(({ hash, bytes }) => ({ hash, bytes, ...(sent.has(hash) ? { sent: sent.get(hash) ?? '' } : {}) })), edited, stale }, text };
504
+ };
505
+ /**
506
+ * What the pool says it served a bundle at, printed as the bundle states it: the client holds no floor of its own and applies none (a floor is the pool's deploy setting), so a bundle that states none prints nothing.
507
+ * A family on a floor of its own is named with it, but only a family this client knows: a key the pool adds later parses and travels in `--json`, and no pool-supplied string reaches the terminal.
508
+ */
509
+ const floorSentence = (floors) => {
510
+ if (floors === undefined)
511
+ return '';
512
+ const own = Object.entries(floors.families ?? {})
513
+ .filter(([family]) => FIRST_ORDER_FAMILIES.some((known) => known === family))
514
+ .map(([family, floor]) => `${family} at ${String(floor)}`);
515
+ return ` The pool served it at a public floor of ${String(floors.public)} clusters and a disclosure floor of ${String(floors.disclosure)}${own.length === 0 ? '' : `, with ${own.join(' and ')}`}.`;
516
+ };
517
+ /** The weekly cap, as a failure of its own code: the bundle on disk is untouched, and the words say when the pool will answer again. */
518
+ const cappedError = (deps, reply, kept) => {
519
+ const retryAfter = reply.headers.get('retry-after');
520
+ const wait = retryAfter === null ? undefined : waitWords(retryAfter, deps.now());
521
+ const when = wait === undefined ? 'The pool did not say when; it counts fetches per ISO week, so try again next week.' : `Try again ${wait}.`;
522
+ const title = `The pool's weekly fetch cap is used up. ${when} ${kept ? 'The bundle already on disk is untouched.' : 'No verified bundle is kept.'}`;
523
+ return new CliError(title, ExitCode.CAPPED, FETCH, { code: 'fetch_cap', title, status: 429 }, { retryAfter });
524
+ };
525
+ /** What a fetch asks for when no `--harness` is given, and what it falls back to when no one harness leads the local store. */
526
+ const DEFAULT_HARNESS = 'claude-code';
527
+ /** The harnesses a bundle is served for. `otel` reads the framework into the version slot, so no public bundle is keyed by it (lead 219): it is in `HARNESSES` for the wire, not for a fetch. */
528
+ const FETCHABLE = HARNESSES.filter((name) => name !== 'otel');
529
+ /**
530
+ * The harness a fetch asks for. `--harness` names it, and nothing is printed. Without the flag it is the harness the local store holds the most sessions of, and the choice and the way to
531
+ * change it go to stderr before the request: a fetch never picks silently. An empty store or a tie asks for Claude Code's and says so. Sessions of a harness with no bundle (`otel`) are not counted. It reads counts only, and none of them leaves the machine.
532
+ */
533
+ const fetchHarness = async (deps, storeDir, named) => {
534
+ if (named !== undefined) {
535
+ if (named === 'otel')
536
+ throw usage(`otel has no pool bundle. Choose one of ${FETCHABLE.join(', ')}.`, FETCH);
537
+ if (!isHarness(named))
538
+ throw usage(`${named} is not a harness the pool serves. Choose one of ${FETCHABLE.join(', ')}.`, FETCH);
539
+ return named;
540
+ }
541
+ const counts = new Map();
542
+ const sessions = (await loadStore(storeDir)).sessions;
543
+ for (const session of sessions) {
544
+ const harness = sessionHarness(session);
545
+ if (FETCHABLE.includes(harness))
546
+ counts.set(harness, (counts.get(harness) ?? 0) + 1);
547
+ }
548
+ const [top, runnerUp] = [...counts.entries()].sort((a, b) => b[1] - a[1]);
549
+ const lead = top !== undefined && (runnerUp === undefined || top[1] > runnerUp[1]) ? top : undefined;
550
+ const harness = lead === undefined ? DEFAULT_HARNESS : lead[0];
551
+ const total = [...counts.values()].reduce((sum, count) => sum + count, 0);
552
+ const none = sessions.length === 0 ? 'your local store is empty' : 'your local store holds no session of a harness the pool serves';
553
+ const why = lead === undefined ? `${total === 0 ? none : 'no one harness leads your local store'}, so Claude Code is the default` : `the harness your local store holds the most sessions of (${String(lead[1])} of ${String(total)})`;
554
+ deps.io.stderr(`No --harness given: fetching ${harness}, ${why}. Pass --harness <name> to fetch another (${FETCHABLE.join(', ')}).
555
+ `);
556
+ return harness;
557
+ };
558
+ /** `oars pool fetch`: one harness's signed bundle at one version, kept only once the key pinned at login has verified it. It keeps the signed form, so the replica can be checked again later, and the ETag, so the next fetch asks only whether it changed. The request prints before it is sent. */
559
+ export const poolFetch = async (deps, options) => {
560
+ const { storeDir } = runtimeOf(deps);
561
+ const server = poolServer(deps.env, FETCH, options.server);
562
+ const { harnessVersion } = options;
563
+ if (!isBundleVersion(harnessVersion))
564
+ throw usage(`${harnessVersion} is not a public harness version.`, FETCH);
565
+ const { token, signingKey } = await signedIn(deps, storeDir, server, FETCH);
566
+ const harness = await fetchHarness(deps, storeDir, options.harness);
567
+ const file = replicaFile(storeDir, server, harness, harnessVersion);
568
+ const kept = await keptReplica(file, signingKey, harness, harnessVersion);
569
+ const credential = new Masker();
570
+ credential.add(token);
571
+ const reply = await ask(deps, FETCH, server, `/bundle/${harness}/${encodeURIComponent(harnessVersion)}/${encodeURIComponent(VOCAB_VERSION)}`, { token, show: credential, ...(kept?.etag === undefined ? {} : { ifNoneMatch: kept.etag }) });
572
+ const emit = emitOf(deps);
573
+ if (reply.status === 429) {
574
+ emit('fetch', { harness, fetches: 1, capHits: 1 });
575
+ throw cappedError(deps, reply, kept !== undefined);
576
+ }
577
+ if (reply.status === 304) {
578
+ // A 304 answers an If-None-Match, so one to a fetch that sent none is a pool that has lost track of what it was asked.
579
+ if (kept?.etag === undefined) {
580
+ emit('fetch', { harness, fetches: 1 });
581
+ throw new CliError('The pool answered 304 to a fetch that named no kept bundle.', ExitCode.UNEXPECTED, FETCH);
582
+ }
583
+ const { cells, motifs, reads, asOf, floors } = kept.bundle;
584
+ emit('fetch', { harness, fetches: 1, etagHits: 1, reads: reads.length });
585
+ return {
586
+ document: { ok: true, server, harness, harnessVersion, file, unchanged: true, cells: cells.length, motifs: motifs.length, reads: reads.length, asOf, ...(floors === undefined ? {} : { floors }) },
587
+ text: `${harness} ${harnessVersion} is unchanged: the bundle at ${file} is still current (${String(reads.length)} reads, ${String(cells.length)} cells, and ${String(motifs.length)} motifs).${floorSentence(floors)}`,
588
+ };
589
+ }
590
+ // A refused fetch and a bundle that fails verification both reached the pool, so each is a fetch; only the success adds reads.
591
+ if (reply.status !== 200) {
592
+ emit('fetch', { harness, fetches: 1 });
593
+ throw refusal(FETCH, reply);
594
+ }
595
+ const verified = await verifyBundle(reply.body, signingKey, harness, harnessVersion);
596
+ if (!verified.ok) {
597
+ const title = `Refused the bundle: ${verified.reason}.`;
598
+ emit('fetch', { harness, fetches: 1 });
599
+ throw new CliError(title, ExitCode.INVALID, FETCH, { code: 'bundle_unverified', title });
600
+ }
601
+ const etag = reply.headers.get('etag');
602
+ await mkdir(dirname(file), { recursive: true });
603
+ await writeFile(file, `${JSON.stringify({ ...(isObject(reply.body) ? reply.body : {}), ...(etag === null ? {} : { etag }) })}
604
+ `);
605
+ const { cells, motifs, reads, asOf, floors } = verified.bundle;
606
+ emit('fetch', { harness, fetches: 1, reads: reads.length });
607
+ return {
608
+ document: { ok: true, server, harness, harnessVersion, file, unchanged: false, cells: cells.length, motifs: motifs.length, reads: reads.length, asOf, ...(floors === undefined ? {} : { floors }) },
609
+ text: `Fetched ${harness} ${harnessVersion}: ${String(reads.length)} reads, ${String(cells.length)} cells, and ${String(motifs.length)} motifs, verified against the pinned key and kept at ${file}.${floorSentence(floors)}`,
610
+ };
611
+ };
612
+ const RAW = 'pool-raw';
613
+ /** The raw prompt, in plain words: shown by `oars pool raw` until it is answered, and what the nightly send relies on. Redaction is best effort and the text says so. */
614
+ export const RAW_PROMPT = [
615
+ 'Raw transcripts are separate from the covenant. If you say yes, then once the pool says your account accepted its covenant, the nightly task uploads a redacted copy of each full transcript of every folder you allowed, beside its skeleton, except a folder you set to skeleton only or off.',
616
+ 'Redaction runs on this machine, before anything is queued, and it is best effort: it masks secret shapes it knows, emails, your username, home paths, environment values, and hosts, and it can miss a secret that has no recognizable shape or a name written in prose.',
617
+ 'It also does not catch a host with no dot, a public host name outside a link, quoted file contents and command output, a secret split across lines, images and attachments, a JSON env value, your username written another way, another person\'s hyphenated name in a project folder path, or an IPv6 address with a zone or inside a longer word, and a common-word account name is masked everywhere it appears.',
618
+ 'Say no, or say nothing, and only skeletons go to the pool. Change your mind any time: oars pool raw --consent yes|no. A no stops later sends only; it cannot recall what was already sent.',
619
+ ].join('\n');
620
+ /** `oars pool raw`: shows the raw prompt and the answer so far; `--consent yes|no` records the answer; `--folder <entry> --mode all|skeleton|off` sets what one folder may send. */
621
+ export const poolRaw = async (deps, options) => {
622
+ const runtime = runtimeOf(deps);
623
+ if (options.consent !== undefined && options.consent !== 'yes' && options.consent !== 'no')
624
+ throw usage('--consent takes yes or no.', RAW);
625
+ if ((options.folder === undefined) !== (options.mode === undefined))
626
+ throw usage('--folder and --mode go together: --folder <folder or glob> --mode all|skeleton|off.', RAW);
627
+ if (options.mode !== undefined && options.mode !== 'all' && options.mode !== 'skeleton' && options.mode !== 'off')
628
+ throw usage('--mode takes all, skeleton, or off.', RAW);
629
+ const { folder, mode } = options;
630
+ const choices = await updatePoolChoices(runtime.storeDir, (current) => {
631
+ if (options.consent !== undefined)
632
+ current.rawConsent = options.consent === 'yes' ? 'granted' : 'declined';
633
+ if (folder !== undefined) {
634
+ if (mode === 'all')
635
+ Reflect.deleteProperty(current.folders, folder);
636
+ else if (mode === 'skeleton' || mode === 'off')
637
+ current.folders[folder] = mode;
638
+ }
639
+ });
640
+ const answer = choices.rawConsent === null ? 'not answered yet: only skeletons go to the pool' : choices.rawConsent === 'granted' ? 'yes: redacted raw copies leave' : 'no: only skeletons go to the pool';
641
+ const switched = Object.entries(choices.folders).map(([entry, set]) => ` ${entry}: ${set === 'off' ? 'nothing sent to the pool' : 'skeletons only'}`);
642
+ const lines = [...(choices.rawConsent === null ? [RAW_PROMPT, ''] : []), `Raw copies: ${answer}.`, ...(switched.length === 0 ? [] : ['Folders switched down:', ...switched])];
643
+ return { document: { ok: true, rawConsent: choices.rawConsent, folders: choices.folders }, text: lines.join('\n') };
644
+ };
645
+ const COVERAGE = 'pool-coverage';
646
+ /** A named value from a closed list, or a usage error that lists the members. */
647
+ const oneOfList = (list, flag, value) => {
648
+ const found = list.find((entry) => entry === value);
649
+ if (found === undefined)
650
+ throw usage(`${flag} takes one of ${list.join(', ')}; got ${value}. Nothing was saved.`, COVERAGE);
651
+ return found;
652
+ };
653
+ /**
654
+ * `oars pool coverage`: the two bands only the operator can state, kept on this machine and read at deposit. They are what the pool can say about whom a deposit covers, not a filter; `unknown` states nothing.
655
+ * With no flag it prints what is set.
656
+ */
657
+ export const poolCoverage = async (deps, options) => {
658
+ const { storeDir } = runtimeOf(deps);
659
+ const before = await readCoverageSetting(storeDir);
660
+ const setting = {
661
+ orgSize: options.orgSize === undefined ? before.orgSize : oneOfList(ORG_SIZES, '--org-size', options.orgSize),
662
+ repoVisibility: options.repoVisibility === undefined ? before.repoVisibility : oneOfList(REPO_VISIBILITIES, '--repo-visibility', options.repoVisibility),
663
+ };
664
+ const changed = options.orgSize !== undefined || options.repoVisibility !== undefined;
665
+ if (changed)
666
+ await writeCoverageSetting(storeDir, setting);
667
+ const lines = [`Organization size: ${setting.orgSize}`, `Repository visibility: ${setting.repoVisibility}`, 'Both stay on this machine until a deposit states them as bands; nothing is looked up. The team band is counted from each repository\'s local git log at deposit.'];
668
+ return { document: { ok: true, saved: changed, ...setting }, text: lines.join('\n') };
669
+ };