@manyoars/oars 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +44 -29
  2. package/dist/cli.d.ts +4 -1
  3. package/dist/cli.js +102 -32
  4. package/dist/commands/login.d.ts +1 -1
  5. package/dist/commands/login.js +1 -1
  6. package/dist/gate/hook-main.js +13 -1
  7. package/dist/gate/hook-process.d.ts +11 -1
  8. package/dist/gate/hook-process.js +50 -2
  9. package/dist/gate/hook-record.d.ts +3 -0
  10. package/dist/gate/hook-record.js +8 -0
  11. package/dist/gate/hook.d.ts +2 -0
  12. package/dist/gate/hook.js +3 -0
  13. package/dist/gate/replay.js +2 -5
  14. package/dist/gate/report.js +1 -1
  15. package/dist/gate/transcripts.js +1 -1
  16. package/dist/hook-bin.js +27 -2
  17. package/dist/issuer.d.ts +7 -9
  18. package/dist/issuer.js +10 -32
  19. package/dist/main.js +3 -0
  20. package/dist/sessions/actions.js +78 -0
  21. package/dist/sessions/allow.d.ts +10 -1
  22. package/dist/sessions/allow.js +29 -9
  23. package/dist/sessions/baseline.d.ts +1 -1
  24. package/dist/sessions/baseline.js +6 -4
  25. package/dist/sessions/call-shape.d.ts +19 -0
  26. package/dist/sessions/call-shape.js +85 -0
  27. package/dist/sessions/cell-increments.d.ts +5 -1
  28. package/dist/sessions/cell-increments.js +84 -50
  29. package/dist/sessions/cells.d.ts +33 -3
  30. package/dist/sessions/cells.js +89 -9
  31. package/dist/sessions/codex.js +93 -27
  32. package/dist/sessions/command.d.ts +11 -1
  33. package/dist/sessions/command.js +64 -18
  34. package/dist/sessions/coverage.d.ts +19 -0
  35. package/dist/sessions/coverage.js +56 -0
  36. package/dist/sessions/cursor.js +2 -2
  37. package/dist/sessions/derive.js +72 -6
  38. package/dist/sessions/exclusion.d.ts +6 -0
  39. package/dist/sessions/exclusion.js +8 -0
  40. package/dist/sessions/extract.d.ts +4 -2
  41. package/dist/sessions/extract.js +58 -26
  42. package/dist/sessions/hook-calls.js +1 -1
  43. package/dist/sessions/hook-index.d.ts +14 -1
  44. package/dist/sessions/hook-index.js +10 -3
  45. package/dist/sessions/hooks.d.ts +5 -1
  46. package/dist/sessions/hooks.js +133 -22
  47. package/dist/sessions/identity.d.ts +8 -3
  48. package/dist/sessions/identity.js +45 -15
  49. package/dist/sessions/install.d.ts +18 -2
  50. package/dist/sessions/install.js +53 -8
  51. package/dist/sessions/mcp.d.ts +3 -2
  52. package/dist/sessions/mcp.js +8 -4
  53. package/dist/sessions/motif.d.ts +2 -1
  54. package/dist/sessions/motif.js +2 -2
  55. package/dist/sessions/opencode.d.ts +33 -0
  56. package/dist/sessions/opencode.js +319 -0
  57. package/dist/sessions/otel.d.ts +60 -0
  58. package/dist/sessions/otel.js +494 -0
  59. package/dist/sessions/package-books.d.ts +48 -0
  60. package/dist/sessions/package-books.js +120 -0
  61. package/dist/sessions/package-resolve.d.ts +2 -0
  62. package/dist/sessions/package-resolve.js +3 -1
  63. package/dist/sessions/pi.d.ts +36 -0
  64. package/dist/sessions/pi.js +353 -0
  65. package/dist/sessions/pool-books.d.ts +21 -0
  66. package/dist/sessions/pool-books.js +75 -0
  67. package/dist/sessions/pool-choices.d.ts +5 -0
  68. package/dist/sessions/pool-choices.js +11 -0
  69. package/dist/sessions/pool-command.d.ts +76 -5
  70. package/dist/sessions/pool-command.js +263 -136
  71. package/dist/sessions/pool-nightly-deps.d.ts +3 -0
  72. package/dist/sessions/pool-nightly-deps.js +11 -0
  73. package/dist/sessions/pool-nightly.d.ts +10 -0
  74. package/dist/sessions/pool-nightly.js +85 -0
  75. package/dist/sessions/pool-outbox.d.ts +15 -2
  76. package/dist/sessions/pool-outbox.js +79 -26
  77. package/dist/sessions/pool-print.d.ts +1 -1
  78. package/dist/sessions/pool-raw.d.ts +27 -0
  79. package/dist/sessions/pool-raw.js +68 -0
  80. package/dist/sessions/pool-replica.d.ts +35 -0
  81. package/dist/sessions/pool-replica.js +91 -0
  82. package/dist/sessions/pool-send-lock.d.ts +20 -0
  83. package/dist/sessions/pool-send-lock.js +87 -0
  84. package/dist/sessions/pool-sent.d.ts +4 -0
  85. package/dist/sessions/pool-sent.js +26 -0
  86. package/dist/sessions/pool-totals.d.ts +10 -0
  87. package/dist/sessions/pool-totals.js +21 -0
  88. package/dist/sessions/pool-wire.d.ts +240 -30
  89. package/dist/sessions/pool-wire.js +695 -215
  90. package/dist/sessions/public-vocabulary.d.ts +2 -3
  91. package/dist/sessions/public-vocabulary.js +2 -9
  92. package/dist/sessions/query.d.ts +16 -4
  93. package/dist/sessions/query.js +32 -10
  94. package/dist/sessions/read-cost.js +14 -3
  95. package/dist/sessions/read-recovery-edge.d.ts +2 -0
  96. package/dist/sessions/read-recovery-edge.js +26 -0
  97. package/dist/sessions/read.d.ts +8 -1
  98. package/dist/sessions/read.js +6 -3
  99. package/dist/sessions/recall-bundle.d.ts +12 -0
  100. package/dist/sessions/recall-bundle.js +33 -0
  101. package/dist/sessions/recall-frames.d.ts +18 -0
  102. package/dist/sessions/recall-frames.js +44 -0
  103. package/dist/sessions/recall-next.d.ts +145 -0
  104. package/dist/sessions/recall-next.js +241 -0
  105. package/dist/sessions/recall-object.d.ts +2 -0
  106. package/dist/sessions/recall-object.js +1 -0
  107. package/dist/sessions/recall-push.d.ts +2 -2
  108. package/dist/sessions/recall-push.js +3 -51
  109. package/dist/sessions/recall-reads.d.ts +7 -0
  110. package/dist/sessions/recall-reads.js +7 -0
  111. package/dist/sessions/recovery-calls.d.ts +21 -0
  112. package/dist/sessions/recovery-calls.js +25 -0
  113. package/dist/sessions/redact.d.ts +11 -0
  114. package/dist/sessions/redact.js +59 -0
  115. package/dist/sessions/schema.d.ts +59 -9
  116. package/dist/sessions/schema.js +22 -3
  117. package/dist/sessions/session-shape.d.ts +6 -3
  118. package/dist/sessions/session-shape.js +19 -6
  119. package/dist/sessions/sources.js +75 -1
  120. package/dist/sessions/store.d.ts +13 -1
  121. package/dist/sessions/store.js +36 -16
  122. package/dist/sessions/survival.d.ts +2 -0
  123. package/dist/sessions/survival.js +2 -2
  124. package/dist/sessions/transcript-tail.d.ts +14 -0
  125. package/dist/sessions/transcript-tail.js +64 -0
  126. package/dist/sessions/vscode-copilot.js +24 -7
  127. package/dist/telemetry/command.d.ts +8 -0
  128. package/dist/telemetry/command.js +85 -0
  129. package/dist/telemetry/consent.d.ts +17 -0
  130. package/dist/telemetry/consent.js +63 -0
  131. package/dist/telemetry/crash.d.ts +8 -0
  132. package/dist/telemetry/crash.js +52 -0
  133. package/dist/telemetry/emit.d.ts +31 -0
  134. package/dist/telemetry/emit.js +42 -0
  135. package/dist/telemetry/fields.d.ts +16 -0
  136. package/dist/telemetry/fields.js +26 -0
  137. package/dist/telemetry/health.d.ts +21 -0
  138. package/dist/telemetry/health.js +108 -0
  139. package/dist/telemetry/hook-emit.d.ts +4 -0
  140. package/dist/telemetry/hook-emit.js +18 -0
  141. package/dist/telemetry/measures.d.ts +32 -0
  142. package/dist/telemetry/measures.js +128 -0
  143. package/dist/telemetry/observe.d.ts +13 -0
  144. package/dist/telemetry/observe.js +51 -0
  145. package/dist/telemetry/read-events.d.ts +9 -0
  146. package/dist/telemetry/read-events.js +17 -0
  147. package/dist/telemetry/record.d.ts +17 -0
  148. package/dist/telemetry/record.js +26 -0
  149. package/dist/telemetry/report.d.ts +77 -0
  150. package/dist/telemetry/report.js +188 -0
  151. package/dist/telemetry/schema.d.ts +40 -0
  152. package/dist/telemetry/schema.js +187 -0
  153. package/dist/telemetry/send-main.d.ts +1 -0
  154. package/dist/telemetry/send-main.js +11 -0
  155. package/dist/telemetry/send.d.ts +36 -0
  156. package/dist/telemetry/send.js +172 -0
  157. package/dist/telemetry/spool.d.ts +17 -0
  158. package/dist/telemetry/spool.js +70 -0
  159. package/dist/telemetry/wire.d.ts +25 -0
  160. package/dist/telemetry/wire.js +76 -0
  161. package/package.json +2 -2
@@ -1,11 +1,19 @@
1
- // What crosses the wire between `oars pool` and the pool server (recall spec Sections 2.5, 4, and 5). A skeleton is one session in closed vocabulary: the records a cell is
1
+ // What crosses the wire between `oars pool` and the pool server (recall spec Sections 2.5, 4, and 5). A skeleton is one session as a closed list of fields, each value redacted first: the records a cell is
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, publicModel, publicVersion, PUBLIC_FLAGS, PUBLIC_HOSTS, PUBLIC_MCP_SERVERS, PUBLIC_PACKAGES, PUBLIC_PROGRAMS, PUBLIC_SKILLS, projectedAgentType, PUBLIC_SUBCOMMANDS, REPO_SIZES, VOCAB_VERSION } from './public-vocabulary.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';
7
+ import { recoveryCallsOf, RECOVERY_CALLS } from './recovery-calls.js';
6
8
  import { projectLanguage, projectLanguageShare } from './strata.js';
7
- import { LANGUAGE_SHARES } from './schema.js';
8
- export const SKELETON_FORMAT = 2;
9
+ import { ERROR_CODES, LANGUAGE_SHARES, PROMPT_INTERVENTIONS } from './schema.js';
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;
12
+ /**
13
+ * 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
+ * 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
+ */
16
+ export const FIELD_LIST_VERSION = 8;
9
17
  export const BUNDLE_FORMAT = 1;
10
18
  /** One constant: the pool serves only the vocabulary version the client holds. */
11
19
  export { VOCAB_VERSION };
