@manyoars/oars 0.2.0 → 0.3.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 (241) hide show
  1. package/README.md +132 -28
  2. package/dist/api.js +1 -1
  3. package/dist/bin.js +6 -29
  4. package/dist/cli.d.ts +6 -3
  5. package/dist/cli.js +367 -88
  6. package/dist/commands/keys.d.ts +31 -0
  7. package/dist/commands/keys.js +65 -0
  8. package/dist/commands/login.js +2 -2
  9. package/dist/errors.d.ts +13 -13
  10. package/dist/errors.js +28 -11
  11. package/dist/gate/checks.d.ts +68 -0
  12. package/dist/gate/checks.js +584 -0
  13. package/dist/gate/command.d.ts +14 -0
  14. package/dist/gate/command.js +61 -0
  15. package/dist/gate/hook-fill-main.d.ts +1 -0
  16. package/dist/gate/hook-fill-main.js +16 -0
  17. package/dist/gate/hook-fill.d.ts +38 -0
  18. package/dist/gate/hook-fill.js +94 -0
  19. package/dist/gate/hook-main.d.ts +1 -0
  20. package/dist/gate/hook-main.js +35 -0
  21. package/dist/gate/hook-process.d.ts +14 -0
  22. package/dist/gate/hook-process.js +64 -0
  23. package/dist/gate/hook.d.ts +46 -0
  24. package/dist/gate/hook.js +256 -0
  25. package/dist/gate/install.d.ts +25 -0
  26. package/dist/gate/install.js +126 -0
  27. package/dist/gate/local-bin.d.ts +8 -0
  28. package/dist/gate/local-bin.js +36 -0
  29. package/dist/gate/notes.d.ts +19 -0
  30. package/dist/gate/notes.js +27 -0
  31. package/dist/gate/novelty.d.ts +35 -0
  32. package/dist/gate/novelty.js +99 -0
  33. package/dist/gate/policy.d.ts +25 -0
  34. package/dist/gate/policy.js +73 -0
  35. package/dist/gate/record.d.ts +120 -0
  36. package/dist/gate/record.js +125 -0
  37. package/dist/gate/registry-state.d.ts +10 -0
  38. package/dist/gate/registry-state.js +30 -0
  39. package/dist/gate/registry.d.ts +56 -0
  40. package/dist/gate/registry.js +94 -0
  41. package/dist/gate/replay.d.ts +20 -0
  42. package/dist/gate/replay.js +257 -0
  43. package/dist/gate/report.d.ts +50 -0
  44. package/dist/gate/report.js +89 -0
  45. package/dist/gate/scope.d.ts +7 -0
  46. package/dist/gate/scope.js +27 -0
  47. package/dist/gate/transcripts.d.ts +68 -0
  48. package/dist/gate/transcripts.js +232 -0
  49. package/dist/hook-bin.d.ts +1 -0
  50. package/dist/hook-bin.js +25 -0
  51. package/dist/issuer.d.ts +7 -1
  52. package/dist/issuer.js +18 -1
  53. package/dist/main.d.ts +1 -0
  54. package/dist/main.js +64 -0
  55. package/dist/oidc.js +1 -1
  56. package/dist/sessions/actions.d.ts +35 -0
  57. package/dist/sessions/actions.js +351 -0
  58. package/dist/sessions/allow.d.ts +14 -0
  59. package/dist/sessions/allow.js +51 -0
  60. package/dist/sessions/archive.d.ts +11 -0
  61. package/dist/sessions/archive.js +38 -0
  62. package/dist/sessions/audit.d.ts +42 -0
  63. package/dist/sessions/audit.js +104 -0
  64. package/dist/sessions/baseline.d.ts +62 -0
  65. package/dist/sessions/baseline.js +188 -0
  66. package/dist/sessions/capture-runtime.d.ts +10 -0
  67. package/dist/sessions/capture-runtime.js +12 -0
  68. package/dist/sessions/cell-increments.d.ts +43 -0
  69. package/dist/sessions/cell-increments.js +209 -0
  70. package/dist/sessions/cells.d.ts +79 -0
  71. package/dist/sessions/cells.js +181 -0
  72. package/dist/sessions/codex.d.ts +53 -0
  73. package/dist/sessions/codex.js +679 -0
  74. package/dist/sessions/command.d.ts +117 -0
  75. package/dist/sessions/command.js +373 -0
  76. package/dist/sessions/copilot.d.ts +40 -0
  77. package/dist/sessions/copilot.js +432 -0
  78. package/dist/sessions/cursor.d.ts +24 -0
  79. package/dist/sessions/cursor.js +324 -0
  80. package/dist/sessions/derive.d.ts +57 -0
  81. package/dist/sessions/derive.js +320 -0
  82. package/dist/sessions/exclusion.d.ts +81 -0
  83. package/dist/sessions/exclusion.js +353 -0
  84. package/dist/sessions/extract.d.ts +179 -0
  85. package/dist/sessions/extract.js +1774 -0
  86. package/dist/sessions/fold.d.ts +17 -0
  87. package/dist/sessions/fold.js +71 -0
  88. package/dist/sessions/frame.d.ts +24 -0
  89. package/dist/sessions/frame.js +95 -0
  90. package/dist/sessions/gemini.d.ts +37 -0
  91. package/dist/sessions/gemini.js +613 -0
  92. package/dist/sessions/gold-arms.d.ts +67 -0
  93. package/dist/sessions/gold-arms.js +172 -0
  94. package/dist/sessions/gold.d.ts +197 -0
  95. package/dist/sessions/gold.js +463 -0
  96. package/dist/sessions/graph.d.ts +12 -0
  97. package/dist/sessions/graph.js +34 -0
  98. package/dist/sessions/grok.d.ts +36 -0
  99. package/dist/sessions/grok.js +421 -0
  100. package/dist/sessions/hook-calls.d.ts +3 -0
  101. package/dist/sessions/hook-calls.js +47 -0
  102. package/dist/sessions/hook-index.d.ts +53 -0
  103. package/dist/sessions/hook-index.js +157 -0
  104. package/dist/sessions/hooks.d.ts +13 -0
  105. package/dist/sessions/hooks.js +170 -0
  106. package/dist/sessions/identity.d.ts +22 -0
  107. package/dist/sessions/identity.js +101 -0
  108. package/dist/sessions/install.d.ts +54 -0
  109. package/dist/sessions/install.js +201 -0
  110. package/dist/sessions/lineage.d.ts +19 -0
  111. package/dist/sessions/lineage.js +118 -0
  112. package/dist/sessions/mcp.d.ts +6 -0
  113. package/dist/sessions/mcp.js +153 -0
  114. package/dist/sessions/merkle.d.ts +7 -0
  115. package/dist/sessions/merkle.js +28 -0
  116. package/dist/sessions/motif.d.ts +43 -0
  117. package/dist/sessions/motif.js +59 -0
  118. package/dist/sessions/package-resolve.d.ts +30 -0
  119. package/dist/sessions/package-resolve.js +183 -0
  120. package/dist/sessions/pending.d.ts +34 -0
  121. package/dist/sessions/pending.js +142 -0
  122. package/dist/sessions/pool-command.d.ts +44 -0
  123. package/dist/sessions/pool-command.js +542 -0
  124. package/dist/sessions/pool-delta.d.ts +54 -0
  125. package/dist/sessions/pool-delta.js +190 -0
  126. package/dist/sessions/pool-outbox.d.ts +63 -0
  127. package/dist/sessions/pool-outbox.js +260 -0
  128. package/dist/sessions/pool-print.d.ts +26 -0
  129. package/dist/sessions/pool-print.js +68 -0
  130. package/dist/sessions/pool-wire.d.ts +225 -0
  131. package/dist/sessions/pool-wire.js +620 -0
  132. package/dist/sessions/pool.d.ts +32 -0
  133. package/dist/sessions/pool.js +164 -0
  134. package/dist/sessions/projection.d.ts +75 -0
  135. package/dist/sessions/projection.js +165 -0
  136. package/dist/sessions/public-vocabulary.d.ts +73 -0
  137. package/dist/sessions/public-vocabulary.js +302 -0
  138. package/dist/sessions/query.d.ts +111 -0
  139. package/dist/sessions/query.js +246 -0
  140. package/dist/sessions/read-adoption.d.ts +2 -0
  141. package/dist/sessions/read-adoption.js +27 -0
  142. package/dist/sessions/read-call-shape.d.ts +2 -0
  143. package/dist/sessions/read-call-shape.js +25 -0
  144. package/dist/sessions/read-change.d.ts +2 -0
  145. package/dist/sessions/read-change.js +30 -0
  146. package/dist/sessions/read-cost.d.ts +4 -0
  147. package/dist/sessions/read-cost.js +37 -0
  148. package/dist/sessions/read-day-move.d.ts +3 -0
  149. package/dist/sessions/read-day-move.js +65 -0
  150. package/dist/sessions/read-fit.d.ts +7 -0
  151. package/dist/sessions/read-fit.js +41 -0
  152. package/dist/sessions/read-interaction.d.ts +5 -0
  153. package/dist/sessions/read-interaction.js +62 -0
  154. package/dist/sessions/read-lead-lag.d.ts +15 -0
  155. package/dist/sessions/read-lead-lag.js +81 -0
  156. package/dist/sessions/read-live.d.ts +6 -0
  157. package/dist/sessions/read-live.js +38 -0
  158. package/dist/sessions/read-method.d.ts +2 -0
  159. package/dist/sessions/read-method.js +29 -0
  160. package/dist/sessions/read-next-step.d.ts +2 -0
  161. package/dist/sessions/read-next-step.js +35 -0
  162. package/dist/sessions/read-order.d.ts +54 -0
  163. package/dist/sessions/read-order.js +62 -0
  164. package/dist/sessions/read-readable.d.ts +8 -0
  165. package/dist/sessions/read-readable.js +24 -0
  166. package/dist/sessions/read-recovery.d.ts +20 -0
  167. package/dist/sessions/read-recovery.js +97 -0
  168. package/dist/sessions/read-rows.d.ts +13 -0
  169. package/dist/sessions/read-rows.js +47 -0
  170. package/dist/sessions/read-substitution.d.ts +5 -0
  171. package/dist/sessions/read-substitution.js +51 -0
  172. package/dist/sessions/read-support.d.ts +48 -0
  173. package/dist/sessions/read-support.js +73 -0
  174. package/dist/sessions/read-tool-day.d.ts +2 -0
  175. package/dist/sessions/read-tool-day.js +26 -0
  176. package/dist/sessions/read-version-boundary.d.ts +31 -0
  177. package/dist/sessions/read-version-boundary.js +95 -0
  178. package/dist/sessions/read.d.ts +79 -0
  179. package/dist/sessions/read.js +84 -0
  180. package/dist/sessions/recall-consequence.d.ts +45 -0
  181. package/dist/sessions/recall-consequence.js +179 -0
  182. package/dist/sessions/recall-object.d.ts +137 -0
  183. package/dist/sessions/recall-object.js +77 -0
  184. package/dist/sessions/recall-pool.d.ts +19 -0
  185. package/dist/sessions/recall-pool.js +96 -0
  186. package/dist/sessions/recall-push.d.ts +26 -0
  187. package/dist/sessions/recall-push.js +95 -0
  188. package/dist/sessions/recall-reads.d.ts +117 -0
  189. package/dist/sessions/recall-reads.js +410 -0
  190. package/dist/sessions/recall-render.d.ts +19 -0
  191. package/dist/sessions/recall-render.js +168 -0
  192. package/dist/sessions/redact.d.ts +6 -0
  193. package/dist/sessions/redact.js +54 -0
  194. package/dist/sessions/repo-root.d.ts +10 -0
  195. package/dist/sessions/repo-root.js +101 -0
  196. package/dist/sessions/schema.d.ts +644 -0
  197. package/dist/sessions/schema.js +148 -0
  198. package/dist/sessions/session-shape.d.ts +73 -0
  199. package/dist/sessions/session-shape.js +342 -0
  200. package/dist/sessions/sources.d.ts +50 -0
  201. package/dist/sessions/sources.js +159 -0
  202. package/dist/sessions/steps.d.ts +105 -0
  203. package/dist/sessions/steps.js +250 -0
  204. package/dist/sessions/store.d.ts +297 -0
  205. package/dist/sessions/store.js +1478 -0
  206. package/dist/sessions/strata.d.ts +26 -0
  207. package/dist/sessions/strata.js +77 -0
  208. package/dist/sessions/survival.d.ts +25 -0
  209. package/dist/sessions/survival.js +230 -0
  210. package/dist/sessions/verify.d.ts +53 -0
  211. package/dist/sessions/verify.js +286 -0
  212. package/dist/sessions/vocab-load.d.ts +26 -0
  213. package/dist/sessions/vocab-load.js +61 -0
  214. package/dist/sessions/vscode-copilot.d.ts +38 -0
  215. package/dist/sessions/vscode-copilot.js +477 -0
  216. package/package.json +4 -5
  217. package/vocab/crates.json +1 -0
  218. package/vocab/go.json +1 -0
  219. package/vocab/harness.json +1 -0
  220. package/vocab/homebrew.json +1 -0
  221. package/vocab/manifest.json +1 -0
  222. package/vocab/maven.json +1 -0
  223. package/vocab/models.json +1 -0
  224. package/vocab/npm.json +1 -0
  225. package/vocab/programs.json +1 -0
  226. package/vocab/pypi.json +1 -0
  227. package/vocab/rubygems.json +1 -0
  228. package/dist/commands/deploy-input.d.ts +0 -38
  229. package/dist/commands/deploy-input.js +0 -150
  230. package/dist/commands/deploy.d.ts +0 -22
  231. package/dist/commands/deploy.js +0 -124
  232. package/dist/commands/status.d.ts +0 -7
  233. package/dist/commands/status.js +0 -18
  234. package/dist/commands/templates.d.ts +0 -11
  235. package/dist/commands/templates.js +0 -32
  236. package/dist/commands/test-charge.d.ts +0 -11
  237. package/dist/commands/test-charge.js +0 -34
  238. package/dist/journal.d.ts +0 -26
  239. package/dist/journal.js +0 -58
  240. package/parity/deploy-plans/paid-api.json +0 -34
  241. package/parity/deploy-plans/per-task-agent.json +0 -34
