@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,259 @@
1
+ // `oars gate hook`: the PreToolUse hook that records a crossing as it happens. It records, and says something only when the policy (`policy.ts`) says to: a note, or a deny labeled with a fact. It reads the call the harness hands it, runs the same
2
+ // `checkCall` replay runs, and appends one row per crossing to `crossings.jsonl`; it never allows a call and never changes its input, so the call
3
+ // runs as asked whatever happens here. Every failure is swallowed: malformed input, a missing store, a full disk, a throw. It imports only what it needs, because the
4
+ // harness waits on it before every matching tool call, and it ends before `deadlineMs` however slow the registry is.
5
+ import { appendFile, mkdir, readFile, stat } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { makeRedactor } from '../sessions/redact.js';
8
+ import { checkCall } from './checks.js';
9
+ import { withLocalBin } from './local-bin.js';
10
+ import { decide } from './notes.js';
11
+ import { loadPolicy } from './policy.js';
12
+ import { loadIndex, keepIndex, noveltyOf, scopesOf } from './novelty.js';
13
+ import { GATE_VERSION, folderKey, parseRows, permissionOf } from './record.js';
14
+ import { askable } from './registry.js';
15
+ import { ageOf, againstLatestOf, askedOf, DAY_MS } from './registry-state.js';
16
+ import { repositoryOfCwd } from './scope.js';
17
+ /** Where the crossings go. Named here, not imported from `replay.ts`, which reads transcripts and would be loaded for nothing. */
18
+ export const crossingsPath = (storeDir) => join(storeDir, 'crossings.jsonl');
19
+ /** The hook's kept registry answers, small and its own: reading the record for them would scan it. */
20
+ export const registryCachePath = (storeDir) => join(storeDir, 'gate-registry.jsonl');
21
+ /** The registry ask is cut at this many ms on the process clock, less a write reserve; `hook-main.ts` ends the process a short grace after it, so the whole call is under a second. */
22
+ export const DEADLINE_MS = 800;
23
+ /** A registry answer the hook kept is used for this many days. A `latest` is read "as of the crossing", so an older one is asked again. */
24
+ export const HOOK_REGISTRY_DAYS = 1;
25
+ /** A not-found answer is trusted this long: a name can be published any minute, and a block rests on the answer. */
26
+ export const NOT_FOUND_MS = 600_000;
27
+ /** The room a row's write is given after the registry ask has been cut off. */
28
+ const WRITE_RESERVE_MS = 150;
29
+ /** One call hands at most this many names to helpers; the rest wait for a later sight, so a call naming dozens cannot start dozens of processes. */
30
+ const MAX_HELPERS = 3;
31
+ /** An ask with less than this left is not started: it could not finish. */
32
+ const MIN_ASK_MS = 100;
33
+ const isInput = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
34
+ const text = (value) => (typeof value === 'string' && value !== '' ? value : null);
35
+ const parse = (stdin) => {
36
+ try {
37
+ const parsed = JSON.parse(stdin);
38
+ return isInput(parsed) ? parsed : {};
39
+ }
40
+ catch {
41
+ return {};
42
+ }
43
+ };
44
+ const isFolder = (path) => stat(path).then((found) => found.isDirectory(), () => false);
45
+ /** The organization domains a deposit saved to mask, read only when a call crossed something. */
46
+ const redactDomains = async (storeDir) => {
47
+ try {
48
+ const parsed = JSON.parse(await readFile(join(storeDir, 'state.json'), 'utf8'));
49
+ const domains = isInput(parsed) ? parsed.redactDomains : undefined;
50
+ return Array.isArray(domains) ? domains.filter((domain) => typeof domain === 'string') : [];
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ };
56
+ const keyOf = (crossing) => `${crossing.class}\0${crossing.name}`;
57
+ const keptAnswers = async (storeDir) => {
58
+ const body = await readFile(registryCachePath(storeDir), 'utf8').catch(() => '');
59
+ const kept = new Map();
60
+ for (const row of parseRows(body)) {
61
+ if (row.kind === 'registry')
62
+ kept.set(keyOf(row), row);
63
+ }
64
+ return kept;
65
+ };
66
+ /** A kept answer is dated by its UTC day, so "a day" is that day and the next: at most 48 hours old. Every kept answer is trusted only for the folder that asked, since its config can name another registry and a note says "the registry your package manager uses". A not-found answer is also cut short, since a name can be published any minute. */
67
+ const fresh = (row, now, folder) => row.folder === folder && (row.state === 'not-found' ? row.ts !== undefined && now - Date.parse(row.ts) < NOT_FOUND_MS : now - Date.parse(row.day) < (HOOK_REGISTRY_DAYS + 1) * DAY_MS);
68
+ const within = (promise, ms) => new Promise((resolve) => {
69
+ const timer = setTimeout(() => {
70
+ resolve(undefined);
71
+ }, Math.max(ms, 0));
72
+ promise.then((value) => {
73
+ clearTimeout(timer);
74
+ resolve(value);
75
+ }, () => {
76
+ clearTimeout(timer);
77
+ resolve(undefined);
78
+ });
79
+ });
80
+ /** What is left for a registry ask on the process clock, less the room a row's write is given. */
81
+ const budgetOf = (deps) => deps.deadlineMs - WRITE_RESERVE_MS - deps.clock();
82
+ const asAnswer = (row) => ({ state: row.state, latest: row.latest, created: row.created });
83
+ /** The registry state at the moment: a kept answer first, else one bounded ask. `undefined` is "not read": not asked, not askable, or not answered in time. */
84
+ const registryAnswer = async (deps, crossing, cwd, kept, helpers, hold) => {
85
+ if (!askedOf(crossing) || crossing.versionForm === 'url' || !askable(crossing.class, crossing.name))
86
+ return undefined;
87
+ const held = kept.get(keyOf(crossing));
88
+ if (held !== undefined && cwd !== null && fresh(held, deps.now(), folderKey(cwd)))
89
+ return { answer: asAnswer(held), asked: false };
90
+ if (deps.registry === undefined || cwd === null || !(await isFolder(cwd)))
91
+ return undefined;
92
+ const ask = { class: crossing.class, name: crossing.name, cwd };
93
+ // A helper already asks for this name (or failed within the last five minutes): one ask per name, so this sight reads nothing.
94
+ if ((await deps.locked?.(ask).catch(() => false)) === true)
95
+ return undefined;
96
+ const stop = new AbortController();
97
+ let answer;
98
+ if (budgetOf(deps) >= MIN_ASK_MS) {
99
+ // The first sight on a fresh store has no folder yet, and the deadline's synchronous write needs one: made here, before the ask, so a cut-off hook can still leave its row.
100
+ await mkdir(deps.storeDir, { recursive: true }).catch(() => undefined);
101
+ hold(() => { stop.abort(); });
102
+ const pending = deps.registry({ ...ask, signal: stop.signal });
103
+ // Starting the manager is a blocking spawn that can take a good part of the budget, so the cut counts what is left now, not what was left before the call: read before it, the cut
104
+ // could land behind the process's own deadline, which ends the process with the ask still running.
105
+ answer = await within(pending, budgetOf(deps));
106
+ }
107
+ if (answer === undefined) {
108
+ // Not answered in time: the row reads not-read, and a helper finishes the ask so the next sight reads it from the cache. This ask is stopped first, so the manager is never asked twice at once.
109
+ stop.abort();
110
+ if (helpers.started < MAX_HELPERS) {
111
+ helpers.started += 1;
112
+ await deps.fill?.(ask).catch(() => undefined);
113
+ }
114
+ return undefined;
115
+ }
116
+ return { answer, asked: true };
117
+ };
118
+ const recordOf = (input, crossing, novelty, read, policy, decision) => ({
119
+ kind: 'crossing',
120
+ v: GATE_VERSION,
121
+ via: 'hook',
122
+ day: input.ts.slice(0, 10),
123
+ class: crossing.class,
124
+ form: crossing.form,
125
+ program: crossing.program,
126
+ versionForm: crossing.versionForm,
127
+ statusReports: crossing.statusReports,
128
+ // What a transcript gives is not read here and not guessed: replay fills these in.
129
+ origin: 'not-read',
130
+ novelty,
131
+ decidedBy: 'not-read',
132
+ permission: input.permission,
133
+ status: 'not-read',
134
+ next: 'not-read',
135
+ removedLater: 'not-read',
136
+ depth: input.agent === null ? 'main' : 'subagent',
137
+ miss: 'not-read',
138
+ againstLatest: read === undefined ? 'not-read' : againstLatestOf(crossing, read.answer),
139
+ registryState: read?.answer.state ?? 'not-read',
140
+ registryAge: read === undefined ? 'not-read' : ageOf(read.answer, input.ts),
141
+ arm: decision.arm,
142
+ lineShown: decision.arm === 'held' ? 'none' : decision.lineId,
143
+ lineHeld: decision.arm === 'held' ? decision.lineId : 'none',
144
+ policy,
145
+ session: input.session,
146
+ agent: input.agent,
147
+ toolUseId: input.toolUseId,
148
+ ts: input.ts,
149
+ name: crossing.name,
150
+ version: crossing.version,
151
+ });
152
+ const appendText = async (file, body) => {
153
+ await mkdir(join(file, '..'), { recursive: true });
154
+ await appendFile(file, body, { mode: 0o600 });
155
+ };
156
+ /** A row for a note that never reached the agent: no exposure, no arm. Used when a deny prints instead of the note. */
157
+ const withoutExposure = (row) => row.arm === 'none' ? row : { ...row, arm: 'none', lineShown: 'none', lineHeld: 'none' };
158
+ /** A block is a deny whose reason is the label; else the notes are context for the agent. Never an allow, an ask, or a changed input: with neither, nothing is printed. */
159
+ const outputOf = (blocks, notes) => {
160
+ if (blocks.length > 0)
161
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: blocks.join('\n') } });
162
+ return notes.length > 0 ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: notes.join('\n') } }) : '';
163
+ };
164
+ /**
165
+ * Records the crossings of one tool call. Resolves with the JSON to print: a note as context, or a deny labeled with the fact; never an allow or a changed call.
166
+ * Nothing is written, and no network is touched, for a call that crosses nothing.
167
+ */
168
+ export const runGateHook = async (stdin, deps) => {
169
+ const input = parse(stdin);
170
+ const tool = text(input.tool_name);
171
+ const session = text(input.session_id);
172
+ if (tool === null || session === null)
173
+ return '';
174
+ const call = { tool, input: input.tool_input };
175
+ let check = checkCall(call, makeRedactor());
176
+ if (check.crossings.length === 0)
177
+ return '';
178
+ const domains = await redactDomains(deps.storeDir);
179
+ if (domains.length > 0)
180
+ check = checkCall(call, makeRedactor(domains));
181
+ const append = deps.append ?? appendText;
182
+ const cwd = text(input.cwd);
183
+ const ts = new Date(deps.now()).toISOString();
184
+ const common = { session, toolUseId: text(input.tool_use_id), agent: text(input.agent_id), permission: permissionOf(text(input.permission_mode) ?? 'unknown'), ts };
185
+ const index = await loadIndex(deps.storeDir, crossingsPath(deps.storeDir));
186
+ const repository = cwd === null ? '' : await repositoryOfCwd(cwd);
187
+ const kept = await keptAnswers(deps.storeDir);
188
+ const { policy, source } = await loadPolicy(deps.storeDir);
189
+ const random = deps.random ?? Math.random;
190
+ const blocks = [];
191
+ const notes = [];
192
+ const rows = [];
193
+ const lines = new Set();
194
+ const answered = [];
195
+ const binMemo = new Map();
196
+ const inCall = new Set();
197
+ const helpers = { started: 0 };
198
+ for (const seen of check.crossings) {
199
+ // A project's own bin is a stat walk, local and fast; it runs before the registry ask so a bin that is there is never asked about.
200
+ const crossing = await withLocalBin(seen, cwd, binMemo);
201
+ const scopes = scopesOf(keyOf(crossing), repository, session);
202
+ const { novelty, add, noted: indexed } = noveltyOf(index, scopes);
203
+ // One name twice in a call is one sight: the repeat reads as noted, so its note does not print again and its row is not a second draw.
204
+ const key = keyOf(crossing);
205
+ const noted = indexed || inCall.has(key);
206
+ inCall.add(key);
207
+ for (const line of add)
208
+ lines.add(line);
209
+ // What a process cut off during this ask writes: the rows so far and a not-read row for this crossing (the cut-off hook prints nothing, so none shows a note).
210
+ const hold = (stop) => {
211
+ const unread = recordOf(common, crossing, novelty, undefined, source, { arm: 'none', lineId: 'none', kind: 'none', text: null });
212
+ deps.stage?.({ file: crossingsPath(deps.storeDir), text: [...rows, unread].map((row) => `${JSON.stringify(row)}\n`).join(''), stop });
213
+ };
214
+ const read = await registryAnswer(deps, crossing, cwd, kept, helpers, hold);
215
+ deps.stage?.(null);
216
+ const state = read?.answer.state;
217
+ const answeredState = state === 'found' || state === 'not-found' ? state : undefined;
218
+ // A sight with no answer (a cut ask, no time, a name not asked, an unreachable registry) spends nothing: the session's note waits for the first sight that has one.
219
+ if (answeredState !== undefined && !noted)
220
+ lines.add(scopes.read);
221
+ if (read?.asked === true && read.answer.state !== 'unreachable') {
222
+ answered.push({ kind: 'registry', v: GATE_VERSION, day: ts.slice(0, 10), ts, folder: folderKey(cwd ?? ''), class: crossing.class, state: read.answer.state, created: read.answer.created, name: crossing.name, latest: read.answer.latest });
223
+ }
224
+ const decision = decide(policy, answeredState === undefined ? undefined : { state: answeredState, created: read?.answer.created ?? null, noted }, deps.now(), random, crossing.name);
225
+ if (decision.kind === 'block' && decision.text !== null && !blocks.includes(decision.text))
226
+ blocks.push(decision.text);
227
+ if (decision.kind === 'note' && decision.text !== null)
228
+ notes.push(decision.text);
229
+ rows.push(recordOf(common, crossing, novelty, read, source, decision));
230
+ }
231
+ // A call that holds a block prints only the deny, so a note row (shown or held) was never in front of the agent: it reads as no exposure, since a row cannot be backfilled.
232
+ const written = blocks.length > 0 ? rows.map(withoutExposure) : rows;
233
+ // The record first: an index line for a row that was never written would call the next crossing of the name old.
234
+ const record = { file: crossingsPath(deps.storeDir), text: written.map((row) => `${JSON.stringify(row)}\n`).join('') };
235
+ deps.stage?.(record);
236
+ try {
237
+ await append(record.file, record.text);
238
+ }
239
+ finally {
240
+ deps.stage?.(null);
241
+ }
242
+ if (blocks.length === 0 && notes.length === 0 && written.some((row) => row.arm === 'held'))
243
+ deps.ended?.('held');
244
+ await keepIndex(deps.storeDir, index, [...lines]).catch(() => undefined);
245
+ if (answered.length > 0)
246
+ await append(registryCachePath(deps.storeDir), answered.map((row) => `${JSON.stringify(row)}\n`).join('')).catch(() => undefined);
247
+ return outputOf(blocks, notes);
248
+ };
249
+ /** What the hook writes to stdout: the JSON of a note or a deny, or '' when silent and on any failure, so the call runs as asked. */
250
+ export const gateHook = async (stdin, deps) => {
251
+ try {
252
+ return await runGateHook(stdin, deps);
253
+ }
254
+ catch {
255
+ // Fail-open: a recorder that cannot record must not cost a session its tool call.
256
+ deps.ended?.('error');
257
+ }
258
+ return '';
259
+ };
@@ -0,0 +1,25 @@
1
+ /** The tools whose calls can cross: shell commands (both shells), skills, and every MCP tool. */
2
+ export declare const MATCHER = "Bash|PowerShell|Skill|mcp__.*";
3
+ export declare const EVENT = "PreToolUse";
4
+ /** Seconds the harness waits for the hook; the hook ends itself in under one, so this is only the ceiling for a cold node start. */
5
+ export declare const TIMEOUT_SECONDS = 5;
6
+ /**
7
+ * Run by the harness's shell (Git Bash on Windows). `command -v` is a shell builtin, so a missing `oars` costs a few milliseconds and no node start. The harness reads this command's stdout, so what
8
+ * `oars gate hook` prints (a note or a deny) is passed through, and only when it exits 0: an `oars` with no `gate hook` prints and exits 2, which would block the tool call, so that output is dropped.
9
+ * The trailing `exit 0` matters for the same reason.
10
+ */
11
+ export declare const COMMAND = "command -v oars >/dev/null 2>&1 || exit 0; out=$(oars gate hook 2>/dev/null) && printf '%s' \"$out\"; exit 0";
12
+ /** The command an earlier build wrote, which discarded the hook's output. Still ours, so a rerun replaces it and an uninstall removes it. */
13
+ export declare const PREVIOUS_COMMAND = "command -v oars >/dev/null 2>&1 || exit 0; oars gate hook >/dev/null 2>&1; exit 0";
14
+ export interface GateInstallResult {
15
+ file: string;
16
+ /** False when the file already held exactly this entry: nothing was written. */
17
+ changed: boolean;
18
+ }
19
+ /** Unparsable settings are refused by `readSettings` before anything is written. */
20
+ export declare const installGateHook: (path: string) => Promise<GateInstallResult>;
21
+ export interface GateUninstallResult {
22
+ file: string;
23
+ removed: boolean;
24
+ }
25
+ export declare const uninstallGateHook: (path: string) => Promise<GateUninstallResult>;
@@ -0,0 +1,126 @@
1
+ // `oars gate install` and `oars gate uninstall`: one PreToolUse entry in Claude Code's user settings that runs `oars gate hook`. The entry is ours when its command is
2
+ // exactly `COMMAND` or `PREVIOUS_COMMAND`, so uninstall removes that entry and nothing else, and every other key and hook in the file is carried through as parsed JSON. The command calls the
3
+ // installed `oars`, never `npx` (which would fetch a package on the hook's clock), and does nothing when `oars` is not on the PATH, so a settings file that outlives the
4
+ // install cannot cost a session its tool calls.
5
+ import { chmod, lstat, mkdir, readlink, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { CliError, ExitCode } from '../errors.js';
8
+ import { readSettings } from '../sessions/install.js';
9
+ /** The tools whose calls can cross: shell commands (both shells), skills, and every MCP tool. */
10
+ export const MATCHER = 'Bash|PowerShell|Skill|mcp__.*';
11
+ export const EVENT = 'PreToolUse';
12
+ /** Seconds the harness waits for the hook; the hook ends itself in under one, so this is only the ceiling for a cold node start. */
13
+ export const TIMEOUT_SECONDS = 5;
14
+ /**
15
+ * Run by the harness's shell (Git Bash on Windows). `command -v` is a shell builtin, so a missing `oars` costs a few milliseconds and no node start. The harness reads this command's stdout, so what
16
+ * `oars gate hook` prints (a note or a deny) is passed through, and only when it exits 0: an `oars` with no `gate hook` prints and exits 2, which would block the tool call, so that output is dropped.
17
+ * The trailing `exit 0` matters for the same reason.
18
+ */
19
+ export const COMMAND = `command -v oars >/dev/null 2>&1 || exit 0; out=$(oars gate hook 2>/dev/null) && printf '%s' "$out"; exit 0`;
20
+ /** The command an earlier build wrote, which discarded the hook's output. Still ours, so a rerun replaces it and an uninstall removes it. */
21
+ export const PREVIOUS_COMMAND = 'command -v oars >/dev/null 2>&1 || exit 0; oars gate hook >/dev/null 2>&1; exit 0';
22
+ const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
23
+ const isOwnHook = (hook) => isObject(hook) && (hook.command === COMMAND || hook.command === PREVIOUS_COMMAND);
24
+ const holdsOwn = (group) => isObject(group) && Array.isArray(group.hooks) && group.hooks.some(isOwnHook);
25
+ /** The group we write: our matcher, our one entry. */
26
+ const ownGroup = () => ({ matcher: MATCHER, hooks: [{ type: 'command', command: COMMAND, timeout: TIMEOUT_SECONDS }] });
27
+ /** A group of the operator's, with our entry taken out of it; `[]` when ours was all it held. */
28
+ const remainder = (group) => {
29
+ const rest = group.hooks.filter((hook) => !isOwnHook(hook));
30
+ return rest.length === 0 ? [] : [{ ...group, hooks: rest }];
31
+ };
32
+ /** Our group put in the place our entry already holds, or at the end. Placing it where it is keeps a rerun from moving it past a group someone added after it. */
33
+ const withOwn = (groups) => {
34
+ const first = groups.findIndex(holdsOwn);
35
+ if (first === -1)
36
+ return [...groups, ownGroup()];
37
+ return groups.flatMap((group, at) => {
38
+ if (!holdsOwn(group))
39
+ return [group];
40
+ const kept = remainder(group);
41
+ return at === first ? [...kept, ownGroup()] : kept;
42
+ });
43
+ };
44
+ const withoutOwn = (groups) => groups.flatMap((group) => (holdsOwn(group) ? remainder(group) : [group]));
45
+ /** Where a symlink that points at nothing yet would land, so the rename writes the file it names and leaves the link a link. `undefined` when `path` is not a symlink. */
46
+ const danglingTarget = async (path) => {
47
+ try {
48
+ return (await lstat(path)).isSymbolicLink() ? resolve(dirname(path), await readlink(path)) : undefined;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ };
54
+ /** The file as it stands, followed through a symlink (a dotfile manager's), with its permission bits. `mode` is `undefined` when there is no file yet; `target` is then where it will land. */
55
+ const existing = async (path) => {
56
+ try {
57
+ const target = await realpath(path);
58
+ return { target, mode: (await stat(target)).mode & 0o777 };
59
+ }
60
+ catch {
61
+ const target = await danglingTarget(path);
62
+ return target === undefined ? undefined : { target, mode: undefined };
63
+ }
64
+ };
65
+ /** Whole and atomic: a half-written settings file would take every other hook in it down too. The temp file sits beside the real target with the file's own mode (0600 when new: `env` can hold secrets), and the rename lands on the resolved path, so a symlinked settings file stays a symlink. The mode is set after the write because the umask masks the one `writeFile` is given; a failed rename leaves no temp file behind. */
66
+ const writeSettings = async (path, settings) => {
67
+ const found = await existing(path);
68
+ const target = found?.target ?? path;
69
+ await mkdir(dirname(target), { recursive: true });
70
+ const temp = `${target}.${String(process.pid)}.oars-gate.tmp`;
71
+ const mode = found?.mode ?? 0o600;
72
+ try {
73
+ await writeFile(temp, `${JSON.stringify(settings, null, 2)}
74
+ `, { mode });
75
+ await chmod(temp, mode);
76
+ await rename(temp, target);
77
+ }
78
+ catch (error) {
79
+ await rm(temp, { force: true });
80
+ throw error;
81
+ }
82
+ };
83
+ // A `hooks` key, or a PreToolUse key under it, of another shape is the operator's and this version cannot merge into it: refuse it, and write nothing.
84
+ const hooksOf = (settings, path) => {
85
+ if (settings.hooks === undefined)
86
+ return {};
87
+ if (!isObject(settings.hooks))
88
+ throw new CliError(`"hooks" in ${path} is not an object; nothing was changed.`, ExitCode.USAGE, 'usage');
89
+ return settings.hooks;
90
+ };
91
+ const groupsOf = (hooks, path) => {
92
+ const groups = hooks[EVENT];
93
+ if (groups === undefined)
94
+ return [];
95
+ if (!Array.isArray(groups))
96
+ throw new CliError(`"hooks.${EVENT}" in ${path} is not a list; nothing was changed.`, ExitCode.USAGE, 'usage');
97
+ return groups;
98
+ };
99
+ /** Unparsable settings are refused by `readSettings` before anything is written. */
100
+ export const installGateHook = async (path) => {
101
+ const settings = await readSettings(path);
102
+ const before = JSON.stringify(settings);
103
+ const hooks = hooksOf(settings, path);
104
+ hooks[EVENT] = withOwn(groupsOf(hooks, path));
105
+ settings.hooks = hooks;
106
+ if (JSON.stringify(settings) === before)
107
+ return { file: path, changed: false };
108
+ await writeSettings(path, settings);
109
+ return { file: path, changed: true };
110
+ };
111
+ export const uninstallGateHook = async (path) => {
112
+ const settings = await readSettings(path);
113
+ const hooks = hooksOf(settings, path);
114
+ const groups = groupsOf(hooks, path);
115
+ if (!groups.some(holdsOwn))
116
+ return { file: path, removed: false };
117
+ const kept = withoutOwn(groups);
118
+ // An event this call emptied goes, and so does a `hooks` key it emptied; anything else the operator wrote stays where it was.
119
+ if (kept.length === 0)
120
+ Reflect.deleteProperty(hooks, EVENT);
121
+ else
122
+ hooks[EVENT] = kept;
123
+ settings.hooks = Object.keys(hooks).length === 0 ? undefined : hooks;
124
+ await writeSettings(path, settings);
125
+ return { file: path, removed: true };
126
+ };
@@ -0,0 +1,8 @@
1
+ import type { Crossing } from './checks.js';
2
+ /** Answers already found, so one folder and bin cost one walk however many calls ask. */
3
+ export type LocalBinMemo = Map<string, Promise<boolean>>;
4
+ /**
5
+ * The crossing as it should be recorded: `local-bin-run` for an unstated `npx` whose folder, or a parent's, holds the bin, else the crossing itself. A version, a call that chose
6
+ * its own folder or registry (`registryAsked` false), or an unknown folder leaves the question open, so the crossing stays a fetch-and-run.
7
+ */
8
+ export declare const withLocalBin: (crossing: Crossing, cwd: string | null, memo: LocalBinMemo) => Promise<Crossing>;
@@ -0,0 +1,36 @@
1
+ // `npx <name>` where the folder already has `node_modules/.bin/<name>` runs the project's own copy and fetches nothing. It is the common case (most main-session `npx` crossings
2
+ // whose folder exists), so it gets its own closed form, `local-bin-run`, and is not counted as a fetch-and-run pick. Live and replay both apply this one rule.
3
+ import { stat } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ const LEVELS = 12;
6
+ /** A Windows install leaves `.cmd` and `.ps1` shims beside the bare name. */
7
+ const SHIMS = ['', '.cmd', '.ps1'];
8
+ const exists = (path) => stat(path).then(() => true, () => false);
9
+ /** The bin an `npx` name runs: the last segment of a scoped name. */
10
+ const binOf = (name) => name.slice(name.lastIndexOf('/') + 1);
11
+ const walk = async (cwd, bin) => {
12
+ let folder = cwd;
13
+ for (let level = 0; level < LEVELS; level += 1) {
14
+ for (const shim of SHIMS) {
15
+ if (await exists(join(folder, 'node_modules', '.bin', `${bin}${shim}`)))
16
+ return true;
17
+ }
18
+ const parent = dirname(folder);
19
+ if (parent === folder)
20
+ break;
21
+ folder = parent;
22
+ }
23
+ return false;
24
+ };
25
+ /**
26
+ * The crossing as it should be recorded: `local-bin-run` for an unstated `npx` whose folder, or a parent's, holds the bin, else the crossing itself. A version, a call that chose
27
+ * its own folder or registry (`registryAsked` false), or an unknown folder leaves the question open, so the crossing stays a fetch-and-run.
28
+ */
29
+ export const withLocalBin = async (crossing, cwd, memo) => {
30
+ if (crossing.program !== 'npx' || crossing.form !== 'fetch-and-run' || crossing.versionForm !== 'unstated' || !crossing.registryAsked || cwd === null)
31
+ return crossing;
32
+ const key = `${cwd}\0${binOf(crossing.name)}`;
33
+ const known = memo.get(key) ?? walk(cwd, binOf(crossing.name));
34
+ memo.set(key, known);
35
+ return (await known) ? { ...crossing, form: 'local-bin-run' } : crossing;
36
+ };
@@ -0,0 +1,19 @@
1
+ import type { Policy } from './policy.js';
2
+ import type { Arm, LineId } from './record.js';
3
+ /** The facts a decision reads: what the registry answered, when the name was first published, and whether this session has already had a sight of the name with a reading (`noted`). */
4
+ export interface LineFacts {
5
+ state: 'found' | 'not-found';
6
+ created: string | null;
7
+ noted: boolean;
8
+ }
9
+ export interface Decision {
10
+ arm: Arm;
11
+ lineId: LineId;
12
+ kind: 'block' | 'note' | 'none';
13
+ text: string | null;
14
+ }
15
+ /** The only words the hook ever shows. Each states what the registry said and nothing about what to do. */
16
+ export declare const LINE_TABLE: Readonly<Record<Exclude<LineId, 'none'>, string>>;
17
+ export declare const lineText: (id: LineId, name: string, days: number) => string;
18
+ /** Silent unless a class fires. A block is for a name the registry answered as not found, only when the policy says `block`, and is never held back. */
19
+ export declare const decide: (policy: Policy, facts: LineFacts | undefined, now: number, random: () => number, name: string) => Decision;
@@ -0,0 +1,27 @@
1
+ import { DAY_MS } from './registry-state.js';
2
+ /** The only words the hook ever shows. Each states what the registry said and nothing about what to do. */
3
+ export const LINE_TABLE = {
4
+ 'young-name-note': 'Registry note: {name} was first published {age} ago in the registry your package manager uses.',
5
+ 'not-found-note': 'Registry note: {name} was not found in the registry your package manager uses.',
6
+ 'not-found-block': 'Blocked by your gate policy: {name} was not found in the registry your package manager uses.',
7
+ };
8
+ const ageText = (days) => (days < 1 ? 'less than a day' : days === 1 ? '1 day' : `${String(days)} days`);
9
+ export const lineText = (id, name, days) => (id === 'none' ? '' : LINE_TABLE[id].replace('{name}', name).replace('{age}', ageText(days)));
10
+ const NONE = { arm: 'none', lineId: 'none', kind: 'none', text: null };
11
+ /** A note that would show, held back when the draw falls inside the share. The draw is spent on a note only. */
12
+ const note = (policy, lineId, name, days, random) => random() < policy.holdbackPercent / 100 ? { arm: 'held', lineId, kind: 'none', text: null } : { arm: 'shown', lineId, kind: 'note', text: lineText(lineId, name, days) };
13
+ /** Silent unless a class fires. A block is for a name the registry answered as not found, only when the policy says `block`, and is never held back. */
14
+ export const decide = (policy, facts, now, random, name) => {
15
+ if (facts === undefined)
16
+ return NONE;
17
+ if (facts.state === 'not-found') {
18
+ const action = policy.classes['not-found'];
19
+ if (action === 'block')
20
+ return { arm: 'none', lineId: 'not-found-block', kind: 'block', text: lineText('not-found-block', name, 0) };
21
+ return action === 'warn' && !facts.noted ? note(policy, 'not-found-note', name, 0, random) : NONE;
22
+ }
23
+ if (policy.classes['young-name'] !== 'warn' || facts.created === null || facts.noted)
24
+ return NONE;
25
+ const days = (now - Date.parse(facts.created)) / DAY_MS;
26
+ return days >= 0 && days < policy.youngDays ? note(policy, 'young-name-note', name, Math.floor(days), random) : NONE;
27
+ };
@@ -0,0 +1,35 @@
1
+ import type { Novelty } from './record.js';
2
+ export declare const INDEX_HEADER = "oars-gate-index 1";
3
+ export declare const indexFile: (storeDir: string) => string;
4
+ /** The entries of one crossing, as the lines the index keeps. `read` is the session's sight of the name that had a registry reading, which is what spends the session's one note. */
5
+ export interface Scopes {
6
+ fleet: string;
7
+ repository: string;
8
+ session: string;
9
+ read: string;
10
+ }
11
+ /** `key` is the class and name; the repository and session ids are as the caller knows them. */
12
+ export declare const scopesOf: (key: string, repository: string, session: string) => Scopes;
13
+ /**
14
+ * `ready`: read cleanly. `fresh`: no index and no record, so an empty one is right. `unseeded`: no index but a record exists, so what is new cannot be told without a
15
+ * scan; `oars gate replay` seeds it. `damaged`: it did not read cleanly, and nothing is written to it.
16
+ */
17
+ export type KeptIndex = {
18
+ state: 'ready';
19
+ lines: ReadonlySet<string>;
20
+ } | {
21
+ state: 'fresh' | 'unseeded' | 'damaged';
22
+ };
23
+ export declare const loadIndex: (storeDir: string, record: string) => Promise<KeptIndex>;
24
+ export declare const noveltyOf: (index: KeptIndex, scopes: Scopes) => {
25
+ novelty: Novelty;
26
+ add: string[];
27
+ noted: boolean;
28
+ };
29
+ /**
30
+ * Appends the lines a crossing added. A `fresh` index starts with its header (a second header from a hook racing this one is skipped on read). A torn tail (a line cut off
31
+ * before its line break) gets one first, so the append is its own lines and the torn one costs only itself.
32
+ */
33
+ export declare const keepIndex: (storeDir: string, index: KeptIndex, add: readonly string[]) => Promise<void>;
34
+ /** Writes the index from every line given plus the ones already kept, whole and atomic. Used by `replay`, which is not in a hurry. */
35
+ export declare const seedIndex: (storeDir: string, record: string, lines: Iterable<string>) => Promise<void>;
@@ -0,0 +1,99 @@
1
+ // The kept index behind the hook's `novelty`: which names have been seen in the whole record, in a repository, and in a session. The hook reads it instead of scanning
2
+ // `crossings.jsonl`, which a long-lived record would make slow. It holds hashes only (a name, a repository, or a session id is never written here), one per line, and a
3
+ // line is only ever appended, so two hooks running at once cannot lose each other's update. A file that does not read cleanly is `damaged`: the row says `not-read`.
4
+ import { createHash } from 'node:crypto';
5
+ import { appendFile, mkdir, open, readFile, rename, stat, writeFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ export const INDEX_HEADER = 'oars-gate-index 1';
8
+ export const indexFile = (storeDir) => join(storeDir, 'gate-index.txt');
9
+ const LINE = /^[frs][0-9a-f]{12}$/;
10
+ const TORN = /^(?:[frs][0-9a-f]{0,11})?$/;
11
+ const digest = (...parts) => createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 12);
12
+ /** `key` is the class and name; the repository and session ids are as the caller knows them. */
13
+ export const scopesOf = (key, repository, session) => ({
14
+ fleet: `f${digest(key)}`,
15
+ repository: `r${digest(repository, key)}`,
16
+ session: `s${digest(session, key)}`,
17
+ read: `s${digest(session, key, 'read')}`,
18
+ });
19
+ const notFound = (error) => error instanceof Error && Reflect.get(error, 'code') === 'ENOENT';
20
+ const hasRecord = (file) => stat(file).then((found) => found.size > 0, () => false);
21
+ const parseIndex = (text) => {
22
+ const parts = text.split('\n');
23
+ // The last part is empty after a complete line; anything else there is a line that was being appended, and says nothing yet.
24
+ parts.pop();
25
+ if (parts[0] !== INDEX_HEADER)
26
+ return { state: 'damaged' };
27
+ const lines = new Set();
28
+ for (const [at, line] of parts.entries()) {
29
+ if (line === INDEX_HEADER)
30
+ continue;
31
+ // A torn line that the next append closed with a line break: a cut-off hash, or an empty line when two appends raced on the same torn tail. Skipped, and only ever before a later line.
32
+ if (at < parts.length - 1 && TORN.test(line))
33
+ continue;
34
+ if (!LINE.test(line))
35
+ return { state: 'damaged' };
36
+ lines.add(line);
37
+ }
38
+ return { state: 'ready', lines };
39
+ };
40
+ export const loadIndex = async (storeDir, record) => {
41
+ try {
42
+ return parseIndex(await readFile(indexFile(storeDir), 'utf8'));
43
+ }
44
+ catch (error) {
45
+ if (!notFound(error))
46
+ return { state: 'damaged' };
47
+ return (await hasRecord(record)) ? { state: 'unseeded' } : { state: 'fresh' };
48
+ }
49
+ };
50
+ export const noveltyOf = (index, scopes) => {
51
+ // With no readable index there is no telling whether the name was noted, and no line can be kept to say so: it counts as noted, so no note shows until a replay seeds the index. A block does not read `noted`.
52
+ if (index.state === 'damaged' || index.state === 'unseeded')
53
+ return { novelty: 'not-read', add: [], noted: true };
54
+ const held = index.state === 'ready' ? index.lines : new Set();
55
+ const add = [scopes.fleet, scopes.repository, scopes.session].filter((line) => !held.has(line));
56
+ const noted = held.has(scopes.read);
57
+ if (!held.has(scopes.fleet))
58
+ return { novelty: 'fleet', add, noted };
59
+ if (!held.has(scopes.repository))
60
+ return { novelty: 'repository', add, noted };
61
+ return { novelty: held.has(scopes.session) ? 'none' : 'session', add, noted };
62
+ };
63
+ /** True when the file's last byte is a line break, so an append starts a line of its own. The caller has read the header, so the file is not empty. */
64
+ const endsClean = async (file) => {
65
+ const handle = await open(file, 'r');
66
+ try {
67
+ const { size } = await handle.stat();
68
+ const last = Buffer.alloc(1);
69
+ await handle.read(last, 0, 1, size - 1);
70
+ return last[0] === 0x0a;
71
+ }
72
+ finally {
73
+ await handle.close();
74
+ }
75
+ };
76
+ /**
77
+ * Appends the lines a crossing added. A `fresh` index starts with its header (a second header from a hook racing this one is skipped on read). A torn tail (a line cut off
78
+ * before its line break) gets one first, so the append is its own lines and the torn one costs only itself.
79
+ */
80
+ export const keepIndex = async (storeDir, index, add) => {
81
+ if (add.length === 0 || (index.state !== 'ready' && index.state !== 'fresh'))
82
+ return;
83
+ await mkdir(storeDir, { recursive: true });
84
+ const file = indexFile(storeDir);
85
+ const lead = index.state === 'fresh' ? `${INDEX_HEADER}\n` : (await endsClean(file)) ? '' : '\n';
86
+ await appendFile(file, `${lead}${add.join('\n')}\n`, { mode: 0o600 });
87
+ };
88
+ /** Writes the index from every line given plus the ones already kept, whole and atomic. Used by `replay`, which is not in a hurry. */
89
+ export const seedIndex = async (storeDir, record, lines) => {
90
+ const kept = await loadIndex(storeDir, record);
91
+ const all = new Set(kept.state === 'ready' ? kept.lines : []);
92
+ for (const line of lines)
93
+ all.add(line);
94
+ await mkdir(storeDir, { recursive: true });
95
+ const file = indexFile(storeDir);
96
+ const temp = `${file}.${String(process.pid)}.tmp`;
97
+ await writeFile(temp, `${[INDEX_HEADER, ...all].join('\n')}\n`, { mode: 0o600 });
98
+ await rename(temp, file);
99
+ };