@manyoars/oars 0.4.1 → 0.5.2

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 (188) hide show
  1. package/README.md +79 -29
  2. package/dist/bin.js +3 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +207 -21
  5. package/dist/club.d.ts +19 -0
  6. package/dist/club.js +132 -0
  7. package/dist/gate/hook-fill-main.js +1 -1
  8. package/dist/gate/hook-fill.d.ts +1 -1
  9. package/dist/gate/hook-fill.js +1 -1
  10. package/dist/gate/hook-main.js +7 -5
  11. package/dist/gate/hook.d.ts +12 -1
  12. package/dist/gate/hook.js +38 -7
  13. package/dist/hook-bin.js +23 -4
  14. package/dist/main.js +5 -0
  15. package/dist/node-floor.d.ts +10 -0
  16. package/dist/node-floor.js +23 -0
  17. package/dist/oars-hook.d.ts +2 -0
  18. package/dist/oars-hook.js +7 -0
  19. package/dist/sessions/allow.d.ts +1 -0
  20. package/dist/sessions/allow.js +1 -1
  21. package/dist/sessions/call-shape.d.ts +111 -0
  22. package/dist/sessions/call-shape.js +464 -0
  23. package/dist/sessions/candidates.d.ts +65 -0
  24. package/dist/sessions/candidates.js +236 -0
  25. package/dist/sessions/card-command.d.ts +24 -0
  26. package/dist/sessions/card-command.js +71 -0
  27. package/dist/sessions/card-font.d.ts +6 -0
  28. package/dist/sessions/card-font.js +36 -0
  29. package/dist/sessions/card-line.d.ts +30 -0
  30. package/dist/sessions/card-line.js +105 -0
  31. package/dist/sessions/card-render.d.ts +15 -0
  32. package/dist/sessions/card-render.js +146 -0
  33. package/dist/sessions/card.d.ts +59 -0
  34. package/dist/sessions/card.js +107 -0
  35. package/dist/sessions/cell-increments.d.ts +14 -3
  36. package/dist/sessions/cell-increments.js +79 -12
  37. package/dist/sessions/cells.d.ts +42 -4
  38. package/dist/sessions/cells.js +72 -18
  39. package/dist/sessions/codex-hook-bin.d.ts +1 -0
  40. package/dist/sessions/codex-hook-bin.js +24 -0
  41. package/dist/sessions/codex-hook-install.d.ts +21 -0
  42. package/dist/sessions/codex-hook-install.js +47 -0
  43. package/dist/sessions/codex-hook-view.d.ts +20 -0
  44. package/dist/sessions/codex-hook-view.js +20 -0
  45. package/dist/sessions/codex-hook.d.ts +77 -0
  46. package/dist/sessions/codex-hook.js +255 -0
  47. package/dist/sessions/codex.d.ts +9 -0
  48. package/dist/sessions/codex.js +104 -18
  49. package/dist/sessions/command.d.ts +17 -2
  50. package/dist/sessions/command.js +145 -33
  51. package/dist/sessions/copilot.d.ts +3 -0
  52. package/dist/sessions/copilot.js +12 -4
  53. package/dist/sessions/derive.js +2 -1
  54. package/dist/sessions/empty-state.d.ts +17 -0
  55. package/dist/sessions/empty-state.js +38 -0
  56. package/dist/sessions/exclusion.js +48 -3
  57. package/dist/sessions/extract.d.ts +16 -3
  58. package/dist/sessions/extract.js +281 -24
  59. package/dist/sessions/gemini.d.ts +2 -0
  60. package/dist/sessions/gemini.js +29 -2
  61. package/dist/sessions/harness-files.d.ts +27 -0
  62. package/dist/sessions/harness-files.js +109 -0
  63. package/dist/sessions/head-only.d.ts +7 -0
  64. package/dist/sessions/head-only.js +21 -0
  65. package/dist/sessions/hook-calls.js +4 -3
  66. package/dist/sessions/hooks.d.ts +1 -1
  67. package/dist/sessions/hooks.js +6 -2
  68. package/dist/sessions/identity.d.ts +6 -0
  69. package/dist/sessions/injected.d.ts +13 -0
  70. package/dist/sessions/injected.js +260 -0
  71. package/dist/sessions/install.d.ts +31 -20
  72. package/dist/sessions/install.js +80 -82
  73. package/dist/sessions/load-status.d.ts +41 -0
  74. package/dist/sessions/load-status.js +156 -0
  75. package/dist/sessions/machine-child-deps.d.ts +5 -0
  76. package/dist/sessions/machine-child-deps.js +83 -0
  77. package/dist/sessions/machine-child.d.ts +43 -0
  78. package/dist/sessions/machine-child.js +120 -0
  79. package/dist/sessions/machine-credential.d.ts +10 -0
  80. package/dist/sessions/machine-credential.js +20 -0
  81. package/dist/sessions/machine-enroll.d.ts +47 -0
  82. package/dist/sessions/machine-enroll.js +230 -0
  83. package/dist/sessions/machine-hold.d.ts +49 -0
  84. package/dist/sessions/machine-hold.js +88 -0
  85. package/dist/sessions/machine-ids.d.ts +6 -0
  86. package/dist/sessions/machine-ids.js +9 -0
  87. package/dist/sessions/machine-notice.d.ts +21 -0
  88. package/dist/sessions/machine-notice.js +89 -0
  89. package/dist/sessions/machine-pull.d.ts +40 -0
  90. package/dist/sessions/machine-pull.js +220 -0
  91. package/dist/sessions/machine-repo.d.ts +7 -0
  92. package/dist/sessions/machine-repo.js +30 -0
  93. package/dist/sessions/machine-scan-cache.d.ts +12 -0
  94. package/dist/sessions/machine-scan-cache.js +47 -0
  95. package/dist/sessions/machine-send.d.ts +12 -0
  96. package/dist/sessions/machine-send.js +156 -0
  97. package/dist/sessions/machine-stop.d.ts +22 -0
  98. package/dist/sessions/machine-stop.js +53 -0
  99. package/dist/sessions/machine.d.ts +41 -0
  100. package/dist/sessions/machine.js +89 -0
  101. package/dist/sessions/opencode.d.ts +2 -0
  102. package/dist/sessions/opencode.js +6 -0
  103. package/dist/sessions/otel.d.ts +21 -0
  104. package/dist/sessions/otel.js +109 -38
  105. package/dist/sessions/owner-only.d.ts +7 -0
  106. package/dist/sessions/owner-only.js +34 -0
  107. package/dist/sessions/pi.d.ts +13 -1
  108. package/dist/sessions/pi.js +27 -13
  109. package/dist/sessions/pool-books.d.ts +1 -1
  110. package/dist/sessions/pool-books.js +1 -1
  111. package/dist/sessions/pool-choices.d.ts +4 -2
  112. package/dist/sessions/pool-choices.js +4 -2
  113. package/dist/sessions/pool-command.d.ts +46 -6
  114. package/dist/sessions/pool-command.js +613 -136
  115. package/dist/sessions/pool-delta.d.ts +7 -0
  116. package/dist/sessions/pool-delta.js +28 -5
  117. package/dist/sessions/pool-due.d.ts +43 -0
  118. package/dist/sessions/pool-due.js +113 -0
  119. package/dist/sessions/pool-nightly.d.ts +9 -1
  120. package/dist/sessions/pool-nightly.js +65 -21
  121. package/dist/sessions/pool-outbox.d.ts +51 -5
  122. package/dist/sessions/pool-outbox.js +217 -24
  123. package/dist/sessions/pool-print.d.ts +1 -1
  124. package/dist/sessions/pool-raw.js +4 -3
  125. package/dist/sessions/pool-replica.d.ts +4 -4
  126. package/dist/sessions/pool-replica.js +26 -7
  127. package/dist/sessions/pool-send-lock.js +1 -1
  128. package/dist/sessions/pool-sent.d.ts +2 -0
  129. package/dist/sessions/pool-sent.js +21 -4
  130. package/dist/sessions/pool-wire.d.ts +176 -15
  131. package/dist/sessions/pool-wire.js +297 -33
  132. package/dist/sessions/pool.js +4 -2
  133. package/dist/sessions/public-vocabulary.d.ts +5 -0
  134. package/dist/sessions/public-vocabulary.js +16 -0
  135. package/dist/sessions/read-call-shape.js +12 -6
  136. package/dist/sessions/read-fit.d.ts +5 -0
  137. package/dist/sessions/read-fit.js +23 -2
  138. package/dist/sessions/read-interaction.js +1 -1
  139. package/dist/sessions/read-lead-lag.js +1 -1
  140. package/dist/sessions/read-readable.js +1 -1
  141. package/dist/sessions/read.d.ts +1 -1
  142. package/dist/sessions/recall-consequence.d.ts +2 -2
  143. package/dist/sessions/recall-consequence.js +14 -9
  144. package/dist/sessions/recall-object.d.ts +9 -2
  145. package/dist/sessions/receipt.d.ts +8 -0
  146. package/dist/sessions/receipt.js +78 -0
  147. package/dist/sessions/recovery-calls.d.ts +3 -2
  148. package/dist/sessions/recovery-calls.js +9 -3
  149. package/dist/sessions/redact.d.ts +11 -2
  150. package/dist/sessions/redact.js +201 -23
  151. package/dist/sessions/repo-root.d.ts +7 -0
  152. package/dist/sessions/repo-root.js +17 -13
  153. package/dist/sessions/reversion.d.ts +8 -0
  154. package/dist/sessions/reversion.js +114 -0
  155. package/dist/sessions/schema.d.ts +78 -4
  156. package/dist/sessions/schema.js +31 -1
  157. package/dist/sessions/session-shape.d.ts +2 -1
  158. package/dist/sessions/session-shape.js +15 -0
  159. package/dist/sessions/sources.d.ts +9 -0
  160. package/dist/sessions/sources.js +16 -5
  161. package/dist/sessions/steps.js +3 -2
  162. package/dist/sessions/store.d.ts +41 -2
  163. package/dist/sessions/store.js +68 -16
  164. package/dist/sessions/try-demo.d.ts +8 -0
  165. package/dist/sessions/try-demo.js +90 -0
  166. package/dist/sessions/try.d.ts +13 -0
  167. package/dist/sessions/try.js +50 -0
  168. package/dist/sessions/vscode-copilot.d.ts +4 -0
  169. package/dist/sessions/vscode-copilot.js +25 -18
  170. package/dist/telemetry/command.js +12 -6
  171. package/dist/telemetry/consent.d.ts +9 -3
  172. package/dist/telemetry/consent.js +25 -9
  173. package/dist/telemetry/fields.d.ts +11 -0
  174. package/dist/telemetry/fields.js +56 -0
  175. package/dist/telemetry/measures.d.ts +13 -0
  176. package/dist/telemetry/measures.js +30 -0
  177. package/dist/telemetry/observe.d.ts +15 -2
  178. package/dist/telemetry/observe.js +34 -7
  179. package/dist/telemetry/region.d.ts +6 -0
  180. package/dist/telemetry/region.js +28 -0
  181. package/dist/telemetry/report.js +1 -1
  182. package/dist/telemetry/schema.d.ts +3 -0
  183. package/dist/telemetry/schema.js +11 -1
  184. package/dist/telemetry/send.d.ts +5 -2
  185. package/dist/telemetry/send.js +15 -7
  186. package/dist/telemetry/words.d.ts +6 -0
  187. package/dist/telemetry/words.js +6 -0
  188. package/package.json +24 -5