@@ -27,6 +35,26 @@ export const bucketOf = (kind, value) => EDGES[kind].filter((edge) => value >= e
27
35
  export const projectExplore = (stored) => (stored !== undefined && Number.isSafeInteger(stored) && stored >= 0 ? bucketOf('calls', stored) : null);
28
36
  /** The lowest value of a bucket, which is what a derivation counts a bucketed value as: it never reads above what the sender held. */
29
37
  export const floorOf = (kind, bucket) => EDGES[kind][bucket - 1] ?? 0;
38
+ /** The closed list of how a step came about (`schema.ts` `StepEdge`). */
39
+ export const EDGE_KINDS = ['next', 'spawned', 'retried', 'after-error'];
40
+ /** How many people the operator's organization has (an operator's setting, read locally, never looked up). */
41
+ export const ORG_SIZES = ['solo', '2-10', '11-100', '101-1000', 'over-1000', 'unknown'];
42
+ /** Whether the repository's local git log shows one commit author or several (distinct authors, counted and then dropped). */
43
+ export const TEAMS = ['solo', 'team', 'unknown'];
44
+ /** Whether the repository is public or private (an operator's setting, read locally, never looked up). */
45
+ export const REPO_VISIBILITIES = ['public', 'private', 'unknown'];
46
+ export const NO_COVERAGE = { orgSize: 'unknown', team: 'unknown', repoVisibility: 'unknown' };
47
+ /** A repo key is the store-salted hash a session record holds of its repository's identity: sixteen lowercase hex characters. */
48
+ const REPO_KEY = /^[0-9a-f]{16}$/;
49
+ /** The UTC hour of day, 0 to 23, of a timestamp, whatever offset it was written in; null when there is none or it does not parse. */
50
+ export const hourOf = (ts) => {
51
+ if (ts === null || ts === undefined || ts === '')
52
+ return null;
53
+ const millis = Date.parse(ts);
54
+ return Number.isNaN(millis) ? null : new Date(millis).getUTCHours();
55
+ };
56
+ /** 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
+ const observedBucket = (value, observed, field) => (observed === undefined || observed.includes(field) ? bucketOf('tokens', value) : null);
30
58
  /** W's minimum for a timing print (deposit-weighting spec, layer 5): fewer timed calls than this say too little to tell sessions apart. */
31
59
  export const MIN_TIMED_CALLS = 50;
32
60
  /** A session has ended once no transcript of it has gained a line for this long (`session_idle_hours`, spec Section 7). */
@@ -60,65 +88,135 @@ export const sessionsOf = (store, roots) => {
60
88
  // A subagent whose main transcript the store does not hold is in no group: it cannot be folded, and counting it alone would raise `n`.
61
89
  return store.sessions.map((main) => ({ main, transcripts: [main, ...[...(subagents.get(main.session) ?? [])].sort((a, b) => (a.session < b.session ? -1 : 1))].map(recordsOf) }));
62
90
  };
91
+ /** The most characters a name may hold on the wire. A longer value is not a name: it counts as redacted. */
92
+ export const NAME_MAX = 128;
93
+ // A name is a word: letters, digits, and a few joiners, with an optional leading `@` for a scope. A mis-parsed command line, a path, an email, or a quoted string fails it. A `/` is not a joiner here: a name holding one is a path (`src/acme/plan.md`).
94
+ const WIRE_NAME = /^@?[A-Za-z0-9_][A-Za-z0-9_.+-]*(?::[A-Za-z0-9_.+-]+)*$/;
95
+ // A pull's package `name` is the one place a `/` belongs: a scope (`@scope/pkg`) or a Go module path (`golang.org/x/tools`).
96
+ const WIRE_PULL_NAME = /^@?[A-Za-z0-9_][A-Za-z0-9_.+-]*(?:[/:][A-Za-z0-9_.+-]+)*$/;
97
+ /** Whether a value has the shape of a name (and so can be told from a command line, a path, or an email) and is within `NAME_MAX`. */
98
+ export const isWireName = (name) => name.length <= NAME_MAX && WIRE_NAME.test(name);
99
+ /** The shape of a pull's package name: a name, or a scope or module path with `/` in it. */
100
+ export const isWirePullName = (name) => name.length <= NAME_MAX && WIRE_PULL_NAME.test(name);
101
+ /** No redaction and no terms: the name shape and the length limit still apply. A local read, which sends nothing, uses it. */
102
+ export const OPEN_GATE = { redact: (text) => text, terms: [] };
103
+ /** 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
+ const unchanged = (gate, name) => gate.redact(name) === name && name.split(/_+/).every((part) => gate.redact(part) === part);
105
+ /**
106
+ * 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
+ * `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
+ */
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
+ };
113
+ const FLAG_DASHES = /^-{1,2}/;
63
114
  const SHORT_FLAG = /^-[A-Za-z]$/;
64
- /** The flags the wire has a row for: the curated list and a single letter. */
65
- const isWireFlag = (flag) => PUBLIC_FLAGS.has(flag) || SHORT_FLAG.test(flag);
66
- const PRIVATE_COMMAND = { program: PRIVATE, sub: null, flags: [] };
115
+ // 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.
116
+ const LONG_FLAG = /^--[A-Za-z0-9][A-Za-z0-9_.+-]*$/;
117
+ /**
118
+ * The shape of a flag name: a long flag (two dashes, then a name with no `:`) or a short one (one dash, one letter), or a single-dash token longer than one letter only when the public flag list holds it (`-NoProfile`, `-Command`). A value joined to its flag
119
+ * fails it: `--token=abc`, `--password:hunter2`, and a short flag with its value attached (`-psecret123`, `-uuser:pass`), which no list holds.
120
+ */
121
+ const isWireFlag = (flag) => SHORT_FLAG.test(flag) || PUBLIC_FLAGS.has(flag) || (flag.length <= NAME_MAX && LONG_FLAG.test(flag));
122
+ /** A multi-letter single-dash flag no public list holds (`go test -race`, `-psecret123`): the client before t900 dropped it, so this one does too, and an old and a new client key one motif for one command. */
123
+ const isDroppedFlag = (flag) => flag.length > 2 && flag.startsWith('-') && !flag.startsWith('--') && !PUBLIC_FLAGS.has(flag);
124
+ /** A flag name as the wire holds it: the name after the one gate, or `private` when it counts as redacted. Only the name is ever read, never a value. */
125
+ const wireFlag = (gate, flag) => (isWireFlag(flag) && wireName(gate, flag.replace(FLAG_DASHES, '')) !== PRIVATE ? flag : PRIVATE);
126
+ /**
127
+ * 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
+ * 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
+ */
130
+ const wireKeys = (stored, native, gate) => {
131
+ const distinct = [...new Set(stored)];
132
+ const listed = native === undefined ? undefined : TOOL_INPUT_KEYS.get(native);
133
+ const kept = native === undefined || native === PRIVATE ? [] : distinct.filter((name) => (listed === undefined ? wireName(gate, name) !== PRIVATE : listed.has(name)));
134
+ return { keys: kept.sort(), dropped: distinct.length - kept.length };
135
+ };
136
+ /** A stored closed-list member, else dropped: a hand-edited record never puts a value off the list on the wire. */
137
+ const memberOf = (list, value) => list.find((entry) => entry === value);
138
+ /** The basename of a program as capture read it: a path never goes on the wire. */
139
+ const basenameOf = (program) => program.replace(/^.*[\\/]/, '');
67
140
  /**
68
- * A projected command as the server's parser reads it. The client's snapshot lists more programs than the curated list the server holds, and a name the server has no row for
69
- * does not go on the wire (Section 1.2): such a program is `private`, and a subcommand or flag outside the curated lists is dropped, as the projection drops an unlisted one.
141
+ * A projected command as the server's parser reads it: the program as its basename after the gate (the stored identity reads `<literal>` for a program off the curated list, so the name comes from the call's segments),
142
+ * and the subcommand and flag names the reader gives, each through the same gate and never a flag value. Which of them the reader holds for a program is capture's; the wire adds no list of its own.
70
143
  */