@@ -0,0 +1,125 @@
1
+ // The shape of `crossings.jsonl`, in closed vocabulary. Field classes are in `docs/gate.md` and `docs/sessions-store.md`: a name and a stated
2
+ // version are class A because a private package's name can identify private work, and no path, working folder, or command text is kept at all.
3
+ import { createHash } from 'node:crypto';
4
+ import { CROSSING_CLASSES, CROSSING_FORMS } from './checks.js';
5
+ export const GATE_VERSION = 1;
6
+ /** Where a row came from. A replay row is read from a transcript, a hook row was written as the call crossed, and `both` is a hook row a replay then filled in. */
7
+ export const VIAS = ['hook', 'replay', 'both'];
8
+ /** `not-read` on a field says no one has read it for this row yet: a hook row has no transcript, so the fields a transcript gives read `not-read` until a replay fills them. */
9
+ export const NOT_READ = 'not-read';
10
+ /** Every program a crossing can name: the package managers and runners the gate reads, their `python -m` forms, and the `mcp` and `skill` tools. */
11
+ export const PROGRAMS = ['npm', 'pnpm', 'yarn', 'bun', 'npx', 'pnpx', 'bunx', 'pip', 'python -m pip', 'python -m pipx', 'python -m uv', 'uv', 'uvx', 'pipx', 'poetry', 'pdm', 'rye', 'pipenv', 'conda', 'mamba', 'micromamba', 'mcp', 'skill'];
12
+ export const ORIGINS = ['prompt', 'repo-file', 'web', 'tool-output', 'skill', 'harness', 'none-seen', 'not-read'];
13
+ export const NOVELTIES = ['fleet', 'repository', 'session', 'none', 'not-read'];
14
+ export const DECIDERS = ['operator', 'agent', 'not-read'];
15
+ export const PERMISSIONS = ['default', 'acceptEdits', 'plan', 'bypassPermissions', 'dontAsk', 'auto', 'other', 'unknown'];
16
+ export const STATUSES = ['ok', 'error', 'refused', 'none', 'not-read'];
17
+ export const NEXTS = ['proceeded', 'retried', 'routed-around', 'changed-name', 'stopped', 'no-result', 'not-read'];
18
+ export const MISSES = ['none', 'not-found', 'not-read'];
19
+ export const AGAINST_LATEST = ['not-read', 'latest', 'not-latest', 'range', 'unstated', 'unknown'];
20
+ export const REGISTRY_STATES = ['not-read', 'found', 'not-found', 'unreachable'];
21
+ export const REGISTRY_AGES = ['not-read', 'after-crossing', 'under-7-days', 'under-30-days', 'under-1-year', 'older', 'unknown'];
22
+ export const DEPTHS = ['main', 'subagent'];
23
+ /** Whether a note that would show did (`shown`), was held back by the policy's holdback (`held`), or there was none to show or the line was a block (`none`). A replay shows nothing, so it reads `none`. */
24
+ export const ARMS = ['none', 'shown', 'held'];
25
+ /** The fixed ids of the lines the hook can show; the words are in `notes.ts`. `none` is no line shown, a held note included. */
26
+ export const LINE_IDS = ['none', 'young-name-note', 'not-found-note', 'not-found-block'];
27
+ /** A truncated SHA-256 of the folder an ask ran in, so a kept answer names its folder without storing the path. */
28
+ export const folderKey = (cwd) => createHash('sha256').update(cwd).digest('hex').slice(0, 16);
29
+ /** The harness's permission mode, in the closed vocabulary: a mode this version does not know reads `other`. */
30
+ export const permissionOf = (value) => PERMISSIONS.find((known) => known === value) ?? 'other';
31
+ const isRow = (value) => {
32
+ const kind = typeof value === 'object' && value !== null ? Reflect.get(value, 'kind') : undefined;
33
+ return kind === 'crossing' || kind === 'run' || kind === 'registry';
34
+ };
35
+ /** A line this version cannot read is skipped: a torn tail or a newer row kind says nothing about the rest. */
36
+ export const parseRows = (text) => text.split('\n').flatMap((line) => {
37
+ if (line === '')
38
+ return [];
39
+ try {
40
+ const parsed = JSON.parse(line);
41
+ return isRow(parsed) ? [parsed] : [];
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ });
47
+ export { CROSSING_CLASSES, CROSSING_FORMS };
48
+ const isLive = (row) => row.via === 'hook' || row.via === 'both';
49
+ /** A hook never saw these, so a hook row says them as `not-read`, `null`, or `unknown`: then the replay's value stands. */
50
+ const unsaid = (value) => value === undefined || value === NOT_READ || value === null || value === 'unknown';
51
+ const pick = (key, live, replayed) => (unsaid(live[key]) ? replayed[key] : live[key]);
52
+ /**
53
+ * One crossing seen twice. The hook's fields from the time of the crossing win: its clock, its permission mode, the registry as it stood, and what was new. A replay's
54
+ * fields from the transcript fill in what the hook could not read. A row that came out of an earlier merge is a hook row again here, so a second replay refreshes
55
+ * only the transcript fields and keeps what the hook saw.
56
+ */
57
+ const merged = (live, replayed) => ({
58
+ ...replayed,
59
+ via: 'both',
60
+ // The folder as it was at the crossing: a bin installed or removed since must not turn a fetch into a local run, or the reverse.
61
+ form: live.form,
62
+ day: pick('day', live, replayed),
63
+ ts: pick('ts', live, replayed),
64
+ version: pick('version', live, replayed),
65
+ permission: pick('permission', live, replayed),
66
+ novelty: pick('novelty', live, replayed),
67
+ // One reading, so all three come from one side: the registry as it stood at the crossing when the hook read it, else the replay's. Picked field by field, a hook's
68
+ // `unreachable` (with `unknown` beside it) would take a later replay's `not-latest`, a row that contradicts itself.
69
+ // A local bin run was never asked of the registry (docs/gate.md), so a replay that saw no bin and asked cannot give it a reading.
70
+ ...(live.registryState === NOT_READ && live.form !== 'local-bin-run'
71
+ ? { againstLatest: replayed.againstLatest, registryState: replayed.registryState, registryAge: replayed.registryAge }
72
+ : live.form === 'local-bin-run'
73
+ ? { againstLatest: NOT_READ, registryState: NOT_READ, registryAge: NOT_READ }
74
+ : { againstLatest: live.againstLatest, registryState: live.registryState, registryAge: live.registryAge }),
75
+ ...(live.policy === undefined ? {} : { policy: live.policy }),
76
+ arm: pick('arm', live, replayed),
77
+ lineShown: pick('lineShown', live, replayed),
78
+ // A hook row from an earlier build has no `lineHeld`: `unsaid` reads it as nothing said, so the replay's `none` stands.
79
+ lineHeld: pick('lineHeld', live, replayed),
80
+ });
81
+ /** The key a call crossing is counted by: nothing counts twice when a row has a tool use id and another row has the same one. */
82
+ const crossingKey = (row) => (row.toolUseId === null ? null : [row.session, row.toolUseId, row.class, row.name].join('\0'));
83
+ /** The first row of each key; a row with no key cannot repeat, so it stays. */
84
+ const once = (rows) => {
85
+ const seen = new Set();
86
+ return rows.filter((row) => {
87
+ const key = crossingKey(row);
88
+ if (key === null)
89
+ return true;
90
+ if (seen.has(key))
91
+ return false;
92
+ seen.add(key);
93
+ return true;
94
+ });
95
+ };
96
+ /**
97
+ * Every crossing once. The rows of `replayed` are replay rows and the rows of `live` are hook rows (or merged ones); rows with the same session, tool use id, class, and
98
+ * name are one row, whether two sources saw it or one wrote it twice, so the same name twice in one call counts once. A row with no tool use id cannot be paired and stays.
99
+ */
100
+ export const mergeCrossings = (replayed, live) => {
101
+ const hooked = once(live);
102
+ const waiting = new Map();
103
+ for (const row of hooked) {
104
+ const key = crossingKey(row);
105
+ if (key !== null)
106
+ waiting.set(key, row);
107
+ }
108
+ const paired = new Set();
109
+ const result = once(replayed).map((row) => {
110
+ const key = crossingKey(row);
111
+ const mate = key === null ? undefined : waiting.get(key);
112
+ if (mate === undefined)
113
+ return row;
114
+ paired.add(mate);
115
+ return merged(mate, row);
116
+ });
117
+ return [...result, ...hooked.filter((row) => !paired.has(row))];
118
+ };
119
+ /** The crossings of a file's rows with each counted once: replay rows and hook rows of the same call become one. */
120
+ export const crossingsOf = (rows) => {
121
+ const crossings = rows.filter((row) => row.kind === 'crossing');
122
+ return mergeCrossings(crossings.filter((row) => !isLive(row)), crossings.filter(isLive));
123
+ };
124
+ /** The hook-written rows (and merged ones) of a file, which a replay keeps when it rewrites the rest. */
125
+ export const liveCrossings = (rows) => rows.filter((row) => row.kind === 'crossing' && isLive(row));
@@ -0,0 +1,10 @@
1
+ import type { Crossing } from './checks.js';
2
+ import type { AgainstLatest, RegistryAge } from './record.js';
3
+ import type { RegistryAnswer } from './registry.js';
4
+ export declare const DAY_MS = 86400000;
5
+ /** Only these managers read the registry the `npm view` and `pip index` asks reach. A `pnpm` (its own global `rc`), `poetry`, `uv`, `yarn`, or `conda` install may use one they do not. */
6
+ export declare const ASKED_PROGRAMS: ReadonlySet<string>;
7
+ export declare const ageOf: (answer: RegistryAnswer, ts: string | null) => RegistryAge;
8
+ export declare const againstLatestOf: (crossing: Crossing, answer: RegistryAnswer) => AgainstLatest;
9
+ /** An answer reaches a row only when that row's own program was asked and its command left the registry to the default: the same name can cross twice. */
10
+ export declare const askedOf: (crossing: Crossing) => boolean;
@@ -0,0 +1,30 @@
1
+ export const DAY_MS = 86_400_000;
2
+ /** Only these managers read the registry the `npm view` and `pip index` asks reach. A `pnpm` (its own global `rc`), `poetry`, `uv`, `yarn`, or `conda` install may use one they do not. */
3
+ export const ASKED_PROGRAMS = new Set(['npm', 'npx', 'pip', 'python -m pip']);
4
+ export const ageOf = (answer, ts) => {
5
+ if (answer.created === null || ts === null)
6
+ return 'unknown';
7
+ const days = (Date.parse(ts) - Date.parse(answer.created)) / DAY_MS;
8
+ if (Number.isNaN(days))
9
+ return 'unknown';
10
+ if (days < 0)
11
+ return 'after-crossing';
12
+ if (days < 7)
13
+ return 'under-7-days';
14
+ if (days < 30)
15
+ return 'under-30-days';
16
+ return days < 365 ? 'under-1-year' : 'older';
17
+ };
18
+ export const againstLatestOf = (crossing, answer) => {
19
+ if (answer.state !== 'found')
20
+ return 'unknown';
21
+ if (crossing.versionForm === 'unstated')
22
+ return 'unstated';
23
+ if (crossing.versionForm === 'range')
24
+ return 'range';
25
+ if (crossing.version === 'latest' || crossing.version === answer.latest)
26
+ return 'latest';
27
+ return crossing.versionForm === 'pinned' ? 'not-latest' : 'unknown';
28
+ };
29
+ /** An answer reaches a row only when that row's own program was asked and its command left the registry to the default: the same name can cross twice. */
30
+ export const askedOf = (crossing) => crossing.registryAsked && crossing.form !== 'local-bin-run' && ASKED_PROGRAMS.has(crossing.program);
@@ -0,0 +1,56 @@
1
+ import { type ChildProcess, type SpawnOptions } from 'node:child_process';
2
+ import type { CrossingClass } from './checks.js';
3
+ export interface RegistryAsk {
4
+ class: CrossingClass;
5
+ name: string;
6
+ cwd: string;
7
+ /** Aborting it stops the manager the ask started, and the processes that manager started (the hook does so before it hands the name to a helper). */
8
+ signal?: AbortSignal;
9
+ }
10
+ export interface RegistryAnswer {
11
+ state: 'found' | 'not-found' | 'unreachable';
12
+ latest: string | null;
13
+ created: string | null;
14
+ }
15
+ export type RegistryReader = (ask: RegistryAsk) => Promise<RegistryAnswer>;
16
+ /** What a manager's run came back with: the exit code, null when it could not be started or ran out of time. */
17
+ export interface ManagerRun {
18
+ code: number | null;
19
+ stdout: string;
20
+ stderr: string;
21
+ }
22
+ export type ManagerRunner = (program: string, args: readonly string[], cwd: string, signal?: AbortSignal) => Promise<ManagerRun>;
23
+ /** Whether a name can be asked about: a package class, spelled as a registry could hold it. */
24
+ export declare const askable: (cls: CrossingClass, name: string) => boolean;
25
+ /** `npm view <name> dist-tags.latest time.created --json`, as an object or a one-element array. A not-found answer is an `E404` on either stream. */
26
+ export declare const readNpmView: (run: ManagerRun) => RegistryAnswer;
27
+ /**
28
+ * `pip index versions <name>` prints `name (latest)` first. It states no dates, so a Python name's age stays unknown. A Python name never reads
29
+ * `not-found`: pip prints "No matching distribution" for a missing name and for a down index alike, so the line cannot tell them apart.
30
+ */
31
+ export declare const readPipIndex: (run: ManagerRun) => RegistryAnswer;
32
+ /**
33
+ * How a manager is started. `cwd` comes from a transcript, and `cmd.exe` searches the current directory before PATH, so `NoDefaultCurrentDirectoryInExePath`
34
+ * keeps an `npm.cmd` left there from running: nothing here runs package code.
35
+ */
36
+ export declare const managerOptions: (cwd: string, platform?: NodeJS.Platform, timeout?: number) => {
37
+ cwd: string;
38
+ timeout: number;
39
+ shell: boolean;
40
+ windowsHide: boolean;
41
+ encoding: "utf8";
42
+ env: {
43
+ NoDefaultCurrentDirectoryInExePath: string;
44
+ };
45
+ };
46
+ /** What `stopTree` needs of `spawn`: a program, its arguments, and a child to hear an error from. */
47
+ export type StartProcess = (program: string, args: readonly string[], options: SpawnOptions) => {
48
+ on: (event: 'error', listener: () => void) => unknown;
49
+ };
50
+ /**
51
+ * Stops a manager and what it started. On Windows `shell: true` puts `cmd.exe` first, and killing it leaves the manager running, so the whole tree goes (`taskkill /T`); elsewhere
52
+ * the manager is the child itself.
53
+ */
54
+ export declare const stopTree: (child: Pick<ChildProcess, "pid" | "kill">, platform?: NodeJS.Platform, start?: StartProcess) => void;
55
+ /** The reader the option uses. `npm view` and `pip index` run no package code; the manager's own cache is the manager's. */
56
+ export declare const managerReader: (given?: ManagerRunner, timeout?: number) => RegistryReader;
@@ -0,0 +1,94 @@
1
+ // The `--registry-state` option: what a registry says about a name, asked through `npm` or `pip` in the folder the call ran in. For a crossing made
2
+ // with npm, npx, or pip whose command left the registry, the config, and the folder to their defaults (the replay asks for no other crossing),
3
+ // that is the registry the manager would contact, because those read the same project and user config; so a private name does not go to a public
4
+ // registry on the gate's account. Off by default; nothing here runs unless the operator asks.
5
+ import { execFile, spawn } from 'node:child_process';
6
+ const TIMEOUT_MS = 20_000;
7
+ const UNREACHABLE = { state: 'unreachable', latest: null, created: null };
8
+ const NOT_FOUND = { state: 'not-found', latest: null, created: null };
9
+ // On Windows the managers are `.cmd` shims, which run only through a shell, so a name reaches a command line. These accept only what a registry
10
+ // can hold, which has no shell character, and nothing that a manager would read as a flag.
11
+ const NPM_NAME = /^(?:@[a-z0-9~][a-z0-9._~-]*\/)?[a-z0-9~][a-z0-9._~-]*$/i;
12
+ const PYTHON_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
13
+ /** Whether a name can be asked about: a package class, spelled as a registry could hold it. */
14
+ export const askable = (cls, name) => (cls === 'npm-package' ? NPM_NAME.test(name) : cls === 'python-package' && PYTHON_NAME.test(name));
15
+ const textField = (value, key) => {
16
+ const field = typeof value === 'object' && value !== null ? Reflect.get(value, key) : undefined;
17
+ return typeof field === 'string' && field !== '' ? field : null;
18
+ };
19
+ /** `npm view <name> dist-tags.latest time.created --json`, as an object or a one-element array. A not-found answer is an `E404` on either stream. */
20
+ export const readNpmView = (run) => {
21
+ if (/\bE404\b/.test(run.stdout) || /\bE404\b/.test(run.stderr))
22
+ return NOT_FOUND;
23
+ if (run.code !== 0)
24
+ return UNREACHABLE;
25
+ try {
26
+ const answer = JSON.parse(run.stdout);
27
+ // npm 12 wraps the two-field answer in a one-element array; older npm prints the object itself.
28
+ const parsed = Array.isArray(answer) ? answer[0] : answer;
29
+ const latest = textField(parsed, 'dist-tags.latest');
30
+ return latest === null ? UNREACHABLE : { state: 'found', latest, created: textField(parsed, 'time.created') };
31
+ }
32
+ catch {
33
+ return UNREACHABLE;
34
+ }
35
+ };
36
+ /**
37
+ * `pip index versions <name>` prints `name (latest)` first. It states no dates, so a Python name's age stays unknown. A Python name never reads
38
+ * `not-found`: pip prints "No matching distribution" for a missing name and for a down index alike, so the line cannot tell them apart.
39
+ */
40
+ export const readPipIndex = (run) => {
41
+ if (run.code !== 0)
42
+ return UNREACHABLE;
43
+ const latest = /^\S+ \(([^)]+)\)/m.exec(run.stdout)?.[1];
44
+ return latest === undefined ? UNREACHABLE : { state: 'found', latest, created: null };
45
+ };
46
+ /**
47
+ * How a manager is started. `cwd` comes from a transcript, and `cmd.exe` searches the current directory before PATH, so `NoDefaultCurrentDirectoryInExePath`
48
+ * keeps an `npm.cmd` left there from running: nothing here runs package code.
49
+ */
50
+ export const managerOptions = (cwd, platform = process.platform, timeout = TIMEOUT_MS) => ({
51
+ cwd,
52
+ timeout,
53
+ shell: platform === 'win32',
54
+ windowsHide: true,
55
+ encoding: 'utf8',
56
+ env: { ...process.env, NoDefaultCurrentDirectoryInExePath: '1' },
57
+ });
58
+ /**
59
+ * Stops a manager and what it started. On Windows `shell: true` puts `cmd.exe` first, and killing it leaves the manager running, so the whole tree goes (`taskkill /T`); elsewhere
60
+ * the manager is the child itself.
61
+ */
62
+ export const stopTree = (child, platform = process.platform, start = spawn) => {
63
+ if (platform === 'win32' && child.pid !== undefined) {
64
+ start('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => undefined);
65
+ return;
66
+ }
67
+ child.kill();
68
+ };
69
+ const runnerWith = (timeout) => (program, args, cwd, signal) => new Promise((resolve) => {
70
+ const child = execFile(program, [...args], managerOptions(cwd, process.platform, timeout), (error, stdout, stderr) => {
71
+ const code = error === null ? 0 : typeof error.code === 'number' ? error.code : null;
72
+ resolve({ code, stdout, stderr });
73
+ });
74
+ if (signal?.aborted === true)
75
+ stopTree(child);
76
+ else
77
+ signal?.addEventListener('abort', () => { stopTree(child); }, { once: true });
78
+ });
79
+ /**
80
+ * With a deadline the child gets its own: the kill `execFile`'s timeout sends never comes when the caller's process exits first, and npm's default fetch retries run
81
+ * for minutes against an unreachable registry. Without one (a replay) the manager keeps its defaults.
82
+ */
83
+ const npmLimits = (timeout) => (timeout === undefined ? [] : [`--fetch-timeout=${String(timeout)}`, '--fetch-retries=0']);
84
+ /** pip counts its socket timeout in whole seconds. */
85
+ const pipLimits = (timeout) => (timeout === undefined ? [] : ['--timeout', String(Math.ceil(timeout / 1000)), '--retries', '0']);
86
+ /** The reader the option uses. `npm view` and `pip index` run no package code; the manager's own cache is the manager's. */
87
+ export const managerReader = (given, timeout) => async ({ class: cls, name, cwd, signal }) => {
88
+ const run = given ?? runnerWith(timeout ?? TIMEOUT_MS);
89
+ if (!askable(cls, name))
90
+ return UNREACHABLE;
91
+ if (cls === 'npm-package')
92
+ return readNpmView(await run('npm', ['view', name, 'dist-tags.latest', 'time.created', '--json', '--logs-max=0', ...npmLimits(timeout)], cwd, signal));
93
+ return readPipIndex(await run('pip', ['index', 'versions', name, '--disable-pip-version-check', ...pipLimits(timeout)], cwd, signal));
94
+ };
@@ -0,0 +1,20 @@
1
+ import { type SessionsRuntime } from '../sessions/store.js';
2
+ import { type RunRow } from './record.js';
3
+ import { type RegistryReader } from './registry.js';
4
+ export declare const crossingsFile: (storeDir: string) => string;
5
+ export declare const REGISTRY_NOT_READ = "registry state not read";
6
+ export interface ReplayOptions {
7
+ /** Project folders to read; empty means the saved allowlist. */
8
+ allow: readonly string[];
9
+ /** Only with `--registry-state`. Absent, nothing leaves this machine. */
10
+ registry?: RegistryReader;
11
+ now: () => number;
12
+ }
13
+ export interface ReplayResult {
14
+ run: RunRow;
15
+ file: string;
16
+ text: string;
17
+ }
18
+ /** A kept registry answer older than this many days is asked again: a `latest` goes stale, and a row would otherwise read "as of this run" forever. */
19
+ export declare const REGISTRY_MAX_DAYS = 30;
20
+ export declare const replay: (runtime: Pick<SessionsRuntime, "storeDir" | "projectsRoot">, options: ReplayOptions) => Promise<ReplayResult>;
@@ -0,0 +1,257 @@
1
+ // `oars gate replay`: the crossing checks over logs already on disk. It reads raw transcripts under the allowlist and writes one file, `crossings.jsonl`.
2
+ // It never calls `localSalt` or the step log, both of which write, and it holds no store lock, since a replay touches nothing a deposit reads.
3
+ import { readFile, stat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { CliError, ExitCode } from '../errors.js';
6
+ import { compileAllow } from '../sessions/allow.js';
7
+ import { makeRedactor } from '../sessions/redact.js';
8
+ import { paths, readState, writeAtomic } from '../sessions/store.js';
9
+ import { GATE_VERSION, liveCrossings, mergeCrossings, parseRows } from './record.js';
10
+ import { askable } from './registry.js';
11
+ import { withLocalBin } from './local-bin.js';
12
+ import { ageOf, againstLatestOf, askedOf, DAY_MS } from './registry-state.js';
13
+ import { scopesOf, seedIndex } from './novelty.js';
14
+ import { repositoryOf } from './scope.js';
15
+ import { discover, readCalls, readOrigins } from './transcripts.js';
16
+ export const crossingsFile = (storeDir) => join(storeDir, 'crossings.jsonl');
17
+ export const REGISTRY_NOT_READ = 'registry state not read';
18
+ /** A failed install is looked at for this many calls after it. */
19
+ const NEXT_WINDOW = 10;
20
+ const REGISTRY_WORKERS = 4;
21
+ /** A kept registry answer older than this many days is asked again: a `latest` goes stale, and a row would otherwise read "as of this run" forever. */
22
+ export const REGISTRY_MAX_DAYS = 30;
23
+ const keyOf = (crossing) => `${crossing.class}\0${crossing.name}`;
24
+ const PACKAGE_CLASSES = new Set(['npm-package', 'python-package']);
25
+ const nextOf = (found, read) => {
26
+ if (found.status === 'ok')
27
+ return 'proceeded';
28
+ if (found.status === 'none')
29
+ return 'no-result';
30
+ const end = found.call + NEXT_WINDOW;
31
+ const { crossing } = found;
32
+ const later = read.found.find((one) => one.call > found.call && one.call <= end && one.crossing.class === crossing.class && (one.crossing.name === crossing.name || PACKAGE_CLASSES.has(crossing.class)));
33
+ if (read.pipes.some((call) => call > found.call && call <= end && (later === undefined || call < later.call)))
34
+ return 'routed-around';
35
+ if (later === undefined)
36
+ return 'stopped';
37
+ if (later.crossing.name !== crossing.name)
38
+ return 'changed-name';
39
+ return later.crossing.program === crossing.program && later.crossing.form === crossing.form ? 'retried' : 'routed-around';
40
+ };
41
+ const originsOf = async (transcript, read) => {
42
+ const firstLines = new Map();
43
+ const servers = new Set();
44
+ for (const { crossing, line } of read.found) {
45
+ if (!firstLines.has(crossing.name))
46
+ firstLines.set(crossing.name, line);
47
+ if (crossing.class === 'mcp-server')
48
+ servers.add(crossing.name);
49
+ }
50
+ return firstLines.size === 0 ? { origins: new Map(), prompted: new Set() } : readOrigins(transcript.file, firstLines, servers);
51
+ };
52
+ const byTime = (one, two) => {
53
+ if (one.ts === two.ts)
54
+ return 0;
55
+ if (one.ts === null)
56
+ return 1;
57
+ if (two.ts === null)
58
+ return -1;
59
+ return one.ts < two.ts ? -1 : 1;
60
+ };
61
+ /** The widest scope in which this is the first use, read in time order so a run's result does not depend on the order folders list in. */
62
+ const noveltyWalker = () => {
63
+ const fleet = new Set();
64
+ const repositories = new Set();
65
+ const sessions = new Set();
66
+ return (held) => {
67
+ const key = keyOf(held.found.crossing);
68
+ const inRepository = `${repositoryOf(held.transcript.project)}\0${key}`;
69
+ const inSession = `${held.transcript.session}\0${key}`;
70
+ let novelty = 'none';
71
+ if (!fleet.has(key))
72
+ novelty = 'fleet';
73
+ else if (!repositories.has(inRepository))
74
+ novelty = 'repository';
75
+ else if (!sessions.has(inSession))
76
+ novelty = 'session';
77
+ fleet.add(key);
78
+ repositories.add(inRepository);
79
+ sessions.add(inSession);
80
+ return novelty;
81
+ };
82
+ };
83
+ const lastRemovals = (removals) => {
84
+ const last = new Map();
85
+ for (const { removal, transcript } of removals) {
86
+ if (removal.status === 'error' || removal.status === 'refused' || removal.ts === null)
87
+ continue;
88
+ const key = `${repositoryOf(transcript.project)}\0${keyOf(removal.removal)}`;
89
+ if ((last.get(key) ?? '') < removal.ts)
90
+ last.set(key, removal.ts);
91
+ }
92
+ return last;
93
+ };
94
+ const asAnswer = (row) => ({ state: row.state, latest: row.latest, created: row.created });
95
+ const isFolder = (path) => stat(path).then((found) => found.isDirectory(), () => false);
96
+ /** A command, its environment, or an earlier `cd` chose that call's registry or config: the gate cannot read it from here, so the name is not asked, kept answers included. */
97
+ const unaskedKeys = (rows) => new Set(rows.filter(({ found }) => !found.crossing.registryAsked).map(({ found }) => keyOf(found.crossing)));
98
+ /** One question per name, from the first call whose folder still exists: the manager reads its registry from there, and a guess at another folder could ask the wrong one. */
99
+ const askRegistry = async (rows, reader, cached) => {
100
+ const unasked = unaskedKeys(rows);
101
+ const answers = new Map([...cached].filter(([key]) => !unasked.has(key)).map(([key, row]) => [key, asAnswer(row)]));
102
+ const folders = new Map();
103
+ const asks = new Map();
104
+ for (const { found } of rows) {
105
+ const { crossing, cwd } = found;
106
+ const key = keyOf(crossing);
107
+ if (cwd === null || unasked.has(key) || answers.has(key) || asks.has(key) || crossing.versionForm === 'url' || !askedOf(crossing) || !askable(crossing.class, crossing.name))
108
+ continue;
109
+ const exists = folders.get(cwd) ?? (await isFolder(cwd));
110
+ folders.set(cwd, exists);
111
+ if (exists)
112
+ asks.set(key, { class: crossing.class, name: crossing.name, cwd });
113
+ }
114
+ const queue = [...asks];
115
+ const worker = async () => {
116
+ for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
117
+ const [key, ask] = item;
118
+ answers.set(key, await reader(ask).catch(() => ({ state: 'unreachable', latest: null, created: null })));
119
+ }
120
+ };
121
+ await Promise.all(Array.from({ length: REGISTRY_WORKERS }, worker));
122
+ return answers;
123
+ };
124
+ const recordOf = (held, novelty, removedAt, answers) => {
125
+ const { found, transcript, origin, prompted, next } = held;
126
+ const { crossing, ts, side } = found;
127
+ // An answer reaches a row only when that row's own program was asked and its command left the registry to the default: the same name can cross twice.
128
+ const answer = askedOf(crossing) ? answers?.get(keyOf(crossing)) : undefined;
129
+ return {
130
+ kind: 'crossing',
131
+ v: GATE_VERSION,
132
+ via: 'replay',
133
+ day: ts === null ? null : ts.slice(0, 10),
134
+ class: crossing.class,
135
+ form: crossing.form,
136
+ program: crossing.program,
137
+ versionForm: crossing.versionForm,
138
+ statusReports: crossing.statusReports,
139
+ origin,
140
+ novelty,
141
+ // The operator named it only on the main line: a subagent's prompt is another model's words.
142
+ decidedBy: prompted && !side ? 'operator' : 'agent',
143
+ permission: found.permission,
144
+ status: found.status,
145
+ next,
146
+ removedLater: removedAt !== undefined && ts !== null && ts < removedAt,
147
+ depth: side ? 'subagent' : 'main',
148
+ miss: found.miss,
149
+ againstLatest: answer === undefined ? 'not-read' : againstLatestOf(crossing, answer),
150
+ registryState: answer?.state ?? 'not-read',
151
+ registryAge: answer === undefined ? 'not-read' : ageOf(answer, ts),
152
+ arm: 'none',
153
+ lineShown: 'none',
154
+ lineHeld: 'none',
155
+ session: transcript.session,
156
+ agent: transcript.agent,
157
+ toolUseId: found.toolUseId,
158
+ ts,
159
+ name: crossing.name,
160
+ version: crossing.version,
161
+ };
162
+ };
163
+ const cachedAnswers = async (file, now) => {
164
+ const text = await readFile(file, 'utf8').catch(() => '');
165
+ return new Map(parseRows(text)
166
+ .filter((row) => row.kind === 'registry' && (now - Date.parse(row.day)) / DAY_MS <= REGISTRY_MAX_DAYS)
167
+ .map((row) => [keyOf(row), row]));
168
+ };
169
+ const registryRows = (answers, rows, cached, day) => {
170
+ const added = new Map();
171
+ const unasked = unaskedKeys(rows);
172
+ for (const { found } of rows) {
173
+ const key = keyOf(found.crossing);
174
+ const answer = answers.get(key);
175
+ // An unreachable registry says nothing about the name, so a later run asks again.
176
+ if (cached.has(key) || added.has(key) || answer === undefined || answer.state === 'unreachable')
177
+ continue;
178
+ added.set(key, { kind: 'registry', v: GATE_VERSION, day, class: found.crossing.class, state: answer.state, created: answer.created, name: found.crossing.name, latest: answer.latest });
179
+ }
180
+ return [...[...cached].filter(([key]) => !unasked.has(key)).map(([, row]) => row), ...added.values()];
181
+ };
182
+ const summaryText = (run, file) => [
183
+ `Read ${String(run.transcripts)} transcripts (${String(run.unreadable)} unreadable, ${String(run.badLines)} torn lines) and ${String(run.calls)} tool calls.`,
184
+ `Crossings: ${String(run.crossings)}. Written to ${file}.`,
185
+ run.registryRead ? 'Registry state read through the package manager, as of this run, not as of each crossing.' : REGISTRY_NOT_READ,
186
+ 'Counts: oars gate report',
187
+ ].join('\n');
188
+ const readAll = async (runtime, entries, domains) => {
189
+ const redact = makeRedactor(domains);
190
+ const { files, unreadable: unlisted } = await discover([runtime.projectsRoot, paths(runtime.storeDir).raw], compileAllow(entries));
191
+ const seen = new Map();
192
+ const reads = [];
193
+ let unreadable = unlisted;
194
+ for (const transcript of files) {
195
+ try {
196
+ reads.push({ transcript, read: await readCalls(transcript.file, transcript.agent, redact, seen) });
197
+ }
198
+ catch {
199
+ unreadable += 1;
200
+ }
201
+ }
202
+ // A folder's own bin is read once the whole record is in, so `nextOf` below sees the form the row will carry.
203
+ const memo = new Map();
204
+ for (const { read } of reads) {
205
+ for (const [at, found] of read.found.entries())
206
+ read.found[at] = { ...found, crossing: await withLocalBin(found.crossing, found.cwd, memo) };
207
+ }
208
+ return { reads, transcripts: files.length, unreadable };
209
+ };
210
+ export const replay = async (runtime, options) => {
211
+ const state = await readState(runtime.storeDir);
212
+ const entries = options.allow.length > 0 ? options.allow : state.allow;
213
+ if (entries.length === 0)
214
+ throw new CliError('Name a folder with --allow: nothing is read from a folder you have not named, and no deposit has saved an allowlist.', ExitCode.USAGE, 'usage');
215
+ const { reads, transcripts, unreadable } = await readAll(runtime, entries, state.redactDomains);
216
+ const held = [];
217
+ for (const { transcript, read } of reads) {
218
+ const { origins, prompted } = await originsOf(transcript, read).catch(() => ({ origins: new Map(), prompted: new Set() }));
219
+ for (const found of read.found)
220
+ held.push({ found, transcript, origin: origins.get(found.crossing.name) ?? 'none-seen', prompted: prompted.has(found.crossing.name), next: nextOf(found, read) });
221
+ }
222
+ held.sort((one, two) => byTime(one.found, two.found));
223
+ const removedAt = lastRemovals(reads.flatMap(({ transcript, read }) => read.removals.map((removal) => ({ removal, transcript }))));
224
+ const file = crossingsFile(runtime.storeDir);
225
+ const cached = await cachedAnswers(file, options.now());
226
+ const answers = options.registry === undefined ? undefined : await askRegistry(held, options.registry, cached);
227
+ const novelty = noveltyWalker();
228
+ const records = held.map((one) => recordOf(one, novelty(one), removedAt.get(`${repositoryOf(one.transcript.project)}\0${keyOf(one.found.crossing)}`), answers));
229
+ const unread = {};
230
+ for (const { read } of reads) {
231
+ for (const shape of read.unread)
232
+ unread[shape] = (unread[shape] ?? 0) + 1;
233
+ }
234
+ const day = new Date(options.now()).toISOString().slice(0, 10);
235
+ const run = {
236
+ kind: 'run',
237
+ v: GATE_VERSION,
238
+ day,
239
+ transcripts,
240
+ unreadable,
241
+ badLines: reads.reduce((sum, { read }) => sum + read.badLines, 0),
242
+ calls: reads.reduce((sum, { read }) => sum + read.fresh, 0),
243
+ crossings: records.length,
244
+ unread,
245
+ registryRead: options.registry !== undefined,
246
+ };
247
+ const registry = registryRows(answers ?? new Map(), held, cached, day);
248
+ // The file is read again here, as late as it can be, so a row the hook appended while the transcripts were read is kept, not overwritten.
249
+ const live = liveCrossings(parseRows(await readFile(file, 'utf8').catch(() => '')));
250
+ await writeAtomic(file, `${[run, ...registry, ...mergeCrossings(records, live)].map((row) => JSON.stringify(row)).join('\n')}\n`);
251
+ // The hook reads novelty from a kept index, not from this file; every name a transcript showed is in it, in the scopes the hook names them in.
252
+ await seedIndex(runtime.storeDir, file, held.flatMap((one) => {
253
+ const scopes = scopesOf(keyOf(one.found.crossing), repositoryOf(one.transcript.project), one.transcript.session);
254
+ return [scopes.fleet, scopes.repository, scopes.session];
255
+ }));
256
+ return { run, file, text: summaryText(run, file) };
257
+ };
@@ -0,0 +1,50 @@
1
+ import { type Row } from './record.js';
2
+ export interface Tally {
3
+ value: string;
4
+ count: number;
5
+ }
6
+ export interface ClassCount {
7
+ class: string;
8
+ form: string;
9
+ count: number;
10
+ distinct: number;
11
+ }
12
+ export interface Report {
13
+ ok: true;
14
+ transcripts: number;
15
+ unreadable: number;
16
+ badLines: number;
17
+ calls: number;
18
+ crossings: number;
19
+ /** Whether a replay has run: when it has not, only the hook's rows are here and the transcript fields read `not-read`. */
20
+ replayed: boolean;
21
+ registryRead: boolean;
22
+ via: Tally[];
23
+ classes: ClassCount[];
24
+ origin: Tally[];
25
+ novelty: Tally[];
26
+ decidedBy: Tally[];
27
+ permission: Tally[];
28
+ status: Tally[];
29
+ next: Tally[];
30
+ miss: Tally[];
31
+ depth: Tally[];
32
+ againstLatest: Tally[];
33
+ registryState: Tally[];
34
+ registryAge: Tally[];
35
+ /** Whether a note that would show did (`shown`) or was held back (`held`); `none` is every row with no note to show, and every block. */
36
+ arm: Tally[];
37
+ /** The fixed id of the line a row showed. */
38
+ lineShown: Tally[];
39
+ /** The fixed id of the line a held note withheld. */
40
+ lineHeld: Tally[];
41
+ /** Which policy stood for the rows the hook wrote: no file, a file that fit, or one that did not (the defaults stood). Rows with no policy read (replay) are not counted. */
42
+ policy: Tally[];
43
+ removedLater: number;
44
+ unseen: Tally[];
45
+ limits: string[];
46
+ }
47
+ /** What a replay of logs on disk cannot see, said every time so a count is never read as the whole. */
48
+ export declare const LIMITS: readonly string[];
49
+ export declare const reportOf: (rows: readonly Row[]) => Report;
50
+ export declare const formatReport: (report: Report) => string;