@@ -2,18 +2,38 @@
2
2
  // computed from, with every class A field left out, so any later derivation can run over old data while nothing private leaves. The client builds a skeleton and checks it before
3
3
  // sending, and the server parses it again, field by field, because a live URL takes anything from anyone. This file imports nothing from the file system: the Worker bundles it.
4
4
  import { ACTION_TABLES, CANONICAL_ACTIONS, mcpOf } from './actions.js';
5
- import { CUSTOM_AGENT, FRAMEWORKS, isPublicMcpTool, isRegistryVersion, LANGUAGES, LITERAL, PRIVATE, publicEffort, publicVersion, PUBLIC_PACKAGES, projectedAgentType, PUBLIC_FLAGS, REPO_SIZES, TOOL_INPUT_KEYS, VOCAB_VERSION } from './public-vocabulary.js';
6
- import { ARG_CLASSES, TARGET_CLASSES } from './call-shape.js';
5
+ import { CLOSED_ARG_VALUES, CUSTOM_AGENT, FRAMEWORKS, isPublicMcpTool, isRegistryVersion, LANGUAGES, LITERAL, PRIVATE, publicEffort, publicVersion, PUBLIC_PACKAGES, projectedAgentType, PUBLIC_FLAGS, REPO_SIZES, TOOL_INPUT_KEYS, VOCAB_VERSION } from './public-vocabulary.js';
6
+ import { ARG_CLASSES, CHANGE_KINDS, DIFF_SHAPES, DIRECTORY_KINDS, EXTENSION_CLASSES, NAME_KINDS, PATH_DEPTHS, PATTERN_FEATURES, TARGET_CLASSES, TEMPLATE_SLOTS, VALUE_SIZES } from './call-shape.js';
7
7
  import { recoveryCallsOf, RECOVERY_CALLS } from './recovery-calls.js';
8
8
  import { projectLanguage, projectLanguageShare } from './strata.js';
9
9
  import { ERROR_CODES, LANGUAGE_SHARES, PROMPT_INTERVENTIONS } from './schema.js';
10
10
  // Format 3 (step 1 of the wire): a tool carries its `errorCode` and, for a failed call, what followed it (`recoveryCalls`). Formats 1 and 2 are refused by name; nothing is deployed, so nothing is migrated.
11
- export const SKELETON_FORMAT = 3;
11
+ // Format 4 (layer 1 of a call's parameters): a tool may carry `argFeatures`, `argValues`, and `changeKind`. The pool reads formats 3 and 4 (`READ_FORMATS`): a format 3 batch is read as before, and one of those
12
+ // three names in it is a field format 3 does not know, so it goes to the bag and is served from nowhere. The table is "Formats on open" in docs/pool.md.
13
+ // Format 5 (layer 2 of a call's parameters): a tool may carry `names`, the file, directory, and variable names its values held. The pool reads formats 3, 4, and 5, and a name field in a batch of an older format goes to the bag the same way.
14
+ // Format 6 (exposure): a batch may carry `recommendations`, one entry for each recommendation the session was shown or held back from, so the pool can tell shown from held. A batch with none says nothing, and the pool reads that as unknown, never as unexposed.
15
+ // Format 7 (edit digests): a tool may carry `digests`, the digest of the old and new text of an Edit, MultiEdit, or Write call, so an edit that restores earlier content can be told from one that does not. A `git restore` names its path the way an edit
16
+ // does, in `names`, so it needs no field of its own. The pool reads formats 3 to 7, and a recommendations or digests list in an older batch goes to the bag the same way.
17
+ export const SKELETON_FORMAT = 7;
18
+ /** The formats the parser reads, oldest first: the client builds only the last. */
19
+ export const READ_FORMATS = [3, 4, 5, 6, SKELETON_FORMAT];
20
+ /** The tool fields format 4 added: a format 3 batch that holds one is holding a field it does not define. */
21
+ export const FORMAT_4_TOOL_FIELDS = ['argFeatures', 'argValues', 'changeKind'];
22
+ /** The tool fields format 5 added: a batch of format 4 or 3 that holds one is holding a field it does not define. */
23
+ export const FORMAT_5_TOOL_FIELDS = ['names', 'namesDropped', 'templates', 'templatesDropped', 'diffShape'];
24
+ /** The batch fields format 6 added: a batch of an older format that holds one is holding a field it does not define. */
25
+ export const FORMAT_6_BATCH_FIELDS = ['recommendations'];
26
+ /** The format that added exposure. A batch of an older format holds no `recommendations`, and its failed calls are checked with the rule they were built under. */
27
+ export const EXPOSURE_FORMAT = 6;
28
+ /** The format that added edit digests. */
29
+ export const DIGEST_FORMAT = 7;
30
+ /** The tool fields format 7 added: a batch of format 6 or older that holds one is holding a field it does not define. */
31
+ export const FORMAT_7_TOOL_FIELDS = ['digests'];
12
32
  /**
13
33
  * The version of the field list: every field name a batch can hold (`BATCH_FIELDS` in `pool-outbox.ts`). It moves with any field added, so a server told the version knows which names a batch may carry,
14
34
  * and a test pins the list to it (`pool-wire-additive.test.ts`). A field is deprecated and left on the list, never removed (conclusion section 17, rule 3).
15
35
  */
16
- export const FIELD_LIST_VERSION = 8;
36
+ export const FIELD_LIST_VERSION = 14;
17
37
  export const BUNDLE_FORMAT = 1;
18
38
  /** One constant: the pool serves only the vocabulary version the client holds. */
19
39
  export { VOCAB_VERSION };