71
- const wireCommand = ({ program, subcommand, flags }) => {
72
- if (!PUBLIC_PROGRAMS.has(program))
73
- return PRIVATE_COMMAND;
74
- return { program, sub: subcommand !== null && PUBLIC_SUBCOMMANDS.get(program)?.has(subcommand) === true ? subcommand : null, flags: flags.filter(isWireFlag) };
75
- };
76
- /** The wire's one spelling of "not admitted": a name the server's list does not hold goes as `private`, whatever the local store spells it. */
77
- const listed = (allowed, name) => (allowed.has(name) ? name : PRIVATE);
78
- /** A pull in the wire's spelling: its package name is a list member or `private`. A version beside a masked name could identify it, so it goes as `<literal>` (null stays null), the rule capture applies. */
79
- const wirePull = (pull) => {
80
- const name = listed(PUBLIC_PACKAGES, pull.name);
81
- return { ...pull, name, version: name === PRIVATE && pull.version !== null ? LITERAL : pull.version };
82
- };
83
- /** A non-shell step's identity in the wire's spelling; a shell step's comes from `wireCommand`. */
84
- const wireIdentity = (identity, harness) => {
144
+ const wireCommand = ({ program, subcommand, flags }, raw, gate) => {
145
+ const named = program !== PRIVATE ? program : raw;
146
+ if (named === undefined)
147
+ return { program: PRIVATE, sub: null, flags: [] };
148
+ const sent = wireName(gate, basenameOf(named));
149
+ // A private program holds no subcommand or flag: the parser refuses one that does.
150
+ if (sent === PRIVATE)
151
+ return { program: PRIVATE, sub: null, flags: [] };
152
+ return { program: sent, sub: subcommand === null ? null : wireName(gate, subcommand), flags: flags.filter((flag) => !isDroppedFlag(flag)).map((flag) => wireFlag(gate, flag)) };
153
+ };
154
+ /** A pull in the wire's spelling: its package name after the gate. A version beside a name that is not on the public list could identify it, so it goes as `<literal>` (null stays null), the rule capture applies. */
155
+ const wirePull = (pull, gate) => {
156
+ const name = wireName(gate, pull.name, isWirePullName);
157
+ return { ...pull, name, version: !PUBLIC_PACKAGES.has(name) && pull.version !== null ? LITERAL : pull.version };
158
+ };
159
+ /** A non-shell step's identity in the wire's spelling; a shell step's comes from `wireCommand`. `raw` is the harness's own tool name, class A, which an MCP call's masked identity is read back from. */
160
+ const wireIdentity = (identity, harness, raw, gate) => {
85
161
  if (identity === null || 'program' in identity)
86
162
  return identity;
87
163
  if ('server' in identity) {
88
- const server = listed(PUBLIC_MCP_SERVERS, identity.server);
89
- // A tool beside a masked server has no list to be checked against, so it goes as the literal. Beside a listed one it goes as itself only where the server's list still holds it
90
- // and the harness's spelling delimits the server (the parser's rule); a tool an older list held, or one stored before that rule, goes as the literal rather than refusing the session.
91
- const named = server !== PRIVATE && ACTION_TABLES[harness].toolDelimited && isPublicMcpTool(server, identity.tool);
92
- return { server, tool: named ? identity.tool : LITERAL };
164
+ const table = ACTION_TABLES[harness];
165
+ const parsed = mcpOf(table, raw);
166
+ const server = wireName(gate, identity.server === LITERAL ? (parsed?.server ?? LITERAL) : identity.server);
167
+ // A tool beside a masked server has no server to belong to, and a harness that does not delimit the server cannot split the tool from it: both go as the literal.
168
+ const name = identity.tool === LITERAL ? parsed?.tool : identity.tool;
169
+ const tool = server === PRIVATE || !table.toolDelimited || name === undefined ? PRIVATE : wireName(gate, name);
170
+ return { server, tool: tool === PRIVATE ? LITERAL : tool };
93
171
  }
94
172
  if ('skill' in identity)
95
- return { skill: listed(PUBLIC_SKILLS, identity.skill) };
96
- return { host: identity.host === null ? null : listed(PUBLIC_HOSTS, identity.host) };
173
+ return { skill: wireName(gate, identity.skill) };
174
+ return { host: identity.host === null ? null : wireName(gate, identity.host) };
97
175
  };
98
176
  /**
99
- * A stored tool's steps as the wire holds them: each shell step's identity is replaced by the projected command, and every name the server has no row for (a native name, MCP server,
100
- * skill, host, or pull name) reads `private`. A `private` program holds no pull, so a stored step whose public program has since left the list carries none. The one place a deposit and a local read take it from.
177
+ * A stored tool's steps as the wire holds them: each shell step's identity is replaced by the projected command, and every long-tail name (a native name, MCP server, skill, host, program, or pull name) is
178
+ * sent as it is after the gate, or `private` when it counts as redacted. A `private` program holds no pull. The one place a deposit and a local read take it from.
101
179
  */
102
- export const projectedSteps = (tool, read, harness) => {
180
+ export const projectedSteps = (tool, read, harness, gate = OPEN_GATE) => {
103
181
  const commands = read(tool);
104
- return (tool.steps ?? []).map((step, at) => {
105
- const native = admittedNative(harness, step.native) ? step.native : PRIVATE;
182
+ const steps = tool.steps ?? [];
183
+ // `segments` pair with the steps when their counts agree (`projectLiteralStep`): segment `at` is step `at`, and its `run` starts with the program's basename.
184
+ const segments = tool.segments?.length === steps.length ? tool.segments : undefined;
185
+ return steps.map((step, at) => {
186
+ const native = admittedNative(harness, step.native) ? step.native : wireName(gate, step.native === LITERAL ? tool.tool : step.native);
106
187
  if (step.action !== 'shell.exec')
107
- return { ...step, native, identity: wireIdentity(step.identity, harness) };
108
- const command = wireCommand(commands[at] ?? { program: PRIVATE, subcommand: null, flags: [] });
188
+ return { ...step, native, identity: wireIdentity(step.identity, harness, tool.tool, gate) };
189
+ const command = wireCommand(commands[at] ?? { program: PRIVATE, subcommand: null, flags: [] }, segments?.[at]?.run.split(' ')[0], gate);
109
190
  const pulls = step.identity !== null && 'program' in step.identity ? step.identity.pulls : undefined;
110
- return { ...step, native, identity: pulls === undefined || command.program === PRIVATE ? command : { ...command, pulls: pulls.map(wirePull) } };
191
+ return { ...step, native, identity: pulls === undefined || command.program === PRIVATE ? command : { ...command, pulls: pulls.map((pull) => wirePull(pull, gate)) } };
111
192
  });
112
193
  };
194
+ /** A failed call's stored `errorCode` as the wire holds it: a member of the closed list, else `other`; null for a call that did not fail and for one captured without a code (the wire does not guess one). */
195
+ const wireErrorCode = ({ status, errorCode }) => (status !== 'error' || errorCode === undefined ? null : ERROR_CODES.has(errorCode) ? errorCode : 'other');
113
196
  /** A transcript's calls in the order they ran, which is the order a skeleton holds them in. */
114
197
  const byCall = (tools) => [...tools].sort((a, b) => a.n - b.n);
198
+ const placesOf = (tools) => {
199
+ const calls = new Map();
200
+ const steps = new Map();
201
+ for (const [position, { n, steps: held }] of byCall(tools).entries()) {
202
+ calls.set(n, position);
203
+ for (const step of held ?? [])
204
+ steps.set(`${String(n)}/${String(step.seg)}`, steps.size);
205
+ }
206
+ return { calls, steps };
207
+ };
115
208
  /** The step of a call that carries the runs it spawned: its first spawn step, else its first step. A call with no step carries none. */
116
209
  const carrier = (steps) => Math.max(0, steps.findIndex(({ action }) => action === 'agent.spawn'));
117
210
  /**
118
- * A stored frame with its stack's language shares: each stored share is read through its projection, so a share outside the list is dropped and its language stays in `languages`. A stored
211
+ * A model or harness version string as the wire holds it: the string after the gate (`wireName`), null staying null. The pool assigns its class. Capture's closed value (`<literal>`) goes as it is, the form a client before
212
+ * t900 sent, so an old and a new client key one closed frame to one pool cell; the parser accepts it.
213
+ */
214
+ const wireString = (gate, value) => (value === null || value === LITERAL ? value : wireName(gate, value));
215
+ /**
216
+ * A stored frame with the UTC hour of `ts` beside its day, and its stack's language shares: each stored share is read through its projection, so a share outside the list is dropped and its language stays in `languages`. A stored
119
217
  * language outside the pinned list is `other`, and `other` holds the first share a record gives it. A record captured before the strata holds none.
120
218
  */
121
- const wireFrame = (frame, strata) => {
219
+ const wireFrame = (frame, strata, gate, ts) => {
122
220
  const shares = new Map();
123
221
  for (const { language, share } of strata?.languages ?? []) {
124
222
  const held = projectLanguageShare(share);
@@ -126,15 +224,45 @@ const wireFrame = (frame, strata) => {
126
224
  if (held !== null && !shares.has(name))
127
225
  shares.set(name, held);
128
226
  }
129
- return { ...frame, stack: { ...frame.stack, languageShares: [...shares].map(([language, languageShare]) => ({ language, languageShare })) } };
227
+ const hour = hourOf(ts);
228
+ return { ...frame, harnessVersion: wireString(gate, frame.harnessVersion), model: wireString(gate, frame.model), stack: { ...frame.stack, languageShares: [...shares].map(([language, languageShare]) => ({ language, languageShare })) }, ...(hour === null ? {} : { hour }) };
229
+ };
230
+ /** Each of `names` after the gate (a name that counts as redacted reads `private`), sorted. */
231
+ const listedAll = (gate, names) => [...new Set(names)].map((name) => wireName(gate, name)).sort();
232
+ /**
233
+ * The reach a session's record shows (t867): the servers of the tools its `funnel` offered or loaded and of the MCP tools it called, the skills it called, and the plugins its `setup` names. A record
234
+ * with neither `setup` nor `funnel` is unfilled (a harness that records none, or a capture before version 12) and lists nothing, so the absence is not read as no tools.
235
+ */
236
+ const reachOf = (session, tools, harness, gate) => {
237
+ if (session.setup === undefined && session.funnel === undefined)
238
+ return { filled: false, mcp: [], skills: [], plugins: [] };
239
+ const table = ACTION_TABLES[harness];
240
+ const servers = new Set();
241
+ const skills = new Set();
242
+ for (const name of [...(session.funnel?.offered ?? []), ...(session.funnel?.loaded ?? [])]) {
243
+ const server = mcpOf(table, name)?.server;
244
+ if (server !== undefined)
245
+ servers.add(server);
246
+ }
247
+ for (const { steps, skill } of tools) {
248
+ if (skill !== undefined)
249
+ skills.add(skill);
250
+ for (const { identity } of steps ?? []) {
251
+ if (identity !== null && 'server' in identity)
252
+ servers.add(identity.server);
253
+ if (identity !== null && 'skill' in identity)
254
+ skills.add(identity.skill);
255
+ }
256
+ }
257
+ return { filled: true, mcp: listedAll(gate, servers), skills: listedAll(gate, skills), plugins: (session.setup?.plugins ?? []).map(() => PRIVATE) };
130
258
  };
131
- const transcriptOf = ({ session, prompts, tools, root }, spawners, spawned, agentType, read) => {
259
+ const transcriptOf = ({ session, prompts, tools, root }, spawner, spawned, agentType, read, gate, coverage) => {
132
260
  if (session.frame === undefined || root === null)
133
261
  return null;
134
262
  const harness = session.harness ?? 'claude-code';
135
263
  const framedPrompts = [];
136
264
  const promptAt = new Map();
137
- for (const { index, cls, errors, wallSec, usage, frame, strata } of [...prompts].sort((a, b) => a.index - b.index)) {
265
+ for (const { index, cls, errors, wallSec, usage, usageObserved, usageResponses, frame, strata, ts, intervention } of [...prompts].sort((a, b) => a.index - b.index)) {
138
266
  if (frame === undefined)
139
267
  return null;
140
268
  promptAt.set(index, framedPrompts.length);
@@ -142,41 +270,108 @@ const transcriptOf = ({ session, prompts, tools, root }, spawners, spawned, agen
142
270
  cls,
143
271
  errors: bucketOf('count', errors),
144
272
  wallSec: wallSec === null ? null : bucketOf('seconds', wallSec),
145
- usage: { input: bucketOf('tokens', usage.input), cacheWrite: bucketOf('tokens', usage.cacheWrite), cacheRead: bucketOf('tokens', usage.cacheRead), output: bucketOf('tokens', usage.output), thinking: bucketOf('tokens', usage.thinking) },
273
+ usage: {
274
+ input: observedBucket(usage.input, usageObserved, 'input'),
275
+ cacheWrite: observedBucket(usage.cacheWrite, usageObserved, 'cacheWrite'),
276
+ cacheRead: observedBucket(usage.cacheRead, usageObserved, 'cacheRead'),
277
+ output: observedBucket(usage.output, usageObserved, 'output'),
278
+ thinking: observedBucket(usage.thinking, usageObserved, 'thinking'),
279
+ },
146
280
  explore: projectExplore(strata?.explore),
147
- frame: wireFrame(frame, strata),
281
+ ...(usageResponses === undefined ? {} : { usageResponses: bucketOf('count', usageResponses) }),
282
+ ...(intervention === undefined ? {} : { intervention }),
283
+ frame: wireFrame(frame, strata, gate, ts),
148
284
  });
149
285
  }
286
+ // The frames the steps ran in: the transcript's own first, then one for each distinct (prompt frame, call model, call harness version) a call shows, so a model that changes in the middle of a prompt
287
+ // reaches the steps that ran on it. A call that recorded neither takes its prompt's frame, or the transcript's before the first prompt.
288
+ const reach = reachOf(session, tools, harness, gate);
289
+ // The repo key and the bands describe the session, not a prompt, so they sit where the reach does. A session in no repository sends no key, and so does one whose record holds anything but a key's shape: the emitter is the privacy control.
290
+ const where = { reach, ...(session.repo !== null && REPO_KEY.test(session.repo) ? { repoKey: session.repo } : {}), ...coverage };
291
+ const transcriptFrame = { ...wireFrame(session.frame, session.strata, gate, session.tsFirst), ...where };
292
+ const frames = [transcriptFrame];
293
+ const frameAtKey = new Map([[JSON.stringify(transcriptFrame), 0]]);
294
+ const frameOfCall = ({ model, harnessVersion }, promptPosition) => {
295
+ const base = promptPosition === null ? undefined : framedPrompts[promptPosition]?.frame;
296
+ const held = {
297
+ ...(base ?? transcriptFrame),
298
+ ...(model === undefined ? {} : { model: wireString(gate, model) }),
299
+ ...(harnessVersion === null ? {} : { harnessVersion: wireString(gate, harnessVersion) }),
300
+ ...where,
301
+ };
302
+ const key = JSON.stringify(held);
303
+ const found = frameAtKey.get(key);
304
+ if (found !== undefined)
305
+ return found;
306
+ frames.push(held);
307
+ frameAtKey.set(key, frames.length - 1);
308
+ return frames.length - 1;
309
+ };
150
310
  const steppedTools = [];
311
+ const own = placesOf(tools);
151
312
  // The exact tokens of every step, by call and step, which the `remainingTokens` buckets below are computed from: a bucket of a sum is not a sum of buckets.
152
313
  const exact = [];
153
314
  let unrecorded = 0;
154
- for (const tool of byCall(tools)) {
315
+ const ordered = byCall(tools);
316
+ for (const [at, tool] of ordered.entries()) {
155
317
  if (tool.steps === undefined)
156
318
  return null;
157
319
  const { side, prompt, status, durationMs } = tool;
320
+ const errorCode = wireErrorCode(tool);
321
+ const recoveryCalls = recoveryCallsOf(ordered, at);
158
322
  // A subagent's call names the prompt that spawned it, which its own transcript holds no record of: such a number takes the next position past the prompts, one per distinct number, so
159
323
  // the calls that shared a prompt still share one, and no position reads as a prompt.
160
324
  if (prompt !== null && !promptAt.has(prompt))
161
325
  promptAt.set(prompt, framedPrompts.length + unrecorded++);
162
326
  const promptPosition = prompt === null ? null : (promptAt.get(prompt) ?? null);
163
- const projected = projectedSteps(tool, read, harness);
164
- exact.push(projected.map(({ cost }) => cost.tokens ?? 0));
165
- const steps = projected.map(({ action, native, identity, status: reported, cost, parent }) => ({
166
- action,
167
- native,
168
- identity,
169
- status: reported,
170
- tokens: cost.tokens === null ? null : bucketOf('tokens', cost.tokens),
171
- ms: cost.durationMs === null ? null : bucketOf('ms', cost.durationMs),
172
- remainingTokens: 0,
173
- agentType,
174
- parent: parent === null || spawners === null ? null : (spawners.get(parent) ?? null),
175
- }));
176
- steppedTools.push({ side, prompt: promptPosition, status, durationMs, steps });
327
+ const projected = projectedSteps(tool, read, harness, gate);
328
+ exact.push(projected.map(({ cost }) => cost.tokens));
329
+ const frame = frameOfCall(tool, promptPosition);
330
+ const steps = projected.map(({ action, native, identity, status: reported, cost, parent, edges }) => {
331
+ // An edge names a step by its position in the transcript it sits in; a `spawned` edge sits in the spawner, and one with no spawner in the batch goes unsent, since it names nothing here.
332
+ const arriving = edges.flatMap(({ kind, n: from, seg: fromSeg }) => {
333
+ const target = (kind === 'spawned' ? spawner?.places : own)?.steps.get(`${String(from)}/${String(fromSeg)}`);
334
+ return target === undefined ? [] : [{ kind, target }];
335
+ });
336
+ return {
337
+ action,
338
+ native,
339
+ identity,
340
+ status: reported,
341
+ tokens: cost.tokens === null ? null : bucketOf('tokens', cost.tokens),
342
+ ms: cost.durationMs === null ? null : bucketOf('ms', cost.durationMs),
343
+ ...(cost.allocated === true ? { allocated: true } : {}),
344
+ remainingTokens: 0,
345
+ agentType,
346
+ parent: parent === null || spawner === null ? null : (spawner.places.calls.get(parent) ?? null),
347
+ frame,
348
+ ...(arriving.length === 0 ? {} : { edges: arriving }),
349
+ ...(tool.skill === undefined ? {} : { skill: wireName(gate, tool.skill) }),
350
+ };
351
+ });
352
+ const { keys, dropped } = wireKeys(tool.keys, steps[0]?.native, gate);
353
+ const targetClass = memberOf(TARGET_CLASSES, tool.targetClass);
354
+ const stored = tool.argClass;
355
+ const argClass = stored === undefined ? undefined : ARG_CLASSES.filter((kind) => stored.includes(kind));
356
+ steppedTools.push({
357
+ side,
358
+ prompt: promptPosition,
359
+ status,
360
+ durationMs,
361
+ errorCode,
362
+ ...(recoveryCalls === null ? {} : { recoveryCalls }),
363
+ ...(keys.length === 0 ? {} : { keys }),
364
+ ...(dropped === 0 ? {} : { keysDropped: bucketOf('count', dropped) }),
365
+ ...(targetClass === undefined ? {} : { targetClass }),
366
+ ...(argClass === undefined ? {} : { argClass: argClass.sort() }),
367
+ steps,
368
+ });
177
369
  }
178
370
  // A step's remaining tokens are those of the steps after it in its own run; a main-run step also counts the subagent runs its later steps spawned (a run belongs to the spawning call's carrier step).
371
+ // `later` sums the steps that reported tokens; `laterKnown` is how many did, so a run whose later steps all reported nothing stays null instead of reading as zero.
179
372
  let later = 0;
373
+ let laterSteps = 0;
374
+ let laterKnown = 0;
180
375
  for (let call = steppedTools.length - 1; call >= 0; call -= 1) {
181
376
  const steps = steppedTools[call]?.steps ?? [];
182
377
  const held = carrier(steps);
@@ -184,25 +379,41 @@ const transcriptOf = ({ session, prompts, tools, root }, spawners, spawned, agen
184
379
  const step = steps[at];
185
380
  if (step === undefined)
186
381
  continue;
187
- step.remainingTokens = bucketOf('tokens', later);
188
- later += (exact[call]?.[at] ?? 0) + (at === held ? (spawned.get(call) ?? 0) : 0);
382
+ step.remainingTokens = laterSteps > 0 && laterKnown === 0 ? null : bucketOf('tokens', later);
383
+ const own = exact[call]?.[at] ?? null;
384
+ const spawnedTokens = at === held ? (spawned.get(call) ?? 0) : 0;
385
+ later += (own ?? 0) + spawnedTokens;
386
+ laterSteps += 1;
387
+ if (own !== null || spawnedTokens > 0)
388
+ laterKnown += 1;
189
389
  }
190
390
  }
191
- return { merkleRoot: root.merkleRoot, leaves: root.leaves, harness, frame: wireFrame(session.frame, session.strata), prompts: framedPrompts, tools: steppedTools };
391
+ return { merkleRoot: root.merkleRoot, leaves: root.leaves, harness, frame: transcriptFrame, frames, spawnedIn: spawner?.index ?? null, prompts: framedPrompts, tools: steppedTools };
192
392
  };
193
393
  /**
194
394
  * 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
195
395
  * 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.
196
396
  */
197
- export const skeletonOf = (records, id, read, print, deltas = []) => {
397
+ export const skeletonOf = (records, id, read, print, deltas = [], gate = OPEN_GATE, coverage = NO_COVERAGE) => {
198
398
  const mainTools = byCall(records.transcripts[0]?.tools ?? []);
199
- // A subagent's step names the call that spawned it by that call's `n` in the main run, which the skeleton holds as the call's position.
200
- const spawners = new Map(mainTools.map(({ n }, position) => [n, position]));
201
- const spawnedBy = (entry) => entry.session.spawnedIn === records.main.session;
399
+ const places = records.transcripts.map(({ tools }) => placesOf(tools));
400
+ const spawners = places[0]?.calls ?? new Map();
401
+ // A subagent's step names the call that spawned it by that call's `n` in the transcript its session record names as `spawnedIn`, which the skeleton holds as a position in the batch: that transcript's, and the call's within it.
402
+ // A record that names none the batch holds, or itself, or lies on a cycle of spawners, sends no link; one whose spawner sits on a cycle it is not on keeps its link.
403
+ const spawnerOf = records.transcripts.map(({ session }, index) => {
404
+ const at = index === 0 || session.spawnedIn === null ? -1 : records.transcripts.findIndex((held) => held.session.session === session.spawnedIn);
405
+ return at === -1 ? null : at;
406
+ });
407
+ const cyclic = (index) => {
408
+ let at = spawnerOf[index] ?? null;
409
+ for (let hop = 0; hop < spawnerOf.length && at !== null && at !== index; hop += 1)
410
+ at = spawnerOf[at] ?? null;
411
+ return at === index;
412
+ };
202
413
  // The exact tokens of the runs each main-run call spawned. A run a subagent spawned names a call of that other transcript, so it joins no main-run step.
203
414
  const spawned = new Map();
204
- for (const entry of records.transcripts.slice(1)) {
205
- if (!spawnedBy(entry))
415
+ for (const [index, entry] of records.transcripts.entries()) {
416
+ if (spawnerOf[index] !== 0)
206
417
  continue;
207
418
  for (const tool of entry.tools) {
208
419
  for (const { cost, parent } of tool.steps ?? []) {
@@ -214,17 +425,23 @@ export const skeletonOf = (records, id, read, print, deltas = []) => {
214
425
  }
215
426
  const transcripts = [];
216
427
  for (const [index, entry] of records.transcripts.entries()) {
217
- // Only a call of the main run has a position here: a step of a subagent a subagent spawned names a call of that other transcript, so it sends no link rather than a false one.
218
- const transcript = transcriptOf(entry, index > 0 && spawnedBy(entry) ? spawners : null, index === 0 ? spawned : new Map(), index === 0 ? null : projectedAgentType(entry.session.agentType ?? CUSTOM_AGENT), read);
428
+ const at = spawnerOf[index] ?? null;
429
+ const spawnerPlaces = at === null || cyclic(index) ? undefined : places[at];
430
+ const transcript = transcriptOf(entry, at === null || spawnerPlaces === undefined ? null : { index: at, places: spawnerPlaces }, index === 0 ? spawned : new Map(), index === 0 ? null : projectedAgentType(entry.session.agentType ?? CUSTOM_AGENT), read, gate, coverage);
219
431
  if (transcript === null)
220
432
  return null;
221
433
  transcripts.push(transcript);
222
434
  }
223
435
  const durations = mainTools.flatMap(({ durationMs }) => (durationMs === null ? [] : [durationMs]));
224
- return { format: SKELETON_FORMAT, id, transcripts, timingPrint: durations.length < MIN_TIMED_CALLS ? null : print(durations), deltas: [...deltas] };
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] };
225
437
  };
226
- /** A harness version a bundle can be asked for: public, so the request carries nothing private. The admitted list replaces this shape rule when it ships. */
227
- export const isBundleVersion = (version) => version !== LITERAL && publicVersion(version) === version;
438
+ /**
439
+ * 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
440
+ * `<harness>/unknown/<model>`. It is the one non-numeric word a bundle can be asked for, so those harnesses are served the cells they have.
441
+ */
442
+ export const UNKNOWN_VERSION = 'unknown';
443
+ /** A harness version a bundle can be asked for: public, so the request carries nothing private, or `unknown` for a harness with none on record. The admitted list replaces this shape rule when it ships. */
444
+ export const isBundleVersion = (version) => version === UNKNOWN_VERSION || (version !== LITERAL && publicVersion(version) === version);
228
445
  /** Whether `name` is a harness the schema names. */
229
446
  export const isHarness = (name) => HARNESSES.some((harness) => harness === name);
230
447
  class Refused extends Error {
@@ -238,16 +455,65 @@ class Refused extends Error {
238
455
  }
239
456
  const isFields = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
240
457
  const under = (at, key) => (at === '' ? key : `${at}.${key}`);
241
- /** The object at `at`, holding every field of `known` and, of the rest, only `optional`. Nothing unnamed passes, which is what keeps a class A field out. */
242
- const fieldsOf = (value, at, known, optional = []) => {
458
+ /** The value a reader gives an enumerated field whose value it does not know (conclusion section 17, rule 3). */
459
+ export const OTHER = 'other';
460
+ /** The one way into the bag: an entry over `MAX_EXTENSION_BYTES` (as JSON, in UTF-8) is refused as too large at its path, whatever kind it is and whatever the deposit's own cap is. */
461
+ const bagged = (held, entry) => {
462
+ if (new TextEncoder().encode(JSON.stringify(entry.value)).length > MAX_EXTENSION_BYTES)
463
+ throw new Refused(entry.at, 'too large');
464
+ held.extensions.push(entry);
465
+ };
466
+ /**
467
+ * A value no vocabulary of this reader holds: counted, kept as sent in the bag, and read as `other`. The bag is the stored form of what the reader does not serve; the client's projection is the gate on what it
468
+ * sends (lead 194), so the pool keeps the value and serves nothing from it.
469
+ */
470
+ const unknownValue = (held, at, sent) => {
471
+ held.others += 1;
472
+ bagged(held, { at, kind: 'value', value: sent });
473
+ return OTHER;
474
+ };
475
+ /** 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
+ 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
+ /**
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
479
+ * served from what remains. A field the cells need to be sound (a harness, a status, a bucket of tokens) is never here.
480
+ */
481
+ export const OPTIONAL = {
482
+ batch: ['timingPrint', 'deltas'],
483
+ frame: ['harnessVersion', 'model', 'effort', 'day', 'shell', 'reach', 'repoKey', 'orgSize', 'team', 'repoVisibility', 'hour'],
484
+ stack: ['size', 'languageShares'],
485
+ prompt: ['wallSec', 'explore', 'usageResponses', 'intervention'],
486
+ tool: ['prompt', 'durationMs', 'errorCode', 'recoveryCalls', 'keys', 'keysDropped', 'targetClass', 'argClass'],
487
+ step: ['tokens', 'ms', 'allocated', 'parent', 'skill', 'edges', 'remainingTokens'],
488
+ transcript: ['spawnedIn'],
489
+ };
490
+ /** 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
+ export const MAX_EXTENSION_BYTES = 8 * 1024;
492
+ const LITERAL_SUFFIX = 'Literal';
493
+ const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
494
+ /**
495
+ * The object at `at`, holding every field of `known` and, of the rest, any of `optional`. A name that is neither is not refused: it goes to the bag as it came (rule 3), so a newer client loses nothing
496
+ * against this reader. The emitter is what keeps a class A field out of a batch, not this check. A shape error still refuses: a name that is no field name, a missing field, a literal that is not text.
497
+ */
498
+ const fieldsOf = (held, value, at, known, optional = []) => {
243
499
  if (!isFields(value))
244
500
  throw new Refused(at === '' ? 'skeleton' : at, 'not an object');
245
- for (const key of Object.keys(value))
246
- if (!known.includes(key) && !optional.includes(key))
247
- throw new Refused(under(at, key), 'not a field of a skeleton');
248
501
  for (const key of known)
249
502
  if (!Object.hasOwn(value, key))
250
503
  throw new Refused(under(at, key), 'missing');
504
+ for (const key of Object.keys(value)) {
505
+ if (known.includes(key) || optional.includes(key))
506
+ continue;
507
+ if (!FIELD_NAME.test(key))
508
+ throw new Refused(under(at, key), 'not a field name');
509
+ const entry = value[key];
510
+ const stem = key.endsWith(LITERAL_SUFFIX) ? key.slice(0, -LITERAL_SUFFIX.length) : undefined;
511
+ // A literal sits beside an `other` and nowhere else; beside a value this reader knows it is a field it does not know.
512
+ const literal = stem !== undefined && ENUMERATED_FIELDS.has(stem) && (known.includes(stem) || optional.includes(stem)) && value[stem] === OTHER;
513
+ if (literal)
514
+ textOf(entry, under(at, key));
515
+ bagged(held, { at: under(at, key), kind: literal ? 'literal' : 'field', value: entry });
516
+ }
251
517
  return value;
252
518
  };
253
519
  const listOf = (value, at, item) => {
@@ -267,48 +533,67 @@ const countOf = (value, at) => {
267
533
  throw new Refused(at, 'not a count');
268
534
  return value;
269
535
  };
270
- const orNull = (value, at, item) => (value === null ? null : item(value, at));
536
+ /** The allocation marker is `true` or absent: a depositor cannot say `false`, since an unmarked step is the measured one. */
537
+ const allocatedOf = (value, at) => {
538
+ if (value !== true)
539
+ throw new Refused(at, 'not true');
540
+ return true;
541
+ };
542
+ /** An absent field reads as null: an optional field is absent or null alike. */
543
+ const orNull = (value, at, item) => (value === null || value === undefined ? null : item(value, at));
544
+ /** An absent list reads as empty. */
545
+ const listOrEmpty = (value, at, item) => (value === undefined ? [] : listOf(value, at, item));
271
546
  const booleanOf = (value, at) => {
272
547
  if (typeof value !== 'boolean')
273
548
  throw new Refused(at, 'not a boolean');
274
549
  return value;
275
550
  };
276
- /** A value from a closed list; the list narrows the type, so no caller needs a cast. */
277
- const oneOf = (list, value, at) => {
278
- const found = list.find((entry) => entry === value);
279
- if (found === undefined)
280
- throw new Refused(at, 'outside the vocabulary');
281
- return found;
551
+ /** A value from a closed list, or `other` for a text the list does not hold; the list narrows the type, so no caller needs a cast. A value that is no text is a shape error. */
552
+ const oneOf = (held, list, value, at) => {
553
+ const name = textOf(value, at);
554
+ return list.find((entry) => entry === name) ?? (name === OTHER ? OTHER : unknownValue(held, at, name));
282
555
  };
283
- const inSet = (allowed, value, at) => {
556
+ const inSet = (held, allowed, value, at) => {
284
557
  const name = textOf(value, at);
285
- if (name !== PRIVATE && !allowed.has(name))
286
- throw new Refused(at, 'outside the vocabulary');
287
- return name;
558
+ return name === PRIVATE || name === OTHER || allowed.has(name) ? name : unknownValue(held, at, name);
288
559
  };
289
- const exactlyIn = (allowed) => (value, at) => {
290
- if (typeof value !== 'string' || !allowed.has(value))
291
- throw new Refused(at, 'outside the vocabulary');
292
- return value;
560
+ /** A long-tail name field: `private`, `other`, or a name (the pool assigns its class, `packages/pool/src/field-classes.ts`). A value that is no name is refused: a client that sends one has not run its gate. */
561
+ const nameIn = (value, at, hasShape = isWireName) => {
562
+ const name = textOf(value, at);
563
+ if (name === PRIVATE || name === OTHER || hasShape(name))
564
+ return name;
565
+ throw new Refused(at, 'not a name');
293
566
  };
294
- /** `publicModel` and its like hand back what is public and the literal for what is not, so only a value they return unchanged is in the vocabulary. */
295
- const heldTo = (isHeld, value, at) => {
296
- if (value === null)
567
+ /** A model or harness version: null, or a long-tail name (`private`, `other`, the older client's literal, or a name). A value that is no name is set aside, as a native name is. */
568
+ const nameOrNull = (held, value, at) => {
569
+ if (value === null || value === undefined)
297
570
  return null;
298
571
  const name = textOf(value, at);
299
- if (isHeld(name) !== name)
300
- throw new Refused(at, 'outside the vocabulary');
301
- return name;
572
+ return name === LITERAL ? name : nameValue(held, name, at);
573
+ };
574
+ /** A long-tail name in a field whose value the pool classes: `private`, `other`, or a name; a value that is no name is set aside. */
575
+ const nameValue = (held, name, at) => (name === PRIVATE || name === OTHER || isWireName(name) ? name : unknownValue(held, at, name));
576
+ const exactlyIn = (held, allowed) => (value, at) => {
577
+ const name = textOf(value, at);
578
+ return allowed.has(name) || name === OTHER ? name : unknownValue(held, at, name);
579
+ };
580
+ /** `publicEffort` and its like hand back what is public and the literal for what is not, so only a value they return unchanged is in the vocabulary. */
581
+ const heldTo = (held, isHeld, value, at) => {
582
+ if (value === null || value === undefined)
583
+ return null;
584
+ const name = textOf(value, at);
585
+ return name === OTHER || isHeld(name) === name ? name : unknownValue(held, at, name);
302
586
  };
303
587
  // `satisfies` checks every entry is a `Harness` but not that every `Harness` is listed; this refuses a list that leaves one out at compile time.
304
588
  const allHarnesses = (list) => list;
305
- export const HARNESSES = allHarnesses(['claude-code', 'cursor', 'codex', 'copilot', 'gemini', 'grok', 'vscode-copilot']);
589
+ export const HARNESSES = allHarnesses(['claude-code', 'cursor', 'codex', 'copilot', 'gemini', 'grok', 'opencode', 'pi', 'vscode-copilot', 'otel']);
306
590
  const PROMPT_CLASSES = ['no-tools', 'orchestration', 'research', 'edit-verify', 'edit-only', 'explore-only', 'shell-only', 'mixed'];
307
591
  const STATUSES = ['ok', 'error', 'none'];
308
592
  const PLATFORMS = new Set(['aix', 'android', 'cygwin', 'darwin', 'freebsd', 'haiku', 'linux', 'netbsd', 'openbsd', 'sunos', 'win32']);
309
593
  const SHELLS = new Set(Object.values(ACTION_TABLES).flatMap((table) => Object.values(table.shells)));
310
594
  /** The registries a package comes from; a pull may also come from a skill, plugin, or MCP registry. */
311
595
  export const PACKAGE_REGISTRIES = ['npm', 'pypi', 'crates', 'go', 'rubygems', 'homebrew'];
596
+ const NO_NAME = new Set();
312
597
  const REGISTRIES = [...PACKAGE_REGISTRIES, 'skill', 'plugin', 'mcp'];
313
598
  const DAY = /^\d{4}-\d{2}-\d{2}$/;
314
599
  const ID = /^[0-9a-f]{16}$/;
@@ -321,14 +606,40 @@ const isCalendarDay = (text) => {
321
606
  };
322
607
  /** The languages a share can name: the pinned list and `other`, which `projectLanguage` gives any language off the list. The frame's own `languages` stay closed to the list. */
323
608
  const SHARE_LANGUAGES = new Set([...LANGUAGES, 'other']);
324
- const parseLanguageShare = (value, at) => {
325
- const raw = fieldsOf(value, at, ['language', 'languageShare']);
326
- return { language: exactlyIn(SHARE_LANGUAGES)(raw.language, under(at, 'language')), languageShare: oneOf(LANGUAGE_SHARES, raw.languageShare, under(at, 'languageShare')) };
609
+ const parseLanguageShare = (held, value, at) => {
610
+ const raw = fieldsOf(held, value, at, ['language', 'languageShare']);
611
+ return { language: exactlyIn(held, SHARE_LANGUAGES)(raw.language, under(at, 'language')), languageShare: oneOf(held, LANGUAGE_SHARES, raw.languageShare, under(at, 'languageShare')) };
612
+ };
613
+ /** A reach, each list held to its own row; an unfilled reach lists nothing. A plugin has no pinned list, so only the word `private` stands for one. */
614
+ const parseReach = (held, value, at) => {
615
+ const raw = fieldsOf(held, value, at, ['filled', 'mcp', 'skills', 'plugins']);
616
+ const filled = booleanOf(raw.filled, under(at, 'filled'));
617
+ const reach = {
618
+ filled,
619
+ mcp: listOf(raw.mcp, under(at, 'mcp'), (entry, entryAt) => nameIn(entry, entryAt)),
620
+ skills: listOf(raw.skills, under(at, 'skills'), (entry, entryAt) => nameIn(entry, entryAt)),
621
+ plugins: listOf(raw.plugins, under(at, 'plugins'), (entry, entryAt) => inSet(held, NO_NAME, entry, entryAt)),
622
+ };
623
+ if (!filled && reach.mcp.length + reach.skills.length + reach.plugins.length > 0)
624
+ throw new Refused(at, 'an unfilled reach lists nothing');
625
+ return reach;
626
+ };
627
+ /** Sixteen lowercase hex characters and nothing else, so no path or remote text can ride in a repo key. */
628
+ const parseRepoKey = (value, at) => {
629
+ const text = textOf(value, at);
630
+ if (!REPO_KEY.test(text))
631
+ throw new Refused(at, 'outside the vocabulary');
632
+ return text;
327
633
  };
328
- const parseFrame = (value, at) => {
329
- const raw = fieldsOf(value, at, ['harness', 'harnessVersion', 'model', 'effort', 'day', 'platform', 'shell', 'stack']);
634
+ const parseHour = (value, at) => {
635
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 23)
636
+ throw new Refused(at, 'not an hour');
637
+ return value;
638
+ };
639
+ const parseFrame = (held, value, at) => {
640
+ const raw = fieldsOf(held, value, at, ['harness', 'platform', 'stack'], OPTIONAL.frame);
330
641
  const stackAt = under(at, 'stack');
331
- const stack = fieldsOf(raw.stack, stackAt, ['languages', 'frameworks', 'size', 'languageShares']);
642
+ const stack = fieldsOf(held, raw.stack, stackAt, ['languages', 'frameworks'], OPTIONAL.stack);
332
643
  const day = orNull(raw.day, under(at, 'day'), (entry, entryAt) => {
333
644
  const text = textOf(entry, entryAt);
334
645
  if (!isCalendarDay(text))
@@ -336,19 +647,25 @@ const parseFrame = (value, at) => {
336
647
  return text;
337
648
  });
338
649
  return {
339
- harness: oneOf(HARNESSES, raw.harness, under(at, 'harness')),
340
- harnessVersion: heldTo(publicVersion, raw.harnessVersion, under(at, 'harnessVersion')),
341
- model: heldTo(publicModel, raw.model, under(at, 'model')),
342
- effort: heldTo(publicEffort, raw.effort, under(at, 'effort')),
650
+ harness: oneOf(held, HARNESSES, raw.harness, under(at, 'harness')),
651
+ harnessVersion: nameOrNull(held, raw.harnessVersion, under(at, 'harnessVersion')),
652
+ model: nameOrNull(held, raw.model, under(at, 'model')),
653
+ effort: heldTo(held, publicEffort, raw.effort, under(at, 'effort')),
343
654
  day,
344
- platform: exactlyIn(PLATFORMS)(raw.platform, under(at, 'platform')),
345
- shell: orNull(raw.shell, under(at, 'shell'), exactlyIn(SHELLS)),
655
+ platform: exactlyIn(held, PLATFORMS)(raw.platform, under(at, 'platform')),
656
+ shell: orNull(raw.shell, under(at, 'shell'), exactlyIn(held, SHELLS)),
346
657
  stack: {
347
- languages: listOf(stack.languages, under(stackAt, 'languages'), exactlyIn(LANGUAGES)),
348
- frameworks: listOf(stack.frameworks, under(stackAt, 'frameworks'), exactlyIn(FRAMEWORKS)),
349
- size: orNull(stack.size, under(stackAt, 'size'), (entry, entryAt) => oneOf(REPO_SIZES, entry, entryAt)),
350
- languageShares: listOf(stack.languageShares, under(stackAt, 'languageShares'), parseLanguageShare),
658
+ languages: listOf(stack.languages, under(stackAt, 'languages'), exactlyIn(held, LANGUAGES)),
659
+ frameworks: listOf(stack.frameworks, under(stackAt, 'frameworks'), exactlyIn(held, FRAMEWORKS)),
660
+ size: orNull(stack.size, under(stackAt, 'size'), (entry, entryAt) => oneOf(held, REPO_SIZES, entry, entryAt)),
661
+ languageShares: listOrEmpty(stack.languageShares, under(stackAt, 'languageShares'), (entry, entryAt) => parseLanguageShare(held, entry, entryAt)),
351
662
  },
663
+ ...(raw.reach === undefined ? {} : { reach: parseReach(held, raw.reach, under(at, 'reach')) }),
664
+ ...(raw.repoKey === undefined ? {} : { repoKey: parseRepoKey(raw.repoKey, under(at, 'repoKey')) }),
665
+ ...(raw.orgSize === undefined ? {} : { orgSize: oneOf(held, ORG_SIZES, raw.orgSize, under(at, 'orgSize')) }),
666
+ ...(raw.team === undefined ? {} : { team: oneOf(held, TEAMS, raw.team, under(at, 'team')) }),
667
+ ...(raw.repoVisibility === undefined ? {} : { repoVisibility: oneOf(held, REPO_VISIBILITIES, raw.repoVisibility, under(at, 'repoVisibility')) }),
668
+ ...(raw.hour === undefined ? {} : { hour: parseHour(raw.hour, under(at, 'hour')) }),
352
669
  };
353
670
  };
354
671
  const bucketIn = (kind, value, at) => {
@@ -356,70 +673,81 @@ const bucketIn = (kind, value, at) => {
356
673
  throw new Refused(at, 'not a bucket');
357
674
  return value;
358
675
  };
359
- const parseUsage = (value, at) => {
360
- const raw = fieldsOf(value, at, ['input', 'cacheWrite', 'cacheRead', 'output', 'thinking']);
361
- return {
362
- input: bucketIn('tokens', raw.input, under(at, 'input')),
363
- cacheWrite: bucketIn('tokens', raw.cacheWrite, under(at, 'cacheWrite')),
364
- cacheRead: bucketIn('tokens', raw.cacheRead, under(at, 'cacheRead')),
365
- output: bucketIn('tokens', raw.output, under(at, 'output')),
366
- thinking: bucketIn('tokens', raw.thinking, under(at, 'thinking')),
367
- };
676
+ const parseUsage = (held, value, at) => {
677
+ const raw = fieldsOf(held, value, at, ['input', 'cacheWrite', 'cacheRead', 'output', 'thinking']);
678
+ const bucket = (field) => orNull(raw[field], under(at, field), (entry, entryAt) => bucketIn('tokens', entry, entryAt));
679
+ return { input: bucket('input'), cacheWrite: bucket('cacheWrite'), cacheRead: bucket('cacheRead'), output: bucket('output'), thinking: bucket('thinking') };
368
680
  };
369
- const parsePull = (value, at) => {
370
- const raw = fieldsOf(value, at, ['registry', 'name', 'version']);
371
- const name = inSet(PUBLIC_PACKAGES, raw.name, under(at, 'name'));
681
+ const parsePull = (held, value, at) => {
682
+ const raw = fieldsOf(held, value, at, ['registry', 'name', 'version']);
683
+ const name = nameIn(raw.name, under(at, 'name'), isWirePullName);
372
684
  const version = orNull(raw.version, under(at, 'version'), (entry, entryAt) => {
373
685
  const text = textOf(entry, entryAt);
374
- if (text !== LITERAL && !isRegistryVersion(text))
375
- throw new Refused(entryAt, 'outside the vocabulary');
376
686
  // A version beside a masked name could identify it: capture masks the two together.
377
- if (name === PRIVATE && text !== LITERAL)
378
- throw new Refused(entryAt, 'a private name holds no version');
379
- return text;
687
+ if (!PUBLIC_PACKAGES.has(name) && text !== LITERAL)
688
+ throw new Refused(entryAt, 'a name off the public list holds no version');
689
+ return text === LITERAL || text === OTHER || isRegistryVersion(text) ? text : unknownValue(held, entryAt, text);
380
690
  });
381
- return { registry: oneOf(REGISTRIES, raw.registry, under(at, 'registry')), name, version };
691
+ return { registry: oneOf(held, REGISTRIES, raw.registry, under(at, 'registry')), name, version };
382
692
  };
383
- const parseFlag = (value, at) => {
693
+ const parseFlag = (held, value, at) => {
384
694
  const flag = textOf(value, at);
385
- if (!isWireFlag(flag))
386
- throw new Refused(at, 'outside the vocabulary');
387
- return flag;
695
+ return flag === PRIVATE || flag === OTHER || isWireFlag(flag) ? flag : unknownValue(held, at, flag);
696
+ };
697
+ /** What a reader sets aside whole: a part of a step it cannot read against a vocabulary. Its place is noted and its content kept as sent, as for any value it does not know (see `unknownValue`). */
698
+ const setAside = (held, at, sent) => {
699
+ bagged(held, { at, kind: 'value', value: sent });
388
700
  };
389
- const parseIdentity = (harness, action, value, at) => {
701
+ const parseIdentity = (held, harness, action, value, at) => {
390
702
  if (value === null)
391
703
  return null;
704
+ // An action this reader does not know holds an identity it cannot read: the step goes without one and the place of what it held is noted.
705
+ if (action === OTHER) {
706
+ setAside(held, at, value);
707
+ return null;
708
+ }
392
709
  if (action === 'shell.exec') {
393
- const raw = fieldsOf(value, at, ['program', 'sub', 'flags'], ['pulls']);
394
- // A program is a list member or `private`, and a `private` program holds no subcommand, flag, or pull.
710
+ const raw = fieldsOf(held, value, at, ['program', 'sub', 'flags'], ['pulls']);
711
+ // A program is a name, `private`, or `other`; only `other` holds no subcommand, flag, or pull here.
395
712
  const program = textOf(raw.program, under(at, 'program'));
396
- if (program !== PRIVATE && !PUBLIC_PROGRAMS.has(program))
397
- throw new Refused(under(at, 'program'), 'outside the vocabulary');
713
+ nameIn(program, under(at, 'program'));
714
+ // The subcommands, flags, and pulls of a program this reader does not know cannot be read against its lists: it takes none, and notes what it left.
715
+ if (program === OTHER) {
716
+ for (const key of ['sub', 'flags', 'pulls'])
717
+ if (raw[key] !== undefined && !(raw[key] === null || (Array.isArray(raw[key]) && raw[key].length === 0)))
718
+ setAside(held, under(at, key), raw[key]);
719
+ return { program: OTHER, sub: null, flags: [] };
720
+ }
398
721
  if (program === PRIVATE) {
399
722
  if (!(Array.isArray(raw.flags) && raw.flags.length === 0))
400
723
  throw new Refused(under(at, 'flags'), 'a private program holds no flags');
401
724
  if (raw.pulls !== undefined)
402
725
  throw new Refused(under(at, 'pulls'), 'a private program holds no pulls');
403
726
  }
404
- const subs = PUBLIC_SUBCOMMANDS.get(program) ?? new Set();
405
- const sub = orNull(raw.sub, under(at, 'sub'), exactlyIn(subs));
406
- const flags = listOf(raw.flags, under(at, 'flags'), parseFlag);
407
- return raw.pulls === undefined ? { program, sub, flags } : { program, sub, flags, pulls: listOf(raw.pulls, under(at, 'pulls'), parsePull) };
727
+ // A private program holds no subcommand, so one it sends is set aside, as its flags are refused.
728
+ const sub = orNull(raw.sub, under(at, 'sub'), (entry, entryAt) => (program === PRIVATE ? unknownValue(held, entryAt, textOf(entry, entryAt)) : nameValue(held, textOf(entry, entryAt), entryAt)));
729
+ const flags = listOf(raw.flags, under(at, 'flags'), (entry, entryAt) => parseFlag(held, entry, entryAt));
730
+ return raw.pulls === undefined ? { program, sub, flags } : { program, sub, flags, pulls: listOf(raw.pulls, under(at, 'pulls'), (entry, entryAt) => parsePull(held, entry, entryAt)) };
408
731
  }
409
732
  if (action === 'mcp.call') {
410
- const raw = fieldsOf(value, at, ['server', 'tool']);
411
- const server = inSet(PUBLIC_MCP_SERVERS, raw.server, under(at, 'server'));
733
+ const raw = fieldsOf(held, value, at, ['server', 'tool']);
734
+ const server = nameIn(raw.server, under(at, 'server'));
412
735
  const tool = textOf(raw.tool, under(at, 'tool'));
413
736
  // The same rule capture applies (`callIdentity`), so a name it would have masked is refused here rather than stored.
414
- if (tool !== LITERAL && !(ACTION_TABLES[harness].toolDelimited && isPublicMcpTool(server, tool)))
415
- throw new Refused(under(at, 'tool'), 'outside the vocabulary');
416
- return { server, tool };
737
+ if (harness === OTHER) {
738
+ // The name crosses as the literal, and what the client sent is kept in the bag.
739
+ if (tool !== OTHER && tool !== LITERAL)
740
+ setAside(held, under(at, 'tool'), tool);
741
+ return { server, tool: tool === OTHER ? tool : LITERAL };
742
+ }
743
+ const admitted = tool === LITERAL || tool === OTHER || (server !== PRIVATE && ACTION_TABLES[harness].toolDelimited && (isPublicMcpTool(server, tool) || isWireName(tool)));
744
+ return { server, tool: admitted ? tool : unknownValue(held, under(at, 'tool'), tool) };
417
745
  }
418
746
  if (action === 'skill.run')
419
- return { skill: inSet(PUBLIC_SKILLS, fieldsOf(value, at, ['skill']).skill, under(at, 'skill')) };
747
+ return { skill: nameIn(fieldsOf(held, value, at, ['skill']).skill, under(at, 'skill')) };
420
748
  if (action === 'web.fetch' || action === 'web.search') {
421
- const { host } = fieldsOf(value, at, ['host']);
422
- return { host: orNull(host, under(at, 'host'), (entry, entryAt) => inSet(PUBLIC_HOSTS, entry, entryAt)) };
749
+ const { host } = fieldsOf(held, value, at, ['host']);
750
+ return { host: orNull(host, under(at, 'host'), (entry, entryAt) => nameIn(entry, entryAt)) };
423
751
  }
424
752
  throw new Refused(at, 'this action has no identity');
425
753
  };
@@ -427,59 +755,119 @@ const parseIdentity = (harness, action, value, at) => {
427
755
  const admittedNative = (harness, native) => {
428
756
  if (native === PRIVATE)
429
757
  return true;
758
+ if (harness === OTHER)
759
+ return false;
430
760
  const table = ACTION_TABLES[harness];
431
761
  if (Object.hasOwn(table.rows, native))
432
762
  return true;
433
763
  const mcp = mcpOf(table, native);
434
764
  return mcp !== undefined && table.toolDelimited && isPublicMcpTool(mcp.server, mcp.tool);
435
765
  };
436
- const parseNative = (harness, value, at) => {
766
+ const parseNative = (held, harness, value, at) => {
437
767
  const native = textOf(value, at);
438
- if (!admittedNative(harness, native))
439
- throw new Refused(at, 'outside the vocabulary');
440
- return native;
768
+ // The tools of a harness this reader does not know are off any table it holds, so they cross as `private`, the way capture masks a name its table lacks.
769
+ if (harness === OTHER)
770
+ return native === OTHER || isWireName(native) ? native : unknownValue(held, at, native);
771
+ return native === OTHER || admittedNative(harness, native) || isWireName(native) ? native : unknownValue(held, at, native);
441
772
  };
442
773
  /** A subagent type as the projection leaves it: a built-in of a harness, or `custom`; a custom agent's own name is private and never admitted. */
443
- const parseAgentType = (value, at) => {
774
+ const parseAgentType = (held, value, at) => {
444
775
  const name = textOf(value, at);
445
- if (projectedAgentType(name) !== name)
446
- throw new Refused(at, 'outside the vocabulary');
447
- return name;
776
+ return name === OTHER || projectedAgentType(name) === name ? name : unknownValue(held, at, name);
448
777
  };
449
- const parseStep = (harness, value, at) => {
450
- const raw = fieldsOf(value, at, ['action', 'native', 'identity', 'status', 'tokens', 'ms', 'remainingTokens', 'agentType', 'parent']);
451
- const action = oneOf(CANONICAL_ACTIONS, raw.action, under(at, 'action'));
778
+ const parseEdge = (held, value, at) => {
779
+ const raw = fieldsOf(held, value, at, ['kind', 'target']);
780
+ return { kind: oneOf(held, EDGE_KINDS, raw.kind, under(at, 'kind')), target: countOf(raw.target, under(at, 'target')) };
781
+ };
782
+ const parseStep = (held, harness, value, at) => {
783
+ const raw = fieldsOf(held, value, at, ['action', 'native', 'identity', 'status', 'agentType', 'frame'], OPTIONAL.step);
784
+ const action = oneOf(held, CANONICAL_ACTIONS, raw.action, under(at, 'action'));
452
785
  return {
453
786
  action,
454
- native: parseNative(harness, raw.native, under(at, 'native')),
455
- identity: parseIdentity(harness, action, raw.identity, under(at, 'identity')),
456
- status: oneOf(STATUSES, raw.status, under(at, 'status')),
787
+ native: parseNative(held, harness, raw.native, under(at, 'native')),
788
+ identity: parseIdentity(held, harness, action, raw.identity, under(at, 'identity')),
789
+ status: oneOf(held, STATUSES, raw.status, under(at, 'status')),
457
790
  tokens: orNull(raw.tokens, under(at, 'tokens'), (entry, entryAt) => bucketIn('tokens', entry, entryAt)),
458
791
  ms: orNull(raw.ms, under(at, 'ms'), (entry, entryAt) => bucketIn('ms', entry, entryAt)),
459
- remainingTokens: bucketIn('tokens', raw.remainingTokens, under(at, 'remainingTokens')),
460
- agentType: orNull(raw.agentType, under(at, 'agentType'), parseAgentType),
792
+ ...(raw.allocated === undefined ? {} : { allocated: allocatedOf(raw.allocated, under(at, 'allocated')) }),
793
+ remainingTokens: orNull(raw.remainingTokens, under(at, 'remainingTokens'), (entry, entryAt) => bucketIn('tokens', entry, entryAt)),
794
+ agentType: orNull(raw.agentType, under(at, 'agentType'), (entry, entryAt) => parseAgentType(held, entry, entryAt)),
461
795
  parent: orNull(raw.parent, under(at, 'parent'), countOf),
796
+ frame: countOf(raw.frame, under(at, 'frame')),
797
+ ...(raw.edges === undefined ? {} : { edges: listOf(raw.edges, under(at, 'edges'), (entry, entryAt) => parseEdge(held, entry, entryAt)) }),
798
+ ...(raw.skill === undefined ? {} : { skill: nameIn(raw.skill, under(at, 'skill')) }),
462
799
  };
463
800
  };
464
- const parseTool = (harness, value, at) => {
465
- const raw = fieldsOf(value, at, ['side', 'prompt', 'status', 'durationMs', 'steps']);
801
+ /** A call's `errorCode`: absent or null is none; otherwise a member of the closed list (a value off it reads as `other` and goes to the bag), and only on a call that did not succeed or sit out. */
802
+ const parseErrorCode = (held, status, value, at) => {
803
+ if (value === undefined || value === null)
804
+ return null;
805
+ const code = exactlyIn(held, ERROR_CODES)(value, at);
806
+ if (status === 'ok' || status === 'none')
807
+ throw new Refused(at, 'only a failed call has one');
808
+ return code;
809
+ };
810
+ /** A call's `recoveryCalls`: absent is none; otherwise at most 5 actions (a value off the list reads as `other`) and a flag, and only on a call that did not succeed or sit out; the transcript parse then refuses one on a call whose status is not `error`. */
811
+ const parseRecovery = (held, status, value, at) => {
812
+ if (value === undefined)
813
+ return undefined;
814
+ if (status === 'ok' || status === 'none')
815
+ throw new Refused(at, 'only a failed call has one');
816
+ const raw = fieldsOf(held, value, at, ['next', 'editFollowed']);
817
+ const next = listOf(raw.next, under(at, 'next'), (entry, entryAt) => oneOf(held, CANONICAL_ACTIONS, entry, entryAt));
818
+ if (next.length > RECOVERY_CALLS)
819
+ throw new Refused(under(at, 'next'), `more than ${String(RECOVERY_CALLS)} calls`);
820
+ return { next, editFollowed: booleanOf(raw.editFollowed, under(at, 'editFollowed')) };
821
+ };
822
+ /**
823
+ * A call's `keys`: absent is none. A tool with a pinned input list sends names on it, and a name off it reads as `other` and goes to the bag; any other tool's names must have the shape of a name. Key names
824
+ * only, so a value that is no name shape is not a key.
825
+ */
826
+ const parseKeys = (held, native, value, at) => {
827
+ if (value === undefined)
828
+ return undefined;
829
+ const listed = native === undefined ? undefined : TOOL_INPUT_KEYS.get(native);
830
+ return listOf(value, at, (entry, entryAt) => {
831
+ const name = textOf(entry, entryAt);
832
+ return (listed === undefined ? isWireName(name) : listed.has(name)) ? name : unknownValue(held, entryAt, name);
833
+ });
834
+ };
835
+ const parseTool = (held, harness, value, at) => {
836
+ const raw = fieldsOf(held, value, at, ['side', 'status', 'steps'], OPTIONAL.tool);
837
+ const status = oneOf(held, STATUSES, raw.status, under(at, 'status'));
838
+ const errorCode = parseErrorCode(held, status, raw.errorCode, under(at, 'errorCode'));
839
+ const recoveryCalls = parseRecovery(held, status, raw.recoveryCalls, under(at, 'recoveryCalls'));
840
+ const steps = listOf(raw.steps, under(at, 'steps'), (entry, entryAt) => parseStep(held, harness, entry, entryAt));
841
+ const keys = parseKeys(held, steps[0]?.native, raw.keys, under(at, 'keys'));
842
+ const keysDropped = raw.keysDropped === undefined ? undefined : bucketIn('count', raw.keysDropped, under(at, 'keysDropped'));
843
+ const targetClass = raw.targetClass === undefined ? undefined : oneOf(held, TARGET_CLASSES, raw.targetClass, under(at, 'targetClass'));
844
+ const argClass = raw.argClass === undefined ? undefined : listOf(raw.argClass, under(at, 'argClass'), (entry, entryAt) => oneOf(held, ARG_CLASSES, entry, entryAt));
466
845
  return {
467
846
  side: booleanOf(raw.side, under(at, 'side')),
468
847
  prompt: orNull(raw.prompt, under(at, 'prompt'), countOf),
469
- status: oneOf(STATUSES, raw.status, under(at, 'status')),
848
+ status,
470
849
  durationMs: orNull(raw.durationMs, under(at, 'durationMs'), countOf),
471
- steps: listOf(raw.steps, under(at, 'steps'), (entry, entryAt) => parseStep(harness, entry, entryAt)),
850
+ errorCode,
851
+ ...(recoveryCalls === undefined ? {} : { recoveryCalls }),
852
+ ...(keys === undefined ? {} : { keys }),
853
+ ...(keysDropped === undefined ? {} : { keysDropped }),
854
+ ...(targetClass === undefined ? {} : { targetClass }),
855
+ ...(argClass === undefined ? {} : { argClass }),
856
+ steps,
472
857
  };
473
858
  };
474
- const parsePrompt = (value, at) => {
475
- const raw = fieldsOf(value, at, ['cls', 'errors', 'wallSec', 'usage', 'explore', 'frame']);
859
+ const parsePrompt = (held, value, at) => {
860
+ const raw = fieldsOf(held, value, at, ['cls', 'errors', 'usage', 'frame'], OPTIONAL.prompt);
476
861
  return {
477
- cls: oneOf(PROMPT_CLASSES, raw.cls, under(at, 'cls')),
862
+ cls: oneOf(held, PROMPT_CLASSES, raw.cls, under(at, 'cls')),
478
863
  errors: bucketIn('count', raw.errors, under(at, 'errors')),
479
864
  wallSec: orNull(raw.wallSec, under(at, 'wallSec'), (entry, entryAt) => bucketIn('seconds', entry, entryAt)),
480
- usage: parseUsage(raw.usage, under(at, 'usage')),
865
+ usage: parseUsage(held, raw.usage, under(at, 'usage')),
481
866
  explore: orNull(raw.explore, under(at, 'explore'), (entry, entryAt) => bucketIn('calls', entry, entryAt)),
482
- frame: parseFrame(raw.frame, under(at, 'frame')),
867
+ ...(raw.usageResponses === undefined ? {} : { usageResponses: bucketIn('count', raw.usageResponses, under(at, 'usageResponses')) }),
868
+ // A sent null reads as an absent field, the way every optional field does.
869
+ ...(raw.intervention === undefined || raw.intervention === null ? {} : { intervention: oneOf(held, PROMPT_INTERVENTIONS, raw.intervention, under(at, 'intervention')) }),
870
+ frame: parseFrame(held, raw.frame, under(at, 'frame')),
483
871
  };
484
872
  };
485
873
  const ROOT = /^[0-9a-f]{64}$/;
@@ -490,17 +878,24 @@ const parseRoot = (value, at) => {
490
878
  throw new Refused(at, 'not a SHA-256');
491
879
  return root;
492
880
  };
493
- const parseTranscript = (value, at) => {
494
- const raw = fieldsOf(value, at, ['merkleRoot', 'leaves', 'harness', 'frame', 'prompts', 'tools']);
495
- const harness = oneOf(HARNESSES, raw.harness, under(at, 'harness'));
496
- return {
497
- merkleRoot: parseRoot(raw.merkleRoot, under(at, 'merkleRoot')),
498
- leaves: countOf(raw.leaves, under(at, 'leaves')),
499
- harness,
500
- frame: parseFrame(raw.frame, under(at, 'frame')),
501
- prompts: listOf(raw.prompts, under(at, 'prompts'), parsePrompt),
502
- tools: listOf(raw.tools, under(at, 'tools'), (entry, entryAt) => parseTool(harness, entry, entryAt)),
503
- };
881
+ const parseTranscript = (held, value, at) => {
882
+ const raw = fieldsOf(held, value, at, ['merkleRoot', 'leaves', 'harness', 'frame', 'frames', 'prompts', 'tools'], OPTIONAL.transcript);
883
+ const harness = oneOf(held, HARNESSES, raw.harness, under(at, 'harness'));
884
+ const merkleRoot = parseRoot(raw.merkleRoot, under(at, 'merkleRoot'));
885
+ const leaves = countOf(raw.leaves, under(at, 'leaves'));
886
+ // Read in the order the fields are met, so the bag holds what was set aside in that order.
887
+ const frame = parseFrame(held, raw.frame, under(at, 'frame'));
888
+ const frames = listOf(raw.frames, under(at, 'frames'), (entry, entryAt) => parseFrame(held, entry, entryAt));
889
+ // Absent reads as null: a client that names no spawner sends none.
890
+ const spawnedIn = orNull(raw.spawnedIn, under(at, 'spawnedIn'), countOf);
891
+ const prompts = listOf(raw.prompts, under(at, 'prompts'), (entry, entryAt) => parsePrompt(held, entry, entryAt));
892
+ 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.
894
+ tools.forEach((tool, index) => {
895
+ if (tool.recoveryCalls !== undefined && JSON.stringify(tool.recoveryCalls) !== JSON.stringify(recoveryCallsOf(tools, index)))
896
+ throw new Refused(`${at}.tools[${String(index)}].recoveryCalls`, 'not what followed the call');
897
+ });
898
+ return { merkleRoot, leaves, harness, frame, frames, spawnedIn, prompts, tools };
504
899
  };
505
900
  const DELTA_FIELDS = ['plugin', 'package', 'permissionMode', 'effort'];
506
901
  /** The kinds each field can take: a plugin comes or goes, a package only goes (an addition is adoption, which `pool` reads from the calls), and a mode or an effort changes. */
@@ -508,7 +903,8 @@ const KINDS_OF = { plugin: ['added', 'removed'], package: ['removed'], permissio
508
903
  /** The one metric each field's change is measured in, and the bucket edges that metric is held to. */
509
904
  const METRIC_OF = { plugin: 'callsPerSession', package: 'callsPerSession', permissionMode: 'callsPerSession', effort: 'thinking' };
510
905
  const METRIC_EDGES = { callsPerSession: 'count', thinking: 'tokens' };
511
- const NO_NAME = new Set();
906
+ const ALL_KINDS = ['added', 'removed', 'changed'];
907
+ const ALL_METRICS = ['callsPerSession', 'thinking'];
512
908
  const nullOnly = (value, at) => {
513
909
  if (value !== null)
514
910
  throw new Refused(at, 'outside the vocabulary');
@@ -518,32 +914,47 @@ const nullOnly = (value, at) => {
518
914
  * The entry of a delta, held to its field's own row. A plugin has no registry to pass, so only the word `private` stands for one; a package is a public name with its registry, or `private` with none; a mode
519
915
  * and an effort are named by their field and carry no name. The change is a signed bucket of the field's metric.
520
916
  */
521
- const parseDelta = (value, at) => {
522
- const raw = fieldsOf(value, at, ['deltaKind', 'field', 'name', 'registry', 'metric', 'change']);
523
- const field = oneOf(DELTA_FIELDS, raw.field, under(at, 'field'));
524
- const deltaKind = oneOf(KINDS_OF[field], raw.deltaKind, under(at, 'deltaKind'));
525
- const metric = oneOf([METRIC_OF[field]], raw.metric, under(at, 'metric'));
526
- const name = field === 'plugin' || field === 'package' ? inSet(field === 'package' ? PUBLIC_PACKAGES : NO_NAME, raw.name, under(at, 'name')) : nullOnly(raw.name, under(at, 'name'));
527
- const registry = field === 'package' && name !== PRIVATE ? oneOf(PACKAGE_REGISTRIES, raw.registry, under(at, 'registry')) : nullOnly(raw.registry, under(at, 'registry'));
917
+ const parseDelta = (held, value, at) => {
918
+ const raw = fieldsOf(held, value, at, ['deltaKind', 'field', 'name', 'registry', 'metric', 'change']);
919
+ const field = oneOf(held, DELTA_FIELDS, raw.field, under(at, 'field'));
920
+ // A field this reader does not know has no row to hold its kind, metric, name, or registry to: the first two read against every kind and metric, and the last two are kept as sent.
921
+ const kinds = field === OTHER ? ALL_KINDS : KINDS_OF[field];
922
+ const deltaKind = oneOf(held, kinds, raw.deltaKind, under(at, 'deltaKind'));
923
+ const metric = oneOf(held, field === OTHER ? ALL_METRICS : [METRIC_OF[field]], raw.metric, under(at, 'metric'));
924
+ const named = field === 'plugin' || field === 'package';
925
+ if (field === OTHER) {
926
+ for (const key of ['name', 'registry'])
927
+ if (raw[key] !== null)
928
+ setAside(held, under(at, key), raw[key]);
929
+ }
930
+ const name = named ? inSet(held, field === 'package' ? PUBLIC_PACKAGES : NO_NAME, raw.name, under(at, 'name')) : field === OTHER ? null : nullOnly(raw.name, under(at, 'name'));
931
+ const registry = field === 'package' && name !== PRIVATE ? oneOf(held, PACKAGE_REGISTRIES, raw.registry, under(at, 'registry')) : field === OTHER ? null : nullOnly(raw.registry, under(at, 'registry'));
528
932
  const change = raw.change;
529
- if (typeof change !== 'number' || !Number.isInteger(change) || Math.abs(change) > EDGES[METRIC_EDGES[metric]].length)
933
+ const limit = metric === OTHER ? Math.max(...ALL_METRICS.map((kind) => EDGES[METRIC_EDGES[kind]].length)) : EDGES[METRIC_EDGES[metric]].length;
934
+ if (typeof change !== 'number' || !Number.isInteger(change) || Math.abs(change) > limit)
530
935
  throw new Refused(under(at, 'change'), 'not a signed bucket');
531
936
  return { deltaKind, field, name, registry, metric, change };
532
937
  };
533
- /** The skeleton in `value`, rebuilt from the fields read; else the first field that is not closed vocabulary, and why. */
938
+ const VOCAB_TEXT = /^[0-9A-Za-z._-]{1,32}$/;
939
+ /** 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. */
534
940
  export const parseSkeleton = (value) => {
941
+ const held = { extensions: [], others: 0 };
535
942
  try {
536
- // The first form sent exact usage; it is named here, since its fields would otherwise read as a list of strangers.
537
- if (isFields(value) && value.format === 1)
538
- throw new Refused('format', 'the exact-count form (format 1) is no longer read; send format 2');
539
- const raw = fieldsOf(value, '', ['format', 'id', 'transcripts', 'timingPrint', 'deltas']);
943
+ // 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)
945
+ 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);
540
947
  if (raw.format !== SKELETON_FORMAT)
541
948
  throw new Refused('format', 'not a format this version reads');
949
+ const vocabVersion = textOf(raw.vocabVersion, 'vocabVersion');
950
+ if (!VOCAB_TEXT.test(vocabVersion))
951
+ throw new Refused('vocabVersion', 'not a version');
952
+ const fieldsVersion = countOf(raw.fieldsVersion, 'fieldsVersion');
542
953
  const id = textOf(raw.id, 'id');
543
954
  if (!ID.test(id))
544
955
  throw new Refused('id', 'not a hash');
545
- const deltas = listOf(raw.deltas, 'deltas', parseDelta);
546
- const transcripts = listOf(raw.transcripts, 'transcripts', parseTranscript);
956
+ const deltas = listOrEmpty(raw.deltas, 'deltas', (entry, entryAt) => parseDelta(held, entry, entryAt));
957
+ const transcripts = listOf(raw.transcripts, 'transcripts', (entry, entryAt) => parseTranscript(held, entry, entryAt));
547
958
  if (transcripts.length === 0)
548
959
  throw new Refused('transcripts', 'empty');
549
960
  // A counting batch is what lifts the fetch cap, so a deposit has to hold something to derive from.
@@ -569,20 +980,62 @@ export const parseSkeleton = (value) => {
569
980
  });
570
981
  });
571
982
  // Positions are the only ordinals on the wire, so each must name something the batch holds.
572
- const mainCalls = transcripts[0]?.tools.length ?? 0;
573
- transcripts.forEach(({ prompts, tools }, index) => {
983
+ // A spawner is another transcript of the batch whose own spawners lead to a run with none, so the batch is a forest and the main run is its first root.
984
+ transcripts.forEach(({ spawnedIn }, index) => {
985
+ if (spawnedIn === null)
986
+ return;
987
+ const spawnedAt = `transcripts[${String(index)}].spawnedIn`;
988
+ if (index === 0)
989
+ throw new Refused(spawnedAt, 'the main run has no spawner');
990
+ if (spawnedIn >= transcripts.length || spawnedIn === index)
991
+ throw new Refused(spawnedAt, 'no such transcript');
992
+ let above = spawnedIn;
993
+ for (let hop = 0; hop < transcripts.length && above !== null; hop += 1)
994
+ above = transcripts[above]?.spawnedIn ?? null;
995
+ if (above !== null)
996
+ throw new Refused(spawnedAt, 'a spawner cycle');
997
+ });
998
+ const stepsOf = transcripts.map(({ tools }) => tools.reduce((all, { steps }) => all + steps.length, 0));
999
+ transcripts.forEach(({ prompts, tools, frames, spawnedIn }, index) => {
1000
+ if (frames.length === 0)
1001
+ throw new Refused(`transcripts[${String(index)}].frames`, 'empty');
1002
+ // The call and prompt of every step in the transcript's order, which an edge's target is a position in.
1003
+ const placed = tools.flatMap(({ prompt, steps }) => steps.map(() => ({ prompt })));
1004
+ let before = 0;
574
1005
  tools.forEach(({ prompt, steps }, call) => {
575
1006
  const callAt = `transcripts[${String(index)}].tools[${String(call)}]`;
576
1007
  // Past the prompts it names a prompt with no record, numbered in order of appearance: no more of those than calls.
577
1008
  if (prompt !== null && prompt >= prompts.length + tools.length)
578
1009
  throw new Refused(`${callAt}.prompt`, 'no such prompt position');
579
- steps.forEach(({ parent }, position) => {
580
- if (parent !== null && parent >= mainCalls)
581
- throw new Refused(`${callAt}.steps[${String(position)}].parent`, 'no such call in the main run');
1010
+ steps.forEach(({ parent, frame, edges }, position) => {
1011
+ const stepAt = `${callAt}.steps[${String(position)}]`;
1012
+ if (parent !== null && (spawnedIn === null || parent >= (transcripts[spawnedIn]?.tools.length ?? 0)))
1013
+ throw new Refused(`${stepAt}.parent`, 'no such call in the spawning transcript');
1014
+ if (frame >= frames.length)
1015
+ throw new Refused(`${stepAt}.frame`, 'no such frame position');
1016
+ const here = before + position;
1017
+ (edges ?? []).forEach(({ kind, target }, edge) => {
1018
+ const edgeAt = `${stepAt}.edges[${String(edge)}]`;
1019
+ if (kind === OTHER)
1020
+ return;
1021
+ if (kind === 'spawned') {
1022
+ if (spawnedIn === null)
1023
+ throw new Refused(`${edgeAt}.kind`, 'a spawned edge needs a spawning transcript');
1024
+ if (target >= (stepsOf[spawnedIn] ?? 0))
1025
+ throw new Refused(`${edgeAt}.target`, 'no such step in the spawning transcript');
1026
+ return;
1027
+ }
1028
+ // The other kinds name an earlier step of the same transcript; a next stays inside one prompt.
1029
+ if (target >= here)
1030
+ throw new Refused(`${edgeAt}.target`, 'not an earlier step of this transcript');
1031
+ if (kind === 'next' && placed[target]?.prompt !== prompt)
1032
+ throw new Refused(`${edgeAt}.target`, 'a next edge crosses a prompt');
1033
+ });
582
1034
  });
1035
+ before += steps.length;
583
1036
  });
584
1037
  });
585
- return { ok: true, skeleton: { format: SKELETON_FORMAT, id, transcripts, timingPrint, deltas } };
1038
+ return { ok: true, skeleton: { format: SKELETON_FORMAT, vocabVersion, fieldsVersion, id, transcripts, timingPrint, deltas }, extensions: held.extensions, others: held.others };
586
1039
  }
587
1040
  catch (error) {
588
1041
  if (error instanceof Refused)
@@ -591,7 +1044,34 @@ export const parseSkeleton = (value) => {
591
1044
  }
592
1045
  };
593
1046
  const isSignedBundle = (value) => isFields(value) && typeof value.payload === 'string' && typeof value.signature === 'string';
594
- const isBundle = (value) => isFields(value) && value.format === BUNDLE_FORMAT && typeof value.harness === 'string' && isHarness(value.harness) && typeof value.harnessVersion === 'string' && typeof value.vocabVersion === 'string' && isFields(value.asOf) && Array.isArray(value.cells) && Array.isArray(value.motifs) && Array.isArray(value.reads) && Array.isArray(value.strata);
1047
+ const isFloor = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 1;
1048
+ /** 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)));
1050
+ const isSourced = (value) => isFields(value) && ((value.state === 'value' && typeof value.value === 'number') || (value.state === 'no source yet' && typeof value.reason === 'string'));
1051
+ const isCount = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
1052
+ /** 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. */
1053
+ const isBooks = (value) => isFields(value) &&
1054
+ isFloor(value.floor) &&
1055
+ value.format === BUNDLE_FORMAT &&
1056
+ typeof value.vocabVersion === 'string' &&
1057
+ isFields(value.asOf) &&
1058
+ (value.asOf.day === null || typeof value.asOf.day === 'string') &&
1059
+ isFields(value.served) &&
1060
+ isCount(value.served.total) &&
1061
+ Array.isArray(value.served.byWeek) &&
1062
+ Array.isArray(value.served.byTuple) &&
1063
+ isCount(value.backlog) &&
1064
+ isSourced(value.panel) &&
1065
+ (value.panelDay === null || typeof value.panelDay === 'string') &&
1066
+ isFields(value.demand) &&
1067
+ isSourced(value.demand.comparisonReadOwners) &&
1068
+ isSourced(value.demand.followOnReads) &&
1069
+ isFields(value.goal) &&
1070
+ isSourced(value.goal.readYield) &&
1071
+ isSourced(value.goal.costPerServedCell) &&
1072
+ isSourced(value.goal.yieldPerServedCell);
1073
+ // A bundle states the floor it applied (`floor`), and a client cannot tell which floor a bundle without one applied, so a format 1 bundle with no `floor` is refused as not a bundle this version reads.
1074
+ const isBundle = (value) => isFields(value) && value.format === BUNDLE_FORMAT && typeof value.harness === 'string' && isHarness(value.harness) && typeof value.harnessVersion === 'string' && typeof value.vocabVersion === 'string' && typeof value.floor === 'number' && isFields(value.asOf) && Array.isArray(value.cells) && Array.isArray(value.motifs) && Array.isArray(value.reads) && Array.isArray(value.strata) && (value.floors === undefined || isFloors(value.floors)) && (value.books === undefined || isBooks(value.books));
595
1075
  const signatureHolds = async ({ payload, signature }, key) => {
596
1076
  try {
597
1077
  const verifier = await crypto.subtle.importKey('jwk', { kty: key.kty, crv: key.crv, x: key.x }, 'Ed25519', false, ['verify']);