@@ -43,6 +63,10 @@ export const ORG_SIZES = ['solo', '2-10', '11-100', '101-1000', 'over-1000', 'un
43
63
  export const TEAMS = ['solo', 'team', 'unknown'];
44
64
  /** Whether the repository is public or private (an operator's setting, read locally, never looked up). */
45
65
  export const REPO_VISIBILITIES = ['public', 'private', 'unknown'];
66
+ /** Where a session ran, as a trusted machine states it for a batch it admitted: the five host classes an enrollment knows (`HOST_CLASSES` in `machine.ts`, not imported: the pool bundles this file). */
67
+ export const CLAIMED_HOST_CLASSES = ['local', 'vps', 'cloud', 'ci', 'hosted'];
68
+ /** What started the session, as an enrollment declares it. */
69
+ export const CLAIMED_LAUNCHERS = ['interactive', 'orchestrated', 'scheduled', 'ci', 'hosted-run', 'unknown'];
46
70
  export const NO_COVERAGE = { orgSize: 'unknown', team: 'unknown', repoVisibility: 'unknown' };
47
71
  /** A repo key is the store-salted hash a session record holds of its repository's identity: sixteen lowercase hex characters. */
48
72
  const REPO_KEY = /^[0-9a-f]{16}$/;
@@ -53,6 +77,8 @@ export const hourOf = (ts) => {
53
77
  const millis = Date.parse(ts);
54
78
  return Number.isNaN(millis) ? null : new Date(millis).getUTCHours();
55
79
  };
80
+ /** Whether a recommendation reached the asker (`shown`) or was withheld by the holdback (`held`). The local row's arm, as the closed list holds it. */
81
+ export const RECOMMENDATION_ARMS = ['shown', 'held'];
56
82
  /** A usage category's `tokens` bucket, or null when the record says the source never recorded it. A record that predates `usageObserved` reads every category as observed. */
57
83
  const observedBucket = (value, observed, field) => (observed === undefined || observed.includes(field) ? bucketOf('tokens', value) : null);
58
84
  /** W's minimum for a timing print (deposit-weighting spec, layer 5): fewer timed calls than this say too little to tell sessions apart. */
@@ -60,12 +86,24 @@ export const MIN_TIMED_CALLS = 50;
60
86
  /** A session has ended once no transcript of it has gained a line for this long (`session_idle_hours`, spec Section 7). */
61
87
  export const SESSION_IDLE_HOURS = 6;
62
88
  const HOUR_MS = 60 * 60 * 1000;
89
+ /** No session leaves this machine sooner than this after it ends, whatever `session_idle_hours` is: time for `oars sessions deposit --deny` or a delete to act on it first. Today the longer idle window governs; this is the floor under it. */
90
+ export const HOLD_WINDOW_MS = HOUR_MS;
63
91
  /**
64
- * Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript gains a line when capture sees it change, which
65
- * stamps a new `rev` in epoch milliseconds, but a transcript can grow after the last capture, so `written` (the newest write to any raw file of the session, 0 when none is known) counts too: the
66
- * session was last active at the later of the two. A subagent that died without an end marker holds its session back at most that long.
92
+ * Whether a session's batch may be built at `now`: no transcript of it, main or subagent, has gained a line for `SESSION_IDLE_HOURS`. A transcript was last active at its own last line (`tsLast`, the
93
+ * session's clock, not ours), or at its capture (`rev`, epoch milliseconds) when it has none, so a store captured a minute ago from days-old transcripts holds sessions that ended days ago. A transcript can
94
+ * also grow after the last capture, so `written` (the newest write to any raw file of the session, 0 when none is known) counts when it is later than every capture: a write at or before the capture is the
95
+ * file arriving, not the session going on. A last line later than the capture is a bad clock, and the capture caps it. A person's build (`sender` false) takes the ended session at once. The sender, which builds and sends in one run, also waits `HOLD_WINDOW_MS` after the newest capture, so a session oars is seeing for the first time (a days-old transcript of a new harness) leaves no sooner than an hour later, time for `--deny` or a delete. A subagent that died without an end marker holds its session back at most that long.
67
96
  */
68
- export const hasEnded = (records, now, written) => now - Math.max(written, ...records.transcripts.map(({ session }) => session.rev)) >= SESSION_IDLE_HOURS * HOUR_MS;
97
+ export const hasEnded = (records, now, written, idleMs = SESSION_IDLE_HOURS * HOUR_MS, sender = false) => {
98
+ const lastLines = records.transcripts.map(({ session }) => {
99
+ const ts = session.tsLast === null ? Number.NaN : Date.parse(session.tsLast);
100
+ return Number.isNaN(ts) ? session.rev : Math.min(ts, session.rev);
101
+ });
102
+ const captured = Math.max(...records.transcripts.map(({ session }) => session.rev));
103
+ if (sender && now - captured < HOLD_WINDOW_MS)
104
+ return false;
105
+ return now - Math.max(written > captured ? written : 0, ...lastLines) >= Math.max(idleMs, HOLD_WINDOW_MS);
106
+ };
69
107
  const groupBy = (items, keyOf) => {
70
108
  const groups = new Map();
71
109
  for (const item of items) {
@@ -102,14 +140,16 @@ export const isWirePullName = (name) => name.length <= NAME_MAX && WIRE_PULL_NAM
102
140
  export const OPEN_GATE = { redact: (text) => text, terms: [] };
103
141
  /** The redactor leaves the name and each of its underscore-delimited parts as they are: `_` is a word character to it, so a host inside `mcp__git.acme.internal__run` is invisible to a pass over the whole name. */
104
142
  const unchanged = (gate, name) => gate.redact(name) === name && name.split(/_+/).every((part) => gate.redact(part) === part);
143
+ /** Whether a name clears the one gate: it has the name shape, the redactor leaves it as it is, and it holds none of the exclusion terms. A name token that is really the word `private` passes; only this says a name was refused. */
144
+ const passesGate = (gate, name, hasShape) => {
145
+ const lower = name.toLowerCase();
146
+ return hasShape(name) && unchanged(gate, name) && !gate.terms.some((term) => term !== '' && lower.includes(term.toLowerCase()));
147
+ };
105
148
  /**
106
149
  * A name as the wire holds it: the value itself, or `private` when it counts as redacted. A value counts as redacted when the redactor changes it, it holds an exclusion term, it fails the name shape, or it is over
107
150
  * `NAME_MAX` characters, because a secret, an employer's name, or a mis-parsed command is not a name and nothing downstream can take it back. `keys` (t870) sends its names through this same function.
108
151
  */
109
- export const wireName = (gate, name, hasShape = isWireName) => {
110
- const lower = name.toLowerCase();
111
- return hasShape(name) && unchanged(gate, name) && !gate.terms.some((term) => term !== '' && lower.includes(term.toLowerCase())) ? name : PRIVATE;
112
- };
152
+ export const wireName = (gate, name, hasShape = isWireName) => (passesGate(gate, name, hasShape) ? name : PRIVATE);
113
153
  const FLAG_DASHES = /^-{1,2}/;
114
154
  const SHORT_FLAG = /^-[A-Za-z]$/;
115
155
  // The long form has its own shape, with no `:` (the joiner `WIRE_NAME` allows for `scope:name`), because `--password:hunter2` is a value joined to its flag and no part of it is a name.
@@ -127,7 +167,7 @@ const wireFlag = (gate, flag) => (isWireFlag(flag) && wireName(gate, flag.replac
127
167
  * A call's key names as the wire holds them (t870): names only, never a value. A public tool (one with a pinned input list) sends the names on its list and counts the rest; any other tool sends a name only when it passes the
128
168
  * name gate, and a tool that went as `private` sends none, so a private tool's keys name no private work. `dropped` counts the distinct names not sent.
129
169
  */
130
- const wireKeys = (stored, native, gate) => {
170
+ export const wireKeys = (stored, native, gate) => {
131
171
  const distinct = [...new Set(stored)];
132
172
  const listed = native === undefined ? undefined : TOOL_INPUT_KEYS.get(native);
133
173
  const kept = native === undefined || native === PRIVATE ? [] : distinct.filter((name) => (listed === undefined ? wireName(gate, name) !== PRIVATE : listed.has(name)));
@@ -135,6 +175,65 @@ const wireKeys = (stored, native, gate) => {
135
175
  };
136
176
  /** A stored closed-list member, else dropped: a hand-edited record never puts a value off the list on the wire. */
137
177
  const memberOf = (list, value) => list.find((entry) => entry === value);
178
+ /** Whether a closed value is one the wire may carry for a tool's key: a boolean's two words, or a member of the tool's list. */
179
+ export const isClosedValue = (native, key, value) => value === 'true' || value === 'false' || (native !== undefined && CLOSED_ARG_VALUES.get(native)?.get(key)?.has(value) === true);
180
+ /**
181
+ * A call's value features as the wire sends them (layer 1): only a sent key's, and only closed-list members, so a hand-edited record never puts a value on the wire. An entry whose size is off its list is dropped whole, a
182
+ * path or pattern field off its list is left out, and a pattern holding no listed feature sends none.
183
+ */
184
+ export const wireFeatures = (stored, sent) => (stored ?? []).flatMap((feature) => {
185
+ const valueSize = memberOf(VALUE_SIZES, feature.valueSize);
186
+ if (!sent.has(feature.key) || valueSize === undefined)
187
+ return [];
188
+ const pathDepth = memberOf(PATH_DEPTHS, feature.pathDepth);
189
+ const extension = memberOf(EXTENSION_CLASSES, feature.extension);
190
+ const dirKind = memberOf(DIRECTORY_KINDS, feature.dirKind);
191
+ const patternFeatures = PATTERN_FEATURES.filter((entry) => feature.patternFeatures?.includes(entry) === true);
192
+ return [{ key: feature.key, valueSize, ...(pathDepth === undefined ? {} : { pathDepth }), ...(extension === undefined ? {} : { extension }), ...(dirKind === undefined ? {} : { dirKind }), ...(patternFeatures.length === 0 ? {} : { patternFeatures }) }];
193
+ });
194
+ /** A call's closed values as the wire sends them (layer 1): only a sent key's, and only a value `isClosedValue` admits. */
195
+ export const wireValues = (stored, sent, native) => (stored ?? []).filter(({ key, enumValue }) => sent.has(key) && isClosedValue(native, key, enumValue)).map(({ key, enumValue }) => ({ key, enumValue }));
196
+ /** A call's change kinds as the wire sends them: the closed-list members, in the list's order. */
197
+ export const wireChange = (stored) => CHANGE_KINDS.filter((kind) => stored?.includes(kind) === true);
198
+ /** A call's diff shapes as the wire sends them: the closed-list members, in the list's order. */
199
+ export const wireDiffShape = (stored) => DIFF_SHAPES.filter((shape) => stored?.includes(shape) === true);
200
+ /**
201
+ * A call's name tokens as the wire sends them (layer 2): every stored token whose kind is on the closed list, through the one gate (`wireName`): a token the operator's exclusion terms or the redactor's secret shapes match, or that has no name shape,
202
+ * is dropped and counted in `dropped`. No floor, no threshold: a name is sent as it is.
203
+ */
204
+ export const wireNames = (stored, gate) => {
205
+ const known = (stored ?? []).filter(({ kind }) => NAME_KINDS.includes(kind));
206
+ const names = known.filter(({ name }) => passesGate(gate, name, isWireName));
207
+ return { names, dropped: known.length - names.length };
208
+ };
209
+ /** A digest as capture writes it: the first 32 hex digits of a SHA-256, lowercase. */
210
+ const DIGEST = /^[0-9a-f]{32}$/;
211
+ /** A call's edit digests as the wire sends them: a pair whose digest is not that shape is dropped whole, so a corrupt store row costs the pair and never the deposit. */
212
+ export const wireDigests = (stored) => (stored ?? []).filter((pair) => DIGEST.test(pair.new) && (pair.old === undefined || DIGEST.test(pair.old))).map((pair) => (pair.old === undefined ? { new: pair.new } : { old: pair.old, new: pair.new }));
213
+ const TEMPLATE_SLOT_LIST = TEMPLATE_SLOTS;
214
+ /**
215
+ * Whether every word of a template is one the wire may carry: a slot of `TEMPLATE_SLOTS`, the `--` marker, a flag with a slot joined to it, a flag, or a name (the program or subcommand), the last two as the caller judges them. A template is a single line of words
216
+ * set apart by one space, since no name or flag holds a space.
217
+ */
218
+ const templateHolds = (template, name, flag) => template.split(' ').every((word) => {
219
+ if (TEMPLATE_SLOT_LIST.includes(word) || word === '--')
220
+ return true;
221
+ if (!word.startsWith('-'))
222
+ return name(word);
223
+ const joined = word.indexOf('=');
224
+ return joined < 0 ? flag(word) : flag(word.slice(0, joined)) && TEMPLATE_SLOT_LIST.includes(word.slice(joined + 1));
225
+ });
226
+ /** Whether a received template has the shape the wire sends: the pool's check, which holds no operator terms and no redactor. */
227
+ export const isWireTemplate = (template) => templateHolds(template, isWireName, isWireFlag);
228
+ /**
229
+ * A call's command templates as the wire sends them (layer 3): every stored template whose kept words (program, subcommand, flags) all clear the one gate, as a name or a flag does. A template with a word the redactor changes, an exclusion term matches, or that
230
+ * has no name shape is dropped whole and counted in `dropped`. No floor, no threshold: a template is sent as it is, and a literal is only ever a slot.
231
+ */
232
+ export const wireTemplates = (stored, gate) => {
233
+ const known = stored ?? [];
234
+ const templates = known.filter((template) => templateHolds(template, (word) => wireName(gate, word) !== PRIVATE, (flag) => wireFlag(gate, flag) !== PRIVATE));
235
+ return { templates, dropped: known.length - templates.length };
236
+ };
138
237
  /** The basename of a program as capture read it: a path never goes on the wire. */
139
238
  const basenameOf = (program) => program.replace(/^.*[\\/]/, '');
140
239
  /**
@@ -353,6 +452,14 @@ const transcriptOf = ({ session, prompts, tools, root }, spawner, spawned, agent
353
452
  const targetClass = memberOf(TARGET_CLASSES, tool.targetClass);
354
453
  const stored = tool.argClass;
355
454
  const argClass = stored === undefined ? undefined : ARG_CLASSES.filter((kind) => stored.includes(kind));
455
+ const sent = new Set(keys);
456
+ const argFeatures = wireFeatures(tool.argFeatures, sent);
457
+ const argValues = wireValues(tool.argValues, sent, steps[0]?.native);
458
+ const changeKind = wireChange(tool.changeKind);
459
+ const { names, dropped: namesDropped } = wireNames(tool.names, gate);
460
+ const digests = wireDigests(tool.digests);
461
+ const { templates, dropped: templatesDropped } = wireTemplates(tool.templates, gate);
462
+ const diffShape = wireDiffShape(tool.diffShape);
356
463
  steppedTools.push({
357
464
  side,
358
465
  prompt: promptPosition,
@@ -364,6 +471,15 @@ const transcriptOf = ({ session, prompts, tools, root }, spawner, spawned, agent
364
471
  ...(dropped === 0 ? {} : { keysDropped: bucketOf('count', dropped) }),
365
472
  ...(targetClass === undefined ? {} : { targetClass }),
366
473
  ...(argClass === undefined ? {} : { argClass: argClass.sort() }),
474
+ ...(argFeatures.length === 0 ? {} : { argFeatures }),
475
+ ...(argValues.length === 0 ? {} : { argValues }),
476
+ ...(changeKind.length === 0 ? {} : { changeKind }),
477
+ ...(names.length === 0 ? {} : { names }),
478
+ ...(namesDropped === 0 ? {} : { namesDropped: bucketOf('count', namesDropped) }),
479
+ ...(digests.length === 0 ? {} : { digests }),
480
+ ...(templates.length === 0 ? {} : { templates }),
481
+ ...(templatesDropped === 0 ? {} : { templatesDropped: bucketOf('count', templatesDropped) }),
482
+ ...(diffShape.length === 0 ? {} : { diffShape }),
367
483
  steps,
368
484
  });
369
485
  }
@@ -390,11 +506,18 @@ const transcriptOf = ({ session, prompts, tools, root }, spawner, spawned, agent
390
506
  }
391
507
  return { merkleRoot: root.merkleRoot, leaves: root.leaves, harness, frame: transcriptFrame, frames, spawnedIn: spawner?.index ?? null, prompts: framedPrompts, tools: steppedTools };
392
508
  };
509
+ /**
510
+ * The recommendations of one session, from the local rows after `deriveConsequences` has given each its window. Only a row that showed or withheld something is carried: a miss recommended nothing, so it is neither shown nor held, and
511
+ * a row with no window has no calls to place. They go in window order, then by id, so one store builds one batch.
512
+ */
513
+ export const recommendationsOf = (recalls, session) => recalls
514
+ .flatMap(({ sid, id, arm, poolVersion, answer, consequence }) => (sid === session && answer.kind === 'hit' && consequence !== undefined ? [{ id, arm, poolVersion, from: consequence.window.from, to: consequence.window.to }] : []))
515
+ .sort((a, b) => a.from - b.from || a.to - b.to || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
393
516
  /**
394
517
  * The skeleton of a session; null when any record of it was captured before steps and frames, or a transcript of it has no root, which holds no closed reading to send. The batch holds the main
395
518
  * transcript's run first, then each subagent's as its own run; its `id` is the main session's, and its one `timingPrint` is over the main transcript's calls alone.
396
519
  */
397
- export const skeletonOf = (records, id, read, print, deltas = [], gate = OPEN_GATE, coverage = NO_COVERAGE) => {
520
+ export const skeletonOf = (records, id, read, print, deltas = [], gate = OPEN_GATE, coverage = NO_COVERAGE, recommendations = []) => {
398
521
  const mainTools = byCall(records.transcripts[0]?.tools ?? []);
399
522
  const places = records.transcripts.map(({ tools }) => placesOf(tools));
400
523
  const spawners = places[0]?.calls ?? new Map();
@@ -433,7 +556,7 @@ export const skeletonOf = (records, id, read, print, deltas = [], gate = OPEN_GA
433
556
  transcripts.push(transcript);
434
557
  }
435
558
  const durations = mainTools.flatMap(({ durationMs }) => (durationMs === null ? [] : [durationMs]));
436
- return { format: SKELETON_FORMAT, vocabVersion: VOCAB_VERSION, fieldsVersion: FIELD_LIST_VERSION, id, transcripts, timingPrint: durations.length < MIN_TIMED_CALLS ? null : print(durations), deltas: [...deltas] };
559
+ return { format: SKELETON_FORMAT, vocabVersion: VOCAB_VERSION, fieldsVersion: FIELD_LIST_VERSION, id, transcripts, timingPrint: durations.length < MIN_TIMED_CALLS ? null : print(durations), deltas: [...deltas], ...(recommendations.length === 0 ? {} : { recommendations: recommendations.map((entry) => ({ ...entry })) }) };
437
560
  };
438
561
  /**
439
562
  * The version bucket of a session that recorded none: Cursor, Gemini, and Grok Build keep no harness version in their local files, and neither does a VS Code Copilot Chat session whose requests carry no `agent.extensionVersion`, so their cells are
@@ -475,18 +598,20 @@ const unknownValue = (held, at, sent) => {
475
598
  /** The fields a client may send a literal beside (`<field>Literal`) when the field itself says `other`: the enumerated fields, whose values a closed list holds. */
476
599
  export const ENUMERATED_FIELDS = new Set(['harness', 'harnessVersion', 'model', 'effort', 'platform', 'shell', 'size', 'languages', 'frameworks', 'language', 'languageShare', 'cls', 'status', 'action', 'native', 'agentType', 'program', 'sub', 'flags', 'registry', 'name', 'version', 'server', 'tool', 'skill', 'host', 'deltaKind', 'field', 'metric', 'intervention']);
477
600
  /**
478
- * The fields that may be absent at format 3, by the level of the batch that holds them. An absent one reads as null, or as empty for a list, exactly as a sent null does, so a batch with all of them left out is
601
+ * The fields that may be absent at formats 3, 4, and 5 (a format 4 batch reads `OPTIONAL.tool` less `FORMAT_5_TOOL_FIELDS`, and a format 3 batch also less `FORMAT_4_TOOL_FIELDS`), by the level of the batch that holds them. An absent one reads as null, or as empty for a list, exactly as a sent null does, so a batch with all of them left out is
479
602
  * served from what remains. A field the cells need to be sound (a harness, a status, a bucket of tokens) is never here.
480
603
  */
481
604
  export const OPTIONAL = {
482
- batch: ['timingPrint', 'deltas'],
605
+ batch: ['timingPrint', 'deltas', 'claimed', 'recommendations'],
483
606
  frame: ['harnessVersion', 'model', 'effort', 'day', 'shell', 'reach', 'repoKey', 'orgSize', 'team', 'repoVisibility', 'hour'],
484
607
  stack: ['size', 'languageShares'],
485
608
  prompt: ['wallSec', 'explore', 'usageResponses', 'intervention'],
486
- tool: ['prompt', 'durationMs', 'errorCode', 'recoveryCalls', 'keys', 'keysDropped', 'targetClass', 'argClass'],
609
+ tool: ['prompt', 'durationMs', 'errorCode', 'recoveryCalls', 'keys', 'keysDropped', 'targetClass', 'argClass', 'argFeatures', 'argValues', 'changeKind', 'names', 'namesDropped', 'templates', 'templatesDropped', 'diffShape', 'digests'],
487
610
  step: ['tokens', 'ms', 'allocated', 'parent', 'skill', 'edges', 'remainingTokens'],
488
611
  transcript: ['spawnedIn'],
489
612
  };
613
+ /** The fields of a value feature that only a path or a pattern holds: absent, never null, since a feature of another kind of value has no such field. */
614
+ const FEATURE_OPTIONAL = ['pathDepth', 'extension', 'dirKind', 'patternFeatures'];
490
615
  /** The most bytes (as JSON) one field this reader does not know, or one literal, may hold: an enumerated value or a literal never needs more, and a newer client's larger structure belongs in a new format version. The deposit's own cap is no part of this. */
491
616
  export const MAX_EXTENSION_BYTES = 8 * 1024;
492
617
  const LITERAL_SUFFIX = 'Literal';
@@ -589,8 +714,8 @@ const allHarnesses = (list) => list;
589
714
  export const HARNESSES = allHarnesses(['claude-code', 'cursor', 'codex', 'copilot', 'gemini', 'grok', 'opencode', 'pi', 'vscode-copilot', 'otel']);
590
715
  const PROMPT_CLASSES = ['no-tools', 'orchestration', 'research', 'edit-verify', 'edit-only', 'explore-only', 'shell-only', 'mixed'];
591
716
  const STATUSES = ['ok', 'error', 'none'];
592
- const PLATFORMS = new Set(['aix', 'android', 'cygwin', 'darwin', 'freebsd', 'haiku', 'linux', 'netbsd', 'openbsd', 'sunos', 'win32']);
593
- const SHELLS = new Set(Object.values(ACTION_TABLES).flatMap((table) => Object.values(table.shells)));
717
+ export const PLATFORMS = new Set(['aix', 'android', 'cygwin', 'darwin', 'freebsd', 'haiku', 'linux', 'netbsd', 'openbsd', 'sunos', 'win32']);
718
+ export const SHELLS = new Set(Object.values(ACTION_TABLES).flatMap((table) => Object.values(table.shells)));
594
719
  /** The registries a package comes from; a pull may also come from a skill, plugin, or MCP registry. */
595
720
  export const PACKAGE_REGISTRIES = ['npm', 'pypi', 'crates', 'go', 'rubygems', 'homebrew'];
596
721
  const NO_NAME = new Set();
@@ -624,6 +749,11 @@ const parseReach = (held, value, at) => {
624
749
  throw new Refused(at, 'an unfilled reach lists nothing');
625
750
  return reach;
626
751
  };
752
+ /** A claim, each member held to its closed list. */
753
+ const parseClaimed = (held, value, at) => {
754
+ const raw = fieldsOf(held, value, at, ['hostClass', 'launcher']);
755
+ return { hostClass: oneOf(held, CLAIMED_HOST_CLASSES, raw.hostClass, under(at, 'hostClass')), launcher: oneOf(held, CLAIMED_LAUNCHERS, raw.launcher, under(at, 'launcher')) };
756
+ };
627
757
  /** Sixteen lowercase hex characters and nothing else, so no path or remote text can ride in a repo key. */
628
758
  const parseRepoKey = (value, at) => {
629
759
  const text = textOf(value, at);
@@ -752,7 +882,7 @@ const parseIdentity = (held, harness, action, value, at) => {
752
882
  throw new Refused(at, 'this action has no identity');
753
883
  };
754
884
  /** Whether the harness's table names this native, so it can cross as itself; anything else (including the literal) crosses as `private`. */
755
- const admittedNative = (harness, native) => {
885
+ export const admittedNative = (harness, native) => {
756
886
  if (native === PRIVATE)
757
887
  return true;
758
888
  if (harness === OTHER)
@@ -832,8 +962,69 @@ const parseKeys = (held, native, value, at) => {
832
962
  return (listed === undefined ? isWireName(name) : listed.has(name)) ? name : unknownValue(held, entryAt, name);
833
963
  });
834
964
  };
965
+ /** A sent key's entry names a key of the call's own `keys`, so a feature or a value never names a key the call did not send. */
966
+ const sentKey = (held, keys, value, at) => {
967
+ const key = textOf(value, at);
968
+ const index = keys.sent.indexOf(key);
969
+ if (index < 0)
970
+ throw new Refused(at, 'not a key the call sent');
971
+ return keys.read[index] === OTHER ? unknownValue(held, at, key) : key;
972
+ };
973
+ const parseFeature = (held, keys, value, at) => {
974
+ const raw = fieldsOf(held, value, at, ['key', 'valueSize'], FEATURE_OPTIONAL);
975
+ const pathDepth = raw.pathDepth === undefined ? undefined : oneOf(held, PATH_DEPTHS, raw.pathDepth, under(at, 'pathDepth'));
976
+ const extension = raw.extension === undefined ? undefined : oneOf(held, EXTENSION_CLASSES, raw.extension, under(at, 'extension'));
977
+ const dirKind = raw.dirKind === undefined ? undefined : oneOf(held, DIRECTORY_KINDS, raw.dirKind, under(at, 'dirKind'));
978
+ const patternFeatures = raw.patternFeatures === undefined ? undefined : listOf(raw.patternFeatures, under(at, 'patternFeatures'), (entry, entryAt) => oneOf(held, PATTERN_FEATURES, entry, entryAt));
979
+ return {
980
+ key: sentKey(held, keys, raw.key, under(at, 'key')),
981
+ valueSize: oneOf(held, VALUE_SIZES, raw.valueSize, under(at, 'valueSize')),
982
+ ...(pathDepth === undefined ? {} : { pathDepth }),
983
+ ...(extension === undefined ? {} : { extension }),
984
+ ...(dirKind === undefined ? {} : { dirKind }),
985
+ ...(patternFeatures === undefined ? {} : { patternFeatures }),
986
+ };
987
+ };
988
+ /** A closed value this reader does not hold for the key reads as `other` and goes to the bag, as any value off a list does. */
989
+ const parseValue = (held, keys, native, value, at) => {
990
+ const raw = fieldsOf(held, value, at, ['key', 'enumValue']);
991
+ const key = sentKey(held, keys, raw.key, under(at, 'key'));
992
+ const enumValue = textOf(raw.enumValue, under(at, 'enumValue'));
993
+ return { key, enumValue: key !== OTHER && isClosedValue(native, key, enumValue) ? enumValue : unknownValue(held, under(at, 'enumValue'), enumValue) };
994
+ };
995
+ /** The tool fields a format 5 or 6 batch may hold: format 7's are fields it does not know. */
996
+ const FORMAT_6_TOOL = OPTIONAL.tool.filter((name) => !FORMAT_7_TOOL_FIELDS.some((added) => added === name));
997
+ /** The tool fields a format 4 batch may hold: format 5's are fields it does not know. */
998
+ const FORMAT_4_TOOL = FORMAT_6_TOOL.filter((name) => !FORMAT_5_TOOL_FIELDS.some((added) => added === name));
999
+ /** The tool fields a format 3 batch may hold: format 4's three are fields it does not know too. */
1000
+ const FORMAT_3_TOOL = FORMAT_4_TOOL.filter((name) => !FORMAT_4_TOOL_FIELDS.some((added) => added === name));
1001
+ /** A name token: its kind off the closed list, and a name that has the shape of one. Either off its list or shape reads as `other` and goes to the bag, as any value off a list does. */
1002
+ const parseName = (held, value, at) => {
1003
+ const raw = fieldsOf(held, value, at, ['kind', 'name']);
1004
+ const name = textOf(raw.name, under(at, 'name'));
1005
+ return { kind: oneOf(held, NAME_KINDS, raw.kind, under(at, 'kind')), name: isWireName(name) ? name : unknownValue(held, under(at, 'name'), name) };
1006
+ };
1007
+ /** A digest is the shape capture writes, or the batch is refused: unlike a name, no newer client's value is a digest of another shape, and the field holds only what a hash can. */
1008
+ const digestOf = (value, at) => {
1009
+ const digest = textOf(value, at);
1010
+ if (!DIGEST.test(digest))
1011
+ throw new Refused(at, 'not a digest');
1012
+ return digest;
1013
+ };
1014
+ const parseDigest = (held, value, at) => {
1015
+ const raw = fieldsOf(held, value, at, ['new'], ['old']);
1016
+ const old = raw.old === undefined ? undefined : digestOf(raw.old, under(at, 'old'));
1017
+ return { ...(old === undefined ? {} : { old }), new: digestOf(raw.new, under(at, 'new')) };
1018
+ };
1019
+ const parseTemplate = (held, value, at) => {
1020
+ const template = textOf(value, at);
1021
+ return isWireTemplate(template) ? template : unknownValue(held, at, template);
1022
+ };
835
1023
  const parseTool = (held, harness, value, at) => {
836
- const raw = fieldsOf(held, value, at, ['side', 'status', 'steps'], OPTIONAL.tool);
1024
+ const layered = held.format >= 4;
1025
+ const named = held.format >= 5;
1026
+ const digested = held.format >= DIGEST_FORMAT;
1027
+ const raw = fieldsOf(held, value, at, ['side', 'status', 'steps'], digested ? OPTIONAL.tool : named ? FORMAT_6_TOOL : layered ? FORMAT_4_TOOL : FORMAT_3_TOOL);
837
1028
  const status = oneOf(held, STATUSES, raw.status, under(at, 'status'));
838
1029
  const errorCode = parseErrorCode(held, status, raw.errorCode, under(at, 'errorCode'));
839
1030
  const recoveryCalls = parseRecovery(held, status, raw.recoveryCalls, under(at, 'recoveryCalls'));
@@ -842,6 +1033,16 @@ const parseTool = (held, harness, value, at) => {
842
1033
  const keysDropped = raw.keysDropped === undefined ? undefined : bucketIn('count', raw.keysDropped, under(at, 'keysDropped'));
843
1034
  const targetClass = raw.targetClass === undefined ? undefined : oneOf(held, TARGET_CLASSES, raw.targetClass, under(at, 'targetClass'));
844
1035
  const argClass = raw.argClass === undefined ? undefined : listOf(raw.argClass, under(at, 'argClass'), (entry, entryAt) => oneOf(held, ARG_CLASSES, entry, entryAt));
1036
+ const sentKeys = { sent: Array.isArray(raw.keys) ? raw.keys : [], read: keys ?? [] };
1037
+ const argFeatures = !layered || raw.argFeatures === undefined ? undefined : listOf(raw.argFeatures, under(at, 'argFeatures'), (entry, entryAt) => parseFeature(held, sentKeys, entry, entryAt));
1038
+ const argValues = !layered || raw.argValues === undefined ? undefined : listOf(raw.argValues, under(at, 'argValues'), (entry, entryAt) => parseValue(held, sentKeys, steps[0]?.native, entry, entryAt));
1039
+ const changeKind = !layered || raw.changeKind === undefined ? undefined : listOf(raw.changeKind, under(at, 'changeKind'), (entry, entryAt) => oneOf(held, CHANGE_KINDS, entry, entryAt));
1040
+ const names = !named || raw.names === undefined ? undefined : listOf(raw.names, under(at, 'names'), (entry, entryAt) => parseName(held, entry, entryAt));
1041
+ const namesDropped = !named || raw.namesDropped === undefined ? undefined : bucketIn('count', raw.namesDropped, under(at, 'namesDropped'));
1042
+ const digests = !digested || raw.digests === undefined ? undefined : listOf(raw.digests, under(at, 'digests'), (entry, entryAt) => parseDigest(held, entry, entryAt));
1043
+ const templates = !named || raw.templates === undefined ? undefined : listOf(raw.templates, under(at, 'templates'), (entry, entryAt) => parseTemplate(held, entry, entryAt));
1044
+ const templatesDropped = !named || raw.templatesDropped === undefined ? undefined : bucketIn('count', raw.templatesDropped, under(at, 'templatesDropped'));
1045
+ const diffShape = !named || raw.diffShape === undefined ? undefined : listOf(raw.diffShape, under(at, 'diffShape'), (entry, entryAt) => oneOf(held, DIFF_SHAPES, entry, entryAt));
845
1046
  return {
846
1047
  side: booleanOf(raw.side, under(at, 'side')),
847
1048
  prompt: orNull(raw.prompt, under(at, 'prompt'), countOf),
@@ -853,6 +1054,15 @@ const parseTool = (held, harness, value, at) => {
853
1054
  ...(keysDropped === undefined ? {} : { keysDropped }),
854
1055
  ...(targetClass === undefined ? {} : { targetClass }),
855
1056
  ...(argClass === undefined ? {} : { argClass }),
1057
+ ...(argFeatures === undefined ? {} : { argFeatures }),
1058
+ ...(argValues === undefined ? {} : { argValues }),
1059
+ ...(changeKind === undefined ? {} : { changeKind }),
1060
+ ...(names === undefined ? {} : { names }),
1061
+ ...(namesDropped === undefined ? {} : { namesDropped }),
1062
+ ...(digests === undefined ? {} : { digests }),
1063
+ ...(templates === undefined ? {} : { templates }),
1064
+ ...(templatesDropped === undefined ? {} : { templatesDropped }),
1065
+ ...(diffShape === undefined ? {} : { diffShape }),
856
1066
  steps,
857
1067
  };
858
1068
  };
@@ -890,9 +1100,9 @@ const parseTranscript = (held, value, at) => {
890
1100
  const spawnedIn = orNull(raw.spawnedIn, under(at, 'spawnedIn'), countOf);
891
1101
  const prompts = listOf(raw.prompts, under(at, 'prompts'), (entry, entryAt) => parsePrompt(held, entry, entryAt));
892
1102
  const tools = listOf(raw.tools, under(at, 'tools'), (entry, entryAt) => parseTool(held, harness, entry, entryAt));
893
- // What followed a call is read off the calls themselves, so a list that disagrees with them is a wrong deposit, not a new reading.
1103
+ // What followed a call is read off the calls themselves, so a list that disagrees with them is a wrong deposit, not a new reading. A batch of a format before 6 was built when a failed edit still counted, so it is checked with that rule.
894
1104
  tools.forEach((tool, index) => {
895
- if (tool.recoveryCalls !== undefined && JSON.stringify(tool.recoveryCalls) !== JSON.stringify(recoveryCallsOf(tools, index)))
1105
+ if (tool.recoveryCalls !== undefined && JSON.stringify(tool.recoveryCalls) !== JSON.stringify(recoveryCallsOf(tools, index, held.format < EXPOSURE_FORMAT)))
896
1106
  throw new Refused(`${at}.tools[${String(index)}].recoveryCalls`, 'not what followed the call');
897
1107
  });
898
1108
  return { merkleRoot, leaves, harness, frame, frames, spawnedIn, prompts, tools };
@@ -936,16 +1146,39 @@ const parseDelta = (held, value, at) => {
936
1146
  return { deltaKind, field, name, registry, metric, change };
937
1147
  };
938
1148
  const VOCAB_TEXT = /^[0-9A-Za-z._-]{1,32}$/;
1149
+ /** The deposit time of the pool a recommendation answered from: an instant, or a version word. */
1150
+ const POOL_VERSION = /^[0-9A-Za-z:.+_-]{1,40}$/;
1151
+ /** The batch fields a batch of format 5 or older may hold: format 6's are fields it does not know. */
1152
+ const FORMAT_5_BATCH = OPTIONAL.batch.filter((name) => !FORMAT_6_BATCH_FIELDS.some((added) => added === name));
1153
+ /** One recommendation: a hash id, its arm off the closed list, the version it answered from, and a window that is two counts in order. Past-the-calls is checked once the transcripts are known. */
1154
+ const parseRecommendation = (held, value, at) => {
1155
+ const raw = fieldsOf(held, value, at, ['id', 'arm', 'poolVersion', 'from', 'to']);
1156
+ const id = textOf(raw.id, under(at, 'id'));
1157
+ if (!ID.test(id))
1158
+ throw new Refused(under(at, 'id'), 'not a hash');
1159
+ const poolVersion = textOf(raw.poolVersion, under(at, 'poolVersion'));
1160
+ if (!POOL_VERSION.test(poolVersion))
1161
+ throw new Refused(under(at, 'poolVersion'), 'not a version');
1162
+ const from = countOf(raw.from, under(at, 'from'));
1163
+ const to = countOf(raw.to, under(at, 'to'));
1164
+ if (to < from)
1165
+ throw new Refused(under(at, 'to'), 'ends before it starts');
1166
+ return { id, arm: oneOf(held, RECOMMENDATION_ARMS, raw.arm, under(at, 'arm')), poolVersion, from, to };
1167
+ };
939
1168
  /** The skeleton in `value`, rebuilt from the fields read, with the bag of what was set aside; else the first field that is a shape error, and why. */
940
1169
  export const parseSkeleton = (value) => {
941
- const held = { extensions: [], others: 0 };
1170
+ const held = { extensions: [], others: 0, format: SKELETON_FORMAT };
942
1171
  try {
943
1172
  // The earlier forms are named, since their fields would otherwise read as a list of strangers; any other format is a form this reader does not know.
944
- if (isFields(value) && typeof value.format === 'number' && value.format < SKELETON_FORMAT)
1173
+ if (isFields(value) && typeof value.format === 'number' && value.format < READ_FORMATS[0])
945
1174
  throw new Refused('format', `the format ${String(value.format)} form is no longer read; send format ${String(SKELETON_FORMAT)}`);
946
- const raw = fieldsOf(held, value, '', ['format', 'vocabVersion', 'fieldsVersion', 'id', 'transcripts'], OPTIONAL.batch);
947
- if (raw.format !== SKELETON_FORMAT)
1175
+ // The format is read first, since which batch fields it defines depends on it.
1176
+ const declared = isFields(value) ? READ_FORMATS.find((known) => known === value.format) : undefined;
1177
+ const raw = fieldsOf(held, value, '', ['format', 'vocabVersion', 'fieldsVersion', 'id', 'transcripts'], declared !== undefined && declared < EXPOSURE_FORMAT ? FORMAT_5_BATCH : OPTIONAL.batch);
1178
+ const format = READ_FORMATS.find((known) => known === raw.format);
1179
+ if (format === undefined)
948
1180
  throw new Refused('format', 'not a format this version reads');
1181
+ held.format = format;
949
1182
  const vocabVersion = textOf(raw.vocabVersion, 'vocabVersion');
950
1183
  if (!VOCAB_TEXT.test(vocabVersion))
951
1184
  throw new Refused('vocabVersion', 'not a version');
@@ -957,6 +1190,13 @@ export const parseSkeleton = (value) => {
957
1190
  const transcripts = listOf(raw.transcripts, 'transcripts', (entry, entryAt) => parseTranscript(held, entry, entryAt));
958
1191
  if (transcripts.length === 0)
959
1192
  throw new Refused('transcripts', 'empty');
1193
+ const recommendations = format < EXPOSURE_FORMAT ? [] : listOrEmpty(raw.recommendations, 'recommendations', (entry, entryAt) => parseRecommendation(held, entry, entryAt));
1194
+ // Positions are the only ordinals on the wire, so a window must lie within the main run's own calls: the ones that are not sidechain, which is the line the windows were counted on.
1195
+ const mainCalls = (transcripts[0]?.tools ?? []).filter(({ side }) => !side).length;
1196
+ recommendations.forEach(({ to }, index) => {
1197
+ if (to > mainCalls)
1198
+ throw new Refused(`recommendations[${String(index)}].to`, 'past the main run\'s calls');
1199
+ });
960
1200
  // A counting batch is what lifts the fetch cap, so a deposit has to hold something to derive from.
961
1201
  if (transcripts.every(({ prompts, tools }) => prompts.length === 0 && tools.length === 0))
962
1202
  throw new Refused('transcripts', 'holds no prompt and no tool');
@@ -1035,7 +1275,8 @@ export const parseSkeleton = (value) => {
1035
1275
  before += steps.length;
1036
1276
  });
1037
1277
  });
1038
- return { ok: true, skeleton: { format: SKELETON_FORMAT, vocabVersion, fieldsVersion, id, transcripts, timingPrint, deltas }, extensions: held.extensions, others: held.others };
1278
+ const claimed = raw.claimed === undefined ? {} : { claimed: parseClaimed(held, raw.claimed, 'claimed') };
1279
+ return { ok: true, skeleton: { format, vocabVersion, fieldsVersion, id, transcripts, timingPrint, deltas, ...claimed, ...(recommendations.length === 0 ? {} : { recommendations }) }, extensions: held.extensions, others: held.others };
1039
1280
  }
1040
1281
  catch (error) {
1041
1282
  if (error instanceof Refused)
@@ -1043,10 +1284,21 @@ export const parseSkeleton = (value) => {
1043
1284
  throw error;
1044
1285
  }
1045
1286
  };
1046
- const isSignedBundle = (value) => isFields(value) && typeof value.payload === 'string' && typeof value.signature === 'string';
1287
+ /** The key id of a bundle that names none, and of a sign-in file written before key ids: the pool's default `BUNDLE_KEY_ID`. */
1288
+ export const LEGACY_KEY_ID = 'k1';
1289
+ /** Key ids this release no longer trusts: the way a leaked signing key is rotated out (docs/pool.md, "Rotating the bundle signing key"). A bundle signed under one is refused even when its key is pinned. */
1290
+ export const RETIRED_KEY_IDS = new Set();
1291
+ /**
1292
+ * Public keys this release of oars ships, by key id: the only way a key id the login did not pin becomes trusted, because a server at a pool's URL must not be able to introduce a key by naming a new id (S17). Empty until the pool's first rotation
1293
+ * (docs/pool.md, "Rotating the bundle signing key"); a bundle is checked against the keys pinned at login and these, a shipped key winning under a shared id, so a server cannot shadow it with a key of its own.
1294
+ */
1295
+ export const RELEASE_KEYS = Object.freeze({});
1296
+ /** The first key id a login offers that the server's entry does not hold and no release ships, or none: such an id could only have come from the server. */
1297
+ export const unpinnedKid = (held, offered, released = RELEASE_KEYS) => Object.keys(offered).find((kid) => !Object.hasOwn(held, kid) && !Object.hasOwn(released, kid));
1298
+ const isSignedBundle = (value) => isFields(value) && typeof value.payload === 'string' && typeof value.signature === 'string' && (value.kid === undefined || typeof value.kid === 'string');
1047
1299
  const isFloor = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 1;
1048
1300
  /** The floors a bundle states, or none: a client prints what is here, so a value that is not a whole number of at least 1 is refused and not shown, while a family this client does not know is carried (in `--json`) and not printed, since `floorSentence` prints only the families this client knows (additive, section 17 rule 3). */
1049
- const isFloors = (value) => isFields(value) && isFloor(value.public) && isFloor(value.disclosure) && (value.families === undefined || (isFields(value.families) && Object.values(value.families).every(isFloor)));
1301
+ const isFloors = (value) => isFields(value) && isFloor(value.public) && isFloor(value.disclosure) && (value.clear === undefined || isFloor(value.clear)) && (value.families === undefined || (isFields(value.families) && Object.values(value.families).every(isFloor)));
1050
1302
  const isSourced = (value) => isFields(value) && ((value.state === 'value' && typeof value.value === 'number') || (value.state === 'no source yet' && typeof value.reason === 'string'));
1051
1303
  const isCount = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
1052
1304
  /** The set of four a bundle may carry: every field the `books` read prints is present and of its type, with its format, vocabulary version, and the two served lists checked as well, so a signed bundle with a malformed block reads as not a bundle, not as a crash in the printer. */
@@ -1081,12 +1333,24 @@ const signatureHolds = async ({ payload, signature }, key) => {
1081
1333
  return false;
1082
1334
  }
1083
1335
  };
1336
+ /** A key id as the pool sends them: letters, digits, dot, dash, and underscore, 1 to 32 characters. */
1337
+ export const isKeyId = (kid) => /^[A-Za-z0-9._-]{1,32}$/.test(kid);
1338
+ /** A key id for a message: the id as the pool sends them (letters, digits, dot, dash, underscore), and a stand-in for anything else, since the pool or a man in the middle chose it. */
1339
+ const keyIdName = (kid) => (isKeyId(kid) ? kid : '(unreadable key id)');
1084
1340
  /** The bundle, only when the pinned key signed these bytes for the harness and version that were asked for: another harness's or version's bundle, under a good signature, is still the wrong answer. */
1085
- export const verifyBundle = async (signed, key, harness, harnessVersion) => {
1341
+ export const verifyBundle = async (signed, keys, harness, harnessVersion, retired = RETIRED_KEY_IDS, released = RELEASE_KEYS) => {
1086
1342
  if (!isSignedBundle(signed))
1087
1343
  return { ok: false, reason: 'not a signed bundle' };
1344
+ const kid = signed.kid ?? LEGACY_KEY_ID;
1345
+ const named = keyIdName(kid);
1346
+ if (retired.has(kid))
1347
+ return { ok: false, reason: `this bundle is signed with key ${named}, which this release of oars no longer trusts. Upgrade with npm install -g @manyoars/oars` };
1348
+ const trusted = { ...keys, ...released };
1349
+ const key = Object.hasOwn(trusted, kid) ? trusted[kid] : undefined;
1350
+ if (key === undefined)
1351
+ return { ok: false, reason: `this bundle is signed with key ${named}, which is not pinned for this server and not shipped in this release of oars. Upgrade with npm install -g @manyoars/oars` };
1088
1352
  if (!(await signatureHolds(signed, key)))
1089
- return { ok: false, reason: 'the signature does not match the key pinned at login' };
1353
+ return { ok: false, reason: `the signature does not match the key ${named} pinned at login` };
1090
1354
  const bundle = JSON.parse(signed.payload);
1091
1355
  if (!isBundle(bundle))
1092
1356
  return { ok: false, reason: 'not a signed bundle this version reads' };
@@ -6,6 +6,7 @@ import { basename, dirname, join } from 'node:path';
6
6
  import { CliError, ExitCode } from '../errors.js';
7
7
  import { cellsOf, projectedStore } from './cell-increments.js';
8
8
  import { poolOf, serializeCells, UNKNOWN } from './cells.js';
9
+ import { LOAD_STATUS_FILE, loadStatusLines } from './load-status.js';
9
10
  import { shellReader } from './projection.js';
10
11
  import { contentHash, FIRST_ORDER_FAMILIES, frozenRead, makeRead, orderResolver } from './read.js';
11
12
  import { deriveConsequences, recallGraphOf } from './recall-consequence.js';
@@ -14,7 +15,7 @@ import { deriveAdoption } from './read-adoption.js';
14
15
  import { deriveCallShape } from './read-call-shape.js';
15
16
  import { deriveChange } from './read-change.js';
16
17
  import { deriveCost } from './read-cost.js';
17
- import { deriveFit } from './read-fit.js';
18
+ import { deriveFit, deriveFitShape } from './read-fit.js';
18
19
  import { deriveLive } from './read-live.js';
19
20
  import { deriveMethod } from './read-method.js';
20
21
  import { deriveNextStep } from './read-next-step.js';
@@ -30,7 +31,7 @@ import { writeAtomic } from './store.js';
30
31
  // Built on call.
31
32
  const derivers = () => ({
32
33
  recovery: deriveRecovery,
33
- fit: (input) => [...deriveFit(input), ...deriveCallShape(input)],
34
+ fit: (input) => [...deriveFit(input), ...deriveFitShape(input), ...deriveCallShape(input)],
34
35
  cost: deriveCost,
35
36
  change: deriveChange,
36
37
  live: deriveLive,
@@ -100,6 +101,7 @@ export const refreshPool = async (storeDir, store) => {
100
101
  const pool = poolOf(set, store.errors);
101
102
  const files = poolFiles(storeDir);
102
103
  await writeAtomic(files.consequences, recalls.flatMap(({ id, n, consequence }) => (consequence === undefined ? [] : [`${JSON.stringify({ id, n, consequence })}\n`])).join(''));
104
+ await writeAtomic(join(storeDir, LOAD_STATUS_FILE), await loadStatusLines(storeDir, store.sessions));
103
105
  await writeAtomic(files.cells, serializeCells(set.cells));
104
106
  await writeAtomic(files.motifs, set.motifs.map((motif) => `${JSON.stringify(motif)}\n`).join(''));
105
107
  const file = files.reads(pool.version);
@@ -60,6 +60,9 @@ export declare const HARNESS_TOOLS: ReadonlyMap<string, ReadonlySet<string>>;
60
60
  * is pinned yet, so an admitted plugin agent is not on this list and pools as `custom` (spec Section 2).
61
61
  */
62
62
  export declare const HARNESS_AGENT_TYPES: ReadonlyMap<string, ReadonlySet<string>>;
63
+ export declare const CLOSED_ARG_VALUES: ReadonlyMap<string, ReadonlyMap<string, ReadonlySet<string>>>;
64
+ /** The keys of `CLOSED_ROWS` whose list is not exhaustive: a custom agent's name or a full model id is off it and has no `enum` cell, so the key's on-list values do not sum to the calls that send it. */
65
+ export declare const OPEN_LIST_KEYS: ReadonlySet<string>;
63
66
  /** What a subagent type pools as when it is not a built-in: a custom agent's name is private. */
64
67
  export declare const CUSTOM_AGENT = "custom";
65
68
  /** A subagent type as it pools: a built-in of a harness as itself, anything else as `custom`. */
@@ -70,3 +73,5 @@ export declare const projectedAgentType: (agentType: string) => string;
70
73
  * the name gate and counts the rest in `keysDropped`.
71
74
  */
72
75
  export declare const TOOL_INPUT_KEYS: ReadonlyMap<string, ReadonlySet<string>>;
76
+ /** Every key name a public tool's pinned list holds: a value feature or closed value keyed on one of these names nothing private, so the pool serves its cell at the clear floor. */
77
+ export declare const PUBLIC_INPUT_KEYS: ReadonlySet<string>;